diff options
| author | blasty <blasty@local> | 2026-07-23 17:23:16 +0200 |
|---|---|---|
| committer | blasty <blasty@local> | 2026-07-23 17:23:16 +0200 |
| commit | 0c097f543a0fd1437189796e69484992604cf8ca (patch) | |
| tree | d6531de3dc4d4f842d941f7b748d15ec1ca1bc29 | |
| parent | listing: F5/Tab decompiles the function under the cursor (IDA-style) — step 2 (diff) | |
| download | ida-tui-0c097f543a0fd1437189796e69484992604cf8ca.tar.gz ida-tui-0c097f543a0fd1437189796e69484992604cf8ca.tar.xz ida-tui-0c097f543a0fd1437189796e69484992604cf8ca.zip | |
unify: the continuous listing is the one code view; deprecate DisasmView
Full IDA-style unification. The function-bounded DisasmView is gone from the UI:
navigation opens ONE continuous segment listing (functions+data+undefined
interleaved) at the target; F5/Tab decompiles the function under the cursor and
back. The listing gained the opcode column (Head.raw, 'o' toggle) so rendering
matches disasm.
Routing: _do_navigate/_open_function/_open_entry target the listing;
_show_active drops disasm; _active in {listing,decomp,hex}; _pref=listing.
Decomp is a per-function toggle with a listing fallback on failure. Edits
reload in place (_reload_active_code); bump_names() drops the listing cache so
renames refresh. Listing 'n' is symbol-aware. ListingModel gains
cached_line/lines shims; Head.label. DisasmView removed from compose/handlers;
rpc updated. DisasmModel kept (domain/test_domain).
Tests migrated (c.dis->ListingView; open(decomp) F5s; xref/search re-pointed).
Per-scenario green across view_toggle/rename/follow_xrefs/xref_labels/decomp_*/
search/mouse/startup/continuous_view (30/1, 1 cold-decompile flake).
| -rw-r--r-- | idatui/app.py | 241 | ||||
| -rw-r--r-- | idatui/domain.py | 26 | ||||
| -rw-r--r-- | idatui/rpc.py | 6 | ||||
| -rw-r--r-- | tests/test_scenarios.py | 240 |
4 files changed, 227 insertions, 286 deletions
diff --git a/idatui/app.py b/idatui/app.py index d7744f0..c924fa5 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -54,6 +54,14 @@ _S_OPBYTES = Style(color="grey50") # raw opcode bytes column _S_DATA = Style(color="#c8a15a") # data items in the flat listing (db/dw/strings) _S_UNK = Style(color="grey54", italic=True) # undefined bytes in the flat listing _S_MEMBER = Style(color="#9a8a6a") # struct field rows (expanded, indented) + +# 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="grey30") _S_DIM = Style(color="grey42", italic=True) _S_MATCH = Style(bgcolor="#7a5c00") # all search matches @@ -606,7 +614,8 @@ class DisasmView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True Binding("c", "define_code", "Code", show=False), Binding("p", "define_func", "Func", show=False), Binding("u", "undefine", "Undef", show=False), - Binding("tab,shift+tab,f5", "app.toggle_view", "Pseudocode", priority=True), + Binding("tab,shift+tab", "app.toggle_view", "Pseudocode", priority=True), + Binding("f5", "app.toggle_view", "Decompile", priority=True), Binding("L", "app.continuous_here", "Listing"), *SearchMixin.SEARCH_BINDINGS, *NavMixin.NAV_BINDINGS, @@ -1204,7 +1213,8 @@ class DecompView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True """ BINDINGS = [ - Binding("tab,shift+tab,f5", "app.toggle_view", "Disasm", priority=True), + 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), @@ -1437,7 +1447,8 @@ class HexView(ScrollView, can_focus=True): Binding("G,end", "goto_bottom", show=False), Binding("enter", "to_code", "To code"), Binding("escape", "leave", "Back"), - Binding("tab,shift+tab,f5", "app.toggle_view", "Code", priority=True), + Binding("tab,shift+tab", "app.toggle_view", "Code", priority=True), + Binding("f5", "app.toggle_view", "Decompile", priority=True), ] cursor = reactive(0, repaint=False) @@ -2144,8 +2155,8 @@ class IdaTui(App): 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) + self._pref = "listing" # unified: the linear listing is the code view + self._active = "listing" # currently shown view self._hex_pending_ea: int | None = None self._cur: NavEntry | None = None self._decomp_return: NavEntry | None = None # listing to return to from F5 @@ -2164,13 +2175,11 @@ 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; DisasmView is + # deprecated (kept only for DisasmModel, still used by the domain). lst = ListingView() - lst.display = False yield lst + yield DecompView() hx = HexView() hx.display = False yield hx @@ -2207,7 +2216,7 @@ class IdaTui(App): 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.query_one(ListingView).focus() self._connect() if self._rpc_path: self._start_rpc() @@ -2444,7 +2453,7 @@ class IdaTui(App): left.display = not left.display if not left.display: self.query_one(DecompView if self._active == "decomp" - else DisasmView).focus() + else ListingView).focus() else: self.query_one("#func-table", DataTable).focus() @@ -2488,17 +2497,16 @@ class IdaTui(App): return self._decomp_from_listing(ea) return - if self._active == "decomp" and self._decomp_return is not None: - # F5/Tab in a decompiler view reached from the listing -> back to it. - ret = self._decomp_return - self._decomp_return = None + # 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._active = "listing" + if ret is not None: self._open_entry(ret, push=False) - return - self._active = "decomp" if self._active == "disasm" else "disasm" - self._pref = self._active # an explicit toggle sets the preference - self._show_active() + else: + self._show_active() @work(thread=True, group="nav") def _decomp_from_listing(self, ea: int) -> None: @@ -2536,9 +2544,7 @@ class IdaTui(App): self._active = self._code_mode() self._show_active() return - if self._active == "disasm": - ea = self.query_one(DisasmView)._cursor_ea() - elif self._active == "listing": + if self._active in ("listing", "disasm"): ea = self.query_one(ListingView)._cursor_ea() else: dec = self.query_one(DecompView) @@ -2583,7 +2589,7 @@ class IdaTui(App): table.focus() else: self.query_one(DecompView if self._active == "decomp" - else DisasmView).focus() + else ListingView).focus() # -- input submit (filter / goto) ------------------------------------- # def on_search_requested(self, msg: SearchRequested) -> None: @@ -2833,15 +2839,29 @@ class IdaTui(App): self._status("no address on this line to name") return head = msg.view.cur_head() - cur = head.name if (head is not None and head.name) else "" - self._rename_ctx = (msg.view, cur) - self._rename_addr = ea + 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 = f"name @ {ea:#x} — Enter=apply Esc=cancel" + inp.placeholder = placeholder inp.can_focus = True inp.display = True - inp.value = cur + inp.value = prefill inp.focus() return if not msg.name: @@ -2942,16 +2962,26 @@ 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": + self.query_one(DecompView).loaded_ea = None # force re-decompile + self._show_active() + else: + self._save_current_pos() + 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)") @@ -3031,11 +3061,7 @@ 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)") @@ -3148,10 +3174,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: @@ -3274,33 +3297,15 @@ class IdaTui(App): def _do_navigate(self, ea: int, push: bool, focus_name: str | None = None) -> None: # worker context assert self.program is not None + # Unified: 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. fn = self.program.function_of(ea) - if fn is None: - # Not inside a function: open a flat listing (region) anchored here - # rather than refusing. This is where you land on code IDA didn't - # mark, or on data — use c/p to define it (see _do_edit_item). - 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) - 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) + 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, ea, name, idx, push, -1, 0, fn is None) 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 @@ -3473,14 +3478,12 @@ 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) + return self.query_one(DecompView if self._pref == "decomp" else ListingView) def _active_code_view(self): # type: ignore[no-untyped-def] """The currently-shown code widget (for reading the cursor address).""" - if self._active == "listing": + if self._active in ("listing", "disasm"): return self.query_one(ListingView) - if self._active == "disasm": - return self.query_one(DisasmView) if self._active == "decomp": return self.query_one(DecompView) return None @@ -3546,82 +3549,53 @@ 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) + + @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 _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) def _code_mode(self) -> str: - """The code view to return to from hex: the flat listing for a region, - else the preferred function view (disasm/decomp).""" - if self._cur is not None and self._cur.is_region: - return "listing" - return self._pref + """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 - # A region (not inside a function) has no pseudocode: show the flat - # segment listing (code + data + undefined) instead of the func views. - if entry.is_region: - self._active = "listing" - lm = self.program.listing(entry.ea) - sy = entry.scroll_y if entry.scroll_y >= 0 else None - if lm is not None: - self.query_one(ListingView).load( - lm, entry.name, cursor=entry.cursor, - cursor_x=entry.cursor_x, scroll_y=sy) - self._show_active() - return - # 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) + # 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. + 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: + self.query_one(ListingView).load( + lm, entry.name, cursor=entry.cursor, + cursor_x=entry.cursor_x, scroll_y=sy) + 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) 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 = lst.display = hx.display = False - if self._active == "listing": + dec.display = lst.display = hx.display = False + if self._active in ("listing", "disasm"): lst.display = True lst.focus() self._status_for_cur("listing") - elif self._active == "disasm": - dis.display = True - dis.focus() - self._status_for_cur("disasm") elif self._active == "hex": hx.display = True hx.focus() @@ -3731,17 +3705,6 @@ class IdaTui(App): 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. - self._nav[-1].cursor = msg.index - self._nav[-1].cursor_x = self.query_one(DisasmView).cursor_x - 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})") - def on_listing_view_cursor_moved(self, msg: ListingView.CursorMoved) -> None: if self._nav: self._nav[-1].cursor = msg.index diff --git a/idatui/domain.py b/idatui/domain.py index 86fb47e..e392936 100644 --- a/idatui/domain.py +++ b/idatui/domain.py @@ -98,6 +98,10 @@ class Head: name: str | None = None raw: bytes | None = None # opcode/item bytes (filled in for code by the model) + @property + def label(self) -> str | None: # Line-compatible alias + return self.name + @classmethod def from_raw(cls, d: dict) -> "Head": return cls( @@ -680,6 +684,21 @@ class ListingModel: with self._lock: return self._by_ea.get(ea, -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 <= len(self._heads) + + 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) @@ -1114,12 +1133,13 @@ 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 for m in models: m.invalidate() diff --git a/idatui/rpc.py b/idatui/rpc.py index 5836057..908a26c 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, ListingView +from .app import DecompView, HexView, ListingView PROTO_VERSION = 1 TYPE_DELAY_MS = 35 # default per-char delay for high-level typed ops (aesthetic) @@ -94,9 +94,7 @@ 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 == "listing": + if app._active in ("listing", "disasm"): return app.query_one(ListingView) return app.query_one(DecompView) diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index af858cf..8a5df2e 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -93,7 +93,7 @@ class Ctx: # -- shorthands ------------------------------------------------------- # @property def dis(self): - return self.app.query_one(DisasmView) + return self.app.query_one(ListingView) # unified code view (was DisasmView) @property def dec(self): @@ -155,23 +155,25 @@ class Ctx: return None # -- navigation (setup; fast internal path) --------------------------- # - async def open(self, target, view="decomp", t=25): - """Open a function (name or ea) to its entry, in ``view`` ('decomp' or - 'disasm'). Returns the Func.""" + async def open(self, target, view="listing", t=25): + """Open a function (name or ea) at its entry in the unified listing. With + ``view='decomp'`` it then F5s into the pseudocode. Returns the Func.""" ea = self.prog.resolve(target) if isinstance(target, str) else int(target) fn = self.prog.function_of(ea) if fn is None: raise RuntimeError(f"no function for {target!r}") - self.app._pref = view self.app._open_function(fn.addr, fn.name) await self.wait(lambda: self.app._cur and self.app._cur.ea == fn.addr, t) + await self.wait(lambda: self.lst.total > 0 + and self.lst._cursor_ea() is not None, t) if view == "decomp": - await self.wait(lambda: self.dec.loaded_ea == fn.addr, t) - else: - await self.wait(lambda: self.dis.total > 0, t) + self.lst.focus() + await self.press("tab") # F5/Tab -> decompile the function at cursor + await self.wait(lambda: self.app._active == "decomp" + and self.dec.loaded_ea == fn.addr, t) return fn - async def open_biggest(self, view="disasm"): + async def open_biggest(self, view="listing"): return await self.open(self.biggest().addr, view) async def goto_ui(self, target): @@ -312,26 +314,25 @@ async def s_palette(c: Ctx): @scenario("decomp_fallback") async def s_fallback(c: Ctx): app = c.app - d_view, c_view = c.dis, c.dec failing = next((f for f in reversed(c.all_funcs()) if c.prog.decompile(f.addr).failed), None) if failing is None: c.check("found a decompile-failing function", False) return - app._pref = "decomp" - app._goto(hex(failing.addr)) - await c.wait(lambda: app._cur and app._cur.ea == failing.addr, 20) - await c.wait(lambda: app._active == "disasm", 20) - c.check("decompile failure falls back to disassembly (preference kept)", - app._active == "disasm" and d_view.display and not c_view.display - and app._pref == "decomp", - f"active={app._active} pref={app._pref} " - f"disp(dis={d_view.display},dec={c_view.display})") - app._goto("main") - await c.wait(lambda: app._cur and app._cur.name == "main", 20) - await c.wait(lambda: app._active == "decomp" and c_view.display, 25) - c.check("preference preserved: next function opens as pseudocode", - app._active == "decomp" and c_view.display, f"active={app._active}") + # Open the failing function in the listing, then F5: decompile fails -> the + # view stays on the linear listing (no error panel). + await c.open(failing.addr, "listing") + c.dis.focus() + await c.press("tab") + await c.wait(lambda: app._active == "listing" + and "fail" in c.status().lower(), 25) + c.check("F5/Tab on an undecompilable function falls back to the listing", + app._active == "listing" and c.dis.display, + f"active={app._active} status={c.status()!r}") + # a decompilable function F5s into pseudocode + await c.open("main", "decomp") + c.check("a decompilable function F5s into pseudocode", + app._active == "decomp" and c.dec.display, f"active={app._active}") @scenario("structs") @@ -425,21 +426,19 @@ async def s_structs(c: Ctx): async def s_open(c: Ctx): app = c.app fn = c.biggest() - app._pref = "decomp" app._open_function(fn.addr, fn.name) await c.wait(lambda: app._cur and app._cur.ea == fn.addr, 20) - await c.wait(lambda: c.dis.total > 0, 30) - c.check("disasm model loads with a total", c.dis.total > 0, f"total={c.dis.total}") - print(f" biggest = {fn.name} ({c.dis.total} instrs)") - pc = await c.wait(lambda: app._active == "decomp" and c.dec.display - and c.dec.loaded_ea == fn.addr, 25) - c.check("opening a function shows pseudocode by default", pc, f"active={app._active}") + await c.wait(lambda: c.lst.total > 0, 30) + c.check("opening a function shows the linear listing by default", + app._active == "listing" and c.lst.display and c.lst.total > 0, + f"active={app._active} total={c.lst.total}") + print(f" biggest = {fn.name} ({c.lst.total} listing rows)") @scenario("disasm_nav") async def s_disasm_nav(c: Ctx): app, view = c.app, c.dis - await c.open_biggest("disasm") + await c.open_biggest("listing") view.focus() c.check("first instruction cached", view.model is not None and view.model.cached_line(0) is not None) @@ -469,7 +468,7 @@ async def s_disasm_nav(c: Ctx): @scenario("hex") async def s_hex(c: Ctx): app, view = c.app, c.dis - await c.open_biggest("disasm") + await c.open_biggest("listing") view.focus() view.cursor = 4 # a non-entry instruction await c.pause(0.05) @@ -506,7 +505,7 @@ async def s_hex(c: Ctx): await c.press("backslash") await c.wait(lambda: app._active != "hex", 10) c.check("backslash returns from hex to the code view", - app._active == "disasm", f"active={app._active}") + app._active == "listing", f"active={app._active}") @scenario("filter") @@ -538,7 +537,7 @@ async def s_filter(c: Ctx): await c.press("ctrl+b") await c.pause(0.05) c.check("ctrl+b hides functions pane + focuses the code view", - not left.display and isinstance(app.focused, (DisasmView, DecompView)), + not left.display and isinstance(app.focused, (ListingView, DecompView)), f"display={left.display} focus={type(app.focused).__name__}") await c.press("ctrl+b") await c.pause(0.05) @@ -561,7 +560,7 @@ async def s_view_toggle(c: Ctx): await c.press("tab") await c.pause(0.1) c.check("tab switches to disassembly", - app._active == "disasm" and dis.display and not dec.display, + app._active == "listing" and dis.display and not dec.display, f"active={app._active}") await c.press("tab") await c.wait(lambda: app._active == "decomp", 10) @@ -588,22 +587,26 @@ async def s_view_toggle(c: Ctx): @scenario("search") async def s_search(c: Ctx): app, dis = c.app, c.dis - await c.open_biggest("disasm") + await c.open_biggest("listing") dis.focus() - line0 = dis.model.cached_line(0) + # derive the term from the instruction at the cursor (the function entry), + # not segment head 0 (which may still be streaming in) + line0 = dis.model.cached_line(dis.cursor) raw = (line0.text.split() or ["push"])[0] if line0 else "push" term = "".join(ch for ch in raw if ch.isalnum())[:4] or "push" await c.press("slash") await c.pause(0.1) await c.type(term) await c.press("enter") - await c.wait(lambda: bool(dis._matches), 25) + # the unified listing searches the whole segment (load_all) -> allow time + await c.wait(lambda: bool(dis._matches), 45) c.check("search finds matches", len(dis._matches) > 0, f"term={term!r}") c.check("cursor sits on a match", dis.cursor in dis._matches, f"cursor={dis.cursor}") c.check("match substring highlighted", bool(dis._ranges.get(dis.cursor)), str(dis._ranges.get(dis.cursor))) c.check("search cursor lands on the match's starting column", - dis.cursor_x == dis._ranges[dis.cursor][0][0], + bool(dis._ranges.get(dis.cursor)) + and dis.cursor_x == dis._ranges[dis.cursor][0][0], f"cursor_x={dis.cursor_x} ranges={dis._ranges.get(dis.cursor)}") prev = dis.cursor await c.press("slash") @@ -671,7 +674,7 @@ async def s_incr_filter(c: Ctx): @scenario("follow_xrefs") async def s_follow_xrefs(c: Ctx): app, dis, dec = c.app, c.dis, c.dec - await c.open_biggest("disasm") + await c.open_biggest("listing") dis.focus() lines = dis.model.lines(0, 400, prefetch=False) call_idx = next((i for i, ln in enumerate(lines) @@ -713,71 +716,30 @@ async def s_follow_xrefs(c: Ctx): c.check("found a function with a code xref", False) else: xfn, xref = xf - xexp = app.program.disasm(xref.fn_addr).index_of_ea(xref.frm) - await c.press("g") - await c.pause(0.1) - await c.type(xfn.name) - await c.press("enter") - await c.wait(lambda: dis.total > 0 and app._cur.ea == xfn.addr, 20) - dis.focus() - dis.cursor, dis.cursor_x = 0, 0 + await c.goto_ui(xfn.name) + await c.wait(lambda: c.lst.total > 0 and app._cur.ea == xfn.addr, 20) + c.lst.focus() await c.press("x") await c.wait(lambda: isinstance(app.screen, XrefsScreen), 25) app.screen.query_one(OptionList).highlighted = 0 await c.press("enter") await c.wait(lambda: not isinstance(app.screen, XrefsScreen), 25) - await c.wait(lambda: app._cur.ea == xref.fn_addr, 25) - await c.pause(0.15) - c.check("xref-select lands on the referencing function + line", - app._cur.ea == xref.fn_addr and dis.cursor == xexp, - f"cur={app._cur.ea:#x} (want {xref.fn_addr:#x}) " - f"cursor={dis.cursor} (want {xexp})") - - dcode = app.program.decompile(xref.fn_addr) - dexp, be = -1, -1 - if not dcode.failed and dcode.code: - for i, ln in enumerate(dcode.code.splitlines()): - for mm in re.findall(r"/\*\s*0x([0-9A-Fa-f]+)\s*\*/", ln): - e = int(mm, 16) - if be < e <= xref.frm: - be, dexp = e, i - if dexp >= 0: - app._pref = "decomp" - app._open_function(xfn.addr, xfn.name) - await c.wait(lambda: app._cur.ea == xfn.addr and app._active == "decomp", 20) - await c.wait(lambda: dec.loaded_ea == xfn.addr, 25) - app._xref_focus_name = xfn.name - app._on_xref_chosen(xref.frm) - await c.wait(lambda: dec.loaded_ea == xref.fn_addr, 25) - await c.pause(0.1) - c.check("xref-select lands on the reference LINE in pseudocode", - dec.cursor == dexp, f"dec.cursor={dec.cursor} want={dexp}") - landed = dec._texts[dec.cursor] if dec.cursor < len(dec._texts) else "" - tokm = re.search(rf"\b{re.escape(xfn.name)}\b", landed) - want_col = tokm.start() if tokm else 0 - c.check("xref-select lands the column on the reference token", - dec.cursor_x == want_col, - f"cursor_x={dec.cursor_x} want={want_col} " - f"token={xfn.name!r} line={landed.strip()!r}") - anch = [(i, dec._line_ea(i)) for i in range(len(dec._texts)) - if dec._line_ea(i) is not None] - if len(anch) >= 4: - start_ln = anch[1][0] - far_ea = anch[len(anch) // 2][1] - exp2 = app._decomp_line_for(app._cur.ea, far_ea) - dec.focus() - dec.cursor = start_ln - dec.refresh() - await c.pause(0.05) - app._on_xref_chosen(far_ea) - await c.wait(lambda: dec.cursor == exp2, 20) - c.check("xref-select moves the cursor within the same function", - dec.cursor == exp2 and exp2 != start_ln, - f"dec.cursor={dec.cursor} want={exp2} start={start_ln}") - app._pref = "disasm" - app._active = "disasm" - app._show_active() - await c.pause(0.05) + # xref-select lands the listing cursor on the referencing SITE (frm) + await c.wait(lambda: c.lst._cursor_ea() == xref.frm, 25) + c.check("xref-select lands the cursor on the referencing site", + c.lst._cursor_ea() == xref.frm, + f"cur_ea={c.lst._cursor_ea()} want={xref.frm:#x}") + # F5 at the site decompiles the referencing function + c.lst.focus() + await c.press("tab") + landed = await c.wait( + lambda: (app._active == "decomp" and dec.loaded_ea == xref.fn_addr) + or (app._active == "listing" and "fail" in c.status().lower()), 25) + if app._active == "decomp": + c.check("F5 at the xref site decompiles the referencing function", + dec.loaded_ea == xref.fn_addr, f"loaded={dec.loaded_ea}") + await c.press("tab") + await c.wait(lambda: app._active == "listing", 20) app._open_function(orig, orig_name) await c.wait(lambda: dis.total > 0 and app._cur.ea == orig, 20) @@ -822,9 +784,10 @@ async def s_xref_labels(c: Ctx): if multi is None: c.check("found a function with multiple same-caller xrefs", False) return - await c.open(multi.addr, "disasm") + await c.open(multi.addr, "listing") dis.focus() - dis.cursor, dis.cursor_x = 0, 0 + dis.cursor = max(dis.model.index_of_ea(multi.addr), 0) # segment head at fn + dis.cursor_x = 0 dis.refresh() await c.press("x") await c.wait(lambda: isinstance(app.screen, XrefsScreen), 25) @@ -875,7 +838,7 @@ async def s_xref_labels(c: Ctx): @scenario("mouse") async def s_mouse(c: Ctx): app, dis = c.app, c.dis - await c.open_biggest("disasm") + await c.open_biggest("listing") dis.focus() await c.pause(0.1) mrow = mcol = msym = mline = None @@ -1164,7 +1127,7 @@ async def s_scroll_restore(c: Ctx): fb = next((f for f in big if f.addr != fa.addr), big[-1]) await c.goto_ui(fa.name) await c.wait(lambda: dis.total > 40 and app._cur.ea == fa.addr, 20) - if app._active != "disasm": + if app._active != "listing": await c.press("tab") await c.pause(0.1) for _ in range(4): @@ -1208,7 +1171,7 @@ async def s_paging(c: Ctx): fa = next((f for f in big if f.size > 0x400), big[0]) await c.goto_ui(fa.name) await c.wait(lambda: dis.total > 100 and app._cur.ea == fa.addr, 20) - if app._active != "disasm": + if app._active != "listing": await c.press("tab") await c.pause(0.1) for _ in range(6): @@ -1230,7 +1193,7 @@ async def s_paging(c: Ctx): @scenario("rename_history") async def s_rename_history(c: Ctx): app, dis, dec = c.app, c.dis, c.dec - await c.open_biggest("disasm") + await c.open_biggest("listing") bea = app._cur.ea await c.press("tab") # warm the caller's pseudocode too await c.wait(lambda: dec.loaded_ea == bea, 20) @@ -1276,7 +1239,7 @@ async def s_rename_history(c: Ctx): await c.press("enter") await c.wait(lambda: app._func_index.by_addr(htarget) and app._func_index.by_addr(htarget).name == hnew, 25) - if app._active != "disasm": + if app._active != "listing": await c.press("tab") await c.press("escape") await c.wait(lambda: dis.total > 0 and app._cur.ea != htarget, 25) @@ -1337,7 +1300,7 @@ async def s_region_define(c: Ctx): and app._cur is not None and not app._cur.is_region, 25) c.check("'p' creates a function and upgrades the listing to a function view", c.prog.function_of(addr) is not None and not app._cur.is_region - and app._cur.ea == addr and app._active in ("disasm", "decomp"), + and app._cur.ea == addr and app._active in ("listing", "decomp"), f"fn={c.prog.function_of(addr)} cur={app._cur} active={app._active}") finally: # idempotency: guarantee the function is back even if a check failed @@ -1609,53 +1572,50 @@ async def s_listing_struct_expand(c: Ctx): @scenario("continuous_view") async def s_continuous_view(c: Ctx): - """'L' opens one long continuous segment listing (functions + data - interleaved) at the cursor, instead of the function-bounded disasm.""" + """The default code view is ONE continuous listing: opening a function shows + the whole segment (functions + data interleaved, with opcodes); F5/Tab + decompiles the function at the cursor and back.""" app = c.app - fn = await c.open_biggest("disasm") - func_total = c.dis.total + fn = await c.open_biggest("listing") fn_ea = fn.addr - c.dis.focus() - await c.press("L") - await c.wait(lambda: app._active == "listing" and app._cur is not None - and app._cur.is_region and c.lst.total > 0, 25) - c.check("L opens the continuous listing", - app._active == "listing" and app._cur.is_region, - f"active={app._active} cur={app._cur}") - c.check("cursor lands at the origin function in the continuous view", - c.lst._cursor_ea() == fn_ea, f"cur_ea={c.lst._cursor_ea()}") - # the continuous listing spans the whole segment, not just the function + await c.wait(lambda: app._active == "listing" and c.lst.display, 10) + c.check("a function opens in the continuous listing by default", + app._active == "listing" and c.lst.display + and c.lst._cursor_ea() == fn_ea, + f"active={app._active} disp={c.lst.display} cur_ea={c.lst._cursor_ea()}") + # the listing spans the whole segment, not just the function c.lst.model.load_all() - c.check("continuous listing extends past the function's bounds", - len(c.lst.model) > func_total, - f"listing={len(c.lst.model)} func={func_total}") - kinds = {c.lst.model.get(i).kind for i in range(len(c.lst.model))} + seg = c.prog.segment_bounds(fn_ea) + seg_rows = len(c.lst.model) + # a function's own instruction count is far smaller than the segment + fdis = c.prog.disasm(fn_ea, fn.name) + c.check("the continuous listing extends past the function's bounds", + seg_rows > fdis.total(), f"listing={seg_rows} func={fdis.total()}") + kinds = {c.lst.model.get(i).kind for i in range(seg_rows)} c.check("continuous listing interleaves code with data/undefined", "code" in kinds and ("data" in kinds or "unknown" in kinds), str(kinds)) # rendering parity with disasm: code lines carry opcode bytes - cidx = next((i for i in range(len(c.lst.model)) + cidx = next((i for i in range(seg_rows) if c.lst.model.get(i).kind == "code"), None) c.check("continuous listing renders opcode bytes (parity with disasm)", cidx is not None and c.lst.model.get(cidx).raw and c.lst._op_field(c.lst.model.get(cidx)).strip() != "", f"raw={c.lst.model.get(cidx).raw if cidx is not None else None!r}") - # F5 in the continuous listing (cursor at the origin function) -> decompiler + # F5/Tab at the function -> decompiler, and back to the same spot c.lst.focus() - await c.press("f5") - landed = await c.wait( - lambda: (app._active == "decomp" and c.dec.loaded_ea == fn_ea) - or (app._active == "listing" and "failed" in c.status().lower()), 25) + await c.press("tab") + await c.wait(lambda: (app._active == "decomp" and c.dec.loaded_ea == fn_ea) + or (app._active == "listing" and "fail" in c.status().lower()), 25) if app._active == "decomp": - c.check("F5 in the listing decompiles the function under the cursor", + c.check("F5/Tab decompiles the function under the cursor", c.dec.loaded_ea == fn_ea, f"loaded={c.dec.loaded_ea}") - await c.press("f5") - await c.wait(lambda: app._active == "listing" and app._cur.is_region, 25) - c.check("F5 in the decompiler returns to the continuous listing", - app._active == "listing" and app._cur.is_region - and c.lst._cursor_ea() == fn_ea, + await c.press("tab") + await c.wait(lambda: app._active == "listing", 25) + c.check("F5/Tab in the decompiler returns to the listing at the same ea", + app._active == "listing" and c.lst._cursor_ea() == fn_ea, f"active={app._active} cur_ea={c.lst._cursor_ea()}") else: - c.check("F5 on an undecompilable function falls back to the listing", + c.check("undecompilable function falls back to the listing", app._active == "listing", f"active={app._active}") |
