aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-07-23 17:23:16 +0200
committerblasty <blasty@local>2026-07-23 17:23:16 +0200
commit0c097f543a0fd1437189796e69484992604cf8ca (patch)
treed6531de3dc4d4f842d941f7b748d15ec1ca1bc29
parentlisting: F5/Tab decompiles the function under the cursor (IDA-style) — step 2 (diff)
downloadida-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.py241
-rw-r--r--idatui/domain.py26
-rw-r--r--idatui/rpc.py6
-rw-r--r--tests/test_scenarios.py240
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}")