aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-07-09 23:28:37 +0200
committerblasty <blasty@local>2026-07-09 23:28:37 +0200
commit0c738bbcf00cf329847d9409f9639a84adeeced0 (patch)
tree94f2068e45f3809eeb34d3a20675b88a7794c697
parentdefault to the pseudocode view instead of disassembly (diff)
downloadida-tui-0c738bbcf00cf329847d9409f9639a84adeeced0.tar.gz
ida-tui-0c738bbcf00cf329847d9409f9639a84adeeced0.tar.xz
ida-tui-0c738bbcf00cf329847d9409f9639a84adeeced0.zip
decompiler: fall back to disassembly when decompilation fails
Opening a function that Hex-Rays can't decompile (import/PLT stubs, mis-analyzed monsters) now shows the disassembly instead of a '/* decompilation failed */' panel. A separate _pref (the preferred view, set by Tab) is kept, so only the failing function falls back — the next decompilable function still opens as pseudocode. _apply_decomp ignores a stale result if you've navigated away. Pilot checks: failing func -> disasm (pref kept), next func -> pseudocode. full suite 77/77.
-rw-r--r--idatui/app.py28
-rw-r--r--tests/test_tui.py35
2 files changed, 51 insertions, 12 deletions
diff --git a/idatui/app.py b/idatui/app.py
index 41e7e08..c87c27d 100644
--- a/idatui/app.py
+++ b/idatui/app.py
@@ -1232,7 +1232,8 @@ class IdaTui(App):
self._filter_timer = None
self._sort_col = 0 # 0=addr, 1=name, 2=size
self._sort_reverse = False
- self._active = "decomp" # default code view (or "disasm")
+ self._pref = "decomp" # preferred code view (changed by Tab)
+ self._active = "decomp" # currently shown view (falls back on decomp fail)
self._cur: NavEntry | None = None
self._search_ctx: tuple[object | None, int] = (None, 1)
self._rename_ctx: tuple[object | None, str] = (None, "")
@@ -1463,6 +1464,7 @@ class IdaTui(App):
if self._cur is None:
return
self._active = "decomp" if self._active == "disasm" else "disasm"
+ self._pref = self._active # an explicit toggle sets the preference
self._show_active()
def action_filter(self) -> None:
@@ -2130,6 +2132,9 @@ class IdaTui(App):
if self.program is None:
return
self._cur = entry
+ # Each open honours the preferred view; a decomp failure last time fell
+ # back to disasm without changing the preference, so retry decomp here.
+ self._active = self._pref
model = self.program.disasm(entry.ea, entry.name)
sy = entry.scroll_y if entry.scroll_y >= 0 else None
self.query_one(DisasmView).load(
@@ -2176,6 +2181,17 @@ class IdaTui(App):
def _apply_decomp(self, ea: int, name: str, dec) -> None: # type: ignore[no-untyped-def]
view = self.query_one(DecompView)
view.loading = False
+ if dec.failed:
+ # No pseudocode for this function: fall back to the disassembly view
+ # rather than showing an error panel. Keep the pseudocode preference
+ # so the next (decompilable) function still opens as pseudocode.
+ if self._cur is None or self._cur.ea != ea:
+ return # navigated away; stale result
+ self._active = "disasm"
+ 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
# Restore the saved pseudocode position when returning to this function.
@@ -2185,13 +2201,9 @@ class IdaTui(App):
cx = cur.dec_cursor_x if same else 0
sy = cur.dec_scroll_y if (same and cur.dec_scroll_y >= 0) else -1
sx = cur.dec_scroll_x if same else 0
- if dec.failed:
- view.show(ea, f"/* decompilation failed at {ea:#x}: {dec.error} */\n")
- self._status(f"{name} — decompile failed; Tab for disasm")
- else:
- note = " (truncated)" if dec.truncated else ""
- view.show(ea, dec.code or "", cursor=c, cursor_x=cx, scroll_y=sy, scroll_x=sx)
- self._status(f"{name} @ {ea:#x} [pseudocode {len(dec.code or '')} chars]{note}")
+ note = " (truncated)" if dec.truncated else ""
+ view.show(ea, dec.code or "", cursor=c, cursor_x=cx, scroll_y=sy, scroll_x=sx)
+ self._status(f"{name} @ {ea:#x} [pseudocode {len(dec.code or '')} chars]{note}")
def on_decomp_view_cursor_moved(self, msg: DecompView.CursorMoved) -> None:
if self._nav:
diff --git a/tests/test_tui.py b/tests/test_tui.py
index 044aabd..47a0e3e 100644
--- a/tests/test_tui.py
+++ b/tests/test_tui.py
@@ -117,6 +117,32 @@ async def run(db):
await wait_until(pilot, lambda: not isinstance(app.screen, SymbolPalette), 10)
check("Esc closes the palette", not isinstance(app.screen, SymbolPalette))
+ # Decompile-failure fallback: a function that can't be decompiled opens
+ # in disassembly (not an error panel), and the pseudocode preference is
+ # kept so the next decompilable function still opens as pseudocode.
+ # (Import/PLT stubs at high addresses fail fast; scan from the top.)
+ d_view = app.query_one(DisasmView)
+ c_view = app.query_one(DecompView)
+ failing = next((f for f in reversed(app.program.functions().all_loaded())
+ if app.program.decompile(f.addr).failed), None)
+ if failing is not None and app._pref == "decomp":
+ app._goto(hex(failing.addr))
+ await wait_until(pilot, lambda: app._cur and app._cur.ea == failing.addr, 20)
+ await wait_until(pilot, lambda: app._active == "disasm", 20)
+ 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 wait_until(pilot, lambda: app._cur and app._cur.name == "main", 20)
+ await wait_until(pilot, lambda: app._active == "decomp" and c_view.display, 25)
+ check("preference preserved: next function opens as pseudocode",
+ app._active == "decomp" and c_view.display, f"active={app._active}")
+ else:
+ check("found a decompile-failing function", failing is not None,
+ f"pref={app._pref}")
+
# Open the biggest function we can find (scan a sample of rows for size).
# Simpler: select the row whose Size column is largest among first N.
biggest_i, biggest_sz = 0, -1
@@ -430,10 +456,10 @@ async def run(db):
if be < e <= xref.frm:
be, dexp = e, i
if dexp >= 0:
+ app._pref = "decomp" # navigation honours the preferred view
app._open_function(xfn.addr, xfn.name) # start elsewhere
- await wait_until(pilot, lambda: app._cur.ea == xfn.addr, 20)
- app._active = "decomp"
- app._show_active()
+ await wait_until(pilot, lambda: app._cur.ea == xfn.addr
+ and app._active == "decomp", 20)
await wait_until(pilot, lambda: dec.loaded_ea == xfn.addr, 25)
# xfn is the referenced function; its name is the token that
# appears at the call site the xref points to.
@@ -472,7 +498,8 @@ async def run(db):
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._active = "disasm" # restore for the following blocks
+ app._pref = "disasm" # restore for the following blocks
+ app._active = "disasm"
app._show_active()
await pilot.pause(0.05)