From 5f70f365fe8acd992fc22c295d0da6a550ee9b44 Mon Sep 17 00:00:00 2001 From: Duncan Ogilvie Date: Tue, 28 Apr 2026 15:40:39 +0200 Subject: [PATCH 1/6] Fix IDA decompiler version detection --- libbs/decompilers/ida/compat.py | 17 ++++++++--------- libbs/decompilers/ida/interface.py | 4 ++-- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/libbs/decompilers/ida/compat.py b/libbs/decompilers/ida/compat.py index b987f55..43ba507 100644 --- a/libbs/decompilers/ida/compat.py +++ b/libbs/decompilers/ida/compat.py @@ -1736,18 +1736,17 @@ def has_older_hexrays_version(): @execute_write def get_decompiler_version() -> typing.Optional[Version]: wait_for_idc_initialization() - try: - _vers = ida_hexrays.get_hexrays_version() - except Exception as e: - _l.critical("Failed to get decompiler version: %s", e) - return None - try: - vers = Version(_vers) - except TypeError: + # init_hexrays_plugin() must succeed before any other ida_hexrays.* call — + # otherwise IDA emits "Hex-Rays Decompiler got called from Python without + # being loaded" warnings (e.g. during early plugin load before Hex-Rays + # finishes wiring up). Returns False if the decompiler is genuinely + # unavailable (headless without license, etc.); the caller should treat + # None as "decompiler unavailable, skip version-gated behavior". + if not ida_hexrays.init_hexrays_plugin(): return None - return vers + return Version(ida_hexrays.get_hexrays_version()) # diff --git a/libbs/decompilers/ida/interface.py b/libbs/decompilers/ida/interface.py index 98d25e0..42bf9e2 100755 --- a/libbs/decompilers/ida/interface.py +++ b/libbs/decompilers/ida/interface.py @@ -246,7 +246,7 @@ def start_artifact_watchers(self): super().start_artifact_watchers() # TODO: this is a hack for backwards compatibility and should be removed in IDA 9 idb_hook = IDBHooks(self) - if self.dec_version < Version("8.4"): + if self.decompiler_available and self.dec_version < Version("8.4"): idb_hook.local_types_changed = lambda: 0 else: # this code in this block must exist in 9.0, so don't delete it! @@ -355,7 +355,7 @@ def _global_vars(self, **kwargs) -> Dict[int, GlobalVariable]: # structs def _set_struct(self, struct: Struct, header=True, members=True, **kwargs) -> bool: data_changed = False - if (self.dec_version < Version("8.3")) and "gcc_va_list" in struct.name: + if self.decompiler_available and self.dec_version < Version("8.3") and "gcc_va_list" in struct.name: _l.critical("Syncing the struct %s in IDA Pro 8.2 <= will cause a crash. Skipping...", struct.name) return False From 81d6f9786a3ad8add592de3bcca073893692b053 Mon Sep 17 00:00:00 2001 From: Duncan Ogilvie Date: Tue, 28 Apr 2026 15:41:00 +0200 Subject: [PATCH 2/6] Expose QSplitter from ui.qt_objects --- libbs/ui/qt_objects.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/libbs/ui/qt_objects.py b/libbs/ui/qt_objects.py index a2101ad..32ca592 100644 --- a/libbs/ui/qt_objects.py +++ b/libbs/ui/qt_objects.py @@ -49,6 +49,7 @@ QToolTip, QStackedLayout, QDateTimeEdit, + QSplitter, ) from PySide6.QtGui import ( QFontDatabase, @@ -118,6 +119,7 @@ QToolTip, QStackedLayout, QDateTimeEdit, + QSplitter, ) from PyQt5.QtGui import ( QFontDatabase, From 7655f687cb3315cf854de4ad018b82dad58b191c Mon Sep 17 00:00:00 2001 From: Duncan Ogilvie Date: Tue, 28 Apr 2026 16:36:35 +0200 Subject: [PATCH 3/6] Add better navigation hooks --- libbs/decompilers/ida/hooks.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/libbs/decompilers/ida/hooks.py b/libbs/decompilers/ida/hooks.py index af40964..cb2d6e6 100644 --- a/libbs/decompilers/ida/hooks.py +++ b/libbs/decompilers/ida/hooks.py @@ -539,6 +539,25 @@ def refresh_pseudocode(self, vu): self._send_decompilation_event(vu.cfunc) return 0 + def curpos(self, vu): + # Hex-Rays cursor moved within pseudocode. View_Hooks.view_curpos doesn't + # fire reliably for pseudocode caret motion (esp. arrow-key navigation), + # so mirror it through to the same context-update path the disassembly + # view uses, so "users on current function" updates promptly. + if not (self.interface.force_click_recording or self.interface.artifact_watchers_started): + return 0 + widget = vu.ct if hasattr(vu, "ct") else None + if widget is None: + return 0 + ctx = compat.view_to_bs_context(widget, action=Context.ACT_VIEW_OPEN) + if ctx is None: + return 0 + ctx = self.interface.art_lifter.lift(ctx) + ctx.last_change = datetime.datetime.now(tz=datetime.timezone.utc) + self.interface._gui_active_context = ctx + self.interface.gui_context_changed(ctx) + return 0 + # # helpers # @@ -636,6 +655,12 @@ def view_click(self, view, event): def view_activated(self, view: "TWidget *"): self._handle_view_event(view, action_type=Context.ACT_VIEW_OPEN) + def view_curpos(self, view: "TWidget *"): + # fires when the cursor (current position) moves within the view — + # includes keyboard navigation (G/jump, arrow keys, double-clicking + # in the Functions list, etc.), which view_click/view_activated miss. + self._handle_view_event(view, action_type=Context.ACT_VIEW_OPEN) + def view_mouse_moved(self, view: "TWidget *", event: "view_mouse_event_t"): if self.interface.track_mouse_moves: self._handle_view_event(view, ida_event=event, action_type=Context.ACT_MOUSE_MOVE) From 9ba328d02f1c5725f5e9c90a27a7af7fc793cf7a Mon Sep 17 00:00:00 2001 From: Duncan Ogilvie Date: Tue, 28 Apr 2026 16:37:17 +0200 Subject: [PATCH 4/6] Do not return $HASH::member which cannot be retrieved using get_named_type_tid --- libbs/decompilers/ida/compat.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/libbs/decompilers/ida/compat.py b/libbs/decompilers/ida/compat.py index 43ba507..2531354 100644 --- a/libbs/decompilers/ida/compat.py +++ b/libbs/decompilers/ida/compat.py @@ -263,10 +263,15 @@ def get_types(structs=True, enums=True, typedefs=True) -> typing.Dict[str, Artif if structs and tif.is_struct(): bs_struct = bs_struct_from_tif(tif) - types[bs_struct.name] = bs_struct + # IDA exposes nested types inside anonymous unions/structs as separate + # numbered types whose name is "$PARENT_HASH::member" — that qualified + # form can't be looked up via get_named_type_tid, so skip it. + if bs_struct.name and "::" not in bs_struct.name: + types[bs_struct.name] = bs_struct elif enums and tif.is_enum(): bs_enum = enum_from_tif(tif) - types[bs_enum.name] = bs_enum + if bs_enum is not None: + types[bs_enum.name] = bs_enum return types @@ -1403,10 +1408,11 @@ def _deprecated_get_enum_mmebers(_enum_id, max_size=100) -> typing.Dict[str, int return enum_members -def get_enum_members(_enum: typing.Union["ida_typeinf.tinfo_t", int], max_size=100) -> typing.Dict[str, int]: +def get_enum_members(_enum: typing.Union["ida_typeinf.tinfo_t", int], max_size=100) -> typing.Optional[typing.Dict[str, int]]: """ - _enum can either be an ida_typeinf.tinfo_t or an int (the old enum id system) - + _enum can either be an ida_typeinf.tinfo_t or an int (the old enum id system). + Returns None if the tif reports as an enum but IDA can't fetch its details + (e.g. typedef wrappers that pass tif.is_enum() but aren't real enums). """ if not new_ida_typing_system(): _enum_id: int = _enum @@ -1416,8 +1422,8 @@ def get_enum_members(_enum: typing.Union["ida_typeinf.tinfo_t", int], max_size=1 enum_tif: "ida_typeinf.tinfo_t" = _enum ei = ida_typeinf.enum_type_data_t() if not enum_tif.get_enum_details(ei): - _l.error("IDA failed to get enum details for %s", enum_tif) - return {} + _l.debug("IDA could not get enum details for %s; treating as non-enum", enum_tif) + return None enum_members = {} for e_memb in ei: @@ -1442,6 +1448,8 @@ def enum_from_tif(tif): return None enum_members = get_enum_members(tif) + if enum_members is None: + return None return Enum(enum_name, enum_members) @@ -1459,6 +1467,8 @@ def enum(name) -> typing.Optional[Enum]: enum_name = str(_enum.get_type_name()) if new_enums else idc.get_enum_name(_enum) enum_members = get_enum_members(_enum) + if enum_members is None: + return None return Enum(enum_name, enum_members) From f668c9b5fbe0f586c7c0af30c9fcc722c7808a3d Mon Sep 17 00:00:00 2001 From: Duncan Ogilvie Date: Tue, 28 Apr 2026 16:50:45 +0200 Subject: [PATCH 5/6] Add proper teardown for UI hooks --- libbs/decompilers/ida/compat.py | 4 ++++ libbs/decompilers/ida/interface.py | 13 +++++++++++++ 2 files changed, 17 insertions(+) diff --git a/libbs/decompilers/ida/compat.py b/libbs/decompilers/ida/compat.py index 2531354..4652410 100644 --- a/libbs/decompilers/ida/compat.py +++ b/libbs/decompilers/ida/compat.py @@ -1911,6 +1911,10 @@ def run(self, arg): pass def term(self): + try: + self.interface._term_gui_hooks() + except Exception: + _l.exception("Error tearing down GUI hooks") self.interface.decompiler_closed_event() del self.interface diff --git a/libbs/decompilers/ida/interface.py b/libbs/decompilers/ida/interface.py index 42bf9e2..9eb6859 100755 --- a/libbs/decompilers/ida/interface.py +++ b/libbs/decompilers/ida/interface.py @@ -96,6 +96,19 @@ def _init_gui_hooks(self): for hook in self._ui_hooks: hook.hook() + def _term_gui_hooks(self): + """ + Symmetric teardown for _init_gui_hooks. Must run before IDAPython tears + down — otherwise a still-registered hook can fire during shutdown + events (e.g. term_database) and try to re-enter a finalized Python. + """ + for hook in self._ui_hooks: + try: + hook.unhook() + except Exception: + _l.exception("Failed to unhook %r", hook) + self._ui_hooks = [] + def _init_gui_plugin(self, *args, **kwargs): self.decompiler_opened_event() plugin_cls_name = self._plugin_name + "_cls" From 13ef54c80f7018bddbd018e62dd47129f134f954 Mon Sep 17 00:00:00 2001 From: Duncan Ogilvie Date: Tue, 28 Apr 2026 17:34:21 +0200 Subject: [PATCH 6/6] Add QFormLayout to ui.qt_objects --- libbs/ui/qt_objects.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/libbs/ui/qt_objects.py b/libbs/ui/qt_objects.py index 32ca592..dbabd16 100644 --- a/libbs/ui/qt_objects.py +++ b/libbs/ui/qt_objects.py @@ -12,6 +12,7 @@ QComboBox, QDialog, QFileDialog, + QFormLayout, QGridLayout, QGroupBox, QHBoxLayout, @@ -81,6 +82,7 @@ QComboBox, QDialog, QFileDialog, + QFormLayout, QGridLayout, QGroupBox, QHBoxLayout,