aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--idatui/app.py45
-rw-r--r--idatui/domain.py14
-rw-r--r--server/patch_server.py41
-rw-r--r--tests/test_thumb_ui.py20
4 files changed, 108 insertions, 12 deletions
diff --git a/idatui/app.py b/idatui/app.py
index d025ed2..1ea630e 100644
--- a/idatui/app.py
+++ b/idatui/app.py
@@ -5259,7 +5259,7 @@ class IdaTui(App):
self._dirty = True
self._flash = anchor.flash
if anchor.flash:
- self._status(anchor.flash)
+ self._status(anchor.flash) # and again from the reload, via _flash
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
@@ -5565,6 +5565,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:
@@ -5934,9 +5936,12 @@ 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
+ if self._flash:
+ # Shown, NOT consumed. A reload emits several of these (prime, then
+ # cursor), so consuming on the first one meant the second erased the
+ # message the user was meant to read. It clears on the next keypress
+ # instead — i.e. when they've moved on.
+ self._status(self._flash)
return
if self._cur is not None:
self._status(f"{self._cur.name} @ {self._cur.ea:#x} [{mode}]")
@@ -5945,12 +5950,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.
@@ -5961,13 +5976,20 @@ 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._flash = msg
self._open_entry(ret, push=False)
- self._status(f"{name} — decompile failed; back to the listing")
+ self._status(msg)
return
self._active = "disasm"
+ msg = f"{name}: cannot decompile{detail}"
+ self._flash = msg
self._show_active()
- self._status(
- f"{name} — no pseudocode (decompile failed); showing disassembly")
+ self._status(msg)
return
if self._active == "decomp":
view.focus() # loading cover had blurred it; restore focus
@@ -6177,9 +6199,8 @@ class IdaTui(App):
# 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)
+ if self._flash:
+ self._status(self._flash)
return
sec = self.program.section_of(ea) if self.program else None
self._status(f"{sec or '?'} @ {ea:#x} [listing] "
diff --git a/idatui/domain.py b/idatui/domain.py
index 9047d82..a92ea49 100644
--- a/idatui/domain.py
+++ b/idatui/domain.py
@@ -1373,6 +1373,20 @@ class Program:
if res.get("error"):
raise IDAToolError("define_code", f"@ {ea:#x}: {res['error']}")
+ def decomp_error(self, ea: int) -> str:
+ """Hex-Rays' own reason for refusing ``ea``, or "" if it won't say."""
+ try:
+ r = self.client.call("decomp_error", addr=hex(ea))
+ except IDAToolError:
+ return ""
+ if not isinstance(r, dict):
+ return ""
+ reason = str(r.get("reason") or "")
+ if reason and r.get("bitness") == 64 and "64-bit" in reason:
+ # Unfixable in place: the database's bitness is decided at load.
+ reason += " \u2014 Ctrl+L and pick arm:ARMv7-A"
+ return reason
+
def set_thumb(self, ea: int, mode: str = "toggle") -> dict:
"""Switch ARM/Thumb decoding at ``ea``. Returns the resulting state."""
r = self.client.call("set_thumb", addr=hex(ea), mode=mode)
diff --git a/server/patch_server.py b/server/patch_server.py
index ee7600a..2de852f 100644
--- a/server/patch_server.py
+++ b/server/patch_server.py
@@ -1083,6 +1083,47 @@ def define_func_run(
f = idaapi.get_func(ea)
return {"addr": hex(ea), "ok": True, "start": hex(f.start_ea),
"end": hex(f.end_ea), "how": "explicit-end"}
+
+@tool
+@idasync
+def decomp_error(
+ addr: Annotated[str, "Address of the function that failed to decompile"],
+) -> dict:
+ """Why Hex-Rays refused this function, in its own words.
+
+ The plain decompile tool reports "Decompilation failed at 0x0" and drops the
+ reason, which is the only useful part. Hex-Rays fills in a hexrays_failure_t
+ saying things like "only 64-bit functions can be decompiled in the current
+ database" — that one is unfixable in place (the database's bitness is set at
+ load), so a user who can't see it has no way to know they must reload."""
+ import ida_funcs
+ import ida_hexrays
+ import ida_ida
+
+ try:
+ ea = parse_address(addr)
+ except Exception as e:
+ return {"addr": str(addr), "error": str(e)}
+ out = {"addr": hex(ea), "bitness": ida_ida.inf_get_app_bitness()}
+ fn = ida_funcs.get_func(ea)
+ if fn is None:
+ out["reason"] = "no function here"
+ return out
+ try:
+ if not ida_hexrays.init_hexrays_plugin():
+ out["reason"] = "the decompiler is not available for this processor"
+ return out
+ hf = ida_hexrays.hexrays_failure_t()
+ cf = ida_hexrays.decompile_func(fn, hf)
+ if cf is not None:
+ out["reason"] = "" # it decompiles now
+ return out
+ out["reason"] = hf.desc() or f"error {hf.code}"
+ out["code"] = int(hf.code)
+ out["errea"] = hex(hf.errea)
+ except Exception as e: # noqa: BLE001
+ out["reason"] = f"{type(e).__name__}: {e}"
+ return out
'''
SNIPPET = f"{BEGIN}\n{BODY.strip()}\n{END}\n"
diff --git a/tests/test_thumb_ui.py b/tests/test_thumb_ui.py
index 090dc2a..5b6f18e 100644
--- a/tests/test_thumb_ui.py
+++ b/tests/test_thumb_ui.py
@@ -146,6 +146,26 @@ async def run() -> int:
"64-bit" in status and "decompile" in status, status[:120])
check("and names the fix", "ARMv7-A" in status, status[:120])
+ # And if you ignore that and carry on, the failure has to say WHY. The
+ # plain decompile tool reports "Decompilation failed at 0x0" and drops
+ # the reason, which is the only part that tells you what to do — with it
+ # missing, F5 doing nothing is indistinguishable from a bug in the TUI.
+ lst.cursor = lst.model.index_of_ea(0)
+ await pilot.pause(0.2)
+ mp = lst.model
+ await pilot.press("p")
+ await wait(lambda: lst.model is not mp and lst.model is not None, pilot, 60)
+ await wait(lambda: app._func_index is not None
+ and len(app._func_index) > 0, pilot, 60)
+ await pilot.press("tab")
+ await wait(lambda: "cannot decompile" in
+ str(app.query_one("#status", Static).render()), pilot, 90)
+ status = str(app.query_one("#status", Static).render())
+ check("a failed decompile says why, in Hex-Rays' own words",
+ "only 64-bit functions" in status, status[:130])
+ check("and the reason survives the view reloading under it",
+ "cannot decompile" in status, status[:130])
+
# -- the whole point: a 32-bit database decompiles ---------------------- #
for ext in (".i64", ".id0", ".id1", ".id2", ".nam", ".til"):
try: