diff options
| author | blasty <blasty@local> | 2026-07-26 15:38:06 +0200 |
|---|---|---|
| committer | blasty <blasty@local> | 2026-07-26 15:38:06 +0200 |
| commit | fb275522b143d3891f5f8e9b1e6eaa635537ec15 (patch) | |
| tree | 28dc600876269e32758aa7429ca492e779ca1cc1 | |
| parent | app: rebuild the function index after an edit changes it (diff) | |
| download | ida-tui-fb275522b143d3891f5f8e9b1e6eaa635537ec15.tar.gz ida-tui-fb275522b143d3891f5f8e9b1e6eaa635537ec15.tar.xz ida-tui-fb275522b143d3891f5f8e9b1e6eaa635537ec15.zip | |
decomp: say WHY a function won't decompile
"I can't decompile my freshly defined function" — and the app's answer was to
flash and go back to the listing. It knew the reason and threw it away twice.
The plain decompile tool reports "Decompilation failed at 0x0 (address: 0x0)".
Hex-Rays itself fills in a hexrays_failure_t with the actual sentence, and in
this case it is the whole answer: "only 64-bit functions can be decompiled in
the current database". Nobody can guess that from a flash, and it is not fixable
in place — the database's bitness is set at load — so without the message there
is no way forward at all.
New decomp_error tool returns Hex-Rays' own description; _load_decomp asks for it
in the same worker when a decompile fails, and the status now reads:
sub_0: cannot decompile — only 64-bit functions can be decompiled in the
current database — Ctrl+L and pick arm:ARMv7-A
Then it got thrown away a second time, by the reload. Falling back to the
listing reopens it, and the reload writes its own status afterwards — the same
clobber that has now bitten four times. The fix this round is the last one:
_flash is SHOWN by idle status writes but no longer CONSUMED by them, because a
reload emits several (prime, then cursor) and consuming on the first meant the
second erased the message. It clears on the next keypress instead — when the
user has actually moved on.
(That also fixes a self-inflicted "status: None": the old code read _flash back
after something else had already consumed it.)
tests: +2 thumb (15) — a failed decompile says why in Hex-Rays' words, and the
reason survives the view reloading under it. 209/0 scenarios, 30/0 blob, 30/0
project UI.
| -rw-r--r-- | idatui/app.py | 45 | ||||
| -rw-r--r-- | idatui/domain.py | 14 | ||||
| -rw-r--r-- | server/patch_server.py | 41 | ||||
| -rw-r--r-- | tests/test_thumb_ui.py | 20 |
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: |
