aboutsummaryrefslogtreecommitdiffstats
path: root/idatui/app.py
diff options
context:
space:
mode:
Diffstat (limited to 'idatui/app.py')
-rw-r--r--idatui/app.py435
1 files changed, 406 insertions, 29 deletions
diff --git a/idatui/app.py b/idatui/app.py
index ec55911..cc9cfa3 100644
--- a/idatui/app.py
+++ b/idatui/app.py
@@ -91,6 +91,13 @@ _ASM_KEYWORDS = frozenset({
"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
_S_DIM = Style(color="#7c8b9e", italic=True)
_S_MATCH = Style(bgcolor="#7a5c00") # all search matches
_S_MATCH_CUR = Style(bgcolor="#d0a215", color="#12161c") # the current match
@@ -149,6 +156,8 @@ class ViewAnchor:
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
@@ -739,6 +748,7 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru
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,
@@ -774,6 +784,8 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru
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:
@@ -1042,6 +1054,12 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru
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))
@@ -1130,6 +1148,9 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru
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))
@@ -1247,6 +1268,9 @@ class DecompView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True
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]]] = {}
@@ -1380,6 +1404,11 @@ class DecompView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True
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:
@@ -2280,6 +2309,101 @@ class HelpScreen(ModalScreen):
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 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)
+ tl = self.query_one(TraceTimeline)
+ tl.idx = self.idx
+ tl.refresh()
+
+
+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.
@@ -3020,6 +3144,10 @@ class IdaTui(App):
#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-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
@@ -3065,6 +3193,12 @@ class IdaTui(App):
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.
+ 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),
Binding("f1", "help", "Keys", show=False),
Binding("g", "goto", "Goto"),
Binding("slash", "filter", "Filter", show=False),
@@ -3076,7 +3210,7 @@ class IdaTui(App):
def __init__(self, open_path: str | None = None, keepalive: bool = True,
rpc_path: str | None = None, ttl: int = 1800,
- project=None, load_args: str = "") -> None:
+ project=None, load_args: str = "", trace_path: str = "") -> None:
super().__init__()
# Project mode is additive: with no project this is the plain
# single-binary app, unchanged.
@@ -3090,6 +3224,7 @@ class IdaTui(App):
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
# None = teardown wasn't an explicit quit (crash/kill): save defensively.
@@ -3107,6 +3242,12 @@ class IdaTui(App):
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._t = 0 # current timestamp in that trace
self._do_keepalive = keepalive
self._rpc_path = rpc_path
self._rpc = None
@@ -3161,6 +3302,11 @@ class IdaTui(App):
hx = HexView()
hx.display = False
yield hx
+ # 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
@@ -3253,8 +3399,9 @@ class IdaTui(App):
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} functions, "
- f"plus any names and comments you've added")
+ 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)
@@ -3413,12 +3560,37 @@ class IdaTui(App):
await self._rpc.stop()
# -- status helper ----------------------------------------------------- #
- def _status(self, text: str) -> None:
- if self._binary: # project mode: always say which binary you're in
- text = f"[{self._binary}] {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:
@@ -3556,6 +3728,7 @@ class IdaTui(App):
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(
@@ -3578,7 +3751,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)
@@ -3586,18 +3758,20 @@ 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:
@@ -3808,7 +3982,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
@@ -4010,6 +4184,7 @@ class IdaTui(App):
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 ""
@@ -5155,7 +5330,7 @@ class IdaTui(App):
assert self.program is not None
verb = {"code": "defined code", "func": "created function",
"undef": "undefined", "string": "made string",
- "thumb": "switched decoding"}[kind]
+ "thumb": "switched decoding", "thumbscan": "scanned"}[kind]
try:
if kind == "code":
# Keep going until something stops it: one instruction is rarely
@@ -5180,6 +5355,19 @@ class IdaTui(App):
"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
@@ -5202,6 +5390,7 @@ class IdaTui(App):
# 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']}"
@@ -5211,6 +5400,8 @@ class IdaTui(App):
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}")
@@ -5246,9 +5437,190 @@ class IdaTui(App):
written and lost.
"""
self._dirty = True
- self._flash = anchor.flash
if anchor.flash:
- self._status(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
+ self._t = max(0, min(int(idx), t.length - 1))
+ self.query_one(TraceDock).show(t, self._t)
+ self._paint_trail()
+ if follow:
+ self._goto_ea(t.ip(self._t), push=False)
+
+ 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
+ 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:
+ # Cached per function: decomp_map is an RPC and stepping is
+ # interactive, so paying it per keystroke would be felt.
+ try:
+ self._trail_map = self.program.decomp_map(ea)
+ except Exception: # noqa: BLE001
+ self._trail_map = []
+ self._trail_map_ea = ea
+ 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()
+
+ 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 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:
@@ -5526,6 +5898,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:
@@ -5895,10 +6269,6 @@ class IdaTui(App):
self._goto_ea(msg.va, push=True)
def _status_for_cur(self, mode: str) -> None:
- flash, self._flash = self._flash, None
- if flash:
- self._status(flash) # the edit that caused this reload wins
- return
if self._cur is not None:
self._status(f"{self._cur.name} @ {self._cur.ea:#x} [{mode}]")
@@ -5906,12 +6276,22 @@ 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 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:
+ 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.
@@ -5922,13 +6302,17 @@ class IdaTui(App):
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)
- self._status(f"{name} — decompile failed; back to the listing")
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
@@ -6135,13 +6519,6 @@ class IdaTui(App):
return
ea = msg.ea
if ea is not None:
- # A pending flash (the result of an edit that caused this reload)
- # outranks the idle hint: the cursor lands here as part of the
- # reload, so this handler would otherwise always have the last word.
- flash, self._flash = self._flash, None
- if flash:
- self._status(flash)
- return
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)")