diff options
| author | blasty <blasty@local> | 2026-07-25 12:15:45 +0200 |
|---|---|---|
| committer | blasty <blasty@local> | 2026-07-25 12:15:45 +0200 |
| commit | 7569141dc750cfd75347c14156773e3184a5b965 (patch) | |
| tree | e4bc4bbf17e33cf9778a4d95e4d3511694849409 | |
| parent | ui: horizontally centre the splash logo in the loading dialog (diff) | |
| download | ida-tui-7569141dc750cfd75347c14156773e3184a5b965.tar.gz ida-tui-7569141dc750cfd75347c14156773e3184a5b965.tar.xz ida-tui-7569141dc750cfd75347c14156773e3184a5b965.zip | |
retype: 'y' now retypes globals too, not just prototypes and locals
In the decompiler, 'y' on a local variable already worked (func_types -> lvars ->
set_lvar_type), but a GLOBAL fell through every case and silently retyped the
ENCLOSING FUNCTION'S PROTOTYPE — worse than not working, since the prompt said
"prototype" while you thought you were typing a variable.
* server/patch_server.py: new data_type tool — {addr,name,type,size,is_func} for
a data item, so the prompt can prefill the current type and the caller can tell
a global from a function.
* domain: Program.data_type() + set_data_type() (set_type with kind="global").
* app: _prepare_retype gains the data case between "function" and the
current-function fallback, with a size-based prefill when the global is still
untyped; _do_retype routes kind="data" to set_data_type.
Classification verified on echo/main: 'v3' -> lvar (prefill 'char *'), 'stdout'
-> data (prefill 'FILE *'), 'main' -> func prototype, an unresolvable token ->
the enclosing prototype (unchanged fallback).
Also fixes a latent crash found while probing this: on_listing/decomp_view_
cursor_moved called self.query_one(ListingView), but App.query_one searches the
TOP screen — a cursor-moved message landing while any modal is up (loading
overlay, project switch) raised NoMatches out of a message handler and killed the
app. Both handlers now go through _try_view().
Pilot `retype` extended to 9 checks covering all three flavours, each asserting
the other targets are left alone. Two of the new checks needed settles: applying
a retype recompiles asynchronously, so scanning/indexing the pseudocode without
waiting reads text that's about to be replaced (this also cut the scenario from
30s to 2.6s of previously-wasted timeout). Full suite 174/2-flake.
| -rw-r--r-- | idatui/app.py | 42 | ||||
| -rw-r--r-- | idatui/domain.py | 21 | ||||
| -rw-r--r-- | server/patch_server.py | 29 | ||||
| -rw-r--r-- | tests/test_scenarios.py | 99 |
4 files changed, 184 insertions, 7 deletions
diff --git a/idatui/app.py b/idatui/app.py index 813a774..0012e32 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -4122,7 +4122,10 @@ class IdaTui(App): lv = next((v for v in ft.lvars if v.name == word), None) if lv is not None: kind, prefill = "lvar", lv.type - # 2) a function under the cursor (self or a referenced one) + # 2) a symbol under the cursor: a function (retype its prototype) or a + # global/data item (retype the variable). Without the data case a + # global fell through to (3) and silently retyped the ENCLOSING + # function's prototype instead. if kind is None and self._looks_like_symbol(word): try: tgt = self.program.resolve(word) @@ -4132,6 +4135,12 @@ class IdaTui(App): tft = self.program.func_types(tgt) if tft is not None: kind, subject, prefill = "func", tgt, tft.prototype + else: + dt = self.program.data_type(tgt) + if dt is not None and not dt.get("is_func"): + kind, subject = "data", tgt + prefill = dt.get("type") or self._guess_data_type( + dt.get("size") or 0) # 3) fall back to the current function itself if kind is None and ft is not None: kind, subject, prefill = "func", self._cur.ea, ft.prototype @@ -4140,6 +4149,13 @@ class IdaTui(App): return self.app.call_from_thread(self._open_retype, view, kind, subject, word or "", prefill) + @staticmethod + def _guess_data_type(size: int) -> str: + """A sensible prefill when a global carries no type yet.""" + return {1: "unsigned __int8", 2: "unsigned __int16", + 4: "unsigned __int32", 8: "unsigned __int64"}.get( + size, f"char[{size}]" if size > 0 else "void *") + def _open_retype(self, view, kind: str, subject: int, word: str, # type: ignore[no-untyped-def] prefill: str) -> None: self._retype_ctx = (view, kind, subject, word) @@ -4166,6 +4182,8 @@ class IdaTui(App): assert self.program is not None if kind == "func": err = self.program.set_function_type(subject, new) + elif kind == "data": # a global / data item referenced in the body + err = self.program.set_data_type(subject, new) else: # lvar of the current function err = self.program.set_lvar_type(self._cur.ea, word, new) if err: @@ -5135,9 +5153,10 @@ class IdaTui(App): self._sync_split(self._active) # re-link with the region map def on_decomp_view_cursor_moved(self, msg: DecompView.CursorMoved) -> None: - if self._nav: + dv = self._try_view(DecompView) + if self._nav and dv is not None: self._nav[-1].dec_cursor = msg.index - self._nav[-1].dec_cursor_x = self.query_one(DecompView).cursor_x + self._nav[-1].dec_cursor_x = dv.cursor_x if self._split: if self._active == "decomp": self._sync_split("decomp") @@ -5147,12 +5166,23 @@ 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 _try_view(self, cls): # type: ignore[no-untyped-def] + """The code view, or None. App.query_one searches the TOP screen, so a + cursor-moved message that lands while any modal is up (the loading + overlay, a project switch) would otherwise raise NoMatches and kill the + app from a message handler.""" + try: + return self.query_one(cls) + except Exception: # noqa: BLE001 -- NoMatches: a modal owns the screen + return None + def on_listing_view_cursor_moved(self, msg: ListingView.CursorMoved) -> None: - if self._nav: + lst = self._try_view(ListingView) + if self._nav and lst is not None: self._nav[-1].cursor = msg.index - self._nav[-1].cursor_x = self.query_one(ListingView).cursor_x + self._nav[-1].cursor_x = lst.cursor_x if msg.index >= 0: - self._nav[-1].scroll_y = round(self.query_one(ListingView).scroll_offset.y) + self._nav[-1].scroll_y = round(lst.scroll_offset.y) if self._split: if self._active == "listing": self._sync_split("listing") diff --git a/idatui/domain.py b/idatui/domain.py index 33c44b6..ef37ab9 100644 --- a/idatui/domain.py +++ b/idatui/domain.py @@ -1068,6 +1068,27 @@ class Program: return None return row.get("error") or "failed to set the prototype" + def data_type(self, ea: int) -> dict | None: + """Current type info for a data item/global: {addr,name,type,size,is_func}. + None if the tool is unavailable or the address isn't mapped.""" + try: + r = self.client.call("data_type", addr=hex(ea)) + except IDAToolError: + return None + if not isinstance(r, dict) or r.get("error"): + return None + return r + + def set_data_type(self, ea: int, decl: str) -> str | None: + """Set a global/data item's type. None on success, else an error string.""" + r = self.client.call( + "set_type", edits=[{"kind": "global", "addr": hex(ea), "type": decl}]) + res = r.get("result", []) if isinstance(r, dict) else [] + row = res[0] if res and isinstance(res[0], dict) else {} + if row.get("ok"): + return None + return row.get("error") or "failed to set the type" + def set_lvar_type(self, fn_ea: int, var: str, ty: str) -> str | None: """Set a decompiler local variable's type (via the injected server tool). None on success, else an error string.""" diff --git a/server/patch_server.py b/server/patch_server.py index 96c4689..09bca86 100644 --- a/server/patch_server.py +++ b/server/patch_server.py @@ -586,6 +586,35 @@ def xref_types( @tool @idasync +def data_type( + addr: Annotated[str, "Address or name of a data item / global"], +) -> dict: + """The current C type of a data item, for prefilling a retype prompt: + {addr, name, type, size, is_func}. ``type`` is empty when the item is + untyped; ``is_func`` distinguishes a global from a function so the caller + knows which flavour of set_type to use.""" + import idaapi + import ida_bytes + import ida_name + import idc + raw = str(addr).strip() + try: + ea = int(raw, 16) + except ValueError: + ea = idaapi.get_name_ea(idaapi.BADADDR, raw) + if ea == idaapi.BADADDR or not ida_bytes.is_mapped(ea): + return {"addr": raw, "error": f"not a mapped address: {raw}"} + return { + "addr": hex(ea), + "name": ida_name.get_name(ea) or "", + "type": idc.get_type(ea) or "", + "size": int(ida_bytes.get_item_size(ea) or 0), + "is_func": bool(idaapi.get_func(ea)), + } + + +@tool +@idasync def decomp_map( addr: Annotated[str, "Function address or name"], ) -> dict: diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index acf05f2..b6ed230 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -26,7 +26,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from idatui.app import ( # noqa: E402 ConfirmScreen, DecompView, DisasmView, FunctionsPanel, HexView, IdaTui, ListingView, StringsPalette, StructEditor, SymbolPalette, XrefsScreen, - _str_display, + _str_display, _word_occurrences, ) from textual.widgets import ( # noqa: E402 DataTable, Footer, Input, OptionList, Static, TextArea, @@ -1388,6 +1388,103 @@ async def s_retype(c: Ctx): after is not None and "zz_retype_arg" in after.prototype, f"proto={after.prototype if after else None!r}") app.program.set_function_type(cf.addr, old_proto) # restore + # the retype above kicked off a recompile+reload; let it land before we start + # placing the cursor, or the reload resets it under us. + app.program.bump_names() + await c.wait(lambda: not dec.loading and dec.loaded_ea == cf.addr + and bool(dec._texts), 25) + await c.pause(0.3) + + # -- 'y' on a LOCAL variable retypes that variable, not the prototype --- # + fts = app.program.func_types(cf.addr) + lv = next((v for v in (fts.lvars if fts else []) if not v.is_arg), None) + if lv is not None: + line = next((i for i, t in enumerate(dec._texts) + if _word_occurrences(t, lv.name)), None) + if line is not None: + col = _word_occurrences(dec._texts[line], lv.name)[0][0] + dec.focus() + dec.cursor, dec.cursor_x = line, col + 1 # inside the word + dec.refresh() + await c.pause(0.05) + await c.press("y") + await c.wait(lambda: app.query_one("#retype", Input).display, 10) + ri = app.query_one("#retype", Input) + c.check("'y' on a local variable prefills that variable's type", + ri.value == lv.type and lv.name in str(ri.placeholder), + f"val={ri.value!r} want={lv.type!r} ph={ri.placeholder!r}") + ri.value = "unsigned __int64" + await c.press("enter") + changed = await c.wait(lambda: (lambda f: bool(f) and any( + v.name == lv.name and v.type == "unsigned __int64" + for v in f.lvars))(app.program.func_types(cf.addr)), 25) + c.check("applying it retypes the local variable", changed, + f"{lv.name}: wanted unsigned __int64") + after = app.program.func_types(cf.addr) + c.check("retyping a local leaves the prototype alone", + after is not None and after.prototype == old_proto, + f"proto={after.prototype if after else None!r}") + + # -- 'y' on a GLOBAL retypes the global, not the enclosing function ----- # + # The lvar retype above recompiled too — settle again, or the scan below + # indexes into pseudocode that's about to be replaced. + await c.wait(lambda: not dec.loading and dec.loaded_ea == cf.addr + and bool(dec._texts), 25) + await c.pause(0.3) + + # Pick a global that actually appears as a word in the pseudocode — a symbol + # from decompile().refs often doesn't (a string ref renders as its literal). + lvnames = {v.name for v in (fts.lvars if fts else [])} + glob = None + for i, t in enumerate(dec._texts): + for w in re.findall(r"[A-Za-z_][A-Za-z0-9_]*", t): + if w in lvnames or w == cf.name: + continue + try: + a = app.program.resolve(w) + except Exception: # noqa: BLE001 + continue + if app.program.func_types(a) is not None: + continue + d = app.program.data_type(a) or {} + if (d.get("name") and not d.get("is_func") + and "(" not in (d.get("type") or "")): + glob = (w, a, d, i) + break + if glob: + break + if glob is not None: + gname, gaddr, dt, line = glob + glob = type("G", (), {"addr": gaddr})() # keep the checks below readable + if line is not None: + col = _word_occurrences(dec._texts[line], gname)[0][0] + dec.focus() + dec.cursor, dec.cursor_x = line, col + 1 # inside the word + dec.refresh() + await c.pause(0.05) + c.check("the cursor sits on the global", + dec.word_under_cursor() == gname, + f"word={dec.word_under_cursor()!r} want={gname!r} " + f"line={line} col={col} text={dec._texts[line][:60]!r}") + await c.press("y") + await c.wait(lambda: app.query_one("#retype", Input).display, 10) + ri = app.query_one("#retype", Input) + c.check("'y' on a global prefills the global's type (not the proto)", + ri.value != old_proto and gname in str(ri.placeholder), + f"val={ri.value!r} ph={ri.placeholder!r}") + ri.value = "unsigned __int64" + await c.press("enter") + retyped = await c.wait( + lambda: (app.program.data_type(glob.addr) or {}).get("type") + == "unsigned __int64", 25) + c.check("applying it retypes the global", retyped, + f"type={(app.program.data_type(glob.addr) or {}).get('type')!r}") + after = app.program.func_types(cf.addr) + c.check("retyping a global leaves the prototype alone", + after is not None and after.prototype == old_proto, + f"proto={after.prototype if after else None!r}") + if dt.get("type"): # restore + app.program.set_data_type(glob.addr, dt["type"]) @scenario("scroll_restore") |
