aboutsummaryrefslogtreecommitdiffstats
path: root/idatui
diff options
context:
space:
mode:
Diffstat (limited to 'idatui')
-rw-r--r--idatui/__init__.py9
-rw-r--r--idatui/__main__.py6
-rw-r--r--idatui/app.py6028
-rw-r--r--idatui/client.py678
-rw-r--r--idatui/domain.py1060
-rw-r--r--idatui/drive.py92
-rw-r--r--idatui/errors.py76
-rw-r--r--idatui/formats.py143
-rw-r--r--idatui/graph.py715
-rw-r--r--idatui/highlight.py31
-rw-r--r--idatui/index.py206
-rw-r--r--idatui/kittygfx.py271
-rw-r--r--idatui/launch.py181
-rw-r--r--idatui/pane.py570
-rw-r--r--idatui/pool.py235
-rw-r--r--idatui/project.py353
-rw-r--r--idatui/rpc.py589
-rw-r--r--idatui/rpcclient.py10
-rw-r--r--idatui/trace.py495
-rw-r--r--idatui/tui.py45
-rw-r--r--idatui/worker.py233
-rw-r--r--idatui/worker_client.py234
22 files changed, 10927 insertions, 1333 deletions
diff --git a/idatui/__init__.py b/idatui/__init__.py
index a52ba8b..2cbdde8 100644
--- a/idatui/__init__.py
+++ b/idatui/__init__.py
@@ -1,7 +1,7 @@
-"""idatui — a minimal keyboard-first TUI for IDA Pro over ida-pro-mcp (idalib)."""
+"""idatui — a minimal keyboard-first TUI for IDA Pro, driving idalib via a
+private unix-socket worker (idatui.worker / WorkerClient)."""
-from .client import (
- IDAClient,
+from .errors import (
IDAError,
IDAConnectionError,
IDATimeoutError,
@@ -10,7 +10,6 @@ from .client import (
IDAToolError,
IDASessionError,
Session,
- KeepAlive,
)
from .domain import (
Program,
@@ -36,7 +35,6 @@ __all__ = [
"Decompilation",
"LIST_PAGE",
"DISASM_BLOCK",
- "IDAClient",
"IDAError",
"IDAConnectionError",
"IDATimeoutError",
@@ -45,5 +43,4 @@ __all__ = [
"IDAToolError",
"IDASessionError",
"Session",
- "KeepAlive",
]
diff --git a/idatui/__main__.py b/idatui/__main__.py
index 29f5cf4..0090ace 100644
--- a/idatui/__main__.py
+++ b/idatui/__main__.py
@@ -1,6 +1,6 @@
-"""``python -m idatui`` -> client self-check (until the TUI app lands)."""
+"""``python -m idatui`` -> the one-shot launcher (open a binary in the TUI)."""
import sys
-from .client import _main
+from .launch import main
-raise SystemExit(_main(sys.argv[1:]))
+raise SystemExit(main(sys.argv[1:]))
diff --git a/idatui/app.py b/idatui/app.py
index 1db32ff..fc19fa5 100644
--- a/idatui/app.py
+++ b/idatui/app.py
@@ -20,46 +20,108 @@ import asyncio
import os
import re
import subprocess
-from dataclasses import dataclass
+import time
+from dataclasses import dataclass, field
+from rich.align import Align
from rich.segment import Segment
from rich.style import Style
from rich.text import Text
from textual import work
from textual.app import App, ComposeResult
from textual.binding import Binding
-from textual.containers import Horizontal, Vertical
+from textual.command import DiscoveryHit, Hit, Provider
+from textual.containers import Grid, Horizontal, Vertical, VerticalScroll
from textual.geometry import Region, Size
from textual.message import Message
from textual.reactive import reactive
+from textual.theme import Theme
from textual.screen import ModalScreen
from textual.scroll_view import ScrollView
from textual.strip import Strip
from textual.widgets import (
- DataTable, Footer, Input, OptionList, Static, TextArea,
+ DataTable, Input, OptionList, Static, TextArea,
)
from textual.widgets.option_list import Option
+from . import graph
+from . import kittygfx
from .highlight import highlight_c
-from .client import IDAClient, IDAToolError
-from .domain import DisasmModel, Func, Program, Struct
+from .errors import IDAToolError, IDAConnectionError
+from .worker_client import WorkerClient
+from .domain import DisasmModel, Func, Head, ListingModel, Program, Struct
# Styles for the disassembly listing.
-_S_ADDR = Style(color="grey58")
-_S_LABEL = Style(color="yellow", bold=True)
-_S_INSN = Style(color="white")
-_S_MNEM = Style(color="cyan")
-_S_OPBYTES = Style(color="grey50") # raw opcode bytes column
-_S_CURSOR = Style(bgcolor="grey30")
-_S_DIM = Style(color="grey42", italic=True)
+_S_ADDR = Style(color="#6b7684")
+_S_LABEL = Style(color="#7aa2f7", bold=True)
+_S_INSN = Style(color="#c3cad3")
+#: IDA's token kinds -> the measured palette. The rule that keeps a dense
+#: disassembly readable: NEUTRALS for the machine (mnemonic brightest because you
+#: scan down that column, registers at body weight because they're most of the
+#: text), HUES only where they mean something (numbers, strings, symbols),
+#: structure recedes so brackets and commas stop competing with operands.
+_S_SPAN = {
+ "insn": Style(color="#e8ecf2"), # 15.3:1 mnemonic / directive
+ "reg": Style(color="#c3cad3"), # 11.0:1 registers = body weight
+ "num": Style(color="#d8a657"), # 8.2:1 immediates, offsets
+ "str": Style(color="#9ece6a"), # 9.9:1 string literals
+ "name": Style(color="#7aa2f7"), # 7.2:1 symbols / xref targets
+ "seg": Style(color="#93aee0"), # 8.1:1 segment names
+ "cmt": Style(color="#7c8b9e", italic=True), # 5.2:1
+ "punct": Style(color="#626c7a"), # 3.4:1 brackets, commas, +/-
+ "err": Style(color="#c9762f"), # IDA's own error marker
+ "text": Style(color="#c3cad3"), # 11.0:1 anything unclassified
+}
+_S_MNEM = Style(color="#e8ecf2")
+_S_OPBYTES = Style(color="#5e6875") # raw opcode bytes column
+_S_DATA = Style(color="#d8a657")
+_S_UNK = Style(color="#7c8b9e", italic=True) # undefined bytes in the flat listing
+_S_MEMBER = Style(color="#93aee0")
+_S_SEP = Style(color="#5e6875") # function boundary separators / banners
+_S_FUNCHDR = Style(color="#7aa2f7", bold=True) # 'name proc'/'endp' headers
+
+_LST_INDENT = " " # one depth level: function names sit at level 0, code at 1
+_OP_LIMIT = 8 # opcode bytes shown in the 'limited' column mode
+_JUMP_CONTEXT = 4 # lines of context kept above a jump target (cursor stays on it)
+_SPLIT_MIN_WIDTH = 100 # need room for two usable code panes side by side
+
+# Tokens that look like identifiers but aren't renamable symbols (so 'n' on them
+# in the listing names the address instead of trying to rename the token).
+_ASM_KEYWORDS = frozenset({
+ "db", "dw", "dd", "dq", "dt", "byte", "word", "dword", "qword", "tbyte",
+ "offset", "short", "near", "far", "ptr", "dup", "cs", "ds", "es", "fs",
+ "gs", "ss", "align", "public", "assume", "end",
+})
+_S_CURSOR = Style(bgcolor="#2a313c")
+#: Execution trails. Deliberately faint: they sit UNDER the code palette and
+#: must not compete with it — the trail says "you came through here", the text
+#: still has to be readable as code. Now is the loudest because there is exactly
+#: one of it.
+_S_TRAIL_NOW = Style(bgcolor="#3f3410")
+_S_TRAIL_PAST = Style(bgcolor="#2b1c17") # warm: behind you
+_S_TRAIL_FUTURE = Style(bgcolor="#152230") # cool: ahead of you
+#: Hex with a trace loaded: bytes the trace SAW at this timestamp vs bytes we're
+#: still showing from the file. The distinction matters more than the values —
+#: one is evidence, the other is an assumption.
+_S_HEX_LIVE = Style(color="#9ece6a")
+_S_HEX_STALE = Style(color="#5e6875")
+_S_DIM = Style(color="#7c8b9e", italic=True)
_S_MATCH = Style(bgcolor="#7a5c00") # all search matches
-_S_MATCH_CUR = Style(bgcolor="#b58900", color="black") # the current match
-_S_NAME_MATCH = Style(bgcolor="#b58900", color="black") # filter match in a name
-_S_WORD = Style(bgcolor="#264f78") # identifier under the cursor
+_S_MATCH_CUR = Style(bgcolor="#d0a215", color="#12161c") # the current match
+_S_NAME_MATCH = Style(bgcolor="#d0a215", color="#12161c") # filter match in a name
+_S_WORD = Style(bgcolor="#2a3f5f") # identifier under the cursor
+#: The operand/literal under the cursor — what `o` would reformat. Distinct from
+#: _S_WORD (which marks every occurrence of an identifier): this marks ONE span,
+#: the thing a keypress acts on, so it reads as a selection rather than a match.
+_S_OPERAND = Style(bgcolor="#3a3560", underline=True)
_S_CELL = Style(reverse=True) # the block cursor cell
-_S_LINENO = Style(color="grey37") # pseudocode line-number gutter
-_S_LINENO_CUR = Style(color="grey66", bold=True) # gutter on the cursor line
+_S_LINENO = Style(color="#626c7a") # pseudocode line-number gutter
+_S_LINENO_CUR = Style(color="#c3cad3", bold=True) # gutter on the cursor line
+_S_DECOMP_SPIN = Style(color="#d0a215", bold=True) # 'decompiling' spinner glyph
+_S_DECOMP_WAIT = Style(color="#7c8b9e", italic=True) # 'decompiling' label
+_S_DECOMP_DOTS = Style(color="#626c7a") # trailing ellipsis
+_S_LINK = Style(bgcolor="#233044") # split view: rows linked to the other pane's cursor
# Hex-Rays appends a `/*0xEA*/` address marker to each pseudocode line (we fetch
# with include_addresses so we have a per-line anchor). Matched here to extract
@@ -69,6 +131,48 @@ _ADDR_MARK_STRIP_RE = re.compile(r"\s*/\*\s*0x[0-9A-Fa-f]+\s*\*/")
@dataclass
+class BinaryState:
+ """Everything that makes one project binary's session resumable across a
+ switch. Addresses outlive the worker, so nav history survives eviction; the
+ Program/index only survive while that worker is still resident."""
+
+ label: str
+ program: object | None = None
+ func_index: object | None = None
+ nav: list = field(default_factory=list)
+ cur: object | None = None
+ active: str = "listing"
+ split: bool = False
+ filter_term: str = ""
+ dirty: bool = False
+
+
+@dataclass
+class ViewAnchor:
+ """Where the user is looking, expressed in ADDRESSES.
+
+ Every path that rebuilds a model must round-trip through this. Row indices
+ do NOT survive a rebuild: defining code collapses four undefined byte rows
+ into one instruction row, undefining does the reverse, and a rename can add
+ or remove banner rows above a function. Anything that remembers an index
+ puts the user somewhere else afterwards, which reads as "the edit jumped my
+ screen" or, worse, "the edit didn't apply".
+
+ ``flash`` travels with it because the same rebuild also decides what the
+ status bar says: the reload writes its own status when it lands, so an edit
+ that doesn't hand its message over here gets silently overwritten.
+ """
+
+ view: str = "listing"
+ ea: int | None = None # cursor address
+ top_ea: int | None = None # first visible address
+ cursor_x: int = 0
+ flash: str | None = None
+ #: The edit changed which functions exist, so the index must be rebuilt.
+ refresh_functions: bool = False
+
+
+@dataclass
class NavEntry:
ea: int
name: str
@@ -79,6 +183,8 @@ class NavEntry:
dec_cursor_x: int = 0 # pseudocode column
dec_scroll_y: int = -1 # pseudocode viewport top (-1 = derive)
dec_scroll_x: int = 0 # pseudocode horizontal scroll
+ is_region: bool = False # not inside a function (flat listing view)
+ view: str = "listing" # which code view to restore this entry in
class SearchRequested(Message):
@@ -132,6 +238,35 @@ class RetypeRequested(Message):
self.name = name
+class EditItemRequested(Message):
+ """The disasm view asks to change item structure (IDA c/d/u/p) at the line
+ under the cursor. ``kind`` is 'code' | 'func' | 'undef'."""
+
+ def __init__(self, view, kind: str) -> None: # type: ignore[no-untyped-def]
+ super().__init__()
+ self.view = view
+ self.kind = kind
+
+
+class MakeDataRequested(Message):
+ """The listing view asks to define typed data (IDA 'd') at the current head."""
+
+ def __init__(self, view) -> None: # type: ignore[no-untyped-def]
+ super().__init__()
+ self.view = view
+
+
+class OpFormatRequested(Message):
+ """A code view asks to change how the literal under the cursor is DISPLAYED
+ (IDA's 'o'): hex, decimal, binary, character, offset. ``mode`` is 'cycle',
+ 'back', or a format by name."""
+
+ def __init__(self, view, mode: str = "cycle") -> None: # type: ignore[no-untyped-def]
+ super().__init__()
+ self.view = view
+ self.mode = mode
+
+
class NavMixin:
"""Follow / xrefs actions shared by the code views (bindings live on each
view since Textual only merges BINDINGS from DOMNode subclasses)."""
@@ -206,6 +341,27 @@ def _word_bounds(text: str, x: int) -> tuple[int, int]:
return (s, e)
+def _word_occurrences(text: str, word: str) -> list[tuple[int, int]]:
+ """[start, end) spans of every WHOLE-word occurrence of ``word`` in ``text``
+ (for highlight-all-occurrences of the token under the cursor)."""
+ if not word or not text:
+ return []
+ isw = lambda c: c.isalnum() or c == "_" # noqa: E731
+ out: list[tuple[int, int]] = []
+ n = len(word)
+ i = 0
+ while True:
+ j = text.find(word, i)
+ if j < 0:
+ break
+ before_ok = j == 0 or not isw(text[j - 1])
+ after_ok = j + n >= len(text) or not isw(text[j + n])
+ if before_ok and after_ok:
+ out.append((j, j + n))
+ i = j + n
+ return out
+
+
def _overlay_over(strip: Strip, ranges: list[tuple[int, int]], style: Style) -> Strip:
"""Like _overlay_ranges but ``style`` OVERRIDES the existing cell styles
(used for the cursor cell / word, which must win over the line background)."""
@@ -255,9 +411,21 @@ class ColumnCursor:
Binding("dollar_sign", "col_end", "eol", show=False),
]
+ _hl_word: str | None = None # token to highlight across all visible lines
+
def _line_plain(self, idx: int) -> str | None:
raise NotImplementedError
+ def _refresh_hl(self) -> None:
+ """Recompute the highlight-all token from the word under the cursor; if it
+ changed, repaint the whole viewport (occurrences elsewhere changed)."""
+ w = self.word_under_cursor()
+ if not (w and len(w) >= 2 and (w[0].isalpha() or w[0] == "_")):
+ w = None
+ if w != self._hl_word:
+ self._hl_word = w
+ self.refresh()
+
def _hscroll(self) -> None:
pass
@@ -271,6 +439,7 @@ class ColumnCursor:
self.cursor_x = max(0, min(max(len(plain) - 1, 0), self.cursor_x + dx))
self._hscroll()
_refresh_lines(self, self.cursor)
+ self._refresh_hl()
def action_col_left(self) -> None:
self._move_x(-1)
@@ -282,12 +451,14 @@ class ColumnCursor:
self.cursor_x = 0
self._hscroll()
_refresh_lines(self, self.cursor)
+ self._refresh_hl()
def action_col_end(self) -> None:
plain = self._line_plain(self.cursor) or ""
self.cursor_x = max(len(plain) - 1, 0)
self._hscroll()
_refresh_lines(self, self.cursor)
+ self._refresh_hl()
def action_col_word(self, direction: int) -> None:
plain = self._line_plain(self.cursor) or ""
@@ -311,6 +482,7 @@ class ColumnCursor:
self.cursor_x = max(i, 0)
self._hscroll()
_refresh_lines(self, self.cursor)
+ self._refresh_hl()
def word_under_cursor(self) -> str | None:
plain = self._line_plain(self.cursor)
@@ -338,6 +510,7 @@ class ColumnCursor:
self._scroll_cursor_into_view()
self._hscroll()
self.refresh()
+ self._refresh_hl()
self._after_cursor_move()
def on_click(self, event) -> None: # type: ignore[no-untyped-def]
@@ -549,6 +722,7 @@ class SearchMixin:
self.scroll_to(y=max(self.cursor - self._visible_height() // 2, 0), animate=False)
self._hscroll() # bring the match column into horizontal view
self.refresh()
+ self._refresh_hl()
def clear_search(self) -> None:
self._term = ""
@@ -568,8 +742,15 @@ class SearchMixin:
# --------------------------------------------------------------------------- #
# Virtualized disassembly view
# --------------------------------------------------------------------------- #
-class DisasmView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True):
- """A line-virtualized disassembly listing for a single function."""
+# --------------------------------------------------------------------------- #
+# Virtualized flat listing view (code + data + undefined, per segment)
+# --------------------------------------------------------------------------- #
+class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True):
+ """A line-virtualized *flat* listing over one segment: code, data and
+ undefined heads interleaved (IDA's disassembly view), unlike ``DisasmModel``
+ which is bounded to one function. Backed by ``ListingModel`` (the ``heads``
+ server tool). Used for non-function regions and raw segment browsing.
+ """
BINDINGS = [
Binding("j,down", "cursor_down", "Down", show=False),
@@ -578,10 +759,23 @@ class DisasmView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True
Binding("ctrl+u", "half_page(-1)", "½↑", show=False),
Binding("pagedown", "page(1)", "PgDn", show=False),
Binding("pageup", "page(-1)", "PgUp", show=False),
- Binding("home", "goto_top", "Top", show=False),
- Binding("G,end", "goto_bottom", "Bottom", show=False),
- Binding("o", "toggle_opcodes", "Opcodes", show=False),
- Binding("tab,shift+tab", "app.toggle_view", "Pseudocode", priority=True),
+ Binding("home", "col_home", "bol", show=False),
+ Binding("shift+home", "col_insn_home", "insn start", show=False),
+ Binding("end", "col_end", "eol", show=False),
+ Binding("G,ctrl+end", "goto_bottom", "Bottom", show=False),
+ Binding("ctrl+home", "goto_top", "Top", show=False),
+ # `o` is IDA's operand-format key, and that muscle memory is worth more
+ # than the opcode column's old claim on it (moved to B, for bytes).
+ Binding("o", "op_format('cycle')", "Format"),
+ Binding("O", "op_format('back')", "Format \u2190", show=False),
+ Binding("B", "toggle_opcodes", "Bytes", show=False),
+ Binding("c", "define_code", "Code", show=False),
+ Binding("d", "make_data", "Data", show=False),
+ Binding("a", "make_string", "Str", show=False),
+ Binding("p", "define_func", "Func", show=False),
+ Binding("u", "undefine", "Undef", show=False),
+ Binding("t", "toggle_thumb", "ARM/Thumb", show=False),
+ Binding("T", "thumb_scan", "Scan vectors", show=False),
*SearchMixin.SEARCH_BINDINGS,
*NavMixin.NAV_BINDINGS,
*ColumnCursor.COL_BINDINGS,
@@ -591,125 +785,272 @@ class DisasmView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True
cursor_x = reactive(0, repaint=False)
class CursorMoved(Message):
- """Posted when the disasm cursor moves; carries the instruction ea."""
+ """Posted when the listing cursor moves; carries the head ea."""
def __init__(self, index: int, ea: int | None) -> None:
super().__init__()
self.index = index
self.ea = ea
+ class Scrolled(Message):
+ """Posted when the viewport scrolls (wheel/scrollbar) — the cursor need
+ not have moved, so the split view can still follow along."""
+
def __init__(self) -> None:
super().__init__()
- self.model: DisasmModel | None = None
+ self.model: ListingModel | None = None
self.total = 0
self._name = ""
self._pending_scroll_y: int | None = None
+ self._pending_focus: str | None = None
+ #: Operand index to put the cursor column back on once rows land — a
+ #: reformat can change an operand's width, moving the ones after it.
+ self._pending_op: int | None = None
self._term = ""
self._matches: list[int] = []
self._ranges: dict[int, list[tuple[int, int]]] = {}
- self._search_texts: list[str] | None = None
- self._show_ops = True
- self._op_w = 0 # char width of the hex-bytes field (excl. trailing gap)
+ self._op_mode = 1 # opcode column: 0=off, 1=limited, 2=full ('o' cycles)
+ self._op_w = 0 # char width of the hex-bytes field (excl. gap)
+ self._search_loading = False
+ self._search_pending: list = [] # done-callbacks awaiting the load
+ self._link_rows: set[int] = set() # split-view: linked instruction rows
+ #: {address: 'now'|'past'|'future'} painted under the code (trace mode).
+ self.trail: dict[int, str] = {}
+
+ # -- text helpers ------------------------------------------------------ #
+ def _head(self, idx: int) -> Head | None:
+ return self.model.get(idx) if self.model is not None else None
+
+ @staticmethod
+ def _name_prefix(h: Head) -> str:
+ return f"{h.name} " if h.name else ""
- def _op_field(self, line) -> str: # type: ignore[no-untyped-def]
- """The padded opcode-bytes column (empty when hidden). Kept identical
- between the rendered strip and the plain text so cursor/search offsets
- line up."""
- if not self._show_ops or self._op_w <= 0:
+ def _op_bytes_text(self, h: Head) -> str:
+ """Hex bytes for ``h``, truncated with an ellipsis in 'limited' mode so a
+ long x86-64 instruction doesn't blow out the column."""
+ raw = h.raw or b""
+ if self._op_mode == 1 and len(raw) > _OP_LIMIT:
+ return " ".join(f"{b:02X}" for b in raw[:_OP_LIMIT]) + "\u2026"
+ return " ".join(f"{b:02X}" for b in raw)
+
+ @staticmethod
+ def _span_segments(h: Head, fallback: Style):
+ """Segments for a row's disassembly text.
+
+ Uses IDA's own token classification when the worker supplied it; falls
+ back to the old mnemonic/rest split so an older worker (or a row whose
+ spans didn't match the text) still renders.
+ """
+ if h.spans:
+ return [Segment(t, _S_SPAN.get(k, fallback)) for k, t in h.spans]
+ if h.kind == "code":
+ mnem, _, rest = h.text.partition(" ")
+ segs = [Segment(mnem, _S_MNEM)]
+ if rest:
+ segs.append(Segment(" " + rest, fallback))
+ return segs
+ return [Segment(h.text, fallback)]
+
+ def _op_field(self, h: Head) -> str:
+ """The padded opcode-bytes column (empty when hidden). Shared format so
+ cursor/search offsets line up."""
+ if self._op_mode == 0 or self._op_w <= 0:
return ""
- raw = line.raw or b""
- return " ".join(f"{b:02X}" for b in raw).ljust(self._op_w) + " "
+ return self._op_bytes_text(h).ljust(self._op_w) + " "
def _line_plain(self, idx: int) -> str | None:
- if self.model is None:
- return None
- line = self.model.cached_line(idx)
- if line is None:
+ h = self._head(idx)
+ if h is None:
return None
- s = f"{line.ea:08X} " + self._op_field(line)
- if line.label:
- s += f"{line.label}: "
- return s + line.text
+ # Function headers and code labels sit at depth 0 (with the address);
+ # everything else is indented one level (opcode+text included).
+ if h.kind in ("funchdr", "label"):
+ return f"{h.ea:08X} {h.text}"
+ base = f"{h.ea:08X} " + _LST_INDENT
+ if h.kind == "sep":
+ return base + h.text
+ extra = _LST_INDENT if h.kind == "member" else ""
+ return base + self._op_field(h) + extra + self._name_prefix(h) + h.text
+
+ def _insn_col(self, idx: int) -> int:
+ """Column where the instruction/content text begins, past the address +
+ opcode-bytes gutter — the shift+home target. Mirrors ``_line_plain``'s
+ prefix so the column lines up with what's rendered."""
+ h = self._head(idx)
+ if h is None:
+ return 0
+ if h.kind in ("funchdr", "label"):
+ return len(f"{h.ea:08X} ")
+ base = f"{h.ea:08X} " + _LST_INDENT
+ if h.kind == "sep":
+ return len(base)
+ extra = _LST_INDENT if h.kind == "member" else ""
+ return len(base + self._op_field(h) + extra + self._name_prefix(h))
+
+ def action_col_insn_home(self) -> None:
+ """shift+home: jump to the start of the instruction text, skipping the
+ address + opcode-bytes gutter (home/0 still go to the true line start)."""
+ self.cursor_x = self._insn_col(self.cursor)
+ self._hscroll()
+ _refresh_lines(self, self.cursor)
+ self._refresh_hl()
# -- public API -------------------------------------------------------- #
- def load(self, model: DisasmModel, name: str, cursor: int = 0,
- cursor_x: int = 0, scroll_y: int | None = None) -> None:
+ def load(self, model: ListingModel, name: str, cursor: int = 0,
+ cursor_x: int = 0, scroll_y: int | None = None,
+ focus: str | None = None) -> None:
self.model = model
self._name = name
self.total = 0
self.cursor = cursor
self.cursor_x = cursor_x
self._pending_scroll_y = scroll_y
- # NB: don't zero virtual_size here — that snaps the scroll to 0 and
- # causes a visible jump before _on_primed restores the target scroll.
+ self._pending_focus = focus # token to land the cursor column on
+ self._pending_op = None
self._matches = []
self._ranges = {}
- self._search_texts = None
self._prime()
- # -- search hooks ------------------------------------------------------ #
- def _fmt(self, line) -> str: # type: ignore[no-untyped-def]
- s = f"{line.ea:08X} " + self._op_field(line)
- if line.label:
- s += f"{line.label}: "
- return s + line.text
-
- def _search_line_count(self) -> int:
- return self.total
-
- def _search_line_text(self, i: int) -> str | None:
- t = self._search_texts
- return t[i] if t is not None and 0 <= i < len(t) else None
-
- def _search_ensure(self, done) -> None:
- if self._search_texts is not None:
- done()
- return
- self._app_status(f"/{self._term}/ indexing {self.total} lines…")
- self._index_for_search(done)
-
- @work(thread=True, exclusive=True, group="search-index")
- def _index_for_search(self, done) -> None:
- model = self.model
- if model is None:
- self.app.call_from_thread(done)
- return
- texts: list[str] = []
- off, total = 0, self.total
- while off < total:
- lines = model.lines(off, DisasmModel.BLOCK, prefetch=False)
- if not lines:
- break
- texts.extend(self._fmt(ln) for ln in lines)
- off += len(lines)
- self._search_texts = texts
- self.app.call_from_thread(done)
-
- @work(thread=True, exclusive=True, group="disasm-prime")
+ @work(thread=True, exclusive=True, group="listing-prime")
def _prime(self) -> None:
model = self.model
if model is None:
return
- total = model.total()
+ # Load just enough to render the viewport around the cursor, so the
+ # listing appears immediately even on a huge segment; the rest streams
+ # in via _grow. (load_all here would blank the pane for seconds.)
height = max(self.size.height, 1)
- model.lines(0, min(total, height + DisasmModel.BLOCK), prefetch=True)
- if self.cursor:
- model.lines(max(self.cursor - 2, 0), height, prefetch=True)
- self.app.call_from_thread(self._on_primed, total)
+ model.ensure(self.cursor + height + 2 * ListingModel.PAGE)
+ self.app.call_from_thread(self._on_primed, len(model), model.complete)
+ if not model.complete:
+ self._grow()
- def _on_primed(self, total: int) -> None:
+ def _on_primed(self, total: int, complete: bool) -> None:
+ if self.model is None:
+ return
self.total = total
self.virtual_size = Size(0, total)
- self._update_op_w() # provisional width from the primed window
- self._scan_op_width() # settle it against the whole function
- self._clamp_x() # cursor line is now cached; keep the column in range
+ self.cursor = max(0, min(self.cursor, max(total - 1, 0)))
+ self._update_op_w() # finalize column layout before locating the token
+ # Land the cursor on the requested token's column (e.g. the ref an xref
+ # jump targets), now that the row is loaded and renderable.
+ if self._pending_focus:
+ plain = self._line_plain(self.cursor)
+ if plain:
+ occ = _word_occurrences(plain, self._pending_focus)
+ if occ:
+ self.cursor_x = occ[0][0]
+ self._pending_focus = None
+ if self._pending_op is not None:
+ # Stay on the operand that was just reformatted: its text can change
+ # width, which moves every operand after it out from under the
+ # cursor (and the next press would then hit a different one).
+ h = self._head(self.cursor)
+ for lo, _hi, n in (h.ops if h is not None else None) or ():
+ if n == self._pending_op:
+ self.cursor_x = self._insn_col(self.cursor) + lo
+ break
+ self._pending_op = None
+ self._clamp_x()
if self._pending_scroll_y is not None and self._pending_scroll_y >= 0:
self._apply_scroll(min(self._pending_scroll_y, max(total - 1, 0)))
else:
self._scroll_cursor_into_view()
self._pending_scroll_y = None
+ self._hscroll() # bring the cursor column into horizontal view
+ self.refresh()
+ self.post_message(ListingView.CursorMoved(self.cursor, self._cursor_ea()))
+
+ def _update_op_w(self) -> bool:
+ """Recompute the opcode-column width from the widest code head seen (capped
+ in 'limited' mode). Returns True if it changed."""
+ w = 0
+ if self.model is not None and self._op_mode != 0:
+ mx = self.model.max_raw_len()
+ if mx > 0:
+ if self._op_mode == 1: # limited: cap bytes, +1 for the ellipsis
+ shown = min(mx, _OP_LIMIT)
+ w = shown * 3 - 1 + (1 if mx > _OP_LIMIT else 0)
+ else: # full
+ w = mx * 3 - 1
+ if w != self._op_w:
+ self._op_w = w
+ return True
+ return False
+
+ def action_toggle_opcodes(self) -> None:
+ # cycle: off -> limited -> full -> off
+ self._op_mode = (self._op_mode + 1) % 3
+ self._update_op_w()
+ self._ranges = {} # column layout changed -> stale match offsets
+ self._clamp_x()
+ self.refresh()
+ self._app_status("opcodes: " + {0: "off", 1: f"limited ({_OP_LIMIT} bytes)",
+ 2: "full"}[self._op_mode])
+
+ @work(thread=True, exclusive=True, group="listing-grow")
+ def _grow(self) -> None:
+ """Stream the rest of the segment's heads in the background, growing the
+ virtual size as they land so the scrollbar/paging catch up."""
+ model = self.model
+ if model is None:
+ return
+ since = 0
+ while not model.complete:
+ if model.load_next_page() == 0:
+ break
+ if self.model is not model: # a new load() replaced us
+ return
+ since += 1
+ if since >= 4: # throttle repaints on huge segments
+ since = 0
+ self.app.call_from_thread(self._grew, len(model))
+ self.app.call_from_thread(self._grew, len(model))
+
+ def _grew(self, total: int) -> None:
+ if self.model is None or total <= self.total:
+ return
+ self.total = total
+ self.virtual_size = Size(0, total)
+ self._update_op_w() # more code streamed in -> widen the op column
self.refresh()
+ # -- search hooks ----------------------------------------------------- #
+ def _search_line_count(self) -> int:
+ return self.total
+
+ def _search_line_text(self, i: int) -> str | None:
+ return self._line_plain(i)
+
+ def _search_ensure(self, done) -> None:
+ # Search needs the whole segment loaded to find every match. Kick off a
+ # SINGLE background load (guarded so per-keystroke updates don't spawn
+ # one worker each — an exclusive worker would cancel+restart on every
+ # keypress and never finish) and queue the callbacks until it lands.
+ model = self.model
+ if model is None or model.complete:
+ done()
+ return
+ self._search_pending.append(done)
+ if not self._search_loading:
+ self._search_loading = True
+ self._search_load_all()
+
+ @work(thread=True, group="listing-search-load")
+ def _search_load_all(self) -> None:
+ model = self.model
+ if model is not None:
+ model.load_all()
+ self.app.call_from_thread(self._search_loaded)
+
+ def _search_loaded(self) -> None:
+ self._search_loading = False
+ if self.model is not None:
+ self._grew(len(self.model))
+ pending, self._search_pending = self._search_pending, []
+ for cb in pending:
+ cb()
+
# -- rendering --------------------------------------------------------- #
def render_line(self, y: int) -> Strip:
model = self.model
@@ -717,85 +1058,108 @@ class DisasmView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True
if model is None or self.total == 0:
return Strip([Segment("".ljust(width), _S_DIM)])
top = round(self.scroll_offset.y)
- if y == 0: # once per refresh: warm the visible window + a little ahead
- self._ensure_window(top)
idx = top + y
if idx >= self.total:
return Strip([Segment("".ljust(width), _S_INSN)])
- line = model.cached_line(idx)
- is_cursor = idx == self.cursor
- if line is None:
+ h = model.get(idx)
+ if h is None:
strip = Strip([Segment(f" {idx:>8} …", _S_DIM)])
+ elif h.kind == "sep":
+ strip = Strip([Segment(f"{h.ea:08X} ", _S_ADDR),
+ Segment(_LST_INDENT + h.text, _S_SEP)])
+ elif h.kind == "funchdr":
+ # depth-0: address + 'name proc'/'endp' (no indent)
+ strip = Strip([Segment(f"{h.ea:08X} ", _S_ADDR),
+ Segment(h.text, _S_FUNCHDR)])
+ elif h.kind == "label":
+ # depth-0: address + 'loc_XXX:' on its own line
+ strip = Strip([Segment(f"{h.ea:08X} ", _S_ADDR),
+ Segment(h.text, _S_LABEL)])
else:
- segs: list[Segment] = [Segment(f"{line.ea:08X} ", _S_ADDR)]
- op = self._op_field(line)
+ # depth-1: address, one indent, then opcode+text
+ segs: list[Segment] = [Segment(f"{h.ea:08X} ", _S_ADDR),
+ Segment(_LST_INDENT, _S_INSN)]
+ op = self._op_field(h)
if op:
segs.append(Segment(op, _S_OPBYTES))
- if line.label:
- segs.append(Segment(f"{line.label}: ", _S_LABEL))
- mnem, _, rest = line.text.partition(" ")
- segs.append(Segment(mnem, _S_MNEM))
- if rest:
- segs.append(Segment(" " + rest, _S_INSN))
+ if h.kind == "member":
+ segs.append(Segment(_LST_INDENT, _S_MEMBER))
+ if h.name:
+ segs.append(Segment(f"{h.name} ", _S_LABEL))
+ if h.kind == "member":
+ segs.append(Segment(h.text, _S_MEMBER))
+ else:
+ base = {"code": _S_INSN, "data": _S_DATA}.get(h.kind, _S_UNK)
+ segs.extend(self._span_segments(h, base))
strip = Strip(segs)
+ linked = idx in self._link_rows
+ if linked:
+ strip = strip.apply_style(_S_LINK) # split-view companion band
+ if self.trail and h is not None:
+ kind = self.trail.get(h.ea)
+ if kind is not None:
+ strip = strip.apply_style(
+ _S_TRAIL_NOW if kind == "now" else
+ _S_TRAIL_PAST if kind == "past" else _S_TRAIL_FUTURE)
+ plain = self._line_plain(idx) if (self._hl_word or idx == self.cursor) else None
if idx in self._ranges:
strip = _overlay_ranges(strip, self._ranges[idx], self._match_style(idx))
- if is_cursor:
- strip = _cursor_decorate(strip, self._line_plain(idx) or "", self.cursor_x)
- return strip.adjust_cell_length(width, _S_INSN)
+ if self._hl_word and plain:
+ occ = _word_occurrences(plain, self._hl_word)
+ if occ:
+ strip = _overlay_ranges(strip, occ, _S_WORD)
+ if idx == self.cursor:
+ strip = _cursor_decorate(strip, plain or "", self.cursor_x)
+ # The operand 'o' would reformat, marked before you press it. A line
+ # can hold several literals and the cursor picks one; showing which
+ # is the difference between an edit you chose and one you got.
+ # Drawn LAST on purpose: _cursor_decorate paints the word under the
+ # cursor, and that word is usually the literal itself — so painting
+ # this first just loses to it.
+ span = self._cursor_operand(idx)
+ if span is not None:
+ strip = _overlay_over(strip, [span], _S_OPERAND)
+ return strip.adjust_cell_length(width, _S_LINK if linked else _S_INSN)
- def _update_op_w(self) -> bool:
- """Recompute the opcode column width from the model's widest instruction.
- Returns True if it changed."""
- w = 0
- if self.model is not None and self._show_ops:
- mx = self.model.max_raw_len()
- w = max(mx * 3 - 1, 0) if mx > 0 else 0
- if w != self._op_w:
- self._op_w = w
- return True
- return False
+ def _cursor_operand(self, idx: int) -> tuple[int, int] | None:
+ """Screen columns of the operand under the cursor on row ``idx``, or None.
- @work(thread=True, exclusive=True, group="disasm-opwidth")
- def _scan_op_width(self) -> None:
- model = self.model
- if model is None:
- return
- model.scan_bytes() # fetch all blocks -> stable widest instruction
- self.app.call_from_thread(self._settle_op_w)
+ The extents come from the worker (IDA's own operand markers) and are
+ offsets into the head's text, so they shift by the same gutter the
+ cursor column is measured against."""
+ h = self._head(idx)
+ if h is None or not h.ops:
+ return None
+ base = self._insn_col(idx)
+ got = h.op_at(self.cursor_x - base)
+ return (base + got[0], base + got[1]) if got else None
- def _settle_op_w(self) -> None:
- if self._update_op_w():
- self._search_texts = None # layout changed -> stale offsets
+ # -- split-view link highlight ---------------------------------------- #
+ def set_link(self, rows) -> None:
+ rows = set(rows) if rows else set()
+ if rows != self._link_rows:
+ self._link_rows = rows
self.refresh()
- def action_toggle_opcodes(self) -> None:
- self._show_ops = not self._show_ops
- self._update_op_w()
- self._search_texts = None # column layout changed -> reindex on next search
- self._ranges = {}
- self._clamp_x()
- self.refresh()
- self._app_status("opcodes " + ("on" if self._show_ops else "off"))
+ def reveal(self, row: int) -> None:
+ """Scroll ``row`` into view without moving the cursor (companion pane)."""
+ height = self._visible_height()
+ top = round(self.scroll_offset.y)
+ if row < top or row >= top + height:
+ self.scroll_to(y=max(row - height // 3, 0), animate=False)
- def _ensure_window(self, top: int) -> None:
- if self.model is None:
- return
- height = max(self.size.height, 1)
- start = max(top - DisasmModel.BLOCK, 0)
- count = height + 2 * DisasmModel.BLOCK
- if not self.model.is_cached(top, height):
- self._fetch_window(start, count)
- else:
- self.model.ensure_async(start, count) # warm neighbors
+ def align(self, row: int, screen_row: int) -> None:
+ """Scroll so ``row`` sits at viewport offset ``screen_row`` — keeps this
+ (companion) pane visually level with the driver's cursor in split view.
+ Clamps at the ends, so alignment is best-effort near the edges."""
+ top = max(0, min(row - max(screen_row, 0), max(self.total - 1, 0)))
+ if top != round(self.scroll_offset.y):
+ self.scroll_to(y=top, animate=False)
- @work(thread=True, exclusive=False, group="disasm-fetch")
- def _fetch_window(self, start: int, count: int) -> None:
- model = self.model
- if model is None:
- return
- model.lines(start, count, prefetch=True)
- self.app.call_from_thread(self.refresh)
+ def watch_scroll_y(self, old_value: float, new_value: float) -> None:
+ super().watch_scroll_y(old_value, new_value)
+ if round(old_value) != round(new_value):
+ self.post_message(ListingView.Scrolled())
# -- navigation -------------------------------------------------------- #
def _visible_height(self) -> int:
@@ -818,19 +1182,58 @@ class DisasmView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True
self._clamp_x()
self._scroll_cursor_into_view()
if round(self.scroll_offset.y) != before:
- self.refresh() # scrolled: the whole viewport shifted
+ self.refresh()
else:
- _refresh_lines(self, old, self.cursor) # only the two changed rows
- self.post_message(DisasmView.CursorMoved(self.cursor, self._cursor_ea()))
+ _refresh_lines(self, old, self.cursor)
+ self.post_message(ListingView.CursorMoved(self.cursor, self._cursor_ea()))
def _after_cursor_move(self) -> None:
- self.post_message(DisasmView.CursorMoved(self.cursor, self._cursor_ea()))
+ self.post_message(ListingView.CursorMoved(self.cursor, self._cursor_ea()))
def _cursor_ea(self) -> int | None:
- if self.model is None:
- return None
- line = self.model.cached_line(self.cursor)
- return line.ea if line else None
+ h = self._head(self.cursor)
+ return h.ea if h else None
+
+ def _next_ea(self) -> int | None:
+ h = self._head(self.cursor + 1)
+ return h.ea if h else None
+
+ # -- item structure edits (IDA c/p/u) --------------------------------- #
+ def action_define_code(self) -> None:
+ self.post_message(EditItemRequested(self, "code"))
+
+ def action_define_func(self) -> None:
+ self.post_message(EditItemRequested(self, "func"))
+
+ def action_undefine(self) -> None:
+ self.post_message(EditItemRequested(self, "undef"))
+
+ def action_toggle_thumb(self) -> None:
+ self.post_message(EditItemRequested(self, "thumb"))
+
+ def action_thumb_scan(self) -> None:
+ self.post_message(EditItemRequested(self, "thumbscan"))
+
+ def action_make_data(self) -> None:
+ self.post_message(MakeDataRequested(self))
+
+ def action_make_string(self) -> None:
+ self.post_message(EditItemRequested(self, "string"))
+
+ # -- literal display format (IDA 'o') --------------------------------- #
+ def action_op_format(self, mode: str = "cycle") -> None:
+ self.post_message(OpFormatRequested(self, mode))
+
+ def op_col(self) -> int:
+ """Cursor column inside the head's OWN text — the same string the worker
+ renders, so it can say which operand the cursor is standing on. -1 when
+ the cursor is left of it (in the address/opcode gutter), which means "you
+ didn't pick one, take the first literal"."""
+ base = self._insn_col(self.cursor)
+ return self.cursor_x - base if self.cursor_x >= base else -1
+
+ def cur_head(self) -> Head | None:
+ return self._head(self.cursor)
def action_cursor_down(self) -> None:
self._move(1)
@@ -863,6 +1266,32 @@ def _refresh_lines(view, *indices: int) -> None:
# --------------------------------------------------------------------------- #
# Decompiler (pseudocode) view
# --------------------------------------------------------------------------- #
+class _DecompLoading(Static):
+ """Animated cover shown over the pseudocode pane while a (re)decompile runs.
+ A braille spinner + label, dim over the grayed-out code — fits the muted TUI
+ palette (no ASCII-bar noise)."""
+
+ _FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
+
+ def __init__(self, **kwargs) -> None:
+ self._i = 0
+ super().__init__(self._label(), **kwargs)
+
+ def _label(self) -> Text:
+ t = Text(justify="center")
+ t.append(self._FRAMES[self._i], _S_DECOMP_SPIN)
+ t.append(" decompiling", _S_DECOMP_WAIT)
+ t.append("…", _S_DECOMP_DOTS)
+ return t
+
+ def on_mount(self) -> None:
+ self.set_interval(1 / 12, self._tick)
+
+ def _tick(self) -> None:
+ self._i = (self._i + 1) % len(self._FRAMES)
+ self.update(self._label())
+
+
class DecompView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True):
"""Read-only, line-virtualized Hex-Rays pseudocode with Pygments C
highlighting. Lines are highlighted once at load and cached as Strips, so
@@ -871,14 +1300,25 @@ class DecompView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True
BINDINGS = [
Binding("tab,shift+tab", "app.toggle_view", "Disasm", priority=True),
+ Binding("f5", "app.toggle_view", "Decompile", priority=True),
+ Binding("L", "app.continuous_here", "Listing"),
Binding("j,down", "cursor_down", "Down", show=False),
Binding("k,up", "cursor_up", "Up", show=False),
Binding("ctrl+d", "half_page(1)", "½↓", show=False),
Binding("ctrl+u", "half_page(-1)", "½↑", show=False),
Binding("pagedown", "page(1)", "PgDn", show=False),
Binding("pageup", "page(-1)", "PgUp", show=False),
- Binding("home", "goto_top", "Top", show=False),
- Binding("G,end", "goto_bottom", "Bottom", show=False),
+ # Home/End move the cursor along the line (as in the listing); top and
+ # bottom of the function move to the ctrl+ pair (G still works too).
+ Binding("home", "col_home", "bol", show=False),
+ Binding("shift+home", "col_code_home", "code start", show=False),
+ Binding("end", "col_end", "eol", show=False),
+ Binding("ctrl+home", "goto_top", "Top", show=False),
+ Binding("G,ctrl+end", "goto_bottom", "Bottom", show=False),
+ # Hex-Rays keeps number formats of its own, so `o` works here too — on
+ # the C literal under the cursor, not on the instruction's operand.
+ Binding("o", "op_format('cycle')", "Format"),
+ Binding("O", "op_format('back')", "Format \u2190", show=False),
*SearchMixin.SEARCH_BINDINGS,
*NavMixin.NAV_BINDINGS,
*ColumnCursor.COL_BINDINGS,
@@ -895,6 +1335,10 @@ class DecompView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True
self.index = index
self.ea = ea
+ class Scrolled(Message):
+ """Posted when the viewport scrolls (wheel/scrollbar) — the cursor need
+ not have moved, so the split view can still follow along."""
+
def __init__(self) -> None:
super().__init__(id="decomp")
self.loaded_ea: int | None = None
@@ -902,13 +1346,45 @@ class DecompView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True
self._texts: list[str] = []
self._gutter = 0 # line-number gutter width (cells)
self._line_eas: list[int | None] = [] # per-line address (marker stripped)
+ self._link_line: int | None = None # split-view: linked pseudocode line
+ #: {line index: 'now'|'past'|'future'} — the execution trail, mapped from
+ #: instructions onto pseudocode via decomp_map.
+ self.trail: dict[int, str] = {}
self._term = ""
self._matches: list[int] = []
self._ranges: dict[int, list[tuple[int, int]]] = {}
+ #: {line: [(x0, x1, value, ea, opnum)]} — where the number literals are,
+ #: so the one under the cursor can be marked (Hex-Rays keeps formats per
+ #: literal, and a C line often has several).
+ self._nums: dict[int, list[tuple[int, int, str, int, int]]] = {}
+ #: (ea, opnum) to put the cursor back on once the nums land — a reformat
+ #: reflows the line, so the old column points at the wrong literal.
+ self._keep_lit: tuple[int, int] | None = None
def _line_plain(self, idx: int) -> str | None:
return self._texts[idx] if 0 <= idx < len(self._texts) else None
+ def set_nums(self, nums: dict) -> None:
+ self._nums = nums or {}
+ keep, self._keep_lit = self._keep_lit, None
+ if keep is not None:
+ # Land the cursor back on the literal that was just reformatted:
+ # `48` becoming `0x30` moves everything after it, so holding the
+ # column would put the next press on a different literal.
+ for x0, _x1, _v, ea, opnum in self._nums.get(self.cursor, ()):
+ if (ea, opnum) == keep:
+ self.cursor_x = x0
+ self._hscroll()
+ break
+ self.refresh()
+
+ def _cursor_literal(self, idx: int) -> tuple[int, int] | None:
+ """Columns of the number literal under the cursor on ``idx``, or None."""
+ for x0, x1, _v, _ea, _op in self._nums.get(idx, ()):
+ if x0 <= self.cursor_x < x1:
+ return (x0, x1)
+ return None
+
def _col_offset(self) -> int:
return self._gutter
@@ -982,19 +1458,27 @@ class DecompView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True
else:
scroll_y = top
width = max(self.size.width - self._gutter, 1)
- sx = round(self.scroll_offset.x)
- if self.cursor_x < sx:
- scroll_x = self.cursor_x
- elif self.cursor_x >= sx + width:
+ # Always baseline the horizontal scroll at 0 for a jump, then scroll
+ # right only if the target column falls outside the viewport — never
+ # keep a stale horizontal offset from wherever we were before.
+ sx = 0
+ if self.cursor_x >= sx + width:
scroll_x = self.cursor_x - width + 1
else:
scroll_x = sx
self._apply_scroll(min(max(scroll_y, 0), max(total - 1, 0)), max(scroll_x, 0))
+ # `cursor` is reactive(repaint=False) and _apply_scroll only repaints via
+ # call_after_refresh, so a jump that lands in the SAME viewport (Esc back
+ # to another spot in the function already on screen) moved the cursor
+ # with nothing to redraw it — the pane kept showing the old highlight
+ # until the next keypress. Repaint here; _move does the same via
+ # _refresh_lines.
+ self.refresh()
self._after_cursor_move()
def get_loading_widget(self): # type: ignore[override]
# Shown (grayed, centered) while a (re)decompile is in flight.
- return Static("― decompiling… ―", classes="decomp-loading")
+ return _DecompLoading(classes="decomp-loading")
def _line_ea(self, idx: int) -> int | None:
return self._line_eas[idx] if 0 <= idx < len(self._line_eas) else None
@@ -1023,20 +1507,84 @@ class DecompView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True
return Strip([Segment(" " * gw, _S_LINENO)]).adjust_cell_length(width)
return Strip.blank(width)
x = round(self.scroll_offset.x)
+ linked = idx == self._link_line
base = self._strips[idx]
+ if linked:
+ base = base.apply_style(_S_LINK) # split-view companion band
+ kind = self.trail.get(idx) if self.trail else None
+ if kind is not None:
+ base = base.apply_style(
+ _S_TRAIL_NOW if kind == "now" else
+ _S_TRAIL_PAST if kind == "past" else _S_TRAIL_FUTURE)
if idx in self._ranges:
base = _overlay_ranges(base, self._ranges[idx], self._match_style(idx))
+ if self._hl_word:
+ occ = _word_occurrences(self._texts[idx], self._hl_word)
+ if occ:
+ base = _overlay_ranges(base, occ, _S_WORD)
if idx == self.cursor:
base = _cursor_decorate(base, self._texts[idx], self.cursor_x)
+ span = self._cursor_literal(idx) # the literal `o` would reformat
+ if span is not None: # (last: see ListingView)
+ base = _overlay_over(base, [span], _S_OPERAND)
code_w = max(width - gw, 0)
- code = base.crop(x, x + code_w).adjust_cell_length(code_w)
+ code = base.crop(x, x + code_w).adjust_cell_length(
+ code_w, _S_LINK if linked else None)
if gw <= 0:
return code
style = _S_LINENO_CUR if idx == self.cursor else _S_LINENO
+ if linked:
+ style = style + _S_LINK
gutter = Strip([Segment(f"{idx + 1:>{gw - 1}} ", style)])
return Strip.join([gutter, code]).adjust_cell_length(width)
- # -- navigation (mirrors DisasmView) ---------------------------------- #
+ # -- split-view link highlight ---------------------------------------- #
+ def set_link(self, line: int | None) -> None:
+ if line != self._link_line:
+ self._link_line = line
+ self.refresh()
+
+ def reveal(self, line: int) -> None:
+ """Scroll ``line`` into view without moving the cursor (companion pane)."""
+ height = self._visible_height()
+ top = round(self.scroll_offset.y)
+ if line < top or line >= top + height:
+ self.scroll_to(y=max(line - height // 3, 0), animate=False)
+
+ def align(self, line: int, screen_row: int) -> None:
+ """Scroll so ``line`` sits at viewport offset ``screen_row`` — keeps this
+ (companion) pane visually level with the driver's cursor in split view."""
+ top = max(0, min(line - max(screen_row, 0), max(len(self._strips) - 1, 0)))
+ if top != round(self.scroll_offset.y):
+ self.scroll_to(y=top, animate=False)
+
+ def watch_scroll_y(self, old_value: float, new_value: float) -> None:
+ super().watch_scroll_y(old_value, new_value)
+ if round(old_value) != round(new_value):
+ self.post_message(DecompView.Scrolled())
+
+ def action_op_format(self, mode: str = "cycle") -> None:
+ self.post_message(OpFormatRequested(self, mode))
+
+ def action_col_code_home(self) -> None:
+ """shift+home: first non-blank column — past the C indentation, the
+ pseudocode analogue of the listing's skip-the-address-gutter."""
+ text = self._line_plain(self.cursor) or ""
+ self.cursor_x = len(text) - len(text.lstrip()) if text.strip() else 0
+ self._hscroll()
+ _refresh_lines(self, self.cursor)
+ self._refresh_hl()
+
+ def line_for_ea(self, ea: int) -> int | None:
+ """The pseudocode line whose marker ea is the largest <= ``ea`` (the C
+ line that best covers an instruction address)."""
+ best, best_ea = None, -1
+ for i, e in enumerate(self._line_eas):
+ if e is not None and best_ea < e <= ea:
+ best, best_ea = i, e
+ return best
+
+ # -- navigation -------------------------------------------------------- #
def _visible_height(self) -> int:
return max(self.size.height, 1)
@@ -1060,6 +1608,7 @@ class DecompView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True
self.refresh()
else:
_refresh_lines(self, old, self.cursor)
+ self._refresh_hl()
self._after_cursor_move()
def action_cursor_down(self) -> None:
@@ -1078,9 +1627,9 @@ class DecompView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True
# --------------------------------------------------------------------------- #
# Hex view (raw bytes of the loaded image, VA-addressed, virtualized)
# --------------------------------------------------------------------------- #
-_S_HEX = Style(color="grey74")
-_S_ASCII = Style(color="#6a9955")
-_S_FOFF = Style(color="#c586c0") # file-offset column (distinct from the VA)
+_S_HEX = Style(color="#c3cad3")
+_S_ASCII = Style(color="#9ece6a")
+_S_FOFF = Style(color="#d8a657")
class HexView(ScrollView, can_focus=True):
@@ -1103,6 +1652,7 @@ class HexView(ScrollView, can_focus=True):
Binding("enter", "to_code", "To code"),
Binding("escape", "leave", "Back"),
Binding("tab,shift+tab", "app.toggle_view", "Code", priority=True),
+ Binding("f5", "app.toggle_view", "Decompile", priority=True),
]
cursor = reactive(0, repaint=False)
@@ -1124,6 +1674,11 @@ class HexView(ScrollView, can_focus=True):
super().__init__(id="hex")
self.model = None
self.total = 0
+ #: Trace to read memory from, and the timestamp to read it at. When set,
+ #: the dump shows what memory HELD then rather than what the file holds.
+ self.trace = None
+ self.trace_idx = 0
+ self._internal_top: int | None = None # scroll target we set ourselves
# -- public API -------------------------------------------------------- #
def load(self, model, va: int | None = None) -> None: # type: ignore[no-untyped-def]
@@ -1165,12 +1720,16 @@ class HexView(ScrollView, can_focus=True):
def _apply_scroll(self, y: int) -> None:
y = max(0, y)
- self.scroll_to(y=y, animate=False)
+ if round(self.scroll_offset.y) != y:
+ self._internal_top = y # cursor-driven scroll incoming; don't follow it
def _fix(yy: int = y) -> None:
+ if round(self.scroll_offset.y) != yy:
+ self._internal_top = yy
self.scroll_to(y=yy, animate=False)
self.refresh(layout=True)
+ self.scroll_to(y=y, animate=False)
self.call_after_refresh(_fix)
def _scroll_to_cursor(self, center: bool = False) -> None:
@@ -1187,6 +1746,28 @@ class HexView(ScrollView, can_focus=True):
top = max(0, min(top, max(self.total - 1, 0)))
self._apply_scroll(top)
+ def watch_scroll_y(self, old_value: float, new_value: float) -> None:
+ super().watch_scroll_y(old_value, new_value)
+ if round(old_value) == round(new_value):
+ return
+ nt = round(new_value)
+ if self._internal_top is not None and nt == self._internal_top:
+ self._internal_top = None # our cursor-driven scroll: cursor already placed
+ return
+ # A user scroll (wheel / scrollbar): drag the cursor by the same row delta
+ # so its screen position stays frozen (it points at a new byte in place).
+ self._internal_top = None
+ self._shift_cursor(nt - round(old_value))
+
+ def _shift_cursor(self, rows: int) -> None:
+ if not rows or self.model is None or self.model.size == 0:
+ return
+ new = max(0, min(self.cursor + rows * 16, self.model.size - 1))
+ if new != self.cursor:
+ self.cursor = new
+ self.refresh() # cursor is reactive(repaint=False)
+ self.post_message(HexView.Moved(self.cursor_va()))
+
# -- navigation -------------------------------------------------------- #
def _move(self, delta: int) -> None:
if self.model is None or self.model.size == 0:
@@ -1219,6 +1800,39 @@ class HexView(ScrollView, can_focus=True):
def action_leave(self) -> None:
self.post_message(HexView.Leave())
+ # -- mouse ------------------------------------------------------------- #
+ def _byte_at_x(self, x: int) -> int:
+ """Map a content column to a byte index 0..15, across the hex and ASCII
+ panes. Matches ``render_line``'s layout: addr(9) + file-offset(10) + 16
+ hex cells of 3 cols (with a 1-col gap before byte 8), then ' |' + ASCII."""
+ HEX, ASCII = 19, 70
+ if x < HEX: # clicked the address/offset gutter -> row start
+ return 0
+ if x < HEX + 49: # hex byte region
+ rel = x - HEX
+ if rel >= 24: # collapse the 1-col gap between the two halves
+ rel -= 1
+ return min(rel // 3, 15)
+ if x < ASCII: # the ' |' separator -> last byte of the row
+ return 15
+ return min(x - ASCII, 15) # ASCII pane (and anything past it)
+
+ def on_click(self, event) -> None: # type: ignore[no-untyped-def]
+ if self.model is None or self.model.size == 0:
+ return
+ off = event.get_content_offset(self)
+ if off is None:
+ return
+ self.focus()
+ row = round(self.scroll_offset.y) + off.y
+ x = round(self.scroll_offset.x) + off.x
+ new = row * 16 + self._byte_at_x(x)
+ self.cursor = max(0, min(self.model.size - 1, new))
+ self.refresh() # cursor is reactive(repaint=False); repaint the highlight
+ self.post_message(HexView.Moved(self.cursor_va()))
+ if event.chain >= 2: # double-click == place cursor + jump to code
+ self.post_message(HexView.ToCode(self.cursor_va()))
+
# -- rendering --------------------------------------------------------- #
def _ensure_window(self, top: int) -> None:
if self.model is None:
@@ -1250,6 +1864,15 @@ class HexView(ScrollView, can_focus=True):
return Strip([Segment("".ljust(width), _S_HEX)])
va, data = model.row(r)
cur_row, cur_col = self.cursor // 16, self.cursor % 16
+ # With a trace loaded the row shows what memory HELD at the current
+ # timestamp, not what the file contains. Only the bytes the trace
+ # actually saw are overlaid: the rest stay the database's, dimmed, so
+ # you can always tell evidence from the file's idea of the world.
+ tmem = tknown = None
+ if self.trace is not None and data is not None:
+ tmem, tknown = self.trace.memory(va, len(data), self.trace_idx)
+ if not any(tknown):
+ tmem = tknown = None
fo = model.file_offset(va)
fo_str = f"{fo:08X}" if fo is not None else "--------"
segs: list[Segment] = [
@@ -1264,15 +1887,29 @@ class HexView(ScrollView, can_focus=True):
if i == 8:
segs.append(Segment(" ", _S_HEX))
if i < n:
- st = _S_CELL if (r == cur_row and i == cur_col) else _S_HEX
- segs.append(Segment(f"{data[i]:02X} ", st))
+ live = tknown is not None and tknown[i]
+ val = tmem[i] if live else data[i]
+ if r == cur_row and i == cur_col:
+ st = _S_CELL
+ elif tknown is None:
+ st = _S_HEX
+ else:
+ st = _S_HEX_LIVE if live else _S_HEX_STALE
+ segs.append(Segment(f"{val:02X} ", st))
else:
segs.append(Segment(" ", _S_HEX))
segs.append(Segment(" |", _S_DIM))
for i in range(16):
if i < n:
- ch = chr(data[i]) if 32 <= data[i] < 127 else "."
- st = _S_CELL if (r == cur_row and i == cur_col) else _S_ASCII
+ live = tknown is not None and tknown[i]
+ val = tmem[i] if live else data[i]
+ ch = chr(val) if 32 <= val < 127 else "."
+ if r == cur_row and i == cur_col:
+ st = _S_CELL
+ elif tknown is None:
+ st = _S_ASCII
+ else:
+ st = _S_HEX_LIVE if live else _S_HEX_STALE
else:
ch, st = " ", _S_ASCII
segs.append(Segment(ch, st))
@@ -1281,6 +1918,822 @@ class HexView(ScrollView, can_focus=True):
# --------------------------------------------------------------------------- #
+# Graph view
+# --------------------------------------------------------------------------- #
+_S_GBORDER = Style(color="#4b5565") # box border, idle
+_S_GBORDER_CUR = Style(color="#7aa2f7", bold=True) # box border, cursor block
+_S_GLABEL = Style(color="#7aa2f7", bold=True) # loc_XXXX in the border
+_S_GLABEL_CUR = Style(color="#c0caf5", bold=True)
+_S_GDIM = Style(color="#5e6875")
+_S_GENTRY = Style(color="#9ece6a", bold=True) # the entry block's label
+#: Edge colours follow IDA's convention: green = branch taken, red = falls
+#: through, blue = the block's only successor, purple = loops back.
+_S_EDGE = {
+ graph.E_TRUE: Style(color="#5fbf5f"),
+ graph.E_FALSE: Style(color="#cf5f5f"),
+ graph.E_UNCOND: Style(color="#5f87d7"),
+ graph.E_SWITCH: Style(color="#c9a227"),
+ graph.E_BACK: Style(color="#a06fd0"),
+}
+#: Same hues, brightened: the edges touching the block you're on.
+_S_EDGE_HOT = {
+ graph.E_TRUE: Style(color="#8ff08f", bold=True),
+ graph.E_FALSE: Style(color="#ff8f8f", bold=True),
+ graph.E_UNCOND: Style(color="#9fc4ff", bold=True),
+ graph.E_SWITCH: Style(color="#ffd75f", bold=True),
+ graph.E_BACK: Style(color="#d0a0ff", bold=True),
+}
+_S_MINI_BG = Style(bgcolor="#161b22", color="#3b4453")
+_S_MINI_NODE = Style(bgcolor="#161b22", color="#5f7799")
+_S_MINI_CUR = Style(bgcolor="#161b22", color="#9ece6a", bold=True)
+_S_MINI_VIEW = Style(bgcolor="#233044", color="#c0caf5")
+_S_MINI_EDGE = Style(bgcolor="#161b22", color="#2f3945")
+
+_GPAD = 1 # columns of padding inside a box
+_MINI_W, _MINI_H = 30, 14
+
+
+class _CellRow:
+ """A row of (char, style) cells that coalesces into a Strip.
+
+ The graph is drawn per screen row from three independent sources -- edge
+ cells, boxes, then the minimap -- which overwrite each other. Composing into
+ a flat cell array and coalescing once at the end is both simpler and cheaper
+ than splicing Strips three times.
+ """
+
+ __slots__ = ("ch", "st", "width")
+
+ def __init__(self, width: int, base: Style) -> None:
+ self.width = max(width, 0)
+ self.ch = [" "] * self.width
+ self.st = [base] * self.width
+
+ def put(self, i: int, ch: str, style: Style) -> None:
+ if 0 <= i < self.width:
+ self.ch[i] = ch
+ self.st[i] = style
+
+ def text(self, i: int, s: str, style: Style) -> None:
+ for k, c in enumerate(s):
+ self.put(i + k, c, style)
+
+ def restyle(self, a: int, b: int, style: Style) -> None:
+ """Merge ``style`` over the cells in [a, b) (keeps the characters)."""
+ for i in range(max(a, 0), min(b, self.width)):
+ self.st[i] = self.st[i] + style
+
+ def strip(self) -> Strip:
+ segs: list[Segment] = []
+ if not self.width:
+ return Strip([])
+ run_start = 0
+ cur = self.st[0]
+ for i in range(1, self.width):
+ if self.st[i] is not cur and self.st[i] != cur:
+ segs.append(Segment("".join(self.ch[run_start:i]), cur))
+ run_start, cur = i, self.st[i]
+ segs.append(Segment("".join(self.ch[run_start:]), cur))
+ return Strip(segs)
+
+
+class GraphView(NavMixin, ScrollView, can_focus=True):
+ """IDA-style control-flow graph of one function, in character cells.
+
+ The layout comes from ``idatui.graph`` (pure, offline-testable); this class
+ is only presentation, navigation and hit-testing. Nothing is pre-painted: a
+ big function is millions of cells, so each screen row is composed on demand
+ from the edge index plus whichever boxes cover that row -- the same
+ discipline as ``ListingView.render_line``.
+
+ Box contents are the SAME ``Head`` rows the listing renders, so IDA's own
+ colour tags, names and operand text come along for free instead of this
+ growing a second disassembler renderer.
+ """
+
+ BINDINGS = [
+ Binding("j,down", "cursor_down", "Down", show=False),
+ Binding("k,up", "cursor_up", "Up", show=False),
+ Binding("h,left", "cursor_left", "Left", show=False),
+ Binding("l,right", "cursor_right", "Right", show=False),
+ Binding("J", "succ_block", "Next block", show=False),
+ Binding("K", "pred_block", "Prev block", show=False),
+ Binding("w", "next_block", "Block →", show=False),
+ Binding("b", "prev_block", "Block ←", show=False),
+ Binding("0", "goto_entry", "Entry", show=False),
+ Binding("z", "zoom", "Zoom"),
+ Binding("m", "minimap", "Minimap", show=False),
+ Binding("f", "center", "Centre", show=False),
+ Binding("ctrl+d", "pan(12)", "½↓", show=False),
+ Binding("ctrl+u", "pan(-12)", "½↑", show=False),
+ Binding("pagedown", "pan(24)", "PgDn", show=False),
+ Binding("pageup", "pan(-24)", "PgUp", show=False),
+ Binding("home", "col_home", "bol", show=False),
+ Binding("end", "col_end", "eol", show=False),
+ *NavMixin.NAV_BINDINGS,
+ ]
+
+ cursor_node = reactive(-1, repaint=False)
+ cursor_row = reactive(0, repaint=False)
+ cursor_x = reactive(0, repaint=False)
+
+ ZOOMS = ("full", "compact", "collapsed")
+
+ class CursorMoved(Message):
+ """Posted when the graph cursor lands on a new address."""
+
+ def __init__(self, ea: int | None, block: int) -> None:
+ super().__init__()
+ self.ea = ea
+ self.block = block
+
+ def __init__(self) -> None:
+ super().__init__()
+ self.fc = None # domain.Flowchart
+ self.lay: graph.Layout | None = None
+ self.loaded_ea: int | None = None
+ self._blocks: dict[int, object] = {}
+ self._zoom = 0
+ self._show_minimap = True
+ self._mini_cache: tuple | None = None
+ self._drag: tuple[int, int, float, float] | None = None
+ self._drag_map = False # the drag started on the minimap
+ self._hl_word = ""
+ self.trail: dict[int, str] | None = None
+
+ # -- content ---------------------------------------------------------- #
+ def set_graph(self, fc, ea: int | None = None) -> None:
+ """Install a flowchart and lay it out at the current zoom."""
+ self.fc = fc
+ self._blocks = {b.id: b for b in fc.blocks} if fc else {}
+ self.loaded_ea = fc.func_ea if fc else None
+ self._relayout()
+ if fc:
+ blk = fc.block_at(ea) if ea is not None else None
+ self.cursor_node = blk.id if blk else fc.entry
+ self.cursor_row = 0
+ if blk is not None and ea is not None:
+ rows = self._rows(blk.id)
+ for i, h in enumerate(rows):
+ if h is not None and h.ea == ea:
+ self.cursor_row = i
+ break
+ self.cursor_x = 0
+ self._center_cursor()
+ self.refresh(layout=True)
+
+ def _relayout(self) -> None:
+ self._mini_cache = None
+ if not self.fc or not self.fc.blocks:
+ self.lay = None
+ self.virtual_size = Size(0, 0)
+ return
+ blocks = [graph.Block(id=b.id, start=b.start, end=b.end,
+ succs=list(b.succs)) for b in self.fc.blocks]
+ self.lay = graph.layout(blocks, self._sizer, entry=self.fc.entry)
+ self.virtual_size = Size(self.lay.width + 2, self.lay.height + 1)
+
+ def _rows(self, nid: int):
+ """The Head rows shown inside block ``nid`` at the current zoom."""
+ b = self._blocks.get(nid)
+ if b is None:
+ return []
+ if self._zoom == 2:
+ return [None] # one synthetic summary row
+ return b.rows
+
+ def _row_plain(self, nid: int, i: int) -> str:
+ b = self._blocks.get(nid)
+ if b is None:
+ return ""
+ if self._zoom == 2:
+ n = len(b.rows)
+ return f"{n} instruction{'s' if n != 1 else ''}"
+ rows = b.rows
+ if not (0 <= i < len(rows)):
+ return ""
+ h = rows[i]
+ if self._zoom == 0:
+ return f"{h.ea:08X} {self._head_text(h)}"
+ return self._head_text(h)
+
+ @staticmethod
+ def _head_text(h) -> str:
+ return (f"{h.name} {h.text}" if h.name else h.text)
+
+ def _sizer(self, b: graph.Block) -> tuple[int, int]:
+ nid = b.id
+ rows = self._rows(nid)
+ n = max(len(rows), 1)
+ label = f"loc_{b.start:X}"
+ widest = max([len(label) + 4]
+ + [len(self._row_plain(nid, i)) for i in range(n)])
+ return (widest + 2 * _GPAD + 2, n + 2)
+
+ # -- geometry --------------------------------------------------------- #
+ def _cur_node(self) -> graph.Node | None:
+ if self.lay is None:
+ return None
+ return self.lay.by_id.get(self.cursor_node)
+
+ def cur_head(self):
+ rows = self._rows(self.cursor_node)
+ if self._zoom == 2 or not rows:
+ b = self._blocks.get(self.cursor_node)
+ return b.rows[0] if (b and b.rows) else None
+ i = max(0, min(self.cursor_row, len(rows) - 1))
+ return rows[i]
+
+ def _cursor_ea(self) -> int | None:
+ h = self.cur_head()
+ return h.ea if h is not None else None
+
+ def _next_ea(self) -> int | None:
+ """The address after the cursor's instruction -- what ``follow`` uses to
+ skip the fall-through edge and land on a real branch target."""
+ b = self._blocks.get(self.cursor_node)
+ if b is None:
+ return None
+ rows = b.rows
+ i = max(0, min(self.cursor_row, len(rows) - 1)) if rows else 0
+ if rows and i + 1 < len(rows):
+ return rows[i + 1].ea
+ return b.end
+
+ def _line_plain(self, _idx=None) -> str:
+ return self._row_plain(self.cursor_node, self.cursor_row)
+
+ def word_under_cursor(self) -> str:
+ plain = self._line_plain()
+ if not plain:
+ return ""
+ a, b = _word_bounds(plain, self.cursor_x)
+ return plain[a:b] if b > a else ""
+
+ def set_highlight(self, word: str) -> None:
+ if word != self._hl_word:
+ self._hl_word = word
+ self.refresh()
+
+ def set_trail(self, trail: dict[int, str] | None) -> None:
+ self.trail = trail
+ self.refresh()
+
+ # -- cursor motion ---------------------------------------------------- #
+ def _clamp_cursor(self) -> None:
+ if self.lay is None or not self.lay.nodes:
+ return
+ if self.cursor_node not in self.lay.by_id:
+ self.cursor_node = self.lay.nodes[0].id
+ rows = self._rows(self.cursor_node)
+ self.cursor_row = max(0, min(self.cursor_row, max(len(rows) - 1, 0)))
+ plain = self._line_plain()
+ self.cursor_x = max(0, min(self.cursor_x, max(len(plain) - 1, 0)))
+
+ def _order(self) -> list[int]:
+ return [n.id for n in self.lay.nodes] if self.lay else []
+
+ def _moved(self) -> None:
+ self._clamp_cursor()
+ self._scroll_to_cursor()
+ self.refresh()
+ self.post_message(self.CursorMoved(self._cursor_ea(), self.cursor_node))
+
+ def action_cursor_down(self) -> None:
+ rows = self._rows(self.cursor_node)
+ if self.cursor_row + 1 < len(rows):
+ self.cursor_row += 1
+ else:
+ order = self._order()
+ if self.cursor_node in order:
+ i = order.index(self.cursor_node)
+ if i + 1 < len(order):
+ self.cursor_node = order[i + 1]
+ self.cursor_row = 0
+ self._moved()
+
+ def action_cursor_up(self) -> None:
+ if self.cursor_row > 0:
+ self.cursor_row -= 1
+ else:
+ order = self._order()
+ if self.cursor_node in order:
+ i = order.index(self.cursor_node)
+ if i > 0:
+ self.cursor_node = order[i - 1]
+ self.cursor_row = max(len(self._rows(self.cursor_node)) - 1, 0)
+ self._moved()
+
+ def action_cursor_left(self) -> None:
+ self.cursor_x = max(0, self.cursor_x - 1)
+ self._moved()
+
+ def action_cursor_right(self) -> None:
+ self.cursor_x = min(len(self._line_plain()), self.cursor_x + 1)
+ self._moved()
+
+ def action_col_home(self) -> None:
+ self.cursor_x = 0
+ self._moved()
+
+ def action_col_end(self) -> None:
+ self.cursor_x = max(len(self._line_plain()) - 1, 0)
+ self._moved()
+
+ def _hop(self, table: dict) -> None:
+ tgt = table.get(self.cursor_node) or []
+ if not tgt:
+ self.app._status("no edge that way")
+ return
+ self.cursor_node = tgt[0][0]
+ self.cursor_row = 0
+ self._moved()
+
+ def action_succ_block(self) -> None:
+ if self.lay:
+ self._hop(self.lay.succ)
+
+ def action_pred_block(self) -> None:
+ if self.lay:
+ self._hop(self.lay.pred)
+
+ def _step_order(self, delta: int) -> None:
+ order = self._order()
+ if not order:
+ return
+ i = order.index(self.cursor_node) if self.cursor_node in order else 0
+ self.cursor_node = order[max(0, min(len(order) - 1, i + delta))]
+ self.cursor_row = 0
+ self._moved()
+
+ def action_next_block(self) -> None:
+ self._step_order(1)
+
+ def action_prev_block(self) -> None:
+ self._step_order(-1)
+
+ def action_goto_entry(self) -> None:
+ if self.fc:
+ self.cursor_node = self.fc.entry
+ self.cursor_row = 0
+ self._moved()
+ self._center_cursor()
+
+ def action_pan(self, rows: int) -> None:
+ self.scroll_to(y=max(0, self.scroll_offset.y + rows), animate=False)
+ self._snap_into_view()
+
+ def _viewport_has_block(self) -> bool:
+ if self.lay is None:
+ return False
+ y0 = int(self.scroll_offset.y)
+ x0 = int(self.scroll_offset.x)
+ y1, x1 = y0 + self.size.height, x0 + self.size.width
+ return any(n.y <= y1 and y0 <= n.bottom and n.x <= x1 and x0 <= n.right
+ for n in self.lay.nodes)
+
+ def _snap_into_view(self) -> None:
+ """After a pan, if the viewport holds no block at all, ease to the
+ nearest one.
+
+ Blocks cover a few percent of a laid-out graph -- 4.6% on an 87-block
+ function, under 1% on a 424-block one -- the rest being the padding that
+ keeps edges apart. Panning therefore lands in empty space more often
+ than not, and an empty screen gives you nothing to navigate back by.
+ Only fires when nothing is visible, so it never fights a deliberate pan.
+ """
+ if self.lay is None or self._viewport_has_block():
+ return
+ cy = self.scroll_offset.y + self.size.height / 2
+ cx = self.scroll_offset.x + self.size.width / 2
+ n = self._nearest_node(cy, cx)
+ if n is not None:
+ self._center_on(n, defer=False)
+ self.refresh()
+
+ def action_zoom(self) -> None:
+ self._zoom = (self._zoom + 1) % len(self.ZOOMS)
+ self._relayout()
+ self._clamp_cursor()
+ self._center_cursor()
+ self.refresh(layout=True)
+ self.app._graph_status() # keeps the function name; names the zoom
+
+ def action_minimap(self) -> None:
+ self._show_minimap = not self._show_minimap
+ self.refresh()
+ self.app._status(f"graph: minimap {'on' if self._show_minimap else 'off'}")
+
+ def action_center(self) -> None:
+ self._center_cursor()
+ self.refresh()
+
+ def action_copy_line(self) -> None:
+ plain = self._line_plain()
+ if not plain:
+ return
+ n = self.app._copy(plain)
+ self.app._status(f"copied line ({n} chars) to clipboard")
+
+ def goto_ea(self, ea: int) -> bool:
+ """Put the cursor on ``ea`` if it lives in this graph."""
+ if not self.fc:
+ return False
+ b = self.fc.block_at(ea)
+ if b is None:
+ return False
+ self.cursor_node = b.id
+ self.cursor_row = 0
+ for i, h in enumerate(self._rows(b.id)):
+ if h is not None and h.ea == ea:
+ self.cursor_row = i
+ break
+ self.cursor_x = 0
+ self._clamp_cursor()
+ self._center_cursor()
+ self.refresh()
+ return True
+
+ # -- scrolling -------------------------------------------------------- #
+ def _cursor_cell(self) -> tuple[int, int] | None:
+ n = self._cur_node()
+ if n is None:
+ return None
+ row = n.y + 1 + max(0, min(self.cursor_row, n.h - 3))
+ col = n.x + 1 + _GPAD + self.cursor_x
+ return (row, col)
+
+ def _scroll_to_cursor(self) -> None:
+ cell = self._cursor_cell()
+ if cell is None:
+ return
+ row, col = cell
+ w, h = self.size.width, self.size.height
+ if w <= 0 or h <= 0:
+ return
+ y, x = self.scroll_offset.y, self.scroll_offset.x
+ if row < y:
+ y = row
+ elif row >= y + h:
+ y = row - h + 1
+ if col < x + 2:
+ x = max(0, col - 2)
+ elif col >= x + w - 2:
+ x = col - w + 3
+ if (y, x) != (self.scroll_offset.y, self.scroll_offset.x):
+ self.scroll_to(y=max(0, y), x=max(0, x), animate=False)
+
+ def _center_on(self, n: graph.Node, defer: bool = True) -> None:
+ """Bring block ``n`` into the middle of the viewport.
+
+ ``defer=False`` during a drag: layout is already valid then, and
+ queueing a callback per mouse-move makes the scrub lag behind.
+ """
+ if n is None or self.size.width <= 0:
+ return
+ y = max(0, n.y - max(self.size.height // 2 - n.h // 2, 0))
+ x = max(0, int(n.cx) - self.size.width // 2)
+ self.scroll_to(y=y, x=x, animate=False)
+ if not defer:
+ return
+ # Setting virtual_size then scrolling immediately clamps to 0 (max_scroll
+ # isn't recomputed until layout), so apply it again after the refresh.
+ def _again() -> None:
+ self.scroll_to(y=y, x=x, animate=False)
+ self.call_after_refresh(_again)
+
+ def _center_cursor(self) -> None:
+ n = self._cur_node()
+ if n is not None:
+ self._center_on(n)
+
+ # -- minimap hit-testing ----------------------------------------------- #
+ def _minimap_rect(self) -> tuple[int, int, int, int] | None:
+ """(left, top, w, h) of the minimap in CONTENT coordinates, or None.
+
+ The minimap is pinned to the viewport, not the canvas, so these are
+ screen-relative and the scroll offset must NOT be added. Must agree with
+ _draw_minimap_row, which is why both take the inset from here.
+ """
+ if not self._show_minimap or self.lay is None:
+ return None
+ w, h = self.size.width, self.size.height
+ if w < _MINI_W + 10 or h < _MINI_H + 2:
+ return None
+ return (w - _MINI_W - 2, 0, _MINI_W, _MINI_H)
+
+ def _nearest_node(self, row: float, col: float) -> graph.Node | None:
+ """The block nearest a canvas point (distance 0 if the point is inside).
+
+ Cells are about twice as tall as they are wide, so the column distance
+ is halved -- otherwise "nearest" means nearest in cells, which does not
+ look nearest on screen.
+ """
+ if self.lay is None:
+ return None
+ best, best_d = None, None
+ for n in self.lay.nodes:
+ dx = 0.0 if n.x <= col <= n.right else min(abs(col - n.x),
+ abs(col - n.right))
+ dy = 0.0 if n.y <= row <= n.bottom else min(abs(row - n.y),
+ abs(row - n.bottom))
+ d = (dx * 0.5) ** 2 + dy ** 2
+ if best_d is None or d < best_d:
+ best, best_d = n, d
+ return best
+
+ def _minimap_seek(self, x: int, y: int, defer: bool = True) -> bool:
+ """Treat (x, y) as a point on the minimap and go to the block there.
+
+ Deliberately snaps to the NEAREST BLOCK rather than scrolling to the raw
+ coordinate. Most of a laid-out graph is the padding that keeps edges
+ apart, so a coordinate-accurate jump usually parks the viewport in empty
+ space -- and the cursor, which only moved when the point landed exactly
+ on a block, stayed behind. Snapping means every click lands on something
+ and the keyboard carries on from there.
+
+ Returns False if the point isn't on the minimap, so the caller can fall
+ through to ordinary canvas hit-testing.
+ """
+ rect = self._minimap_rect()
+ if rect is None or self.lay is None:
+ return False
+ left, top, _w, _h = rect
+ gw, gh = _MINI_W - 2, _MINI_H - 2
+ c, r = x - left - 1, y - top - 1 # inside the border
+ if not (0 <= c < gw and 0 <= r < gh):
+ return False
+ lay = self.lay
+ sx = max(lay.width / gw, 1e-9)
+ sy = max(lay.height / gh, 1e-9)
+ cx, cy = (c + 0.5) * sx, (r + 0.5) * sy # centre of that mini-cell
+ n = self._nearest_node(cy, cx)
+ if n is None:
+ self.scroll_to(x=max(0, int(cx - self.size.width / 2)),
+ y=max(0, int(cy - self.size.height / 2)),
+ animate=False)
+ return True
+ if n.id == self.cursor_node:
+ return True # already there; don't churn while dragging
+ self.cursor_node = n.id
+ self.cursor_row = 0
+ self.cursor_x = 0
+ self._clamp_cursor()
+ self._center_on(n, defer=defer)
+ self.refresh()
+ self.post_message(self.CursorMoved(self._cursor_ea(), self.cursor_node))
+ return True
+
+ # -- mouse ------------------------------------------------------------- #
+ def on_mouse_down(self, event) -> None: # type: ignore[no-untyped-def]
+ off = event.get_content_offset(self)
+ if off is None:
+ return
+ if self._minimap_seek(off.x, off.y):
+ self._drag = None
+ self._drag_map = True # keep scrubbing while the button is held
+ return
+ self._drag_map = False
+ self._drag = (off.x, off.y, self.scroll_offset.x, self.scroll_offset.y)
+
+ def on_mouse_up(self, event) -> None: # type: ignore[no-untyped-def]
+ was_pan = self._drag is not None and not self._drag_map
+ self._drag = None
+ self._drag_map = False
+ if was_pan:
+ self._snap_into_view() # don't leave them adrift in the padding
+
+ def on_mouse_move(self, event) -> None: # type: ignore[no-untyped-def]
+ if not event.button:
+ return
+ off = event.get_content_offset(self)
+ if off is None:
+ return
+ if self._drag_map:
+ # drag = scrub block to block through the overview
+ self._minimap_seek(off.x, off.y, defer=False)
+ return
+ if self._drag is None:
+ return
+ x0, y0, sx, sy = self._drag
+ self.scroll_to(x=max(0, sx + (x0 - off.x)), y=max(0, sy + (y0 - off.y)),
+ animate=False)
+
+ def on_click(self, event) -> None: # type: ignore[no-untyped-def]
+ if self.lay is None:
+ return
+ off = event.get_content_offset(self)
+ if off is None:
+ return
+ # The minimap floats over the canvas, so it has to be tested FIRST --
+ # otherwise a click on it is read as canvas coordinates and drops the
+ # cursor into whatever block happens to lie underneath.
+ if self._minimap_seek(off.x, off.y):
+ self.focus()
+ return
+ row = off.y + int(self.scroll_offset.y)
+ col = off.x + int(self.scroll_offset.x)
+ n = self.lay.node_at(row, col)
+ if n is None or n.block is None:
+ return
+ self.focus()
+ self.cursor_node = n.id
+ self.cursor_row = max(0, min(row - n.y - 1,
+ max(len(self._rows(n.id)) - 1, 0)))
+ self.cursor_x = max(0, col - n.x - 1 - _GPAD)
+ self._clamp_cursor()
+ self.refresh()
+ self.post_message(self.CursorMoved(self._cursor_ea(), self.cursor_node))
+ if getattr(event, "chain", 1) >= 2:
+ self.post_message(FollowRequested(self))
+
+ # -- rendering -------------------------------------------------------- #
+ def _edge_styles(self) -> tuple[dict, set]:
+ hot = self.lay.incident.get(self.cursor_node, set()) if self.lay else set()
+ return (_S_EDGE, hot)
+
+ def render_line(self, y: int) -> Strip:
+ width = self.size.width
+ if self.lay is None or not self.lay.nodes:
+ return Strip([Segment("".ljust(width), _S_GDIM)])
+ row = int(self.scroll_offset.y) + y
+ col0 = int(self.scroll_offset.x)
+ out = _CellRow(width, _S_INSN)
+ base, hot = self._edge_styles()
+
+ # 1. edge cells (an index query, never a painted canvas)
+ for col, (ch, kind, eid) in self.lay.painting.cells_at_row(
+ row, col0, col0 + width).items():
+ st = (_S_EDGE_HOT if eid in hot else base).get(kind, _S_GDIM)
+ out.put(col - col0, ch, st)
+
+ # 2. boxes covering this row (they win over edges: nothing routes inside)
+ for n in self.lay.nodes_at_row(row):
+ self._draw_node_row(out, n, row, col0)
+
+ # 3. minimap, last, over everything
+ if self._show_minimap:
+ self._draw_minimap_row(out, y, width)
+ return out.strip().adjust_cell_length(width, _S_INSN)
+
+ def _draw_node_row(self, out: _CellRow, n: graph.Node, row: int,
+ col0: int) -> None:
+ cur = n.id == self.cursor_node
+ bs = _S_GBORDER_CUR if cur else _S_GBORDER
+ left = n.x - col0
+ w = n.w
+ b = self._blocks.get(n.id)
+ if row == n.y:
+ # top border carries the label: ┌─ loc_1234 ─────┐
+ label = f"loc_{n.block.start:X}" if n.block else ""
+ if b is not None and b.rows and b.rows[0].name:
+ label = b.rows[0].name
+ out.put(left, graph.BOX["tl"], bs)
+ for i in range(1, w - 1):
+ out.put(left + i, graph.BOX["h"], bs)
+ out.put(left + w - 1, graph.BOX["tr"], bs)
+ tag = f" {label} "
+ if len(tag) <= w - 4:
+ st = _S_GENTRY if (self.fc and n.id == self.fc.entry) else (
+ _S_GLABEL_CUR if cur else _S_GLABEL)
+ out.text(left + 2, tag, st)
+ if n.block is not None and n.block.selfloop:
+ out.put(left + w - 2, "↺", _S_EDGE[graph.E_BACK])
+ return
+ if row == n.y + n.h - 1:
+ out.put(left, graph.BOX["bl"], bs)
+ for i in range(1, w - 1):
+ out.put(left + i, graph.BOX["h"], bs)
+ out.put(left + w - 1, graph.BOX["br"], bs)
+ return
+ out.put(left, graph.BOX["v"], bs)
+ out.put(left + w - 1, graph.BOX["v"], bs)
+ for i in range(1, w - 1):
+ out.put(left + i, " ", _S_INSN)
+ i = row - n.y - 1
+ rows = self._rows(n.id)
+ if not (0 <= i < len(rows)):
+ return
+ text_col = left + 1 + _GPAD
+ h = rows[i]
+ plain = self._row_plain(n.id, i)
+ if h is None: # collapsed summary
+ out.text(text_col, plain, _S_GDIM)
+ else:
+ c = text_col
+ if self._zoom == 0:
+ out.text(c, f"{h.ea:08X} ", _S_ADDR)
+ c += 10
+ if h.name:
+ out.text(c, f"{h.name} ", _S_LABEL)
+ c += len(h.name) + 2
+ fallback = {"code": _S_INSN, "data": _S_DATA}.get(h.kind, _S_UNK)
+ if h.spans:
+ for kind, t in h.spans:
+ out.text(c, t, _S_SPAN.get(kind, fallback))
+ c += len(t)
+ else:
+ out.text(c, h.text, fallback)
+ inner_a, inner_b = left + 1, left + w - 1
+ # execution trail (a loaded Tenet trace), same palette as the listing
+ if self.trail is not None and h is not None:
+ k = self.trail.get(h.ea)
+ if k is not None:
+ out.restyle(inner_a, inner_b,
+ _S_TRAIL_NOW if k == "now" else
+ _S_TRAIL_PAST if k == "past" else _S_TRAIL_FUTURE)
+ if self._hl_word and plain:
+ for a, bb in _word_occurrences(plain, self._hl_word):
+ out.restyle(text_col + a, text_col + bb, _S_WORD)
+ if cur and i == self.cursor_row:
+ out.restyle(inner_a, inner_b, _S_CURSOR)
+ x = max(0, min(self.cursor_x, max(len(plain) - 1, 0)))
+ wa, wb = _word_bounds(plain, x)
+ if wb > wa:
+ out.restyle(text_col + wa, text_col + wb, _S_WORD)
+ if self.has_focus:
+ out.restyle(text_col + x, text_col + x + 1, _S_CELL)
+
+ # -- minimap ---------------------------------------------------------- #
+ def _minimap(self) -> list[list[int]]:
+ """A coarse occupancy grid of the whole graph: 0 empty, 1 edge, 2 block,
+ 3 the cursor's block. Cached per (layout, zoom, cursor block)."""
+ key = (id(self.lay), self._zoom, self.cursor_node)
+ if self._mini_cache and self._mini_cache[0] == key:
+ return self._mini_cache[1]
+ gw, gh = _MINI_W - 2, _MINI_H - 2
+ grid = [[0] * gw for _ in range(gh)]
+ lay = self.lay
+ if lay is not None and lay.width and lay.height:
+ sx = max(lay.width / gw, 1e-9)
+ sy = max(lay.height / gh, 1e-9)
+ for lo, hi, col, _kind, _eid in lay.painting.vruns:
+ c = min(int(col / sx), gw - 1)
+ for r in range(min(int(lo / sy), gh - 1),
+ min(int(hi / sy), gh - 1) + 1):
+ if not grid[r][c]:
+ grid[r][c] = 1
+ for n in lay.nodes:
+ mark = 3 if n.id == self.cursor_node else 2
+ r0, r1 = int(n.y / sy), int((n.y + n.h - 1) / sy)
+ c0, c1 = int(n.x / sx), int(n.right / sx)
+ for r in range(max(r0, 0), min(r1, gh - 1) + 1):
+ for c in range(max(c0, 0), min(c1, gw - 1) + 1):
+ if grid[r][c] < mark:
+ grid[r][c] = mark
+ self._mini_cache = (key, grid)
+ return grid
+
+ def _draw_minimap_row(self, out: _CellRow, y: int, width: int) -> None:
+ # Inset by two: a ScrollView paints its vertical scrollbar over the last
+ # column, which otherwise eats the minimap's right border.
+ if self._minimap_rect() is None or not (0 <= y < _MINI_H):
+ return
+ left = self._minimap_rect()[0] # one source of truth with the hit-test
+ grid = self._minimap()
+ gw, gh = _MINI_W - 2, _MINI_H - 2
+ lay = self.lay
+ sx = max(lay.width / gw, 1e-9)
+ sy = max(lay.height / gh, 1e-9)
+ vy0 = int(self.scroll_offset.y / sy)
+ vy1 = int((self.scroll_offset.y + self.size.height) / sy)
+ vx0 = int(self.scroll_offset.x / sx)
+ vx1 = int((self.scroll_offset.x + width) / sx)
+
+ if y == 0:
+ out.put(left, graph.BOX["tl"], _S_MINI_BG)
+ for i in range(1, _MINI_W - 1):
+ out.put(left + i, graph.BOX["h"], _S_MINI_BG)
+ out.put(left + _MINI_W - 1, graph.BOX["tr"], _S_MINI_BG)
+ tag = f" {len(lay.nodes)} blocks "
+ out.text(left + 2, tag, _S_MINI_BG)
+ return
+ if y == _MINI_H - 1:
+ out.put(left, graph.BOX["bl"], _S_MINI_BG)
+ for i in range(1, _MINI_W - 1):
+ out.put(left + i, graph.BOX["h"], _S_MINI_BG)
+ out.put(left + _MINI_W - 1, graph.BOX["br"], _S_MINI_BG)
+ return
+ r = y - 1
+ out.put(left, graph.BOX["v"], _S_MINI_BG)
+ out.put(left + _MINI_W - 1, graph.BOX["v"], _S_MINI_BG)
+ for c in range(gw):
+ v = grid[r][c] if r < len(grid) else 0
+ inview = vy0 <= r <= vy1 and vx0 <= c <= vx1
+ if v == 3:
+ ch, st = "█", _S_MINI_CUR
+ elif v == 2:
+ ch, st = "█", _S_MINI_NODE
+ elif v == 1:
+ ch, st = "·", _S_MINI_EDGE
+ else:
+ ch, st = " ", _S_MINI_BG
+ if inview and v != 3:
+ st = _S_MINI_VIEW if v == 0 else st + Style(bgcolor="#233044")
+ out.put(left + 1 + c, ch, st)
+
+
+# --------------------------------------------------------------------------- #
# Function list panel
# --------------------------------------------------------------------------- #
class FunctionsPanel(Vertical):
@@ -1303,8 +2756,10 @@ class XrefsScreen(ModalScreen):
BINDINGS = [Binding("escape", "close", "Close")]
- def __init__(self, label: str, items: list[tuple[int, str]],
+ def __init__(self, label: str, items: list[tuple[object, str]],
preselect: int = 0) -> None:
+ # payload is an int address, or (binary, address) for a caller in another
+ # project binary; dismiss() hands it back untouched.
super().__init__()
self._label = label
self._items = items
@@ -1339,10 +2794,18 @@ def _fuzzy(name: str, q: str):
matched character indices (for highlighting)."""
if not q:
return (0.0, ())
+ if not name: # defensive: never assume a symbol has a name
+ return None
nl = name.lower()
+ # Case-insensitive means BOTH sides: the name was lowered but the query
+ # wasn't, so a single capital could never match and any query containing one
+ # returned nothing at all. Invisible on lowercase C symbols (main, strlen),
+ # fatal on libraries that capitalise — "PEM_read_bio" found 0 of 10093
+ # functions in libcrypto while the backend resolved it fine.
+ ql = q.lower()
pos: list[int] = []
i = 0
- for ch in q:
+ for ch in ql:
j = nl.find(ch, i)
if j < 0:
return None
@@ -1350,9 +2813,9 @@ def _fuzzy(name: str, q: str):
i = j + 1
span = pos[-1] - pos[0]
score = -(span * 2.0) - pos[0] - len(name) * 0.01
- if q in nl:
+ if ql in nl:
score += 50.0
- if nl.startswith(q):
+ if nl.startswith(ql):
score += 100.0
return (score, tuple(pos))
@@ -1364,17 +2827,28 @@ class SymbolPalette(ModalScreen):
Binding("escape", "close", "Close"),
Binding("down,ctrl+n", "cursor_down", show=False),
Binding("up,ctrl+p", "cursor_up", show=False),
+ # F2, not ctrl+a: the focused Input binds "home,ctrl+a" so it would never
+ # reach us. Function keys are untouched by Input.
+ Binding("f2", "scope", "This binary / whole project", show=False),
]
LIMIT = 200
+ #: Project scope nearly always saturates its cap (every binary contributes),
+ #: where a local filter usually returns a handful. Keep the list short so
+ #: arrowing through it stays snappy; narrow further by typing.
+ PROJECT_LIMIT = 60
- def __init__(self, funcs: list[Func]) -> None:
+ def __init__(self, funcs: list[Func], index=None, binary=None) -> None:
super().__init__()
self._funcs = funcs
- self._results: list[Func] = []
+ self._index = index # ProjectIndex, when this is a project
+ self._binary = binary # label of the binary we're currently in
+ self._project_scope = False
+ #: (binary|None, addr, name) — binary is None for a local hit
+ self._results: list[tuple] = []
def compose(self) -> ComposeResult:
with Vertical(id="pal-box"):
- yield Static(" symbols", id="pal-title")
+ yield Static(" symbols", id="pal-title", markup=False)
yield Input(placeholder="fuzzy find symbol… ↑↓ select · Enter open · Esc close",
id="pal-input")
yield OptionList(id="pal-list")
@@ -1391,35 +2865,735 @@ class SymbolPalette(ModalScreen):
event.stop()
self.action_choose()
+ def action_scope(self) -> None:
+ if self._index is None:
+ return # not a project: nothing else to search
+ self._project_scope = not self._project_scope
+ self._apply(self.query_one("#pal-input", Input).value.strip())
+
def _apply(self, query: str) -> None:
- if query:
+ # rows: (binary|None, addr, name, match positions)
+ if self._project_scope and self._index is not None:
+ from .index import KIND_FUNC
+ # The trigram index already guarantees every hit CONTAINS the query,
+ # so ranking only has to order them — an exact-substring rank (match
+ # position, then name length) costs a find() per row instead of a
+ # full fuzzy pass, and fetching 3x the display limit rather than 10x
+ # keeps the per-keystroke work down on a big project.
+ hits = self._index.search(query, kind=KIND_FUNC,
+ limit=self.PROJECT_LIMIT * 3) if query else []
+ q = query.lower()
+ scored = []
+ for h in hits:
+ at = h.text.lower().find(q)
+ scored.append((at if at >= 0 else 1 << 30, len(h.text), h.text, h))
+ # Sort on an explicit key: the same symbol name in two binaries ties
+ # on (position, length, text), and a bare sort() would then fall
+ # through to comparing Hit objects, which aren't orderable.
+ scored.sort(key=lambda t: (t[0], t[1], t[2], t[3].binary, t[3].addr))
+ rows = [(h.binary, h.addr, h.text,
+ tuple(range(at, at + len(q))) if at < (1 << 30) else ())
+ for at, _, _, h in scored[:self.PROJECT_LIMIT]]
+ elif query:
scored = []
for f in self._funcs:
m = _fuzzy(f.name, query)
if m is not None:
scored.append((m[0], m[1], f))
scored.sort(key=lambda t: (-t[0], t[2].name))
- rows = [(f, pos) for _, pos, f in scored[:self.LIMIT]]
+ rows = [(None, f.addr, f.name, pos) for _, pos, f in scored[:self.LIMIT]]
else:
- rows = [(f, ()) for f in self._funcs[:self.LIMIT]]
- self._results = [f for f, _ in rows]
+ rows = [(None, f.addr, f.name, ()) for f in self._funcs[:self.LIMIT]]
+ self._results = [(b, a, n) for b, a, n, _ in rows]
ol = self.query_one(OptionList)
ol.clear_options()
opts = []
- for f, pos in rows:
+ for binary, addr, name, pos in rows:
label = Text()
- label.append(f"{f.addr:08X} ", _S_ADDR)
- nm = Text(f.name)
+ if binary:
+ label.append(f"{binary:<14.14} ", _S_LABEL)
+ label.append(f"{addr:08X} ", _S_ADDR)
+ nm = Text(name)
for p in pos:
- if p < len(f.name):
+ if p < len(name):
nm.stylize(_S_NAME_MATCH, p, p + 1)
label.append_text(nm)
opts.append(Option(label))
ol.add_options(opts)
if self._results:
ol.highlighted = 0
+ scope = "project" if self._project_scope else "this binary"
+ cap = self.PROJECT_LIMIT if self._project_scope else self.LIMIT
+ more = "+" if len(self._results) == cap else ""
+ hint = " (F2: this binary)" if self._project_scope else (
+ " (F2: whole project)" if self._index is not None else "")
+ self.query_one("#pal-title", Static).update(
+ f" symbols [{scope}]: {len(self._results)}{more}{hint}")
+
+ def action_cursor_down(self) -> None:
+ ol = self.query_one(OptionList)
+ if ol.option_count:
+ ol.highlighted = min((ol.highlighted or 0) + 1, ol.option_count - 1)
+
+ def action_cursor_up(self) -> None:
+ ol = self.query_one(OptionList)
+ if ol.option_count:
+ ol.highlighted = max((ol.highlighted or 0) - 1, 0)
+
+ def action_choose(self) -> None:
+ ol = self.query_one(OptionList)
+ i = ol.highlighted
+ if i is not None and 0 <= i < len(self._results):
+ b, a, _ = self._results[i]
+ self.dismiss((b, a))
+
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
+ if 0 <= event.option_index < len(self._results):
+ b, a, _ = self._results[event.option_index]
+ self.dismiss((b, a))
+
+ def action_close(self) -> None:
+ self.dismiss(None)
+
+
+def _str_display(text: str, limit: int = 200) -> str:
+ """One-line, printable rendering of a string literal for the browser: escape
+ the common control chars, drop the rest, and clip long bodies."""
+ out = (text.replace("\\", "\\\\").replace("\n", "\\n")
+ .replace("\r", "\\r").replace("\t", "\\t"))
+ out = "".join(ch if ch.isprintable() else "." for ch in out)
+ return out[:limit] + ("\u2026" if len(out) > limit else "")
+
+
+class StringsPalette(ModalScreen):
+ """Every string in the binary (IDA's Shift+F12), filterable; Enter jumps to
+ it in the unified listing."""
+
+ BINDINGS = [
+ Binding("escape", "close", "Close"),
+ Binding("down,ctrl+n", "cursor_down", show=False),
+ Binding("up,ctrl+p", "cursor_up", show=False),
+ Binding("f2", "scope", "This binary / whole project", show=False),
+ ]
+ LIMIT = 500
+ #: Project scope saturates its cap (every binary contributes); keep the list
+ #: short so arrowing stays snappy.
+ PROJECT_LIMIT = 60
+
+ def __init__(self, strings: list, index=None, binary=None) -> None:
+ super().__init__()
+ # Pre-render + pre-lower once: filtering runs on every keystroke and a
+ # big binary has tens of thousands of strings.
+ self._rows = [(s, d, d.lower())
+ for s in strings for d in (_str_display(s.text),)]
+ self._index = index # ProjectIndex, when this is a project
+ self._binary = binary
+ self._project_scope = False
+ #: (binary|None, addr, display text) — binary is None for a local hit
+ self._results: list[tuple] = []
+
+ def compose(self) -> ComposeResult:
+ with Vertical(id="pal-box"):
+ yield Static(" strings", id="pal-title", markup=False)
+ yield Input(placeholder="filter strings\u2026 \u2191\u2193 select \u00b7 "
+ "Enter jump \u00b7 Esc close", id="pal-input")
+ yield OptionList(id="pal-list")
+
+ def on_mount(self) -> None:
+ self._apply("")
+ self.query_one("#pal-input", Input).focus()
+
+ def on_input_changed(self, event: Input.Changed) -> None:
+ event.stop() # don't leak to the app's #search/#filter handlers
+ self._apply(event.value.strip())
+
+ def on_input_submitted(self, event: Input.Submitted) -> None:
+ event.stop()
+ self.action_choose()
+
+ def action_scope(self) -> None:
+ if self._index is None:
+ return # not a project: nothing else to search
+ self._project_scope = not self._project_scope
+ self._apply(self.query_one("#pal-input", Input).value.strip())
+
+ def _apply(self, query: str) -> None:
+ q = query.lower()
+ # rows: (binary|None, addr, length, display text, match offset)
+ if self._project_scope and self._index is not None:
+ from .index import KIND_STRING
+ hits = self._index.search(query, kind=KIND_STRING,
+ limit=self.PROJECT_LIMIT * 3) if query else []
+ rows = []
+ for h in hits:
+ disp = _str_display(h.text)
+ rows.append((h.binary, h.addr, len(h.text), disp,
+ disp.lower().find(q)))
+ # the index already guarantees a match, so ranking only orders them:
+ # earliest match, then shortest, with a stable (binary, addr) tiebreak
+ # (a literal shared by two binaries would otherwise be unordered).
+ rows.sort(key=lambda r: (r[4] if r[4] >= 0 else 1 << 30,
+ r[2], r[0], r[1]))
+ rows = rows[:self.PROJECT_LIMIT]
+ else:
+ rows = []
+ for s, disp, low in self._rows:
+ hit = low.find(q) if q else -1
+ if q and hit < 0:
+ continue
+ rows.append((None, s.addr, s.length, disp, hit))
+ if len(rows) >= self.LIMIT:
+ break
+ self._results = [(b, a, d) for b, a, _, d, _ in rows]
+ ol = self.query_one(OptionList)
+ ol.clear_options()
+ opts = []
+ for binary, addr, length, disp, hit in rows:
+ label = Text()
+ if binary:
+ label.append(f"{binary:<14.14} ", _S_LABEL)
+ label.append(f"{addr:08X} ", _S_ADDR)
+ label.append(f"{length:>5} ", _S_DIM)
+ body = Text(disp)
+ if hit >= 0:
+ body.stylize(_S_NAME_MATCH, hit, hit + len(q))
+ label.append_text(body)
+ opts.append(Option(label))
+ ol.add_options(opts)
+ if self._results:
+ ol.highlighted = 0
+ scope = "project" if self._project_scope else "this binary"
+ cap = self.PROJECT_LIMIT if self._project_scope else self.LIMIT
+ more = "+" if len(rows) == cap else ""
+ hint = " (F2: this binary)" if self._project_scope else (
+ " (F2: whole project)" if self._index is not None else "")
+ self.query_one("#pal-title", Static).update(
+ f" strings [{scope}]: {len(self._results)}{more} of {len(self._rows)}{hint}")
+
+ def action_cursor_down(self) -> None:
+ ol = self.query_one(OptionList)
+ if ol.option_count:
+ ol.highlighted = min((ol.highlighted or 0) + 1, ol.option_count - 1)
+
+ def action_cursor_up(self) -> None:
+ ol = self.query_one(OptionList)
+ if ol.option_count:
+ ol.highlighted = max((ol.highlighted or 0) - 1, 0)
+
+ def action_choose(self) -> None:
+ ol = self.query_one(OptionList)
+ i = ol.highlighted
+ if i is not None and 0 <= i < len(self._results):
+ b, a, _ = self._results[i]
+ self.dismiss((b, a))
+
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
+ if 0 <= event.option_index < len(self._results):
+ b, a, _ = self._results[event.option_index]
+ self.dismiss((b, a))
+
+ def action_close(self) -> None:
+ self.dismiss(None)
+
+
+#: The keyboard cheatsheet (F1). Grouped by task rather than by widget, which is
+#: what makes it readable; keep it in step with the BINDINGS above it.
+_HELP = (
+ ("Navigate", (
+ ("Enter", "follow the symbol under the cursor"),
+ ("Esc", "back (navigation history)"),
+ ("g", "goto address or symbol"),
+ ("Ctrl+N", "find symbol (fuzzy)"),
+ ("\"", "strings browser"),
+ ("x", "cross-references to the symbol"),
+ ("L", "continuous listing at the cursor"),
+ ("Ctrl+O", "switch binary (projects)"),
+ )),
+ ("Views", (
+ ("Tab / F5", "disassembly \u21c4 pseudocode"),
+ ("Space", "control-flow graph \u21c4 text"),
+ ("s", "split view: listing + pseudocode"),
+ ("Tab", "in split: switch the driving pane"),
+ ("\\", "hex view"),
+ ("B", "cycle the opcode-bytes column"),
+ ("Ctrl+B", "show/hide the names pane"),
+ ("Ctrl+T", "structs / types editor"),
+ ("Ctrl+P", "command palette"),
+ )),
+ ("Move", (
+ ("j / k", "down / up"),
+ ("Ctrl+D / Ctrl+U", "half page down / up"),
+ ("PgDn / PgUp", "page down / up"),
+ ("Ctrl+Home / Ctrl+End", "top / bottom (G also)"),
+ ("Home / End", "start / end of line"),
+ ("Shift+Home", "start of the instruction / code"),
+ ("h / l", "column left / right"),
+ ("w / b", "word forward / back"),
+ )),
+ ("Edit", (
+ ("n", "rename"),
+ ("y", "set type (prototype, local or global)"),
+ (";", "comment"),
+ ("c", "make code"),
+ ("p", "make function"),
+ ("d", "make data"),
+ ("a", "make string"),
+ ("u", "undefine"),
+ ("o / O", "literal format: hex/dec/bin/char/offset"),
+ ("Ctrl+S", "save the database"),
+ )),
+ ("Search", (
+ ("/", "search forward (repeat to continue)"),
+ ("?", "search backward"),
+ ("N", "previous match"),
+ ("Ctrl+Y", "copy the current line"),
+ ("F1 / H", "this cheatsheet"),
+ ("q", "quit"),
+ )),
+ ("Graph (Space)", (
+ ("j / k", "line up/down, crossing blocks"),
+ ("h / l", "column left / right"),
+ ("J / K", "follow an edge to a successor / predecessor"),
+ ("w / b", "next / previous block in layout order"),
+ ("0", "jump to the entry block"),
+ ("z", "zoom: full \u2192 compact \u2192 collapsed"),
+ ("m", "show/hide the minimap"),
+ ("f", "centre on the current block"),
+ ("Enter", "follow (stays in the graph if it lands here)"),
+ ("drag / click", "pan / put the cursor in a block"),
+ ("click minimap", "jump the view there (drag to scrub)"),
+ )),
+)
+
+
+class QuitScreen(ModalScreen):
+ """Asked before exiting with unsaved database changes. Dismisses with
+ "save", "discard" or None (stay)."""
+
+ BINDINGS = [
+ Binding("s", "save", "Save & quit"),
+ Binding("d", "discard", "Discard & quit"),
+ Binding("escape,c", "cancel", "Cancel"),
+ ]
+
+ def __init__(self, labels: list[str]) -> None:
+ super().__init__()
+ self._labels = labels
+
+ def compose(self) -> ComposeResult:
+ what = (f"{len(self._labels)} databases have unsaved changes"
+ if len(self._labels) > 1 else "unsaved changes")
+ with Vertical(id="quit-box"):
+ yield Static(f"\u26a0 {what}", id="quit-title")
+ body = Text()
+ for label in self._labels:
+ body.append(f" \u2022 {label}\n", _S_LABEL)
+ yield Static(body, id="quit-list")
+ yield Static("s save & quit d discard & quit Esc cancel",
+ id="quit-help")
+
+ def action_save(self) -> None:
+ self.dismiss("save")
+
+ def action_discard(self) -> None:
+ self.dismiss("discard")
+
+ def action_cancel(self) -> None:
+ self.dismiss(None)
+
+
+class HelpScreen(ModalScreen):
+ """F1: the keyboard cheatsheet, replacing the permanent footer."""
+
+ BINDINGS = [Binding("escape,f1,H,q,question_mark", "close", "Close")]
+
+ #: widest cell content, +2 for the card's border, +2 for its padding
+ _CARD_PAD = 4
+
+ def compose(self) -> ComposeResult:
+ # Fluid: as many columns as the terminal can hold. Textual CSS has no
+ # media queries, so the split is computed here from the real width.
+ avail = max(self.app.size.width - 6, 20)
+ cols = self._columns(avail)
+ per = -(-len(_HELP) // cols) # ceil, so the columns stay balanced
+ with Vertical(id="help-box"):
+ yield Static(" keys", id="help-title", markup=False)
+ # Still inside a scroll container, so a genuinely tiny terminal
+ # degrades to scrolling rather than clipping — but spread across the
+ # width it shouldn't come to that.
+ with VerticalScroll(id="help-body"):
+ with Horizontal(id="help-cols"):
+ for c in range(cols):
+ chunk = _HELP[c * per:(c + 1) * per]
+ if not chunk:
+ continue
+ with Vertical(classes="help-col"):
+ for title, rows in chunk:
+ card = Static(self._card(rows),
+ classes="help-card", markup=False)
+ card.border_title = title
+ yield card
+ yield Static("Esc · F1 · H to close", id="help-foot")
+
+ @staticmethod
+ def _section_widths() -> list[int]:
+ """Rendered width of each section's card (keys right-aligned per card)."""
+ out = []
+ for _, rows in _HELP:
+ kw = max(len(k) for k, _ in rows)
+ out.append(max(kw + 2 + len(d) for _, d in rows) + HelpScreen._CARD_PAD)
+ return out
+
+ @classmethod
+ def _columns(cls, avail: int) -> int:
+ """Most columns that actually fit in ``avail``.
+
+ Sizing off the widest section would let one long row (Move's
+ "Ctrl+Home / Ctrl+End") inflate every column and cost a column that would
+ otherwise fit. Each column hugs its own content, so measure the real
+ layout: chunk the sections and sum the per-chunk maxima.
+ """
+ ws = cls._section_widths()
+ n = len(ws)
+ for cols in range(min(n, 4), 1, -1):
+ per = -(-n // cols)
+ chunks = [ws[c * per:(c + 1) * per] for c in range(cols)]
+ total = sum(max(c) for c in chunks if c) + (cols - 1)
+ if total <= avail:
+ return cols
+ return 1
+
+ @staticmethod
+ def _card(rows) -> Text: # NB: not _render — that's a Widget internal
+ """One section's keys. The key column is sized per section, so a card of
+ short keys stays narrow instead of padding out to the global maximum."""
+ width = max(len(k) for k, _ in rows)
+ out = Text()
+ for i, (key, desc) in enumerate(rows):
+ if i:
+ out.append("\n")
+ out.append(f"{key:>{width}}", _S_MNEM)
+ out.append(f" {desc}", _S_INSN)
+ return out
+
+ def action_close(self) -> None:
+ self.dismiss(None)
+
+
+class RegWriteScreen(ModalScreen):
+ """Registers, and the instruction that set each one.
+
+ "Which instruction set this register to its current value?" is the question
+ a trace exists to answer, and seeking backwards to it is a single keypress
+ here rather than a manual walk. Forward is offered too, but backward is what
+ people actually want — you notice a bad value after it has been used.
+ """
+
+ BINDINGS = [
+ Binding("escape", "close", "Close"),
+ Binding("down,ctrl+n", "cursor_down", show=False),
+ Binding("up,ctrl+p", "cursor_up", show=False),
+ Binding("enter", "choose", show=False, priority=True),
+ Binding("f", "choose_forward", show=False),
+ ]
+
+ def __init__(self, rows, idx: int) -> None:
+ super().__init__()
+ self._rows = rows # (name, value, last_write, next_write)
+ self._idx = idx
+
+ def compose(self) -> ComposeResult:
+ with Vertical(id="pal-box"):
+ yield Static(f" registers at t={self._idx:,} \u2014 Enter seeks to the "
+ f"write, f seeks forward", id="pal-title", markup=False)
+ yield OptionList(id="pal-list")
+
+ def on_mount(self) -> None:
+ ol = self.query_one(OptionList)
+ opts = []
+ for name, val, last, nxt in self._rows:
+ label = Text()
+ label.append(f" {name:>4} ", _S_MNEM)
+ label.append(f"{val:#018x} " if val > 0xFFFFFFFF else f"{val:#010x} ",
+ _S_INSN)
+ if last is None:
+ label.append("never written in this trace", _S_DIM)
+ elif last == self._idx:
+ label.append("set by THIS instruction", _S_DATA)
+ else:
+ label.append(f"set at t={last:,}", _S_LABEL)
+ label.append(f" ({self._idx - last:,} steps back)", _S_DIM)
+ if nxt is not None:
+ label.append(f" next t={nxt:,}", _S_DIM)
+ opts.append(Option(label))
+ ol.add_options(opts)
+ ol.highlighted = 0
+ ol.focus()
+
+ def action_cursor_down(self) -> None:
+ ol = self.query_one(OptionList)
+ if ol.option_count:
+ ol.highlighted = min((ol.highlighted or 0) + 1, ol.option_count - 1)
+
+ def action_cursor_up(self) -> None:
+ ol = self.query_one(OptionList)
+ if ol.option_count:
+ ol.highlighted = max((ol.highlighted or 0) - 1, 0)
+
+ def _pick(self, forward: bool) -> None:
+ i = self.query_one(OptionList).highlighted
+ if i is None or not (0 <= i < len(self._rows)):
+ self.dismiss(None)
+ return
+ _name, _val, last, nxt = self._rows[i]
+ self.dismiss(nxt if forward else last)
+
+ def action_choose(self) -> None:
+ self._pick(False)
+
+ def action_choose_forward(self) -> None:
+ self._pick(True)
+
+ def on_option_list_option_selected(self, event) -> None: # type: ignore[no-untyped-def]
+ self._pick(False)
+
+ def action_close(self) -> None:
+ self.dismiss(None)
+
+
+class TraceDock(Vertical):
+ """Registers and a timeline for the loaded execution trace, docked right.
+
+ Persistent rather than a modal: a trace turns every other view into "state
+ at time T", so the time and the registers are context you read WHILE looking
+ at code, not something you open and dismiss.
+ """
+
+ def __init__(self) -> None:
+ super().__init__(id="trace-dock")
+ self.trace = None
+ self.idx = 0
+
+ def compose(self) -> ComposeResult:
+ yield Static("", id="trace-head", markup=False)
+ yield Static("", id="trace-regs", markup=False)
+ yield Static("", id="trace-stack", markup=False)
+ yield TraceTimeline(id="trace-timeline")
+
+ def show(self, trace, idx: int) -> None:
+ self.trace = trace
+ self.idx = idx
+ tl = self.query_one(TraceTimeline)
+ tl.trace, tl.idx = trace, idx
+ self.refresh_state()
+
+ def refresh_state(self) -> None:
+ t = self.trace
+ if t is None:
+ return
+ n = max(t.length, 1)
+ pct = (self.idx + 1) * 100.0 / n
+ head = Text()
+ head.append(f" {self.idx:,}", _S_MNEM)
+ head.append(f" / {t.length - 1:,} ", _S_DIM)
+ head.append(f"{pct:5.1f}%\n", _S_ADDR)
+ # Register values are machine state and stay as the trace recorded them,
+ # but everything else on screen is in database addresses. Showing both
+ # here explains the relationship once, where it's read, instead of
+ # leaving "pc 0x2aed" next to "rip 0x7ffff6faaaed" to be puzzled over.
+ head.append(f" pc {t.ip(self.idx):#x}", _S_LABEL)
+ if t.slide:
+ head.append(f" (trace {t.raw_ip(self.idx):#x})", _S_DIM)
+ self.query_one("#trace-head", Static).update(head)
+
+ # Registers, with the ones THIS instruction wrote called out: that
+ # difference is the entire reason a delta trace is readable.
+ changed = t.changed(self.idx)
+ body = Text()
+ pc = t.pc_name
+ for name in t.registers:
+ v = t.register(name, self.idx)
+ if v is None:
+ continue
+ hot = name in changed
+ body.append(f" {name:>4} ", _S_MNEM if hot else _S_DIM)
+ body.append(f"{v:#018x}\n" if v > 0xFFFFFFFF else f"{v:#010x}\n",
+ _S_DATA if hot else (_S_LABEL if name == pc else _S_INSN))
+ self.query_one("#trace-regs", Static).update(body)
+ self._render_stack(t)
+ tl = self.query_one(TraceTimeline)
+ tl.idx = self.idx
+ tl.refresh()
+
+
+ STACK_WORDS = 8
+
+ def _render_stack(self, t) -> None: # type: ignore[no-untyped-def]
+ """The stack as of this instant, read out of the trace.
+
+ This is where a trace's memory actually is: on two real traces, NONE of
+ the accesses fell inside the image — every one was stack or heap. A
+ memory view that could only address the image would have nothing to show.
+
+ Bytes the trace never saw are printed as '??' rather than zeros. A trace
+ knows what it observed and nothing else, and quietly rendering unseen
+ memory as zero would invent facts.
+ """
+ sp_name = next((r for r in ("rsp", "esp", "sp") if r in t.reg_at), "")
+ sp = t.register(sp_name, self.idx) if sp_name else None
+ out = Text()
+ if sp is None:
+ self.query_one("#trace-stack", Static).update(out)
+ return
+ width = 8 if (t.info and "64" in (t.info.arch or "")) else 4
+ out.append(f" stack ({sp_name})\n", _S_DIM)
+ for k in range(self.STACK_WORDS):
+ a = sp + k * width
+ data, known = t.memory_raw(a, width, self.idx)
+ out.append(" \u25b8" if k == 0 else " ", _S_MNEM)
+ out.append(f"{a:012x} ", _S_ADDR)
+ if all(known):
+ v = int.from_bytes(data, "little")
+ out.append(f"{v:0{width * 2}x}\n", _S_DATA if k == 0 else _S_INSN)
+ elif any(known):
+ out.append("".join(f"{b:02x}" if known[i] else "??"
+ for i, b in enumerate(data)) + "\n", _S_INSN)
+ else:
+ out.append("?" * (width * 2) + "\n", _S_SEP)
+ self.query_one("#trace-stack", Static).update(out)
+
+
+class TraceTimeline(Static):
+ """The trace as a vertical bar: where you are, and where you've been.
+
+ Tenet's timeline is a Qt widget you scroll and drag to zoom. A terminal
+ column can't do that, but it can do the part that matters — show the shape
+ of the trace and your position in it — with one row per N timestamps.
+ """
+
+ def __init__(self, **kw) -> None:
+ super().__init__("", **kw)
+ self.trace = None
+ self.idx = 0
+
+ def render(self) -> Text:
+ t = self.trace
+ out = Text()
+ h = max(self.size.height - 1, 1)
+ if t is None or not t.length:
+ return out
+ out.append(" timeline\n", _S_DIM)
+ h = max(h - 1, 1)
+ per = max(t.length / h, 1.0)
+ here = int(self.idx / per)
+ for row in range(h):
+ if row == here:
+ out.append(" \u25b6", _S_MNEM)
+ out.append(f" {int(row * per):>10,}\n", _S_ADDR)
+ else:
+ out.append(" \u2502\n", _S_SEP if row % 5 else _S_ADDR)
+ return out
+
+
+class LoadOptionsScreen(ModalScreen):
+ """Ask how to load a file no loader recognised.
+
+ IDA's own answer to an unidentified file is a dialog; ours is this. Without
+ it the fallback is x86 at address 0, which doesn't fail — it analyses to
+ nothing, and you're left wondering why a firmware image has no functions.
+
+ Returns ``{"processor": str, "base": int}``, or ``{}`` to load it the way
+ IDA would have anyway (that IS the right answer sometimes: our sniff only
+ recognises formats we're sure about, so it says "unknown" for things IDA
+ can in fact handle).
+ """
+
+ BINDINGS = [
+ Binding("escape", "close", "Close"),
+ Binding("down,ctrl+n", "cursor_down", show=False),
+ Binding("up,ctrl+p", "cursor_up", show=False),
+ Binding("enter", "choose", show=False, priority=True),
+ ]
+
+ def __init__(self, path: str, size: int = 0) -> None:
+ super().__init__()
+ self._path = path
+ # NOT self._size: that is Textual's own backing field for outer_size, and
+ # assigning an int to it crashes the layout with a bewildering
+ # "'int' object has no attribute 'region'" from deep inside _set_dirty.
+ self._nbytes = size
+ self._results: list[tuple[str, str]] = []
+
+ def compose(self) -> ComposeResult:
+ from .formats import PROCESSORS
+ self._all = list(PROCESSORS)
+ with Vertical(id="pal-box"):
+ yield Static(" unrecognised file \u2014 how should IDA load it?",
+ id="pal-title", markup=False)
+ yield Static(f" {os.path.basename(self._path)} ({self._nbytes:,} bytes) "
+ f"\u2014 no loader matched; without a processor IDA "
+ f"assumes x86 at 0", id="load-note", markup=False)
+ yield Input(placeholder="filter processors\u2026", id="pal-input")
+ yield OptionList(id="pal-list")
+ yield Input(placeholder="load address, e.g. 0x8000000 (blank = 0)",
+ id="load-base")
+ yield Static(" Enter accept \u00b7 Tab base address \u00b7 "
+ "Esc load as IDA would", id="load-help", markup=False)
+
+ def on_mount(self) -> None:
+ self._apply("")
+ self.query_one("#pal-input", Input).focus()
+
+ def focus_next(self, selector="*"): # type: ignore[override]
+ """Tab moves between the two things you TYPE into.
+
+ DOM order would stop at the option list on the way, which is
+ arrow-driven and has nothing to type — and the address you meant to
+ enter goes into whichever box happened to have focus. Typing an address
+ into the processor filter is then taken as a processor name, IDA rejects
+ it, and the open fails; that is a bad enough outcome to be worth
+ overriding Tab for.
+ """
+ inp = self.query_one("#pal-input", Input)
+ base = self.query_one("#load-base", Input)
+ (inp if self.focused is base else base).focus()
+ return self.focused
+
+ def focus_previous(self, selector="*"): # type: ignore[override]
+ return self.focus_next()
+
+ def on_input_changed(self, event: Input.Changed) -> None:
+ event.stop()
+ if event.input.id == "pal-input":
+ self._apply(event.value.strip())
+
+ def on_input_submitted(self, event: Input.Submitted) -> None:
+ event.stop()
+ self.action_choose()
+
+ def _apply(self, query: str) -> None:
+ q = query.lower()
+ rows = [(name, desc) for name, desc in self._all
+ if not q or q in name.lower() or q in desc.lower()]
+ # An unlisted processor is still valid: IDA has 73 modules and this
+ # offers 20, so a typed name that matches nothing is taken literally
+ # rather than refused.
+ if not rows and q:
+ rows = [(query.strip(), "use this processor name as typed")]
+ self._results = rows
+ ol = self.query_one(OptionList)
+ ol.clear_options()
+ opts = []
+ for name, desc in rows:
+ label = Text()
+ label.append(f" {name:<12}", _S_LABEL)
+ label.append(desc, _S_DIM)
+ opts.append(Option(label))
+ ol.add_options(opts)
+ if rows:
+ ol.highlighted = 0
self.query_one("#pal-title", Static).update(
- f" symbols: {len(self._results)}" + ("+" if len(self._results) == self.LIMIT else ""))
+ f" unrecognised file \u2014 processor? ({len(rows)})")
def action_cursor_down(self) -> None:
ol = self.query_one(OptionList)
@@ -1434,12 +3608,117 @@ class SymbolPalette(ModalScreen):
def action_choose(self) -> None:
ol = self.query_one(OptionList)
i = ol.highlighted
+ if i is None or not (0 <= i < len(self._results)):
+ self.dismiss({})
+ return
+ raw = self.query_one("#load-base", Input).value.strip()
+ base = 0
+ if raw:
+ try:
+ base = int(raw, 0)
+ except ValueError:
+ self.query_one("#load-help", Static).update(
+ f" {raw!r} is not an address \u2014 try 0x8000000")
+ self.query_one("#load-base", Input).focus()
+ return
+ if base % 16:
+ # IDA's -b is in paragraphs, so an unaligned base can't be
+ # expressed and would quietly load somewhere else.
+ self.query_one("#load-help", Static).update(
+ f" {base:#x} must be 16-byte aligned")
+ self.query_one("#load-base", Input).focus()
+ return
+ self.dismiss({"processor": self._results[i][0], "base": base})
+
+ def action_close(self) -> None:
+ self.dismiss({})
+
+
+class ProjectPalette(ModalScreen):
+ """The project's binaries; Enter switches to one. Shows which are resident
+ (a live worker, so switching is instant) vs cold (needs an open)."""
+
+ BINDINGS = [
+ Binding("escape", "close", "Close"),
+ Binding("down,ctrl+n", "cursor_down", show=False),
+ Binding("up,ctrl+p", "cursor_up", show=False),
+ ]
+
+ def __init__(self, entries: list[dict]) -> None:
+ super().__init__()
+ self._entries = entries
+ self._results: list[dict] = []
+
+ def compose(self) -> ComposeResult:
+ with Vertical(id="pal-box"):
+ yield Static(" binaries", id="pal-title", markup=False)
+ yield Input(placeholder="filter binaries\u2026 \u2191\u2193 select \u00b7 "
+ "Enter switch \u00b7 Esc close", id="pal-input")
+ yield OptionList(id="pal-list")
+
+ def on_mount(self) -> None:
+ self._apply("")
+ self.query_one("#pal-input", Input).focus()
+
+ def on_input_changed(self, event: Input.Changed) -> None:
+ event.stop()
+ self._apply(event.value.strip())
+
+ def on_input_submitted(self, event: Input.Submitted) -> None:
+ event.stop()
+ self.action_choose()
+
+ def _apply(self, query: str) -> None:
+ q = query.lower()
+ rows = [e for e in self._entries
+ if not q or q in e["label"].lower() or q in e["source"].lower()]
+ self._results = rows
+ ol = self.query_one(OptionList)
+ ol.clear_options()
+ opts = []
+ for e in rows:
+ label = Text()
+ label.append("\u25b8 " if e["active"] else " ",
+ _S_MNEM if e["active"] else _S_DIM)
+ label.append(f"{e['label']:<22}", _S_LABEL)
+ if e["resident"]:
+ mb = e.get("memory_mb") or 0
+ label.append(f"resident {mb:>4}MB ", _S_MNEM)
+ elif e["analysed"]:
+ label.append("analysed ", _S_DIM)
+ else:
+ label.append("not opened ", _S_DIM)
+ if e["pinned"]:
+ label.append("pin ", _S_ADDR)
+ label.append(e["source"], _S_DIM)
+ opts.append(Option(label))
+ ol.add_options(opts)
+ if rows:
+ # Land on the binary you're already in, so the switcher opens where
+ # you are rather than at whatever sorts first.
+ active = next((i for i, e in enumerate(rows) if e["active"]), 0)
+ ol.highlighted = active
+ self.query_one("#pal-title", Static).update(
+ f" binaries: {len(rows)} of {len(self._entries)}")
+
+ def action_cursor_down(self) -> None:
+ ol = self.query_one(OptionList)
+ if ol.option_count:
+ ol.highlighted = min((ol.highlighted or 0) + 1, ol.option_count - 1)
+
+ def action_cursor_up(self) -> None:
+ ol = self.query_one(OptionList)
+ if ol.option_count:
+ ol.highlighted = max((ol.highlighted or 0) - 1, 0)
+
+ def action_choose(self) -> None:
+ i = self.query_one(OptionList).highlighted
if i is not None and 0 <= i < len(self._results):
- self.dismiss(self._results[i].addr)
+ self.dismiss(self._results[i]["label"])
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
if 0 <= event.option_index < len(self._results):
- self.dismiss(self._results[event.option_index].addr)
+ self.dismiss(self._results[event.option_index]["label"])
def action_close(self) -> None:
self.dismiss(None)
@@ -1456,13 +3735,16 @@ class ConfirmScreen(ModalScreen):
Binding("escape,n", "cancel", "Cancel"),
]
- def __init__(self, message: str) -> None:
+ def __init__(self, message: str, note: str = "") -> None:
super().__init__()
self._message = message
+ self._note = note
def compose(self) -> ComposeResult:
with Vertical(id="confirm-box"):
- yield Static(self._message, id="confirm-msg")
+ yield Static(self._message, id="confirm-msg", markup=False)
+ if self._note:
+ yield Static(self._note, id="confirm-note", markup=False)
yield Static("[Enter/y] confirm [Esc/n] cancel", id="confirm-help")
def action_confirm(self) -> None:
@@ -1472,6 +3754,179 @@ class ConfirmScreen(ModalScreen):
self.dismiss(False)
+_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+_LOGO_PATH = os.path.join(_REPO_ROOT, "logo.ans")
+#: The same artwork as a real image, for terminals that can draw one. logo.ans
+#: is half-blocks (two pixels per cell); this is a transparent PNG at 768px.
+LOGO_PNG = os.path.join(_REPO_ROOT, "logo.png")
+_LOGO_BOX = (60, 33) # the most room the splash will give the art
+_logo_cells: tuple[int, int] | None = None
+
+
+def logo_cells() -> tuple[int, int]:
+ """Cell footprint for the image, derived from the artwork and the terminal's
+ real cell size rather than hardcoded.
+
+ Cells are nowhere near square (9x22 px here, 1:2.44), so a fixed box picked
+ for one aspect ratio stretches any other. Recomputing means the art can be
+ replaced without anyone remembering to edit a constant.
+ """
+ global _logo_cells
+ if _logo_cells is None:
+ px = kittygfx.png_size(LOGO_PNG)
+ _logo_cells = kittygfx.fit(px, *_LOGO_BOX) if px else _LOGO_BOX
+ return _logo_cells
+_logo_cache: object = False # False == not yet loaded (None == absent/unreadable)
+
+
+def _load_logo() -> "Text | None":
+ """The ANSI-art splash logo (logo.ans) as a Rich Text, or None if missing.
+ Loaded once; cursor show/hide escapes are stripped so Rich sees only SGR."""
+ global _logo_cache
+ if _logo_cache is not False:
+ return _logo_cache # type: ignore[return-value]
+ try:
+ with open(_LOGO_PATH, encoding="utf-8", errors="replace") as f:
+ data = f.read()
+ data = re.sub(r"\x1b\[\?25[lh]", "", data) # drop cursor hide/show
+ _logo_cache = Text.from_ansi(data.strip("\n"), no_wrap=True)
+ except OSError:
+ _logo_cache = None
+ return _logo_cache # type: ignore[return-value]
+
+
+class LoadingScreen(ModalScreen):
+ """Startup overlay shown while the binary is opened + analyzed, so a slow
+ load (big binary) isn't just empty panes and dead air. The app updates the
+ note line with progress and dismisses it once we land on a function."""
+
+ BINDINGS = [Binding("escape", "hide", "Hide")]
+
+ def __init__(self, title: str, note: str = "opening\u2026") -> None:
+ super().__init__()
+ self._title = title
+ self._note = note
+ self._image = False # drawing the real image, not the block art
+ self._last_place = 0.0 # throttles re-anchoring after a repaint
+
+ def _fits(self, rows: int) -> bool:
+ """Room for the art plus the title/note/help lines and box chrome."""
+ sz = self.app.size
+ return sz.height >= rows + 9 and sz.width >= 64
+
+ def compose(self) -> ComposeResult:
+ with Vertical(id="loading-box"):
+ # A terminal that can draw a real image gets one: same artwork,
+ # same cell footprint, ~10x the linear resolution of the block art.
+ # The image is anchored to screen cells rather than composited by
+ # Textual (no unicode-placeholder support here), so the widget is
+ # only reserved blank space -- see _place_logo.
+ cols, rows = logo_cells()
+ kittygfx.log(f"compose: supported={kittygfx.supported()} "
+ f"app.size={self.app.size} cells={cols}x{rows} "
+ f"fits={self._fits(rows)}")
+ if kittygfx.supported() and self._fits(rows):
+ self._image = True
+ blank = Static("\n" * (rows - 1), id="loading-image")
+ blank.styles.height = rows
+ yield blank
+ else:
+ logo = _load_logo()
+ # Only show the splash art when the terminal can fit it plus
+ # the title/note/help + box chrome; otherwise fall back to a
+ # text-only overlay so nothing important is clipped.
+ if logo is not None and self._fits(len(logo.split("\n"))):
+ # Align.center, not the box's align-horizontal: the 1fr
+ # title/note siblings make the child group span the full
+ # width, so container alignment has nothing left to centre.
+ yield Static(Align.center(logo), id="loading-logo")
+ yield Static(f"\u23f3 loading {self._title}", id="loading-title")
+ yield Static(self._note, id="loading-note")
+ yield Static("first open of a big binary can take a while \u00b7 "
+ "Esc to hide", id="loading-help")
+
+ def update_note(self, text: str) -> None:
+ try:
+ self.query_one("#loading-note", Static).update(text)
+ except Exception: # noqa: BLE001 -- not mounted yet / already gone
+ pass
+ # Textual doesn't know the image is there, so a repaint can drop it.
+ # Re-anchoring is one short escape with no image data; throttled so a
+ # chatty progress callback can't turn it into a flicker.
+ if self._image:
+ now = time.monotonic()
+ if now - self._last_place > 0.2:
+ self._last_place = now
+ self._place_logo()
+
+ # -- the image, which Textual knows nothing about ---------------------- #
+ def _place_logo(self) -> None:
+ """Anchor the image over the blank cells reserved for it.
+
+ Deferred to after a refresh because a widget has no screen region until
+ it has been laid out, and re-run on resize because the region moves.
+ """
+ if not self._image:
+ return
+ try:
+ region = self.query_one("#loading-image", Static).region
+ except Exception as e: # noqa: BLE001 -- gone already
+ kittygfx.log(f"place_logo: no widget ({e})")
+ return
+ kittygfx.log(f"place_logo: region={region}")
+ if not region.width or not region.height:
+ return
+ cols, rows = logo_cells()
+ col = region.x + max((region.width - cols) // 2, 0) # centre it
+ kittygfx.place(region.y, col, min(cols, region.width), rows)
+
+ def on_mount(self) -> None:
+ if not self._image:
+ return
+ # Upload HERE, not from the launcher: Textual is on the alternate screen
+ # by now, and an image uploaded to the primary screen cannot be placed
+ # from the alternate one -- the placement reports success and draws
+ # nothing at all.
+ if not kittygfx.upload(LOGO_PNG):
+ self._image = False
+ return
+ self.call_after_refresh(self._place_logo)
+
+ def on_resize(self) -> None:
+ if self._image:
+ kittygfx.clear()
+ self.call_after_refresh(self._place_logo)
+
+ def on_unmount(self) -> None:
+ # The image is anchored to the screen, not owned by the compositor, so
+ # it would sit there over the disassembly forever if we didn't say so.
+ if self._image:
+ kittygfx.clear()
+
+ def action_hide(self) -> None:
+ self.dismiss()
+
+
+class BusyScreen(ModalScreen):
+ """A tiny blocking overlay for a short async step (e.g. gathering xrefs) so
+ the user can't fire more actions into a half-finished operation. Esc cancels
+ (dismisses with "cancel"); a programmatic dismiss carries no result."""
+
+ BINDINGS = [Binding("escape", "cancel", "Cancel")]
+
+ def __init__(self, message: str) -> None:
+ super().__init__()
+ self._message = message
+
+ def compose(self) -> ComposeResult:
+ with Vertical(id="busy-box"):
+ yield Static(self._message, id="busy-msg")
+ yield Static("Esc to cancel", id="busy-help")
+
+ def action_cancel(self) -> None:
+ self.dismiss("cancel")
+
+
# --------------------------------------------------------------------------- #
# Struct editor (C-style local type editor)
# --------------------------------------------------------------------------- #
@@ -1708,16 +4163,131 @@ class StructEditor(ModalScreen):
# --------------------------------------------------------------------------- #
# The app
# --------------------------------------------------------------------------- #
+#: The app's own theme. Textual's default (textual-dark) paints every accent and
+#: border in #ffa62b, a neon orange that fights the muted VS Code/Solarized
+#: palette the code views already use (amber #b58900 for matches, #6a9955 green,
+#: #264f78 blue). This keeps the chrome in the same family as the content:
+#: desaturated blue-greys, amber for emphasis, blue reserved for focus.
+IDATUI_THEME = Theme(
+ name="idatui",
+ dark=True,
+ background="#12161c", # deep blue-black, softer than pure black
+ surface="#181d25", # views
+ panel="#212832", # dialogs, status bar, gutters
+ foreground="#d6d9de",
+ primary="#5aa0d6", # focus / links: the one cool accent
+ secondary="#2f5d82",
+ accent="#d0a215", # the same amber as a search match — one meaning
+ warning="#c9762f", # burnt orange — distinct from accent, reads as care
+ error="#ff5f5f", # already used for failure text
+ success="#6a9955", # already used for comments
+)
+
+
+class IdaCommands(Provider):
+ """Fills the Ctrl+P command palette with real ida-tui actions instead of the
+ stock Textual system commands (change theme / take screenshot / …).
+
+ App-level actions run directly; cursor-scoped ones (rename/xrefs/comment/…)
+ are dispatched to the active code view via ``IdaTui._palette_action``."""
+
+ def _commands(self):
+ app = self.app
+ va = app._palette_action # dispatch to the focused code view
+ return (
+ ("Goto address / symbol…", "jump to an address or name (g)",
+ app.action_goto),
+ ("Find symbol…", "fuzzy function finder (Ctrl+N)", app.action_symbols),
+ ("Strings…", "browse every string in the binary (\")",
+ app.action_strings),
+ ("Switch binary…", "another binary in the project (Ctrl+O)",
+ app.action_switch_binary),
+ ("Keyboard shortcuts", "the key cheatsheet (F1 or H)",
+ app.action_help),
+ ("Follow symbol under cursor", "jump to the referenced symbol (Enter)",
+ lambda: va("follow")),
+ ("Show xrefs to symbol", "cross-references to the cursor symbol (x)",
+ lambda: va("xrefs")),
+ ("Back", "navigation history (Esc)", app.action_back),
+ ("Toggle disassembly / pseudocode", "decompile / listing (F5, Tab)",
+ app.action_toggle_view),
+ ("Continuous listing here", "flat segment listing (L)",
+ app.action_continuous_here),
+ ("Hex view", "raw bytes at the cursor (\\)", app.action_hex),
+ ("Split view (listing ⇄ pseudocode)",
+ "side-by-side synced views (s)", app.action_toggle_split),
+ ("Graph view (control flow)",
+ "the function's basic blocks as a graph (Space)",
+ app.action_toggle_graph),
+ ("Graph: cycle zoom",
+ "full → compact → collapsed (z, in the graph)",
+ lambda: va("zoom")),
+ ("Graph: toggle minimap",
+ "the overview box (m, in the graph)", lambda: va("minimap")),
+ ("Rename symbol…", "rename the symbol under the cursor (n)",
+ lambda: va("rename")),
+ ("Set type / prototype…", "retype the symbol under the cursor (y)",
+ lambda: va("retype")),
+ ("Add comment…", "comment at the cursor (;)", lambda: va("comment")),
+ ("Define code", "make code at the cursor (c)", lambda: va("define_code")),
+ ("Create function", "define a function at the cursor (p)",
+ lambda: va("define_func")),
+ ("Make data", "define a data item at the cursor (d)",
+ lambda: va("make_data")),
+ ("Make string", "define a string at the cursor (a)",
+ lambda: va("make_string")),
+ ("Undefine", "undefine the item at the cursor (u)",
+ lambda: va("undefine")),
+ ("Literal format: next", "cycle the literal under the cursor (o)",
+ lambda: va("op_format", "cycle")),
+ ("Literal format: previous", "the other way round (O)",
+ lambda: va("op_format", "back")),
+ *((f"Literal format: {label}", f"show the literal as {label} ({fmt})",
+ (lambda f=fmt: va("op_format", f)))
+ for fmt, label in (("hex", "hexadecimal"), ("dec", "decimal"),
+ ("oct", "octal"), ("bin", "binary"),
+ ("char", "a character"),
+ ("offset", "an offset (reference)"),
+ ("stack", "a stack variable"),
+ ("default", "IDA's own choice"))),
+ ("Toggle opcode bytes", "cycle the opcode-bytes column (B)",
+ lambda: va("toggle_opcodes")),
+ ("Structs / types editor", "view + edit local types (Ctrl+T)",
+ app.action_structs),
+ ("Filter functions…", "glob-filter the function list (/)",
+ app.action_filter),
+ ("Toggle names pane", "function-list sidebar (Ctrl+B)",
+ app.action_toggle_functions),
+ ("Save database (.i64)", "persist changes (Ctrl+S)", app.action_save),
+ ("Quit", "exit ida-tui (q)", app.action_quit),
+ )
+
+ async def discover(self):
+ for title, help_text, cb in self._commands():
+ yield DiscoveryHit(title, cb, help=help_text)
+
+ async def search(self, query: str):
+ matcher = self.matcher(query)
+ for title, help_text, cb in self._commands():
+ score = matcher.match(title)
+ if score > 0:
+ yield Hit(score, matcher.highlight(title), cb, help=help_text)
+
+
class IdaTui(App):
+ COMMANDS = {IdaCommands} # replace the stock system-commands palette
+
CSS = """
Screen { layout: vertical; }
#panes { height: 1fr; }
#left { width: 30%; min-width: 42; max-width: 44; border-right: solid $panel; }
#func-table { height: 1fr; }
#func-filter { dock: top; }
- DisasmView { width: 1fr; padding: 0 1; }
DecompView { width: 1fr; }
+ ListingView { width: 1fr; padding: 0 1; }
+ #panes.split ListingView { border-right: tall $panel-lighten-2; }
HexView { width: 1fr; padding: 0 1; }
+ GraphView { width: 1fr; padding: 0 1; }
#search {
height: 1; border: none; padding: 0 1;
background: $primary-darken-2; color: $text;
@@ -1734,6 +4304,10 @@ class IdaTui(App):
height: 1; border: none; padding: 0 1;
background: $accent-darken-2; color: $text;
}
+ #makedata {
+ height: 1; border: none; padding: 0 1;
+ background: $secondary-darken-2; color: $text;
+ }
#goto {
height: 1; border: none; padding: 0 1;
background: $primary-darken-3; color: $text;
@@ -1741,24 +4315,58 @@ class IdaTui(App):
#status { height: 1; background: $panel; color: $text; padding: 0 1; }
.decomp-loading {
width: 100%; height: 100%; content-align: center middle;
- background: $panel-darken-1; color: $text-muted; text-style: italic bold;
+ background: $panel-darken-1;
}
+ QuitScreen { align: center middle; }
+ #quit-box { width: 64; height: auto; border: thick $warning; background: $panel; }
+ #quit-title { dock: top; height: 1; background: $warning; color: $background; text-style: bold; padding: 0 1; }
+ #quit-list { height: auto; padding: 1 2 0 2; }
+ #quit-help { height: 1; color: $text-muted; padding: 0 2; margin-top: 1; }
+ HelpScreen { align: center middle; }
+ #help-box { width: auto; max-width: 98%; height: auto; max-height: 90%;
+ border: thick $accent; background: $panel; }
+ #help-title { dock: top; height: 1; background: $accent; color: $background; text-style: bold; padding: 0 1; }
+ #help-body { height: auto; max-height: 100%; width: auto; padding: 1 1; }
+ #help-cols { height: auto; width: auto; }
+ .help-col { height: auto; width: auto; margin-right: 1; }
+ .help-card { height: auto; width: auto; padding: 0 1;
+ border: round $panel-lighten-2; }
+ #help-foot { dock: bottom; height: 1; color: $text-muted; padding: 0 2; }
XrefsScreen { align: center middle; }
#xref-box { width: 84; max-height: 70%; height: auto; border: thick $accent; background: $panel; }
- #xref-title { dock: top; height: 1; background: $accent; color: $text; padding: 0 1; }
+ #xref-title { dock: top; height: 1; background: $accent; color: $background; text-style: bold; padding: 0 1; }
#xref-list { height: auto; max-height: 100%; }
- SymbolPalette { align: center middle; }
+ /* every #pal-box palette centres, not just the symbol one */
+ SymbolPalette, StringsPalette, ProjectPalette,
+ LoadOptionsScreen, RegWriteScreen { align: center middle; }
+ /* Give the stock Ctrl+P command palette side padding instead of full width;
+ the input + results inherit this width (results is an overlay, so pin it). */
+ CommandPalette > Vertical { width: 80%; max-width: 120; }
+ CommandPalette #--results { width: 100%; }
#pal-box { width: 96; max-width: 92%; height: auto; max-height: 80%;
border: thick $accent; background: $panel; }
- #pal-title { dock: top; height: 1; background: $accent; color: $text; padding: 0 1; }
+ #pal-title { dock: top; height: 1; background: $accent; color: $background; text-style: bold; padding: 0 1; }
#pal-input { border: none; height: 1; margin: 0 1; background: $panel; color: $text; }
#pal-list { height: auto; max-height: 24; }
+ #trace-dock { dock: right; width: 34; background: $surface; border-left: solid $panel; }
+ #trace-head { height: 2; padding: 0 1; background: $panel; }
+ #trace-regs { height: auto; padding: 1 0 0 0; }
+ #trace-stack { height: auto; padding: 1 0 0 0; }
+ #trace-timeline { height: 1fr; padding: 1 0 0 0; }
+ #load-note { height: 2; padding: 1 1 0 1; color: $text-muted; }
+ /* Cap the processor list so the ADDRESS FIELD is always on screen: with the
+ palette default (24) the box outgrew the terminal and the field you need
+ was clipped off the bottom, which read as "Tab does nothing". */
+ LoadOptionsScreen #pal-list { max-height: 12; }
+ #load-base { border: none; height: 1; margin: 1 1 0 1; background: $panel; color: $text; }
+ #load-help { height: 1; padding: 0 1; color: $text-muted; }
+ #confirm-note { height: auto; padding: 0 1; color: $text-muted; }
StructEditor { align: center middle; }
#se-box { width: 90%; height: 84%; border: thick $accent; background: $panel; }
#se-panes { height: 1fr; }
#se-left { width: 38; border-right: solid $accent; }
#se-right { width: 1fr; }
- #se-title, #se-hint { height: 1; background: $accent; color: $text; padding: 0 1; }
+ #se-title, #se-hint { height: 1; background: $accent; color: $background; text-style: bold; padding: 0 1; }
#se-list { height: 1fr; }
#se-edit { height: 1fr; border: none; }
#se-status { height: 1; background: $panel-darken-2; color: $text-muted; padding: 0 1; }
@@ -1767,6 +4375,19 @@ class IdaTui(App):
background: $panel; padding: 1 2; }
#confirm-msg { height: auto; }
#confirm-help { height: 1; color: $text-muted; margin-top: 1; }
+ LoadingScreen { align: center middle; }
+ #loading-box { width: 72; height: auto; border: thick $accent;
+ background: $panel; padding: 1 2; }
+ #loading-logo { width: 100%; height: auto; margin-bottom: 1; }
+ #loading-image { width: 100%; margin-bottom: 1; }
+ #loading-title { width: 1fr; height: 1; text-style: bold; }
+ #loading-note { height: auto; color: $text-muted; margin-top: 1; }
+ #loading-help { height: auto; color: $text-muted; margin-top: 1; }
+ BusyScreen { align: center middle; }
+ #busy-box { width: auto; min-width: 26; height: auto; border: thick $accent;
+ background: $panel; padding: 1 2; }
+ #busy-msg { height: 1; text-style: bold; }
+ #busy-help { height: 1; color: $text-muted; margin-top: 1; }
"""
BINDINGS = [
@@ -1774,6 +4395,29 @@ class IdaTui(App):
Binding("ctrl+n", "symbols", "Symbols"),
Binding("ctrl+t", "structs", "Structs"),
Binding("backslash", "hex", "Hex"),
+ Binding("s", "toggle_split", "Split", show=False),
+ # IDA's own key for text/graph. Graph mode is opt-in and self-contained:
+ # with it off nothing else in the app does any extra work.
+ Binding("space", "toggle_graph", "Graph", show=False),
+ Binding("quotation_mark,shift+f12", "strings", "Strings", show=False),
+ Binding("ctrl+o", "switch_binary", "Binaries", show=False),
+ Binding("ctrl+l", "load_options", "Reload as…", show=False),
+ # Trace stepping. ] / [ move one instruction, } / { step over a
+ # call by following the stack pointer.
+ # Seeking, as opposed to stepping: jump to the next/previous time THIS
+ # thing was touched, where "this thing" is whatever the focused view
+ # addresses — an instruction in the code views, a byte in hex.
+ Binding("greater_than_sign", "seek_next_hit", "Next hit", show=False),
+ Binding("less_than_sign", "seek_prev_hit", "Prev hit", show=False),
+ Binding("W", "seek_reg_write", "Reg writes", show=False),
+ Binding("right_square_bracket", "step_fwd", "Step", show=False),
+ Binding("left_square_bracket", "step_back", "Step back", show=False),
+ Binding("right_curly_bracket", "step_over_fwd", "Step over", show=False),
+ Binding("left_curly_bracket", "step_over_back", "Step over back", show=False),
+ # H as well as F1: terminals and multiplexers swallow function keys all
+ # the time (and the one that does it is upstream of us, so there is
+ # nothing to fix on this side), which left the cheatsheet unreachable.
+ Binding("f1,H", "help", "Keys", show=False),
Binding("g", "goto", "Goto"),
Binding("slash", "filter", "Filter", show=False),
Binding("ctrl+b", "toggle_functions", "Names", show=False),
@@ -1782,33 +4426,91 @@ class IdaTui(App):
Binding("escape", "back", "Back"),
]
- def __init__(self, url: str, db: str | None, keepalive: bool = True,
- open_path: str | None = None, rpc_path: str | None = None) -> None:
+ def __init__(self, open_path: str | None = None, keepalive: bool = True,
+ rpc_path: str | None = None, ttl: int = 1800,
+ project=None, load_args: str = "", trace_path: str = "") -> None:
super().__init__()
- self._url = url
- self._db = db
+ # Project mode is additive: with no project this is the plain
+ # single-binary app, unchanged.
+ self._project = project
+ self._pool = None
+ self._binary: str | None = None # active project binary (label)
+ self._states: dict[str, BinaryState] = {}
+ self._pending_restore = None # entry to reopen after a switch
+ self._goto_after_switch = None # cross-binary search hit to land on
+ self._hops: list[str] = [] # binaries a navigation crossed FROM
+ self._load_for_label = None # project binary the dialog is for
+ self._no_functions = False # analysis produced nothing at all
+ self._flash: str | None = None # message a pending reload must keep
+ self._flash_until = 0.0 # ...until this monotonic time
+ self._pending_switch = None # switch waiting on that answer
+ self._nav_seq = 0 # bumped per navigation; drops stale ones
+ #: Literal positions for the decompilation being loaded (worker thread
+ #: -> the view, handed over when the pseudocode is applied).
+ self._pending_nums: dict = {}
+ # None = teardown wasn't an explicit quit (crash/kill): save defensively.
+ # False = the user chose discard, or we already saved on the way out.
+ self._save_on_exit: bool | None = None
+ self._index = None # project-wide symbol/string index
+ if project is not None:
+ from .index import ProjectIndex
+ from .pool import WorkerPool
+ self._pool = WorkerPool(project, ttl=ttl)
+ self._index = ProjectIndex(
+ os.path.join(project.index_dir, "project.db"))
+ self._binary = project.refs[0].label
+ open_path = project.refs[0].staged
self._open_path = open_path
+ self._ttl = ttl
+ self._load_args = load_args or "" # IDA switches for a headerless blob
+ self._title = (os.path.basename(open_path) if open_path else "")
+ self._trace_path = trace_path or "" # Tenet execution trace to explore
+ self._trace = None # the loaded Trace, once analysed
+ self._trail_map = [] # decomp_map for _trail_map_ea
+ self._trail_map_ea = None
+ self._pending_trace_line = None # step waiting on a re-decompile
+ self._trail_line_of: dict[int, int] = {} # ea -> pseudocode line
+ self._trail_span = None # ea span of that function
+ self._trail_eas: list[int] = [] # sorted keys of _trail_line_of
+ self._t = 0 # current timestamp in that trace
self._do_keepalive = keepalive
self._rpc_path = rpc_path
self._rpc = None
- self.client: IDAClient | None = None
+ self.client: WorkerClient | None = None
self.program: Program | None = None
+ self._loading_screen: LoadingScreen | None = None
self._ka = None
self._nav: list[NavEntry] = []
self._func_index = None # the unfiltered FunctionIndex (source of truth)
+ self._did_auto_land = False # startup jump-to-main/picker fires once
self._filter_term = ""
self._pending_filter = ""
self._filter_timer = None
self._sort_col = 0 # 0=addr, 1=name, 2=size
self._sort_reverse = False
- self._pref = "decomp" # preferred code view (changed by Tab)
- self._active = "decomp" # currently shown view (falls back on decomp fail)
+ # ONE notion of "which pane you're in": _active, kept in step with focus
+ # (on_descendant_focus does that while split). There used to be a second,
+ # _pref, but it was only ever assigned "listing" — see _code_mode().
+ self._active = "listing" # currently shown view (in split: the focused pane)
+ self._split = False # side-by-side listing + pseudocode
+ self._graph_sticky = False # stay in graph mode across navigations
+ self._split_eamap: list[list[int]] = [] # split: decomp line -> instr EAs
+ self._split_ea2line: dict[int, int] = {} # split: instr EA -> decomp line
+ self._split_range: tuple[int, int] | None = None # decomp'd fn ea span
self._hex_pending_ea: int | None = None
self._cur: NavEntry | None = None
+ self._pending_focus_name: str | None = None # token to land the cursor on
+ self._decomp_return: NavEntry | None = None # listing to return to from F5
+ self._busy_screen: BusyScreen | None = None
+ self._xref_active = False # an xref gather is in flight (blocks re-entry)
+ self._conn_screen: LoadingScreen | None = None
+ self._reconnecting = False # a reconnect attempt is in flight
self._search_ctx: tuple[object | None, int] = (None, 1)
self._rename_ctx: tuple[object | None, str] = (None, "")
+ self._rename_addr: int | None = None # set for address-based (listing) naming
self._comment_ctx: tuple[object | None, int, str] = (None, 0, "")
self._retype_ctx: tuple[object | None, str, int, str] = (None, "", 0, "")
+ self._makedata_ctx: tuple[object | None, int] = (None, 0)
self._xref_focus_name: str | None = None
self._dirty = False
@@ -1818,13 +4520,22 @@ class IdaTui(App):
fp = FunctionsPanel(id="left")
fp.display = False # overlay-first: reveal the docked pane with Ctrl+B
yield fp
- dis = DisasmView()
- dis.display = False
- yield dis
- yield DecompView() # pseudocode is the default view
+ # The unified continuous listing is the one code view. (DisasmModel
+ # is still used by the domain to index a function's instructions.)
+ lst = ListingView()
+ yield lst
+ yield DecompView()
hx = HexView()
hx.display = False
yield hx
+ gv = GraphView()
+ gv.display = False
+ yield gv
+ # Docked right and only shown once a trace is loaded, so a normal
+ # session looks exactly as it did.
+ td = TraceDock()
+ td.display = False
+ yield td
si = Input(id="search")
si.display = False
si.can_focus = False
@@ -1841,23 +4552,227 @@ class IdaTui(App):
ti.display = False
ti.can_focus = False
yield ti
+ mdi = Input(id="makedata")
+ mdi.display = False
+ mdi.can_focus = False
+ yield mdi
gi = Input(id="goto")
gi.display = False
gi.can_focus = False
yield gi
- yield Static("connecting…", id="status")
- yield Footer()
+ # markup=False: the status is plain text full of [listing]/[split]/[label]
+ # markers and symbol names that may contain brackets. With Textual markup
+ # on, a single-word marker parses as a style tag and is silently eaten —
+ # which is why [listing] and [pseudocode] never actually rendered.
+ yield Static("connecting\u2026", id="status", markup=False)
def on_mount(self) -> None:
+ self.register_theme(IDATUI_THEME)
+ self.theme = IDATUI_THEME.name
# Keep the hidden command input out of the focus chain until summoned.
inp = self.query_one("#func-filter", Input)
inp.can_focus = False
# The names pane is an overlay now (Ctrl+N); focus the default code view
# (pseudocode) so app bindings work before anything is opened.
- self.query_one(DecompView).focus()
- self._connect()
+ self.query_one(ListingView).focus()
if self._rpc_path:
self._start_rpc()
+ # A file no loader recognises has to be described before it can be
+ # opened, so ask BEFORE the worker starts — once IDA has made a database
+ # the answer is baked in and changing it means deleting the .i64.
+ if self._project is not None:
+ ref = self._pending_load_ref()
+ if ref is not None:
+ self._ask_load_options(ref.source, label=ref.label)
+ return
+ elif self._should_ask_load_options():
+ self._ask_load_options(self._open_path)
+ return
+ # Show a loading overlay immediately so a slow open/analysis (big binary)
+ # isn't just dead air behind empty panes; dismissed once we land.
+ self._loading_screen = LoadingScreen(self._loading_title())
+ self.push_screen(self._loading_screen)
+ self._connect()
+
+ def _should_ask_load_options(self) -> bool:
+ """Ask only when nobody has already answered, and only when it matters.
+
+ Skipped when: options came from the command line or the project (the
+ user already said); a database exists (the answer is recorded in it, and
+ re-passing switches fails the open); or the file is a format IDA
+ recognises, which is nearly always.
+ """
+ if not self._open_path:
+ return False
+ if self._load_args:
+ return False
+ from .formats import needs_load_options
+ if os.path.exists(self._open_path + ".i64") or os.path.exists(
+ os.path.splitext(self._open_path)[0] + ".i64"):
+ return False
+ return needs_load_options(self._open_path)
+
+ def action_load_options(self) -> None:
+ """Ctrl+L: re-open this binary with different load options.
+
+ The database IDA already built has the old processor and base baked into
+ it and takes precedence over any switches, so re-loading means throwing
+ it away. That destroys names and comments, hence the confirmation — but
+ for the case this exists for (a blob loaded as the wrong architecture,
+ which analysed to nothing) there is nothing to lose and no other way
+ forward.
+ """
+ if not self._can_reload():
+ self._status("nothing to reload")
+ return
+ n = len(self._func_index) if self._func_index else 0
+ note = ("this image has no functions, so nothing is lost"
+ if n == 0 else
+ f"discards the database for this binary \u2014 {n} "
+ f"function{'s' if n != 1 else ''}, plus any names and comments "
+ f"you've added")
+ self.push_screen(ConfirmScreen("Reload with different options?", note),
+ self._on_reload_confirmed)
+
+ def _on_reload_confirmed(self, yes) -> None: # type: ignore[no-untyped-def]
+ if not yes:
+ return
+ path, label = self._open_path, None
+ if self._project is not None and self._binary is not None:
+ ref = self._project.by_label(self._binary)
+ if ref is not None:
+ path, label = ref.source, ref.label
+ # Drop the worker first: it holds the database open, and the .i64 can't
+ # be removed (or rebuilt) underneath a live one.
+ self._release_worker()
+ self._drop_database()
+ self._reset_for_reload()
+ self._load_args = ""
+ if label is not None and self._project is not None:
+ self._project.set_load(label, processor="", base=0)
+ i = self._project._refs.index(self._project.by_label(label))
+ self._project._entries[i].pop("processor", None)
+ self._project._entries[i].pop("base", None)
+ self._project.save()
+ self._pending_switch = None
+ self._ask_load_options(path, label=label)
+
+ def _release_worker(self) -> None:
+ if self._pool is not None and self._binary is not None:
+ try:
+ self._pool.evict(self._binary, save=False)
+ except Exception: # noqa: BLE001
+ pass
+ elif self.client is not None:
+ try:
+ self.client.close()
+ except Exception: # noqa: BLE001
+ pass
+ self.client = None
+ self.program = None
+
+ def _drop_database(self) -> None:
+ """Remove the .i64 (and any unpacked scratch) so the next open re-reads
+ the raw image with new options."""
+ base = self._open_path
+ if self._project is not None and self._binary is not None:
+ ref = self._project.by_label(self._binary)
+ if ref is not None:
+ base = ref.staged
+ if not base:
+ return
+ for suffix in (".i64", ".id0", ".id1", ".id2", ".nam", ".til"):
+ for cand in (base + suffix, os.path.splitext(base)[0] + suffix):
+ try:
+ os.remove(cand)
+ except OSError:
+ pass
+
+ def _reset_for_reload(self) -> None:
+ self._no_functions = False
+ self._func_index = None
+ self._cur = None
+ self._nav = []
+ self._did_auto_land = False
+ self._pending_restore = None
+ self._split = False
+ self.query_one(DecompView).loaded_ea = None
+
+ def _retry_load_options(self) -> None:
+ """Re-ask after IDA refused what we told it."""
+ path, label = self._open_path, None
+ if self._project is not None and self._binary is not None:
+ ref = self._project.by_label(self._binary)
+ if ref is not None:
+ path, label = ref.source, ref.label
+ # Clear the rejected answer or _pending_load_ref would see the
+ # binary as already described and never ask again.
+ self._project.set_load(label, processor="", base=0)
+ self._project._entries[self._project._refs.index(ref)].pop(
+ "processor", None)
+ self._project.save()
+ self._load_args = ""
+ if path:
+ self._status("those load options were rejected \u2014 try again")
+ self._ask_load_options(path, label=label)
+
+ def _pending_load_ref(self, label: str | None = None): # type: ignore[no-untyped-def]
+ """The project binary about to be opened, if it needs describing.
+
+ Checked against the SOURCE: staging may not have happened yet, and the
+ question is about the bytes, not where they were copied to.
+ """
+ if self._project is None:
+ return None
+ label = label or self._binary or self._project.refs[0].label
+ ref = self._project.by_label(label)
+ if ref is None or ref.load_args:
+ return None
+ if os.path.exists(ref.db) or os.path.exists(
+ os.path.splitext(ref.staged)[0] + ".i64"):
+ return None # already analysed: the .i64 records how
+ from .formats import needs_load_options
+ return ref if needs_load_options(ref.source) else None
+
+ def _ask_load_options(self, path: str, label: str | None = None) -> None:
+ try:
+ size = os.path.getsize(path)
+ except OSError:
+ size = 0
+ self._load_for_label = label
+ self.push_screen(LoadOptionsScreen(path, size), self._on_load_options)
+
+ def _on_load_options(self, choice) -> None: # type: ignore[no-untyped-def]
+ from .formats import load_args
+ choice = choice or {}
+ label, self._load_for_label = self._load_for_label, None
+ proc, base = choice.get("processor", ""), int(choice.get("base", 0) or 0)
+ if proc:
+ if label is not None and self._project is not None:
+ # Persist it: the answer belongs to the binary, not to this run.
+ self._project.set_load(label, proc, base)
+ else:
+ self._load_args = load_args(proc, base)
+ self._status(f"loading as {proc} @ {base:#x}")
+ if self._pending_switch is not None:
+ label2, self._pending_switch = self._pending_switch, None
+ self._switch_binary(label2)
+ return
+ self._loading_screen = LoadingScreen(self._loading_title())
+ self.push_screen(self._loading_screen)
+ self._connect()
+
+ def _loading_title(self) -> str:
+ return os.path.basename(self._open_path) if self._open_path else "database"
+
+ def _dismiss_loading(self) -> None:
+ ls = self._loading_screen
+ self._loading_screen = None
+ if ls is not None:
+ try:
+ ls.dismiss()
+ except Exception: # noqa: BLE001 -- already popped
+ pass
def _start_rpc(self) -> None:
from .rpc import RpcServer
@@ -1874,8 +4789,45 @@ class IdaTui(App):
await self._rpc.stop()
# -- status helper ----------------------------------------------------- #
- def _status(self, text: str) -> None:
- self.query_one("#status", Static).update(text)
+ def _status(self, text: str, priority: bool = False) -> None:
+ """Write the status bar. ``priority`` marks the RESULT of something the
+ user did.
+
+ An action's result is written, and then the reload it triggered writes
+ its own idle status on top — cursor moved, filter re-applied, functions
+ re-counted. I patched that at five separate call sites before admitting
+ it's one problem: routine chatter must not outrank an answer. A priority
+ message holds the bar briefly and is cleared by the next keypress, i.e.
+ when the user has read it and moved on.
+ """
+ import time as _time
+ if priority:
+ self._flash = text
+ self._flash_until = _time.monotonic() + 8.0
+ elif self._flash and _time.monotonic() < self._flash_until:
+ text = self._flash
+ # Always say WHICH file this is. In project mode that's the binary's
+ # label; otherwise the filename we opened. Cheap on purpose — _module()
+ # asks the worker, and this runs on every status write.
+ tag = self._binary or self._title
+ if tag:
+ text = f"[{tag}] {text}"
+ # An image with no functions at all is nearly always a blob described
+ # wrongly, and that stays true as you scroll around — so it belongs in
+ # the status bar, not in a one-off message the next write clobbers.
+ # It stops being true the moment a function exists, though: latching it
+ # meant the warning survived defining one with `p` and kept telling you
+ # the load was wrong when it no longer was.
+ if self._no_functions and self._func_index is not None and len(self._func_index):
+ self._no_functions = False
+ if self._no_functions:
+ text += " \u2014 no functions: wrong processor/base? Ctrl+L to reload"
+ try:
+ self.query_one("#status", Static).update(text)
+ except Exception: # noqa: BLE001 -- status bar transiently unavailable
+ pass
+ if self._loading_screen is not None: # mirror progress into the overlay
+ self._loading_screen.update_note(text)
# -- clipboard --------------------------------------------------------- #
def _copy(self, text: str) -> int:
@@ -1898,43 +4850,129 @@ class IdaTui(App):
pass
return len(text)
+ # -- connection loss / recovery --------------------------------------- #
+ def _handle_exception(self, error: BaseException) -> None:
+ """Intercept a lost-connection error from any worker so the whole app
+ doesn't die when the analysis server goes away (it can idle out, be
+ killed, or the box can sleep). Everything else crashes as usual."""
+ from textual.worker import WorkerFailed
+ orig = error.error if isinstance(error, WorkerFailed) else error
+ if isinstance(orig, IDAConnectionError):
+ self._on_connection_lost()
+ return
+ super()._handle_exception(error)
+
+ def _on_connection_lost(self) -> None:
+ if self._reconnecting:
+ return
+ self._reconnecting = True
+ self._conn_screen = LoadingScreen(
+ "the analysis server", note="connection lost \u2014 reconnecting\u2026")
+ self.push_screen(self._conn_screen)
+ self._reconnect()
+
+ def _conn_note(self, text: str) -> None:
+ if self._conn_screen is not None:
+ self._conn_screen.update_note(text)
+
+ def _dismiss_conn(self) -> None:
+ cs = self._conn_screen
+ self._conn_screen = None
+ if cs is not None:
+ try:
+ cs.dismiss()
+ except Exception: # noqa: BLE001 -- already popped (Esc)
+ pass
+
+ @work(thread=True, exclusive=True, group="reconnect")
+ def _reconnect(self) -> None:
+ # The worker died (segfault -> dropped socket). Respawn it: it re-opens
+ # and re-analyzes the binary in a fresh process, then we rebuild.
+ try:
+ if self._open_path is None:
+ self.app.call_from_thread(self._reconnect_failed,
+ "no binary to reopen")
+ return
+ client = WorkerClient(self._open_path, ttl=self._ttl,
+ load_args=self._load_args)
+ client.connect(progress=lambda m: self.app.call_from_thread(
+ self._conn_note, m))
+ except Exception as e: # noqa: BLE001
+ self.app.call_from_thread(self._reconnect_failed, str(e))
+ return
+ self.app.call_from_thread(self._after_reconnect, client, Program(client))
+
+ def _after_reconnect(self, client: "WorkerClient", program: "Program") -> None:
+ self.client = client
+ self.program = program
+ self._reconnecting = False
+ self._dismiss_conn()
+ self._status("reconnected \u2014 reloading\u2026")
+ self._load_functions() # rebuild the function index against the new client
+ cur = self._cur
+ if cur is not None: # refresh the current view with the new program
+ self._open_entry(cur, push=False)
+
+ def _reconnect_failed(self, why: str) -> None:
+ self._reconnecting = False
+ self._conn_note(f"reconnect failed: {why} \u2014 retry on next action, or 'q'")
+ self._status(f"reconnect failed: {why}")
+
# -- connection + initial load ---------------------------------------- #
@work(thread=True, exclusive=True, group="connect")
def _connect(self) -> None:
try:
- client = IDAClient(self._url, db=self._db)
- client.connect()
- if self._open_path is not None:
- self.app.call_from_thread(self._status, f"opening {self._open_path}…")
- import os
- path = os.path.abspath(os.path.expanduser(self._open_path))
- # Moderate idle TTL: the keepalive heartbeat (below) keeps the
- # session alive while the TUI runs; once it exits the worker
- # idles out and frees its slot (avoids piling up to max-workers).
- res = client.call("idb_open", input_path=path,
- idle_ttl_sec=1800, timeout=1800.0)
- if not (isinstance(res, dict) and res.get("success")):
- err = res.get("error") if isinstance(res, dict) else res
- self.app.call_from_thread(self._status, f"open failed: {err}")
- return
- client.set_db(res["session"]["session_id"])
- elif self._db is None:
- client.set_db(client.resolve_db())
- health = client.health()
- module = health.get("module", "?")
+ client = self._open_worker_client()
+ if client is None:
+ return # the opener already reported + dismissed the overlay
+ module = client.health().get("module", "?")
if self._do_keepalive:
# Keep the session warm while we run; don't make it immortal, so
- # it's reclaimed after the TUI closes.
+ # it's reclaimed after the TUI closes. (No-op for the worker.)
self._ka = client.keepalive(interval=120.0).start()
program = Program(client)
except Exception as e: # noqa: BLE001
self.app.call_from_thread(self._status, f"connect failed: {e}")
+ self.app.call_from_thread(self._dismiss_loading)
+ # A load we described ourselves and IDA refused: offer the dialog
+ # again rather than leaving an empty app with an error in the status
+ # bar. Getting the processor wrong is an ordinary mistake and should
+ # cost one more keypress, not a restart.
+ if "load options" in str(e):
+ self.app.call_from_thread(self._retry_load_options)
return
self.client = client
self.program = program
self.app.call_from_thread(self._status, f"{module} — loading functions…")
self._load_functions()
+ def _open_worker_client(self): # type: ignore[no-untyped-def]
+ """Our idalib-worker path: spawn the worker (it opens + analyzes the
+ binary in its own process) and connect. Returns the client, or None."""
+ from .worker_client import WorkerClient
+ if self._pool is not None: # project mode: the pool owns the workers
+ label = self._binary or self._project.refs[0].label
+ client = self._pool.get(label, progress=lambda m:
+ self.app.call_from_thread(self._status, m))
+ self._binary = label
+ self._pool.set_active(label)
+ self._open_path = self._project.by_label(label).staged
+ self._title = os.path.basename(self._open_path)
+ return client
+ if not self._open_path:
+ self.app.call_from_thread(
+ self._status, "the worker backend needs a binary path")
+ self.app.call_from_thread(self._dismiss_loading)
+ return None
+ base = os.path.basename(self._open_path)
+ self.app.call_from_thread(
+ self._status, f"starting worker — initial auto-analysis of {base}…")
+ client = WorkerClient(self._open_path, ttl=self._ttl,
+ load_args=self._load_args)
+ client.connect(progress=lambda m: self.app.call_from_thread(
+ self._status, m))
+ return client
+
@work(thread=True, exclusive=True, group="load-funcs")
def _load_functions(self) -> None:
assert self.program is not None
@@ -1942,7 +4980,6 @@ class IdaTui(App):
self._func_index = idx
self.app.call_from_thread(lambda: self.query_one("#func-table", DataTable).clear())
last = 0
- module = self._module()
while not idx.complete:
idx.load_next_page()
rows = idx.window(last, len(idx) - last)
@@ -1950,14 +4987,178 @@ class IdaTui(App):
if rows:
self.app.call_from_thread(self._append_rows, rows)
self.app.call_from_thread(
- self._status, f"{module} — {last} functions…"
+ self._status, f"{last} functions…"
)
# If a filter is active (typed during load), re-apply it over the full set.
if self._filter_term:
self.app.call_from_thread(self._apply_filter, self._filter_term)
else:
self.app.call_from_thread(
- self._status, f"{module} — {len(idx)} functions (Ctrl+N: find symbol)")
+ self._status, f"{len(idx)} functions (Ctrl+N: find symbol)")
+ # Land somewhere useful instead of an empty pane: main() if present,
+ # otherwise pop the fuzzy symbol picker.
+ self.app.call_from_thread(self._auto_land)
+ self._index_binary() # project mode: keep the cross-binary index fresh
+ if self._trace_path and self._trace is None:
+ self._load_trace() # needs the index above: rebasing reads it
+
+ @work(thread=True, exclusive=True, group="prewarm")
+ def _prewarm_provider(self) -> None:
+ """Warm the binary this one leans on hardest, once we're idle.
+
+ Not "the next in the list" — phase 3 tells us something better. The
+ binary providing the most of this one's imports is where a follow is
+ most likely to take you, so paying its startup now is the switch you
+ would otherwise wait for. Refuses to evict anything (see pool.prewarm),
+ so at a tight budget this simply does nothing.
+ """
+ if self._pool is None or self._index is None or self._binary is None:
+ return
+ try:
+ imps, _ = self.program.linkage()
+ except Exception: # noqa: BLE001
+ return
+ if not imps:
+ return
+ from collections import Counter
+ votes: Counter = Counter()
+ for name in {i.name for i in imps}:
+ for h in self._index.providers(name, exclude=self._binary):
+ votes[h.binary] += 1
+ resident = set(self._pool.resident())
+ cand = next((b for b, _ in votes.most_common() if b not in resident), None)
+ if cand is None:
+ return
+ n = votes[cand]
+ try:
+ if self._pool.prewarm(cand):
+ self.app.call_from_thread(
+ self._status, f"pre-warmed {cand} (provides {n} imports)")
+ except Exception: # noqa: BLE001 -- speculative work must never surface
+ pass
+
+ @work(thread=True, exclusive=True, group="index")
+ def _index_binary(self) -> None:
+ """Fold this binary's symbols + strings into the project index, so it can
+ be searched later even when its worker is gone."""
+ if self._index is None or self._project is None or self._binary is None:
+ return
+ ref = self._project.by_label(self._binary)
+ if ref is None or not self._index.is_stale(self._binary, ref.source):
+ return
+ from .index import KIND_EXPORT, KIND_FUNC, KIND_IMPORT, KIND_STRING
+ idx = self._func_index
+ entries = [(KIND_FUNC, f.addr, f.name) for f in (idx.all_loaded() if idx else [])]
+ try:
+ entries += [(KIND_STRING, s.addr, s.text)
+ for s in self.program.strings()]
+ except Exception: # noqa: BLE001 -- symbols alone are still worth indexing
+ pass
+ try:
+ imps, exps = self.program.linkage()
+ entries += [(KIND_IMPORT, i.addr, i.name) for i in imps]
+ entries += [(KIND_EXPORT, e.addr, e.name) for e in exps]
+ except Exception: # noqa: BLE001 -- an old worker has no list_linkage
+ pass
+ try:
+ n = self._index.reindex(self._binary, entries, source=ref.source)
+ except Exception as e: # noqa: BLE001
+ self.app.call_from_thread(self._status, f"indexing failed: {e}")
+ return
+ self.app.call_from_thread(
+ self._status, f"indexed {self._binary}: {n} symbols, strings + linkage")
+ self._prewarm_provider()
+
+ # -- initial landing --------------------------------------------------- #
+ #: function names tried (in order) as the startup landing spot
+ ENTRY_NAMES = ("main", "_main", "wmain", "WinMain", "wWinMain")
+
+ def _auto_land(self) -> None:
+ """On startup, once functions are loaded and nothing is open yet, jump to
+ main() if it exists; else open the symbol picker so you're never staring
+ at a blank pane. Runs once (guarded by ``_cur``) and never steals focus
+ from a user who already navigated."""
+ goto = self._goto_after_switch
+ if goto is not None: # arrived here from a project-wide search hit
+ self._goto_after_switch = None
+ self._did_auto_land = True
+ self._dismiss_loading()
+ self._goto_ea(goto, push=True)
+ return
+ entry = self._pending_restore
+ if entry is not None: # switched back to a binary we'd already explored
+ self._pending_restore = None
+ self._did_auto_land = True
+ self._dismiss_loading()
+ self._open_entry(entry, push=False)
+ return
+ if self._cur is not None or self._did_auto_land or self._func_index is None:
+ return
+ self._did_auto_land = True
+ self._dismiss_loading() # binary is up; hand the screen back to the views
+ fn = self._entry_func()
+ if fn is not None:
+ self._open_function(fn.addr, fn.name)
+ elif len(self._func_index):
+ # No main(): land on the first function rather than pushing the
+ # symbol palette. A modal as the *startup* state leaves a human
+ # staring at a picker over an empty pane, and silently swallows
+ # every keystroke an RPC driver injects while `ping` still says
+ # ready:true. Landing somewhere real is better for both; Ctrl+N is
+ # one keypress away.
+ first = self._func_index.get(0)
+ if first is not None:
+ self._open_function(first.addr, first.name)
+ self._status(f"no entry function — opened {first.name} "
+ "(Ctrl+N: find symbol)")
+ else:
+ self.action_symbols()
+ else:
+ self._land_without_functions()
+
+ def _land_without_functions(self) -> None:
+ """Analysis found nothing. Show the bytes and say so.
+
+ Falling through to the symbol picker here left two empty panes and
+ "functions still loading…" — which is a lie, loading had finished. There
+ is always something to look at: the segments exist even when IDA
+ recognised no code in them, so open the listing at the start of the image.
+
+ Zero functions is also the signal that a blob was described wrongly. It's
+ exactly what a good image loaded as the wrong processor looks like, so
+ the status says so rather than leaving you to guess.
+ """
+ start = None
+ try:
+ regions = self.program.file_regions()
+ if regions:
+ start = regions[0][0]
+ except Exception: # noqa: BLE001
+ pass
+ self._no_functions = self._can_reload()
+ if start is None:
+ self._status("no functions and no segments \u2014 nothing to show")
+ return
+ self._open_at(start, self.program.section_of(start) or "image",
+ cursor=0, push=True, is_region=True)
+
+ def _can_reload(self) -> bool:
+ """Whether we're able to re-open this binary with different options."""
+ if self._project is not None and self._binary is not None:
+ return True
+ return bool(self._open_path)
+
+ def _entry_func(self) -> Func | None:
+ """The best startup landing function (exact-name match against
+ ENTRY_NAMES, in priority order), or None."""
+ idx = self._func_index
+ if idx is None:
+ return None
+ by_name = {f.name: f for f in idx.all_loaded()}
+ for nm in self.ENTRY_NAMES:
+ if nm in by_name:
+ return by_name[nm]
+ return None
def _module(self) -> str:
try:
@@ -2022,7 +5223,7 @@ class IdaTui(App):
if term:
self._status(f"filter '{term}': {len(matched)}/{total}")
else:
- self._status(f"{self._module()} — {total} functions")
+ self._status(f"{total} functions")
def _apply_pending_filter(self) -> None:
self._filter_timer = None
@@ -2057,8 +5258,7 @@ class IdaTui(App):
left = self.query_one("#left", FunctionsPanel)
left.display = not left.display
if not left.display:
- self.query_one(DecompView if self._active == "decomp"
- else DisasmView).focus()
+ self._focus_code_view()
else:
self.query_one("#func-table", DataTable).focus()
@@ -2070,32 +5270,539 @@ class IdaTui(App):
self.push_screen(StructEditor(self.program))
def action_symbols(self) -> None:
- """Ctrl+N: fuzzy-find a symbol in a command-palette overlay."""
+ """Ctrl+N: fuzzy-find a symbol (Ctrl+A widens it to the whole project)."""
idx = self._func_index
funcs = idx.all_loaded() if idx is not None else []
if not funcs:
self._status("functions still loading…")
return
- self.push_screen(SymbolPalette(funcs), self._on_symbol_chosen)
+ self.push_screen(SymbolPalette(funcs, index=self._index,
+ binary=self._binary),
+ self._on_symbol_chosen)
- def _on_symbol_chosen(self, addr: int | None) -> None:
- if addr is None:
+ def _on_symbol_chosen(self, choice) -> None: # type: ignore[no-untyped-def]
+ if choice is None:
+ return
+ binary, addr = choice
+ if binary and binary != self._binary:
+ self._switch_then_goto(binary, addr)
return
f = self._func_index.by_addr(addr) if self._func_index else None
self._open_function(addr, f.name if f else hex(addr))
+ def _switch_then_goto(self, binary: str, addr: int) -> None:
+ """A project-wide hit in another binary: switch to it, then jump there
+ once its index has loaded.
+
+ Records the hop so Esc can come back. Nav history is per-binary, so
+ without this a cross-binary jump — a project search hit, or following an
+ import into the library that implements it — is a one-way door: you
+ arrive in a binary whose history is empty and nothing takes you back.
+ """
+ if self._binary is not None and binary != self._binary:
+ self._hops.append(self._binary)
+ self._goto_after_switch = addr
+ self._switch_binary(binary)
+
+ # -- projects: switching between the binaries of one target ------------ #
+ # -- exit ---------------------------------------------------------------- #
+ def _dirty_labels(self) -> list[str]:
+ """Databases with edits that aren't on disk yet.
+
+ A binary the pool has evicted was saved on the way out, so only the
+ active one and other still-resident binaries can be dirty.
+ """
+ if self._pool is None:
+ return [os.path.basename(self._open_path or "database")] if self._dirty else []
+ out = [self._binary] if (self._dirty and self._binary) else []
+ out += [label for label, st in self._states.items()
+ if st.dirty and label != self._binary
+ and self._pool.is_resident(label)]
+ return out
+
+ async def action_quit(self) -> None:
+ """Never drop edits on the floor: ask before exiting when a database has
+ unsaved changes (IDA/Ghidra behaviour)."""
+ dirty = self._dirty_labels()
+ if not dirty:
+ self._save_on_exit = False # nothing to write
+ self.exit()
+ return
+ self.push_screen(QuitScreen(dirty), self._on_quit_choice)
+
+ def _on_quit_choice(self, choice: str | None) -> None:
+ if choice == "discard":
+ self._save_on_exit = False
+ self.exit()
+ elif choice == "save":
+ # Save with the overlay up: writing a big .i64 takes seconds, and
+ # doing it during teardown would look like a hang with no UI left.
+ self._loading_screen = LoadingScreen("saving", note="writing databases\u2026")
+ self.push_screen(self._loading_screen)
+ self._save_then_exit()
+ # None: cancel, stay put
+
+ @work(thread=True, exclusive=True, group="save-exit")
+ def _save_then_exit(self) -> None:
+ try:
+ if self._pool is not None:
+ self._pool.close_all(save=True) # saves each resident worker
+ elif self.program is not None:
+ self.program.client.call("idb_save", timeout=600.0)
+ except Exception as e: # noqa: BLE001 -- still exit, but say so
+ self.app.call_from_thread(self._status, f"save failed: {e}")
+ self.app.call_from_thread(self._finish_exit)
+
+ def _finish_exit(self) -> None:
+ self._save_on_exit = False # already written above
+ self._dirty = False
+ self.exit()
+
+ def action_help(self) -> None:
+ """F1: the keyboard cheatsheet (there's no permanent footer any more)."""
+ if self._prompt_active():
+ return
+ if not isinstance(self.screen, HelpScreen):
+ self.push_screen(HelpScreen())
+
+ def action_switch_binary(self) -> None:
+ """Ctrl+O: pick another binary from the project (Ghidra-style)."""
+ if self._pool is None:
+ self._status("not a project — open one with --project")
+ return
+ if self._prompt_active():
+ return
+ self.push_screen(ProjectPalette(self._pool.status()),
+ self._on_binary_chosen)
+
+ def _on_binary_chosen(self, label: str | None) -> None:
+ if label and label != self._binary:
+ self._switch_binary(label)
+
+ def _switch_binary(self, label: str) -> None:
+ # A blob nobody has described yet has to be described before its worker
+ # opens it — same as at boot, just reached by switching instead.
+ ref = self._pending_load_ref(label)
+ if ref is not None and self._load_for_label is None:
+ self._pending_switch = label
+ self._ask_load_options(ref.source, label=label)
+ return
+ # Snapshot what we're leaving so coming back restores the view, then let
+ # the pool hand us a worker (spawning + evicting as the budget dictates).
+ if self._binary is not None:
+ self._states[self._binary] = BinaryState(
+ label=self._binary, program=self.program,
+ func_index=self._func_index, nav=list(self._nav), cur=self._cur,
+ active=self._active, split=self._split,
+ filter_term=self._filter_term, dirty=self._dirty)
+ self._loading_screen = LoadingScreen(label, note="switching\u2026")
+ self.push_screen(self._loading_screen)
+ self._do_switch(label)
+
+ @work(thread=True, exclusive=True, group="switch-binary")
+ def _do_switch(self, label: str) -> None:
+ assert self._pool is not None
+ try:
+ client = self._pool.get(label, progress=lambda m:
+ self.app.call_from_thread(self._status, m))
+ except Exception as e: # noqa: BLE001
+ self.app.call_from_thread(self._switch_failed, label, str(e))
+ return
+ st = self._states.get(label)
+ # The Program (and its caches) only survive while that worker does; a
+ # binary that was evicted comes back with a fresh one. Either way the nav
+ # history is just addresses, so it always survives.
+ reuse = (st is not None and st.program is not None
+ and getattr(st.program, "client", None) is client)
+ program = st.program if reuse else Program(client)
+ self.app.call_from_thread(self._after_switch, label, client, program,
+ st, reuse)
+
+ def _after_switch(self, label, client, program, st, reuse) -> None: # type: ignore[no-untyped-def]
+ self.client = client
+ self.program = program
+ self._binary = label
+ self._pool.set_active(label)
+ self._open_path = self._project.by_label(label).staged
+ self._title = os.path.basename(self._open_path)
+ self._active = st.active if st else "listing"
+ self._split = st.split if st else False
+ self._filter_term = st.filter_term if st else ""
+ self._dirty = st.dirty if st else False
+ self._nav = list(st.nav) if st else []
+ self._decomp_return = None
+ self._split_eamap, self._split_ea2line, self._split_range = [], {}, None
+ self.query_one(DecompView).loaded_ea = None # belongs to the old binary
+ if reuse and st.func_index is not None:
+ self._cur = st.cur
+ self._func_index = st.func_index
+ self._apply_filter(self._filter_term) # repopulate the names table
+ self._dismiss_loading()
+ if self._goto_after_switch is not None:
+ addr, self._goto_after_switch = self._goto_after_switch, None
+ self._goto_ea(addr, push=True)
+ elif self._cur is not None:
+ self._open_entry(self._cur, push=False)
+ else:
+ self._did_auto_land = False
+ self._auto_land()
+ return
+ # Cold (first visit, or the worker was evicted): rebuild the index, then
+ # land back where we were via _pending_restore.
+ self._cur = None
+ self._func_index = None
+ self._pending_restore = st.cur if st else None
+ # Landing is per-binary: without this the app-wide "already landed" flag
+ # from the first binary would stop the new one landing at all (and leave
+ # the switch overlay up forever).
+ self._did_auto_land = False
+ self._status("loading functions\u2026")
+ self._load_functions()
+
+ def _switch_failed(self, label: str, why: str) -> None:
+ self._dismiss_loading()
+ self._status(f"could not open {label}: {why}")
+
+ def action_strings(self) -> None:
+ """'\"' / Shift+F12: browse every string in the binary (filterable, F2
+ widens to the whole project); Enter jumps to it in the unified listing."""
+ if self.program is None:
+ self._status("not connected yet")
+ return
+ if self._prompt_active():
+ return
+ self._status("collecting strings\u2026")
+ self._load_strings()
+
+ @work(thread=True, exclusive=True, group="strings")
+ def _load_strings(self) -> None:
+ assert self.program is not None
+ try:
+ items, err = self.program.strings(), None
+ except Exception as e: # noqa: BLE001 -- report, never kill the app
+ items, err = [], str(e)
+ self.app.call_from_thread(self._present_strings, items, err)
+
+ def _present_strings(self, items: list, err: str | None) -> None:
+ if err:
+ self._status(f"strings failed: {err}")
+ return
+ if not items:
+ self._status("no strings found (needs the list_strings tool)")
+ return
+ self._status(f"strings: {len(items)}")
+ self.push_screen(StringsPalette(items, index=self._index,
+ binary=self._binary),
+ self._on_string_chosen)
+
+ def _on_string_chosen(self, choice) -> None: # type: ignore[no-untyped-def]
+ if choice is None:
+ return
+ binary, addr = choice
+ if binary and binary != self._binary:
+ self._switch_then_goto(binary, addr)
+ return
+ self._goto_ea(addr, push=True) # land on the literal in the listing
+
+ def on_descendant_focus(self, event) -> None: # type: ignore[no-untyped-def]
+ """Keep ``_active`` in step with focus while split.
+
+ Tab moves both together, but focus also moves on its own — a click, or a
+ pane focusing itself after a load — and then ``_active`` still names the
+ pane you're NOT in. Everything downstream trusts ``_active``: follow
+ resolves the word under that pane's cursor and pushes history for it, so
+ Enter in the pseudocode would follow something from the listing and the
+ next Esc got spent undoing it.
+ """
+ if not self._split:
+ return
+ w = self.focused
+ mode = ("decomp" if isinstance(w, DecompView)
+ else "listing" if isinstance(w, ListingView) else None)
+ if mode is None or mode == self._active:
+ return
+ self._active = mode
+ self._sync_split(mode) # re-link the band from the new driver
+ if not self.query_one(DecompView).loading:
+ self._status_for_cur("split") # never clobber "decompiling…"
+
def action_toggle_view(self) -> None:
"""Tab: switch the code pane between disassembly and pseudocode (or leave
the hex view back to the preferred code view)."""
+ # Tab is a PRIORITY app binding, so it fires even while a modal is up and
+ # nothing inside a dialog could ever be tabbed to. Hand it back to the
+ # dialog: this is the only reason the load dialog's address field was
+ # unreachable, and it was broken the same way in every other modal.
+ if self.screen is not self.screen_stack[0]:
+ try:
+ self.screen.focus_next()
+ except Exception: # noqa: BLE001 -- screen with nothing focusable
+ pass
+ return
if self._cur is None:
return
+ if self._split:
+ # In split mode Tab/F5 just moves focus between the two panes.
+ self._active = "decomp" if self._active == "listing" else "listing"
+ (self.query_one(DecompView) if self._active == "decomp"
+ else self.query_one(ListingView)).focus()
+ self._sync_split(self._active) # re-link from the new driver
+ self._status_for_cur("split")
+ return
if self._active == "hex":
- self._active = self._pref
+ self._active = self._code_mode()
+ self._show_active()
+ return
+ if self._active == "graph":
+ # F5/Tab out of the graph lands in the pseudocode at the block the
+ # cursor was on (Space is the key that returns to the listing).
+ gv = self.query_one(GraphView)
+ ea = gv._cursor_ea()
+ self._graph_sticky = False
+ if ea is None:
+ self._active = "listing"
+ self._show_active()
+ return
+ gv.display = False
+ dec = self.query_one(DecompView)
+ dec.display = True
+ dec.loading = True
+ dec.focus()
+ self._status("decompiling…")
+ self._decomp_from_listing(ea)
+ return
+ if self._active == "listing":
+ # F5/Tab in the continuous listing: decompile the function under the
+ # cursor (IDA-style), if the cursor is inside a defined routine.
+ ea = self.query_one(ListingView)._cursor_ea()
+ if ea is None:
+ self._status("no address here to decompile")
+ return
+ # Raise the pseudocode pane + 'decompiling…' overlay NOW, before the
+ # background decompile runs — otherwise the (cold) decompile happens in
+ # _decomp_from_listing first and _show_active's re-decompile is cached,
+ # so the overlay only flashes for an instant.
+ dec = self.query_one(DecompView)
+ self.query_one(ListingView).display = False
+ self.query_one(HexView).display = False
+ dec.display = True
+ dec.loading = True
+ dec.focus()
+ self._status("decompiling\u2026")
+ self._decomp_from_listing(ea)
+ return
+ # active == "decomp": F5/Tab returns to the linear listing.
+ ret = self._decomp_return
+ self._decomp_return = None
+ if ret is not None:
+ self._cur = ret
+ self._active = "listing"
+ self._open_entry(ret, push=False)
+ elif self._cur is not None:
+ # No F5 snapshot (we arrived via a decomp navigation): show THIS entry
+ # in the listing at the current pseudocode line's address. Reuse the
+ # entry (it is _nav[-1]) rather than spawning a detached one, so cursor
+ # moves keep updating it and a later edit reloads at the right spot.
+ dec = self.query_one(DecompView)
+ ea = dec._line_ea(dec.cursor)
+ self._toggle_to_listing(ea if ea is not None else self._cur.ea)
+ else:
+ self._active = "listing"
+ self._show_active()
+
+ @work(thread=True, group="nav")
+ def _toggle_to_listing(self, ea: int) -> None:
+ assert self.program is not None
+ lm = self.program.listing(ea)
+ idx = max(lm.ensure_ea(ea), 0) if lm is not None else 0
+ self.app.call_from_thread(self._apply_toggle_listing, idx)
+
+ def _apply_toggle_listing(self, idx: int) -> None:
+ cur = self._cur
+ if cur is None:
+ self._active = "listing"
+ self._show_active()
+ return
+ cur.view = "listing"
+ cur.cursor = idx
+ cur.cursor_x = 0
+ cur.scroll_y = -1 # derive a viewport (keeps the target in context)
+ self._active = "listing"
+ self._open_entry(cur, push=False)
+
+ @work(thread=True, group="nav")
+ def _decomp_from_listing(self, ea: int) -> None:
+ assert self.program is not None
+ fn = self.program.function_of(ea)
+ if fn is None:
+ self.app.call_from_thread(
+ self._decomp_from_listing_failed,
+ "F5 — cursor is not inside a defined function ('p' to make one)")
+ return
+ dec_idx = self._decomp_line_for(fn.addr, ea)
+ self.app.call_from_thread(self._enter_decomp, fn.addr, fn.name, dec_idx)
+
+ def _decomp_from_listing_failed(self, msg: str) -> None:
+ # We optimistically raised the pseudocode overlay; drop back to the
+ # listing since there's nothing to decompile here.
+ self.query_one(DecompView).loading = False
+ self._active = "listing"
+ self._show_active()
+ self._status(msg)
+
+ def _enter_decomp(self, fn_addr: int, fn_name: str, dec_idx: int) -> None:
+ # Snapshot the listing position so F5/Tab returns exactly here.
+ lst = self.query_one(ListingView)
+ ret = self._cur
+ if ret is not None:
+ ret.cursor = lst.cursor
+ ret.cursor_x = lst.cursor_x
+ ret.scroll_y = round(lst.scroll_offset.y)
+ self._decomp_return = ret
+ entry = NavEntry(ea=fn_addr, name=fn_name, is_region=False,
+ dec_cursor=max(dec_idx, 0))
+ self._cur = entry
+ self._active = "decomp"
+ self._show_active()
+
+ def action_toggle_split(self) -> None:
+ """Toggle the side-by-side listing ⇄ pseudocode view (Ghidra-style)."""
+ if self._prompt_active():
+ return # a search/rename/… prompt owns the keyboard
+ if self._cur is None or self.program is None:
+ self._status("open a function first")
+ return
+ if not self._split and self.size.width < _SPLIT_MIN_WIDTH:
+ self._status(f"terminal too narrow for split — need ≈{_SPLIT_MIN_WIDTH} "
+ f"cols (have {self.size.width})")
+ return
+ self._split = not self._split
+ if self._active not in ("listing", "decomp"):
+ self._active = "listing"
+ if self._split:
+ self._enter_split(self._cur.ea, self._cur.name)
+ else:
+ self._show_active()
+
+ # -- graph mode -------------------------------------------------------- #
+ #: Above this, a CFG graph stops being a picture and becomes a hairball --
+ #: IDA's own is unreadable at this size too. Refusing beats rendering soup.
+ GRAPH_MAX_BLOCKS = 400
+
+ def action_toggle_graph(self) -> None:
+ """Space: swap the code view for the function's control-flow graph."""
+ if self._prompt_active():
+ return
+ if self._active == "graph":
+ self._graph_sticky = False
+ self._active = self._code_mode()
+ self._show_active()
+ return
+ if self._cur is None or self.program is None:
+ self._status("open a function first")
+ return
+ self._graph_sticky = True
+ gv = self.query_one(GraphView)
+ ea = self._graph_target_ea()
+ if gv.loaded_ea is not None and gv.fc is not None \
+ and gv.fc.func_ea == self._cur.ea:
+ self._active = "graph"
+ self._split = False
self._show_active()
+ if ea is not None:
+ gv.goto_ea(ea)
+ self._graph_status()
+ return
+ self._status(f"{self._cur.name} — building graph…")
+ self._load_graph(self._cur.ea, ea)
+
+ def _graph_target_ea(self) -> int | None:
+ """The address the graph should land on: wherever the code view's cursor
+ is, so Space doesn't lose your place."""
+ try:
+ if self._active in ("listing", "disasm"):
+ return self.query_one(ListingView)._cursor_ea()
+ if self._active == "decomp":
+ dv = self.query_one(DecompView)
+ return dv._line_ea(dv.cursor)
+ except Exception: # noqa: BLE001
+ pass
+ return self._cur.ea if self._cur else None
+
+ @work(thread=True, exclusive=True, group="graph")
+ def _load_graph(self, func_ea: int, want_ea: int | None) -> None:
+ assert self.program is not None
+ err = ""
+ fc = None
+ try:
+ fc = self.program.flowchart(func_ea)
+ except IDAConnectionError:
+ raise
+ except Exception as e: # noqa: BLE001
+ err = f"{type(e).__name__}: {e}"
+ self.app.call_from_thread(self._apply_graph, func_ea, want_ea, fc, err)
+
+ def _apply_graph(self, func_ea: int, want_ea: int | None, fc, # type: ignore[no-untyped-def]
+ err: str) -> None:
+ if self._cur is None or self._cur.ea != func_ea:
+ return # a newer navigation won
+ if not self._graph_sticky and self._active != "graph":
+ # The load lost its race: the user has since left graph mode (or a
+ # rename's reload queued one behind their back). Forcing the view
+ # here drags them back into a graph they already dismissed.
+ return
+ if fc is None:
+ self._status(err or "no control-flow graph for this function "
+ "(is it a thunk or an import?)")
return
- self._active = "decomp" if self._active == "disasm" else "disasm"
- self._pref = self._active # an explicit toggle sets the preference
+ if len(fc.blocks) > self.GRAPH_MAX_BLOCKS:
+ self._status(
+ f"{fc.name}: {len(fc.blocks)} blocks — too many to graph "
+ f"(limit {self.GRAPH_MAX_BLOCKS}); staying in the listing")
+ return
+ gv = self.query_one(GraphView)
+ gv.set_graph(fc, want_ea)
+ self._active = "graph"
+ self._split = False
self._show_active()
+ self._graph_status()
+
+ def _graph_status(self) -> None:
+ gv = self.query_one(GraphView)
+ if gv.lay is None or gv.fc is None:
+ return
+ s = gv.lay.stats
+ loops = f", {s['back']} loop{'s' if s['back'] != 1 else ''}" if s["back"] else ""
+ self._status(
+ f"{gv.fc.name} @ {gv.fc.func_ea:#x} [graph: {s['blocks']} blocks, "
+ f"{s['edges']} edges{loops}] "
+ f"z=zoom({gv.ZOOMS[gv._zoom]}) m=map J/K=edge space=text")
+
+ def on_graph_view_cursor_moved(self, msg: "GraphView.CursorMoved") -> None:
+ gv = self.query_one(GraphView)
+ gv.set_highlight(gv.word_under_cursor())
+ if msg.ea is not None and gv.fc is not None:
+ b = gv.fc.block_at(msg.ea)
+ extra = f" block {b.start:#x}" if b else ""
+ self._status(f"{gv.fc.name} @ {msg.ea:#x}{extra} [graph]")
+
+ @work(thread=True, group="split")
+ def _enter_split(self, ea: int, name: str) -> None:
+ # Load the listing for the current function (bg) then reveal both panes.
+ assert self.program is not None
+ lm = self.program.listing(ea)
+ idx = max(lm.ensure_ea(ea), 0) if lm is not None else 0
+ self.app.call_from_thread(self._apply_enter_split, lm, ea, name, idx)
+
+ def _apply_enter_split(self, lm, ea: int, name: str, idx: int) -> None: # type: ignore[no-untyped-def]
+ if not self._split:
+ return # toggled back off before the listing finished loading
+ lst = self.query_one(ListingView)
+ if lm is not None:
+ lst.load(lm, name, cursor=idx, scroll_y=max(idx - _JUMP_CONTEXT, 0))
+ self._show_active() # split branch shows both + loads the decomp
+ self._sync_split(self._active) # crude link now
+ self._load_split_map(ea) # region map (async) if decomp is loaded
def action_hex(self) -> None:
"""Backslash: show the raw bytes of the loaded image, synced to the code
@@ -2103,11 +5810,13 @@ class IdaTui(App):
if self._cur is None or self.program is None:
return
if self._active == "hex":
- self._active = self._pref
+ self._active = self._code_mode()
self._show_active()
return
- if self._active == "disasm":
- ea = self.query_one(DisasmView)._cursor_ea()
+ if self._active in ("listing", "disasm"):
+ ea = self.query_one(ListingView)._cursor_ea()
+ elif self._active == "graph":
+ ea = self.query_one(GraphView)._cursor_ea()
else:
dec = self.query_one(DecompView)
ea = dec._line_ea(dec.cursor)
@@ -2145,13 +5854,23 @@ class IdaTui(App):
self.clear_filter() # Esc on the list clears an active filter
return
if len(self._nav) > 1:
+ self._nav_seq += 1 # invalidate any navigation still in flight
self._nav.pop()
- self._open_entry(self._nav[-1], push=False)
+ # Say something immediately: going back can need a decompile, and
+ # until it lands the panes still show where you were — with no
+ # feedback Esc reads as a no-op.
+ dest = self._nav[-1]
+ self._status(f"\u25c2 back to {dest.name}\u2026")
+ self._open_entry(dest, push=False)
+ elif self._hops:
+ # Local history is spent, but we got here from another binary.
+ label = self._hops.pop()
+ self._status(f"\u25c2 back to {label}\u2026")
+ self._switch_binary(label) # _states restores its nav and position
elif self.query_one("#left", FunctionsPanel).display:
table.focus()
else:
- self.query_one(DecompView if self._active == "decomp"
- else DisasmView).focus()
+ self._focus_code_view()
# -- input submit (filter / goto) ------------------------------------- #
def on_search_requested(self, msg: SearchRequested) -> None:
@@ -2169,28 +5888,70 @@ class IdaTui(App):
def on_follow_requested(self, msg: FollowRequested) -> None:
view = msg.view
word = view.word_under_cursor()
- if isinstance(view, DisasmView):
+ if isinstance(view, GraphView):
+ ea = view._cursor_ea()
+ if ea is None:
+ return
+ # Inside the graph, a jump to a block of THIS function should move
+ # the cursor, not navigate away and rebuild the whole picture.
+ tgt = self._graph_local_target(view, ea, word)
+ if tgt is not None:
+ view.goto_ea(tgt)
+ self.post_message(GraphView.CursorMoved(tgt, view.cursor_node))
+ return
+ self._follow_disasm(ea, word, view._next_ea())
+ return
+ if isinstance(view, ListingView):
ea = view._cursor_ea()
- # The instruction's ordinary fall-through edge (to the next line) is
- # an indistinguishable 'code' xref; pass it so follow can skip it and
- # land on a call/jump's real target instead of the next instruction.
- nxt = view.model.cached_line(view.cursor + 1) if view.model else None
if ea is not None:
- self._follow_disasm(ea, word, nxt.ea if nxt else None)
+ # _next_ea() is the following item: follow uses it to skip the
+ # ordinary fall-through edge, which is an indistinguishable
+ # 'code' xref, and land on a call/jump's real target instead.
+ self._follow_disasm(ea, word, view._next_ea())
elif isinstance(view, DecompView) and view._texts:
self._follow_decomp(view._texts[view.cursor], word,
view._line_ea(view.cursor))
+ def _graph_local_target(self, view: "GraphView", ea: int,
+ word: str) -> int | None:
+ """If the cursor's instruction branches somewhere inside this same
+ graph, return that address."""
+ if view.fc is None or self.program is None:
+ return None
+ try:
+ if word and self._looks_like_symbol(word):
+ t = self.program.resolve(word)
+ if t and view.fc.block_at(t) is not None:
+ return t
+ except Exception: # noqa: BLE001
+ pass
+ try:
+ for xr in self.program.xrefs_from(ea):
+ t = xr.to
+ if t and t != view._next_ea() and view.fc.block_at(t) is not None:
+ return t
+ except Exception: # noqa: BLE001
+ pass
+ return None
+
def on_xrefs_requested(self, msg: XrefsRequested) -> None:
+ if self._xref_active: # one gather at a time; ignore a second 'x'
+ return
view = msg.view
word = view.word_under_cursor()
- if isinstance(view, DisasmView):
+ if isinstance(view, GraphView):
+ ea = view._cursor_ea()
+ if ea is not None:
+ self._push_busy("finding xrefs…")
+ self._xrefs_disasm(ea, word, ea, view._next_ea())
+ return
+ if isinstance(view, ListingView):
ea = view._cursor_ea()
if ea is not None:
# span = this instruction .. the next, to pre-select the dialog
# entry for the site we invoked xrefs from.
- nxt = view.model.cached_line(view.cursor + 1) if view.model else None
- self._xrefs_disasm(ea, word, ea, nxt.ea if nxt else None)
+ self._push_busy("finding xrefs\u2026")
+ self._xrefs_disasm(ea, word, ea, view._next_ea())
elif isinstance(view, DecompView) and view._texts:
here = view._line_ea(view.cursor)
end = None
@@ -2200,8 +5961,28 @@ class IdaTui(App):
if e is not None and e > here:
end = e
break
+ self._push_busy("finding xrefs\u2026")
self._xrefs_decomp(view._texts[view.cursor], word, here, end)
+ # -- blocking "busy" overlay for short async steps -------------------- #
+ def _push_busy(self, message: str) -> None:
+ self._xref_active = True
+ self._busy_screen = BusyScreen(message)
+ self.push_screen(self._busy_screen, self._on_busy_closed)
+
+ def _on_busy_closed(self, result: object = None) -> None:
+ self._busy_screen = None
+ if result == "cancel": # user hit Esc while we were still gathering
+ self._xref_active = False
+
+ def _dismiss_busy(self) -> None:
+ bs = self._busy_screen
+ if bs is not None:
+ try:
+ bs.dismiss() # no result -> _on_busy_closed leaves the flag alone
+ except Exception: # noqa: BLE001 -- already popped
+ pass
+
@staticmethod
def _looks_like_symbol(word: str | None) -> bool:
# A symbol has a letter but is not a bare hex number (the address column
@@ -2217,7 +5998,8 @@ class IdaTui(App):
# Prefer the symbol under the cursor (handles multiple refs on a line).
if self._looks_like_symbol(word):
try:
- self._do_navigate(self.program.resolve(word), push=True)
+ self._do_navigate(self.program.resolve(word), push=True,
+ focus_name=word)
return
except Exception: # noqa: BLE001 -- not a resolvable name; fall back
pass
@@ -2231,8 +6013,55 @@ class IdaTui(App):
if tgt is None or tgt.to is None:
self.app.call_from_thread(self._status, "nothing to follow here")
return
+ if self._follow_import(tgt.to):
+ return
self._do_navigate(tgt.to, push=True)
+ def _import_stub(self, ea: int) -> str | None:
+ """The import name at ``ea``, if ``ea`` is one of this binary's import
+ stubs. That's the dead end phase 3 exists to open up: a call to strcmp
+ reaches the PLT/extern entry and Hex-Rays has nothing to decompile,
+ because the code lives in a library this binary only references."""
+ try:
+ imps, _ = self.program.linkage()
+ except Exception: # noqa: BLE001
+ return None
+ for i in imps:
+ if i.addr == ea:
+ return i.name
+ return None
+
+ def _cross_binary_impl(self, name: str) -> tuple[str, int] | None:
+ """``(binary, addr)`` of a project binary that EXPORTS ``name``.
+
+ Reads the on-disk index, so a provider resolves even when its worker was
+ evicted — the whole reason the index exists.
+ """
+ if self._index is None or self._project is None or not name:
+ return None
+ try:
+ hits = self._index.providers(name, exclude=self._binary)
+ except Exception: # noqa: BLE001
+ return None
+ return (hits[0].binary, hits[0].addr) if hits else None
+
+ def _follow_import(self, ea: int) -> bool:
+ """Follow an import stub into the binary that implements it. True when
+ it was handled (caller must not also navigate locally)."""
+ name = self._import_stub(ea)
+ if not name:
+ return False
+ found = self._cross_binary_impl(name)
+ if found is None:
+ # Leave the local navigation alone: landing on the stub is still the
+ # honest answer when nothing in the project provides the symbol.
+ return False
+ label, addr = found
+ self.app.call_from_thread(
+ self._status, f"{name} \u2192 {label} (import resolved)")
+ self.app.call_from_thread(self._switch_then_goto, label, addr)
+ return True
+
@work(thread=True, group="nav")
def _follow_decomp(self, line: str, word: str | None,
line_ea: int | None = None) -> None:
@@ -2260,7 +6089,11 @@ class IdaTui(App):
if addr is None:
self.app.call_from_thread(self._status, "nothing to follow on this line")
return
- self._do_navigate(addr, push=True)
+ if self._follow_import(addr):
+ return
+ # Following FROM pseudocode: keep the reader in the decompiler when the
+ # target is decompilable, instead of dropping to the linear listing.
+ self._do_navigate(addr, push=True, prefer_decomp=True)
def _ref_on_line(self, line: str) -> int | None:
"""Address of the first decompiler ref whose name appears on ``line``."""
@@ -2307,6 +6140,8 @@ class IdaTui(App):
subj = self._ref_on_line(line) or (self._cur.ea if self._cur else None)
if subj is not None:
self._xrefs_present(subj, word, here_ea, here_end)
+ else: # nothing to xref -> still clear the busy overlay
+ self.app.call_from_thread(self._present_xrefs, "xrefs", [], None, 0)
@staticmethod
def _xref_preselect(xr, here_ea, here_end) -> int: # type: ignore[no-untyped-def]
@@ -2326,6 +6161,14 @@ class IdaTui(App):
here_ea: int | None = None,
here_end: int | None = None) -> None: # worker
assert self.program is not None
+ try:
+ return self._xrefs_present_inner(subj, subj_name, here_ea, here_end)
+ except Exception as e: # noqa: BLE001 -- never leave the busy overlay stuck
+ self.app.call_from_thread(self._status, f"xrefs failed: {e}")
+ self.app.call_from_thread(self._present_xrefs, "xrefs", [], None, 0)
+
+ def _xrefs_present_inner(self, subj, subj_name, here_ea, here_end): # type: ignore[no-untyped-def]
+ assert self.program is not None
xr = self.program.xrefs_to(subj)
fn = self.program.function_of(subj)
if fn is not None:
@@ -2350,12 +6193,53 @@ class IdaTui(App):
# not inside a function: name the section it lives in (a GOT/reloc
# data slot or a loose thunk) instead of a bare '?'.
loc = self.program.section_of(x.frm) or "<no seg>"
- items.append((x.frm, f"{x.frm:08X} {loc:<26} [{x.type}]"))
+ kind = x.kind or x.type or "?"
+ items.append((x.frm, f"{x.frm:08X} {kind:<6} {loc}"))
preselect = self._xref_preselect(xr, here_ea, here_end)
+ # Callers in OTHER project binaries. xrefs_to only ever sees this
+ # database, so an exported function looks unused from the inside even
+ # when half the project calls it — the import side of the linkage index
+ # is the only place that knowledge exists.
+ for label_b, addr_b, text_b in self._foreign_importers(subj, subj_name, fn):
+ items.append(((label_b, addr_b), text_b))
self.app.call_from_thread(self._present_xrefs, label, items, focus, preselect)
- def _present_xrefs(self, label: str, items: list[tuple[int, str]],
+ def _foreign_importers(self, subj: int, subj_name, fn): # type: ignore[no-untyped-def]
+ """Project binaries that IMPORT the symbol at ``subj`` — the other half
+ of the phase-3 join, read from the on-disk index so a caller shows up
+ whether or not its worker is resident.
+
+ Only for a symbol this binary actually exports: a local name that
+ happens to collide with another binary's import isn't a caller of ours.
+ """
+ if self._index is None or self._project is None or self._binary is None:
+ return []
+ name = subj_name if self._looks_like_symbol(subj_name) else None
+ if name is None and fn is not None and fn.addr == subj:
+ name = fn.name
+ if not name:
+ return []
+ from .domain import link_name
+ name = link_name(name)
+ try:
+ _, exports = self.program.linkage()
+ except Exception: # noqa: BLE001
+ return []
+ if not any(e.name == name for e in exports):
+ return [] # we don't export it; nobody imports it FROM US
+ try:
+ hits = self._index.importers(name, exclude=self._binary)
+ except Exception: # noqa: BLE001
+ return []
+ return [(h.binary, h.addr, f"{h.addr:08X} import [{h.binary}] {name}")
+ for h in hits]
+
+ def _present_xrefs(self, label: str, items: list[tuple[object, str]],
focus_name: str | None = None, preselect: int = 0) -> None:
+ if not self._xref_active:
+ return # cancelled (Esc) while we were still gathering
+ self._xref_active = False
+ self._dismiss_busy()
if not items:
self._status(f"{label}: none")
return
@@ -2363,9 +6247,17 @@ class IdaTui(App):
self._status(f"{label}: {len(items)}")
self.push_screen(XrefsScreen(label, items, preselect), self._on_xref_chosen)
- def _on_xref_chosen(self, addr: int | None) -> None:
- if addr is not None:
- self._goto_ea(addr, push=True, focus_name=self._xref_focus_name)
+ def _on_xref_chosen(self, addr) -> None: # type: ignore[no-untyped-def]
+ if addr is None:
+ return
+ if isinstance(addr, tuple): # a caller in another project binary
+ binary, ea = addr
+ self._switch_then_goto(binary, ea) # records a hop, so Esc returns
+ return
+ # If xrefs was invoked from the decompiler, land the jump back in the
+ # decompiler (when the target is decompilable) rather than the listing.
+ self._goto_ea(addr, push=True, focus_name=self._xref_focus_name,
+ prefer_decomp=(self._active == "decomp"))
# -- rename ------------------------------------------------------------ #
@staticmethod
@@ -2383,6 +6275,41 @@ class IdaTui(App):
return re.search(rf"\bgoto\s+{re.escape(name)}\b", body) is not None
def on_rename_requested(self, msg: RenameRequested) -> None:
+ # In the flat listing, 'n' names the ADDRESS under the cursor (create a
+ # label), not a symbol-by-name. This is what lets you name a bare/
+ # undefined byte — e.g. the free byte at addr+1 after shrinking a u16 to
+ # a u8 — which the word-under-cursor path can't do (no symbol to rename).
+ if isinstance(msg.view, (ListingView, GraphView)):
+ ea = msg.view._cursor_ea()
+ if ea is None:
+ self._status("no address on this line to name")
+ return
+ head = msg.view.cur_head()
+ word = msg.view.word_under_cursor()
+ mnem = head.text.split(" ", 1)[0] if (head and head.text) else ""
+ # If the cursor is on a symbol token (a call/branch target, a data
+ # reference, or this head's own label) rename THAT symbol; otherwise
+ # create/rename a label at the head's address (bare/undefined bytes).
+ if (word and self._looks_like_symbol(word) and word != mnem
+ and word.lower() not in _ASM_KEYWORDS):
+ self._rename_ctx = (msg.view, word)
+ self._rename_addr = None
+ placeholder = f"rename '{word}' — Enter=apply Esc=cancel"
+ prefill = word
+ else:
+ cur = head.name if (head is not None and head.name) else ""
+ self._rename_ctx = (msg.view, cur)
+ self._rename_addr = ea
+ placeholder = f"name @ {ea:#x} — Enter=apply Esc=cancel"
+ prefill = cur
+ self.query_one("#status", Static).display = False
+ inp = self.query_one("#rename", Input)
+ inp.placeholder = placeholder
+ inp.can_focus = True
+ inp.display = True
+ inp.value = prefill
+ inp.focus()
+ return
if not msg.name:
self._status("nothing to rename under the cursor")
return
@@ -2392,6 +6319,7 @@ class IdaTui(App):
"(Hex-Rays goto labels aren't renamable via the API)")
return
self._rename_ctx = (msg.view, msg.name)
+ self._rename_addr = None
self.query_one("#status", Static).display = False
inp = self.query_one("#rename", Input)
inp.placeholder = f"rename '{msg.name}' — Enter=apply Esc=cancel"
@@ -2404,6 +6332,7 @@ class IdaTui(App):
inp = self.query_one("#rename", Input)
inp.display = False
inp.can_focus = False
+ self._rename_addr = None
self.query_one("#status", Static).display = True
view, _ = self._rename_ctx
if view is not None:
@@ -2412,7 +6341,7 @@ class IdaTui(App):
# -- comments ---------------------------------------------------------- #
def _line_ea_for(self, view) -> int | None: # type: ignore[no-untyped-def]
"""Address of the line under the cursor in either code view."""
- if isinstance(view, DisasmView):
+ if isinstance(view, ListingView):
return view._cursor_ea()
if isinstance(view, DecompView):
return view._line_ea(view.cursor)
@@ -2479,16 +6408,53 @@ class IdaTui(App):
return
self.app.call_from_thread(self._after_comment, ea, text)
- def _after_comment(self, ea: int, text: str) -> None:
+ def _reload_active_code(self) -> None:
+ """Refresh whichever code view is showing after an edit (comment/rename/
+ retype), in place: re-decompile if in the decompiler, else reload the
+ listing."""
cur = self._cur
+ if cur is None:
+ return
+ if self._active == "decomp":
+ # Snapshot the LIVE pseudocode position before forcing a recompile.
+ # dec_scroll_y isn't tracked on every move, so without this the reload
+ # falls into show()'s derive path (a bare scroll_to) and leaves a
+ # stale frame until the next cursor move; capturing the real scroll
+ # makes show() take the robust _apply_scroll path and repaint now.
+ dec = self.query_one(DecompView)
+ if cur.ea == dec.loaded_ea:
+ cur.dec_cursor = dec.cursor
+ cur.dec_cursor_x = dec.cursor_x
+ cur.dec_scroll_y = round(dec.scroll_offset.y)
+ cur.dec_scroll_x = round(dec.scroll_offset.x)
+ dec.loaded_ea = None # force re-decompile
+ self._show_active()
+ else:
+ # Capture the LIVE position from the widget (the source of truth)
+ # rather than trusting nav-entry tracking, which goes stale. Capture
+ # it as ADDRESSES via the anchor: bump_names() discards the segment
+ # model so the reload rebuilds it, and an edit that changes how many
+ # rows an item takes makes the old indices point somewhere else.
+ # Index capture is CORRECT here and an anchor is not: a rename or
+ # comment doesn't change how many rows anything takes, and the model
+ # this rebuilds is constructed empty — index_of_ea on it returns -1
+ # until pages load, so an anchor would resolve to nothing while
+ # costing an extra model build on the UI thread. Address anchoring is
+ # for the edit paths that DO change row structure (see _do_edit_item).
+ lst = self.query_one(ListingView)
+ cur.view = "listing"
+ if lst.model is not None:
+ cur.cursor = lst.cursor
+ cur.cursor_x = lst.cursor_x
+ cur.scroll_y = round(lst.scroll_offset.y)
+ self._open_entry(cur, push=False)
+
+ def _after_comment(self, ea: int, text: str) -> None:
# A comment shows in both views but only after Hex-Rays recompiles, so
# reuse the name-generation invalidation (bumps gen -> decompile is
- # force_recompiled lazily; disasm block caches are cleared).
+ # force_recompiled lazily; disasm/listing caches are cleared).
self.program.bump_names()
- if cur is not None:
- self._save_current_pos()
- self.query_one(DecompView).loaded_ea = None # force pseudocode reload
- self._open_entry(cur, push=False)
+ self._reload_active_code()
self._dirty = True
verb = "cleared comment" if not text else "commented"
self._status(f"{verb} @ {ea:#x} (Ctrl+S to save)")
@@ -2513,7 +6479,10 @@ class IdaTui(App):
lv = next((v for v in ft.lvars if v.name == word), None)
if lv is not None:
kind, prefill = "lvar", lv.type
- # 2) a function under the cursor (self or a referenced one)
+ # 2) a symbol under the cursor: a function (retype its prototype) or a
+ # global/data item (retype the variable). Without the data case a
+ # global fell through to (3) and silently retyped the ENCLOSING
+ # function's prototype instead.
if kind is None and self._looks_like_symbol(word):
try:
tgt = self.program.resolve(word)
@@ -2523,6 +6492,12 @@ class IdaTui(App):
tft = self.program.func_types(tgt)
if tft is not None:
kind, subject, prefill = "func", tgt, tft.prototype
+ else:
+ dt = self.program.data_type(tgt)
+ if dt is not None and not dt.get("is_func"):
+ kind, subject = "data", tgt
+ prefill = dt.get("type") or self._guess_data_type(
+ dt.get("size") or 0)
# 3) fall back to the current function itself
if kind is None and ft is not None:
kind, subject, prefill = "func", self._cur.ea, ft.prototype
@@ -2531,6 +6506,13 @@ class IdaTui(App):
return
self.app.call_from_thread(self._open_retype, view, kind, subject, word or "", prefill)
+ @staticmethod
+ def _guess_data_type(size: int) -> str:
+ """A sensible prefill when a global carries no type yet."""
+ return {1: "unsigned __int8", 2: "unsigned __int16",
+ 4: "unsigned __int32", 8: "unsigned __int64"}.get(
+ size, f"char[{size}]" if size > 0 else "void *")
+
def _open_retype(self, view, kind: str, subject: int, word: str, # type: ignore[no-untyped-def]
prefill: str) -> None:
self._retype_ctx = (view, kind, subject, word)
@@ -2557,6 +6539,8 @@ class IdaTui(App):
assert self.program is not None
if kind == "func":
err = self.program.set_function_type(subject, new)
+ elif kind == "data": # a global / data item referenced in the body
+ err = self.program.set_data_type(subject, new)
else: # lvar of the current function
err = self.program.set_lvar_type(self._cur.ea, word, new)
if err:
@@ -2568,15 +6552,66 @@ class IdaTui(App):
# A type change alters the pseudocode (and disasm operand types), so
# recompile via the name-generation invalidation and reopen in place.
self.program.bump_names()
- cur = self._cur
- if cur is not None:
- self._save_current_pos()
- self.query_one(DecompView).loaded_ea = None
- self._open_entry(cur, push=False)
+ self._reload_active_code()
self._dirty = True
what = "prototype" if kind == "func" else f"'{word}'"
self._status(f"retyped {what} (Ctrl+S to save)")
+ # -- typed data definition (make_data, IDA 'd') ----------------------- #
+ @staticmethod
+ def _default_data_type(head) -> str: # type: ignore[no-untyped-def]
+ """A sensible prefill C type for defining data over ``head``."""
+ sz = getattr(head, "size", 0) or 0
+ return {1: "unsigned __int8", 2: "unsigned __int16",
+ 4: "unsigned __int32", 8: "unsigned __int64"}.get(
+ sz, f"char[{sz}]" if sz > 0 else "unsigned __int8")
+
+ def on_make_data_requested(self, msg: MakeDataRequested) -> None:
+ view = msg.view
+ ea = view._cursor_ea() if isinstance(view, ListingView) else None
+ if ea is None:
+ self._status("no address on this line to define data")
+ return
+ self._makedata_ctx = (view, ea)
+ self.query_one("#status", Static).display = False
+ inp = self.query_one("#makedata", Input)
+ inp.placeholder = (f"data type @ {ea:#x} (e.g. int, char[16], my_struct)"
+ " — Enter=apply Esc=cancel")
+ inp.can_focus = True
+ inp.display = True
+ head = view.cur_head() if isinstance(view, ListingView) else None
+ inp.value = self._default_data_type(head) if head is not None else "int"
+ inp.focus()
+
+ def _end_makedata(self) -> None:
+ inp = self.query_one("#makedata", Input)
+ inp.display = False
+ inp.can_focus = False
+ self.query_one("#status", Static).display = True
+ view = self._makedata_ctx[0]
+ if view is not None:
+ view.focus()
+
+ @work(thread=True, exclusive=True, group="makedata")
+ def _do_make_data(self, ea: int, type_decl: str,
+ anchor: ViewAnchor | None = None) -> None: # worker context
+ assert self.program is not None
+ try:
+ self.program.make_data(ea, type_decl)
+ except Exception as e: # noqa: BLE001
+ self.app.call_from_thread(self._status, f"make data: {e}")
+ return
+ self.program.bump_items()
+ anchor = anchor or ViewAnchor()
+ anchor.flash = f"data ({type_decl}) @ {ea:#x} (Ctrl+S to save)"
+ name = self.program.region_label(ea)
+ lm = self.program.listing(ea)
+ idx = max(lm.ensure_ea(ea), 0) if lm is not None else 0
+ _cur, top = self._anchor_rows(anchor, lm, ea)
+ self.app.call_from_thread(
+ self._open_at, ea, name, idx, False, -1, 0, True, None, top)
+ self.app.call_from_thread(self._edit_done, anchor)
+
@work(thread=True, exclusive=True, group="rename")
def _do_rename(self, view, old: str, new: str) -> None: # type: ignore[no-untyped-def]
assert self.program is not None
@@ -2634,10 +6669,7 @@ class IdaTui(App):
# A renamed symbol can appear in many functions, so invalidate globally;
# each function refreshes its names the next time it's viewed.
self.program.bump_names()
- if cur is not None:
- self._save_current_pos()
- self.query_one(DecompView).loaded_ea = None # force pseudocode reload
- self._open_entry(cur, push=False)
+ self._reload_active_code()
if kind == "func" and addr is not None and self._func_index is not None:
self._func_index.update_name(addr, new)
for e in self._nav:
@@ -2654,6 +6686,654 @@ class IdaTui(App):
self._dirty = True
self._status(f"renamed {old} → {new} (Ctrl+S to save)")
+ @work(thread=True, exclusive=True, group="rename")
+ def _do_name_addr(self, addr: int, name: str) -> None: # worker context
+ """Set a label at ``addr`` (listing 'n'). Works on a bare/undefined byte
+ — unlike the symbol-by-name path, this names the address directly."""
+ assert self.program is not None
+ try:
+ res = self.program.client.call(
+ "rename", batch={"data": {"addr": hex(addr), "new": name}})
+ except IDAToolError as e:
+ self.app.call_from_thread(self._status, f"name failed: {e.message}")
+ return
+ summary = res.get("summary", {}) if isinstance(res, dict) else {}
+ if not (summary.get("ok", 0) > 0 and summary.get("failed", 0) == 0):
+ err = "name failed"
+ items = res.get("data") if isinstance(res, dict) else None
+ if isinstance(items, list) and items and items[0].get("error"):
+ err = f"name failed: {items[0]['error']}"
+ self.app.call_from_thread(self._status, err)
+ return
+ # The label shows in the listing's head rows -> invalidate + reopen.
+ self.program.bump_items()
+ # Naming the address *of a function start* is a function rename by any
+ # other name. Without this the cached index kept the old name, so
+ # `functions`/`names`/resolve/the palette all reported the rename had
+ # not happened -- and a driver that trusts those readbacks redoes work
+ # it already did.
+ fn = None
+ try:
+ fn = self.program.function_of(addr)
+ except Exception: # noqa: BLE001
+ fn = None
+ is_func_start = fn is not None and fn.addr == addr
+ lm = self.program.listing(addr)
+ label = name if is_func_start else self.program.region_label(addr)
+ idx = max(lm.ensure_ea(addr), 0) if lm is not None else 0
+ self.app.call_from_thread(self._open_at_named, label, addr, idx, name,
+ is_func_start)
+
+ def _open_at_named(self, label: str, addr: int, idx: int, name: str,
+ is_func_start: bool = False) -> None:
+ if is_func_start:
+ self.program.bump_names()
+ if self._func_index is not None:
+ self._func_index.update_name(addr, name)
+ for e in self._nav:
+ if e.ea == addr:
+ e.name = name
+ try:
+ table = self.query_one("#func-table", DataTable)
+ name_col = list(table.columns.keys())[1]
+ table.update_cell(str(addr), name_col, name)
+ except Exception: # noqa: BLE001 -- row filtered out / not streamed
+ pass
+ self._open_at(addr, label, idx, False, -1, 0, True)
+ self._dirty = True
+ self._status(f"named {addr:#x} → {name} (Ctrl+S to save)")
+
+ # -- literal display formats (IDA 'o') -------------------------------- #
+ def on_op_format_requested(self, msg: OpFormatRequested) -> None:
+ if self.program is None or self._cur is None:
+ return
+ view = msg.view
+ if isinstance(view, ListingView):
+ ea = view._cursor_ea()
+ if ea is None:
+ self._status("no address on this line to reformat")
+ return
+ head = view.cur_head()
+ if head is not None and head.kind in ("sep", "funchdr", "label"):
+ # A banner/label row carries the NEXT item's address so that
+ # navigation lands somewhere real — but it has no operands of
+ # its own, and a column measured against it would point into
+ # that item at random.
+ self._status("no literal on this line to reformat", priority=True)
+ return
+ self._do_op_format(msg.mode, "listing", ea, view.op_col())
+ return
+ if isinstance(view, DecompView):
+ # The pseudocode's formats are keyed on the FUNCTION Hex-Rays
+ # decompiled, not on the line's own address.
+ fn = view.loaded_ea if view.loaded_ea is not None else self._cur.ea
+ self._do_op_format(msg.mode, "decomp", fn, view.cursor_x, view.cursor)
+
+ @work(thread=True, exclusive=True, group="opformat")
+ def _do_op_format(self, mode: str, where: str, ea: int, col: int,
+ line: int = -1) -> None: # worker context
+ assert self.program is not None
+ try:
+ if where == "listing":
+ r = self.program.op_format(ea, mode=mode, col=col)
+ what = f"op{r.get('n', 0)} "
+ else:
+ r = self.program.pc_num_format(ea, mode=mode, line=line, col=col)
+ what = ""
+ # These are the RESULT of a keypress, so they go on the bar with
+ # priority. Without it a refusal is swallowed by the previous edit's
+ # flash and both the screen and the RPC snapshot still show the last
+ # success -- a call that did nothing reads as one that worked.
+ except IDAToolError as e:
+ self.app.call_from_thread(self._status, f"format: {e.message}",
+ True)
+ return
+ except Exception as e: # noqa: BLE001 -- surface transport failures too
+ self.app.call_from_thread(self._status, f"format: {e}", True)
+ return
+ text = " ".join((r.get("text") or "").split())
+ prev, fmt = r.get("prev", ""), r.get("format", "?")
+ if mode == "show":
+ # A question, not an edit: say what this literal is and what it
+ # could be, and leave the database (and the view) alone.
+ self.app.call_from_thread(
+ self._status,
+ f"{what}{fmt} {r.get('value') or ''}"
+ f" [{', '.join(r.get('choices', []))}]", True)
+ return
+ step = f"{prev} \u2192 {fmt}" if prev and prev != fmt else fmt
+ desc = f"{what}{step}: {text[:96]}"
+ if r.get("warn"):
+ desc += f" \u26a0 {r['warn']}"
+ # Which literal this was, so the cursor can be put back on it after the
+ # reload: the line reflows and the old column stops meaning the same
+ # thing (48 -> 0x30 shifts everything to its right).
+ keep: tuple | None = None
+ if where == "listing":
+ if r.get("n") is not None:
+ keep = ("listing", int(r["n"]))
+ elif r.get("ea"):
+ keep = ("decomp", int(str(r["ea"]), 0), int(r.get("opnum", 0)))
+ self.app.call_from_thread(self._after_op_format, desc, keep)
+
+ def _after_op_format(self, desc: str, keep: tuple | None = None) -> None:
+ # Only the rendering changed, but it changed in the database: drop the
+ # cached rows (and bump the generation, so the decompiler re-runs and
+ # picks up its own new number format) and reopen where we are.
+ self.program.bump_names()
+ if keep is not None:
+ # Set before the reload: both views consume this one-shot when their
+ # new content lands, which is always after this handler returns.
+ if keep[0] == "listing":
+ self.query_one(ListingView)._pending_op = keep[1]
+ else:
+ self.query_one(DecompView)._keep_lit = (keep[1], keep[2])
+ self._reload_active_code()
+ self._dirty = True
+ self._status(f"{desc} (Ctrl+S to save)", priority=True)
+
+ # -- item structure edits (IDA c/p/u) --------------------------------- #
+ def on_edit_item_requested(self, msg: EditItemRequested) -> None:
+ view = msg.view
+ ea = view._cursor_ea() if isinstance(view, ListingView) else None
+ if ea is None:
+ self._status("no address on this line to (re)define")
+ return
+ self._do_edit_item(msg.kind, ea, self._anchor())
+
+ @work(thread=True, exclusive=True, group="edititem")
+ def _do_edit_item(self, kind: str, ea: int,
+ anchor: ViewAnchor | None = None) -> None: # worker context
+ assert self.program is not None
+ verb = {"code": "defined code", "func": "created function",
+ "undef": "undefined", "string": "made string",
+ "thumb": "switched decoding", "thumbscan": "scanned"}[kind]
+ try:
+ if kind == "code":
+ # Keep going until something stops it: one instruction is rarely
+ # what you want, and on a raw image it means pressing `c` once
+ # per opcode for the length of a function.
+ r = self.program.define_code_run(ea)
+ n, why = int(r.get("count", 0)), r.get("stopped", "")
+ if n == 0 and why == "defined":
+ # Already code/data here — a no-op, not a failure. Saying
+ # "failed to create instruction" for it would be a lie.
+ self.app.call_from_thread(
+ self._status, f"already defined @ {ea:#x}")
+ return
+ if n == 0:
+ raise IDAToolError("define_code",
+ f"@ {ea:#x}: Failed to create instruction")
+ end = int(str(r.get("end", hex(ea))), 0)
+ reason = {"undecodable": "hit bytes that don't decode",
+ "flow": "control flow ends here",
+ "defined": "ran into existing code/data",
+ "segment": "end of segment",
+ "limit": "instruction limit"}.get(why, why)
+ verb = (f"defined {n} instruction{'s' if n != 1 else ''} "
+ f"({ea:#x}\u2013{end:#x}) \u2014 {reason}")
+ elif kind == "thumbscan":
+ # A vector table is a list of Thumb entry points that IDA won't
+ # follow on a headerless image, because nothing tells it those
+ # words are pointers. Scan from the cursor.
+ anchor.refresh_functions = True
+ r = self.program.thumb_scan(ea, ea + 0x400)
+ n, applied = int(r.get("n", 0)), int(r.get("applied", 0))
+ if not n:
+ verb = (f"no Thumb entry pointers in {ea:#x}\u2013{ea+0x400:#x}"
+ " (odd words pointing into the image)")
+ else:
+ verb = (f"{n} Thumb entr{'y' if n == 1 else 'ies'} found, "
+ f"{applied} disassembled")
+ elif kind == "thumb":
+ # Switch the mode, then disassemble in it: flipping T and
+ # leaving the bytes undefined shows nothing, and the reason you
+ # flipped it was to read the code.
+ r = self.program.set_thumb(ea)
+ run = self.program.define_code_run(ea)
+ n = int(run.get("count", 0))
+ mode = "Thumb" if r.get("thumb") else "ARM"
+ verb = f"{mode} @ {ea:#x}"
+ if r.get("forced_32bit"):
+ verb += " (segment set to 32-bit; Thumb needs ARM32)"
+ if r.get("db_64bit"):
+ # Disassembly will look right and F5 will never work.
+ verb += (" \u26a0 this database is 64-bit, so Hex-Rays "
+ "won't decompile it \u2014 Ctrl+L and pick "
+ "arm:ARMv7-A")
+ verb += (f" \u2014 {n} instruction{'s' if n != 1 else ''}"
+ if n else " \u2014 still doesn't decode")
+ # falls through to the shared reload: same cache bump, same
+ # anchor restore, same flash. That is the whole point of having
+ # one path.
+ elif kind == "func":
+ anchor.refresh_functions = True
+ r = self.program.define_func(ea)
+ if r.get("start") and r.get("end"):
+ verb = (f"created function {r['start']}\u2013{r['end']}"
+ + (" (end worked out from the code)"
+ if r.get("how") == "explicit-end" else ""))
+ elif kind == "string":
+ s = self.program.make_string(ea)
+ verb = f"made string ({s[:24]!r})" if s else verb
+ else:
+ # Undefining can destroy a function as easily as `p` creates one.
+ anchor.refresh_functions = True
+ self.program.undefine(ea)
+ except Exception as e: # noqa: BLE001 -- surface soft/hard tool errors
+ self.app.call_from_thread(self._status, f"{kind}: {e}")
+ return
+ # Structure changed everywhere: drop all item/function/decomp caches.
+ self.program.bump_items()
+ # Re-resolve: a define_func upgrades the region to a real function view;
+ # anything else re-reads the (still function-less) listing in place.
+ anchor = anchor or ViewAnchor()
+ anchor.flash = f"{verb} @ {ea:#x} (Ctrl+S to save)"
+ fn = self.program.function_of(ea)
+ if fn is not None:
+ model = self.program.disasm(fn.addr, fn.name)
+ idx = 0 if ea == fn.addr else model.index_of_ea(ea)
+ _cur, top = self._anchor_rows(anchor, model, ea)
+ self.app.call_from_thread(
+ self._open_at, fn.addr, fn.name, idx, False, -1, 0, False,
+ None, top)
+ else:
+ name = self.program.region_label(ea)
+ lm = self.program.listing(ea)
+ idx = max(lm.ensure_ea(ea), 0) if lm is not None else 0
+ _cur, top = self._anchor_rows(anchor, lm, ea)
+ self.app.call_from_thread(
+ self._open_at, ea, name, idx, False, -1, 0, True, None, top)
+ self.app.call_from_thread(self._edit_done, anchor)
+
+ def _edit_done(self, anchor: ViewAnchor) -> None:
+ """One place where an edit's aftermath is settled.
+
+ The reload this edit triggered will write its own status when it lands —
+ after this — so the message is handed over as a flash rather than
+ written and lost.
+ """
+ self._dirty = True
+ if anchor.flash:
+ self._status(anchor.flash, priority=True)
+ if anchor.refresh_functions:
+ # Creating (or destroying) a function changes the index that the
+ # names pane, Ctrl+N and the "no functions" hint all read. Without
+ # this, `p` gave you a function the rest of the app couldn't see.
+ self._reindex_functions()
+
+ def _load_trace(self) -> None:
+ """Parse the trace and line it up with the database.
+
+ Runs after the function index exists: rebasing needs the database's
+ addresses, and without it nothing in the trace matches anything on
+ screen (our echo trace runs at 0x7ffff6faa000; the database has that
+ code at 0x2000).
+ """
+ from .trace import Trace
+ path = self._trace_path
+ try:
+ def note(n):
+ self.app.call_from_thread(
+ self._status, f"trace: {n:,} instructions\u2026")
+ trace = Trace.load(path, progress=note)
+ except OSError as e:
+ self.app.call_from_thread(self._status, f"trace: {e}")
+ return
+ if not trace.length:
+ self.app.call_from_thread(
+ self._status, f"trace: {os.path.basename(path)} is empty")
+ return
+ idx = self._func_index
+ addrs = [f.addr for f in idx.all_loaded()] if idx is not None else []
+ slide = trace.rebase(addrs)
+ trace.apply_slide(slide)
+ hit = sum(1 for f in (idx.all_loaded() if idx else []) if trace.executions(f.addr))
+ self.app.call_from_thread(self._trace_ready, trace, slide, hit)
+
+ def _trace_ready(self, trace, slide: int, hit: int) -> None:
+ self._trace = trace
+ self._t = 0
+ dock = self.query_one(TraceDock)
+ dock.display = True
+ dock.show(trace, 0)
+ where = (f"rebased {slide:+#x}" if slide else "no rebase needed")
+ self._status(f"trace: {trace.length:,} instructions, {hit} functions "
+ f"touched ({where})", priority=True)
+ self._seek(0, follow=True)
+
+ # -- trace navigation --------------------------------------------------- #
+ def _seek(self, idx: int, follow: bool = True) -> None:
+ """Move to timestamp ``idx``; ``follow`` takes the code view with it."""
+ t = self._trace
+ if t is None or not t.length:
+ return
+ # A seek invalidates any navigation still in flight. They run in workers
+ # and finish out of order: the trace's opening seek lands on the entry
+ # point, takes a while, and used to arrive AFTER later seeks — dragging
+ # the cursor back to _start while the trace was elsewhere, permanently.
+ #
+ # Bumped HERE and not in _goto_ea. Doing it for every navigation is the
+ # more general rule ("the last thing you asked for wins") but it also
+ # lets an ordinary follow be dropped by whatever navigates next, and the
+ # only evidence I have is about seeks. Narrow fix for the measured bug.
+ self._nav_seq += 1
+ self._t = max(0, min(int(idx), t.length - 1))
+ self.query_one(TraceDock).show(t, self._t)
+ self._paint_trail()
+ if not follow:
+ return
+ pc = t.ip(self._t)
+ if self._split and self._seek_split(pc):
+ return
+ # Stay in whichever view you're reading. Without prefer_decomp a step
+ # from the pseudocode navigates to an address, which opens the listing —
+ # so stepping through C threw you out of C on the first keypress.
+ self._goto_ea(pc, push=False,
+ prefer_decomp=(self._active == "decomp"))
+
+ def _seek_split(self, pc: int) -> bool:
+ """Put BOTH panes on ``pc``. True if handled.
+
+ Normal navigation moves one pane and gives the companion a band, never a
+ cursor — that rule exists so the two can't chase each other. A trace step
+ isn't navigation though: time is a single global position, and both views
+ are showing the same instant, so both cursors belong on it.
+
+ The scroll anchoring is unchanged: after placing the cursors, the usual
+ _sync_split still bands the companion and aligns it to the driver's
+ screen row, so the eye tracks straight across.
+ """
+ lst = self.query_one(ListingView)
+ dec = self.query_one(DecompView)
+ if lst.model is None:
+ return False
+ row = lst.model.ensure_ea(pc)
+ if row is None or row < 0:
+ return False # not in this listing (other segment): full nav
+ lst.cursor = row
+ lst._scroll_cursor_into_view()
+
+ # Has execution actually left the decompiled function? Ask the map the
+ # trail painting keeps, which is keyed to what the decompiler currently
+ # HOLDS. _split_range comes from the guarded async path and lags, so a
+ # stale one made every step look like a function change: the decompiler
+ # bounced main -> PLT stub -> main, each bounce costing a synchronous
+ # 769-line map fetch on the UI thread.
+ span = self._trail_span
+ inside = (pc in self._trail_line_of
+ or (span is not None and span[0] <= pc <= span[1]))
+ if not inside:
+ self._pending_trace_line = pc
+ self._resync_decomp_async(pc)
+ return True
+ self._place_decomp_at(pc)
+ self._sync_split(self._active)
+ return True
+
+ def _place_decomp_at(self, pc: int) -> None:
+ """Move the pseudocode cursor to the line covering ``pc``.
+
+ Uses the map the trail painting already keeps (keyed to the decompiler's
+ CURRENTLY loaded function), not the split view's _split_ea2line. That one
+ is refreshed by a guarded async path — it drops a result if _cur moved
+ while it was in flight — and a burst of steps moves _cur constantly, so
+ during stepping it is frequently a map of the function you just left.
+ """
+ dec = self.query_one(DecompView)
+ line = None
+ if self._trail_map_ea == dec.loaded_ea and self._trail_line_of:
+ # EXACT match only. The decompiler doesn't attribute every
+ # instruction to a line (about half of main's aren't), and the
+ # tempting fallback — the nearest mapped instruction at or before
+ # the pc — is unsound: C lines are not monotonic in address, so
+ # 0x24a8 early in main resolved to line 708, "sub_2040();", near the
+ # end. A cursor that jumps to an unrelated statement is worse than
+ # one that waits; the trail still marks where we are.
+ line = self._trail_line_of.get(pc)
+ if line is None:
+ line = self._split_ea2line.get(pc)
+ if line is None:
+ line = dec.line_for_ea(pc)
+ if line is not None:
+ dec.goto(line, dec.cursor_x)
+
+ @work(thread=True, exclusive=True, group="split-resync")
+ def _resync_decomp_async(self, ea: int) -> None:
+ self._resync_decomp(ea)
+
+ def _paint_trail(self) -> None:
+ """Push the execution trail into the code views.
+
+ Recomputed per seek rather than per repaint: it's ~200 lookups, and a
+ repaint happens far more often than a step.
+ """
+ t = self._trace
+ if t is None:
+ return
+ try:
+ hx = self.query_one(HexView)
+ hx.trace, hx.trace_idx = t, self._t
+ if hx.display:
+ hx.refresh()
+ except Exception: # noqa: BLE001 -- not mounted yet
+ pass
+ trail = t.trail(self._t)
+ try:
+ lst = self.query_one(ListingView)
+ lst.trail = trail
+ lst.refresh()
+ except Exception: # noqa: BLE001 -- view not mounted yet
+ pass
+ self._paint_trail_decomp(trail)
+
+ def _paint_trail_decomp(self, trail: dict) -> None:
+ """Map the instruction trail onto pseudocode lines.
+
+ This is the thing Tenet can't do: it paints disassembly, because that's
+ where a trace's addresses live. We already have decomp_map (built for
+ the split view) saying which instructions each pseudocode line covers,
+ so the same trail lands on C.
+
+ A line covers many instructions, so it takes the strongest kind present:
+ 'now' wins over 'past' wins over 'future' — if the instruction you are
+ standing on is part of this line, this line is where you are.
+ """
+ try:
+ dec = self.query_one(DecompView)
+ except Exception: # noqa: BLE001
+ return
+ ea = dec.loaded_ea
+ if not dec.display or ea is None or self.program is None:
+ dec.trail = {}
+ return
+ if self._trail_map_ea != ea:
+ # One index, built once per decompiled function and shared with the
+ # split view (_apply_split_map fills the same fields). decomp_map is
+ # an RPC and stepping is interactive, so paying it per keystroke —
+ # or twice, once for each of two parallel maps — would be felt.
+ try:
+ self._apply_split_map(ea, self.program.decomp_map(ea))
+ except Exception: # noqa: BLE001
+ self._trail_map, self._trail_map_ea = [], ea
+ self._trail_line_of, self._trail_eas = {}, []
+ self._trail_span = None
+ rank = {"future": 0, "past": 1, "now": 2}
+ lines: dict[int, str] = {}
+ for i, eas in enumerate(self._trail_map or []):
+ best = None
+ for a in eas:
+ k = trail.get(a)
+ if k is not None and (best is None or rank[k] > rank[best]):
+ best = k
+ if best is not None:
+ lines[i] = best
+ dec.trail = lines
+ dec.refresh()
+ pend, self._pending_trace_line = self._pending_trace_line, None
+ if pend is not None and self._split:
+ # The function was still decompiling when the step happened; land
+ # now that its line map exists.
+ self._place_decomp_at(pend)
+ self._sync_split(self._active)
+
+ def _step(self, delta: int) -> None:
+ if self._trace is None:
+ self._status("no trace loaded (--trace FILE)")
+ return
+ self._seek(self._t + delta)
+
+ def _seek_hit(self, direction: int) -> None:
+ """Seek to the next/previous time the focused view's subject was touched.
+
+ Two different questions with one pair of keys, because the answer to
+ "which thing?" is already on screen: in a code view it's the instruction
+ under the cursor ("when else did this run?"), in hex it's the byte under
+ the cursor ("who else touched this?").
+ """
+ t = self._trace
+ if t is None:
+ self._status("no trace loaded (--trace FILE)")
+ return
+ if self._active == "hex":
+ hx = self._try_view(HexView)
+ va = hx.cursor_va() if hx is not None else None
+ if va is None:
+ return
+ stamps = t.memory_accesses(va, 1)
+ what = f"access to {va:#x}"
+ else:
+ view = self._active_code_view()
+ if isinstance(view, DecompView):
+ # A C line is not one address, so ask about the whole statement:
+ # "when else did this line run?" is the question, and it's the
+ # union of its instructions' executions. Falling back to the
+ # line's single /*ea*/ marker would answer a narrower question
+ # and often no question at all, since most lines have no marker.
+ line = view.cursor
+ eas = []
+ if (self._trail_map_ea == view.loaded_ea
+ and 0 <= line < len(self._trail_map or [])):
+ eas = list(self._trail_map[line])
+ if not eas:
+ one = view._line_ea(line)
+ eas = [one] if one is not None else []
+ if not eas:
+ self._status("this line has no instructions to seek on",
+ priority=True)
+ return
+ stamps = sorted({x for e in eas for x in t.executions(e)})
+ what = f"execution of C line {line + 1}"
+ else:
+ ea = view._cursor_ea() if view is not None else None
+ if ea is None:
+ self._status("no address on this line", priority=True)
+ return
+ stamps = list(t.executions(ea))
+ what = f"execution of {ea:#x}"
+ if not stamps:
+ self._status(f"no {what} in this trace", priority=True)
+ return
+ import bisect as _b
+ if direction > 0:
+ i = _b.bisect_right(stamps, self._t)
+ else:
+ i = _b.bisect_left(stamps, self._t) - 1
+ if not (0 <= i < len(stamps)):
+ edge = "last" if direction > 0 else "first"
+ self._status(f"already at the {edge} {what} "
+ f"({len(stamps)} in the trace)", priority=True)
+ return
+ self._seek(stamps[i])
+ self._status(f"{what}: {i + 1} of {len(stamps)} @ t={stamps[i]:,}",
+ priority=True)
+
+ def action_seek_next_hit(self) -> None:
+ self._seek_hit(1)
+
+ def action_seek_prev_hit(self) -> None:
+ self._seek_hit(-1)
+
+ def action_seek_reg_write(self) -> None:
+ """W: which instruction set each register to its current value."""
+ t = self._trace
+ if t is None:
+ self._status("no trace loaded (--trace FILE)")
+ return
+ rows = []
+ for name in t.registers:
+ v = t.register(name, self._t)
+ if v is None:
+ continue
+ rows.append((name, v, t.last_write(name, self._t),
+ t.next_write(name, self._t)))
+ if rows:
+ self.push_screen(RegWriteScreen(rows, self._t), self._on_reg_write_chosen)
+
+ def _on_reg_write_chosen(self, idx) -> None: # type: ignore[no-untyped-def]
+ if idx is not None:
+ self._seek(int(idx))
+
+ def action_step_fwd(self) -> None:
+ self._step(1)
+
+ def action_step_back(self) -> None:
+ self._step(-1)
+
+ def _step_over(self, direction: int) -> None:
+ """Step over a call by following the stack pointer.
+
+ A call pushes, so the callee runs with SP BELOW where we started;
+ stepping until SP comes back up lands after the call returns. Cheaper
+ and more robust than recognising call instructions per architecture,
+ which is what the mode makes it: if this instruction doesn't call
+ anything, SP is already >= the start and it degenerates to one step.
+ """
+ t = self._trace
+ if t is None:
+ self._status("no trace loaded (--trace FILE)")
+ return
+ sp_name = "rsp" if "rsp" in t.reg_at else ("esp" if "esp" in t.reg_at else "sp")
+ sp0 = t.register(sp_name, self._t)
+ i = self._t + direction
+ limit = 200000 # a runaway search must not hang the UI
+ while 0 <= i < t.length and limit > 0:
+ sp = t.register(sp_name, i)
+ if sp0 is None or sp is None or sp >= sp0:
+ break
+ i += direction
+ limit -= 1
+ self._seek(max(0, min(i, t.length - 1)))
+
+ def action_step_over_fwd(self) -> None:
+ self._step_over(1)
+
+ def action_step_over_back(self) -> None:
+ self._step_over(-1)
+
+ @work(thread=True, exclusive=True, group="load-funcs")
+ def _reindex_functions(self) -> None:
+ """Rebuild the function index in place after an edit changed it.
+
+ Deliberately not _load_functions(): that one is the BOOT path — it
+ clears the table, streams progress and then auto-lands, which would
+ yank the view away from the function you just made.
+ """
+ if self.program is None:
+ return
+ idx = self.program.functions()
+ idx.load_all()
+ self._func_index = idx
+ self.app.call_from_thread(self._after_reindex)
+
+ def _after_reindex(self) -> None:
+ idx = self._func_index
+ if idx is None:
+ return
+ if len(idx):
+ self._no_functions = False
+ self._apply_filter(self._filter_term) # repopulate the names pane
+
@work(thread=True, exclusive=True, group="save")
def _save(self) -> None:
assert self.program is not None
@@ -2674,32 +7354,81 @@ class IdaTui(App):
# -- navigation to an arbitrary address ------------------------------- #
@work(thread=True, group="nav")
def _goto_ea(self, ea: int, push: bool = True,
- focus_name: str | None = None) -> None:
- self._do_navigate(ea, push, focus_name)
+ focus_name: str | None = None, prefer_decomp: bool = False) -> None:
+ self._do_navigate(ea, push, focus_name, prefer_decomp)
- def _do_navigate(self, ea: int, push: bool,
- focus_name: str | None = None) -> None: # worker context
+ def _do_navigate(self, ea: int, push: bool, focus_name: str | None = None,
+ prefer_decomp: bool = False) -> None: # worker context
assert self.program is not None
+ # Which navigation this is. Decompiling below can take a while, and if
+ # you press Esc (or jump again) in the meantime this result is stale —
+ # applying it anyway silently undoes what you just did.
+ seq = self._nav_seq
fn = self.program.function_of(ea)
- if fn is None:
- self.app.call_from_thread(self._status, f"no function contains {ea:#x}")
- return
- model = self.program.disasm(fn.addr, fn.name)
- idx = 0 if ea == fn.addr else model.index_of_ea(ea)
- # The disasm cursor alone doesn't position the pseudocode pane. For a
- # mid-function target with the decompiler active, resolve the address to
- # its pseudocode line (via the per-line /*0xEA*/ markers) so the jump
- # lands on the reference there too (e.g. selecting an xref). A plain
- # function-entry jump is line 0 in both views -> skip the decompile.
- # With a focus_name (the symbol the xref was on), also land the column
- # on that token where it appears in the line.
- dec_idx, dec_col = -1, 0
- if self._active == "decomp" and ea != fn.addr:
- dec_idx = self._decomp_line_for(fn.addr, ea)
- if dec_idx >= 0 and focus_name:
- dec_col = self._decomp_col_for(fn.addr, dec_idx, focus_name)
+ # Jumping FROM the decompiler: stay in pseudocode when the target is a
+ # decompilable function, landing on the line that matches ``ea``.
+ if prefer_decomp and fn is not None:
+ dec = self.program.decompile(fn.addr)
+ if not dec.failed and dec.code:
+ if ea == fn.addr:
+ # Jumping to the function itself: land on its name in the
+ # prototype (line 0), not column 0.
+ dec_idx = 0
+ col = self._decomp_col_for(fn.addr, 0, fn.name)
+ else:
+ # A mid-function site (e.g. an xref jump to a call site):
+ # anchor on the address but snap to the nearest line that
+ # actually holds the referenced symbol (the marker line and
+ # the symbol's line can differ), landing on the token.
+ dec_idx, col = self._decomp_locate(fn.addr, ea,
+ focus_name or fn.name)
+ self.app.call_from_thread(
+ self._open_decomp_entry, fn.addr, fn.name, dec_idx, col,
+ push, seq)
+ return
+ # Otherwise: everything opens the one continuous listing at ``ea``. A
+ # function name is used for the status label; a region gets a segment
+ # label. F5/Tab decompiles the function under the cursor from here.
+ lm = self.program.listing(ea)
+ idx = max(lm.ensure_ea(ea), 0) if lm is not None else 0
+ name = fn.name if fn is not None else self.program.region_label(ea)
self.app.call_from_thread(
- self._open_at, fn.addr, fn.name, idx, push, dec_idx, dec_col)
+ self._open_at_if_current, seq, ea, name, idx, push, fn is None,
+ focus_name)
+
+ def _open_decomp_entry(self, fn_addr: int, fn_name: str, dec_idx: int,
+ dec_cursor_x: int, push: bool,
+ seq: int | None = None) -> None:
+ """Open ``fn_addr`` in the decompiler as a real navigation (nav history
+ aware), landing on pseudocode line ``dec_idx`` column ``dec_cursor_x``.
+
+ ``seq`` is the navigation this result belongs to; if the user has
+ navigated since (Esc, another jump), the result is stale and dropped —
+ otherwise a slow decompile lands afterwards and undoes their Esc.
+ """
+ if seq is not None and seq != self._nav_seq:
+ return
+ if push:
+ # Snapshot where we jumped from so 'back' returns there. If that was
+ # the pseudocode (F5 makes a transient _cur not yet on the stack),
+ # record its position and push it as a decomp entry.
+ if self._active == "decomp" and self._cur is not None:
+ dv = self.query_one(DecompView)
+ src = self._cur
+ src.view = "decomp"
+ src.dec_cursor = dv.cursor
+ src.dec_cursor_x = dv.cursor_x
+ src.dec_scroll_y = round(dv.scroll_offset.y)
+ if not self._nav or self._nav[-1] is not src:
+ self._push_nav(src) # never stack a second copy of a spot
+ else:
+ self._save_current_pos()
+ self._decomp_return = None # a real navigation abandons the F5 return
+ entry = NavEntry(ea=fn_addr, name=fn_name, view="decomp",
+ dec_cursor=dec_idx, dec_cursor_x=dec_cursor_x)
+ if push:
+ self._push_nav(entry)
+ self._open_entry(entry, push=False)
def _decomp_col_for(self, fn_addr: int, line_idx: int, name: str) -> int:
"""Column of ``name`` (whole-word) on pseudocode line ``line_idx``, so an
@@ -2717,6 +7446,40 @@ class IdaTui(App):
m = re.search(rf"\b{re.escape(name)}\b", clean)
return m.start() if m else 0
+ def _decomp_locate(self, fn_addr: int, ea: int,
+ token: str | None) -> tuple[int, int]:
+ """Best (line, column) for address ``ea`` in ``fn_addr``'s pseudocode.
+
+ Anchors on the /*0xEA*/ marker line for ``ea``, but Hex-Rays can attribute
+ an address to a line a step off from where the referenced symbol actually
+ appears. So when ``token`` is given, snap to the whole-word occurrence of
+ it NEAREST the anchor line (preferring the anchor line itself, then the
+ line just below), which disambiguates repeated symbols by address."""
+ assert self.program is not None
+ try:
+ dec = self.program.decompile(fn_addr)
+ except Exception: # noqa: BLE001
+ return (0, 0)
+ lines = (dec.code or "").splitlines()
+ if not lines:
+ return (0, 0)
+ anchor = self._decomp_line_for(fn_addr, ea)
+ anchor = anchor if anchor >= 0 else 0
+ if not token:
+ return (anchor, 0)
+ pat = re.compile(rf"\b{re.escape(token)}\b")
+ best: tuple[int, int] | None = None
+ best_key: tuple[int, int] | None = None
+ for i, ln in enumerate(lines):
+ m = pat.search(_ADDR_MARK_STRIP_RE.sub("", ln))
+ if not m:
+ continue
+ # rank: nearest to the anchor; tie -> the line at/after the anchor.
+ key = (abs(i - anchor), 0 if i >= anchor else 1)
+ if best_key is None or key < best_key:
+ best_key, best = key, (i, m.start())
+ return best if best is not None else (anchor, 0)
+
def _decomp_line_for(self, fn_addr: int, ea: int) -> int:
"""Pseudocode line index best matching address ``ea``: the line whose
/*0xEA*/ marker is the largest address <= ``ea``. -1 if unavailable
@@ -2736,16 +7499,104 @@ class IdaTui(App):
best_ea, best_idx = e, i
return best_idx
+ @staticmethod
+ def _same_spot(a: NavEntry, b: NavEntry) -> bool:
+ """Same function, same view, same line — i.e. going from a to b is not a
+ move, so it has no business being a step in the history."""
+ if a.ea != b.ea or a.view != b.view:
+ return False
+ return (a.dec_cursor == b.dec_cursor if a.view == "decomp"
+ else a.cursor == b.cursor)
+
+ def _push_nav(self, entry: NavEntry) -> None:
+ """Append to the nav stack unless that would duplicate where we already are.
+
+ Opening the place you're standing on — the app auto-lands on main, then
+ you pick main out of Ctrl+N — used to append an identical entry. The Esc
+ it created popped the stack without changing anything on screen: a dead
+ keypress, which is exactly what "back doesn't work" feels like. Replace
+ the top instead, so the newer entry's metadata still wins.
+ """
+ top = self._nav[-1] if self._nav else None
+ if top is not None and self._same_spot(top, entry):
+ self._nav[-1] = entry
+ return
+ self._nav.append(entry)
+
+ # -- view state across a model rebuild --------------------------------- #
+ def _anchor(self, flash: str | None = None) -> ViewAnchor:
+ """Capture where we're looking, BEFORE an edit rebuilds the model.
+
+ Must run on the UI thread: it reads live widget state.
+ """
+ a = ViewAnchor(view=self._active, flash=flash)
+ view = self._active_code_view()
+ model = getattr(view, "model", None)
+ if view is None or model is None:
+ return a
+ a.cursor_x = getattr(view, "cursor_x", 0)
+ try:
+ a.ea = view._cursor_ea()
+ except Exception: # noqa: BLE001
+ a.ea = None
+ top = round(view.scroll_offset.y)
+ h = model.cached_line(top) or model.get(top)
+ a.top_ea = getattr(h, "ea", None)
+ return a
+
+ @staticmethod
+ def _anchor_rows(a: ViewAnchor, model, fallback_ea: int | None = None):
+ """(cursor_row, top_row) for ``a`` in a freshly built ``model``.
+
+ -1 means "no opinion" — the caller's own default wins.
+ """
+ if model is None:
+ return (-1, -1)
+
+ def row_of(ea):
+ if ea is None:
+ return -1
+ try:
+ i = model.index_of_ea(ea)
+ except Exception: # noqa: BLE001
+ return -1
+ return i if i >= 0 else -1
+
+ cur = row_of(a.ea if a.ea is not None else fallback_ea)
+ if cur < 0 and fallback_ea is not None:
+ cur = row_of(fallback_ea)
+ return (cur, row_of(a.top_ea))
+
+ def _open_at_if_current(self, seq: int, ea: int, name: str, cursor: int,
+ push: bool, is_region: bool,
+ focus_name: str | None) -> None:
+ """Apply a navigation result only if it's still the one being awaited.
+
+ The decompiler path has had this since 756589a; the listing path hadn't,
+ so a slow navigation could still land on top of a newer one.
+ """
+ if seq != self._nav_seq:
+ return
+ self._open_at(ea, name, cursor, push, -1, 0, is_region, focus_name)
+
def _open_at(self, ea: int, name: str, cursor: int, push: bool,
- dec_cursor: int = -1, dec_cursor_x: int = 0) -> None:
+ dec_cursor: int = -1, dec_cursor_x: int = 0,
+ is_region: bool = False, focus_name: str | None = None,
+ scroll_y: int = -1) -> None:
if push:
self._save_current_pos()
- entry = NavEntry(ea=ea, name=name, cursor=cursor)
+ self._decomp_return = None # a real navigation abandons the F5 return
+ # Land the cursor on this token (e.g. the ref an xref jump targets) once
+ # the row is loaded, instead of column 0.
+ self._pending_focus_name = focus_name
+ entry = NavEntry(ea=ea, name=name, cursor=cursor, is_region=is_region)
+ if scroll_y >= 0:
+ entry.scroll_y = scroll_y
if dec_cursor >= 0:
entry.dec_cursor = dec_cursor
entry.dec_cursor_x = dec_cursor_x
if push:
- self._nav.append(entry)
+ self._push_nav(entry)
self._open_entry(entry, push=False)
def on_input_changed(self, event: Input.Changed) -> None:
@@ -2772,6 +7623,8 @@ class IdaTui(App):
view.focus()
def on_key(self, event) -> None: # type: ignore[no-untyped-def]
+ # Any keypress means the result of the last edit has been read.
+ self._flash = None
if event.key != "escape":
return
if self.query_one("#search", Input).display:
@@ -2794,12 +7647,17 @@ class IdaTui(App):
event.prevent_default()
self._end_retype()
return
+ if self.query_one("#makedata", Input).display:
+ event.stop()
+ event.prevent_default()
+ self._end_makedata()
+ return
if self.query_one("#goto", Input).display:
event.stop()
event.prevent_default()
self._end_goto()
(self.query_one(HexView) if self._active == "hex"
- else self._code_view()).focus()
+ else (self._code_view() or self.query_one(ListingView))).focus()
return
fi = self.query_one("#func-filter", Input)
if fi.display:
@@ -2826,7 +7684,12 @@ class IdaTui(App):
return
if inp.id == "rename":
view, old = self._rename_ctx
+ addr = self._rename_addr # capture before _end_rename clears it
self._end_rename()
+ if addr is not None: # listing: name this address (create a label)
+ if value and value != old:
+ self._do_name_addr(addr, value)
+ return
if view is not None and value and value != old:
self._do_rename(view, old, value)
return
@@ -2842,10 +7705,16 @@ class IdaTui(App):
if view is not None and value:
self._do_retype(kind, subject, word, value)
return
+ if inp.id == "makedata":
+ view, ea = self._makedata_ctx
+ self._end_makedata()
+ if view is not None and value:
+ self._do_make_data(ea, value, self._anchor())
+ return
if inp.id == "goto":
self._end_goto()
(self.query_one(HexView) if self._active == "hex"
- else self._code_view()).focus()
+ else (self._code_view() or self.query_one(ListingView))).focus()
if value:
self._goto(value)
return
@@ -2854,7 +7723,72 @@ class IdaTui(App):
self.query_one("#func-table", DataTable).focus()
def _code_view(self): # type: ignore[no-untyped-def]
- return self.query_one(DecompView if self._pref == "decomp" else DisasmView)
+ """The code pane the user is in — where focus belongs after a prompt closes.
+
+ This used to pick on _pref, which was always "listing", so closing the
+ goto prompt while reading pseudocode threw focus into the listing.
+ """
+ return self._active_code_view()
+
+ def _active_code_view(self): # type: ignore[no-untyped-def]
+ """The currently-shown code widget (for reading the cursor address).
+
+ Everything downstream trusts ``_active``, so a new mode that forgets to
+ appear here doesn't degrade -- it returns None and the first caller that
+ dereferences it crashes the app (which is exactly how graph mode
+ announced itself the first time it was driven).
+ """
+ if self._active in ("listing", "disasm"):
+ return self.query_one(ListingView)
+ if self._active == "decomp":
+ return self.query_one(DecompView)
+ if self._active == "graph":
+ return self.query_one(GraphView)
+ return None
+
+ def _focus_code_view(self) -> None:
+ """Put focus back on whichever code view is showing."""
+ (self._active_code_view() or self.query_one(ListingView)).focus()
+
+ def _palette_action(self, name: str, *args) -> None:
+ """Run a cursor-scoped code-view action (rename/xrefs/follow/…) picked from
+ the command palette against the active code view."""
+ view = self._active_code_view()
+ if view is None:
+ self._status("open a function first")
+ return
+ view.focus()
+ fn = getattr(view, f"action_{name}", None)
+ if fn is None:
+ self._status(f"'{name}' isn't available in this view")
+ return
+ fn(*args)
+
+ def action_continuous_here(self) -> None:
+ """'L': open the continuous segment listing at the cursor — one long
+ flat view where functions, data and undefined bytes are interleaved,
+ instead of the function-bounded disassembly."""
+ if self.program is None:
+ return
+ view = self._active_code_view()
+ ea = self._line_ea_for(view) if view is not None else None
+ if ea is None and self._cur is not None:
+ ea = self._cur.ea
+ if ea is None:
+ self._status("no address here to open the continuous listing")
+ return
+ if self._cur is not None and self._cur.is_region:
+ self._status("already in the continuous listing")
+ return
+ self._goto_continuous(ea)
+
+ @work(thread=True, group="nav")
+ def _goto_continuous(self, ea: int, push: bool = True) -> None:
+ assert self.program is not None
+ name = self.program.region_label(ea)
+ lm = self.program.listing(ea)
+ idx = max(lm.ensure_ea(ea), 0) if lm is not None else 0
+ self.app.call_from_thread(self._open_at, ea, name, idx, push, -1, 0, True)
@work(thread=True, exclusive=True, group="goto")
def _goto(self, target: str) -> None:
@@ -2867,6 +7801,13 @@ class IdaTui(App):
if self._active == "hex":
self.app.call_from_thread(self._hex_goto, ea)
return
+ # A goto that lands inside the graph you're already looking at should
+ # move the cursor, not tear the picture down and build the same one.
+ if self._active == "graph":
+ gv = self.query_one(GraphView)
+ if gv.fc is not None and gv.fc.block_at(ea) is not None:
+ self.app.call_from_thread(gv.goto_ea, ea)
+ return
# Navigate to the containing function at the right line (handles both a
# function name and a mid-function address).
self._do_navigate(ea, push=True)
@@ -2891,61 +7832,140 @@ class IdaTui(App):
self._open_function(ea, name)
def _save_current_pos(self) -> None:
- """Snapshot the active views' cursor + scroll into the top history entry
+ """Snapshot the listing cursor + scroll into the top history entry
(called just before we navigate away)."""
if not self._nav:
return
e = self._nav[-1]
- dis = self.query_one(DisasmView)
- e.cursor, e.cursor_x = dis.cursor, dis.cursor_x
- e.scroll_y = round(dis.scroll_offset.y)
- dec = self.query_one(DecompView)
- if dec.loaded_ea == e.ea:
- e.dec_cursor, e.dec_cursor_x = dec.cursor, dec.cursor_x
- e.dec_scroll_y = round(dec.scroll_offset.y)
- e.dec_scroll_x = round(dec.scroll_offset.x)
+ lst = self.query_one(ListingView)
+ if lst.model is not None:
+ e.cursor, e.cursor_x = lst.cursor, lst.cursor_x
+ e.scroll_y = round(lst.scroll_offset.y)
- def _open_function(self, ea: int, name: str, push: bool = True) -> None:
- if push:
- self._save_current_pos()
- entry = NavEntry(ea=ea, name=name, cursor=0)
- if push:
- self._nav.append(entry)
- self._open_entry(entry, push=False)
+ @work(thread=True, group="nav")
+ def _open_function(self, ea: int, name: str | None = None,
+ push: bool = True) -> None:
+ # Unified: opening a function is just navigating the one linear listing
+ # to its entry address.
+ self._do_navigate(ea, push)
+
+
+ def _code_mode(self) -> str:
+ """The code view to return to from hex — always the unified listing."""
+ return "listing"
def _open_entry(self, entry: NavEntry, push: bool) -> None:
if self.program is None:
return
self._cur = entry
- # Each open honours the preferred view; a decomp failure last time fell
- # back to disasm without changing the preference, so retry decomp here.
- self._active = self._pref
- model = self.program.disasm(entry.ea, entry.name)
+ focus = self._pending_focus_name # one-shot: consume it here
+ self._pending_focus_name = None
+ if entry.view == "decomp":
+ # This entry was viewed in the decompiler (a jump from pseudocode, or
+ # a back/forward to one) — restore it there instead of the listing.
+ self._active = "decomp"
+ dec = self.query_one(DecompView)
+ if dec.loaded_ea == entry.ea:
+ # already decompiled: reposition without a recompile
+ dec.goto(entry.dec_cursor, entry.dec_cursor_x,
+ entry.dec_scroll_y if entry.dec_scroll_y >= 0 else -1,
+ entry.dec_scroll_x)
+ self._show_active() # loads the pseudocode if loaded_ea != entry.ea
+ return
+ # Unified model: the code view is always the continuous listing,
+ # positioned at this entry. The decompiler is a per-function toggle
+ # (F5/Tab -> _decomp_from_listing), never opened implicitly.
+ lst = self.query_one(ListingView)
+ lm = self.program.listing(entry.ea)
sy = entry.scroll_y if entry.scroll_y >= 0 else None
- self.query_one(DisasmView).load(
- model, entry.name, cursor=entry.cursor, cursor_x=entry.cursor_x, scroll_y=sy)
- dec = self.query_one(DecompView)
- # If the decompiler already shows this function, _show_active won't reload
- # it (and thus won't reposition), so move its cursor to the target line
- # here — e.g. an xref/goto whose target is inside the current function.
- reposition_dec = dec.loaded_ea == entry.ea
+ if lm is not None:
+ if sy is None:
+ # Fresh jump (not a back/forward restore, which carries its own
+ # scroll). If the target is already on-screen in the SAME listing,
+ # leave the viewport alone and just move the cursor; otherwise
+ # scroll so the target sits a few lines below the top for context.
+ top = round(lst.scroll_offset.y)
+ if lst.model is lm and top <= entry.cursor < top + lst._visible_height():
+ sy = top
+ else:
+ sy = max(entry.cursor - _JUMP_CONTEXT, 0)
+ lst.load(
+ lm, entry.name, cursor=entry.cursor,
+ cursor_x=entry.cursor_x, scroll_y=sy, focus=focus)
+ self._active = "listing"
self._show_active()
- if reposition_dec and self._active == "decomp":
- dsy = entry.dec_scroll_y if entry.dec_scroll_y >= 0 else -1
- dec.goto(entry.dec_cursor, entry.dec_cursor_x, dsy, entry.dec_scroll_x)
+ # Graph mode is sticky: following a call from the graph should land in
+ # the callee's graph, not dump you back into the listing. The rebuild is
+ # async and re-checks _cur, so a fast second jump just drops the stale one.
+ if self._graph_sticky and entry.ea:
+ self._load_graph(entry.ea, entry.ea)
+
+ # Prompt overlays that own the keyboard while visible; a background
+ # navigation must not yank focus out from under them (else typed keys leak
+ # into a code view as destructive verbs — e.g. 'u' = undefine).
+ _PROMPT_IDS = ("search", "rename", "comment", "retype", "goto", "func-filter")
+
+ def _prompt_active(self) -> bool:
+ for iid in self._PROMPT_IDS:
+ try:
+ if self.query_one(f"#{iid}", Input).display:
+ return True
+ except Exception: # noqa: BLE001 — widget not mounted yet
+ pass
+ return False
def _show_active(self) -> None:
- dis = self.query_one(DisasmView)
dec = self.query_one(DecompView)
+ lst = self.query_one(ListingView)
hx = self.query_one(HexView)
- dis.display = dec.display = hx.display = False
- if self._active == "disasm":
- dis.display = True
- dis.focus()
- self._status_for_cur("disasm")
+ gv = self.query_one(GraphView)
+ # Don't steal focus from an open prompt (search/rename/…) — a late async
+ # navigation completing here would otherwise pull it into the code view.
+ grab = not self._prompt_active()
+ if self._split and self._active in ("listing", "decomp"):
+ # Side-by-side: listing (left) + pseudocode (right), one focused.
+ hx.display = gv.display = False
+ lst.display = dec.display = True
+ self.query_one("#panes").set_class(True, "split")
+ busy = self._cur is not None and dec.loaded_ea != self._cur.ea
+ if busy:
+ dec.loading = True
+ self._load_decomp(self._cur.ea, self._cur.name)
+ else:
+ dec.loading = False
+ if grab:
+ (dec if self._active == "decomp" else lst).focus()
+ if busy and self._cur is not None:
+ # Keep the in-flight message: the idle status used to overwrite
+ # it, so travelling history in split showed nothing at all while
+ # a decompile ran and Esc looked like a no-op.
+ self._status(f"{self._cur.name} \u2014 decompiling\u2026")
+ else:
+ self._status_for_cur("split")
+ return
+ self._split = False # a single-view target (hex, etc.) leaves split
+ self.query_one("#panes").set_class(False, "split")
+ lst.set_link(set())
+ dec.set_link(None)
+ self._split_eamap = []
+ self._split_ea2line = {}
+ self._split_range = None
+ dec.display = lst.display = hx.display = gv.display = False
+ if self._active == "graph":
+ gv.display = True
+ if grab:
+ gv.focus()
+ self._graph_status()
+ return
+ if self._active in ("listing", "disasm"):
+ lst.display = True
+ if grab:
+ lst.focus()
+ self._status_for_cur("listing")
elif self._active == "hex":
hx.display = True
- hx.focus()
+ if grab:
+ hx.focus()
ea = getattr(self, "_hex_pending_ea", None)
self._hex_pending_ea = None
if hx.model is None:
@@ -2957,12 +7977,17 @@ class IdaTui(App):
self._hex_status(hx.cursor_va())
else:
dec.display = True
- dec.focus()
+ if grab:
+ dec.focus()
if self._cur is not None and dec.loaded_ea != self._cur.ea:
self._status(f"{self._cur.name} — decompiling…")
dec.loading = True # gray out + 'decompiling…' overlay
self._load_decomp(self._cur.ea, self._cur.name)
else:
+ # Already showing this function: clear any overlay raised by the
+ # F5-from-listing path (no re-decompile happens here, so nothing
+ # else would).
+ dec.loading = False
self._status_for_cur("pseudocode")
# -- hex view ---------------------------------------------------------- #
@@ -2992,11 +8017,11 @@ class IdaTui(App):
self._hex_status(msg.va)
def on_hex_view_leave(self, msg: HexView.Leave) -> None:
- self._active = self._pref
+ self._active = self._code_mode()
self._show_active()
def on_hex_view_to_code(self, msg: HexView.ToCode) -> None:
- self._active = self._pref
+ self._active = self._code_mode()
self._goto_ea(msg.va, push=True)
def _status_for_cur(self, mode: str) -> None:
@@ -3007,21 +8032,49 @@ class IdaTui(App):
def _load_decomp(self, ea: int, name: str) -> None:
assert self.program is not None
dec = self.program.decompile(ea)
- self.app.call_from_thread(self._apply_decomp, ea, name, dec)
+ why = ""
+ if not dec.failed:
+ # Where the number literals are, fetched in the same worker as the
+ # decompile (it is one call and the answer is cached with it) so the
+ # view can mark the one under the cursor without a round trip per
+ # keypress.
+ self._pending_nums = self.program.pc_nums(ea)
+ if dec.failed:
+ # Ask Hex-Rays why, in the same worker: the plain tool reports
+ # "Decompilation failed at 0x0" and drops the only useful part.
+ # "Decompile failed" with no reason is indistinguishable from a bug
+ # in this app, and for the common cause (a 32-bit function in a
+ # 64-bit database) the user cannot even guess the fix.
+ why = self.program.decomp_error(ea)
+ self.app.call_from_thread(self._apply_decomp, ea, name, dec, why)
- def _apply_decomp(self, ea: int, name: str, dec) -> None: # type: ignore[no-untyped-def]
+ def _apply_decomp(self, ea: int, name: str, dec, # type: ignore[no-untyped-def]
+ why: str = "") -> None:
view = self.query_one(DecompView)
view.loading = False
if dec.failed:
- # No pseudocode for this function: fall back to the disassembly view
- # rather than showing an error panel. Keep the pseudocode preference
- # so the next (decompilable) function still opens as pseudocode.
+ detail = f" \u2014 {why}" if why else ""
+ # No pseudocode for this function: fall back to the code view rather
+ # than an error panel. If we came from the continuous listing (F5),
+ # return there; otherwise show the disassembly.
if self._cur is None or self._cur.ea != ea:
return # navigated away; stale result
+ if self._decomp_return is not None:
+ ret = self._decomp_return
+ self._decomp_return = None
+ self._cur = ret
+ self._active = "listing"
+ # Hand the reason over as a flash BEFORE reopening: going back
+ # to the listing reloads it, and the reload writes its own
+ # status afterwards — which is precisely how "F5 does nothing"
+ # looked like nothing at all.
+ msg = f"{name}: cannot decompile{detail}"
+ self._status(msg, priority=True)
+ self._open_entry(ret, push=False)
+ return
self._active = "disasm"
+ self._status(f"{name}: cannot decompile{detail}", priority=True)
self._show_active()
- self._status(
- f"{name} — no pseudocode (decompile failed); showing disassembly")
return
if self._active == "decomp":
view.focus() # loading cover had blurred it; restore focus
@@ -3034,26 +8087,222 @@ class IdaTui(App):
sx = cur.dec_scroll_x if same else 0
note = " (truncated)" if dec.truncated else ""
view.show(ea, dec.code or "", cursor=c, cursor_x=cx, scroll_y=sy, scroll_x=sx)
- self._status(f"{name} @ {ea:#x} [pseudocode {len(dec.code or '')} chars]{note}")
+ view.set_nums(self._pending_nums)
+ if self._split:
+ self._sync_split(self._active) # crude link now
+ self._load_split_map(ea) # then upgrade to the region map
+ self._split_status()
+ else:
+ self._status(
+ f"{name} @ {ea:#x} [pseudocode {len(dec.code or '')} chars]{note}")
+
+ def _sync_split(self, source: str) -> None:
+ """Split view: highlight (+ scroll into view) the companion pane's
+ location for the focused pane's cursor. The companion only gets a band +
+ scroll (its cursor never moves), so there is no echo/ping-pong. Uses the
+ rich per-line ea map (decomp_map) when loaded, else the single marker."""
+ if not self._split or self.program is None:
+ return
+ lst = self.query_one(ListingView)
+ dec = self.query_one(DecompView)
+ if source == "decomp":
+ dec.set_link(None) # the driver shows its own cursor, no band
+ line, screen = self._split_anchor(dec)
+ eas = (self._split_eamap[line]
+ if 0 <= line < len(self._split_eamap) else [])
+ if not eas: # fallback: the single /*ea*/ marker for the line
+ one = dec._line_ea(line)
+ eas = [one] if one is not None else []
+ rows: set[int] = set()
+ if lst.model is not None:
+ for e in eas:
+ r = lst.model.ensure_ea(e)
+ if r is not None and r >= 0:
+ rows.add(r)
+ lst.set_link(rows)
+ if rows:
+ # keep the linked region level with the driver's anchor row so
+ # the eye tracks straight across the two panes
+ lst.align(min(rows), screen)
+ else: # the listing drives
+ lst.set_link(set())
+ row, screen = self._split_anchor(lst)
+ head = lst._head(row)
+ ea = head.ea if head is not None else None
+ if ea is None:
+ dec.set_link(None)
+ return
+ rng = self._split_range
+ if rng is not None and not (rng[0] <= ea <= rng[1]):
+ # left the decompiled function — follow to whatever function is
+ # under the anchor (the unified view spans many functions).
+ self._resync_decomp(ea)
+ return
+ line = self._split_ea2line.get(ea)
+ if line is None: # fallback: nearest marker
+ line = dec.line_for_ea(ea)
+ if line is not None:
+ dec.set_link(line)
+ dec.align(line, screen)
+ else:
+ dec.set_link(None)
+
+ @staticmethod
+ def _split_anchor(view) -> tuple[int, int]: # type: ignore[no-untyped-def]
+ """(row, screen_row) the split sync anchors on: the driver's cursor while
+ it's visible, else the top visible row. A wheel-scroll/scrollbar drag
+ never moves the cursor, so anchoring on the viewport keeps the companion
+ following once the cursor scrolls out of sight."""
+ top = round(view.scroll_offset.y)
+ if top <= view.cursor < top + view._visible_height():
+ return view.cursor, view.cursor - top
+ return top, 0
+
+ def on_listing_view_scrolled(self, msg: "ListingView.Scrolled") -> None:
+ if self._split and self._active == "listing":
+ self._sync_split("listing")
+
+ def on_decomp_view_scrolled(self, msg: "DecompView.Scrolled") -> None:
+ if self._split and self._active == "decomp":
+ self._sync_split("decomp")
+
+ @work(thread=True, group="split-resync", exclusive=True)
+ def _resync_decomp(self, ea: int) -> None:
+ # The listing cursor crossed out of the decompiled function; find the
+ # function it's in now (off the UI thread) and re-point the decomp pane.
+ assert self.program is not None
+ fn = self.program.function_of(ea)
+ self.app.call_from_thread(self._apply_resync, fn)
+
+ def _apply_resync(self, fn) -> None: # type: ignore[no-untyped-def]
+ if not self._split or self._cur is None:
+ return
+ dec = self.query_one(DecompView)
+ if fn is None:
+ dec.set_link(None) # over data/undefined: keep the decomp, drop the band
+ return
+ self._cur.ea, self._cur.name = fn.addr, fn.name
+ if dec.loaded_ea == fn.addr: # already decompiled (scrolled back): just relink
+ self._sync_split("listing")
+ return
+ dec.loading = True
+ self._load_decomp(fn.addr, fn.name) # -> _apply_decomp -> map -> re-sync
+
+ def _split_status(self) -> None:
+ """A split-aware status line reflecting the focused pane + the link."""
+ if self._cur is None:
+ return
+ if self._active == "decomp":
+ dec = self.query_one(DecompView)
+ ea = dec._line_ea(dec.cursor)
+ n = (len(self._split_eamap[dec.cursor])
+ if 0 <= dec.cursor < len(self._split_eamap) else 0)
+ at = f" @ {ea:#x}" if ea is not None else ""
+ rel = f" \u2194 {n} insn" if n else ""
+ self._status(f"{self._cur.name}{at} "
+ f"[split \u00b7 pseudocode line {dec.cursor + 1}{rel}]"
+ f" (Tab/click: drive listing)")
+ else:
+ ea = self.query_one(ListingView)._cursor_ea()
+ at = f" @ {ea:#x}" if ea is not None else ""
+ self._status(f"{self._cur.name}{at} [split \u00b7 listing]"
+ f" (Tab/click: drive pseudocode)")
+
+ def on_descendant_focus(self, event) -> None: # type: ignore[no-untyped-def]
+ """In split, focusing a pane (Tab or a mouse click) makes it the leading/
+ driver pane, so the sync direction follows where you're actually working."""
+ if not self._split:
+ return
+ w = event.control
+ new = ("decomp" if isinstance(w, DecompView)
+ else "listing" if isinstance(w, ListingView) else None)
+ if new is not None and new != self._active:
+ self._active = new
+ self._sync_split(new)
+ self._split_status()
+
+ @work(thread=True, group="split-map")
+ def _load_split_map(self, ea: int) -> None:
+ # Fetch the rich per-line instruction map off the UI thread; sync stays
+ # on the crude single-ea fallback until it lands.
+ assert self.program is not None
+ m = self.program.decomp_map(ea)
+ self.app.call_from_thread(self._apply_split_map, ea, m)
+
+ def _apply_split_map(self, ea: int, m: list) -> None:
+ """Index the per-line instruction map for the decompiled function.
+
+ Keyed to what the DECOMPILER holds, not to _cur, and not conditional on
+ split being on. The old guard dropped the result whenever _cur had moved
+ while the fetch was in flight — during trace stepping that is almost
+ always — leaving the split view working from the map of the function you
+ just left. _cur follows the cursor; this map describes the pseudocode on
+ screen, and those are different things.
+ """
+ dec = self._try_view(DecompView)
+ if dec is not None and dec.loaded_ea is not None and ea != dec.loaded_ea:
+ return # a stale fetch for a function we no longer show
+ self._split_eamap = m
+ self._split_ea2line = {}
+ alleas = []
+ for line, eas in enumerate(m):
+ for e in eas:
+ self._split_ea2line.setdefault(e, line)
+ alleas.append(e)
+ # ea span of the decompiled function: when the listing cursor leaves it,
+ # _sync_split re-points the decomp to the function under the cursor.
+ self._split_range = (min(alleas), max(alleas)) if alleas else None
+ # ONE index, shared with the trace path: it used to keep a parallel copy
+ # of exactly this, fetched separately and keyed differently, which is how
+ # the two ended up describing different functions.
+ self._trail_map, self._trail_map_ea = m, ea
+ self._trail_line_of = dict(self._split_ea2line)
+ self._trail_eas = sorted(self._trail_line_of)
+ self._trail_span = self._split_range
+ if self._split:
+ self._sync_split(self._active) # re-link with the region map
def on_decomp_view_cursor_moved(self, msg: DecompView.CursorMoved) -> None:
- if self._nav:
+ dv = self._try_view(DecompView)
+ if self._nav and dv is not None:
self._nav[-1].dec_cursor = msg.index
- self._nav[-1].dec_cursor_x = self.query_one(DecompView).cursor_x
+ self._nav[-1].dec_cursor_x = dv.cursor_x
+ if self._split:
+ if self._active == "decomp":
+ self._sync_split("decomp")
+ self._split_status()
+ return
if self._cur is not None:
loc = f" @ {msg.ea:#x}" if msg.ea is not None else ""
self._status(f"{self._cur.name}{loc} [pseudocode line {msg.index}]")
- def on_disasm_view_cursor_moved(self, msg: DisasmView.CursorMoved) -> None:
- if self._nav:
- # Keep the top-of-history position current (line + column) so that
- # returning here later lands exactly where we left.
+ def _try_view(self, cls): # type: ignore[no-untyped-def]
+ """The code view, or None. App.query_one searches the TOP screen, so a
+ cursor-moved message that lands while any modal is up (the loading
+ overlay, a project switch) would otherwise raise NoMatches and kill the
+ app from a message handler."""
+ try:
+ return self.query_one(cls)
+ except Exception: # noqa: BLE001 -- NoMatches: a modal owns the screen
+ return None
+
+ def on_listing_view_cursor_moved(self, msg: ListingView.CursorMoved) -> None:
+ lst = self._try_view(ListingView)
+ if self._nav and lst is not None:
self._nav[-1].cursor = msg.index
- self._nav[-1].cursor_x = self.query_one(DisasmView).cursor_x
+ self._nav[-1].cursor_x = lst.cursor_x
+ if msg.index >= 0:
+ self._nav[-1].scroll_y = round(lst.scroll_offset.y)
+ if self._split:
+ if self._active == "listing":
+ self._sync_split("listing")
+ self._split_status()
+ return
ea = msg.ea
if ea is not None:
- name = self._nav[-1].name if self._nav else ""
- self._status(f"{name} @ {ea:#x} (line {msg.index})")
+ sec = self.program.section_of(ea) if self.program else None
+ self._status(f"{sec or '?'} @ {ea:#x} [listing] "
+ "(c code · p func · u undefine · Enter follow)")
# -- teardown ---------------------------------------------------------- #
def on_unmount(self) -> None:
@@ -3061,5 +8310,14 @@ class IdaTui(App):
self._ka.stop()
if self.program is not None:
self.program.close()
- if self.client is not None:
+ if self._pool is not None:
+ # _save_on_exit is False only when the user chose discard or we
+ # already saved; an unexpected teardown still writes defensively.
+ self._pool.close_all(save=self._save_on_exit is not False)
+ elif self.client is not None:
+ if self._save_on_exit is None and self._dirty:
+ try: # unexpected teardown with edits: don't drop them
+ self.client.call("idb_save", timeout=600.0)
+ except Exception: # noqa: BLE001
+ pass
self.client.close()
diff --git a/idatui/client.py b/idatui/client.py
deleted file mode 100644
index 3e5edb4..0000000
--- a/idatui/client.py
+++ /dev/null
@@ -1,678 +0,0 @@
-"""Persistent, thread-safe client for the ida-pro-mcp (idalib) MCP server.
-
-This is the foundation the whole TUI stands on. Unlike the throwaway CLI in the
-ida-mcp skill (which re-does the MCP handshake and spins a fresh socket on every
-call), this client:
-
- * Performs the MCP handshake exactly once and keeps the session warm
- (measured: ~7ms/call warm vs ~60ms cold).
- * Reuses TCP connections via a small keep-alive pool (no socket churn over a
- long TUI session) while still allowing concurrent calls from worker threads.
- * Has a precise, *grounded* error taxonomy (see below) so callers can tell
- apart transport failures, protocol errors, hard tool errors, and soft
- per-item "not found" results.
- * Recovers automatically from an expired server session (404 -> re-handshake
- -> retry once) and from transient transport hiccups (bounded retries).
- * Auto-injects the mandatory ``database=<session_id>`` argument, resolving a
- single open session automatically and refusing to guess when several are
- open.
-
-Error taxonomy (verified against the live server, 2026-07):
-
- IDAConnectionError transport could not be established / was lost
- IDATimeoutError a request exceeded its deadline
- IDAProtocolError malformed / unexpected HTTP or JSON-RPC framing
- IDARPCError JSON-RPC ``error`` object in the envelope
- IDAToolError tool returned ``result.isError == true`` (hard failure:
- bad params, unknown tool, missing database, ...)
- IDASessionError no session / multiple sessions and none pinned
-
-Note the deliberate *non*-error: a tool that returns ``isError == false`` with an
-``error`` field inside its payload (e.g. ``decompile`` on an unknown address, or
-per-item failures in a batch tool) is treated as *data*, not an exception. The
-caller inspects the payload. Raising on those would break every batch tool.
-
-The client is stdlib-only (``http.client`` / ``urllib.parse``).
-"""
-
-from __future__ import annotations
-
-import http.client
-import json
-import socket
-import threading
-import time
-from collections import deque
-from dataclasses import dataclass
-from typing import Any
-from urllib.parse import urlsplit
-
-DEFAULT_URL = "http://127.0.0.1:8745/mcp"
-PROTOCOL_VERSION = "2025-06-18"
-
-# Tools that must NOT receive an injected ``database`` argument.
-SESSION_AGNOSTIC_TOOLS = frozenset({"idb_list", "idb_open", "int_convert"})
-
-# Substrings (lowercased) that identify a stale/invalid IDB session in a tool
-# error message. Verified against the live server: after a server restart the
-# old session id is gone and every call fails with "Session not found: <id>".
-_STALE_SESSION_MARKERS = ("session not found", "database is required")
-
-
-# --------------------------------------------------------------------------- #
-# Exceptions
-# --------------------------------------------------------------------------- #
-class IDAError(Exception):
- """Base class for all client errors."""
-
-
-class IDAConnectionError(IDAError):
- """The transport could not be established or was lost."""
-
-
-class IDATimeoutError(IDAError):
- """A request exceeded its deadline."""
-
-
-class IDAProtocolError(IDAError):
- """Malformed or unexpected HTTP / JSON-RPC framing."""
-
-
-class IDARPCError(IDAError):
- """The JSON-RPC envelope carried an ``error`` object."""
-
- def __init__(self, code: int, message: str, data: Any = None):
- super().__init__(f"JSON-RPC error {code}: {message}")
- self.code = code
- self.message = message
- self.data = data
-
-
-class IDAToolError(IDAError):
- """A tool call returned ``result.isError == true`` (a hard failure)."""
-
- def __init__(self, tool: str, message: str):
- super().__init__(f"tool {tool!r} failed: {message}")
- self.tool = tool
- self.message = message
-
-
-class IDASessionError(IDAError):
- """No IDB session is open, or several are and none was pinned."""
-
-
-# --------------------------------------------------------------------------- #
-# Session model
-# --------------------------------------------------------------------------- #
-@dataclass(frozen=True)
-class Session:
- session_id: str
- filename: str
- input_path: str
- is_active: bool = False
- is_analyzing: bool = False
-
- @classmethod
- def from_dict(cls, d: dict) -> "Session":
- return cls(
- session_id=d.get("session_id", ""),
- filename=d.get("filename", ""),
- input_path=d.get("input_path", ""),
- is_active=bool(d.get("is_active", False)),
- is_analyzing=bool(d.get("is_analyzing", False)),
- )
-
-
-# --------------------------------------------------------------------------- #
-# Transport: a tiny keep-alive connection pool over http.client
-# --------------------------------------------------------------------------- #
-class _Transport:
- """Keep-alive HTTP/1.1 pool. Thread-safe. One request == one pooled conn.
-
- Connections are checked out, used for exactly one request/response, then
- returned to the pool if still healthy. On a dropped/broken connection the
- request is retried on a fresh connection (bounded by ``max_retries``).
- """
-
- def __init__(self, url: str, *, max_retries: int = 2, pool_size: int = 8):
- parts = urlsplit(url)
- if parts.scheme not in ("http", "https"):
- raise IDAConnectionError(f"unsupported scheme: {parts.scheme!r}")
- self._https = parts.scheme == "https"
- self._host = parts.hostname or "127.0.0.1"
- self._port = parts.port or (443 if self._https else 80)
- self._path = parts.path or "/"
- self._max_retries = max_retries
- self._pool_size = pool_size
- self._idle: deque[http.client.HTTPConnection] = deque()
- self._lock = threading.Lock()
-
- def _new_conn(self, timeout: float) -> http.client.HTTPConnection:
- cls = http.client.HTTPSConnection if self._https else http.client.HTTPConnection
- return cls(self._host, self._port, timeout=timeout)
-
- def _checkout(self, timeout: float) -> http.client.HTTPConnection:
- with self._lock:
- while self._idle:
- conn = self._idle.popleft()
- # Reuse only if the socket still looks alive.
- if conn.sock is not None:
- conn.timeout = timeout
- try:
- conn.sock.settimeout(timeout)
- except OSError:
- try:
- conn.close()
- except OSError:
- pass
- continue
- return conn
- return self._new_conn(timeout)
-
- def _checkin(self, conn: http.client.HTTPConnection) -> None:
- with self._lock:
- if len(self._idle) < self._pool_size:
- self._idle.append(conn)
- return
- try:
- conn.close()
- except OSError:
- pass
-
- def request(
- self, body: bytes, headers: dict[str, str], timeout: float
- ) -> tuple[int, dict[str, str], bytes]:
- """POST ``body``; return (status, response_headers, response_body)."""
- last_exc: Exception | None = None
- for attempt in range(self._max_retries + 1):
- conn = self._checkout(timeout)
- try:
- conn.request("POST", self._path, body=body, headers=headers)
- resp = conn.getresponse()
- status = resp.status
- resp_headers = {k.lower(): v for k, v in resp.getheaders()}
- data = resp.read() # must fully drain before reuse
- except socket.timeout as e:
- self._discard(conn)
- raise IDATimeoutError(f"request timed out after {timeout}s") from e
- except (
- http.client.RemoteDisconnected,
- http.client.BadStatusLine,
- ConnectionError,
- OSError,
- ) as e:
- # A stale pooled connection, or the server closed on us. Drop it
- # and retry on a fresh connection.
- self._discard(conn)
- last_exc = e
- continue
- else:
- if resp.will_close:
- self._discard(conn)
- else:
- self._checkin(conn)
- return status, resp_headers, data
- raise IDAConnectionError(
- f"transport failed after {self._max_retries + 1} attempts: {last_exc}"
- ) from last_exc
-
- def _discard(self, conn: http.client.HTTPConnection) -> None:
- try:
- conn.close()
- except OSError:
- pass
-
- def close(self) -> None:
- with self._lock:
- while self._idle:
- self._discard(self._idle.popleft())
-
-
-# --------------------------------------------------------------------------- #
-# The client
-# --------------------------------------------------------------------------- #
-class IDAClient:
- """A warm, thread-safe handle to one MCP server (and one pinned IDB).
-
- Typical use::
-
- with IDAClient(db="4f2223f9") as ida:
- ida.health()
- funcs = ida.call("list_funcs", queries=[{"count": 50}])
- code = ida.call("decompile", addr="main")
-
- Concurrency: safe to call from multiple threads (Textual workers). Requests
- run on independent pooled connections; only the id counter and handshake
- state are lock-guarded, so calls do not serialize on each other.
- """
-
- def __init__(
- self,
- url: str = DEFAULT_URL,
- db: str | None = None,
- *,
- timeout: float = 30.0,
- max_retries: int = 2,
- pool_size: int = 8,
- client_name: str = "idatui",
- client_version: str = "0.0.1",
- auto_recover_session: bool = True,
- ):
- self.url = url
- self._db = db
- self.timeout = timeout
- self.auto_recover_session = auto_recover_session
- self._transport = _Transport(url, max_retries=max_retries, pool_size=pool_size)
- self._client_info = {"name": client_name, "version": client_version}
-
- self._rid = 0
- self._sid: str | None = None
- self._ready = False
- self._state_lock = threading.Lock() # guards _rid, _sid, _ready
- self._handshake_lock = threading.Lock() # serializes (re)handshake
-
- # -- lifecycle --------------------------------------------------------- #
- def __enter__(self) -> "IDAClient":
- self.connect()
- return self
-
- def __exit__(self, *exc) -> None:
- self.close()
-
- def connect(self) -> "IDAClient":
- """Ensure the MCP handshake has completed (idempotent, thread-safe)."""
- if self._ready:
- return self
- self._handshake()
- return self
-
- def close(self) -> None:
- self._transport.close()
-
- # -- low-level plumbing ------------------------------------------------ #
- def _next_id(self) -> int:
- with self._state_lock:
- self._rid += 1
- return self._rid
-
- def _headers(self) -> dict[str, str]:
- h = {
- "Content-Type": "application/json",
- "Accept": "application/json, text/event-stream",
- }
- sid = self._sid
- if sid:
- h["Mcp-Session-Id"] = sid
- return h
-
- def _handshake(self) -> None:
- with self._handshake_lock:
- if self._ready:
- return
- with self._state_lock:
- self._sid = None
- rid = self._next_id()
- status, headers, body = self._transport.request(
- self._encode(
- {
- "jsonrpc": "2.0",
- "id": rid,
- "method": "initialize",
- "params": {
- "protocolVersion": PROTOCOL_VERSION,
- "capabilities": {},
- "clientInfo": self._client_info,
- },
- }
- ),
- {
- "Content-Type": "application/json",
- "Accept": "application/json, text/event-stream",
- },
- self.timeout,
- )
- if status // 100 != 2:
- raise IDAProtocolError(
- f"initialize failed: HTTP {status}: {body[:200]!r}"
- )
- sid = headers.get("mcp-session-id")
- envelope = self._parse_body(body, rid)
- self._raise_on_rpc_error(envelope)
- with self._state_lock:
- self._sid = sid
- # notifications/initialized is a fire-and-forget notification.
- self._transport.request(
- self._encode({"jsonrpc": "2.0", "method": "notifications/initialized"}),
- self._headers(),
- self.timeout,
- )
- with self._state_lock:
- self._ready = True
-
- @staticmethod
- def _encode(obj: dict) -> bytes:
- return json.dumps(obj).encode()
-
- @staticmethod
- def _parse_body(body: bytes, want_id: int) -> dict:
- """Extract the JSON-RPC envelope for ``want_id`` from a (possibly SSE) body."""
- text = body.decode("utf-8", "replace")
- found: dict | None = None
- for line in text.splitlines():
- line = line.strip()
- if line.startswith("data:"):
- line = line[5:].strip()
- if not line:
- continue
- try:
- obj = json.loads(line)
- except json.JSONDecodeError:
- continue
- if isinstance(obj, dict) and obj.get("id") == want_id:
- found = obj
- if found is None:
- raise IDAProtocolError(
- f"no JSON-RPC response with id={want_id} in body: {text[:200]!r}"
- )
- return found
-
- @staticmethod
- def _raise_on_rpc_error(envelope: dict) -> None:
- err = envelope.get("error")
- if err:
- raise IDARPCError(
- code=err.get("code", -1),
- message=err.get("message", "unknown"),
- data=err.get("data"),
- )
-
- def _rpc(self, method: str, params: dict, *, timeout: float | None = None) -> dict:
- """Send a JSON-RPC request, recovering once from an expired session."""
- if not self._ready:
- self._handshake()
- to = self.timeout if timeout is None else timeout
- rid = self._next_id()
- payload = self._encode(
- {"jsonrpc": "2.0", "id": rid, "method": method, "params": params}
- )
- status, headers, body = self._transport.request(payload, self._headers(), to)
-
- if status == 404 and self._sid is not None:
- # Server session expired: re-handshake and retry exactly once.
- with self._state_lock:
- self._ready = False
- self._handshake()
- rid = self._next_id()
- payload = self._encode(
- {"jsonrpc": "2.0", "id": rid, "method": method, "params": params}
- )
- status, headers, body = self._transport.request(
- payload, self._headers(), to
- )
-
- if status // 100 != 2:
- raise IDAProtocolError(f"HTTP {status}: {body[:200]!r}")
-
- envelope = self._parse_body(body, rid)
- self._raise_on_rpc_error(envelope)
- return envelope
-
- # -- tool calls -------------------------------------------------------- #
- @staticmethod
- def _extract_payload(tool: str, result: dict) -> Any:
- """Turn an MCP ``result`` object into a Python payload, or raise.
-
- Precedence:
- 1. ``isError == true`` -> IDAToolError (hard failure)
- 2. ``structuredContent`` present -> return it (already parsed)
- 3. ``content[0].text`` is JSON -> return parsed JSON
- 4. otherwise -> return the raw text string
- """
- if result.get("isError"):
- text = _first_text(result) or "(no message)"
- raise IDAToolError(tool, text)
- if "structuredContent" in result:
- return result["structuredContent"]
- text = _first_text(result)
- if text is None:
- return result
- try:
- return json.loads(text)
- except (json.JSONDecodeError, TypeError):
- return text
-
- def _prepare_args(self, tool: str, args: dict) -> tuple[dict, bool]:
- """Return (arguments, db_was_injected). Never mutates the caller's dict."""
- prepared = dict(args)
- if tool in SESSION_AGNOSTIC_TOOLS or "database" in prepared:
- return prepared, False
- prepared["database"] = self.resolve_db()
- return prepared, True
-
- def call_envelope(self, tool: str, *, timeout: float | None = None, **args) -> dict:
- """Call ``tool`` and return the full JSON-RPC envelope (for debugging)."""
- prepared, _ = self._prepare_args(tool, args)
- return self._rpc(
- "tools/call", {"name": tool, "arguments": prepared}, timeout=timeout
- )
-
- def call(self, tool: str, *, timeout: float | None = None, **args) -> Any:
- """Call ``tool`` and return its payload (parsed JSON when possible).
-
- Raises IDAToolError on a hard tool failure. Soft/per-item errors (an
- ``error`` field with ``isError == false``) are returned as data.
-
- If ``auto_recover_session`` is set and the db was auto-injected, a stale
- "Session not found" error (e.g. after a server restart) triggers exactly
- one transparent recovery: drop the stale pin, re-resolve the session,
- and retry. A db the caller pinned explicitly is never silently switched.
- """
- prepared, injected = self._prepare_args(tool, args)
- envelope = self._rpc(
- "tools/call", {"name": tool, "arguments": prepared}, timeout=timeout
- )
- try:
- return self._extract_payload(tool, envelope.get("result", {}))
- except IDAToolError as e:
- if not (injected and self.auto_recover_session and _is_stale_session(e)):
- raise
- # The pinned IDB session vanished (server restart). Re-resolve the
- # sole session and retry once. resolve_db() raises IDASessionError
- # if zero/many sessions exist, so we never guess.
- self.set_db(None)
- prepared2, _ = self._prepare_args(tool, args)
- envelope2 = self._rpc(
- "tools/call", {"name": tool, "arguments": prepared2}, timeout=timeout
- )
- return self._extract_payload(tool, envelope2.get("result", {}))
-
- # -- session management ------------------------------------------------ #
- def list_sessions(self) -> list[Session]:
- result = self._rpc("tools/call", {"name": "idb_list", "arguments": {}})
- payload = self._extract_payload("idb_list", result.get("result", {}))
- sessions = payload.get("sessions", []) if isinstance(payload, dict) else []
- return [Session.from_dict(s) for s in sessions]
-
- def resolve_db(self) -> str:
- """Return the pinned session id, auto-resolving a lone open session.
-
- Raises IDASessionError if none is open, or if several are open and none
- has been pinned via ``db=`` / :meth:`set_db`.
- """
- if self._db:
- return self._db
- sessions = self.list_sessions()
- usable = [s for s in sessions if s.session_id]
- if len(usable) == 1:
- self._db = usable[0].session_id
- return self._db
- if not usable:
- raise IDASessionError(
- "no open IDB session with a usable id; open one with idb_open"
- )
- opts = ", ".join(f"{s.session_id} ({s.filename})" for s in usable)
- raise IDASessionError(
- f"multiple sessions open; pin one with db=... . Options: {opts}"
- )
-
- def set_db(self, db: str | None) -> None:
- self._db = db
-
- @property
- def db(self) -> str | None:
- return self._db
-
- def _session_input_path(self, db: str) -> str:
- for s in self.list_sessions():
- if s.session_id == db:
- if not s.input_path:
- raise IDASessionError(f"session {db} has no input_path to re-open")
- return s.input_path
- raise IDASessionError(f"session {db} not found")
-
- def bump_idle_ttl(self, idle_ttl_sec: int = 1_000_000_000,
- path: str | None = None) -> None:
- """Raise the worker's idle self-exit TTL so an interactive session never
- gets reaped while the user is just reading.
-
- Headless idalib workers self-exit after ``idle_ttl_sec`` (default 600s)
- with no requests. ``idb_open`` on the already-open path is idempotent
- (returns the same session) and re-applies the TTL, so this simply opens
- the pinned session's path with a huge TTL. The default (~31 years) is
- effectively 'never'.
- """
- p = path or self._session_input_path(self.resolve_db())
- self.call("idb_open", input_path=p, idle_ttl_sec=int(idle_ttl_sec))
-
- def keepalive(self, interval: float = 120.0) -> "KeepAlive":
- """Return a (not-yet-started) heartbeat that touches the worker's idle
- watchdog every ``interval`` seconds. Belt-and-suspenders next to
- :meth:`bump_idle_ttl`; also covers adopted sessions we didn't open.
- """
- return KeepAlive(self, interval=interval)
-
- # -- convenience ------------------------------------------------------- #
- def health(self) -> dict:
- return self.call("server_health")
-
- def list_tools(self) -> list[tuple[str, str]]:
- envelope = self._rpc("tools/list", {})
- out = []
- for t in envelope.get("result", {}).get("tools", []):
- desc = (t.get("description") or "").splitlines()
- out.append((t["name"], desc[0] if desc else ""))
- return out
-
- def schema(self, tool: str) -> dict:
- envelope = self._rpc("tools/list", {})
- for t in envelope.get("result", {}).get("tools", []):
- if t["name"] == tool:
- return t.get("inputSchema", {})
- raise IDAError(f"tool not found: {tool}")
-
-
-class KeepAlive:
- """Background heartbeat that keeps an idalib worker from idling out.
-
- Any forwarded request resets the worker's idle timer, so a periodic cheap
- ``server_health`` is enough. Failures are swallowed (the next real call will
- auto-recover); the heartbeat just keeps a chilling TUI's worker alive.
- """
-
- def __init__(self, client: "IDAClient", interval: float = 120.0):
- if interval <= 0:
- raise ValueError("interval must be > 0")
- self._client = client
- self.interval = interval
- self._stop = threading.Event()
- self._thread: threading.Thread | None = None
- self.beats = 0
- self.failures = 0
-
- def start(self) -> "KeepAlive":
- if self._thread is not None:
- return self
- self._stop.clear()
- self._thread = threading.Thread(
- target=self._run, daemon=True, name="idatui-keepalive"
- )
- self._thread.start()
- return self
-
- def stop(self) -> None:
- self._stop.set()
- t = self._thread
- self._thread = None
- if t is not None:
- t.join(timeout=2.0)
-
- def __enter__(self) -> "KeepAlive":
- return self.start()
-
- def __exit__(self, *exc) -> None:
- self.stop()
-
- def _run(self) -> None:
- while not self._stop.wait(self.interval):
- try:
- self._client.health()
- self.beats += 1
- except IDAError:
- self.failures += 1
-
-
-def _is_stale_session(e: IDAToolError) -> bool:
- msg = e.message.lower()
- return any(marker in msg for marker in _STALE_SESSION_MARKERS)
-
-
-def _first_text(result: dict) -> str | None:
- content = result.get("content")
- if isinstance(content, list):
- for item in content:
- if isinstance(item, dict) and item.get("type") == "text":
- return item.get("text")
- return None
-
-
-# --------------------------------------------------------------------------- #
-# Tiny self-check CLI: python -m idatui.client [--db ID] [--url URL] [health]
-# --------------------------------------------------------------------------- #
-def _main(argv: list[str]) -> int:
- import os
-
- url = DEFAULT_URL
- db = os.environ.get("IDA_MCP_DB")
- rest: list[str] = []
- it = iter(argv)
- for a in it:
- if a == "--url":
- url = next(it)
- elif a == "--db":
- db = next(it)
- else:
- rest.append(a)
-
- ida = IDAClient(url, db=db)
- try:
- ida.connect()
- t0 = time.time()
- sessions = ida.list_sessions()
- print(f"sessions ({(time.time() - t0) * 1e3:.1f}ms):")
- for s in sessions:
- mark = "*" if s.is_active else " "
- print(f" {mark} {s.session_id or '<none>':10} {s.filename}")
- try:
- h = ida.health()
- print("health:", json.dumps(h, indent=1)[:400])
- except IDASessionError as e:
- print(f"health: skipped ({e})")
- finally:
- ida.close()
- return 0
-
-
-if __name__ == "__main__":
- import sys
-
- raise SystemExit(_main(sys.argv[1:]))
diff --git a/idatui/domain.py b/idatui/domain.py
index 3dd21ea..222f7bb 100644
--- a/idatui/domain.py
+++ b/idatui/domain.py
@@ -28,14 +28,17 @@ import threading
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field, replace
-from typing import Callable
+from typing import Callable, TYPE_CHECKING
-from .client import IDAClient, IDAToolError
+from .errors import IDAToolError
+
+if TYPE_CHECKING: # type hint only
+ from .worker_client import WorkerClient # noqa: F401
# Clamps derived from measured caps (list ~700, disasm ~500). Margin included.
LIST_PAGE = 500
DISASM_BLOCK = 256 # instructions per cached/fetched block (<= disasm cap)
-HEX_BLOCK = 4096 # bytes per cached/fetched hex block
+HEX_BLOCK = 16384 # bytes per cached/fetched hex block (compact read_raw -> cheap)
DECOMPILE_TIMEOUT = 15.0 # s; cap per decompile so a failing one can't hang the CLI
_TRUNC_RE = re.compile(r"\[(\d+) chars total\]\s*$")
@@ -58,7 +61,14 @@ class Func:
@classmethod
def from_raw(cls, d: dict) -> "Func":
- return cls(addr=_as_int(d["addr"]), name=d["name"], size=_as_int(d["size"]))
+ addr = _as_int(d["addr"])
+ name = d.get("name")
+ # An unnamed function (server returns null/empty) must still have a
+ # usable string name — synthesize IDA's sub_ADDR so every consumer
+ # (palette, sort, rename prefill) can treat name as a str.
+ if not name:
+ name = f"sub_{addr:X}"
+ return cls(addr=addr, name=name, size=_as_int(d.get("size", 0)))
@dataclass(frozen=True)
@@ -79,6 +89,81 @@ class Line:
)
+@dataclass(frozen=True)
+class Head:
+ """One flat-listing item (from the ``heads`` server tool): a code
+ instruction, a data item, or an undefined byte run."""
+
+ ea: int
+ kind: str # 'code' | 'data' | 'unknown' | 'member'
+ size: int
+ text: str
+ name: str | None = None
+ raw: bytes | None = None # opcode/item bytes (filled in for code by the model)
+ #: [(kind, text)] from IDA's own colour tags — mnem/reg/num/name/str/punct/…
+ #: None when the worker didn't provide them (older worker, or the spans
+ #: disagreed with the plain text, in which case the text wins).
+ spans: tuple[tuple[str, str], ...] | None = None
+ #: [(start, end, n)] — where each operand sits in ``text``, from IDA's own
+ #: COLOR_OPND markers. Lets the view show which operand the cursor is on,
+ #: and is the same information the worker maps a column through, so the
+ #: highlight and the edit can't disagree.
+ ops: tuple[tuple[int, int, int], ...] | None = None
+
+ @property
+ def label(self) -> str | None: # Line-compatible alias
+ return self.name
+
+ def op_at(self, col: int) -> tuple[int, int, int] | None:
+ """The operand whose text contains ``col``, or None."""
+ for lo, hi, n in self.ops or ():
+ if lo <= col < hi:
+ return (lo, hi, n)
+ return None
+
+ @classmethod
+ def from_raw(cls, d: dict) -> "Head":
+ sp = d.get("spans")
+ ops = d.get("ops")
+ return cls(
+ ea=_as_int(d["ea"]),
+ kind=d.get("kind", "unknown"),
+ size=int(d.get("size", 0) or 0),
+ text=d.get("text", ""),
+ name=d.get("name"),
+ spans=(tuple((str(k), str(t)) for k, t in sp)
+ if isinstance(sp, list) and sp else None),
+ ops=(tuple((int(a), int(b), int(n)) for a, b, n in ops)
+ if isinstance(ops, list) and ops else None),
+ )
+
+
+@dataclass
+class BasicBlock:
+ """One node of a function's control-flow graph, with the listing rows that
+ make up its body (filled in by ``Program.flowchart``)."""
+
+ id: int
+ start: int
+ end: int
+ succs: list[tuple[int, str]] = field(default_factory=list)
+ rows: list[Head] = field(default_factory=list)
+
+
+@dataclass
+class Flowchart:
+ func_ea: int
+ name: str
+ entry: int
+ blocks: list[BasicBlock]
+
+ def block_at(self, ea: int) -> BasicBlock | None:
+ for b in self.blocks:
+ if b.start <= ea < b.end:
+ return b
+ return None
+
+
@dataclass
class Ref:
addr: int
@@ -90,9 +175,10 @@ class Ref:
class Xref:
frm: int # the referencing address
to: int | None # the referenced address
- type: str # "code" | "data" | ...
+ type: str # coarse: "code" | "data"
fn_name: str | None # function containing `frm`
fn_addr: int | None
+ kind: str | None = None # fine: call/jump/flow/read/write/offset/text/info
@dataclass(frozen=True)
@@ -129,6 +215,44 @@ class Struct:
)
+@dataclass(frozen=True)
+class StrLit:
+ """A string literal IDA found in the binary (the Shift+F12 list)."""
+ addr: int
+ text: str
+ length: int
+ type: str = ""
+
+
+def link_name(raw: str) -> str:
+ """A linkage name reduced to what actually joins across binaries.
+
+ ELF symbol versioning means the importer sees ``strrchr@@GLIBC_2.2.5`` while
+ the provider may export ``strrchr``, ``strrchr@GLIBC_2.2.5`` or the versioned
+ spelling — comparing raw names silently resolves almost nothing. Cut at the
+ first '@' so both sides meet on the bare symbol.
+ """
+ n = (raw or "").strip()
+ at = n.find("@")
+ return n[:at] if at > 0 else n
+
+
+@dataclass(frozen=True)
+class Linkage:
+ """One import or export: a name this binary takes from, or offers to, other
+ modules. ``module`` is set for imports (the library IDA attributes it to),
+ ``ordinal`` for exports.
+
+ ``name`` is the joinable name; ``raw`` keeps the spelling IDA reported, which
+ is what the user sees in the listing.
+ """
+ addr: int
+ name: str
+ module: str = ""
+ ordinal: int = 0
+ raw: str = ""
+
+
@dataclass
class Decompilation:
ea: int
@@ -277,8 +401,14 @@ class DisasmModel:
self._lock = threading.Lock()
self._inflight: set[int] = set()
+ def _end_kw(self) -> dict:
+ end = self._function_end()
+ return {"end": hex(end)} if end is not None else {}
+
def total(self) -> int:
- """Total instruction count (fetched once; ~200ms on huge funcs)."""
+ """Instruction/row count of the function (fetched once). Uses disasm's
+ ``include_total`` — one fast call, no response-size truncation. For a
+ code function this equals the heads row count that backs the lines."""
if self._total is not None:
return self._total
payload = self._prog.client.call(
@@ -332,15 +462,22 @@ class DisasmModel:
self._max_raw = biggest
return out
+ @staticmethod
+ def _line_from_head(r: dict) -> Line:
+ """Adapt a ``heads`` row to a disasm Line (label = the head's name)."""
+ return Line(ea=_as_int(r["ea"]), text=r.get("text", ""),
+ label=r.get("name"))
+
def _fetch_block(self, b: int) -> list[Line]:
- # Over-fetch one instruction so the block knows where its last
- # instruction ends (variable-length archs give no size field).
+ # The function disasm view is a listing filtered to the function: fetch a
+ # block of heads (one per instruction for code). Over-fetch one row so
+ # the block knows where its last instruction ends (opcode-byte sizing).
payload = self._prog.client.call(
- "disasm", addr=hex(self.ea), offset=b * self.BLOCK,
- max_instructions=self.BLOCK + 1,
+ "heads", addr=hex(self.ea), offset=b * self.BLOCK,
+ count=self.BLOCK + 1, **self._end_kw(),
)
- raw = payload.get("asm", {}).get("lines", []) if isinstance(payload, dict) else []
- fetched = [Line.from_raw(r) for r in raw]
+ rows = payload.get("heads", []) if isinstance(payload, dict) else []
+ fetched = [self._line_from_head(r) for r in rows]
lines = fetched[:self.BLOCK]
if len(fetched) > self.BLOCK:
end_ea: int | None = fetched[self.BLOCK].ea
@@ -470,6 +607,308 @@ class DisasmModel:
# --------------------------------------------------------------------------- #
+# Listing model: lazily-grown flat listing (code + data + undefined) per segment
+# --------------------------------------------------------------------------- #
+class ListingModel:
+ """A flat, IDA-style disassembly *listing* over one segment: code, data and
+ undefined heads interleaved, unlike ``DisasmModel`` (one function, code only).
+
+ Backed by the injected ``heads`` server tool, which walks item heads and
+ renders each via ``generate_disasm_line``. The segment is walked lazily in
+ forward pages (``FunctionIndex`` style); line index == position in the walked
+ head list. Random access to an address is O(distance-from-seg-start) the
+ first time (then cached) — the same tradeoff as ``disasm offset=N``. Grows
+ on demand as the viewport scrolls. Synchronous + thread-safe.
+ """
+
+ PAGE = 500 # heads per server call (well under the tool's 2000 cap)
+
+ def __init__(self, program: "Program", seg_start: int, seg_end: int,
+ name: str | None = None):
+ self._prog = program
+ self.seg_start = seg_start
+ self.seg_end = seg_end
+ self.name = name or f"seg @ {seg_start:#x}"
+ self._heads: list[Head] = []
+ self._by_ea: dict[int, int] = {}
+ # Logical rows != physical heads. A run of undefined bytes arrives as ONE
+ # head ("db 2044 dup(?)") because materialising millions of one-byte rows
+ # for a .bss would be absurd — but you must still be able to put the
+ # cursor on any byte in it and press `c`, exactly as in IDA. So a run of
+ # N bytes PRESENTS as N rows and the text for each is synthesised on
+ # demand. _row_at[i] is the logical row where physical head i starts.
+ self._row_at: list[int] = []
+ self._head_eas: list[int] = [] # parallel to _heads, for bisect
+ self._rows = 0 # total logical rows loaded
+ self._ubytes: dict[int, bytes] = {} # lazily-read bytes for those rows
+ self._next: int | None = seg_start # next address to fetch from
+ self._done = False
+ self._max_raw = 0 # widest opcode length (bytes) seen, for the op column
+ self._lock = threading.Lock()
+ # Serializes page loads so a background grower and an in-view search can
+ # both drive loading without double-fetching the same page.
+ self._load_lock = threading.Lock()
+
+ # Opcode bytes are only worth showing for code; cap the bulk read so a page
+ # containing a huge coalesced undefined run doesn't pull megabytes.
+ _OP_SPAN_CAP = 1 << 16
+
+ def _attach_opcode_bytes(self, page: list[Head]) -> list[Head]:
+ """Fill ``raw`` (opcode bytes) for the code heads in ``page`` via one
+ bulk read over their extent (variable-length safe)."""
+ code = [h for h in page if h.kind == "code" and h.size > 0]
+ if not code:
+ return page
+ lo = code[0].ea
+ hi = code[-1].ea + code[-1].size
+ if hi - lo <= 0 or hi - lo > self._OP_SPAN_CAP:
+ return page
+ data = self._prog.read_bytes(lo, hi - lo)
+ biggest = self._max_raw
+ out = []
+ for h in page:
+ if h.kind == "code" and h.size > 0:
+ off = h.ea - lo
+ b = bytes(data[off:off + h.size])
+ biggest = max(biggest, len(b))
+ out.append(replace(h, raw=b))
+ else:
+ out.append(h)
+ with self._lock:
+ self._max_raw = biggest
+ return out
+
+ def max_raw_len(self) -> int:
+ with self._lock:
+ return self._max_raw
+
+ def load_next_page(self) -> int:
+ """Load one more page of heads; returns how many were added."""
+ return self._load_next_page()
+
+ def _load_next_page(self) -> int:
+ with self._load_lock:
+ return self._load_next_page_locked()
+
+ def _load_next_page_locked(self) -> int:
+ with self._lock:
+ if self._done or self._next is None:
+ return 0
+ frm = self._next
+ payload = self._prog.client.call(
+ "heads", addr=hex(frm), count=self.PAGE, annotate=True)
+ rows = payload.get("heads", []) if isinstance(payload, dict) else []
+ cur = payload.get("cursor", {}) if isinstance(payload, dict) else {}
+ page = []
+ for r in rows:
+ try:
+ page.append(Head.from_raw(r))
+ except (KeyError, ValueError, TypeError):
+ continue
+ page = self._attach_opcode_bytes(page)
+ with self._lock:
+ for h in page:
+ # Banner/label rows (function headers, separators, code labels)
+ # are display-only; don't index them so navigation lands on the
+ # real code/data head at that address.
+ if h.kind not in ("sep", "funchdr", "label"):
+ self._by_ea.setdefault(h.ea, self._rows)
+ self._row_at.append(self._rows)
+ self._head_eas.append(h.ea)
+ self._heads.append(h)
+ self._rows += self._span(h)
+ nxt = cur.get("next")
+ if nxt is None:
+ self._done = True
+ self._next = None
+ else:
+ self._next = _as_int(nxt)
+ return len(rows)
+
+ @staticmethod
+ def _span(h: Head) -> int:
+ """How many logical rows head ``h`` occupies."""
+ return h.size if (h.kind == "unknown" and h.size > 1) else 1
+
+ def _phys(self, row: int) -> tuple[int, int]:
+ """(physical head index, byte offset into it) for logical ``row``."""
+ import bisect
+ i = bisect.bisect_right(self._row_at, row) - 1
+ if i < 0:
+ return (-1, 0)
+ return (i, row - self._row_at[i])
+
+ def _unknown_bytes(self, ea: int, n: int) -> bytes:
+ """Bytes behind an undefined run, read in blocks and cached.
+
+ Undefined rows are the ones you carve, so their VALUES are the whole
+ point — "db ?" with no byte tells you nothing about where an instruction
+ stream might start.
+ """
+ BLK = 1024
+ out = bytearray()
+ a = ea
+ while len(out) < n:
+ b0 = (a // BLK) * BLK
+ blk = self._ubytes.get(b0)
+ if blk is None:
+ try:
+ blk = self._prog.read_bytes(b0, BLK)
+ except Exception: # noqa: BLE001
+ blk = b""
+ self._ubytes[b0] = blk
+ off = a - b0
+ take = min(BLK - off, n - len(out))
+ chunk = blk[off:off + take] if blk else b""
+ if not chunk:
+ break
+ out += chunk
+ a += len(chunk)
+ return bytes(out)
+
+ def _row_head(self, i: int, off: int) -> Head:
+ """The Head for one logical row: the physical head, or a synthesised
+ single-byte row inside an undefined run.
+
+ The run's FIRST row is synthesised too. Leaving "db 2044 dup(?)" there
+ would say the row covers 2044 bytes when it now covers one, and the
+ column of byte values would start an address late.
+ """
+ h = self._heads[i]
+ if self._span(h) == 1:
+ return h
+ ea = h.ea + off
+ b = self._unknown_bytes(ea, 1)
+ text = f"db {b[0]:02X}h" if b else "db ?"
+ return Head(ea=ea, kind="unknown", size=1, text=text,
+ name=h.name if off == 0 else None)
+
+ def ensure(self, n: int) -> None:
+ """Ensure at least ``n`` logical rows are loaded (or all, if fewer)."""
+ while not self._done and self._rows < n:
+ if self._load_next_page() == 0:
+ break
+
+ def ensure_ea(self, ea: int) -> int:
+ """Walk forward until the head containing ``ea`` is loaded; return its
+ line index (or the nearest head at/after it), or -1 if past the end."""
+ while True:
+ idx = self.index_of_ea(ea)
+ if idx >= 0:
+ return idx
+ with self._lock:
+ have = self._rows
+ last_ea = (self._heads[-1].ea + max(self._heads[-1].size, 1) - 1
+ if self._heads else -1)
+ done = self._done
+ if done or (have and last_ea >= ea):
+ # Loaded past ea without an exact head hit: return the first head
+ # at/after ea (a mid-item address lands on its containing head).
+ return self._first_at_or_after(ea)
+ if self._load_next_page() == 0:
+ return self._first_at_or_after(ea)
+
+ def _first_at_or_after(self, ea: int) -> int:
+ with self._lock:
+ j = self._head_index_at(ea)
+ if j >= 0:
+ h = self._heads[j]
+ if h.ea <= ea < h.ea + max(h.size, 1):
+ off = (ea - h.ea) if self._span(h) > 1 else 0
+ return self._row_at[j] + off
+ for i, h in enumerate(self._heads):
+ if h.ea <= ea < h.ea + max(h.size, 1):
+ # Inside an undefined run, land on the exact BYTE.
+ off = (ea - h.ea) if self._span(h) > 1 else 0
+ return self._row_at[i] + off
+ if h.ea > ea:
+ return self._row_at[i]
+ return -1
+
+ def load_all(self, progress: Callable[[int], None] | None = None) -> None:
+ while not self._done:
+ if self._load_next_page() == 0:
+ break
+ if progress:
+ progress(self._rows)
+
+ @property
+ def complete(self) -> bool:
+ with self._lock:
+ return self._done
+
+ def loaded(self) -> int:
+ with self._lock:
+ return self._rows
+
+ def __len__(self) -> int:
+ return self.loaded()
+
+ def get(self, i: int) -> Head | None:
+ with self._lock:
+ if not (0 <= i < self._rows):
+ return None
+ j, off = self._phys(i)
+ if j < 0:
+ return None
+ span = self._span(self._heads[j])
+ h = self._heads[j]
+ # Synthesis reads bytes, so do it OUTSIDE the lock: an RPC under the
+ # model lock deadlocks the page loader that is filling it.
+ return self._row_head(j, off) if span > 1 else h
+
+ def window(self, start: int, count: int) -> list[Head]:
+ """``count`` logical rows from ``start`` (synthesising undefined ones)."""
+ self.ensure(start + count)
+ with self._lock:
+ rows = min(self._rows, start + count)
+ spans = [self._phys(i) for i in range(max(start, 0), max(rows, 0))]
+ heads = self._heads
+ plain = [(j, off, heads[j]) for j, off in spans if j >= 0]
+ return [self._row_head(j, off) if self._span(h) > 1 else h
+ for j, off, h in plain]
+
+ def index_of_ea(self, ea: int) -> int:
+ with self._lock:
+ hit = self._by_ea.get(ea)
+ if hit is not None:
+ return hit
+ # An address INSIDE an undefined run is a real row now, not a
+ # mid-item address: that is what makes `g <addr>` + `c` work
+ # anywhere in a blob. Heads are address-ordered, so bisect rather
+ # than scan — a big listing has hundreds of thousands of them and
+ # this is on the navigation path.
+ j = self._head_index_at(ea)
+ if j >= 0:
+ h = self._heads[j]
+ if self._span(h) > 1 and h.ea <= ea < h.ea + h.size:
+ return self._row_at[j] + (ea - h.ea)
+ return -1
+
+ def _head_index_at(self, ea: int) -> int:
+ """Index of the physical head containing ``ea`` (caller holds the lock)."""
+ import bisect
+ eas = self._head_eas
+ i = bisect.bisect_right(eas, ea) - 1
+ return i if 0 <= i < len(self._heads) else -1
+
+ # -- DisasmModel-compatible accessors (unified model) ------------------ #
+ def cached_line(self, idx: int) -> Head | None:
+ """Alias of get() for the disasm view's Line interface."""
+ return self.get(idx)
+
+ def lines(self, start: int, count: int, prefetch: bool = True) -> list[Head]:
+ return self.window(start, count)
+
+ def is_cached(self, start: int, count: int) -> bool:
+ with self._lock:
+ return start + count <= self._rows
+
+ def ensure_async(self, start: int, count: int) -> None:
+ pass # the background grower streams the rest in; nothing to prefetch
+
+
+# --------------------------------------------------------------------------- #
# Hex model: block-cached byte view over the loaded image (VA-addressed)
# --------------------------------------------------------------------------- #
class HexModel:
@@ -558,18 +997,31 @@ class HexModel:
class Program:
"""The bound analysis session: models, caches, and a small prefetch pool."""
- def __init__(self, client: IDAClient, prefetch_workers: int = 2):
+ def __init__(self, client: "WorkerClient", prefetch_workers: int = 2):
self.client = client
self._pool = ThreadPoolExecutor(
max_workers=prefetch_workers, thread_name_prefix="idatui-prefetch"
)
self._indices: dict[str | None, FunctionIndex] = {}
self._disasm: dict[int, DisasmModel] = {}
+ self._listings: dict[int, ListingModel] = {} # keyed by segment start
self._decomp: dict[int, tuple[Decompilation, int]] = {}
+ #: {func ea: ({line: [(x0, x1, value)]}, name generation)} — literal
+ #: positions in the pseudocode, cached alongside the decompilation.
+ self._pc_nums: dict[int, tuple[dict, int]] = {}
+ self._decomp_maps: dict[int, tuple[list[list[int]], int]] = {} # line->ea sets
+ #: {func ea: (Flowchart, name generation)} — the CFG plus its block rows.
+ #: Keyed off _name_gen, which BOTH bump_names and bump_items raise: the
+ #: rows carry live symbol names, so a rename must refetch them too.
+ self._flowcharts: dict[int, tuple["Flowchart", int]] = {}
+ self._strings: list["StrLit"] | None = None # whole-binary string literals
+ self._linkage: tuple[list["Linkage"], list["Linkage"]] | None = None
self._name_gen = 0 # bumped on rename; invalidates stale name caches
+ self._segments_cache: list[tuple[int, int, int, str]] | None = None
self._sections: list[tuple[int, int, str]] | None = None
self._fileregions: list[tuple[int, int, int]] | None = None
self._hexmodel: "HexModel | None" = None
+ self._no_read_raw = False # set if the server lacks the read_raw tool
self._lock = threading.Lock()
# -- prefetch plumbing ------------------------------------------------- #
@@ -592,23 +1044,48 @@ class Program:
return idx
# -- sections / segments ---------------------------------------------- #
+ def _segments(self) -> list[tuple[int, int, int, str]]:
+ """Sorted raw segment map [(start, end, file_off, name)] — the single
+ source for sections()/file_regions()/image_range. Cached.
+
+ Uses the injected ``file_regions`` tool (a plain segment walk, ~ms).
+ This deliberately AVOIDS ``survey_binary``, which also computes function
+ counts / strings / stats and takes *seconds* on a large IDB (it was the
+ cause of the multi-second hex-pane open). Falls back to survey_binary
+ only if the injected tool is missing.
+ """
+ if self._segments_cache is not None:
+ return self._segments_cache
+ segs: list[tuple[int, int, int, str]] = []
+ try:
+ r = self.client.call("file_regions")
+ for d in (r.get("regions", []) if isinstance(r, dict) else []):
+ if isinstance(d, dict) and "start" in d:
+ segs.append((_as_int(d["start"]), _as_int(d["end"]),
+ int(d.get("file_off", -1)), d.get("name", "") or ""))
+ except IDAToolError:
+ segs = []
+ if not segs: # older server without file_regions -> survey_binary (slow)
+ try:
+ sb = self.client.call("survey_binary")
+ for s in (sb.get("segments", []) if isinstance(sb, dict) else []):
+ try:
+ segs.append((_as_int(s["start"]), _as_int(s["end"]), -1,
+ s.get("name", "") or ""))
+ except (KeyError, ValueError, TypeError):
+ continue
+ except Exception: # noqa: BLE001 -- best-effort; callers handle empty
+ segs = []
+ segs.sort()
+ with self._lock:
+ self._segments_cache = segs
+ return segs
+
def sections(self) -> list[tuple[int, int, str]]:
- """Sorted, non-overlapping [(start, end, name)] segment map (cached).
- Fetched once from ``survey_binary`` (~0.7s) on first use."""
+ """Sorted, non-overlapping [(start, end, name)] segment map (cached)."""
if self._sections is not None:
return self._sections
- secs: list[tuple[int, int, str]] = []
- try:
- sb = self.client.call("survey_binary")
- for s in (sb.get("segments", []) if isinstance(sb, dict) else []):
- try:
- secs.append((_as_int(s["start"]), _as_int(s["end"]),
- s.get("name", "")))
- except (KeyError, ValueError, TypeError):
- continue
- except Exception: # noqa: BLE001 -- best-effort; callers handle None
- secs = []
- secs.sort()
+ secs = [(s, e, nm) for s, e, _fo, nm in self._segments()]
with self._lock:
self._sections = secs
return secs
@@ -636,16 +1113,7 @@ class Program:
the injected ``file_regions`` server tool."""
if self._fileregions is not None:
return self._fileregions
- regions: list[tuple[int, int, int]] = []
- try:
- r = self.client.call("file_regions")
- for d in (r.get("regions", []) if isinstance(r, dict) else []):
- if isinstance(d, dict) and "start" in d:
- regions.append((_as_int(d["start"]), _as_int(d["end"]),
- int(d.get("file_off", -1))))
- except IDAToolError:
- regions = []
- regions.sort()
+ regions = [(s, e, fo) for s, e, fo, _nm in self._segments()]
with self._lock:
self._fileregions = regions
return regions
@@ -658,9 +1126,29 @@ class Program:
return None
def read_bytes(self, ea: int, n: int) -> bytes:
- """Raw bytes [ea, ea+n) from IDA (gaps read as zero)."""
+ """Raw bytes [ea, ea+n) from IDA (gaps read as zero).
+
+ Fast path: the injected ``read_raw`` tool returns one contiguous hex
+ string (C-speed both ends). Falls back to the stock ``get_bytes`` (a
+ per-byte '0x..'-with-spaces string) on an older server without it.
+ """
if n <= 0:
return b""
+ if not self._no_read_raw:
+ try:
+ r = self.client.call("read_raw", addr=hex(ea), size=int(n))
+ h = r.get("hex") if isinstance(r, dict) else None
+ if isinstance(h, str):
+ out = bytes.fromhex(h)
+ return out[:n] if len(out) >= n else out + b"\x00" * (n - len(out))
+ except IDAToolError as e:
+ # Tool missing on this server: stop trying it, use get_bytes.
+ if "read_raw" in str(e) or "Unknown tool" in str(e) or "not found" in str(e):
+ self._no_read_raw = True
+ else:
+ return b"\x00" * n
+ except (ValueError, KeyError):
+ pass # malformed hex -> fall through to the legacy decoder
try:
r = self.client.call("get_bytes", regions=[{"addr": hex(ea), "size": int(n)}])
except IDAToolError:
@@ -680,14 +1168,33 @@ class Program:
def section_of(self, ea: int) -> str | None:
"""Name of the segment/section containing ``ea`` (e.g. '.got', '.text',
'.data.rel.ro', 'LOAD'), or None if unmapped."""
+ b = self.segment_bounds(ea)
+ return b[2] if b else None
+
+ def segment_bounds(self, ea: int) -> tuple[int, int, str] | None:
+ """(start, end, name) of the segment containing ``ea``, or None."""
secs = self.sections()
if not secs:
return None
i = bisect.bisect_right([s[0] for s in secs], ea) - 1
if 0 <= i < len(secs) and secs[i][0] <= ea < secs[i][1]:
- return secs[i][2]
+ return secs[i]
return None
+ def listing(self, ea: int) -> ListingModel | None:
+ """Flat listing (code+data+undefined) for the segment containing ``ea``,
+ cached per segment. None if ``ea`` is unmapped."""
+ seg = self.segment_bounds(ea)
+ if seg is None:
+ return None
+ start, end, name = seg
+ with self._lock:
+ m = self._listings.get(start)
+ if m is None:
+ m = ListingModel(self, start, end, name)
+ self._listings[start] = m
+ return m
+
# -- structs / local types -------------------------------------------- #
def list_structs(self, filter: str = "") -> list[Struct]:
"""All local structs/unions (optionally name-substring filtered), sorted
@@ -759,6 +1266,27 @@ class Program:
return None
return row.get("error") or "failed to set the prototype"
+ def data_type(self, ea: int) -> dict | None:
+ """Current type info for a data item/global: {addr,name,type,size,is_func}.
+ None if the tool is unavailable or the address isn't mapped."""
+ try:
+ r = self.client.call("data_type", addr=hex(ea))
+ except IDAToolError:
+ return None
+ if not isinstance(r, dict) or r.get("error"):
+ return None
+ return r
+
+ def set_data_type(self, ea: int, decl: str) -> str | None:
+ """Set a global/data item's type. None on success, else an error string."""
+ r = self.client.call(
+ "set_type", edits=[{"kind": "global", "addr": hex(ea), "type": decl}])
+ res = r.get("result", []) if isinstance(r, dict) else []
+ row = res[0] if res and isinstance(res[0], dict) else {}
+ if row.get("ok"):
+ return None
+ return row.get("error") or "failed to set the type"
+
def set_lvar_type(self, fn_ea: int, var: str, ty: str) -> str | None:
"""Set a decompiler local variable's type (via the injected server tool).
None on success, else an error string."""
@@ -844,15 +1372,233 @@ class Program:
return dec
def bump_names(self) -> None:
- """Signal that symbol names changed (a rename). Disasm names are live in
- the IDB, so clearing the block caches is enough for those; decompilation
- is generation-checked and force-recompiled lazily on next access."""
+ """Signal that symbol names changed (a rename). Disasm/listing names are
+ live in the IDB, so clearing the cached rows is enough for those;
+ decompilation is generation-checked and force-recompiled lazily."""
with self._lock:
self._name_gen += 1
models = list(self._disasm.values())
+ self._listings.clear() # listing head rows cache names -> refetch
+ self._pc_nums.clear() # a reformat moves every literal on its line
for m in models:
m.invalidate()
+ def bump_items(self) -> None:
+ """Signal that item/function STRUCTURE changed (define code/data/func,
+ undefine). Unlike a rename this can move instruction boundaries and
+ change function membership anywhere, so drop the disasm block caches,
+ the decompilation cache and the cached function indices outright, and
+ bump the name generation too (labels/names may appear or vanish)."""
+ with self._lock:
+ self._name_gen += 1
+ self._indices.clear()
+ self._decomp.clear()
+ self._listings.clear()
+ self._pc_nums.clear()
+ models = list(self._disasm.values())
+ self._disasm.clear()
+ for m in models:
+ m.invalidate()
+
+ # -- item / function structure edits (IDA c/d/u/p) --------------------- #
+ @staticmethod
+ def _first_result(payload) -> dict:
+ """Unwrap the first row of a batch tool response ({result:[...]} or a
+ bare list); soft per-item ``error`` fields ride along in the dict."""
+ data = payload.get("result", payload) if isinstance(payload, dict) else payload
+ if isinstance(data, list):
+ return data[0] if data and isinstance(data[0], dict) else {}
+ return data if isinstance(data, dict) else {}
+
+ def define_code(self, ea: int) -> None:
+ """Convert the bytes at ``ea`` into a code instruction (IDA's 'c').
+ Undefine first so it works even when the bytes are currently part of a
+ data/align item — ``create_insn`` refuses to carve into a live item."""
+ try:
+ self.client.call("undefine", items=[{"addr": hex(ea)}])
+ except IDAToolError:
+ pass # nothing defined here yet -> just try to create the insn
+ res = self._first_result(
+ self.client.call("define_code", items=[{"addr": hex(ea)}]))
+ if res.get("error"):
+ raise IDAToolError("define_code", f"@ {ea:#x}: {res['error']}")
+
+ def decomp_error(self, ea: int) -> str:
+ """Hex-Rays' own reason for refusing ``ea``, or "" if it won't say."""
+ try:
+ r = self.client.call("decomp_error", addr=hex(ea))
+ except IDAToolError:
+ return ""
+ if not isinstance(r, dict):
+ return ""
+ reason = str(r.get("reason") or "")
+ if reason and r.get("bitness") == 64 and "64-bit" in reason:
+ # Say the FIX, not the diagnosis. Hex-Rays' own sentence ("only
+ # 64-bit functions can be decompiled in the current database") is
+ # accurate and useless: it describes the database, not what to do,
+ # and it's long enough that a status bar cuts off the end — which is
+ # exactly where an appended hint would live. This is unfixable in
+ # place (bitness is decided at load), so the whole message is the
+ # instruction.
+ return "this database is 64-bit \u2014 Ctrl+L, pick arm:ARMv7-A"
+ return reason
+
+ def thumb_scan(self, start: int, end: int, apply: bool = True) -> dict:
+ """Find Thumb entry points from odd pointers in ``[start, end)``."""
+ r = self.client.call("thumb_scan", start=hex(start), end=hex(end),
+ apply=bool(apply))
+ if not isinstance(r, dict) or r.get("error"):
+ raise IDAToolError("thumb_scan",
+ f"@ {start:#x}: {(r or {}).get('error', 'failed')}")
+ return r
+
+ def set_thumb(self, ea: int, mode: str = "toggle") -> dict:
+ """Switch ARM/Thumb decoding at ``ea``. Returns the resulting state."""
+ r = self.client.call("set_thumb", addr=hex(ea), mode=mode)
+ if not isinstance(r, dict) or r.get("error"):
+ raise IDAToolError("set_thumb",
+ f"@ {ea:#x}: {(r or {}).get('error', 'failed')}")
+ return r
+
+ def define_code_run(self, ea: int, limit: int = 20000) -> dict:
+ """Disassemble consecutively from ``ea`` until something stops it.
+
+ Falls back to a single instruction when the worker predates the tool, so
+ an old worker degrades to the previous behaviour instead of failing.
+ """
+ try:
+ r = self.client.call("define_code_run", addr=hex(ea), limit=int(limit))
+ except IDAToolError:
+ self.define_code(ea)
+ return {"count": 1, "stopped": "single", "end": hex(ea)}
+ if not isinstance(r, dict) or r.get("error"):
+ raise IDAToolError("define_code_run",
+ f"@ {ea:#x}: {(r or {}).get('error', 'failed')}")
+ return r
+
+ def define_func(self, ea: int) -> dict:
+ """Create a function starting at ``ea`` (IDA's 'p').
+
+ Prefers the injected tool, which works out the end when IDA can't;
+ falls back to the plain one for an older worker.
+ """
+ try:
+ r = self.client.call("define_func_run", addr=hex(ea))
+ except IDAToolError:
+ res = self._first_result(
+ self.client.call("define_func", items=[{"addr": hex(ea)}]))
+ if res.get("error"):
+ raise IDAToolError("define_func", f"@ {ea:#x}: {res['error']}")
+ return {"ok": True, "how": "legacy"}
+ if not isinstance(r, dict) or not r.get("ok"):
+ raise IDAToolError("define_func",
+ f"@ {ea:#x}: {(r or {}).get('error', 'failed')}")
+ return r
+
+ def undefine(self, ea: int, size: int | None = None) -> None:
+ """Undefine the item at ``ea`` back to raw bytes (IDA's 'u')."""
+ item: dict = {"addr": hex(ea)}
+ if size:
+ item["size"] = int(size)
+ res = self._first_result(self.client.call("undefine", items=[item]))
+ if res.get("error"):
+ raise IDAToolError("undefine", f"@ {ea:#x}: {res['error']}")
+
+ def make_data(self, ea: int, type_decl: str, name: str | None = None) -> None:
+ """Create a typed data item at ``ea`` (IDA's 'd', but typed). ``type_decl``
+ is a C type, e.g. 'int', 'unsigned __int32', 'char[5]', 'my_struct'."""
+ item: dict = {"addr": hex(ea), "type": type_decl}
+ if name:
+ item["name"] = name
+ res = self._first_result(self.client.call("make_data", items=[item]))
+ if res.get("ok") is False or res.get("error"):
+ raise IDAToolError(
+ "make_data", f"@ {ea:#x}: {res.get('error') or 'rejected'}")
+
+ def make_string(self, ea: int, length: int = 0, kind: str = "c") -> str:
+ """Create a string literal at ``ea`` (IDA's 'A'); auto-length when 0.
+ Returns the decoded contents."""
+ r = self.client.call("make_string", addr=hex(ea), length=int(length), kind=kind)
+ res = r if isinstance(r, dict) else {}
+ if not res.get("ok"):
+ raise IDAToolError(
+ "make_string", f"@ {ea:#x}: {res.get('error') or 'rejected'}")
+ return res.get("text", "")
+
+ # -- literal display formats (IDA's 'o': hex / dec / char / offset) ---- #
+ def op_format(self, ea: int, mode: str = "cycle", col: int = -1,
+ n: int = -1) -> dict:
+ """Change how the literal at ``ea`` is DISPLAYED in the listing.
+
+ ``col`` is a column inside the rendered line, which is how the cursor
+ says *which* operand it means; ``n`` names one outright. ``mode`` is
+ ``cycle``/``back`` (step the stops that make sense for this value) or a
+ format by name. ``show`` reports without changing anything.
+ """
+ r = self.client.call("op_format", addr=hex(ea), mode=str(mode),
+ col=int(col), n=int(n))
+ res = r if isinstance(r, dict) else {}
+ if res.get("error"):
+ raise IDAToolError("op_format", f"@ {ea:#x}: {res['error']}")
+ if not res:
+ raise IDAToolError("op_format", f"@ {ea:#x}: no answer")
+ return res
+
+ def pc_nums(self, fn_ea: int) -> dict[int, list[tuple[int, int, str, int, int]]]:
+ """{pseudocode line: [(x0, x1, value, ea, opnum), ...]} — every number
+ literal in a function's decompilation, so the view can show which one
+ the cursor is on. One worker call per decompilation (cached with it);
+ the alternative is a round trip per cursor move.
+
+ ``ea``/``opnum`` identify a literal across a reformat: the text reflows
+ (``48`` becomes ``0x30``) and a column no longer means the same thing.
+ """
+ with self._lock:
+ hit = self._pc_nums.get(fn_ea)
+ gen = self._name_gen
+ if hit is not None and hit[1] == gen:
+ return hit[0]
+ try:
+ r = self.client.call("pc_nums", addr=hex(fn_ea))
+ except Exception: # noqa: BLE001 -- an older worker hasn't got the tool
+ r = {}
+ out: dict[int, list[tuple[int, int, str, int, int]]] = {}
+ for rec in (r or {}).get("nums", []):
+ try:
+ out.setdefault(int(rec["line"]), []).append(
+ (int(rec["x0"]), int(rec["x1"]), str(rec.get("value", "")),
+ _as_int(rec["ea"]), int(rec.get("opnum", 0))))
+ except Exception: # noqa: BLE001 -- skip a malformed row
+ continue
+ with self._lock:
+ self._pc_nums[fn_ea] = (out, gen)
+ return out
+
+ def pc_num_format(self, fn_ea: int, mode: str = "cycle", line: int = -1,
+ col: int = -1) -> dict:
+ """The same, for a number in the DECOMPILATION of ``fn_ea``.
+
+ Hex-Rays keeps number formats of its own, per (address, operand) — the
+ listing's format doesn't reach the pseudocode and vice versa, so this is
+ a separate call rather than a flag on ``op_format``.
+ """
+ r = self.client.call("pc_num_format", addr=hex(fn_ea), mode=str(mode),
+ line=int(line), col=int(col))
+ res = r if isinstance(r, dict) else {}
+ if res.get("error"):
+ raise IDAToolError("pc_num_format", f"@ {fn_ea:#x}: {res['error']}")
+ if not res:
+ raise IDAToolError("pc_num_format", f"@ {fn_ea:#x}: no answer")
+ return res
+
+ def region_label(self, ea: int) -> str:
+ """Display name for a non-function address (segment-qualified)."""
+ try:
+ sec = self.section_of(ea)
+ except Exception: # noqa: BLE001
+ sec = None
+ return f"{sec} @ {ea:#x}" if sec else f"<no function> @ {ea:#x}"
+
@staticmethod
def _fetch_output(url: str, timeout: float = 15.0):
"""GET the server's cached full-output blob (plain HTTP, not MCP)."""
@@ -862,6 +1608,180 @@ class Program:
except Exception: # noqa: BLE001 -- fall back to the truncated preview
return None
+ def strings(self, min_len: int = 4, refresh: bool = False) -> list[StrLit]:
+ """Every string literal in the binary (IDA's Shift+F12 list), paged in
+ full and cached. ``[]`` if the tool is unavailable."""
+ if not refresh:
+ with self._lock:
+ hit = self._strings
+ if hit is not None:
+ return hit
+ out: list[StrLit] = []
+ offset, page = 0, 2000
+ while True:
+ try:
+ payload = self.client.call(
+ "list_strings", offset=offset, count=page, min_len=min_len,
+ refresh=(refresh and offset == 0))
+ except IDAToolError:
+ return []
+ rows = payload.get("strings", []) if isinstance(payload, dict) else []
+ for r in rows:
+ if not isinstance(r, dict):
+ continue
+ out.append(StrLit(
+ addr=_as_int(r.get("addr", 0)),
+ text=r.get("text", ""),
+ length=int(r.get("len", 0) or 0),
+ type=r.get("type", "") or "",
+ ))
+ total = int(payload.get("total", 0) or 0) if isinstance(payload, dict) else 0
+ if len(rows) < page or len(out) >= total:
+ break
+ offset += len(rows)
+ with self._lock:
+ self._strings = out
+ return out
+
+ def linkage(self) -> tuple[list[Linkage], list[Linkage]]:
+ """``(imports, exports)`` for this binary, cached. ``([], [])`` if the
+ tool is unavailable — an old worker must not break the caller."""
+ with self._lock:
+ hit = self._linkage
+ if hit is not None:
+ return hit
+ try:
+ payload = self.client.call("list_linkage", kind="both")
+ except IDAToolError:
+ return ([], [])
+ if not isinstance(payload, dict):
+ return ([], [])
+ imps = [Linkage(addr=_as_int(r.get("addr", 0)),
+ name=link_name(r.get("name", "")),
+ module=r.get("module", "") or "",
+ raw=r.get("name", "") or "")
+ for r in payload.get("imports", []) if isinstance(r, dict)]
+ exps = [Linkage(addr=_as_int(r.get("addr", 0)),
+ name=link_name(r.get("name", "")),
+ ordinal=int(r.get("ordinal", 0) or 0),
+ raw=r.get("name", "") or "")
+ for r in payload.get("exports", []) if isinstance(r, dict)]
+ out = ([i for i in imps if i.name], [e for e in exps if e.name])
+ with self._lock:
+ self._linkage = out
+ return out
+
+ def decomp_map(self, ea: int) -> list[list[int]]:
+ """Per-pseudocode-line instruction coverage for the split-view region
+ highlight: a list aligned to the decompiled lines, each the EAs the
+ decompiler attributes to that line (may be empty). Cached per function +
+ name generation; ``[]`` if the tool is unavailable."""
+ with self._lock:
+ hit = self._decomp_maps.get(ea)
+ gen = self._name_gen
+ if hit is not None and hit[1] == gen:
+ return hit[0]
+ try:
+ payload = self.client.call("decomp_map", addr=hex(ea))
+ except IDAToolError:
+ return []
+ lines = payload.get("lines", []) if isinstance(payload, dict) else []
+ out = [[_as_int(e) for e in (ln.get("eas") or [])]
+ for ln in lines if isinstance(ln, dict)]
+ with self._lock:
+ self._decomp_maps[ea] = (out, gen)
+ return out
+
+ # -- control-flow graph ------------------------------------------------ #
+ def flowchart(self, ea: int) -> "Flowchart | None":
+ """The basic-block CFG of the function containing ``ea``, with each
+ block's listing rows attached.
+
+ Two calls, not one per block: ``flowchart`` for the shape, then a single
+ ``heads`` walk over the function's extent which is sliced up by address.
+ A hundred blocks would otherwise be a hundred round trips.
+
+ Cached per function + item generation, so it survives cursor movement
+ but not an edit that changes the code.
+ """
+ fn = self.function_of(ea)
+ key = fn.addr if fn else ea
+ with self._lock:
+ hit = self._flowcharts.get(key)
+ gen = self._name_gen
+ if hit is not None and hit[1] == gen:
+ return hit[0]
+ try:
+ payload = self.client.call("flowchart", addr=hex(ea))
+ except IDAToolError:
+ return None
+ if not isinstance(payload, dict) or payload.get("error"):
+ return None
+ raw = payload.get("blocks") or []
+ if not raw:
+ return None
+ blocks = []
+ for b in raw:
+ try:
+ blocks.append(BasicBlock(
+ id=int(b["id"]), start=_as_int(b["start"]),
+ end=_as_int(b["end"]),
+ succs=[(int(d), str(k)) for d, k in (b.get("succs") or [])]))
+ except (KeyError, ValueError, TypeError):
+ continue
+ if not blocks:
+ return None
+ f = payload.get("func") or {}
+ lo = min(b.start for b in blocks)
+ hi = max(b.end for b in blocks)
+ rows = self._heads_between(lo, hi)
+ for b in blocks:
+ b.rows = [h for h in rows if b.start <= h.ea < b.end]
+ fcv = Flowchart(
+ func_ea=_as_int(f.get("addr", lo)),
+ name=str(f.get("name") or f"sub_{lo:X}"),
+ entry=int(payload.get("entry", 0) or 0),
+ blocks=blocks,
+ )
+ with self._lock:
+ self._flowcharts[key] = (fcv, gen)
+ return fcv
+
+ def _heads_between(self, lo: int, hi: int) -> list[Head]:
+ """Listing rows for [lo, hi), paged. Same tool and same ``Head`` shape
+ the listing view renders, so the graph inherits IDA's colour tags and
+ operand marks for free."""
+ out: list[Head] = []
+ addr = lo
+ for _ in range(64): # bounded: ~128k heads
+ if addr >= hi:
+ break
+ payload = self.client.call("heads", addr=hex(addr), end=hex(hi),
+ count=2000)
+ rows = payload.get("heads", []) if isinstance(payload, dict) else []
+ if not rows:
+ break
+ for r in rows:
+ try:
+ h = Head.from_raw(r)
+ except (KeyError, ValueError, TypeError):
+ continue
+ # Banners and separators are listing furniture; a box already
+ # has a border and a label of its own.
+ if h.kind in ("sep", "funchdr"):
+ continue
+ if lo <= h.ea < hi:
+ out.append(h)
+ cur = payload.get("cursor", {}) if isinstance(payload, dict) else {}
+ nxt = cur.get("next")
+ if nxt is None or cur.get("done"):
+ break
+ n = _as_int(nxt)
+ if n <= addr:
+ break
+ addr = n
+ return out
+
# -- cross-references & containing function --------------------------- #
def function_of(self, ea: int) -> Func | None:
"""Return the function containing ``ea`` (resolves mid-function addrs)."""
@@ -878,11 +1798,14 @@ class Program:
return _parse_xrefs(payload)
def xrefs_to(self, ea: int, limit: int = 2000) -> list[Xref]:
- payload = self.client.call(
- "xref_query",
- queries=[{"addr": hex(ea), "direction": "to", "include_fn": True,
- "dedup": True, "count": limit}],
- )
+ q = [{"addr": hex(ea), "direction": "to", "include_fn": True,
+ "dedup": True, "count": limit}]
+ try:
+ # xref_types adds a fine-grained `kind` (call/read/write/...) for the
+ # xref dialog; fall back to xref_query (code/data only) if absent.
+ payload = self.client.call("xref_types", queries=q)
+ except IDAToolError:
+ payload = self.client.call("xref_query", queries=q)
return _parse_xrefs(payload)
# -- address resolution ------------------------------------------------ #
@@ -895,8 +1818,20 @@ class Program:
return int(s, 16)
if re.fullmatch(r"[0-9a-fA-F]+", s):
return int(s, 16)
- # Symbol name -> ask the server. lookup_funcs takes an array of strings
- # and returns [{"query":..., "fn": {addr,name,size} | null, "error":...}].
+ # Symbol name -> resolve to the address the NAME denotes (get_name_ea via
+ # resolve_names). This handles functions, data AND mid-function labels
+ # (loc_/locret_): lookup_funcs would map a label to its *containing*
+ # function's entry, so double-clicking a label jumped to the wrong place.
+ try:
+ payload = self.client.call("resolve_names", queries=[s])
+ res = payload.get("result", []) if isinstance(payload, dict) else []
+ ea = res[0].get("ea") if res and isinstance(res[0], dict) else None
+ if ea:
+ return _as_int(ea)
+ except IDAToolError:
+ pass # older server without resolve_names -> fall back below
+ # Fall back to function-name resolution (also drives the 'did you mean'
+ # suggestion when the name is unknown).
try:
payload = self.client.call("lookup_funcs", queries=[s])
except IDAToolError as e:
@@ -904,9 +1839,33 @@ class Program:
res = payload.get("result", []) if isinstance(payload, dict) else []
fn = res[0].get("fn") if res and isinstance(res[0], dict) else None
if not fn:
- raise KeyError(f"cannot resolve {target!r}")
+ raise KeyError(f"cannot resolve {target!r}{self._name_suggestion(s)}")
return _as_int(fn["addr"])
+ def _name_suggestion(self, s: str) -> str:
+ """Best-effort ' — did you mean …?' hint for a failed name resolve.
+
+ ``lookup_funcs`` matches exact function names only, so a demangled or
+ partial name (``QuaziLies`` for ``_Z9QuaziLiesPcS_ii``) or a data symbol
+ (``checkKey``) resolves to nothing. Surface the substring matches from
+ the function index so the caller can retype the exact name instead of
+ getting a bare ``cannot resolve``. Never raises — suggestions are a
+ nicety, not a contract.
+ """
+ try:
+ idx = self.functions(filter=s)
+ idx.ensure(6)
+ cands = idx.window(0, 6)
+ except Exception: # noqa: BLE001 -- suggestions are strictly optional
+ return ""
+ if not cands:
+ return (" (no function name contains it; it may be a data symbol or "
+ "not a function — pass an address like 0x1234)")
+ shown = cands[:5]
+ names = ", ".join(f"{c.name} @ {c.addr:#x}" for c in shown)
+ more = " …" if len(cands) > len(shown) else ""
+ return f" — did you mean: {names}{more}?"
+
# -- comments ---------------------------------------------------------- #
def set_comment(self, ea: int, text: str):
"""Set (empty text clears) the comment at ``ea``; affects both the disasm
@@ -947,6 +1906,7 @@ def _parse_xrefs(payload) -> list[Xref]:
type=d.get("type", "?"),
fn_name=fn.get("name"),
fn_addr=_as_int(fn["addr"]) if fn.get("addr") else None,
+ kind=d.get("kind"),
))
return out
diff --git a/idatui/drive.py b/idatui/drive.py
index 6c96335..b83a8ba 100644
--- a/idatui/drive.py
+++ b/idatui/drive.py
@@ -165,6 +165,31 @@ def cmd_names(c, args):
or "(no match)"
+def cmd_binaries(c, args):
+ """Project inventory: which binaries, which is active, which have a live
+ worker, how much of each is indexed."""
+ r = c.call("binaries")
+ out = []
+ for b in r["binaries"]:
+ mark = "*" if b["active"] else ("~" if b["resident"] else " ")
+ out.append(f" {mark} {b['label']:<28} indexed={b['indexed']:<7} {b['source']}")
+ if r.get("hops"):
+ out.append(f" (Esc returns to: {' <- '.join(r['hops'])})")
+ return "\n".join(out) + "\n * active ~ worker resident"
+
+
+def cmd_switch(c, args):
+ if not args:
+ raise SystemExit("usage: switch <binary> [addr|name]")
+ p = {"binary": args[0]}
+ if len(args) > 1:
+ a = args[1]
+ p["addr"] = a if a.lower().startswith("0x") else c.call("resolve", name=a)["ea"]
+ r = c.call("switch", **p)
+ fn = (r.get("function") or {}).get("name")
+ return f" now on {r.get('binary') or args[0]}" + (f" @ {fn}" if fn else "")
+
+
def _rename_one(c, old, new):
c.call("goto", target=old, delay_ms=0)
st = c.call("rename", name=new, word=old, delay_ms=0)
@@ -194,10 +219,15 @@ def cmd_mv(c, args):
def cmd_note(c, args):
if len(args) < 2:
raise SystemExit("usage: note <fn> <text...>")
- c.call("goto", target=args[0], delay_ms=0)
- c.call("cursor", line=0, col=0)
+ st = c.call("goto", target=args[0], delay_ms=0)
+ # goto already lands on the function's first line. The old `cursor line=0`
+ # meant "the top of the function" only in the decompiler; in the listing
+ # line 0 is the top of the whole SEGMENT, so the note landed at address 0 --
+ # and on a 42k-line firmware listing the scroll to get there timed the
+ # caller out, which read as "comments are broken".
c.call("comment", text=" ".join(args[1:]), delay_ms=0)
- return f" noted {args[0]}"
+ cur = (st.get("function") or {}).get("name") or args[0]
+ return f" noted {cur} @ {(st.get('cursor') or {}).get('ea', 0):#x}"
def cmd_retype(c, args):
@@ -208,6 +238,58 @@ def cmd_retype(c, args):
return _fmt_where(st)
+def cmd_define(c, args):
+ """define <kind> [target ...] — the raw-image workflow (thumb/code/func).
+
+ Several targets are common on a firmware image (a list of entry points from
+ a symbol file), so take them all and report per-target.
+ """
+ if not args:
+ raise SystemExit("usage: define <code|func|undef|thumb|thumbscan|data|"
+ "string> [target ...]")
+ kind, targets = args[0], (args[1:] or [None])
+ out = []
+ for t in targets:
+ try:
+ st = c.call("define", kind=kind, **({"target": t} if t else {}))
+ out.append(f" {t or '.'}: {st.get('status', '')}")
+ except RpcError as e:
+ out.append(f" {t or '.'}: FAILED: {e}")
+ return "\n".join(out)
+
+
+def cmd_fmt(c, args):
+ """fmt [mode] [word] — how the literal under the cursor is DISPLAYED.
+
+ ``fmt`` alone cycles (IDA's 'o'); a mode name sets it outright. A trailing
+ word puts the cursor on that token first, so you can name the literal
+ instead of steering the column there.
+
+ fmt # cycle the literal under the cursor
+ fmt dec # show it in decimal
+ fmt hex 18h # find '18h' on screen, then make it hex
+ """
+ mode = args[0] if args else "cycle"
+ params = {"mode": mode}
+ if len(args) > 1:
+ params["word"] = args[1]
+ st = c.call("opfmt", **params)
+ return " " + (st.get("opfmt", {}).get("status") or st.get("status", ""))
+
+
+def cmd_syms(c, args):
+ """syms <file.json> — bulk-apply a symbol file ([{addr|start|ea, name}])."""
+ if len(args) != 1:
+ raise SystemExit("usage: syms <symbols.json>")
+ r = c.call("rename_many", file=os.path.abspath(os.path.expanduser(args[0])))
+ m = r.get("rename_many", {})
+ out = [f" {m.get('ok', 0)}/{m.get('requested', 0)} renamed"
+ f" (skipped {m.get('skipped', 0)}, failed {m.get('failed', 0)})"]
+ for e in m.get("errors", []):
+ out.append(f" {e.get('addr')}: {e.get('error')}")
+ return "\n".join(out)
+
+
def cmd_save(c, args):
c.call("save")
return " saved"
@@ -231,7 +313,9 @@ COMMANDS = {
"where": cmd_where, "go": cmd_go, "pc": cmd_pc, "dis": cmd_dis,
"callees": cmd_callees, "callers": cmd_callers, "names": cmd_names,
"rename": cmd_rename, "mv": cmd_mv, "note": cmd_note, "retype": cmd_retype,
- "save": cmd_save, "screen": cmd_screen, "raw": cmd_raw,
+ "save": cmd_save, "screen": cmd_screen, "raw": cmd_raw, "define": cmd_define,
+ "syms": cmd_syms, "fmt": cmd_fmt,
+ "binaries": cmd_binaries, "switch": cmd_switch,
}
diff --git a/idatui/errors.py b/idatui/errors.py
new file mode 100644
index 0000000..29b09ae
--- /dev/null
+++ b/idatui/errors.py
@@ -0,0 +1,76 @@
+"""Transport-agnostic error hierarchy and the Session model.
+
+These were originally defined in client.py (the ida-pro-mcp HTTP client), but the
+idalib worker path (worker_client / domain / app) needs the same exception types
+and Session dataclass without dragging in the HTTP transport. They live here so
+both backends share one definition; client.py re-exports them for backwards
+compatibility with the (deprecated) mcp tooling and the stress tests.
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any
+
+
+# --------------------------------------------------------------------------- #
+# Exceptions
+# --------------------------------------------------------------------------- #
+class IDAError(Exception):
+ """Base class for all client errors."""
+
+
+class IDAConnectionError(IDAError):
+ """The transport could not be established or was lost."""
+
+
+class IDATimeoutError(IDAError):
+ """A request exceeded its deadline."""
+
+
+class IDAProtocolError(IDAError):
+ """Malformed or unexpected HTTP / JSON-RPC framing."""
+
+
+class IDARPCError(IDAError):
+ """The JSON-RPC envelope carried an ``error`` object."""
+
+ def __init__(self, code: int, message: str, data: Any = None):
+ super().__init__(f"JSON-RPC error {code}: {message}")
+ self.code = code
+ self.message = message
+ self.data = data
+
+
+class IDAToolError(IDAError):
+ """A tool call returned ``result.isError == true`` (a hard failure)."""
+
+ def __init__(self, tool: str, message: str):
+ super().__init__(f"tool {tool!r} failed: {message}")
+ self.tool = tool
+ self.message = message
+
+
+class IDASessionError(IDAError):
+ """No IDB session is open, or several are and none was pinned."""
+
+
+# --------------------------------------------------------------------------- #
+# Session model
+# --------------------------------------------------------------------------- #
+@dataclass(frozen=True)
+class Session:
+ session_id: str
+ filename: str
+ input_path: str
+ is_active: bool = False
+ is_analyzing: bool = False
+
+ @classmethod
+ def from_dict(cls, d: dict) -> "Session":
+ return cls(
+ session_id=d.get("session_id", ""),
+ filename=d.get("filename", ""),
+ input_path=d.get("input_path", ""),
+ is_active=bool(d.get("is_active", False)),
+ is_analyzing=bool(d.get("is_analyzing", False)),
+ )
diff --git a/idatui/formats.py b/idatui/formats.py
new file mode 100644
index 0000000..f09bb99
--- /dev/null
+++ b/idatui/formats.py
@@ -0,0 +1,143 @@
+"""Recognising what a file is, and what to ask when we can't.
+
+IDA picks a loader by looking at the file. When one matches, it knows the
+processor, the load address and often the entry point, and there is nothing to
+ask. When none matches — a raw firmware dump, a flash image, a decompressed blob
+— IDA falls back to a plain binary load: **x86 at address 0**. That is a silent
+wrong answer, not an error; the database opens, analysis runs, and finds nothing.
+
+Interactive IDA handles this by asking. This module is the "should we ask, and
+what are the sensible answers" half of doing the same.
+
+The sniff deliberately only recognises formats IDA definitely has a loader for.
+Being wrong in the cautious direction costs one dismissible dialog; being wrong
+the other way is the silent-nothing case we're trying to kill.
+"""
+
+from __future__ import annotations
+
+import os
+
+#: (magic, offset, name) for formats IDA loads without help.
+_MAGIC: tuple[tuple[bytes, int, str], ...] = (
+ (b"\x7fELF", 0, "ELF"),
+ (b"MZ", 0, "PE/MZ"),
+ (b"\xfe\xed\xfa\xce", 0, "Mach-O"),
+ (b"\xfe\xed\xfa\xcf", 0, "Mach-O 64"),
+ (b"\xce\xfa\xed\xfe", 0, "Mach-O (LE)"),
+ (b"\xcf\xfa\xed\xfe", 0, "Mach-O 64 (LE)"),
+ (b"\xca\xfe\xba\xbe", 0, "Mach-O fat/Java class"),
+ (b"dex\n", 0, "Dalvik"),
+ (b"\x00asm", 0, "WebAssembly"),
+ (b"!<arch>", 0, "ar archive"),
+ (b"\x4c\x01", 0, "COFF i386"),
+ (b"\x64\x86", 0, "COFF x64"),
+ (b"\x00\x00\x03\xf3", 0, "Amiga hunk"),
+ (b"\x7fCGC", 0, "CGC"),
+)
+
+#: Text-ish container formats: recognised by their first line, not a magic word.
+_TEXT_PREFIX: tuple[tuple[bytes, str], ...] = (
+ (b":", "Intel HEX"),
+ (b"S0", "Motorola S-record"),
+ (b"S1", "Motorola S-record"),
+ (b"S2", "Motorola S-record"),
+ (b"S3", "Motorola S-record"),
+)
+
+
+def sniff(path: str) -> str | None:
+ """The container format of ``path``, or None when nothing recognises it.
+
+ None is the interesting answer: it means IDA will guess, and its guess is
+ x86-at-zero regardless of what the bytes actually are.
+ """
+ try:
+ with open(path, "rb") as f:
+ head = f.read(64)
+ except OSError:
+ return None
+ if not head:
+ return None
+ for magic, off, name in _MAGIC:
+ if head[off:off + len(magic)] == magic:
+ return name
+ # Only treat a text prefix as a format if the whole head is printable —
+ # a raw blob starting with 0x3a (':') is far more likely than Intel HEX.
+ if all(32 <= b < 127 or b in (9, 10, 13) for b in head):
+ for prefix, name in _TEXT_PREFIX:
+ if head.startswith(prefix):
+ return name
+ return None
+
+
+def needs_load_options(path: str) -> bool:
+ """True when we should ask how to load ``path`` rather than let IDA guess."""
+ return os.path.isfile(path) and sniff(path) is None
+
+
+#: Processors worth offering, as (IDA -p name, human label).
+#:
+#: IDA ships 73 processor modules, most of which are museum pieces; a list that
+#: long is a worse answer than a short one plus free text. These are the targets
+#: that actually turn up in firmware work, endianness spelled out because
+#: getting it wrong is the most common way to end up with zero functions.
+#:
+#: EVERY NAME HERE IS VERIFIED against a real IDA by opening a scratch blob with
+#: ``-p<name>`` and reading back ``inf_get_procname()`` — see
+#: ``tools/verify_procs.py``. That is not ceremony: a wrong name is REJECTED by
+#: IDA (rc=4) with no useful message, which is the same silent-failure class this
+#: dialog exists to prevent. Two of the first twenty were wrong ("h8" and
+#: "sparc" are module filenames, not processor names), and the aliases people
+#: reach for first — arm64, aarch64, mips, m68k — are all invalid. Re-run the
+#: script before adding to this list.
+PROCESSORS: tuple[tuple[str, str], ...] = (
+ # Bare 'arm' gives a 64-BIT database in IDA 9 (AArch64). That matters far
+ # more than it looks: Hex-Rays refuses a 32-bit function in a 64-bit database
+ # ("only 64-bit functions can be decompiled in the current database"), and
+ # Thumb doesn't exist in AArch64 at all — so a 32-bit ARM image loaded as
+ # plain 'arm' disassembles wrongly and can never be decompiled. The database
+ # bitness is fixed at load; it cannot be corrected afterwards (setting it
+ # post-hoc makes the decompiler INTERR). Pick the right one here.
+ ("arm", "ARM64 / AArch64 / arm64 — 64-bit, little-endian"),
+ ("arm:ARMv7-A", "ARM 32-bit (ARMv7-A) — Thumb capable, most firmware"),
+ ("arm:ARMv7-M", "ARM 32-bit (ARMv7-M) — Cortex-M, Thumb only"),
+ ("arm:ARMv6-M", "ARM 32-bit (ARMv6-M) — Cortex-M0/M0+"),
+ ("arm:ARMv5TE", "ARM 32-bit (ARMv5TE) — older SoCs"),
+ ("armb", "ARM — big-endian"),
+ ("metapc", "x86 / x86-64"),
+ ("mipsl", "MIPS — little-endian"),
+ ("mipsb", "MIPS — big-endian"),
+ ("ppc", "PowerPC — big-endian"),
+ ("ppcl", "PowerPC — little-endian"),
+ ("sh4", "SuperH SH-4"),
+ ("68k", "Motorola 68000 / m68k"),
+ ("riscv", "RISC-V"),
+ ("tricore", "Infineon TriCore"),
+ ("xtensa", "Tensilica Xtensa (ESP32 etc.)"),
+ ("avr", "Atmel AVR"),
+ ("z80", "Zilog Z80"),
+ ("tms320c6", "TI TMS320C6x DSP"),
+ ("m32r", "Renesas M32R"),
+ ("arc", "Synopsys ARC"),
+ ("h8300", "Renesas H8/300"),
+ ("sparcb", "SPARC — big-endian"),
+ ("sparcl", "SPARC — little-endian"),
+ ("s390", "IBM S/390"),
+)
+
+
+def load_args(processor: str = "", base: int = 0, extra: str = "") -> str:
+ """``processor``/``base`` as IDA command-line switches.
+
+ ``-b`` is in PARAGRAPHS, not bytes: ``-b1000`` loads at 0x10000. Every caller
+ goes through here so that conversion is done in exactly one place.
+ """
+ parts = []
+ if processor:
+ parts.append(f"-p{processor}")
+ if base:
+ parts.append(f"-b{int(base) >> 4:x}")
+ if extra:
+ parts.append(extra)
+ return " ".join(parts)
diff --git a/idatui/graph.py b/idatui/graph.py
new file mode 100644
index 0000000..e0a9422
--- /dev/null
+++ b/idatui/graph.py
@@ -0,0 +1,715 @@
+"""Layered control-flow-graph layout, in character cells.
+
+Pure python: no IDA, no Textual, no I/O. That is deliberate — it means the whole
+layout can be unit-tested offline in milliseconds (``tests/test_graph.py``) and
+iterated without an idalib worker, and it keeps the hard algorithmic part away
+from the UI.
+
+The pipeline is textbook Sugiyama, the same shape IDA's own graph uses:
+
+ 1. break cycles DFS gray-set; back edges are reversed for layout only
+ 2. layer longest-path ranking on the resulting DAG
+ 3. dummies an edge spanning k layers becomes a chain of k-1 dummy
+ nodes, so every segment is between ADJACENT layers and long
+ edges reserve real horizontal space (this is what makes it
+ impossible for an edge to need to cross a box)
+ 4. order median sweeps + adjacent transposition, to cut crossings
+ 5. x-coords priority/median sweeps, variable node widths
+ 6. route ports on node borders, one lane-packed channel per layer gap
+
+Sizing is injected (``sizer``) rather than computed here, so the caller decides
+how wide a block is at the current zoom level without this module knowing
+anything about text.
+
+The result is NOT a painted canvas. A big function lays out to millions of
+cells, so ``Painting`` is an *index* — per-row horizontal runs, a bucketed
+interval index of vertical runs, and point marks — and the view asks it for one
+row at a time (``cells_at_row``), exactly like the listing's ``render_line``.
+"""
+from __future__ import annotations
+
+import time
+from dataclasses import dataclass, field
+
+# Terminal cells are about twice as tall as they are wide, so horizontal gaps
+# need roughly 2x the cell count of vertical gaps to look square.
+HGAP = 3 # min columns between two boxes in a layer
+VGAP = 1 # min rows between a layer band and the channel below it
+
+# Edge classes, used as style keys by the renderer.
+E_UNCOND = "uncond"
+E_TRUE = "jump"
+E_FALSE = "fall"
+E_SWITCH = "switch"
+E_BACK = "back"
+
+
+@dataclass
+class Block:
+ """One basic block, as the backend reports it."""
+
+ id: int
+ start: int
+ end: int
+ succs: list[tuple[int, str]] = field(default_factory=list)
+ selfloop: bool = False
+
+
+@dataclass
+class Node:
+ """A laid-out box (``block`` set) or a routing dummy (``block`` None)."""
+
+ id: int
+ block: Block | None = None
+ label: str = ""
+ rank: int = 0
+ order: int = 0
+ x: int = 0 # left column
+ y: int = 0 # top row
+ w: int = 1
+ h: int = 1
+
+ @property
+ def dummy(self) -> bool:
+ return self.block is None
+
+ @property
+ def cx(self) -> float:
+ return self.x + self.w / 2
+
+ @property
+ def bottom(self) -> int:
+ return self.y + self.h - 1
+
+ @property
+ def right(self) -> int:
+ return self.x + self.w - 1
+
+ def contains(self, row: int, col: int) -> bool:
+ return self.y <= row <= self.bottom and self.x <= col <= self.right
+
+ def inside(self, row: int, col: int) -> bool:
+ """Strictly inside the border (where text lives)."""
+ return (self.y < row < self.bottom) and (self.x < col < self.right)
+
+
+@dataclass
+class Edge:
+ src: int
+ dst: int
+ kind: str = E_UNCOND
+ back: bool = False
+ chain: list[int] = field(default_factory=list)
+
+ @property
+ def style(self) -> str:
+ return E_BACK if self.back else self.kind
+
+
+class _Graph:
+ def __init__(self) -> None:
+ self.nodes: dict[int, Node] = {}
+ self.edges: list[Edge] = []
+ self._next = 0
+
+ def add(self, n: Node) -> Node:
+ self.nodes[n.id] = n
+ self._next = max(self._next, n.id + 1)
+ return n
+
+ def new_dummy(self) -> Node:
+ n = Node(id=self._next, w=1, h=1)
+ return self.add(n)
+
+
+# ------------------------------------------------------------ 1. cycles
+
+def _break_cycles(g: _Graph, root: int) -> None:
+ """Reverse back edges (DFS gray-set) so layering sees a DAG."""
+ color: dict[int, int] = {}
+ adj: dict[int, list[Edge]] = {i: [] for i in g.nodes}
+ for e in g.edges:
+ adj[e.src].append(e)
+ for start in [root] + [i for i in g.nodes if i != root]:
+ if color.get(start):
+ continue
+ color[start] = 1
+ stack = [(start, iter(adj[start]))]
+ while stack:
+ node, it = stack[-1]
+ for e in it:
+ c = color.get(e.dst, 0)
+ if c == 1:
+ e.back = True
+ elif c == 0:
+ color[e.dst] = 1
+ stack.append((e.dst, iter(adj[e.dst])))
+ break
+ else:
+ color[node] = 2
+ stack.pop()
+ for e in g.edges:
+ if e.back:
+ e.src, e.dst = e.dst, e.src
+
+
+# --------------------------------------------------------- 2. layering
+
+def _assign_ranks(g: _Graph, root: int) -> None:
+ """Longest-path layering: rank(v) = 1 + max(rank(preds)).
+
+ Kahn, but it never trusts that ``_break_cycles`` left a perfect DAG: if the
+ ready queue drains with nodes left over (a residual cycle, or a block only
+ reachable through a reversed edge) it force-releases the most-constrained
+ survivor instead of stranding it at rank 0. Getting this wrong collapses the
+ whole graph into three layers and looks like a layout bug, not a ranking one.
+ """
+ indeg = {i: 0 for i in g.nodes}
+ adj: dict[int, list[int]] = {i: [] for i in g.nodes}
+ for e in g.edges:
+ indeg[e.dst] += 1
+ adj[e.src].append(e.dst)
+
+ rank = {i: 0 for i in g.nodes}
+ done: set[int] = set()
+ ready = [i for i in g.nodes if indeg[i] == 0] or [root]
+ pending = dict(indeg)
+ while len(done) < len(g.nodes):
+ if not ready:
+ left = [i for i in g.nodes if i not in done]
+ ready = [min(left, key=lambda i: (pending[i], rank[i], i))]
+ i = ready.pop(0)
+ if i in done:
+ continue
+ done.add(i)
+ for j in adj[i]:
+ if rank[j] < rank[i] + 1:
+ rank[j] = rank[i] + 1
+ pending[j] -= 1
+ if pending[j] <= 0 and j not in done:
+ ready.append(j)
+ for i, n in g.nodes.items():
+ n.rank = rank[i]
+
+
+# ---------------------------------------------------------- 3. dummies
+
+def _add_dummies(g: _Graph) -> None:
+ for e in list(g.edges):
+ span = g.nodes[e.dst].rank - g.nodes[e.src].rank
+ if span <= 0:
+ e.back = True # residual cycle: colour it, route it flat
+ chain = [e.src]
+ if span > 1:
+ for r in range(g.nodes[e.src].rank + 1, g.nodes[e.dst].rank):
+ d = g.new_dummy()
+ d.rank = r
+ chain.append(d.id)
+ chain.append(e.dst)
+ e.chain = chain
+
+
+def _layers_of(g: _Graph) -> list[list[int]]:
+ top = max((n.rank for n in g.nodes.values()), default=0)
+ layers: list[list[int]] = [[] for _ in range(top + 1)]
+ for i, n in g.nodes.items():
+ layers[n.rank].append(i)
+ return layers
+
+
+def _segments(g: _Graph) -> list[tuple[int, int, Edge]]:
+ out = []
+ for e in g.edges:
+ for a, b in zip(e.chain, e.chain[1:]):
+ out.append((a, b, e))
+ return out
+
+
+# ---------------------------------------------------------- 4. ordering
+
+def _neighbors(g: _Graph) -> tuple[dict[int, list[int]], dict[int, list[int]]]:
+ down: dict[int, list[int]] = {i: [] for i in g.nodes}
+ up: dict[int, list[int]] = {i: [] for i in g.nodes}
+ for a, b, _ in _segments(g):
+ down[a].append(b)
+ up[b].append(a)
+ return down, up
+
+
+def _cross_below(layer: list[int], down: dict[int, list[int]],
+ pos: dict[int, int]) -> int:
+ """Crossings between this layer and the one below, counted as inversions
+ with a Fenwick tree: O(E log E). The naive O(E^2) version is the entire
+ runtime on a 400-block function (20s vs 150ms), so it is not an option."""
+ pairs = []
+ for u in layer:
+ for v in down[u]:
+ pairs.append((pos[u], pos[v]))
+ if not pairs:
+ return 0
+ pairs.sort()
+ size = max(p[1] for p in pairs) + 2
+ tree = [0] * (size + 1)
+ total = seen = 0
+ for _, v in pairs:
+ i = v + 1
+ acc, j = 0, i
+ while j > 0:
+ acc += tree[j]
+ j -= j & -j
+ total += seen - acc
+ seen += 1
+ j = i
+ while j <= size:
+ tree[j] += 1
+ j += j & -j
+ return total
+
+
+def _pair_cross(a: int, b: int, side: dict[int, list[int]],
+ pos: dict[int, int]) -> int:
+ """Crossings from a's and b's edges to one neighbouring layer given a sits
+ immediately LEFT of b. Local — O(deg(a)*deg(b)) — so the transposition pass
+ never has to recount the whole graph per candidate swap."""
+ n = 0
+ for u in side[a]:
+ pu = pos[u]
+ for v in side[b]:
+ if pu > pos[v]:
+ n += 1
+ return n
+
+
+def crossings(layers: list[list[int]], down: dict[int, list[int]],
+ pos: dict[int, int]) -> int:
+ return sum(_cross_below(l, down, pos) for l in layers)
+
+
+def _order_layers(g: _Graph, root: int, sweeps: int = 6) -> list[list[int]]:
+ layers = _layers_of(g)
+ down, up = _neighbors(g)
+
+ # Seed with a DFS preorder so the picture already resembles control flow
+ # (fallthrough-first); barycenter alone does not recover that.
+ seed: dict[int, int] = {}
+ stack, seen, tick = [root], {root}, 0
+ while stack:
+ i = stack.pop()
+ seed[i] = tick
+ tick += 1
+ for j in reversed(down.get(i, [])):
+ if j not in seen:
+ seen.add(j)
+ stack.append(j)
+ for layer in layers:
+ layer.sort(key=lambda i: seed.get(i, 10 ** 9))
+ pos = {i: k for layer in layers for k, i in enumerate(layer)}
+
+ def median(i: int, side: dict[int, list[int]]) -> float:
+ ps = sorted(pos[j] for j in side[i])
+ if not ps:
+ return -1.0
+ m = len(ps) // 2
+ return float(ps[m]) if len(ps) % 2 else (ps[m - 1] + ps[m]) / 2
+
+ best, best_x = [list(l) for l in layers], crossings(layers, down, pos)
+ for s in range(sweeps):
+ rng = range(1, len(layers)) if s % 2 == 0 else range(len(layers) - 2, -1, -1)
+ side = up if s % 2 == 0 else down
+ for r in rng:
+ layer = layers[r]
+ keys = {i: median(i, side) for i in layer}
+ layer.sort(key=lambda i: (keys[i] if keys[i] >= 0 else pos[i], pos[i]))
+ for k, i in enumerate(layer):
+ pos[i] = k
+ for _ in range(4):
+ improved = False
+ for layer in layers:
+ for k in range(len(layer) - 1):
+ a, b = layer[k], layer[k + 1]
+ keep = _pair_cross(a, b, down, pos) + _pair_cross(a, b, up, pos)
+ swap = _pair_cross(b, a, down, pos) + _pair_cross(b, a, up, pos)
+ if swap < keep:
+ layer[k], layer[k + 1] = b, a
+ pos[a], pos[b] = k + 1, k
+ improved = True
+ if not improved:
+ break
+ x = crossings(layers, down, pos)
+ if x < best_x:
+ best, best_x = [list(l) for l in layers], x
+
+ layers = best
+ for layer in layers:
+ for k, i in enumerate(layer):
+ g.nodes[i].order = k
+ return layers
+
+
+# --------------------------------------------------------- 5. x coords
+
+def _assign_x(g: _Graph, layers: list[list[int]], sweeps: int = 8) -> None:
+ down, up = _neighbors(g)
+ for layer in layers:
+ x = 0
+ for i in layer:
+ g.nodes[i].x = x
+ x += g.nodes[i].w + HGAP
+
+ def pack(layer: list[int]) -> None:
+ for k in range(1, len(layer)):
+ a, b = g.nodes[layer[k - 1]], g.nodes[layer[k]]
+ if b.x < a.x + a.w + HGAP:
+ b.x = a.x + a.w + HGAP
+ for k in range(len(layer) - 2, -1, -1):
+ a, b = g.nodes[layer[k]], g.nodes[layer[k + 1]]
+ if a.x + a.w + HGAP > b.x:
+ a.x = b.x - HGAP - a.w
+
+ for s in range(sweeps):
+ rng = range(1, len(layers)) if s % 2 == 0 else range(len(layers) - 2, -1, -1)
+ side = up if s % 2 == 0 else down
+ for r in rng:
+ layer = layers[r]
+ # dummies first: keeping long edges straight matters most
+ order = sorted(layer, key=lambda i: (not g.nodes[i].dummy,
+ g.nodes[i].order))
+ for i in order:
+ nb = side[i]
+ if not nb:
+ continue
+ cs = sorted(g.nodes[j].cx for j in nb)
+ m = len(cs) // 2
+ target = cs[m] if len(cs) % 2 else (cs[m - 1] + cs[m]) / 2
+ g.nodes[i].x = int(round(target - g.nodes[i].w / 2))
+ pack(layer)
+
+ lo = min((g.nodes[i].x for layer in layers for i in layer), default=0)
+ for n in g.nodes.values():
+ n.x -= lo
+
+
+# ------------------------------------------------------------ 6. route
+
+def _ports(g: _Graph) -> tuple[dict, dict]:
+ """Spread a node's out-edges along its bottom border and its in-edges along
+ its top, each ordered by the other end's x so they don't cross at the node."""
+ out_port: dict[tuple, int] = {}
+ in_port: dict[tuple, int] = {}
+ by_src: dict[int, list] = {}
+ by_dst: dict[int, list] = {}
+ for a, b, e in _segments(g):
+ by_src.setdefault(a, []).append((a, b, e))
+ by_dst.setdefault(b, []).append((a, b, e))
+
+ def spread(n: Node, k: int, count: int) -> int:
+ if n.dummy or count <= 1:
+ return int(n.cx)
+ usable = max(n.w - 4, 1)
+ step = usable / (count + 1)
+ return int(n.x + 2 + step * (k + 1))
+
+ for i, lst in by_src.items():
+ lst.sort(key=lambda t: g.nodes[t[1]].cx)
+ for k, (a, b, e) in enumerate(lst):
+ out_port[(a, b, id(e))] = spread(g.nodes[i], k, len(lst))
+ for i, lst in by_dst.items():
+ lst.sort(key=lambda t: g.nodes[t[0]].cx)
+ for k, (a, b, e) in enumerate(lst):
+ in_port[(a, b, id(e))] = spread(g.nodes[i], k, len(lst))
+ return out_port, in_port
+
+
+@dataclass
+class Route:
+ edge: Edge
+ pts: list[tuple[int, int]]
+ head: bool = True # arrowhead (target is a real block)
+ tail: bool = True # port tee (source is a real block)
+
+
+def _route(g: _Graph, layers: list[list[int]]) -> list[Route]:
+ out_port, in_port = _ports(g)
+ segs = _segments(g)
+ by_rank: dict[int, list] = {}
+ for a, b, e in segs:
+ by_rank.setdefault(g.nodes[a].rank, []).append((a, b, e))
+
+ lanes: dict[tuple, int] = {}
+ channels = [1] * len(layers)
+ for r, lst in by_rank.items():
+ runs = []
+ for a, b, e in lst:
+ x0, x1 = out_port[(a, b, id(e))], in_port[(a, b, id(e))]
+ if x0 != x1: # a straight drop needs no lane
+ runs.append((min(x0, x1), max(x0, x1), (a, b, id(e))))
+ runs.sort(key=lambda t: (t[1] - t[0], t[0]))
+ occupied: list[list[tuple[int, int]]] = []
+ for lo, hi, key in runs:
+ for li, used in enumerate(occupied):
+ if all(hi < u_lo or lo > u_hi for u_lo, u_hi in used):
+ used.append((lo, hi))
+ lanes[key] = li
+ break
+ else:
+ occupied.append([(lo, hi)])
+ lanes[key] = len(occupied) - 1
+ channels[r] = max(len(occupied), 1)
+
+ # y: a band per layer, then the routing channel underneath it. Horizontal
+ # runs live in the channel BELOW A WHOLE LAYER, never at a per-node offset
+ # -- that is what stops an edge sawing through a taller neighbour.
+ chan_y = []
+ y = 0
+ for r, layer in enumerate(layers):
+ h = max((g.nodes[i].h for i in layer if not g.nodes[i].dummy), default=1)
+ for i in layer:
+ n = g.nodes[i]
+ n.y = y
+ if n.dummy:
+ n.h = h # the band is its pass-through
+ chan_y.append(y + h - 1 + VGAP)
+ y += h - 1 + VGAP + channels[r] + VGAP + 1
+
+ def exit_y(n: Node) -> int:
+ # a dummy leaves from the TOP of its band: its own outgoing segment
+ # draws the vertical that passes through the band.
+ return n.y if n.dummy else n.bottom
+
+ routes = []
+ for a, b, e in segs:
+ na, nb = g.nodes[a], g.nodes[b]
+ x0, x1 = out_port[(a, b, id(e))], in_port[(a, b, id(e))]
+ y0, y1 = exit_y(na), nb.y
+ if x0 == x1:
+ pts = [(y0, x0), (y1, x1)]
+ else:
+ ych = chan_y[na.rank] + lanes.get((a, b, id(e)), 0)
+ pts = [(y0, x0), (ych, x0), (ych, x1), (y1, x1)]
+ routes.append(Route(edge=e, pts=pts,
+ head=not nb.dummy, tail=not na.dummy))
+ return routes
+
+
+# ------------------------------------------------------------ painting
+
+BOX = {"tl": "\u250c", "tr": "\u2510", "bl": "\u2514", "br": "\u2518",
+ "h": "\u2500", "v": "\u2502"}
+LINE_CHARS = set("\u2502\u2500\u250c\u2510\u2514\u2518\u251c\u2524\u252c\u2534"
+ "\u253c\u256d\u256e\u2570\u256f")
+MERGE = {
+ frozenset("\u2502\u2500"): "\u253c",
+ frozenset("\u2502\u250c"): "\u251c", frozenset("\u2502\u2510"): "\u2524",
+ frozenset("\u2502\u2514"): "\u251c", frozenset("\u2502\u2518"): "\u2524",
+ frozenset("\u2500\u250c"): "\u252c", frozenset("\u2500\u2510"): "\u252c",
+ frozenset("\u2500\u2514"): "\u2534", frozenset("\u2500\u2518"): "\u2534",
+ frozenset("\u2502\u256d"): "\u251c", frozenset("\u2502\u256e"): "\u2524",
+ frozenset("\u2502\u2570"): "\u251c", frozenset("\u2502\u256f"): "\u2524",
+ frozenset("\u2500\u256d"): "\u252c", frozenset("\u2500\u256e"): "\u252c",
+ frozenset("\u2500\u2570"): "\u2534", frozenset("\u2500\u256f"): "\u2534",
+}
+CORNER = {
+ ("D", "R"): "\u2570", ("D", "L"): "\u256f", ("R", "D"): "\u256e",
+ ("L", "D"): "\u256d", ("R", "U"): "\u256f", ("L", "U"): "\u2570",
+ ("U", "R"): "\u256d", ("U", "L"): "\u256e",
+}
+BUCKET = 32 # rows per vertical-run index bucket
+
+
+def _dir(p: tuple[int, int], q: tuple[int, int]) -> str:
+ if p[0] == q[0]:
+ return "R" if q[1] > p[1] else "L"
+ return "D" if q[0] > p[0] else "U"
+
+
+class Painting:
+ """A queryable drawing of the edges. Never a full canvas: a 400-block
+ function is ~13M cells, so runs are stored as intervals and asked for one
+ row at a time."""
+
+ def __init__(self) -> None:
+ self.hruns: dict[int, list[tuple[int, int, str, int]]] = {}
+ self.vruns: list[tuple[int, int, int, str, int]] = []
+ self.vindex: dict[int, list[int]] = {}
+ self.marks: dict[int, list[tuple[int, str, str, int]]] = {}
+
+ def add_h(self, row: int, c0: int, c1: int, style: str, eid: int) -> None:
+ self.hruns.setdefault(row, []).append((min(c0, c1), max(c0, c1), style, eid))
+
+ def add_v(self, r0: int, r1: int, col: int, style: str, eid: int) -> None:
+ lo, hi = (r0, r1) if r0 <= r1 else (r1, r0)
+ idx = len(self.vruns)
+ self.vruns.append((lo, hi, col, style, eid))
+ for b in range(lo // BUCKET, hi // BUCKET + 1):
+ self.vindex.setdefault(b, []).append(idx)
+
+ def add_mark(self, row: int, col: int, ch: str, style: str, eid: int) -> None:
+ self.marks.setdefault(row, []).append((col, ch, style, eid))
+
+ def cells_at_row(self, row: int, c0: int, c1: int
+ ) -> dict[int, tuple[str, str, int]]:
+ """{col: (char, style, edge_id)} for ``row`` within [c0, c1)."""
+ out: dict[int, tuple[str, str, int]] = {}
+
+ def put(col: int, ch: str, style: str, eid: int, force: bool = False) -> None:
+ if col < c0 or col >= c1:
+ return
+ old = out.get(col)
+ if old and not force and old[0] != ch \
+ and old[0] in LINE_CHARS and ch in LINE_CHARS:
+ ch = MERGE.get(frozenset((old[0], ch)), ch)
+ out[col] = (ch, style, eid)
+
+ for lo, hi, style, eid in self.hruns.get(row, ()):
+ for c in range(max(lo, c0), min(hi + 1, c1)):
+ put(c, BOX["h"], style, eid)
+ for i in self.vindex.get(row // BUCKET, ()):
+ lo, hi, col, style, eid = self.vruns[i]
+ if lo <= row <= hi:
+ put(col, BOX["v"], style, eid)
+ for col, ch, style, eid in self.marks.get(row, ()):
+ put(col, ch, style, eid, force=True)
+ return out
+
+
+@dataclass
+class Layout:
+ """The finished drawing: boxes, an edge index, and enough structure for the
+ view to hit-test, navigate and highlight."""
+
+ nodes: list[Node] # real blocks only, layout order
+ by_id: dict[int, Node]
+ edges: list[Edge]
+ painting: Painting
+ width: int
+ height: int
+ entry: int
+ rows: dict[int, list[int]] # row -> real node ids covering it
+ incident: dict[int, set[int]] # node id -> edge ids touching it
+ succ: dict[int, list[tuple[int, str]]] # node id -> [(node id, style)]
+ pred: dict[int, list[tuple[int, str]]]
+ stats: dict
+
+ def node_at(self, row: int, col: int) -> Node | None:
+ for nid in self.rows.get(row, ()):
+ n = self.by_id[nid]
+ if n.x <= col <= n.right:
+ return n
+ return None
+
+ def nodes_at_row(self, row: int) -> list[Node]:
+ return [self.by_id[i] for i in self.rows.get(row, ())]
+
+ def edge_at(self, row: int, col: int) -> Edge | None:
+ cells = self.painting.cells_at_row(row, col, col + 1)
+ hit = cells.get(col)
+ if hit is None:
+ return None
+ for e in self.edges:
+ if id(e) == hit[2]:
+ return e
+ return None
+
+
+def layout(blocks: list[Block], sizer, entry: int | None = None) -> Layout:
+ """Lay out ``blocks``. ``sizer(block) -> (width, height)`` in cells."""
+ t0 = time.perf_counter()
+ g = _Graph()
+ for b in blocks:
+ w, h = sizer(b)
+ g.add(Node(id=b.id, block=b, label=f"loc_{b.start:X}",
+ w=max(int(w), 4), h=max(int(h), 3)))
+ for b in blocks:
+ outs = [(d, k) for d, k in b.succs if d in g.nodes]
+ for dst, kind in outs:
+ if dst == b.id:
+ # A self-loop constrains nothing and would deadlock the Kahn
+ # ranking (its own in-degree never drains). Drawn as a marker.
+ b.selfloop = True
+ continue
+ if len(outs) == 1:
+ kind = E_UNCOND
+ g.edges.append(Edge(src=b.id, dst=dst, kind=kind))
+
+ root = entry if entry in g.nodes else (min(g.nodes) if g.nodes else 0)
+ if g.nodes:
+ _break_cycles(g, root)
+ _assign_ranks(g, root)
+ _add_dummies(g)
+ layers = _order_layers(g, root)
+ _assign_x(g, layers)
+ routes = _route(g, layers)
+ else:
+ layers, routes = [], []
+
+ # ---- paint into the index -----------------------------------------
+ p = Painting()
+ real = [n for n in g.nodes.values() if not n.dummy]
+ rows: dict[int, list[int]] = {}
+ for n in real:
+ for r in range(n.y, n.y + n.h):
+ rows.setdefault(r, []).append(n.id)
+ for lst in rows.values():
+ lst.sort(key=lambda i: g.nodes[i].x)
+
+ def blocked(row: int, col: int) -> bool:
+ for nid in rows.get(row, ()):
+ if g.nodes[nid].inside(row, col):
+ return True
+ return False
+
+ incident: dict[int, set[int]] = {n.id: set() for n in real}
+ for rt in routes:
+ e, style, eid = rt.edge, rt.edge.style, id(rt.edge)
+ incident.setdefault(e.src, set()).add(eid)
+ incident.setdefault(e.dst, set()).add(eid)
+ for (r0, c0), (r1, c1) in zip(rt.pts, rt.pts[1:]):
+ if r0 == r1:
+ p.add_h(r0, c0, c1, style, eid)
+ else:
+ p.add_v(r0, r1, c0, style, eid)
+ for k in range(1, len(rt.pts) - 1):
+ a, b, c = rt.pts[k - 1], rt.pts[k], rt.pts[k + 1]
+ ch = CORNER.get((_dir(a, b), _dir(b, c)))
+ if ch and not blocked(*b):
+ p.add_mark(b[0], b[1], ch, style, eid)
+ # A back edge was reversed for layering, so its polyline runs from the
+ # loop HEAD down to the tail: the arrow belongs at the start, pointing
+ # up into the block control returns to.
+ first, last = rt.pts[0], rt.pts[-1]
+ if e.back:
+ if rt.tail:
+ p.add_mark(first[0], first[1], "\u25b2", style, eid)
+ if rt.head:
+ p.add_mark(last[0], last[1], "\u2534", style, eid)
+ else:
+ if rt.tail:
+ p.add_mark(first[0], first[1], "\u252c", style, eid)
+ if rt.head:
+ p.add_mark(last[0], last[1], "\u25bc", style, eid)
+
+ succ: dict[int, list[tuple[int, str]]] = {n.id: [] for n in real}
+ pred: dict[int, list[tuple[int, str]]] = {n.id: [] for n in real}
+ for e in g.edges:
+ a, b = (e.dst, e.src) if e.back else (e.src, e.dst) # undo reversal
+ if a in succ:
+ succ[a].append((b, e.style))
+ if b in pred:
+ pred[b].append((a, e.style))
+
+ width = max((n.right + 1 for n in real), default=1)
+ height = max((n.y + n.h for n in real), default=1)
+ order = sorted(real, key=lambda n: (n.rank, n.order))
+ stats = {
+ "blocks": len(blocks),
+ "nodes": len(g.nodes),
+ "dummies": len(g.nodes) - len(real),
+ "layers": len(layers),
+ "edges": len(g.edges),
+ "back": sum(1 for e in g.edges if e.back),
+ "ms": (time.perf_counter() - t0) * 1000,
+ }
+ return Layout(nodes=order, by_id={n.id: n for n in g.nodes.values()},
+ edges=g.edges, painting=p, width=width, height=height,
+ entry=root, rows=rows, incident=incident,
+ succ=succ, pred=pred, stats=stats)
diff --git a/idatui/highlight.py b/idatui/highlight.py
index 9fc692f..5f504bf 100644
--- a/idatui/highlight.py
+++ b/idatui/highlight.py
@@ -15,19 +15,28 @@ from pygments.lexers import CLexer
from pygments.token import Token
# Token -> style, checked in priority order (first hierarchical match wins).
-# Palette roughly follows a dark IDE theme.
+#
+# Same measured palette as the listing (see the theme notes): one hue = one
+# meaning across BOTH panes, so a string is the same green and a symbol the same
+# blue whether you're reading disassembly or pseudocode. Contrast ratios are
+# against the app background #12161c; signal sits at 7:1+ and structure recedes
+# to 3-4:1 so punctuation stops competing with the code.
+#
+# Control keywords take the brightest NEUTRAL rather than a hue, mirroring the
+# mnemonic column: they're the skeleton you scan for, and a hue there would
+# claim a meaning the rest of the palette already assigns.
_STYLES: list[tuple[object, Style]] = [
- (Token.Comment, Style(color="grey54", italic=True)),
- (Token.Keyword.Type, Style(color="#4ec9b0")), # __int64, char, ...
- (Token.Keyword, Style(color="#c586c0", bold=True)), # if/else/return/goto
- (Token.Name.Builtin, Style(color="#4ec9b0")),
- (Token.Literal.String, Style(color="#ce9178")), # "..."
- (Token.Literal.Number, Style(color="#b5cea8")), # 0x10, 42
- (Token.Operator, Style(color="#d4d4d4")),
- (Token.Punctuation, Style(color="grey70")),
- (Token.Name, Style(color="#dcdcaa")), # identifiers / calls
+ (Token.Comment, Style(color="#7c8b9e", italic=True)), # 5.2:1 commentary
+ (Token.Keyword.Type, Style(color="#93aee0")), # 8.1:1 type info
+ (Token.Keyword, Style(color="#e8ecf2", bold=True)), # 15.3:1 control flow
+ (Token.Name.Builtin, Style(color="#93aee0")), # 8.1:1 type info
+ (Token.Literal.String, Style(color="#9ece6a")), # 9.9:1 strings
+ (Token.Literal.Number, Style(color="#d8a657")), # 8.2:1 data/number
+ (Token.Operator, Style(color="#c3cad3")), # 11.0:1 body
+ (Token.Punctuation, Style(color="#626c7a")), # 3.4:1 structure
+ (Token.Name, Style(color="#7aa2f7")), # 7.2:1 symbol names
]
-_DEFAULT = Style(color="#d4d4d4")
+_DEFAULT = Style(color="#c3cad3") # 11.0:1 body
_lexer = CLexer(stripnl=False, ensurenl=False)
diff --git a/idatui/index.py b/idatui/index.py
new file mode 100644
index 0000000..751ad1f
--- /dev/null
+++ b/idatui/index.py
@@ -0,0 +1,206 @@
+"""ProjectIndex — one searchable index over every binary in a project.
+
+Phase 2 of docs/PROJECTS.md: searching across binaries must work for binaries
+whose worker isn't running, so the index lives on disk rather than in the
+Programs' caches.
+
+SQLite FTS5 with the **trigram** tokenizer, which is stdlib (no dependency) and
+indexes arbitrary *substrings* rather than just word prefixes — the right shape
+for symbol names and string literals. Measured on a 300k-entry corpus: 1.9 ms per
+substring query (vs 11.8 ms for a Python scan and 28.9 ms for plain LIKE), 0.2 ms
+per incremental insert.
+
+Sizing, from real binaries: libcrypto.so.3 (5.9 MB, ~10k functions + ~20k
+strings) contributes 0.52 MB of text, and the index runs ~5.7x the text it
+covers. A 20-binary project therefore lands around 12-23 MB — against the ``.i64``
+files already in the sidecar, where libcrypto's alone is 72 MB. The index is
+roughly 1% of what the project already costs on disk.
+
+Caveat baked into ``search``: a trigram index cannot answer queries shorter than
+three characters — it silently returns nothing rather than erroring — so short
+queries fall back to LIKE. Without that, typing "e" then "er" would show "no
+matches" until the third keystroke.
+"""
+from __future__ import annotations
+
+import os
+import sqlite3
+from dataclasses import dataclass
+
+#: Trigram indexes can't match fewer than 3 characters; below this we scan.
+MIN_TRIGRAM = 3
+
+KIND_FUNC = "func"
+KIND_STRING = "string"
+#: Cross-binary linkage: what a binary takes from, and offers to, other modules.
+KIND_IMPORT = "import"
+KIND_EXPORT = "export"
+
+
+@dataclass(frozen=True)
+class Hit:
+ """One index match."""
+
+ binary: str
+ kind: str
+ addr: int
+ text: str
+
+
+def _fts_phrase(query: str) -> str:
+ """``query`` as an FTS5 phrase: quoted so operators are literal, with any
+ embedded quote doubled."""
+ return '"' + query.replace('"', '""') + '"'
+
+
+class ProjectIndex:
+ """Symbol/string index for a whole project, keyed by binary label."""
+
+ def __init__(self, path: str) -> None:
+ self.path = os.path.abspath(path)
+ parent = os.path.dirname(self.path)
+ if parent:
+ os.makedirs(parent, exist_ok=True)
+ # check_same_thread=False: the TUI indexes from a worker thread and
+ # queries from the UI thread. Writes are serialised by the caller.
+ self._db = sqlite3.connect(self.path, check_same_thread=False)
+ self._db.executescript(
+ """
+ CREATE VIRTUAL TABLE IF NOT EXISTS entries USING fts5(
+ text,
+ binary UNINDEXED, kind UNINDEXED, addr UNINDEXED,
+ tokenize='trigram');
+ CREATE TABLE IF NOT EXISTS stamps(
+ binary TEXT PRIMARY KEY,
+ size INTEGER, mtime INTEGER, n INTEGER);
+ """
+ )
+ self._db.commit()
+
+ # -- freshness --------------------------------------------------------- #
+ def stamp(self, label: str) -> tuple[int, int, int] | None:
+ """(size, mtime, entry count) recorded when ``label`` was last indexed."""
+ row = self._db.execute(
+ "SELECT size, mtime, n FROM stamps WHERE binary = ?", (label,)).fetchone()
+ return tuple(row) if row else None # type: ignore[return-value]
+
+ def is_stale(self, label: str, source: str) -> bool:
+ """True when ``label`` has never been indexed, or its source changed."""
+ st = self.stamp(label)
+ if st is None:
+ return True
+ try:
+ s = os.stat(source)
+ except OSError:
+ return False # source gone: keep what we have rather than wipe it
+ return (st[0], st[1]) != (s.st_size, int(s.st_mtime))
+
+ # -- population -------------------------------------------------------- #
+ def reindex(self, label: str, entries, source: str | None = None) -> int:
+ """Replace ``label``'s entries with ``entries`` — (kind, addr, text)
+ triples. Per-binary, so re-indexing one never touches the others."""
+ rows = [(text, label, kind, int(addr))
+ for kind, addr, text in entries if text]
+ self._db.execute("DELETE FROM entries WHERE binary = ?", (label,))
+ self._db.executemany(
+ "INSERT INTO entries(text, binary, kind, addr) VALUES(?,?,?,?)", rows)
+ size = mtime = 0
+ if source:
+ try:
+ s = os.stat(source)
+ size, mtime = s.st_size, int(s.st_mtime)
+ except OSError:
+ pass
+ self._db.execute(
+ "INSERT INTO stamps(binary, size, mtime, n) VALUES(?,?,?,?) "
+ "ON CONFLICT(binary) DO UPDATE SET size=?, mtime=?, n=?",
+ (label, size, mtime, len(rows), size, mtime, len(rows)))
+ self._db.commit()
+ return len(rows)
+
+ def forget(self, label: str) -> None:
+ """Drop a binary from the index (removed from the project)."""
+ self._db.execute("DELETE FROM entries WHERE binary = ?", (label,))
+ self._db.execute("DELETE FROM stamps WHERE binary = ?", (label,))
+ self._db.commit()
+
+ # -- query -------------------------------------------------------------- #
+ def search(self, query: str, kind: str | None = None,
+ limit: int = 500) -> list[Hit]:
+ """Substring search across every indexed binary, newest-agnostic.
+
+ Uses the trigram index at >= 3 characters and falls back to a LIKE scan
+ below that (the index can't answer shorter queries and would silently
+ return nothing).
+ """
+ q = (query or "").strip()
+ if not q:
+ return []
+ sql = ["SELECT binary, kind, addr, text FROM entries WHERE "]
+ args: list = []
+ if len(q) >= MIN_TRIGRAM:
+ sql.append("text MATCH ?")
+ args.append(_fts_phrase(q))
+ else:
+ sql.append("text LIKE ?")
+ args.append(f"%{q}%")
+ if kind:
+ sql.append(" AND kind = ?")
+ args.append(kind)
+ sql.append(" LIMIT ?")
+ args.append(int(limit))
+ try:
+ rows = self._db.execute("".join(sql), args).fetchall()
+ except sqlite3.OperationalError:
+ return [] # malformed FTS expression: treat as no matches
+ return [Hit(binary=b, kind=k, addr=int(a), text=t) for b, k, a, t in rows]
+
+ def exact(self, name: str, kind: str, exclude: str | None = None) -> list[Hit]:
+ """Every entry whose text is EXACTLY ``name``, for the linkage join.
+
+ Deliberately not ``search()``: an import must resolve to the export of
+ that name, not to everything containing it (``read`` would otherwise
+ match ``pread``, ``read_line``, ``thread_start``). Exact match also
+ works below the trigram floor, which matters — plenty of real exports
+ are one or two characters.
+ """
+ n = (name or "").strip()
+ if not n:
+ return []
+ sql = "SELECT binary, kind, addr, text FROM entries WHERE text = ? AND kind = ?"
+ args: list = [n, kind]
+ if exclude:
+ sql += " AND binary <> ?"
+ args.append(exclude)
+ rows = self._db.execute(sql + " ORDER BY binary, addr", args).fetchall()
+ return [Hit(binary=b, kind=k, addr=int(a), text=t) for b, k, a, t in rows]
+
+ def providers(self, name: str, exclude: str | None = None) -> list[Hit]:
+ """Binaries in the project that EXPORT ``name`` (skip ``exclude``, the
+ binary asking). This is the 'follow an import to its implementation'
+ half of the join."""
+ return self.exact(name, KIND_EXPORT, exclude)
+
+ def importers(self, name: str, exclude: str | None = None) -> list[Hit]:
+ """Binaries in the project that IMPORT ``name`` — 'who in the project
+ calls this export'."""
+ return self.exact(name, KIND_IMPORT, exclude)
+
+ # -- introspection ------------------------------------------------------ #
+ def counts(self) -> dict[str, int]:
+ """Indexed entry count per binary."""
+ return {b: n for b, n in
+ self._db.execute("SELECT binary, n FROM stamps").fetchall()}
+
+ def total(self) -> int:
+ return int(self._db.execute(
+ "SELECT count(*) FROM entries").fetchone()[0])
+
+ def close(self) -> None:
+ try:
+ self._db.close()
+ except Exception: # noqa: BLE001
+ pass
+
+ def __repr__(self) -> str: # pragma: no cover - debug aid
+ return f"<ProjectIndex {self.total()} entries {self.path}>"
diff --git a/idatui/kittygfx.py b/idatui/kittygfx.py
new file mode 100644
index 0000000..e53fba3
--- /dev/null
+++ b/idatui/kittygfx.py
@@ -0,0 +1,271 @@
+"""Kitty graphics protocol: detect it, upload an image, place it on screen.
+
+Used for the startup splash, which otherwise falls back to the block-art
+``logo.ans``. Two things about this were expensive to find out, so they are
+written down here rather than rediscovered.
+
+**Support cannot be sniffed from the environment.** A multiplexer that passes
+the protocol through (recent zellij, tmux with allow-passthrough) leaves TERM as
+``xterm-256color`` with ``KITTY_WINDOW_ID``, ``TERM_PROGRAM`` and ``COLORTERM``
+all empty, while the protocol answers perfectly. Detection by terminal name
+would disable graphics on exactly the terminals that support them. So we ask:
+send a 1x1 graphics query together with a Primary Device Attributes request.
+Every terminal answers DA1, so that reply is the sync point -- a ``_G...OK``
+before it means yes, DA1 alone means no. No timeouts to tune, no allowlist.
+
+**Unicode placeholders are not usable.** The tidy way to put an image in a TUI
+is a virtual placement (``U=1``) plus U+10EEEE placeholder cells, which the
+compositor then moves and clips like ordinary text. It is also what every
+Textual image library is built on -- and this terminal answers
+``ENOTSUPPORTED:unicode placeholders are not supported`` while supporting
+everything else. So we use ordinary placement: the image is anchored at a screen
+cell and stays there until deleted, which means the caller owns its lifetime
+(place on mount and resize, delete on unmount) and must reserve blank cells
+underneath. That is fine for a splash and deliberately not built up into a
+general image widget.
+
+**Uploading and drawing happen on opposite sides of the alternate screen.** The
+detection query must run BEFORE the app starts, because it needs a reply and
+Textual reads stdin on its own thread. The IMAGE, though, must be uploaded AFTER
+Textual has switched to the alternate screen: an image uploaded to the primary
+screen cannot be placed from the alternate one -- placement reports no error, it
+simply draws nothing. That combination is why the splash calls ``supported()``
+from the launcher and ``upload()`` from its own ``on_mount``.
+"""
+from __future__ import annotations
+
+import base64
+import os
+import re
+import select
+import struct
+import sys
+import time
+
+#: One id for the splash. Ids are a terminal-wide namespace shared with whatever
+#: else the user is running, so this is deliberately not 1.
+LOGO_ID = 0x1DA7
+
+_supported: bool | None = None
+_uploaded: dict[int, tuple[int, int]] = {} # image id -> (pixel w, pixel h)
+#: Terminal cell size in pixels, asked for in the same round trip as the
+#: graphics query. Cells are nothing like a fixed 1:2 -- this box reports 9x22,
+#: i.e. 1:2.44 -- and getting it wrong stretches the image.
+_cell: tuple[int, int] | None = None
+
+
+def log(msg: str) -> None:
+ """Trace to ``$IDATUI_KITTY_LOG``. The splash lives inside a full-screen TUI
+ on a tty we can't print to, so this is the only way to see what it decided."""
+ path = os.environ.get("IDATUI_KITTY_LOG")
+ if not path:
+ return
+ try:
+ with open(path, "a") as f:
+ f.write(f"{time.time():.3f} {msg}\n")
+ except OSError:
+ pass
+
+
+# --------------------------------------------------------------------------- #
+# Detection
+# --------------------------------------------------------------------------- #
+def _query_tty(timeout: float = 2.0) -> bool:
+ import termios
+ import tty as ttymod
+
+ try:
+ fd = os.open("/dev/tty", os.O_RDWR | os.O_NOCTTY)
+ except OSError:
+ return False
+ try:
+ old = termios.tcgetattr(fd)
+ except termios.error:
+ os.close(fd)
+ return False
+ try:
+ ttymod.setraw(fd)
+ # graphics query + cell-size query + DA1. DA1 is answered by everything,
+ # so it marks the end of the replies and nothing has to be timed.
+ os.write(fd, b"\033_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\033\\\033[16t\033[c")
+ buf = b""
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ r, _, _ = select.select([fd], [], [], 0.15)
+ if not r:
+ continue
+ chunk = os.read(fd, 4096)
+ if not chunk:
+ break
+ buf += chunk
+ if re.search(rb"\033\[\?[0-9;]*c", buf): # DA1: the answers are in
+ break
+ global _cell
+ m = re.search(rb"\033\[6;(\d+);(\d+)t", buf) # CSI 6 ; height ; width t
+ if m:
+ ch, cw = int(m.group(1)), int(m.group(2))
+ if 0 < cw < 100 and 0 < ch < 200:
+ _cell = (cw, ch)
+ log(f"cell size {cw}x{ch}px")
+ return bool(re.search(rb"\033_G[^\033]*;OK\033\\", buf))
+ except OSError:
+ return False
+ finally:
+ try:
+ termios.tcsetattr(fd, termios.TCSANOW, old)
+ finally:
+ os.close(fd)
+
+
+def supported() -> bool:
+ """True if the terminal speaks the kitty graphics protocol.
+
+ ``$IDATUI_KITTY=0/1`` forces the answer, for a terminal that swallows the
+ query and for tests. Cached: the query costs a round trip and must not run
+ once Textual owns stdin.
+ """
+ global _supported
+ if _supported is not None:
+ return _supported
+ env = os.environ.get("IDATUI_KITTY", "").strip().lower()
+ if env in ("1", "yes", "true", "on"):
+ _supported = True
+ elif env in ("0", "no", "false", "off"):
+ _supported = False
+ elif not (sys.__stdout__ and sys.__stdout__.isatty()):
+ _supported = False # pilot tests, pipes, redirected output
+ log("supported: stdout is not a tty")
+ else:
+ _supported = _query_tty()
+ log(f"supported() -> {_supported}")
+ return _supported
+
+
+# --------------------------------------------------------------------------- #
+# Upload / place / delete
+# --------------------------------------------------------------------------- #
+def png_size(path: str) -> tuple[int, int] | None:
+ """(width, height) from a PNG's IHDR, without decoding it."""
+ try:
+ with open(path, "rb") as f:
+ head = f.read(26)
+ except OSError:
+ return None
+ if len(head) < 24 or head[:8] != b"\x89PNG\r\n\x1a\n":
+ return None
+ w, h = struct.unpack(">II", head[16:24])
+ return (w, h)
+
+
+def _write(data: str) -> bool:
+ """Write escapes to the same stream Textual writes frames to, so the two
+ can't be reordered. Called only from the app's own loop."""
+ out = sys.__stdout__
+ if out is None:
+ return False
+ try:
+ out.write(data)
+ out.flush()
+ return True
+ except (OSError, ValueError):
+ return False
+
+
+def upload(path: str, image_id: int = LOGO_ID) -> bool:
+ """Send the PNG to the terminal WITHOUT placing it (``a=t``).
+
+ Must be called once the app is already on the ALTERNATE screen -- an image
+ uploaded to the primary screen can't be placed from the alternate one, and
+ the placement fails silently. Idempotent, so callers can just ask.
+ """
+ if image_id in _uploaded:
+ return True
+ size = png_size(path)
+ if size is None:
+ return False
+ try:
+ with open(path, "rb") as f:
+ payload = base64.standard_b64encode(f.read())
+ except OSError:
+ return False
+ parts = [payload[i:i + 4096] for i in range(0, len(payload), 4096)]
+ if not parts:
+ return False
+ buf = []
+ for i, part in enumerate(parts):
+ more = 1 if i < len(parts) - 1 else 0
+ ctrl = (f"a=t,f=100,t=d,i={image_id},q=2,m={more}" if i == 0
+ else f"m={more}")
+ buf.append("\033_G" + ctrl + ";" + part.decode("ascii") + "\033\\")
+ if not _write("".join(buf)):
+ log("upload: write failed")
+ return False
+ _uploaded[image_id] = size
+ log(f"upload -> ok id={image_id} px={size} chunks={len(parts)}")
+ return True
+
+
+def is_uploaded(image_id: int = LOGO_ID) -> bool:
+ return image_id in _uploaded
+
+
+def place(row: int, col: int, cols: int, rows: int,
+ image_id: int = LOGO_ID) -> bool:
+ """Draw the uploaded image at (``row``, ``col``), 0-based, sized in cells.
+
+ Saves and restores the cursor, and asks the terminal not to move it
+ (``C=1``), so Textual's idea of where the cursor is stays true.
+ """
+ size = _uploaded.get(image_id)
+ if size is None or cols <= 0 or rows <= 0:
+ log(f"place: refused size={size} cols={cols} rows={rows}")
+ return False
+ w, h = size
+ log(f"place row={row} col={col} c={cols} r={rows}")
+ return _write(
+ f"\033[s\033[{row + 1};{col + 1}H"
+ f"\033_Ga=p,i={image_id},s={w},v={h},c={cols},r={rows},C=1,q=2\033\\"
+ f"\033[u")
+
+
+def clear(image_id: int = LOGO_ID) -> None:
+ """Remove the image's placements from the screen (it stays uploaded)."""
+ _write(f"\033_Ga=d,d=i,i={image_id},q=2\033\\")
+
+
+def delete(image_id: int = LOGO_ID) -> None:
+ """Remove the placements AND free the image data in the terminal."""
+ _write(f"\033_Ga=d,d=I,i={image_id},q=2\033\\")
+ _uploaded.pop(image_id, None)
+
+
+def cell_size() -> tuple[int, int]:
+ """(width, height) of a terminal cell in pixels.
+
+ Measured during the graphics query when the terminal answers CSI 16 t;
+ otherwise a 10x20 guess, which is only ever used to keep the aspect ratio
+ honest.
+ """
+ return _cell or (10, 20)
+
+
+def fit(px: tuple[int, int], max_cols: int, max_rows: int,
+ cell: tuple[int, int] | None = None) -> tuple[int, int]:
+ """Cell size that fits ``max_cols`` x ``max_rows`` keeping the aspect ratio.
+
+ Cells are far from square -- this box reports 9x22 px -- so a naive
+ cols==rows box stretches the image; ``cell`` is that ratio in pixels and
+ defaults to what the terminal actually said.
+ """
+ if cell is None:
+ cell = cell_size()
+ w, h = px
+ if w <= 0 or h <= 0:
+ return (max_cols, max_rows)
+ cw, ch = cell
+ cols = max_cols
+ rows = max(int(round((h / w) * cols * cw / ch)), 1)
+ if rows > max_rows:
+ rows = max_rows
+ cols = max(int(round((w / h) * rows * ch / cw)), 1)
+ return (max(cols, 1), max(rows, 1))
diff --git a/idatui/launch.py b/idatui/launch.py
new file mode 100644
index 0000000..1f9c51c
--- /dev/null
+++ b/idatui/launch.py
@@ -0,0 +1,181 @@
+"""One-shot launcher: ``ida-tui foo.elf`` and you're in the TUI.
+
+Spawns a private idalib worker (``idatui.worker``) that opens + auto-analyzes
+THIS binary in its own process, talking to the TUI over a unix socket. No shared
+supervisor, no HTTP: everything slow (open + analysis) happens behind the TUI's
+loading overlay.
+
+Usage:
+
+ ida-tui /path/to/binary # open a binary and drive it
+
+Extras: --ttl, --no-keepalive, --rpc (all forwarded to the TUI).
+"""
+from __future__ import annotations
+
+import argparse
+import os
+import sys
+
+# The unpacked working-copy files IDA writes next to a `.i64` while a database is
+# open. A hard-killed worker leaves them behind and the `.i64` then refuses to
+# reopen ("Failed to open database"). Safe to delete when nothing holds the DB.
+_LOCK_SUFFIXES = (".id0", ".id1", ".id2", ".nam", ".til")
+
+
+def _load_args(load: dict) -> str:
+ """``load`` as IDA switches, for the single-binary path (no project ref)."""
+ from .formats import load_args
+ return load_args(load.get("processor", ""), int(load.get("base", 0) or 0),
+ str(load.get("ida_args", "") or ""))
+
+
+def _log(msg: str) -> None:
+ print(f"ida-tui: {msg}", file=sys.stderr)
+
+
+def _sweep_locks(binary: str) -> int:
+ """Remove stale unpacked DB files next to ``binary``. Returns how many."""
+ stem = os.path.splitext(binary)[0]
+ n = 0
+ for base in (binary, stem): # IDA may key on the full name or the stem
+ for suf in _LOCK_SUFFIXES:
+ try:
+ os.remove(base + suf)
+ n += 1
+ except OSError:
+ pass
+ return n
+
+
+def main(argv: list[str] | None = None) -> int:
+ p = argparse.ArgumentParser(
+ prog="ida-tui",
+ description="Open a binary in the IDA TUI (private idalib worker).")
+ p.add_argument("binary", nargs="*",
+ help="binary to open and analyze (several with --project "
+ "creates/extends that project)")
+ p.add_argument("--project", metavar="FILE",
+ help="open a multi-binary project (created from the given "
+ "binaries if FILE doesn't exist)")
+ p.add_argument("--ttl", type=int, default=1800,
+ help="worker idle-TTL seconds (default 1800)")
+ p.add_argument("--no-keepalive", action="store_true",
+ help="do not run the keepalive heartbeat")
+ p.add_argument("--rpc", metavar="PATH",
+ help="listen for RPC on this unix socket (puppeteer the TUI)")
+ p.add_argument("--trace", metavar="FILE",
+ help="Tenet execution trace to explore alongside the binary")
+ g = p.add_argument_group(
+ "loading a headerless blob",
+ "An ELF/PE/Mach-O says what it is. A raw firmware dump doesn't, and IDA "
+ "falls back to x86 at address 0 — which analyses to nothing. These say "
+ "how to read it, and are recorded per binary in a project.")
+ g.add_argument("--processor", metavar="NAME",
+ help="IDA processor: arm, armb (big-endian), mipsb, metapc, …")
+ g.add_argument("--base", metavar="ADDR",
+ help="load address, e.g. 0x8000000 (any base; NOT paragraphs)")
+ g.add_argument("--ida-args", metavar="STR", dest="ida_args",
+ help="extra IDA command-line switches, passed through as-is")
+ args = p.parse_args(argv)
+
+ load: dict = {}
+ if args.processor:
+ load["processor"] = args.processor
+ if args.base:
+ try:
+ base = int(args.base, 0)
+ except ValueError:
+ _log(f"--base must be a number (got {args.base!r})")
+ return 2
+ if base % 16:
+ # -b is in paragraphs, so an unaligned base cannot be expressed and
+ # would silently load somewhere else.
+ _log(f"--base must be 16-byte aligned (got {base:#x})")
+ return 2
+ load["base"] = base
+ if args.ida_args:
+ load["ida_args"] = args.ida_args
+
+ project = None
+ binary = None
+ if args.project:
+ from .project import Project, ProjectError
+ ppath = os.path.abspath(os.path.expanduser(args.project))
+ try:
+ if os.path.isfile(ppath):
+ project = Project.load(ppath)
+ if args.binary: # extend an existing project, skipping repeats
+ before = len(project.refs)
+ for b in args.binary:
+ project.add(b, load=load or None)
+ added = len(project.refs) - before
+ dupes = len(args.binary) - added
+ if added:
+ project.save()
+ _log(f"added {added} binary(ies) to {ppath}")
+ if dupes:
+ _log(f"{dupes} already in the project (matched by path) "
+ f"— left alone")
+ elif args.binary:
+ project = Project.create(ppath, args.binary, load=load or None)
+ _log(f"created project {ppath} with {len(project.refs)} binaries")
+ else:
+ _log(f"no such project: {ppath} (pass binaries to create it)")
+ return 2
+ except ProjectError as e:
+ _log(str(e))
+ return 2
+ # Everything IDA writes lives in the project's sidecar, so the source
+ # tree is never touched and never needs to be writable.
+ try:
+ project.stage_all(progress=lambda m: _log(m))
+ except ProjectError as e:
+ _log(str(e))
+ return 2
+ else:
+ if len(args.binary) != 1:
+ _log("give exactly one binary, or use --project for several")
+ return 2
+ binary = os.path.abspath(os.path.expanduser(args.binary[0]))
+ if not os.path.isfile(binary):
+ _log(f"no such file: {binary}")
+ return 2
+ if not os.access(os.path.dirname(binary), os.W_OK):
+ _log(f"directory not writable (IDA writes a .i64 there): "
+ f"{os.path.dirname(binary)}")
+ return 2
+ swept = _sweep_locks(binary) # a crashed worker can leave the DB wedged
+ if swept:
+ _log(f"cleared {swept} stale lock file(s) from a crashed worker")
+
+ # Hand off to the TUI (imported late so --help works without textual). It
+ # spawns the worker behind its loading overlay while auto-analysis runs.
+ try:
+ from .app import IdaTui
+ except ImportError as e:
+ _log(f"the TUI needs textual; run with ~/ida-venv/bin/python ({e})")
+ return 1
+ # Ask the terminal about graphics support NOW: the query needs a reply from
+ # stdin, and once Textual starts it reads stdin on its own thread and would
+ # swallow it. Only the ANSWER is wanted here -- the image itself is uploaded
+ # later, by the splash, because an image uploaded to the primary screen
+ # cannot be placed once Textual has switched to the alternate one. Costs one
+ # round trip, and only when attached to a tty.
+ try:
+ from . import kittygfx
+ kittygfx.supported()
+ except Exception: # noqa: BLE001 -- graphics are decoration, never fatal
+ pass
+
+ rpc_path = os.path.abspath(os.path.expanduser(args.rpc)) if args.rpc else None
+ IdaTui(open_path=binary, keepalive=not args.no_keepalive,
+ rpc_path=rpc_path, ttl=args.ttl, project=project,
+ load_args=_load_args(load),
+ trace_path=(os.path.abspath(os.path.expanduser(args.trace))
+ if args.trace else "")).run()
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/idatui/pane.py b/idatui/pane.py
index 6ee45ca..2592581 100644
--- a/idatui/pane.py
+++ b/idatui/pane.py
@@ -1,16 +1,17 @@
-"""Spawn/stop/list idatui TUI panes in tmux, for an agent to drive over RPC.
+"""Spawn/stop/list idatui TUI panes in tmux or zellij, for an agent to drive over RPC.
-The agent (running inside a tmux pane) can open a fresh pane with the TUI
-running against a binary, wait until it's ready, drive it over the RPC socket,
-then close it — all without a human touching the keyboard.
+The agent (running inside a tmux or zellij pane) can open a fresh pane with the
+TUI running against a binary, wait until it's ready, drive it over the RPC
+socket, then close it — all without a human touching the keyboard.
+
+The multiplexer is auto-detected ($ZELLIJ -> zellij, $TMUX -> tmux) and recorded
+per pane in the registry, so stop/list/capture/keys keep working across both
+(and across a mixed set of panes). $IDATUI_MUX forces a backend.
# open a binary in a new pane, block until analysed + drivable, print JSON
python -m idatui.pane spawn --open /abs/path/to/bin
# -> {"sock": "/run/user/1000/idatui-3f2a.sock", "pane": "%7", "ready": true, ...}
- # or attach to an existing session id
- python -m idatui.pane spawn --db 80d83396
-
# drive it (see docs/RPC.md / the idatui-rpc skill)
python -m idatui.rpcclient --sock <sock> pseudocode target=main
@@ -18,9 +19,13 @@ then close it — all without a human touching the keyboard.
python -m idatui.pane list
python -m idatui.pane stop --sock <sock> # graceful quit + kill pane
-Requires: running inside tmux, and the ida-pro-mcp supervisor already up
-(./spawn.sh). Uses ~/ida-venv/bin/python for the TUI (needs textual) unless
---python / IDATUI_PYTHON says otherwise.
+ # mux-agnostic screen scrape / key injection (for debugging the input layer)
+ python -m idatui.pane capture --pane <pane>
+ python -m idatui.pane keys --pane <pane> Escape
+
+Requires: running inside tmux or zellij. Each pane spawns its own private idalib
+worker (no shared supervisor). Uses ~/ida-venv/bin/python for the TUI (needs
+textual) unless --python / IDATUI_PYTHON says otherwise.
"""
from __future__ import annotations
@@ -28,14 +33,12 @@ import argparse
import json
import os
import secrets
-import socket
+import signal
import subprocess
import sys
import time
from typing import Any
-from urllib.parse import urlparse
-from .client import DEFAULT_URL
from .rpcclient import RpcClient, RpcError
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -66,10 +69,47 @@ def _save_registry(rows: list[dict[str, Any]]) -> None:
os.replace(tmp, _registry_path())
-def _pane_alive(pane: str) -> bool:
- out = subprocess.run(["tmux", "list-panes", "-a", "-F", "#{pane_id}"],
- capture_output=True, text=True)
- return pane in out.stdout.split()
+# --------------------------------------------------------------------------- #
+# terminal multiplexer backends
+#
+# Everything that touches panes goes through here, so the rest of the module (and
+# every caller) is mux-agnostic. tmux pane ids look like ``%7``; zellij ids look
+# like ``terminal_3``, which is what ``zellij action new-pane`` prints, so a pane
+# id alone is enough to route a later stop/capture even if the registry predates
+# the ``mux`` field.
+# --------------------------------------------------------------------------- #
+MUXES = ("tmux", "zellij")
+
+
+def _detect_mux() -> str:
+ """Which multiplexer we're running under: 'tmux', 'zellij', or '' if neither."""
+ forced = os.environ.get("IDATUI_MUX", "").strip().lower()
+ if forced:
+ return forced if forced in MUXES else "?" + forced
+ # Check zellij first: a zellij session started from inside tmux inherits
+ # $TMUX, and the pane we can actually create there is the zellij one.
+ if os.environ.get("ZELLIJ"):
+ return "zellij"
+ if os.environ.get("TMUX"):
+ return "tmux"
+ return ""
+
+
+def _mux_of_pane(pane: str) -> str:
+ """Infer the backend from a pane id ('%7' = tmux, 'terminal_3' = zellij)."""
+ if pane.startswith(("terminal_", "plugin_")):
+ return "zellij"
+ if pane.startswith("%"):
+ return "tmux"
+ return _detect_mux() or "tmux"
+
+
+def _zellij_argv() -> list[str]:
+ """Base zellij argv, pinned to our session when we know it (so it still works
+ from a process that isn't itself attached)."""
+ session = (os.environ.get("IDATUI_ZELLIJ_SESSION")
+ or os.environ.get("ZELLIJ_SESSION_NAME"))
+ return ["zellij", "-s", session] if session else ["zellij"]
def _tmux(*args: str) -> str:
@@ -77,121 +117,268 @@ def _tmux(*args: str) -> str:
check=True).stdout.strip()
-# --------------------------------------------------------------------------- #
-# supervisor (ida-pro-mcp server) — auto-start if down
-# --------------------------------------------------------------------------- #
-def _server_addr(url: str) -> tuple[str, int]:
- u = urlparse(url)
- return (u.hostname or "127.0.0.1", u.port or 8745)
+def _zellij(*args: str) -> str:
+ return subprocess.run([*_zellij_argv(), *args], capture_output=True,
+ text=True, check=True).stdout.strip()
-def _server_up(host: str, port: int, timeout: float = 0.75) -> bool:
- """Is something listening on host:port? (Cheap TCP probe; the readiness poll
- that follows catches a half-up server.)"""
+def _zellij_panes() -> list[dict[str, Any]]:
try:
- with socket.create_connection((host, port), timeout=timeout):
- return True
- except OSError:
- return False
+ out = subprocess.run([*_zellij_argv(), "action", "list-panes",
+ "--state", "--json"],
+ capture_output=True, text=True)
+ rows = json.loads(out.stdout or "[]")
+ except (OSError, ValueError):
+ return []
+ return rows if isinstance(rows, list) else []
-def _ensure_server(host: str, port: int, timeout: float,
- detached: bool = True) -> dict[str, Any]:
- """Make sure the supervisor is up; start ./spawn.sh in a tmux pane if not.
+def _pane_alive(pane: str, mux: str | None = None) -> bool:
+ """True if the pane exists *and* its command is still running.
- Only auto-starts a *local* server (can't launch a remote one). spawn.sh binds
- the port, so starting a duplicate is impossible — the probe guards that.
- ``IDATUI_SERVER_CMD`` overrides the launch command (used by the tests).
+ zellij keeps an exited pane on screen (EXITED, holding its output) rather
+ than removing it like tmux does; that husk must count as dead or ``stop``
+ would wait out its whole timeout and ``_wait_ready`` would never notice a
+ launcher that died on startup.
"""
- if _server_up(host, port):
- return {"server_started": False, "server_up": True}
- if host not in ("127.0.0.1", "localhost", "::1"):
- return {"server_started": False, "server_up": False,
- "error": f"server at {host}:{port} is down and not local; "
- "cannot auto-start"}
- cmd_str = os.environ.get("IDATUI_SERVER_CMD", "./spawn.sh")
- cmd = f"cd {_q(REPO)} && exec {cmd_str}"
- split = ["split-window", "-v", "-P", "-F", "#{pane_id}"]
+ if not pane:
+ return False
+ if (mux or _mux_of_pane(pane)) == "zellij":
+ want = pane.split("_", 1)[-1]
+ for row in _zellij_panes():
+ if str(row.get("id")) == want and bool(row.get("is_plugin")) is False:
+ return not row.get("exited", False)
+ return False
+ out = subprocess.run(["tmux", "list-panes", "-a", "-F", "#{pane_id}"],
+ capture_output=True, text=True)
+ return pane in out.stdout.split()
+
+
+def _pane_exists(pane: str, mux: str | None = None) -> bool:
+ """True if the pane is still on screen at all (including a zellij exit husk)."""
+ if not pane:
+ return False
+ if (mux or _mux_of_pane(pane)) == "zellij":
+ want = pane.split("_", 1)[-1]
+ return any(str(r.get("id")) == want and not r.get("is_plugin")
+ for r in _zellij_panes())
+ return _pane_alive(pane, "tmux")
+
+
+def _pane_kill(pane: str, mux: str | None = None) -> None:
+ """Remove the pane. Idempotent, and also clears a zellij exit husk."""
+ if not pane:
+ return
+ if (mux or _mux_of_pane(pane)) == "zellij":
+ subprocess.run([*_zellij_argv(), "action", "close-pane",
+ "--pane-id", pane], capture_output=True)
+ else:
+ subprocess.run(["tmux", "kill-pane", "-t", pane], capture_output=True)
+
+
+def _pane_split(inner: list[str], *, mux: str, vertical: bool,
+ size: str | None, detached: bool) -> str:
+ """Open a pane running ``inner`` (argv) in REPO, and return its pane id."""
+ if mux == "zellij":
+ # zellij runs the argv directly (no shell) and takes the cwd as a flag,
+ # so there's nothing to quote. --name labels the pane in the UI.
+ argv = [*_zellij_argv(), "action", "new-pane",
+ "--direction", "down" if vertical else "right",
+ "--cwd", REPO, "--name", "idatui"]
+ argv += ["--", *inner]
+ pane = subprocess.run(argv, capture_output=True, text=True,
+ check=True).stdout.strip()
+ # zellij prints the new pane id ('terminal_3'); without it we could not
+ # target this pane later, so treat a missing id as a hard failure.
+ if not pane.startswith(("terminal_", "plugin_")):
+ raise RuntimeError(f"zellij new-pane did not return a pane id: {pane!r}")
+ if detached:
+ # zellij always focuses the pane it creates and has no -d; hop back
+ # to the pane we were called from.
+ origin = os.environ.get("ZELLIJ_PANE_ID")
+ if origin:
+ subprocess.run([*_zellij_argv(), "action", "focus-pane-id",
+ f"terminal_{origin}"], capture_output=True)
+ return pane
+
+ cmd = f"cd {REPO!r} && exec " + " ".join(_q(a) for a in inner)
+ split = ["split-window", "-v" if vertical else "-h",
+ "-P", "-F", "#{pane_id}"]
+ if size:
+ split += ["-l", str(size)]
if detached:
split += ["-d"]
anchor = os.environ.get("TMUX_PANE")
if anchor:
split += ["-t", anchor]
split.append(cmd)
- pane = _tmux(*split)
- deadline = time.time() + timeout
- while time.time() < deadline:
- if not _pane_alive(pane):
- return {"server_started": True, "server_up": False, "server_pane": pane,
- "error": "supervisor pane exited during startup (check it)"}
- if _server_up(host, port):
- return {"server_started": True, "server_up": True, "server_pane": pane}
- time.sleep(0.5)
- return {"server_started": True, "server_up": False, "server_pane": pane,
- "error": "supervisor did not come up in time"}
+ return _tmux(*split)
+
+
+def _pane_capture(pane: str, mux: str | None = None) -> str:
+ """The pane's visible screen as text."""
+ mux = mux or _mux_of_pane(pane)
+ if mux == "zellij":
+ return _zellij("action", "dump-screen", "--pane-id", pane)
+ return _tmux("capture-pane", "-p", "-t", pane)
+
+
+# tmux key names -> zellij key names (zellij rejects e.g. "Escape", wants "Esc").
+_ZELLIJ_KEYS = {
+ "escape": "Esc", "bspace": "Backspace", "space": "Space",
+ "pageup": "PageUp", "pagedown": "PageDown", "ppage": "PageUp",
+ "npage": "PageDown", "ic": "Insert", "dc": "Delete",
+}
+
+
+def _to_zellij_key(key: str) -> str:
+ """Accept tmux-flavoured key names so callers can stay mux-agnostic."""
+ low = key.lower()
+ if low in _ZELLIJ_KEYS:
+ return _ZELLIJ_KEYS[low]
+ if len(key) > 2 and key[1] == "-" and key[0] in "CM": # C-a / M-x
+ return ("Ctrl " if key[0] == "C" else "Alt ") + key[2:]
+ return key
+
+
+def _pane_keys(pane: str, keys: list[str], mux: str | None = None) -> None:
+ """Inject real terminal keystrokes into the pane (the input-layer cross-check)."""
+ mux = mux or _mux_of_pane(pane)
+ if mux == "zellij":
+ subprocess.run([*_zellij_argv(), "action", "send-keys", "--pane-id", pane,
+ *[_to_zellij_key(k) for k in keys]], check=True)
+ else:
+ subprocess.run(["tmux", "send-keys", "-t", pane, *keys], check=True)
+
+
+# --------------------------------------------------------------------------- #
+# idalib worker reaping
+#
+# ``pane stop`` kills the TUI pane, but a hard-killed pane can leave its private
+# idalib worker (idatui/worker.py) running. A worker is only *safe* to reap when
+# no idatui pane is live (then every worker is orphaned), which avoids killing an
+# in-use analyser.
+# --------------------------------------------------------------------------- #
+_WORKER_PATTERN = r"idatui/worker\.py"
+
+
+def _worker_pids() -> list[int]:
+ """PIDs of our private per-pane idalib worker processes (idatui/worker.py),
+ never our own PID."""
+ try:
+ out = subprocess.run(["pgrep", "-f", _WORKER_PATTERN],
+ capture_output=True, text=True)
+ except OSError:
+ return []
+ me = os.getpid()
+ pids: list[int] = []
+ for tok in out.stdout.split():
+ try:
+ pid = int(tok)
+ except ValueError:
+ continue
+ if pid != me:
+ pids.append(pid)
+ return pids
+
+
+def _count_live_panes() -> int:
+ return sum(1 for r in _load_registry()
+ if _pane_alive(r.get("pane", ""), r.get("mux")))
+
+
+def _reap_orphan_workers(force: bool = False) -> int:
+ """Kill leaked idalib workers when it is safe (no live pane) or ``force``.
+
+ Returns the number of workers signalled. Best-effort; never raises.
+ """
+ if not force and _count_live_panes() > 0:
+ return 0
+ reaped = 0
+ for pid in _worker_pids():
+ try:
+ os.kill(pid, signal.SIGKILL)
+ reaped += 1
+ except OSError:
+ pass
+ return reaped
# --------------------------------------------------------------------------- #
# spawn
# --------------------------------------------------------------------------- #
def spawn(args) -> int:
- if not os.environ.get("TMUX"):
- print("error: not inside tmux (spawn creates a tmux pane)", file=sys.stderr)
+ mux = args.mux or _detect_mux()
+ if mux.startswith("?"):
+ print(f"error: unknown multiplexer {mux[1:]!r} (want tmux or zellij)",
+ file=sys.stderr)
+ return 2
+ if not mux:
+ print("error: not inside tmux or zellij (spawn creates a pane there). "
+ "Set $IDATUI_MUX=tmux|zellij to force a backend.", file=sys.stderr)
return 2
- if not args.open and not args.db:
- print("error: pass --open <binary> or --db <session>", file=sys.stderr)
+ if not args.open and not getattr(args, "project", None):
+ print("error: pass --open <binary> or --project <file>", file=sys.stderr)
return 2
sock = args.sock or os.path.join(_sockdir(), f"idatui-{secrets.token_hex(3)}.sock")
- target = os.path.abspath(os.path.expanduser(args.open)) if args.open else args.db
- if args.open and not os.path.exists(target):
+ project = (os.path.abspath(os.path.expanduser(args.project))
+ if getattr(args, "project", None) else None)
+ target = os.path.abspath(os.path.expanduser(args.open)) if args.open else None
+ if target is not None and not os.path.exists(target):
print(f"error: no such binary: {target}", file=sys.stderr)
return 2
+ if project is not None and target is None and not os.path.exists(project):
+ print(f"error: no such project: {project}", file=sys.stderr)
+ return 2
- # make sure the ida-pro-mcp supervisor is up (auto-start it if not)
- srv: dict[str, Any] = {"server_started": False, "server_up": True}
- if not args.no_ensure_server:
- host, port = _server_addr(args.url or DEFAULT_URL)
- srv = _ensure_server(host, port, args.server_timeout)
- if srv.get("server_started"):
- print(f"supervisor was down — started it ({srv.get('server_pane')})",
- file=sys.stderr)
- if not srv.get("server_up"):
- print(json.dumps({"ready": False, **srv}), file=sys.stderr)
- return 3
+ # Reap workers leaked by previously-stopped/crashed panes so we don't spawn
+ # into a full IDA_MCP_MAX_WORKERS (which makes the new TUI hang forever,
+ # never reaching ready). No-op while any pane is live.
+ reaped = _reap_orphan_workers()
+ if reaped:
+ print(f"reaped {reaped} orphaned idalib worker(s) before spawn",
+ file=sys.stderr)
- # the command the pane runs: become the TUI so kill-pane kills it cleanly
- inner = [args.python, "-m", "idatui.tui", "--rpc", sock]
- if args.open:
- inner += ["--open", target]
+ # the command the pane runs: the launcher spawns a private idalib worker for
+ # this binary and becomes the TUI, so kill-pane tears the whole thing down.
+ if project is not None:
+ # launch takes: --project FILE [binaries...]; extra binaries are added to
+ # the project (and a missing project file is created from them).
+ inner = [args.python, "-m", "idatui.launch", "--project", project]
+ if target is not None:
+ inner.append(target)
+ inner += ["--rpc", sock]
else:
- inner += ["--db", target]
- if args.url:
- inner += ["--url", args.url]
- cmd = f"cd {REPO!r} && exec " + " ".join(_q(a) for a in inner)
+ inner = [args.python, "-m", "idatui.launch", target, "--rpc", sock]
+ # Loading a headerless blob: without these IDA reads a raw firmware image as
+ # x86 at 0 and analyses to nothing, and the pane comes up ready-but-empty.
+ # They are launch's options; spawn just forwards them (a project records
+ # them per binary, so they're only needed on the first open).
+ for opt in ("processor", "base", "ida_args"):
+ val = getattr(args, opt, None)
+ if val:
+ inner += ["--" + opt.replace("_", "-"), str(val)]
+ if getattr(args, "trace", None):
+ inner += ["--trace", os.path.abspath(os.path.expanduser(args.trace))]
- split = ["split-window", "-v" if args.vertical else "-h",
- "-P", "-F", "#{pane_id}"]
- if args.size:
- split += ["-l", str(args.size)]
- if args.detached:
- split += ["-d"]
- anchor = os.environ.get("TMUX_PANE")
- if anchor:
- split += ["-t", anchor]
- split.append(cmd)
- pane = _tmux(*split)
+ if args.size and mux == "zellij":
+ print("note: --size is tmux-only; zellij tiles the new pane evenly",
+ file=sys.stderr)
+ try:
+ pane = _pane_split(inner, mux=mux, vertical=args.vertical,
+ size=args.size, detached=args.detached)
+ except (OSError, subprocess.CalledProcessError, RuntimeError) as e:
+ print(f"error: could not create a {mux} pane: {e}", file=sys.stderr)
+ return 2
- row = {"sock": sock, "pane": pane, "target": target,
- "kind": "open" if args.open else "db", "started": time.time(),
- "server_started": srv.get("server_started", False)}
- if srv.get("server_pane"):
- row["server_pane"] = srv["server_pane"]
+ row = {"sock": sock, "pane": pane, "mux": mux, "target": project or target,
+ "kind": "project" if project else "open", "started": time.time()}
reg = [r for r in _load_registry() if r.get("sock") != sock]
reg.append(row)
_save_registry(reg)
- ready = _wait_ready(sock, args.timeout, pane)
+ ready = _wait_ready(sock, args.timeout, pane, mux=mux)
row.update(ready)
print(json.dumps(row))
return 0 if ready.get("ready") else 1
@@ -202,12 +389,20 @@ def _q(s: str) -> str:
return shlex.quote(s)
-def _wait_ready(sock: str, timeout: float, pane: str) -> dict[str, Any]:
- """Poll the socket + ping until the TUI reports ready (or timeout)."""
- deadline = time.time() + timeout
+def _wait_ready(sock: str, timeout: float, pane: str,
+ stuck_after: float = 45.0, mux: str | None = None) -> dict[str, Any]:
+ """Poll the socket + ping until the TUI reports ready (or timeout).
+
+ Emits a one-time hint to stderr if it's still not ready after ``stuck_after``
+ seconds, so a wedged idalib worker / full worker pool surfaces a diagnostic
+ instead of an unexplained silent hang.
+ """
+ start = time.time()
+ deadline = start + timeout
+ warned = False
last: dict[str, Any] = {"ready": False}
while time.time() < deadline:
- if not _pane_alive(pane):
+ if not _pane_alive(pane, mux):
return {"ready": False, "error": "pane exited during startup"}
if os.path.exists(sock):
try:
@@ -217,6 +412,13 @@ def _wait_ready(sock: str, timeout: float, pane: str) -> dict[str, Any]:
return last
except (OSError, RpcError, ConnectionError):
pass
+ if not warned and (time.time() - start) > stuck_after:
+ warned = True
+ why = ("RPC socket not created yet" if not os.path.exists(sock)
+ else "TUI up but analysis not ready")
+ print(f"still waiting ({int(time.time() - start)}s): {why}. If this "
+ f"hangs, the idalib worker may be stuck — try "
+ f"`python -m idatui.pane reap`.", file=sys.stderr)
time.sleep(0.4)
last = dict(last)
last["ready"] = False
@@ -237,25 +439,50 @@ def stop(args) -> int:
if not rows:
print("error: no matching pane (need --sock or --pane)", file=sys.stderr)
return 2
+ killed: list[str] = []
for r in rows:
- sock, pane = r.get("sock"), r.get("pane")
+ sock, pane, mux = r.get("sock"), r.get("pane"), r.get("mux")
+ quit_ok = False
if sock and os.path.exists(sock):
try: # ask it to quit gracefully first
with RpcClient(sock) as c:
c.call("quit")
- time.sleep(0.4)
+ quit_ok = True
except (OSError, RpcError, ConnectionError):
pass
- if pane and _pane_alive(pane):
- subprocess.run(["tmux", "kill-pane", "-t", pane],
- capture_output=True)
+ # Wait for the pane to actually go away. Quitting runs App.on_unmount,
+ # which writes every dirty database; a 90 MB .i64 takes tens of seconds.
+ # Killing the pane on a fixed short sleep truncated that save and
+ # silently destroyed the session's work, so block on the real signal.
+ if pane and quit_ok:
+ deadline = time.monotonic() + float(args.timeout)
+ while time.monotonic() < deadline and _pane_alive(pane, mux):
+ time.sleep(0.25)
+ if pane:
+ # Still running past the timeout = force kill (and warn). Otherwise
+ # it exited cleanly, but under zellij the pane lingers as an exit
+ # husk, so close it either way to leave the layout as we found it.
+ if _pane_alive(pane, mux):
+ killed.append(pane)
+ _pane_kill(pane, mux)
if sock:
try:
os.unlink(sock)
except OSError:
pass
_save_registry([r for r in reg if r not in rows])
- print(json.dumps({"stopped": [r.get("sock") or r.get("pane") for r in rows]}))
+ # Reap the workers those panes leaked (safe: only fires once no pane is live).
+ reaped = _reap_orphan_workers()
+ out = {"stopped": [r.get("sock") or r.get("pane") for r in rows]}
+ if reaped:
+ out["reaped_workers"] = reaped
+ if killed:
+ # Only ever reached on timeout: say so, because it means a save may have
+ # been cut short rather than "clean teardown".
+ out["force_killed"] = killed
+ out["warning"] = (f"pane(s) did not exit within {args.timeout}s and were "
+ "killed; unsaved database changes may be lost")
+ print(json.dumps(out))
return 0
@@ -264,7 +491,8 @@ def list_panes(args) -> int:
alive = []
for r in reg:
r = dict(r)
- r["pane_alive"] = _pane_alive(r.get("pane", ""))
+ r.setdefault("mux", _mux_of_pane(r.get("pane", "")))
+ r["pane_alive"] = _pane_alive(r.get("pane", ""), r.get("mux"))
r["sock_up"] = bool(r.get("sock") and os.path.exists(r["sock"]))
if args.prune and not r["pane_alive"]:
if r.get("sock") and os.path.exists(r["sock"]):
@@ -272,32 +500,114 @@ def list_panes(args) -> int:
os.unlink(r["sock"])
except OSError:
pass
+ # a zellij pane whose command exited is still on screen; drop it
+ if r.get("pane") and _pane_exists(r["pane"], r.get("mux")):
+ _pane_kill(r["pane"], r.get("mux"))
continue
alive.append(r)
if args.prune:
_save_registry(alive)
+ reaped = _reap_orphan_workers()
+ if reaped:
+ print(f"reaped {reaped} orphaned idalib worker(s)", file=sys.stderr)
print(json.dumps(alive, indent=2))
return 0
+def reap(args) -> int:
+ """Kill leaked idalib workers (safe when no pane is live; --force overrides)."""
+ live = _count_live_panes()
+ n = _reap_orphan_workers(force=args.force)
+ print(json.dumps({"reaped_workers": n, "live_panes": live, "forced": args.force}))
+ if n == 0 and not args.force and live > 0:
+ print(f"note: {live} live pane(s) — not reaping in-use workers; pass "
+ f"--force to reap anyway", file=sys.stderr)
+ return 0
+
+
+def capture(args) -> int:
+ """Print a pane's visible screen (tmux capture-pane / zellij dump-screen)."""
+ pane = args.pane or _resolve_pane(args.sock)
+ if not pane:
+ return 2
+ try:
+ print(_pane_capture(pane, args.mux or None))
+ except (OSError, subprocess.CalledProcessError) as e:
+ print(f"error: could not capture {pane}: {e}", file=sys.stderr)
+ return 1
+ return 0
+
+
+def send_keys(args) -> int:
+ """Inject real terminal keystrokes (tmux send-keys / zellij send-keys).
+
+ Key names are tmux-flavoured and translated per backend, so `keys --pane P
+ Escape` does the right thing under either mux.
+ """
+ pane = args.pane or _resolve_pane(args.sock)
+ if not pane:
+ return 2
+ try:
+ _pane_keys(pane, args.keys, args.mux or None)
+ except (OSError, subprocess.CalledProcessError) as e:
+ print(f"error: could not send keys to {pane}: {e}", file=sys.stderr)
+ return 1
+ return 0
+
+
+def _resolve_pane(sock: str | None) -> str | None:
+ """Pane id for a socket, or the single live pane if there's exactly one."""
+ reg = _load_registry()
+ if sock:
+ for r in reg:
+ if r.get("sock") == sock:
+ return r.get("pane")
+ print(f"error: no tracked pane for {sock}", file=sys.stderr)
+ return None
+ live = [r for r in reg if _pane_alive(r.get("pane", ""), r.get("mux"))]
+ if len(live) == 1:
+ return live[0].get("pane")
+ if not live:
+ print("error: no live panes (pass --pane)", file=sys.stderr)
+ else:
+ print("error: several live panes, pass --pane or --sock:", file=sys.stderr)
+ for r in live:
+ print(f" {r.get('pane')} {r.get('sock')} {r.get('target')}",
+ file=sys.stderr)
+ return None
+
+
def main(argv: list[str]) -> int:
- p = argparse.ArgumentParser(prog="idatui.pane",
- description="spawn/manage idatui TUI panes in tmux")
+ p = argparse.ArgumentParser(
+ prog="idatui.pane",
+ description="spawn/manage idatui TUI panes in tmux or zellij")
sub = p.add_subparsers(dest="cmd", required=True)
sp = sub.add_parser("spawn", help="open a TUI pane and wait until ready")
- sp.add_argument("--open", metavar="PATH", help="binary to open (dir must be writable)")
- sp.add_argument("--db", metavar="SESSION", help="attach to an existing session id")
+ sp.add_argument("--open", metavar="PATH",
+ help="binary to open (its dir must be writable)")
+ sp.add_argument("--trace", metavar="FILE",
+ help="Tenet execution trace to load alongside the binary")
+ sp.add_argument("--project", metavar="FILE",
+ help="project file to open instead of a single binary; "
+ "any --open paths are added to it (created if absent)")
+ sp.add_argument("--processor", metavar="NAME",
+ help="IDA processor for a headerless blob: arm, armb, "
+ "mipsb, metapc, … (passed to idatui.launch)")
+ sp.add_argument("--base", metavar="ADDR",
+ help="load address for a headerless blob, e.g. 0x8000000 "
+ "(16-byte aligned)")
+ sp.add_argument("--ida-args", metavar="STR", dest="ida_args",
+ help="extra IDA command-line switches, passed through")
sp.add_argument("--sock", help="RPC socket path (default: auto in $XDG_RUNTIME_DIR)")
sp.add_argument("--python", default=DEFAULT_PY, help=f"python for the TUI ({DEFAULT_PY})")
- sp.add_argument("--url", help="MCP server URL (default: idatui's default)")
- sp.add_argument("--no-ensure-server", action="store_true",
- help="don't auto-start ./spawn.sh if the supervisor is down")
- sp.add_argument("--server-timeout", type=float, default=90.0,
- help="seconds to wait for an auto-started supervisor")
sp.add_argument("--vertical", action="store_true", help="split vertically (stacked)")
- sp.add_argument("--size", help="new pane size (tmux -l value, e.g. 60%% or 120)")
+ sp.add_argument("--size", help="new pane size (tmux -l value, e.g. 60%% or 120; "
+ "ignored under zellij)")
sp.add_argument("--detached", action="store_true", help="don't focus the new pane")
+ sp.add_argument("--mux", choices=MUXES, default="",
+ help="multiplexer to spawn in (default: autodetect from "
+ "$ZELLIJ/$TMUX; $IDATUI_MUX overrides)")
sp.add_argument("--timeout", type=float, default=300.0,
help="seconds to wait for readiness (fresh --open analysis is slow)")
sp.set_defaults(fn=spawn)
@@ -305,12 +615,34 @@ def main(argv: list[str]) -> int:
st = sub.add_parser("stop", help="graceful quit + kill the pane")
st.add_argument("--sock")
st.add_argument("--pane")
+ st.add_argument("--timeout", type=float, default=600.0,
+ help="seconds to wait for the pane to exit (it saves dirty "
+ "databases on the way out) before force-killing it")
st.set_defaults(fn=stop)
ls = sub.add_parser("list", help="list tracked panes")
ls.add_argument("--prune", action="store_true", help="drop dead panes (and their sockets)")
ls.set_defaults(fn=list_panes)
+ rp = sub.add_parser("reap", help="kill leaked idalib workers (frees worker slots)")
+ rp.add_argument("--force", action="store_true",
+ help="reap even while panes are live (may kill an in-use analyser)")
+ rp.set_defaults(fn=reap)
+
+ cp = sub.add_parser("capture", help="print a pane's visible screen")
+ cp.add_argument("--pane")
+ cp.add_argument("--sock", help="resolve the pane from this socket")
+ cp.add_argument("--mux", choices=MUXES, default="")
+ cp.set_defaults(fn=capture)
+
+ kp = sub.add_parser("keys", help="inject real keystrokes into a pane "
+ "(tmux-style names, translated per mux)")
+ kp.add_argument("keys", nargs="+", help="e.g. Escape, Enter, C-a, g m a i n")
+ kp.add_argument("--pane")
+ kp.add_argument("--sock", help="resolve the pane from this socket")
+ kp.add_argument("--mux", choices=MUXES, default="")
+ kp.set_defaults(fn=send_keys)
+
args = p.parse_args(argv)
return args.fn(args)
diff --git a/idatui/pool.py b/idatui/pool.py
new file mode 100644
index 0000000..ae37c25
--- /dev/null
+++ b/idatui/pool.py
@@ -0,0 +1,235 @@
+"""WorkerPool — keeps a live idalib worker per project binary, within a budget.
+
+One worker process holds exactly one database (idalib is single-DB and
+main-thread-only), so a project with N binaries means up to N processes. They are
+not cheap and they do not share: a worker on ``bash`` measures ~126 MB RSS /
+117 MB PSS, and the database working set dominates for anything larger
+(``libcrypto.so.3``'s ``.i64`` alone is 72 MB).
+
+Residency is therefore bounded by a **memory budget**, not a worker count — a
+count is the wrong knob when one project holds both a 50 KB helper and a 6 MB
+crypto library. Workers are spawned lazily on first use, kept resident while they
+fit, and least-recently-used ones evicted when they don't. Eviction **saves the
+database first**, so coming back is a load rather than a re-analysis.
+
+The pool never evicts the active binary, nor anything pinned.
+"""
+from __future__ import annotations
+
+import os
+
+from .project import BinaryRef, Project
+
+#: Fallback budget if /proc/meminfo can't be read (MB).
+_FALLBACK_BUDGET_MB = 2048
+
+
+def _total_ram_mb() -> int:
+ try:
+ with open("/proc/meminfo") as f:
+ for line in f:
+ if line.startswith("MemTotal:"):
+ return int(line.split()[1]) // 1024
+ except (OSError, ValueError, IndexError):
+ pass
+ return 0
+
+
+def _pss_mb(pid: int | None) -> int:
+ """Proportional set size of a worker, in MB.
+
+ PSS (not RSS) is the honest per-worker cost: it splits shared pages between
+ the processes mapping them. In practice workers share very little, so the two
+ are close, but PSS is what makes summing across workers meaningful.
+ """
+ if not pid:
+ return 0
+ try:
+ with open(f"/proc/{pid}/smaps_rollup") as f:
+ for line in f:
+ if line.startswith("Pss:"):
+ return int(line.split()[1]) // 1024
+ except (OSError, ValueError, IndexError):
+ pass
+ return 0
+
+
+def _default_spawn(ref: BinaryRef, ttl: int): # pragma: no cover - needs idalib
+ from .worker_client import WorkerClient
+ return WorkerClient(ref.staged, ttl=ttl, load_args=ref.load_args)
+
+
+class WorkerPool:
+ """Live workers for a project's binaries, keyed by label."""
+
+ def __init__(self, project: Project, *, budget_mb: int | None = None,
+ ttl: int = 1800, spawn=None, mem_fn=None) -> None:
+ self.project = project
+ self._ttl = ttl
+ self._spawn = spawn or _default_spawn
+ self._mem = mem_fn or (lambda c: _pss_mb(getattr(c, "pid", None)))
+ self._clients: dict[str, object] = {}
+ self._lru: list[str] = [] # least-recently-used first
+ self._pinned: set[str] = set()
+ self.active: str | None = None # never evicted
+ if budget_mb is None:
+ ram = _total_ram_mb()
+ budget_mb = (ram * project.memory_pct // 100) if ram else _FALLBACK_BUDGET_MB
+ self.budget_mb = max(budget_mb, 256)
+ self.evicted: list[str] = [] # labels evicted, most recent last
+
+ # -- residency --------------------------------------------------------- #
+ def resident(self) -> list[str]:
+ """Labels with a live worker, least-recently-used first."""
+ return list(self._lru)
+
+ def is_resident(self, label: str) -> bool:
+ return label in self._clients
+
+ def memory_mb(self) -> int:
+ return sum(self._mem(c) for c in self._clients.values())
+
+ def pin(self, label: str, on: bool = True) -> None:
+ """Keep ``label`` resident regardless of the budget."""
+ self._pinned.add(label) if on else self._pinned.discard(label)
+
+ def is_pinned(self, label: str) -> bool:
+ return label in self._pinned
+
+ # -- acquire ----------------------------------------------------------- #
+ def get(self, label: str, progress=None):
+ """A live client for ``label``, spawning it (and making room) if needed.
+
+ Staging and the scratch sweep happen here: a worker killed hard last time
+ leaves unpacked ``.id0/.id1/...`` behind, and the database then refuses to
+ reopen. Nothing else holds this DB (one worker per label), so it is safe.
+ """
+ client = self._clients.get(label)
+ if client is not None:
+ self._touch(label)
+ return client
+ ref = self.project.by_label(label)
+ if ref is None:
+ raise KeyError(f"no such binary in the project: {label}")
+
+ def note(msg: str) -> None:
+ if progress is not None:
+ progress(msg)
+
+ note(f"staging {ref.label}\u2026")
+ self.project.stage(ref)
+ self.project.sweep_scratch(ref)
+ note(f"opening {ref.label}\u2026")
+ client = self._spawn(ref, self._ttl)
+ connect = getattr(client, "connect", None)
+ if connect is not None:
+ connect(progress=progress) if progress is not None else connect()
+ self._clients[label] = client
+ self._lru.append(label)
+ self._enforce_budget(protect=label)
+ return client
+
+ def prewarm(self, label: str, progress=None) -> bool:
+ """Spawn a worker for ``label`` only if it fits the budget AS IT STANDS.
+
+ Pre-warming must never cost residency: evicting a binary the user
+ actually visited to speculatively load one they haven't is a straight
+ downgrade, and the eviction would also throw away that binary's caches.
+ So this refuses rather than making room, and returns False.
+
+ The cost of a worker that doesn't exist yet can only be estimated; the
+ largest resident one is the best evidence available (they are all the
+ same program with a different database). With nothing resident we have
+ no evidence at all, so we allow one — that is the case where the budget
+ is certainly free.
+ """
+ if label in self._clients:
+ return False
+ if self.project.by_label(label) is None:
+ return False
+ used = self.memory_mb()
+ est = max((self._mem(c) for c in self._clients.values()), default=0)
+ if used + est > self.budget_mb:
+ return False
+ self.get(label, progress=progress)
+ # get() enforces the budget protecting the NEW label; if that had to
+ # evict, our estimate was wrong and the speculative worker is the one
+ # that should go — never a binary the user chose.
+ if self.memory_mb() > self.budget_mb and label != self.active:
+ self.evict(label)
+ return False
+ return True
+
+ def _touch(self, label: str) -> None:
+ if label in self._lru:
+ self._lru.remove(label)
+ self._lru.append(label)
+
+ def set_active(self, label: str | None) -> None:
+ self.active = label
+ if label:
+ self._touch(label)
+
+ # -- release ----------------------------------------------------------- #
+ def evict(self, label: str, save: bool = True) -> bool:
+ """Drop a resident worker, persisting its database first."""
+ client = self._clients.pop(label, None)
+ if client is None:
+ return False
+ if label in self._lru:
+ self._lru.remove(label)
+ if save:
+ try: # persist analysis + edits so the next open is a load
+ client.call("idb_save")
+ except Exception: # noqa: BLE001 -- evict regardless
+ pass
+ try:
+ client.close()
+ except Exception: # noqa: BLE001
+ pass
+ self.evicted.append(label)
+ return True
+
+ def _evictable(self, protect: str | None) -> str | None:
+ for label in self._lru: # least-recently-used first
+ if label == protect or label == self.active or label in self._pinned:
+ continue
+ return label
+ return None
+
+ def _enforce_budget(self, protect: str | None = None) -> int:
+ """Evict LRU workers until the pool fits its budget. Returns how many."""
+ n = 0
+ while self.memory_mb() > self.budget_mb:
+ victim = self._evictable(protect)
+ if victim is None: # everything left is active/pinned/protected
+ break
+ self.evict(victim)
+ n += 1
+ return n
+
+ def close_all(self, save: bool = True) -> None:
+ for label in list(self._clients):
+ self.evict(label, save=save)
+ self.active = None
+
+ # -- introspection ------------------------------------------------------ #
+ def status(self) -> list[dict]:
+ """Per-binary residency for the switcher UI."""
+ out = []
+ for ref in self.project.refs:
+ client = self._clients.get(ref.label)
+ out.append({
+ "label": ref.label,
+ "source": ref.source,
+ "resident": client is not None,
+ "pinned": ref.label in self._pinned,
+ "active": ref.label == self.active,
+ "analysed": self.project.has_db(ref),
+ "memory_mb": self._mem(client) if client is not None else 0,
+ })
+ return out
+
+ def __repr__(self) -> str: # pragma: no cover - debug aid
+ return (f"<WorkerPool {len(self._clients)}/{len(self.project.refs)} resident "
+ f"{self.memory_mb()}/{self.budget_mb}MB active={self.active}>")
diff --git a/idatui/project.py b/idatui/project.py
new file mode 100644
index 0000000..e2fc542
--- /dev/null
+++ b/idatui/project.py
@@ -0,0 +1,353 @@
+"""Multi-binary projects: group the binaries of one target under a project file.
+
+A project is an explicit JSON file plus a sidecar directory beside it::
+
+ router-fw.json # the project file
+ router-fw.idatui.d/
+ bin/httpd # hardlink (or copy) of the source binary
+ bin/httpd.i64 # IDA's DB + scratch land here automatically
+ idx/httpd.json # cached index (phase 2)
+
+Binaries are **staged** into ``bin/`` and IDA opens the staged file, so the
+``.i64`` and every ``.id0/.id1/.id2/.nam/.til`` scratch file are created inside
+the sidecar instead of next to the original. Source trees stay pristine.
+
+Staging **copies** rather than hardlinks. A hardlink would be free, but source
+and staged would be one inode: an in-place rebuild (``cp newbuild /path/bin``
+truncates instead of replacing) would silently swap the bytes under an already
+analysed database, with nothing to detect it. A copy costs a fraction of the
+``.i64`` it will grow, decouples the two completely, and leaves the sidecar
+self-contained — the project still opens after the sources go away (an unmounted
+firmware image, a cleaned build tree).
+
+A source whose size/mtime no longer matches the staged copy is re-staged, and its
+now-stale database is dropped (the DB describes the old bytes).
+
+stdlib-only, like the domain/worker layers — the TUI is the only Textual consumer.
+"""
+from __future__ import annotations
+
+import json
+import os
+import shutil
+from dataclasses import dataclass
+
+SIDECAR_SUFFIX = ".idatui.d"
+DEFAULT_MEMORY_PCT = 25
+
+#: The database plus the working files IDA unpacks beside it while it's open.
+DB_SUFFIXES = (".i64", ".idb", ".id0", ".id1", ".id2", ".nam", ".til")
+#: Just the unpacked scratch — safe to delete when no worker holds the DB.
+SCRATCH_SUFFIXES = (".id0", ".id1", ".id2", ".nam", ".til")
+
+
+class ProjectError(Exception):
+ """A malformed project file, or a binary that can't be staged."""
+
+
+@dataclass(frozen=True)
+class BinaryRef:
+ """One binary in a project: where it came from, and where IDA works on it."""
+
+ label: str # unique within the project; names the staged file
+ source: str # absolute path to the original binary
+ staged: str # absolute path IDA actually opens (inside the sidecar)
+ #: How to LOAD it. Only meaningful for a headerless blob: an ELF/PE says what
+ #: it is, a raw firmware image doesn't, and IDA defaults to metapc at 0.
+ processor: str = "" # IDA processor name: arm, armb, mipsb, metapc, …
+ base: int = 0 # load address (natural, e.g. 0x8000000)
+ ida_args: str = "" # escape hatch: extra IDA command-line switches
+
+ @property
+ def db(self) -> str:
+ """The database IDA creates for the staged file."""
+ return self.staged + ".i64"
+
+ @property
+ def load_args(self) -> str:
+ """``processor``/``base`` as IDA command-line switches.
+
+ ``-b`` is in PARAGRAPHS, not bytes — ``-b1000`` loads at 0x10000. That
+ trap is worth hiding: projects say ``"base": "0x8000000"`` and the one
+ conversion lives in ``formats.load_args``.
+ """
+ from .formats import load_args
+ return load_args(self.processor, self.base, self.ida_args)
+
+
+def _as_addr(v) -> int:
+ """A load address from JSON: int, or a string in any base ("0x8000000").
+
+ Addresses are written by hand in a project file, so accept how people write
+ them rather than demanding decimal.
+ """
+ if v is None or v == "":
+ return 0
+ if isinstance(v, int):
+ return v
+ try:
+ return int(str(v), 0)
+ except ValueError:
+ return 0
+
+
+def _stat_key(path: str) -> tuple[int, int] | None:
+ """(size, mtime) identity used to spot a source that changed under us."""
+ try:
+ st = os.stat(path)
+ except OSError:
+ return None
+ return (st.st_size, int(st.st_mtime))
+
+
+def _unlink(path: str) -> bool:
+ try:
+ os.remove(path)
+ return True
+ except OSError:
+ return False
+
+
+class Project:
+ """A set of binaries analysed together, with all IDA artifacts corralled."""
+
+ def __init__(self, path: str, name: str, entries: list[dict],
+ memory_pct: int = DEFAULT_MEMORY_PCT) -> None:
+ self.path = os.path.abspath(os.path.expanduser(path))
+ self.name = name
+ self.memory_pct = memory_pct
+ self._entries = entries # raw, as written to the file
+ self._refs = self._build_refs()
+
+ # -- construction ------------------------------------------------------ #
+ @classmethod
+ def load(cls, path: str) -> "Project":
+ path = os.path.abspath(os.path.expanduser(path))
+ try:
+ with open(path) as f:
+ raw = json.load(f)
+ except OSError as e:
+ raise ProjectError(f"cannot read project {path}: {e}") from e
+ except ValueError as e:
+ raise ProjectError(f"malformed project {path}: {e}") from e
+ if not isinstance(raw, dict):
+ raise ProjectError(f"malformed project {path}: expected an object")
+ entries = raw.get("binaries")
+ if not isinstance(entries, list) or not entries:
+ raise ProjectError(f"project {path} lists no binaries")
+ norm: list[dict] = []
+ for e in entries:
+ if isinstance(e, str):
+ e = {"path": e}
+ if not isinstance(e, dict) or not e.get("path"):
+ raise ProjectError(f"project {path}: bad binary entry {e!r}")
+ # Keep every recognised key: a whitelist of path/label silently
+ # dropped the load options on the first save, so a blob's processor
+ # and base vanished the moment the project was reopened.
+ norm.append({k: e[k] for k in
+ ("path", "label", "processor", "base", "ida_args")
+ if e.get(k) not in (None, "")})
+ name = raw.get("name") or os.path.splitext(os.path.basename(path))[0]
+ try:
+ pct = int(raw.get("memory_pct", DEFAULT_MEMORY_PCT))
+ except (TypeError, ValueError):
+ pct = DEFAULT_MEMORY_PCT
+ return cls(path, str(name), norm, max(1, min(pct, 90)))
+
+ @classmethod
+ def create(cls, path: str, binaries: list[str], name: str | None = None,
+ memory_pct: int = DEFAULT_MEMORY_PCT, load: dict | None = None) -> "Project":
+ """Write a new project file listing ``binaries`` (an ad-hoc project).
+
+ ``load`` carries per-binary load options (processor/base/ida_args) that
+ apply to every binary given here — a headerless blob needs them, and one
+ command line normally adds blobs of the same kind.
+ """
+ if not binaries:
+ raise ProjectError("a project needs at least one binary")
+ entries, seen = [], set()
+ for b in binaries: # the same file twice on one command line is a typo
+ p = os.path.abspath(os.path.expanduser(b))
+ key = os.path.realpath(p)
+ if key in seen:
+ continue
+ seen.add(key)
+ e = {"path": p}
+ e.update({k: v for k, v in (load or {}).items() if v})
+ entries.append(e)
+ path = os.path.abspath(os.path.expanduser(path))
+ proj = cls(path, name or os.path.splitext(os.path.basename(path))[0],
+ entries, memory_pct)
+ proj.save()
+ return proj
+
+ def save(self) -> None:
+ data = {"name": self.name, "memory_pct": self.memory_pct,
+ "binaries": self._entries}
+ tmp = self.path + ".tmp"
+ os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True)
+ with open(tmp, "w") as f:
+ json.dump(data, f, indent=2)
+ f.write("\n")
+ os.replace(tmp, self.path)
+
+ # -- layout ------------------------------------------------------------ #
+ @property
+ def sidecar(self) -> str:
+ """The directory holding staged binaries, databases and indexes."""
+ return os.path.splitext(self.path)[0] + SIDECAR_SUFFIX
+
+ @property
+ def bin_dir(self) -> str:
+ return os.path.join(self.sidecar, "bin")
+
+ @property
+ def index_dir(self) -> str:
+ return os.path.join(self.sidecar, "idx")
+
+ def index_path(self, ref: BinaryRef) -> str:
+ """Where the cached per-binary index lives (phase 2)."""
+ return os.path.join(self.index_dir, ref.label + ".json")
+
+ # -- binaries ---------------------------------------------------------- #
+ def _build_refs(self) -> tuple[BinaryRef, ...]:
+ base = os.path.dirname(self.path)
+ refs: list[BinaryRef] = []
+ used: set[str] = set()
+ for e in self._entries:
+ src = os.path.expanduser(str(e["path"]))
+ if not os.path.isabs(src): # relative to the project file
+ src = os.path.join(base, src)
+ src = os.path.abspath(src)
+ label = str(e.get("label") or os.path.basename(src)) or "binary"
+ label = label.replace("/", "_").replace(os.sep, "_")
+ if label in used: # labels name files; keep them unique + stable
+ n = 2
+ while f"{label}_{n}" in used:
+ n += 1
+ label = f"{label}_{n}"
+ used.add(label)
+ refs.append(BinaryRef(
+ label=label, source=src,
+ staged=os.path.join(self.bin_dir, label),
+ processor=str(e.get("processor") or ""),
+ base=_as_addr(e.get("base")),
+ ida_args=str(e.get("ida_args") or "")))
+ return tuple(refs)
+
+ @property
+ def refs(self) -> tuple[BinaryRef, ...]:
+ return self._refs
+
+ def set_load(self, label: str, processor: str = "", base: int = 0,
+ ida_args: str = "") -> BinaryRef | None:
+ """Record how ``label`` should be loaded, and persist it.
+
+ Answered once: the dialog that asks writes the answer here, so reopening
+ the project doesn't ask again — and neither does adding the same blob to
+ another project, since it travels with the entry.
+ """
+ ref = self.by_label(label)
+ if ref is None:
+ return None
+ i = self._refs.index(ref)
+ e = self._entries[i]
+ if processor:
+ e["processor"] = processor
+ if base:
+ e["base"] = int(base)
+ if ida_args:
+ e["ida_args"] = ida_args
+ self._refs = self._build_refs()
+ self.save()
+ return self._refs[i]
+
+ def by_label(self, label: str) -> BinaryRef | None:
+ return next((r for r in self._refs if r.label == label), None)
+
+ def by_source(self, binary: str) -> BinaryRef | None:
+ """The entry for ``binary``, matched by resolved path.
+
+ Identity is the real path, not the file name: a project can legitimately
+ hold two different ``foo.elf`` from different directories (the labels
+ disambiguate them), but the same file must not be listed twice — and
+ ``./a.elf``, ``/abs/a.elf`` and a symlink to it are all the same file.
+ """
+ key = os.path.realpath(os.path.abspath(os.path.expanduser(binary)))
+ return next((r for r in self._refs
+ if os.path.realpath(r.source) == key), None)
+
+ def add(self, binary: str, label: str | None = None,
+ load: dict | None = None) -> BinaryRef:
+ """Add a binary, or return the existing entry if it's already here."""
+ existing = self.by_source(binary)
+ if existing is not None:
+ return existing
+ entry = {"path": os.path.abspath(os.path.expanduser(binary))}
+ if label:
+ entry["label"] = label
+ entry.update({k: v for k, v in (load or {}).items() if v})
+ self._entries.append(entry)
+ self._refs = self._build_refs()
+ return self._refs[-1]
+
+ def remove(self, label: str) -> bool:
+ ref = self.by_label(label)
+ if ref is None:
+ return False
+ i = self._refs.index(ref)
+ del self._entries[i]
+ self._refs = self._build_refs()
+ return True
+
+ # -- staging ----------------------------------------------------------- #
+ def is_stale(self, ref: BinaryRef) -> bool:
+ """True when the staged file is missing or no longer matches its source."""
+ staged = _stat_key(ref.staged)
+ return staged is None or staged != _stat_key(ref.source)
+
+ def stage(self, ref: BinaryRef) -> str:
+ """Ensure ``ref`` is staged in the sidecar; returns the staged path.
+
+ Re-staging a changed source drops its database: the DB describes the old
+ bytes, so keeping it would silently mismatch the disassembly (any renames
+ in it are lost, which is why callers should say so out loud).
+ """
+ if not os.path.isfile(ref.source):
+ raise ProjectError(f"no such binary: {ref.source}")
+ if not self.is_stale(ref):
+ return ref.staged
+ os.makedirs(self.bin_dir, exist_ok=True)
+ tmp = ref.staged + ".staging"
+ _unlink(tmp)
+ # copy2 (not link): keeps size+mtime so freshness compares cleanly, while
+ # leaving the staged bytes immune to an in-place rewrite of the source.
+ shutil.copy2(ref.source, tmp)
+ os.replace(tmp, ref.staged)
+ for suf in DB_SUFFIXES: # the old DB describes the old bytes
+ _unlink(ref.staged + suf)
+ return ref.staged
+
+ def stage_all(self, progress=None) -> list[BinaryRef]:
+ out = []
+ for ref in self._refs:
+ if progress is not None:
+ progress(f"staging {ref.label}\u2026")
+ self.stage(ref)
+ out.append(ref)
+ return out
+
+ def sweep_scratch(self, ref: BinaryRef) -> int:
+ """Delete IDA's unpacked working files (never the ``.i64``) for ``ref``.
+
+ A hard-killed worker leaves them behind and the database then refuses to
+ reopen. Only safe when no worker holds it.
+ """
+ return sum(1 for suf in SCRATCH_SUFFIXES if _unlink(ref.staged + suf))
+
+ def has_db(self, ref: BinaryRef) -> bool:
+ """True once the binary has been analysed and saved at least once."""
+ return os.path.isfile(ref.db)
+
+ def __repr__(self) -> str: # pragma: no cover - debug aid
+ return f"<Project {self.name!r} {len(self._refs)} binaries {self.path}>"
diff --git a/idatui/rpc.py b/idatui/rpc.py
index f41c734..43a34b7 100644
--- a/idatui/rpc.py
+++ b/idatui/rpc.py
@@ -28,7 +28,7 @@ from typing import Any
from rich.console import Console
from ._sync import drain, settle
-from .app import DecompView, DisasmView, HexView
+from .app import DecompView, GraphView, HexView, ListingView
PROTO_VERSION = 1
TYPE_DELAY_MS = 35 # default per-char delay for high-level typed ops (aesthetic)
@@ -38,6 +38,7 @@ _PROGRAM_METHODS = {
"goto", "open", "rename", "comment", "retype", "follow", "xrefs", "symbols",
"structs", "search", "select", "save", "hex", "toggle_view",
"pseudocode", "disassembly", "xrefs_to", "xrefs_from", "resolve",
+ "define", "rename_many", "opfmt", "graph",
}
# Self-documenting method table (returned by the 'methods' verb).
@@ -59,22 +60,57 @@ METHODS = {
"text": "{text,delay_ms?,settle?} type a literal string into the focused input",
"goto/open": "{target,delay_ms?} g-prompt to a name or 0xADDR",
"rename": "{name,word?,delay_ms?} rename the token under (or 'word') the cursor",
- "comment": "{text,delay_ms?} comment the current line",
+ "comment": "{text} comment the current line (use \\n for newlines)",
"retype": "{proto,word?,delay_ms?} set the prototype/type under the cursor",
"follow": "{word?} follow the reference under (or 'word') the cursor",
"cursor_on": "{word,line?,occurrence?=1} place the cursor on a token",
"back": "pop the nav stack",
"toggle_view": "disasm <-> pseudocode",
"hex": "hex view",
+ "graph": "{action?=show|open|close|toggle|zoom|block|entry|succ|pred,"
+ "target?,blocks?} the control-flow graph: 'show' reports its "
+ "structure (blocks, edges, cursor) without touching it; the others "
+ "drive it. 'block' takes target=<id|0xADDR>",
"xrefs": "open the xref picker",
"symbols": "{query?} open the symbol palette",
"structs": "open the struct editor",
"search": "{term,direction?=1} incremental search in the code view",
"select": "{index?} choose the highlighted/nth item in the open modal",
"save": "persist the .i64 (Ctrl+S)",
+ "trace": "{seek|goto|step,over?} navigate the execution trace (seek '!50' = percent)",
+ "binaries": "-> project binaries {label,active,resident,indexed} (project mode)",
+ "switch": "{binary,addr?} make another project binary active (addr also jumps)",
"close": "dismiss a modal (Escape)",
"move": "{dir,n?=1} fast movement (down/up/.../pagedown)",
"cursor": "{line?,col?} set the code-pane cursor directly",
+ "define": "{kind:code|func|undef|thumb|thumbscan|data|string,target?} "
+ "(re)define bytes at target — the raw-image workflow",
+ "rename_many": "{items:[{addr,name}] | file:JSON} bulk-apply a symbol file "
+ "in ONE call (no typing, no navigation)",
+ "opfmt": "{mode?=cycle|back|show|hex|dec|oct|bin|char|offset|stack|"
+ "default,target?,word?,line?,col?} how the literal under the cursor is "
+ "DISPLAYED (IDA's 'o'); works on the listing and on pseudocode "
+ "numbers. 'show' reports the format and the stops without editing",
+}
+
+#: `opfmt` modes that have a real key on the code views. Driving the key keeps
+#: the pane honest (a viewer sees the same thing a human would do); the named
+#: formats have no key, so those go through the view's action directly.
+_OPFMT_KEYS = {"cycle": "o", "back": "O"}
+_OPFMT_MODES = ("cycle", "back", "show", "hex", "dec", "oct", "bin", "char",
+ "offset", "stack", "default")
+
+# `define` kinds -> the ListingView key that runs them. Driving the real key
+# keeps the pane honest (a viewer sees the same thing a human would do) and
+# reuses the app's own edit worker, which reports what actually happened.
+_DEFINE_KEYS = {
+ "code": "c", # make code (runs until flow/undecodable)
+ "func": "p", # make function
+ "undef": "u",
+ "thumb": "t", # flip ARM/Thumb at the cursor, then disassemble
+ "thumbscan": "T", # find Thumb entry pointers in a vector table
+ "data": "d",
+ "string": "a",
}
# Movement keys — driven fast (no typed delay) so the pane still visibly moves.
@@ -94,13 +130,79 @@ def _active_widget(app):
"""The currently *shown* code widget (mirrors app._active)."""
if app._active == "hex":
return app.query_one(HexView)
- if app._active == "disasm":
- return app.query_one(DisasmView)
+ if app._active == "graph":
+ return app.query_one(GraphView)
+ if app._active in ("listing", "disasm"):
+ return app.query_one(ListingView)
return app.query_one(DecompView)
+def graph_info(app, blocks: bool = True) -> dict[str, Any]:
+ """Structured view of the control-flow graph: what a driver actually wants,
+ rather than the box-drawing characters it is rendered as."""
+ gv = app.query_one(GraphView)
+ if gv.fc is None or gv.lay is None:
+ return {"open": app._active == "graph", "loaded": False,
+ "note": "press space (or graph {action:'open'}) on a function"}
+ lay, fc = gv.lay, gv.fc
+ out: dict[str, Any] = {
+ "open": app._active == "graph",
+ "loaded": True,
+ "func": {"name": fc.name, "ea": fc.func_ea, "entry": fc.entry},
+ "zoom": gv.ZOOMS[gv._zoom],
+ "canvas": {"w": lay.width, "h": lay.height},
+ "stats": dict(lay.stats),
+ "cursor": {"block": gv.cursor_node, "row": gv.cursor_row,
+ "ea": gv._cursor_ea(), "word": gv.word_under_cursor()},
+ }
+ if blocks:
+ rows = []
+ for n in lay.nodes:
+ b = gv._blocks.get(n.id)
+ rows.append({
+ "id": n.id,
+ "start": b.start if b else None,
+ "end": b.end if b else None,
+ "insns": len(b.rows) if b else 0,
+ "rank": n.rank,
+ "box": {"x": n.x, "y": n.y, "w": n.w, "h": n.h},
+ "succs": [{"id": i, "kind": k} for i, k in lay.succ.get(n.id, [])],
+ "preds": [{"id": i, "kind": k} for i, k in lay.pred.get(n.id, [])],
+ "selfloop": bool(b and any(d == n.id for d, _ in b.succs)),
+ })
+ out["blocks"] = rows
+ return out
+
+
_MODALS = ("XrefsScreen", "SymbolPalette", "StructEditor", "ConfirmScreen")
+#: ``drive raw`` (and any k=v CLI) hands every param through as a *string*.
+#: Handlers that did ``int(...)`` coped; the ones that compared directly blew up
+#: with e.g. "'<' not supported between instances of 'int' and 'str'". Coerce the
+#: known-numeric names once, centrally, instead of at every call site.
+_INT_PARAMS = ("lines", "limit", "max", "n", "index", "line", "col",
+ "occurrence", "delay_ms", "direction", "addr", "count")
+_FLOAT_PARAMS = ("timeout",)
+
+
+def _coerce_params(params: dict[str, Any]) -> dict[str, Any]:
+ out = dict(params)
+ for k in _INT_PARAMS:
+ v = out.get(k)
+ if isinstance(v, str) and v.strip():
+ try:
+ out[k] = int(v, 0)
+ except ValueError:
+ pass
+ for k in _FLOAT_PARAMS:
+ v = out.get(k)
+ if isinstance(v, str) and v.strip():
+ try:
+ out[k] = float(v)
+ except ValueError:
+ pass
+ return out
+
def _modal_snapshot(app) -> dict[str, Any] | None:
"""Describe the top modal screen, if any, enough to drive it."""
@@ -129,6 +231,12 @@ def _cursor_info(app, w) -> dict[str, Any]:
if isinstance(w, HexView):
return {"kind": "hex", "va": (w.cursor_va() if w.model else None),
"byte": w.cursor}
+ if isinstance(w, GraphView):
+ # The graph cursor is (block, row), not a line index -- reporting it as
+ # one would make a driver's `cursor line=` land somewhere arbitrary.
+ return {"kind": "graph", "ea": w._cursor_ea(), "block": w.cursor_node,
+ "row": w.cursor_row, "col": w.cursor_x,
+ "word": w.word_under_cursor(), "text": w._line_plain()}
# disasm / decomp share the ColumnCursor surface
word = None
try:
@@ -145,6 +253,14 @@ def _cursor_info(app, w) -> dict[str, Any]:
"scroll_y": round(w.scroll_offset.y)}
+def _where(app) -> str:
+ """Short 'name @ 0xea' for error messages that need to say where we ended up."""
+ cur = getattr(app, "_cur", None)
+ if cur is None:
+ return "nowhere"
+ return f"{getattr(cur, 'name', '?')} @ {getattr(cur, 'ea', 0):#x}"
+
+
def _readiness(app) -> dict[str, Any]:
"""Whether the app is drivable yet, and how far function-loading has got.
(Cheap: no network — never call client.health() here.)"""
@@ -168,12 +284,14 @@ def snapshot(app) -> dict[str, Any]:
pass
return {
"active": app._active,
- "pref": app._pref,
+ "pref": app._code_mode(), # kept for wire compat; a constant now
"function": ({"ea": cur.ea, "name": cur.name} if cur else None),
"cursor": _cursor_info(app, w),
"status": st,
"filter": app._filter_term,
+ "binary": app._binary, # None outside project mode
"nav_depth": len(app._nav),
+ "hops": list(getattr(app, "_hops", [])),
"dirty": bool(app._dirty),
"modal": _modal_snapshot(app),
**_readiness(app),
@@ -187,6 +305,11 @@ def view_lines(app, lines: int | None = None) -> dict[str, Any]:
if isinstance(w, HexView):
return {"active": "hex", "note": "use screen() for the hex grid",
"cursor": _cursor_info(app, w)}
+ if isinstance(w, GraphView):
+ return {"active": "graph", "note": "use graph() for structure, "
+ "screen() for the drawing",
+ "cursor": _cursor_info(app, w),
+ "graph": graph_info(app, blocks=False)}
top = round(w.scroll_offset.y)
height = w.size.height or 40
n = min(lines or height, max(w.total - top, 0))
@@ -220,21 +343,58 @@ def screen_text(app, fmt: str = "text") -> dict[str, Any]:
return out
+def place_cursor(w, line=None, col=None) -> None:
+ """Move a code view's cursor and BRING IT INTO VIEW.
+
+ Setting the cursor without scrolling leaves the pane showing somewhere else
+ entirely, and the next verb then edits a line the operator cannot see — the
+ status describes one thing, the screen shows another. Every programmatic
+ cursor move goes through here for that reason.
+ """
+ if line is not None:
+ w.cursor = max(0, min(getattr(w, "total", 1) - 1, int(line)))
+ if col is not None:
+ w.cursor_x = max(0, int(col))
+ if hasattr(w, "_after_cursor_move"):
+ w._after_cursor_move()
+ if hasattr(w, "_scroll_cursor_into_view"):
+ w._scroll_cursor_into_view()
+ if hasattr(w, "_hscroll"):
+ w._hscroll()
+ w.refresh()
+
+
def cursor_on(app, word: str, line: int | None = None, occurrence: int = 1) -> bool:
"""Place the cursor on the ``occurrence``-th token equal to ``word`` in the
active code pane (optionally restricted to ``line``). Verified with the app's
own tokenizer so 'main' won't match inside 'domain'. Disasm scan is limited to
already-cached lines (what's on/near screen); decomp searches the whole body.
- Returns whether it found and moved."""
+ Returns whether it found and moved.
+
+ Search starts at the VIEWPORT, not at row 0. A continuous listing is the
+ whole segment, so counting from the top finds an occurrence in some unrelated
+ function thousands of rows away -- and the cursor then lands there, off
+ screen, where the next verb edits something the operator cannot see. Wrapping
+ to the rows above keeps every match reachable; landing scrolls, so wherever
+ it goes is visible.
+ """
w = _active_widget(app)
if isinstance(w, HexView):
raise ValueError("cursor_on: not supported in the hex view")
+ if isinstance(w, GraphView):
+ raise ValueError("cursor_on: not supported in the graph view — use "
+ "graph {action:'block'} or goto")
if isinstance(w, DecompView):
texts = list(w._texts)
else:
texts = [(w._line_plain(i) or "") for i in range(getattr(w, "total", 0))]
orig = (w.cursor, w.cursor_x)
- rows = [line] if line is not None else range(len(texts))
+ if line is not None:
+ rows = [line]
+ else:
+ # From the top of the viewport, then wrap round to what's above it.
+ top = round(w.scroll_offset.y)
+ rows = list(range(top, len(texts))) + list(range(0, top))
hits = 0
for i in rows:
if not (0 <= i < len(texts)):
@@ -246,9 +406,7 @@ def cursor_on(app, word: str, line: int | None = None, occurrence: int = 1) -> b
if w.word_under_cursor() == word:
hits += 1
if hits >= max(1, occurrence):
- if hasattr(w, "_after_cursor_move"):
- w._after_cursor_move()
- w.refresh()
+ place_cursor(w) # scrolls: an off-screen cursor edits blind
return True
col = t.find(word, col + 1)
w.cursor, w.cursor_x = orig # not found: leave the cursor untouched
@@ -310,7 +468,7 @@ def disassembly(app, target=None, max_lines: int = 2000) -> dict[str, Any]:
def _xref_dicts(xs, limit: int) -> list[dict[str, Any]]:
- return [{"frm": x.frm, "to": x.to, "type": x.type,
+ return [{"frm": x.frm, "to": x.to, "type": x.type, "kind": x.kind,
"fn_addr": x.fn_addr, "fn_name": x.fn_name} for x in xs[:limit]]
@@ -438,14 +596,94 @@ class RpcServer:
result = await self._dispatch(method, params)
return {"id": rid, "result": result}
except Exception as e: # noqa: BLE001 — report, never kill the connection
- return {"id": rid, "error": {"message": f"{type(e).__name__}: {e}"}}
+ # ``str(KeyError("msg"))`` returns ``repr("msg")`` (adds quotes), which
+ # mangles our friendly resolve messages; unwrap the single arg instead.
+ if isinstance(e, KeyError) and len(e.args) == 1 and isinstance(e.args[0], str):
+ msg = e.args[0]
+ else:
+ msg = str(e)
+ return {"id": rid, "error": {"message": f"{type(e).__name__}: {msg}"}}
# -- composed helpers (semantic verbs) -------------------------------- #
- async def _press(self, keys, pred=None, timeout=20.0):
+ async def _press(self, keys, pred=None, timeout=20.0, what=""):
await self.app._press_keys([str(k) for k in keys])
- await settle(self.app, pred, timeout=timeout)
+ ok = await settle(self.app, pred, timeout=timeout)
+ if pred is not None and not ok:
+ # Never report success for an action that did not happen: the caller
+ # would go on to edit whatever the *previous* location was.
+ raise TimeoutError(
+ f"{what or 'action'} did not complete within {timeout}s "
+ f"(still at {_where(self.app)}); retry with a larger timeout=")
return snapshot(self.app)
+ async def _graph(self, params, timeout):
+ """Drive / read the control-flow graph.
+
+ Everything goes through the real keys and the real view state, so a
+ driver sees exactly what a person would -- and 'show' is a pure read,
+ which is what you want between edits.
+ """
+ app = self.app
+ action = str(params.get("action") or "show").lower()
+ gv = app.query_one(GraphView)
+ want_blocks = params.get("blocks", True) not in (False, "false", "0", 0)
+
+ if action == "show":
+ return {**snapshot(app), "graph": graph_info(app, blocks=want_blocks)}
+ if action in ("open", "toggle", "close"):
+ if action == "open" and app._active == "graph":
+ return {**snapshot(app), "graph": graph_info(app, blocks=want_blocks)}
+ if action == "close" and app._active != "graph":
+ return {**snapshot(app), "graph": graph_info(app, blocks=want_blocks)}
+ want = "graph" if action in ("open", "toggle") and \
+ app._active != "graph" else None
+ res = await self._press(
+ ["space"],
+ (lambda: app._active == "graph") if want else
+ (lambda: app._active != "graph"),
+ timeout, f"graph {action}")
+ return {**res, "graph": graph_info(app, blocks=want_blocks)}
+
+ if app._active != "graph":
+ raise ValueError(f"graph {action}: the graph is not open "
+ f"(graph {{action:'open'}} first)")
+ if action == "zoom":
+ before = gv._zoom
+ await self._press(["z"], lambda: gv._zoom != before, timeout, "graph zoom")
+ elif action == "entry":
+ await self._press(["0"], None, timeout, "graph entry")
+ elif action in ("succ", "pred"):
+ before = gv.cursor_node
+ await self._press(["J" if action == "succ" else "K"],
+ lambda: gv.cursor_node != before, timeout,
+ f"graph {action}")
+ elif action == "block":
+ target = params.get("target")
+ if target is None:
+ raise ValueError("graph block: need target=<block id|0xADDR>")
+ nid = None
+ s = str(target)
+ if s.startswith("0x") or s.startswith("0X"):
+ ea = int(s, 16)
+ b = gv.fc.block_at(ea) if gv.fc else None
+ if b is None:
+ raise ValueError(f"graph block: {s} is not in this graph")
+ nid = b.id
+ else:
+ nid = int(s)
+ if gv.lay is None or nid not in gv.lay.by_id:
+ raise ValueError(f"graph block: no block {nid}")
+ gv.cursor_node = nid
+ gv.cursor_row = 0
+ gv.cursor_x = 0
+ gv._clamp_cursor()
+ gv._center_cursor()
+ gv.refresh()
+ await settle(app, None, timeout=2)
+ else:
+ raise ValueError(f"graph: unknown action {action!r}")
+ return {**snapshot(app), "graph": graph_info(app, blocks=want_blocks)}
+
async def _fill_prompt(self, open_key, input_id, value, delay_ms, clear):
"""Open a prompt (a keystroke), optionally clear its prefill, type the
value with the typed-out delay, submit. Returns after the prompt closes."""
@@ -455,12 +693,101 @@ class RpcServer:
await settle(app, lambda: app.query_one(f"#{input_id}", Input).display, timeout=10)
inp = app.query_one(f"#{input_id}", Input)
if not inp.display:
- raise RuntimeError(f"{input_id!r} prompt did not open (word under cursor?)")
+ # Say *why*. The old message always blamed the word under the cursor,
+ # which sent readers hunting for a cursor problem when the real cause
+ # was usually a modal eating the opening keystroke.
+ modal = type(app.screen).__name__
+ why = (f"modal {modal!r} has focus and ate the {open_key!r} keystroke"
+ if modal in _MODALS or modal != "Screen"
+ else "no renameable token under the cursor")
+ raise RuntimeError(f"{input_id!r} prompt did not open: {why}")
if clear:
inp.value = ""
await app._press_keys(_text_to_keys(value, delay_ms))
await app._press_keys(["enter"])
+ async def _rename_many(self, params: dict[str, Any], timeout: float) -> dict:
+ """Apply a whole symbol file in one worker call.
+
+ The per-symbol path (goto + typed rename prompt) is the right thing for
+ one name a human is watching, and hopeless for the case a firmware image
+ always brings: hundreds of names from a loader map, an emulator's
+ symbols.json, or another tool's export. Each of those renames costs a
+ navigation (which pulls a listing page and a decompile) plus two prompt
+ round-trips, so 400 symbols is tens of minutes of driving and the pane
+ just flickers. IDA's own rename tool already takes a *list*; this hands
+ it the whole list, then refreshes the caches and the function table once.
+ """
+ app = self.app
+ items = params.get("items")
+ src = params.get("file")
+ if isinstance(items, str): # `drive raw` hands params through as text
+ items = json.loads(items)
+ if items is None:
+ if not src:
+ raise ValueError("rename_many needs items=[{addr,name}] or file=<json>")
+ with open(os.path.expanduser(str(src))) as f:
+ items = json.load(f)
+ if isinstance(items, dict): # {"0x4370": "name"} is a natural shape too
+ items = [{"addr": k, "name": v} for k, v in items.items()]
+ if not isinstance(items, list) or not items:
+ raise ValueError("rename_many: items must be a non-empty list")
+
+ ops, skipped = [], 0
+ for it in items:
+ if not isinstance(it, dict):
+ skipped += 1
+ continue
+ # Accept the field names symbol files actually use.
+ addr = next((it[k] for k in ("addr", "start", "ea", "address")
+ if it.get(k) is not None), None)
+ name = it.get("name") or it.get("label")
+ if addr is None or not name:
+ skipped += 1
+ continue
+ ea = int(str(addr), 0) if isinstance(addr, str) else int(addr)
+ ops.append({"addr": hex(ea), "name": str(name)})
+ if not ops:
+ raise ValueError("rename_many: no usable {addr,name} entries")
+
+ overwrite = params.get("allow_overwrite", True)
+ if isinstance(overwrite, str):
+ overwrite = overwrite.lower() not in ("0", "false", "no", "")
+ batch = {"func": ops, "allow_overwrite": bool(overwrite)}
+ # The worker is blocking and single-threaded; off the event loop it goes,
+ # or the TUI freezes for the length of the batch.
+ res = await asyncio.to_thread(app.program.client.call, "rename", batch=batch)
+ summary = res.get("summary", {}) if isinstance(res, dict) else {}
+ failed = [r for r in (res.get("func") or []) if isinstance(r, dict)
+ and r.get("error")] if isinstance(res, dict) else []
+
+ # Names live in the IDB, but every cache in front of it is now stale --
+ # including Hex-Rays', which is per-function and does NOT notice that a
+ # *callee* was renamed. That cache is persisted in the .i64, so without
+ # this a batch import leaves pseudocode calling sub_98C0 forever while
+ # the listing (and every readback) says memset.
+ try:
+ await asyncio.to_thread(app.program.client.call, "force_recompile")
+ except Exception: # noqa: BLE001 -- older worker without the tool
+ pass
+ app.program.bump_names()
+ app.program.invalidate_functions()
+ app._func_index = None
+ app._load_functions() # re-streams the function table
+ await settle(app, timeout=timeout)
+ app._dirty = True
+ app._status(f"renamed {summary.get('ok', 0)} symbols"
+ + (f", {len(failed)} failed" if failed else "")
+ + " (Ctrl+S to save)")
+ snap = snapshot(app)
+ snap["rename_many"] = {
+ "requested": len(ops), "skipped": skipped,
+ "ok": summary.get("ok", 0), "failed": summary.get("failed", 0),
+ "errors": [{"addr": r.get("addr"), "error": r.get("error")}
+ for r in failed[:10]],
+ }
+ return snap
+
def _goto_target_pred(self, target):
"""A predicate that holds once a goto to ``target`` has landed."""
app = self.app
@@ -474,8 +801,33 @@ class RpcServer:
want = fn.addr if fn else ea
return lambda: app._cur is not None and app._cur.ea == want
+ #: Verbs that drive the *main* app by injecting keystrokes. If a modal is on
+ #: top it eats those keys, so they must refuse rather than silently no-op.
+ _NEEDS_NO_MODAL = {
+ "goto", "open", "rename", "comment", "retype", "follow", "back",
+ "toggle_view", "hex", "save", "search", "move", "cursor", "cursor_on",
+ "define", "opfmt",
+ }
+ #: Modals the driver is expected to interact with (they have their own verbs).
+ _DRIVABLE_MODALS = {"XrefsScreen", "SymbolPalette", "StructEditor",
+ "ProjectPalette", "QuitScreen"}
+
+ def _modal_kind(self) -> str | None:
+ scr = self.app.screen
+ name = type(scr).__name__
+ return name if name in _MODALS or name in self._DRIVABLE_MODALS else None
+
async def _dispatch(self, method: str, params: dict[str, Any]) -> Any:
app = self.app
+ params = _coerce_params(params)
+ if method in self._NEEDS_NO_MODAL:
+ modal = self._modal_kind()
+ if modal is not None:
+ raise RuntimeError(
+ f"modal {modal!r} is on top and will swallow this verb's "
+ f"keystrokes; dismiss it first (close) or use its own verb "
+ f"(select/symbols/xrefs). Note: a binary with no entry "
+ f"function can land in the symbol palette on startup.")
if method in (None, "ping"):
module = None
try:
@@ -487,9 +839,25 @@ class RpcServer:
if method == "methods":
return METHODS
if method == "quit":
+ # Route through the same teardown a human gets, so a dirty database
+ # is written instead of dropped. `app.exit()` alone skips the dirty
+ # check entirely, and the caller (pane stop) then kills the pane --
+ # which used to destroy a whole session's annotations.
+ dirty = list(app._dirty_labels())
+ save = params.get("save", True)
+ if isinstance(save, str):
+ save = save.lower() not in ("0", "false", "no", "")
+
+ def _go():
+ if dirty and save:
+ app._on_quit_choice("save") # saves, then exits
+ else:
+ app._on_quit_choice("discard")
+
# answer first, then tear down (so this response still gets written)
- asyncio.get_running_loop().call_later(0.2, app.exit)
- return {"ok": True, "quitting": True}
+ asyncio.get_running_loop().call_later(0.2, _go)
+ return {"ok": True, "quitting": True, "saving": bool(dirty and save),
+ "dirty": dirty}
if method in _PROGRAM_METHODS and app.program is None:
raise ValueError("not ready: still connecting / loading functions")
@@ -520,6 +888,74 @@ class RpcServer:
if method == "functions":
return functions(app, params.get("filter"), int(params.get("limit", 50)))
+ # -- projects ------------------------------------------------------ #
+ if method == "trace":
+ if app._trace is None:
+ raise ValueError("no trace loaded (launch with --trace FILE)")
+ t = app._trace
+ if "seek" in params:
+ v = params["seek"]
+ # "!50" seeks a percentage, like Tenet's timestamp shell.
+ if isinstance(v, str) and v.startswith("!"):
+ idx = int(float(v[1:]) * (t.length - 1) / 100.0)
+ else:
+ idx = int(str(v).replace(",", ""), 0) if isinstance(v, str) else int(v)
+ app._seek(idx)
+ elif "goto" in params: # first execution of an address/name
+ tgt = params["goto"]
+ ea = (int(str(tgt), 0) if str(tgt).lower().startswith("0x")
+ else app.program.resolve(str(tgt)))
+ first = t.first_execution(ea)
+ if first is None:
+ raise ValueError(f"{tgt} never executed in this trace")
+ app._seek(first)
+ elif "step" in params:
+ n = int(params.get("step") or 1)
+ over = bool(params.get("over"))
+ for _ in range(abs(n)):
+ (app._step_over if over else app._step)(1 if n > 0 else -1)
+ await settle(app, timeout=float(params.get("timeout", 20.0)))
+ snap = snapshot(app)
+ snap["trace"] = {"idx": app._t, "length": t.length,
+ "pc": hex(t.ip(app._t)),
+ "changed": sorted(t.changed(app._t))}
+ return snap
+
+ if method == "binaries":
+ if app._project is None:
+ raise ValueError("not a project session (launch with --project)")
+ counts = app._index.counts() if app._index is not None else {}
+ resident = set(app._pool.resident()) if app._pool is not None else set()
+ return {"active": app._binary, "hops": list(app._hops),
+ "binaries": [{"label": r.label, "source": r.source,
+ "active": r.label == app._binary,
+ "resident": r.label in resident,
+ "indexed": int(counts.get(r.label, 0))}
+ for r in app._project.refs]}
+
+ if method == "switch":
+ if app._project is None:
+ raise ValueError("not a project session (launch with --project)")
+ label = str(params.get("binary") or params.get("label") or "")
+ if app._project.by_label(label) is None:
+ have = ", ".join(r.label for r in app._project.refs)
+ raise ValueError(f"no such binary {label!r} (have: {have})")
+ addr = params.get("addr")
+ if label == app._binary and addr is None:
+ return snapshot(app)
+ if addr is None:
+ app._switch_binary(label)
+ else:
+ # Same path a project search hit takes, so it records a hop and
+ # Esc comes back here.
+ app._switch_then_goto(label, int(str(addr), 0)
+ if isinstance(addr, str) else int(addr))
+ await settle(app, lambda: app._binary == label
+ and app._func_index is not None
+ and app._func_index.complete,
+ timeout=float(params.get("timeout", 300.0)))
+ return snapshot(app)
+
# -- structured introspection (heavy: run off the UI loop) -------- #
loop = asyncio.get_running_loop()
if method == "pseudocode":
@@ -563,15 +999,106 @@ class RpcServer:
target = str(params.get("target", ""))
pred = self._goto_target_pred(target)
await self._fill_prompt("g", "goto", target, delay, clear=False)
- await settle(app, pred, timeout=timeout)
+ ok = await settle(app, pred, timeout=timeout)
+ if pred is not None and not ok:
+ # A goto that silently "succeeds" without moving is worse than an
+ # error: on a big database the listing build can outrun the
+ # default timeout, and every subsequent rename/comment then lands
+ # on the function the caller *used* to be looking at.
+ raise TimeoutError(
+ f"goto {target!r} did not land within {timeout}s "
+ f"(still at {_where(app)}); retry with a larger timeout=")
return snapshot(app)
+ if method == "define":
+ kind = str(params.get("kind", "code")).lower()
+ if kind not in _DEFINE_KEYS:
+ raise ValueError(
+ f"unknown define kind {kind!r}; one of "
+ f"{', '.join(sorted(_DEFINE_KEYS))}")
+ target = params.get("target")
+ if target not in (None, ""):
+ # Land on the address first. A raw image is mostly *undefined*,
+ # so the target usually has no name and no function — the goto
+ # predicate can't be address-based, only "we moved".
+ await self._fill_prompt("g", "goto", str(target), delay,
+ clear=False)
+ await settle(app, timeout=timeout)
+ if app._active == "hex":
+ # backslash leaves hex for the code view (which may be decomp).
+ await self._press(["backslash"],
+ lambda: app._active != "hex", timeout,
+ "leave the hex view")
+ if app._active == "decomp":
+ # These bindings live on the listing; in the decompiler the key
+ # would be swallowed or do something else entirely.
+ await self._press(["tab"], lambda: app._active == "listing",
+ timeout, "switch to the listing")
+ if app._active != "listing":
+ raise RuntimeError(
+ f"define needs the listing view, but the active pane is "
+ f"{app._active!r}")
+ snap = await self._press([_DEFINE_KEYS[kind]], timeout=timeout,
+ what=f"define {kind}")
+ snap["define"] = {"kind": kind, "status": snap.get("status", "")}
+ return snap
+
+ if method == "opfmt":
+ mode = str(params.get("mode", "cycle")).lower()
+ if mode not in _OPFMT_MODES:
+ raise ValueError(f"unknown opfmt mode {mode!r}; one of "
+ f"{', '.join(_OPFMT_MODES)}")
+ target = params.get("target")
+ if target not in (None, ""):
+ await self._fill_prompt("g", "goto", str(target), delay,
+ clear=False)
+ await settle(app, timeout=timeout)
+ if app._active == "hex":
+ await self._press(["backslash"], lambda: app._active != "hex",
+ timeout, "leave the hex view")
+ view = _active_widget(app)
+ if isinstance(view, HexView):
+ raise RuntimeError("opfmt needs a code view, not the hex view")
+ if params.get("word"):
+ # Land the column on the literal first: WHICH operand gets
+ # reformatted is decided by where the cursor is.
+ if not cursor_on(app, str(params["word"]), params.get("line"),
+ int(params.get("occurrence", 1) or 1)):
+ raise RuntimeError(
+ f"{params['word']!r} is not on screen in this view, so "
+ f"there is no literal to reformat")
+ await drain(app)
+ elif params.get("line") is not None or params.get("col") is not None:
+ place_cursor(view, params.get("line"), params.get("col"))
+ await drain(app)
+ before = _where(app)
+ if mode in _OPFMT_KEYS:
+ snap = await self._press([_OPFMT_KEYS[mode]], timeout=timeout,
+ what=f"opfmt {mode}")
+ else:
+ view.focus()
+ view.action_op_format(mode)
+ await settle(app, timeout=timeout)
+ snap = snapshot(app)
+ snap["opfmt"] = {"mode": mode, "at": before,
+ "status": snap.get("status", "")}
+ return snap
+
+ if method == "rename_many":
+ return await self._rename_many(params, timeout)
+
if method == "rename":
await self._fill_prompt("n", "rename", str(params["name"]), delay, clear=True)
await settle(app, timeout=timeout)
return snapshot(app)
if method == "comment":
- await self._fill_prompt("semicolon", "comment", str(params["text"]), delay,
+ # Comments can be long; skip the per-char delay so the agent isn't
+ # blocked for seconds watching the typing animation. Also: the
+ # prompt is single-line, so literal newlines (0x0a) get swallowed by
+ # the Input widget. The app's _do_comment converts the two-char
+ # sequence '\n' into a real newline for IDA, so we escape here.
+ ctext = str(params["text"]).replace("\n", "\\n")
+ await self._fill_prompt("semicolon", "comment", ctext, 0,
clear=True)
await settle(app, timeout=timeout)
return snapshot(app)
@@ -582,7 +1109,8 @@ class RpcServer:
if method == "follow":
depth = len(app._nav)
- return await self._press(["enter"], lambda: len(app._nav) > depth, timeout)
+ return await self._press(["enter"], lambda: len(app._nav) > depth,
+ timeout, "follow")
if method == "back":
return await self._press(["escape"], timeout=timeout)
if method == "toggle_view":
@@ -606,12 +1134,16 @@ class RpcServer:
return False
return False
- return await self._press(["tab"], _toggled, timeout)
+ return await self._press(["tab"], _toggled, timeout, "toggle_view")
if method == "hex":
- return await self._press(["backslash"], lambda: app._active == "hex", timeout)
+ return await self._press(["backslash"], lambda: app._active == "hex",
+ timeout, "hex")
+ if method == "graph":
+ return await self._graph(params, timeout)
if method == "xrefs":
return await self._press(
- ["x"], lambda: type(app.screen).__name__ == "XrefsScreen", timeout)
+ ["x"], lambda: type(app.screen).__name__ == "XrefsScreen",
+ timeout, "xrefs")
if method == "symbols":
await app._press_keys(["ctrl+n"])
await settle(app, lambda: type(app.screen).__name__ == "SymbolPalette", timeout=10)
@@ -622,7 +1154,8 @@ class RpcServer:
return snapshot(app)
if method == "structs":
return await self._press(
- ["ctrl+t"], lambda: type(app.screen).__name__ == "StructEditor", timeout)
+ ["ctrl+t"], lambda: type(app.screen).__name__ == "StructEditor",
+ timeout, "structs")
if method == "close":
return await self._press(["escape"], timeout=timeout)
if method == "save":
@@ -667,13 +1200,7 @@ class RpcServer:
w = _active_widget(app)
if isinstance(w, HexView):
raise ValueError("cursor: not supported in the hex view (use goto)")
- if "line" in params and params["line"] is not None:
- w.cursor = max(0, min(getattr(w, "total", 1) - 1, int(params["line"])))
- if "col" in params and params["col"] is not None:
- w.cursor_x = max(0, int(params["col"]))
- if hasattr(w, "_after_cursor_move"):
- w._after_cursor_move()
- w.refresh()
+ place_cursor(w, params.get("line"), params.get("col"))
await drain(app)
return snapshot(app)
diff --git a/idatui/rpcclient.py b/idatui/rpcclient.py
index e0a5602..4231d62 100644
--- a/idatui/rpcclient.py
+++ b/idatui/rpcclient.py
@@ -28,7 +28,15 @@ class RpcClient:
#: Default read timeout (s). Bounds any single call so a slow/hung server
#: (e.g. Hex-Rays grinding on an undecompilable function) can't block the
#: CLI forever. Override via ctor or the IDATUI_RPC_TIMEOUT env var.
- DEFAULT_TIMEOUT = 90.0
+ #:
+ #: 90s was too tight on real firmware: a comment on a 42k-line flat listing
+ #: (one segment, no function boundaries to limit the rebuild) took 26-106s,
+ #: so the client reported "no response ... server busy or the op is hung"
+ #: for edits that had in fact been applied. A driver that believes a
+ #: successful edit failed is worse than a slow one -- it redoes the work, or
+ #: "fixes" something that was never broken. Real hangs still get caught,
+ #: just later.
+ DEFAULT_TIMEOUT = 300.0
def __init__(self, sock_path: str, timeout: float | None = None):
self.path = sock_path
diff --git a/idatui/trace.py b/idatui/trace.py
new file mode 100644
index 0000000..931f918
--- /dev/null
+++ b/idatui/trace.py
@@ -0,0 +1,495 @@
+"""Reading Tenet execution traces.
+
+A Tenet trace is a line-per-instruction delta log::
+
+ rax=0x3c,rbx=0x0,...,rip=0x7ffff6faaae0 # full state on the first line
+ rip=0x7ffff6faaae4 # then only what changed
+ r9=0x7ffff6762e60,rip=0x7ffff6faaae9,mw=0x7ffff6f9b7c8:08c177f6ff7f0000
+
+Registers that changed, the PC on every line, and every memory access *with its
+bytes*. That is enough to reconstruct any register or any memory address at any
+point in time, forwards or backwards, which is the whole trick.
+
+This is our own reader, not a port. The reference implementation
+(``~/dev/tenet/tenet-original/plugins/tenet/trace/``) packs the trace into
+segments with compressed address/mask tables, which earns its keep for its Qt
+timeline; we need different queries and would rather own the ~400 lines than
+inherit 3700. It is differential-tested against that implementation
+(``tests/test_trace_vs_tenet.py``) so "our own" doesn't quietly mean "different".
+
+The index is built around the query the UI actually asks, which the reference
+answers one address at a time: **which timestamps executed this set of
+addresses**. A listing row is one address, but a pseudocode line covers many, so
+``by_ip`` maps address -> timestamps and set queries are unions of those.
+"""
+
+from __future__ import annotations
+
+import array
+import bisect
+import os
+import re
+from dataclasses import dataclass, field
+
+#: Tenet packs its register delta into a uint32, so a trace arch may name at
+#: most 32 registers. Ours is discovered from the trace instead of declared,
+#: but the cap is worth knowing when a trace looks short of registers.
+MAX_REGISTERS = 32
+
+_MEM_RE = re.compile(r"^m(r|w|rw)$")
+
+
+@dataclass
+class TraceInfo:
+ """The sidecar ``<prefix>.info`` written by our QEMU tracer.
+
+ Optional: a trace from another tracer has none, and everything here can be
+ recovered or guessed from the log itself.
+ """
+
+ arch: str = ""
+ mode: str = ""
+ binary: str = ""
+ start_code: int = 0
+ end_code: int = 0
+ entry_code: int = 0
+ traced: str = ""
+
+ @classmethod
+ def load(cls, path: str) -> "TraceInfo | None":
+ try:
+ with open(path) as f:
+ raw = dict(
+ ln.strip().split("=", 1) for ln in f if "=" in ln)
+ except OSError:
+ return None
+ def num(k):
+ try:
+ return int(raw.get(k, "0"), 0)
+ except ValueError:
+ return 0
+ return cls(arch=raw.get("arch", ""), mode=raw.get("mode", ""),
+ binary=raw.get("binary", ""), start_code=num("start_code"),
+ end_code=num("end_code"), entry_code=num("entry_code"),
+ traced=raw.get("traced", ""))
+
+
+@dataclass
+class MemOp:
+ """One memory access made by one instruction.
+
+ ``addr`` is the address the TRACE recorded — not slid onto the database.
+ Most accesses are stack or heap, which have no counterpart in the database
+ at all, and applying the image's relocation to a stack pointer produces a
+ nonsense address (it went negative in testing). Only addresses inside the
+ image can be translated, and the caller knows when that applies.
+ """
+
+ addr: int
+ data: bytes
+ write: bool
+
+ @property
+ def end(self) -> int:
+ return self.addr + len(self.data)
+
+
+@dataclass
+class Trace:
+ """An indexed Tenet trace.
+
+ Timestamps are indices into the executed-instruction sequence: 0 is the
+ first instruction, ``length - 1`` the last.
+ """
+
+ path: str = ""
+ info: TraceInfo | None = None
+ #: PC per timestamp.
+ ips: array.array = field(default_factory=lambda: array.array("Q"))
+ #: name -> (timestamps of change, value at each change). A register's value
+ #: at time t is the last change at or before t; the first line carries a
+ #: full state dump, so every register has an entry at 0.
+ reg_at: dict[str, tuple[array.array, array.array]] = field(default_factory=dict)
+ #: address -> timestamps that executed it (ascending, by construction).
+ by_ip: dict[int, array.array] = field(default_factory=dict)
+ #: memory accesses, parallel arrays indexed by access number.
+ mem_idx: array.array = field(default_factory=lambda: array.array("I"))
+ mem_addr: array.array = field(default_factory=lambda: array.array("Q"))
+ mem_write: bytearray = field(default_factory=bytearray)
+ mem_off: array.array = field(default_factory=lambda: array.array("Q"))
+ mem_len: array.array = field(default_factory=lambda: array.array("H"))
+ mem_blob: bytearray = field(default_factory=bytearray)
+ #: first access number of each timestamp, plus a final sentinel.
+ mem_row: array.array = field(default_factory=lambda: array.array("I"))
+ #: applied so trace addresses line up with the database (see ``rebase``).
+ slide: int = 0
+ #: accesses ordered by address, built on first memory query.
+ _mem_order: list | None = None
+ _mem_starts: list = field(default_factory=list)
+ _mem_maxlen: int = 0
+
+ # -- construction ------------------------------------------------------ #
+ @classmethod
+ def load(cls, path: str, progress=None, limit: int = 0) -> "Trace":
+ """Parse a text trace. ``progress(lines)`` is called every 50k lines."""
+ t = cls(path=os.path.abspath(path))
+ base = path[:-6] if path.endswith(".0.log") else os.path.splitext(path)[0]
+ t.info = TraceInfo.load(base + ".info")
+ regs: dict[str, tuple[array.array, array.array]] = {}
+ ips, by_ip = t.ips, t.by_ip
+ n = 0
+ with open(path) as f:
+ for line in f:
+ line = line.strip()
+ if not line:
+ continue
+ ip = None
+ t.mem_row.append(len(t.mem_idx))
+ for part in line.split(","):
+ key, _, val = part.partition("=")
+ if not val:
+ continue
+ key = key.strip().lower()
+ if _MEM_RE.match(key):
+ addr_s, _, data_s = val.partition(":")
+ try:
+ addr = int(addr_s, 16)
+ data = bytes.fromhex(data_s)
+ except ValueError:
+ continue
+ # 'mrw' is one access that both reads and writes; record
+ # the write, which is what the memory state follows.
+ t.mem_idx.append(n)
+ t.mem_addr.append(addr)
+ t.mem_write.append(0 if key == "mr" else 1)
+ t.mem_off.append(len(t.mem_blob))
+ t.mem_len.append(len(data))
+ t.mem_blob += data
+ continue
+ try:
+ v = int(val, 16)
+ except ValueError:
+ continue
+ slot = regs.get(key)
+ if slot is None:
+ slot = regs[key] = (array.array("I"), array.array("Q"))
+ slot[0].append(n)
+ slot[1].append(v)
+ ip = v if key in ("rip", "eip", "pc") else ip
+ if ip is None:
+ # No PC on this line: the format says always emit it, and
+ # without it the line cannot be placed. Carry the previous
+ # one rather than dropping the instruction.
+ ip = ips[-1] if ips else 0
+ ips.append(ip)
+ where = by_ip.get(ip)
+ if where is None:
+ where = by_ip[ip] = array.array("I")
+ where.append(n)
+ n += 1
+ if progress is not None and n % 50000 == 0:
+ progress(n)
+ if limit and n >= limit:
+ break
+ t.mem_row.append(len(t.mem_idx))
+ t.reg_at = regs
+ return t
+
+ # -- basics ------------------------------------------------------------ #
+ def __len__(self) -> int:
+ return len(self.ips)
+
+ @property
+ def length(self) -> int:
+ return len(self.ips)
+
+ @property
+ def registers(self) -> list[str]:
+ """Register names in the trace, PC last (the order tracers emit)."""
+ return sorted(self.reg_at, key=lambda r: (r in ("rip", "eip", "pc"), r))
+
+ @property
+ def pc_name(self) -> str:
+ for n in ("rip", "eip", "pc"):
+ if n in self.reg_at:
+ return n
+ return ""
+
+ def ip(self, idx: int) -> int:
+ """PC at ``idx``, in DATABASE addresses (slide applied)."""
+ return self.ips[idx] + self.slide
+
+ def raw_ip(self, idx: int) -> int:
+ return self.ips[idx]
+
+ # -- register state ----------------------------------------------------- #
+ def register(self, name: str, idx: int) -> int | None:
+ """Value of ``name`` at ``idx``, or None if the trace never set it."""
+ slot = self.reg_at.get(name.lower())
+ if slot is None:
+ return None
+ idxs, vals = slot
+ i = bisect.bisect_right(idxs, idx) - 1
+ return vals[i] if i >= 0 else None
+
+ def register_state(self, idx: int) -> dict[str, int]:
+ return {n: v for n in self.reg_at
+ if (v := self.register(n, idx)) is not None}
+
+ def changed(self, idx: int) -> set[str]:
+ """Registers written BY the instruction at ``idx`` (what the line said).
+
+ Used to highlight what an instruction actually did, which is the reason
+ a delta trace is readable at all.
+ """
+ out = set()
+ for n, (idxs, _vals) in self.reg_at.items():
+ i = bisect.bisect_left(idxs, idx)
+ if i < len(idxs) and idxs[i] == idx:
+ out.add(n)
+ return out
+
+ def last_write(self, name: str, idx: int) -> int | None:
+ """Timestamp of the write that produced ``name``'s value at ``idx``.
+
+ "Which instruction set this register?" — the question that motivates a
+ trace explorer in the first place.
+ """
+ slot = self.reg_at.get(name.lower())
+ if slot is None:
+ return None
+ i = bisect.bisect_right(slot[0], idx) - 1
+ return slot[0][i] if i >= 0 else None
+
+ def next_write(self, name: str, idx: int) -> int | None:
+ slot = self.reg_at.get(name.lower())
+ if slot is None:
+ return None
+ i = bisect.bisect_right(slot[0], idx)
+ return slot[0][i] if i < len(slot[0]) else None
+
+ # -- memory ------------------------------------------------------------- #
+ def memory_ops(self, idx: int) -> list[MemOp]:
+ """Accesses made by the instruction at ``idx``."""
+ if not (0 <= idx < len(self.mem_row) - 1):
+ return []
+ lo, hi = self.mem_row[idx], self.mem_row[idx + 1]
+ out = []
+ for k in range(lo, hi):
+ off, ln = self.mem_off[k], self.mem_len[k]
+ out.append(MemOp(addr=self.mem_addr[k],
+ data=bytes(self.mem_blob[off:off + ln]),
+ write=bool(self.mem_write[k])))
+ return out
+
+ # -- memory state ------------------------------------------------------- #
+ def _mem_index(self) -> None:
+ """Order the accesses by address, once.
+
+ Queries ask "what was at this window at time t", so the accesses that
+ matter are the few touching that window — not the tens of thousands in
+ the trace. Sorting by address makes those a bisect away; sorting by time
+ (the order they arrive in) would mean scanning everything per repaint.
+ """
+ if self._mem_order is not None:
+ return
+ order = sorted(range(len(self.mem_addr)), key=lambda k: self.mem_addr[k])
+ self._mem_order = order
+ self._mem_starts = [self.mem_addr[k] for k in order]
+ self._mem_maxlen = max(self.mem_len) if len(self.mem_len) else 0
+
+ def memory_raw(self, addr: int, length: int,
+ idx: int | None = None) -> tuple[bytes, bytes]:
+ """Memory at a TRACE address (no slide).
+
+ The stack lives here. Measured on two real traces, 0% of memory accesses
+ fall inside the image — every one is stack or heap — so a query that
+ insists on database addresses can't answer the question anyone actually
+ has about memory in a trace.
+ """
+ return self.memory(addr + self.slide, length, idx)
+
+ def memory(self, addr: int, length: int,
+ idx: int | None = None) -> tuple[bytes, bytes]:
+ """``(data, known)`` for ``length`` bytes at ``addr`` as of ``idx``.
+
+ ``known`` is a byte-per-byte mask: a trace only says what it saw, so a
+ byte nobody read or wrote is genuinely unknown and must not be drawn as
+ zero. That distinction is the whole value of reading memory from a trace
+ rather than from the database — the database has the file's bytes, the
+ trace has what was actually there at that instant.
+
+ Reads count as evidence, not just writes: an instruction reading a byte
+ reveals what it held then.
+ """
+ if idx is None:
+ idx = self.length - 1
+ length = max(int(length), 0)
+ out, known = bytearray(length), bytearray(length)
+ if not length or not len(self.mem_idx):
+ return bytes(out), bytes(known)
+ self._mem_index()
+ raw = addr - self.slide
+ best = [-1] * length
+ import bisect as _b
+ lo = _b.bisect_left(self._mem_starts, raw - self._mem_maxlen)
+ hi = _b.bisect_right(self._mem_starts, raw + length - 1)
+ for pos in range(lo, hi):
+ k = self._mem_order[pos]
+ t = self.mem_idx[k]
+ if t > idx:
+ continue
+ a, ln = self.mem_addr[k], self.mem_len[k]
+ s, e = max(a, raw), min(a + ln, raw + length)
+ if s >= e:
+ continue
+ off = self.mem_off[k]
+ for b in range(s, e):
+ j = b - raw
+ # >= not >: several accesses can share a timestamp (an
+ # instruction that reads and writes), and the later entry on the
+ # line is the one that stands.
+ if t >= best[j]:
+ best[j] = t
+ out[j] = self.mem_blob[off + (b - a)]
+ known[j] = 1
+ return bytes(out), bytes(known)
+
+ def memory_writes(self, addr: int, length: int) -> list[int]:
+ """Timestamps that WROTE any byte of ``[addr, addr+length)``."""
+ return self._mem_touch(addr, length, writes=True)
+
+ def memory_accesses(self, addr: int, length: int) -> list[int]:
+ """Timestamps that read or wrote any byte of the range."""
+ return self._mem_touch(addr, length, writes=False)
+
+ def _mem_touch(self, addr: int, length: int, writes: bool) -> list[int]:
+ if not len(self.mem_idx) or length <= 0:
+ return []
+ self._mem_index()
+ raw = addr - self.slide
+ import bisect as _b
+ lo = _b.bisect_left(self._mem_starts, raw - self._mem_maxlen)
+ hi = _b.bisect_right(self._mem_starts, raw + length - 1)
+ out = set()
+ for pos in range(lo, hi):
+ k = self._mem_order[pos]
+ a, ln = self.mem_addr[k], self.mem_len[k]
+ if a + ln <= raw or a >= raw + length:
+ continue
+ if writes and not self.mem_write[k]:
+ continue
+ out.add(self.mem_idx[k])
+ return sorted(out)
+
+ # -- execution queries (what painting is built on) ---------------------- #
+ def executions(self, ea: int) -> array.array:
+ """Every timestamp that executed ``ea`` (database address)."""
+ return self.by_ip.get(ea - self.slide, array.array("I"))
+
+ def executions_between(self, ea: int, lo: int, hi: int) -> list[int]:
+ ts = self.executions(ea)
+ a = bisect.bisect_left(ts, lo)
+ b = bisect.bisect_right(ts, hi)
+ return list(ts[a:b])
+
+ def hits(self, eas) -> dict[int, int]:
+ """{address: execution count} for a set of addresses.
+
+ The set form is the point: painting a listing row needs one address, but
+ a pseudocode line covers many, and asking per-address would mean a
+ lookup per instruction per repaint.
+ """
+ out = {}
+ for ea in eas:
+ ts = self.by_ip.get(ea - self.slide)
+ if ts:
+ out[ea] = len(ts)
+ return out
+
+ def prev_ips(self, idx: int, n: int) -> list[int]:
+ """Addresses executed in the ``n`` steps before ``idx`` (nearest first).
+
+ A trail, not all of history: showing every address the trace ever
+ touched says almost nothing on a loop-heavy program, whereas the last
+ few dozen steps say how you GOT here.
+ """
+ lo = max(idx - n, 0)
+ return [self.ips[i] + self.slide for i in range(idx - 1, lo - 1, -1)]
+
+ def next_ips(self, idx: int, n: int) -> list[int]:
+ """Addresses executed in the ``n`` steps after ``idx`` (nearest first)."""
+ hi = min(idx + n + 1, self.length)
+ return [self.ips[i] + self.slide for i in range(idx + 1, hi)]
+
+ def trail(self, idx: int, n: int = 96) -> dict[int, str]:
+ """{address: 'now' | 'past' | 'future'} around ``idx``.
+
+ Where an address appears on both sides — a loop body, which is most of
+ them — the nearer side wins, because that's the one that explains the
+ step you are about to take or just took.
+ """
+ out: dict[int, str] = {}
+ for k, ea in enumerate(self.next_ips(idx, n)):
+ out.setdefault(ea, "future")
+ for k, ea in enumerate(self.prev_ips(idx, n)):
+ prev = out.get(ea)
+ if prev is None:
+ out[ea] = "past"
+ elif prev == "future":
+ # Same distance rule as above, resolved by which loop found it
+ # first would be arbitrary; compare real distances instead.
+ fwd = next((i for i, a in enumerate(self.next_ips(idx, n)) if a == ea), n)
+ if k < fwd:
+ out[ea] = "past"
+ if 0 <= idx < self.length:
+ out[self.ips[idx] + self.slide] = "now"
+ return out
+
+ def first_execution(self, ea: int) -> int | None:
+ ts = self.executions(ea)
+ return ts[0] if ts else None
+
+ def next_execution(self, ea: int, idx: int) -> int | None:
+ ts = self.executions(ea)
+ i = bisect.bisect_right(ts, idx)
+ return ts[i] if i < len(ts) else None
+
+ def prev_execution(self, ea: int, idx: int) -> int | None:
+ ts = self.executions(ea)
+ i = bisect.bisect_left(ts, idx) - 1
+ return ts[i] if i >= 0 else None
+
+ # -- lining the trace up with the database ------------------------------ #
+ def rebase(self, db_addresses) -> int:
+ """Find the slide between trace addresses and database addresses.
+
+ A traced process is relocated (ASLR, or a PIE base the database doesn't
+ share): our echo trace runs at 0x7ffff6faa000 while the database has the
+ same code at 0x2490. Nothing lines up until this is solved, so it is not
+ optional.
+
+ Page offsets survive relocation — only whole pages move — so the low 12
+ bits of an instruction address are invariant. Bucket the database's
+ addresses by those bits, and for each trace address the candidate slides
+ are the differences to database addresses in its bucket. The slide that
+ the most instructions agree on wins.
+ """
+ buckets: dict[int, list[int]] = {}
+ for a in db_addresses:
+ buckets.setdefault(a & 0xFFF, []).append(a)
+ if not buckets:
+ return 0
+ votes: dict[int, int] = {}
+ # A sample is enough and keeps this O(1)-ish on a 10M trace; unique
+ # addresses, because a hot loop shouldn't outvote the rest of the code.
+ for ea in list(self.by_ip)[:4096]:
+ for cand in buckets.get(ea & 0xFFF, ()):
+ votes[cand - ea] = votes.get(cand - ea, 0) + 1
+ if not votes:
+ return 0
+ best, n = max(votes.items(), key=lambda kv: kv[1])
+ return best if n > 1 else 0
+
+ def apply_slide(self, slide: int) -> None:
+ self.slide = int(slide)
diff --git a/idatui/tui.py b/idatui/tui.py
deleted file mode 100644
index b399ffe..0000000
--- a/idatui/tui.py
+++ /dev/null
@@ -1,45 +0,0 @@
-"""Launcher for the idatui TUI.
-
- # attach to the single open session on a running server
- python -m idatui.tui
-
- # attach to a specific session
- python -m idatui.tui --db 80d83396
-
- # open (or reopen) an arbitrary binary, then drive it
- python -m idatui.tui --open /path/to/binary
-
-The server (supervisor) must already be running (see spawn.sh). --open creates a
-session via idb_open; the binary's directory must be writable (idalib writes a
-.i64 next to it).
-"""
-from __future__ import annotations
-
-import argparse
-import os
-
-from .app import IdaTui
-from .client import DEFAULT_URL
-
-
-def main(argv: list[str] | None = None) -> int:
- p = argparse.ArgumentParser(prog="idatui", description="Minimal TUI for IDA over MCP")
- p.add_argument("--url", default=os.environ.get("IDA_MCP_URL", DEFAULT_URL),
- help=f"MCP server URL (default {DEFAULT_URL})")
- p.add_argument("--db", default=os.environ.get("IDA_MCP_DB"),
- help="attach to an existing session id")
- p.add_argument("--open", metavar="PATH",
- help="open (or reopen) a binary and drive it (dir must be writable)")
- p.add_argument("--no-keepalive", action="store_true",
- help="Do not bump idle-TTL / run the keepalive heartbeat")
- p.add_argument("--rpc", metavar="PATH",
- help="listen for RPC on this unix socket path (puppeteer the TUI)")
- args = p.parse_args(argv)
- rpc_path = os.path.abspath(os.path.expanduser(args.rpc)) if args.rpc else None
- IdaTui(url=args.url, db=args.db, open_path=args.open,
- keepalive=not args.no_keepalive, rpc_path=rpc_path).run()
- return 0
-
-
-if __name__ == "__main__":
- raise SystemExit(main())
diff --git a/idatui/worker.py b/idatui/worker.py
new file mode 100644
index 0000000..a4e3509
--- /dev/null
+++ b/idatui/worker.py
@@ -0,0 +1,233 @@
+"""idatui's own idalib worker — the replacement for the ida-pro-mcp supervisor.
+
+Opens ONE database in-process (on the main thread, as idalib requires) and
+serves ida-pro-mcp's *tool functions* over a unix socket with length-prefixed
+pickle. Same tool implementations as the MCP path (we call
+``MCP_SERVER.tools.methods[name](**args)`` directly), so return shapes are
+byte-identical — but with ~50us/call instead of the HTTP path's ~5ms, and no
+supervisor / HTTP / JSON / 50KB-truncation machinery.
+
+ python -m idatui.worker <sock_path> <binary_path>
+
+The socket only appears once the database is open + analyzed, so a client can
+poll ``connect()`` to know when the worker is ready. Requests are served
+serially on the main thread (idalib is single-threaded; every tool runs inline
+through its own execute_sync, which is a no-op on the main thread).
+
+Protocol (both directions length-prefixed: 4-byte big-endian len + pickle):
+ request = (tool_name: str, kwargs: dict)
+ response = (ok: bool, result_or_error)
+ tool_name == "__shutdown__" ends the worker.
+"""
+from __future__ import annotations
+
+import os
+import pickle
+import socket
+import struct
+import sys
+import uuid
+
+
+# --------------------------------------------------------------------------- #
+# framing
+# --------------------------------------------------------------------------- #
+def _recvn(sock: socket.socket, n: int) -> bytes | None:
+ buf = bytearray()
+ while len(buf) < n:
+ chunk = sock.recv(n - len(buf))
+ if not chunk:
+ return None
+ buf += chunk
+ return bytes(buf)
+
+
+def send(sock: socket.socket, obj) -> None:
+ data = pickle.dumps(obj, protocol=pickle.HIGHEST_PROTOCOL)
+ sock.sendall(struct.pack(">I", len(data)) + data)
+
+
+def recv(sock: socket.socket):
+ hdr = _recvn(sock, 4)
+ if hdr is None:
+ return None
+ (n,) = struct.unpack(">I", hdr)
+ body = _recvn(sock, n)
+ return None if body is None else pickle.loads(body)
+
+
+# --------------------------------------------------------------------------- #
+# worker
+# --------------------------------------------------------------------------- #
+def _ensure_tools_injected() -> None:
+ """Inject idatui's custom tools (heads/read_raw/resolve_names/func_types/...)
+ into the installed ida_pro_mcp, idempotently, so the worker is self-sufficient
+ (nothing else has to inject these tools first). Must run BEFORE
+ ida_pro_mcp.ida_mcp is imported (the injected code lives in api_types.py)."""
+ import importlib.util
+ repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+ patch = os.path.join(repo, "server", "patch_server.py")
+ if not os.path.exists(patch):
+ return
+ try:
+ spec = importlib.util.spec_from_file_location("_idatui_patch", patch)
+ mod = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(mod) # IDA-free; just defines + patches api_types
+ mod.main()
+ except Exception as e: # noqa: BLE001 -- tools may already be present
+ sys.stderr.write(f"idatui: tool injection skipped: {e}\n")
+
+
+def _has_database(binpath: str) -> bool:
+ """Whether IDA already has a database for ``binpath``.
+
+ IDA names it ``<file>.i64`` (keeping the extension), but a database made
+ from ``foo.bin`` can also appear as ``foo.i64`` depending on how it was
+ created — check both, because guessing wrong here means re-passing load
+ switches to an existing database, which fails the open.
+ """
+ return (os.path.exists(binpath + ".i64")
+ or os.path.exists(os.path.splitext(binpath)[0] + ".i64"))
+
+
+def _open_and_register(binpath: str, load_args: str = ""):
+ """Open the DB (main thread) then import ida-pro-mcp so every @tool registers
+ against this live database. Returns (tools_dict, module_name, save_fn).
+
+ ``load_args`` is passed to IDA as command-line switches, which is the only
+ way to tell it how to read a headerless blob: a raw firmware image has no
+ format to detect, so without ``-p<processor>`` it loads as metapc at 0 and
+ finds nothing. Ignored once a database exists — the .i64 already records how
+ it was loaded, and re-passing conflicting switches is how you corrupt one.
+ """
+ _ensure_tools_injected() # before any ida_pro_mcp import
+ import idapro
+ idapro.enable_console_messages(False)
+ args = load_args or None
+ if args and _has_database(binpath):
+ # The .i64 already records how this image was loaded. Passing the
+ # switches again on reopen makes IDA fail outright (rc != 0) — the load
+ # options belong to the FIRST open only.
+ args = None
+ if idapro.open_database(binpath, run_auto_analysis=True,
+ args=args): # nonzero == failure
+ if args:
+ # With load switches in play they are the likeliest culprit by far:
+ # IDA refuses an unknown -p name with no diagnostic of its own, so
+ # saying "the database is locked" here sends people hunting a
+ # problem they don't have.
+ raise RuntimeError(
+ f"failed to open {binpath} with load options {args!r}: IDA "
+ f"rejected them \u2014 an unknown processor name is the usual "
+ f"cause (see tools/verify_procs.py for the valid ones)")
+ raise RuntimeError(
+ f"failed to open {binpath}: the .i64 is likely held by a running "
+ f"ida-mcp worker (try: pkill -f idalib) or wedged from a crash "
+ f"(delete its .id0/.id1/.id2/.nam/.til next to the binary)")
+ import ida_auto
+ ida_auto.auto_wait() # block until auto-analysis settles (match ida-mcp)
+
+ # importing the package registers all api_*/patched tools against MCP_SERVER
+ from ida_pro_mcp.ida_mcp import MCP_SERVER # noqa: WPS433
+
+ import ida_nalt
+ module = os.path.basename(ida_nalt.get_root_filename() or binpath)
+
+ def save():
+ import idc
+ try:
+ idc.save_database(idc.get_idb_path(), 0)
+ except Exception: # noqa: BLE001
+ import ida_loader, ida_pro # noqa: WPS433
+ ida_loader.save_database(idc.get_idb_path(), 0)
+
+ return MCP_SERVER.tools.methods, module, save
+
+
+def serve(sockpath: str, binpath: str, load_args: str = "") -> None:
+ tools, module, save = _open_and_register(binpath, load_args)
+ sid = uuid.uuid4().hex[:8]
+
+ def dispatch(name: str, args: dict):
+ args = dict(args)
+ args.pop("database", None) # single-DB worker: no session routing
+ # session-management shims (were the supervisor's job):
+ if name in ("idb_open",):
+ return {"success": True,
+ "session": {"session_id": sid, "module": module,
+ "input_path": binpath}}
+ if name in ("idb_save", "save"):
+ save()
+ return {"success": True}
+ if name in ("server_health", "ping", "health", "state"):
+ return {"module": module, "ok": True, "session_id": sid}
+ if name in ("idb_list",):
+ return {"sessions": [{"session_id": sid, "module": module,
+ "input_path": binpath}]}
+ fn = tools.get(name)
+ if fn is None:
+ raise KeyError(f"unknown tool: {name!r}")
+ result = fn(**args)
+ # Match the MCP server's structuredContent: a dict passes through, any
+ # other return (list/scalar) is wrapped as {"result": ...}. domain.py
+ # parses that exact shape (e.g. lookup_funcs -> payload["result"]).
+ return result if isinstance(result, dict) else {"result": result}
+
+ try:
+ os.unlink(sockpath)
+ except OSError:
+ pass
+ srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ srv.bind(sockpath)
+ srv.listen(8)
+ try:
+ while True:
+ conn, _ = srv.accept()
+ try:
+ while True:
+ req = recv(conn)
+ if req is None:
+ break
+ name, args = req
+ if name == "__shutdown__":
+ return
+ try:
+ send(conn, (True, dispatch(name, args)))
+ except Exception as e: # noqa: BLE001 -- report, keep serving
+ send(conn, (False, f"{type(e).__name__}: {e}"))
+ except (ConnectionError, OSError):
+ pass
+ finally:
+ conn.close()
+ finally:
+ try:
+ import idapro
+ idapro.close_database(save=False)
+ except Exception: # noqa: BLE001
+ pass
+ try:
+ os.unlink(sockpath)
+ except OSError:
+ pass
+
+
+def main(argv=None) -> None:
+ argv = argv if argv is not None else sys.argv[1:]
+ if len(argv) < 2:
+ sys.stderr.write(
+ "usage: python -m idatui.worker <sock> <binary> [ida-load-args]\n")
+ raise SystemExit(2)
+ try:
+ serve(argv[0], argv[1], argv[2] if len(argv) > 2 else "")
+ except SystemExit:
+ raise
+ except BaseException as e: # noqa: BLE001 -- surface a clean cause + code 1
+ import traceback
+ sys.stderr.write(f"\nWORKER-FATAL: {type(e).__name__}: {e}\n")
+ traceback.print_exc()
+ sys.stderr.flush()
+ raise SystemExit(1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/idatui/worker_client.py b/idatui/worker_client.py
new file mode 100644
index 0000000..79a6db9
--- /dev/null
+++ b/idatui/worker_client.py
@@ -0,0 +1,234 @@
+"""WorkerClient — a drop-in replacement for ``IDAClient`` backed by our own
+idalib worker (``idatui.worker``) over a unix socket instead of ida-pro-mcp's
+HTTP/JSON transport.
+
+It exposes exactly the surface the app/domain use on the client
+(``call``/``call_envelope``/``connect``/``set_db``/``resolve_db``/
+``list_sessions``/``health``/``keepalive``/``close``) and returns byte-identical
+payloads (the worker calls the same tool functions), so ``domain.py`` and the
+app are unchanged — you just construct a WorkerClient instead of an IDAClient.
+
+Concurrency: the app fires calls from several worker threads over one client;
+the worker is single-threaded, so calls are serialized under a lock (the worker
+processes one tool at a time anyway — and at ~50us/call that's free).
+"""
+from __future__ import annotations
+
+import os
+import socket
+import subprocess
+import sys
+import threading
+import time
+import uuid
+from typing import Any
+
+from .errors import IDAToolError, IDAConnectionError, Session
+from .worker import recv as _recv
+from .worker import send as _send
+
+_WORKER_PY = os.path.join(os.path.dirname(os.path.abspath(__file__)), "worker.py")
+_worker_python_cache: str | None = None
+
+
+def _find_worker_python() -> str:
+ """A python that can import ``ida_pro_mcp`` (and thus idalib) — NOT necessarily
+ the TUI's python. On a typical box the TUI runs under a venv that has textual
+ + idalib but not ida_pro_mcp, while the system python has idalib +
+ ida_pro_mcp. Override with IDATUI_WORKER_PYTHON."""
+ global _worker_python_cache
+ if _worker_python_cache:
+ return _worker_python_cache
+ override = os.environ.get("IDATUI_WORKER_PYTHON")
+ candidates = [override] if override else []
+ candidates += ["/usr/bin/python", "/usr/bin/python3", sys.executable]
+ for py in candidates:
+ if not py or not os.path.exists(py):
+ continue
+ try:
+ r = subprocess.run([py, "-c", "import ida_pro_mcp"],
+ capture_output=True, timeout=30)
+ if r.returncode == 0:
+ _worker_python_cache = py
+ return py
+ except Exception: # noqa: BLE001
+ continue
+ return sys.executable # last resort; the worker will report the real error
+
+
+class _NoopKeepAlive:
+ """The worker is ours and never idles out, so keepalive is a no-op."""
+
+ def __init__(self) -> None:
+ self.beats = self.failures = 0
+
+ def start(self):
+ return self
+
+ def stop(self) -> None:
+ pass
+
+
+class WorkerClient:
+ def __init__(self, binary_path: str, *, ttl: int = 0,
+ python: str | None = None, load_args: str = "") -> None:
+ self._bin = os.path.abspath(os.path.expanduser(binary_path))
+ self._load_args = load_args or "" # IDA switches for a headerless blob
+ self._python = python or _find_worker_python()
+ tag = f"{os.getpid()}-{uuid.uuid4().hex[:8]}"
+ self._sock_path = f"/tmp/idatui-worker-{tag}.sock"
+ self._log_path = f"/tmp/idatui-worker-{tag}.log"
+ self._proc: subprocess.Popen | None = None
+ self._sock: socket.socket | None = None
+ self._sid = uuid.uuid4().hex[:8]
+ self._lock = threading.Lock() # serialize socket use
+ self._spawn_lock = threading.Lock()
+
+ # -- lifecycle --------------------------------------------------------- #
+ def connect(self, timeout: float = 1800.0, progress=None) -> "WorkerClient":
+ """Spawn the worker (opens + analyzes the DB) and connect once ready."""
+ with self._spawn_lock:
+ if self._sock is not None:
+ return self
+ if self._proc is None or self._proc.poll() is not None:
+ # run worker.py as a SCRIPT (not -m idatui.worker) so we don't
+ # import the textual-dependent idatui package __init__ under the
+ # IDA python, which usually has no textual.
+ argv = [self._python, _WORKER_PY, self._sock_path, self._bin]
+ if self._load_args:
+ argv.append(self._load_args)
+ self._proc = subprocess.Popen(
+ argv,
+ stdout=open(self._log_path, "wb"),
+ stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
+ )
+ deadline = time.time() + timeout
+ t0 = time.time()
+ while time.time() < deadline:
+ try:
+ s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ s.connect(self._sock_path)
+ self._sock = s
+ return self
+ except OSError:
+ if self._proc.poll() is not None:
+ raise IDAConnectionError(
+ f"worker exited (code {self._proc.returncode}): "
+ f"{self._log_tail()} [full log: {self._log_path}]")
+ if progress:
+ progress(f"auto-analyzing {os.path.basename(self._bin)}… "
+ f"({int(time.time() - t0)}s)")
+ time.sleep(0.2)
+ raise IDAConnectionError("worker did not become ready in time")
+
+ @property
+ def pid(self) -> int | None:
+ """The worker process id (for memory accounting), or None if not spawned."""
+ return self._proc.pid if self._proc is not None else None
+
+ def close(self, grace: float = 20.0) -> None:
+ """Shut the worker down cleanly.
+
+ After ``__shutdown__`` the worker still has to ``close_database()``, which
+ re-packs the ``.i64`` and removes the unpacked ``.id0/.id1/...`` scratch.
+ Signalling it before that finishes is what leaves databases wedged, so
+ wait out the grace period first and only escalate if it really is stuck.
+ """
+ with self._lock:
+ s = self._sock
+ self._sock = None
+ if s is not None:
+ try:
+ _send(s, ("__shutdown__", {}))
+ except Exception: # noqa: BLE001
+ pass
+ try:
+ s.close()
+ except Exception: # noqa: BLE001
+ pass
+ if self._proc is not None:
+ try:
+ self._proc.wait(timeout=grace) # let it close the DB properly
+ except Exception: # noqa: BLE001 -- TimeoutExpired: it's stuck
+ try:
+ self._proc.terminate()
+ self._proc.wait(timeout=5)
+ except Exception: # noqa: BLE001
+ try:
+ self._proc.kill()
+ except Exception: # noqa: BLE001
+ pass
+
+ # -- the call surface -------------------------------------------------- #
+ def call(self, tool: str, *, timeout: float | None = None, **args) -> Any:
+ if self._sock is None:
+ self.connect()
+ with self._lock:
+ s = self._sock
+ if s is None:
+ raise IDAConnectionError("worker connection is closed")
+ try:
+ _send(s, (tool, args))
+ reply = _recv(s)
+ except (OSError, ConnectionError) as e:
+ self._sock = None
+ raise IDAConnectionError(f"worker transport failed: {e}") from e
+ if reply is None:
+ self._sock = None
+ raise IDAConnectionError("worker closed the connection")
+ ok, payload = reply
+ if not ok:
+ raise IDAToolError(tool, str(payload))
+ return payload
+
+ def call_envelope(self, tool: str, *, timeout: float | None = None,
+ **args) -> dict:
+ # domain.decompile() reads result.structuredContent — mirror that shape.
+ return {"result": {"structuredContent": self.call(tool, timeout=timeout,
+ **args)}}
+
+ # -- session shims (single-DB worker) --------------------------------- #
+ def set_db(self, db: str | None) -> None:
+ if db:
+ self._sid = db
+
+ def resolve_db(self) -> str:
+ return self._sid
+
+ def list_sessions(self) -> list[Session]:
+ return [Session(session_id=self._sid,
+ filename=os.path.basename(self._bin),
+ input_path=self._bin, is_active=True)]
+
+ def health(self) -> dict:
+ try:
+ return self.call("server_health")
+ except IDAToolError:
+ return {"module": os.path.basename(self._bin), "ok": True}
+
+ def keepalive(self, interval: float = 120.0) -> _NoopKeepAlive:
+ return _NoopKeepAlive()
+
+ def _log_tail(self, n: int = 400) -> str:
+ """Last meaningful line(s) of the worker log (skip IDA's licence banner),
+ so a startup crash surfaces the real cause instead of just 'code 1'."""
+ try:
+ with open(self._log_path, encoding="utf-8", errors="replace") as f:
+ lines = [ln.strip() for ln in f if ln.strip()]
+ except OSError:
+ return "(no worker log)"
+ # the worker prints a clean 'WORKER-FATAL: ...' line on a startup crash
+ for ln in reversed(lines):
+ if ln.startswith("WORKER-FATAL:"):
+ return ln[len("WORKER-FATAL:"):].strip()[-n:]
+ skip = ("thank you", "licensed to", "[mcp]", "ida ", "hex-rays")
+ meaningful = [ln for ln in lines
+ if not any(s in ln.lower() for s in skip)]
+ return " | ".join((meaningful or lines)[-3:])[-n:]
+
+ # context manager parity with IDAClient
+ def __enter__(self) -> "WorkerClient":
+ return self.connect()
+
+ def __exit__(self, *exc) -> None:
+ self.close()