aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-07-22 23:21:16 +0200
committerblasty <blasty@local>2026-07-22 23:21:16 +0200
commitad22cf5beb0588c8f454bebfa0cc7c7897e4e7a3 (patch)
tree8800083d7627a998dce628fe47ac1c493ee76396
parentpane: reap leaked idalib workers; drive: friendlier name resolution (fixes sp... (diff)
downloadida-tui-ad22cf5beb0588c8f454bebfa0cc7c7897e4e7a3.tar.gz
ida-tui-ad22cf5beb0588c8f454bebfa0cc7c7897e4e7a3.tar.xz
ida-tui-ad22cf5beb0588c8f454bebfa0cc7c7897e4e7a3.zip
app: non-function regions — open a flat listing + c/p/u edit verbs (M0)
Navigating to an address not inside a function no longer refuses ("no function contains X"): _do_navigate now opens a region view (flat listing anchored at the EA, forced to disasm since there's no pseudocode). The server's no-function disasm already walks heads to the segment end, so DisasmModel is reused as-is; NavEntry gains is_region. Adds the IDA c/p/u structure-edit verbs on the disasm view: c = define_code (undefine-first, since create_insn won't carve a live item) p = define_func u = undefine wired via EditItemRequested -> _do_edit_item worker -> Program.define_code/ define_func/undefine. define_func upgrades the region to a real function view in place. New Program.bump_items() invalidates the item/function structure caches (disasm blocks, decomp, function indices + name gen) — broader than bump_names(), which only covers renames. Pilot: new `region_define` scenario undefines a small function, navigates to the bare region via the 'g' prompt (asserts it opens, not refused), then 'p' recreates the function and upgrades the view; restores the IDB in a finally. 107 passed / 1 pre-existing flaky (filter, fails on baseline).
-rw-r--r--TODO9
-rw-r--r--idatui/app.py85
-rw-r--r--idatui/domain.py62
-rw-r--r--tests/test_scenarios.py50
4 files changed, 200 insertions, 6 deletions
diff --git a/TODO b/TODO
index d9245e1..a1ff814 100644
--- a/TODO
+++ b/TODO
@@ -15,8 +15,13 @@ doable:
hard:
[ ] deal with PLT stubs and such (oh god here we go)
-[ ] how to deal with non-function-body regions?
- -> we want to be able to do data/type definitions in .data etc.
+[~] how to deal with non-function-body regions?
+ -> we want to be able to do data/type definitions in .data etc.
+ -> M0 done: navigating to a non-function EA opens a flat listing (region)
+ instead of refusing; c/p/u edit verbs on the disasm view (define code /
+ function / undefine) with bump_items() cache invalidation + region->func
+ upgrade. next: M1 server `heads` walker + ListingModel (address paging),
+ M2 ListingView (code+data render), M3 data typing in .data.
crazy:
[ ] multi/split view ala ghidra?
diff --git a/idatui/app.py b/idatui/app.py
index 1db32ff..e1b26b8 100644
--- a/idatui/app.py
+++ b/idatui/app.py
@@ -79,6 +79,7 @@ class NavEntry:
dec_cursor_x: int = 0 # pseudocode column
dec_scroll_y: int = -1 # pseudocode viewport top (-1 = derive)
dec_scroll_x: int = 0 # pseudocode horizontal scroll
+ is_region: bool = False # not inside a function (flat listing view)
class SearchRequested(Message):
@@ -132,6 +133,16 @@ class RetypeRequested(Message):
self.name = name
+class EditItemRequested(Message):
+ """The disasm view asks to change item structure (IDA c/d/u/p) at the line
+ under the cursor. ``kind`` is 'code' | 'func' | 'undef'."""
+
+ def __init__(self, view, kind: str) -> None: # type: ignore[no-untyped-def]
+ super().__init__()
+ self.view = view
+ self.kind = kind
+
+
class NavMixin:
"""Follow / xrefs actions shared by the code views (bindings live on each
view since Textual only merges BINDINGS from DOMNode subclasses)."""
@@ -581,6 +592,9 @@ class DisasmView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True
Binding("home", "goto_top", "Top", show=False),
Binding("G,end", "goto_bottom", "Bottom", show=False),
Binding("o", "toggle_opcodes", "Opcodes", show=False),
+ Binding("c", "define_code", "Code", show=False),
+ Binding("p", "define_func", "Func", show=False),
+ Binding("u", "undefine", "Undef", show=False),
Binding("tab,shift+tab", "app.toggle_view", "Pseudocode", priority=True),
*SearchMixin.SEARCH_BINDINGS,
*NavMixin.NAV_BINDINGS,
@@ -832,6 +846,16 @@ class DisasmView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True
line = self.model.cached_line(self.cursor)
return line.ea if line else None
+ # -- item structure edits (IDA c/p/u) --------------------------------- #
+ def action_define_code(self) -> None:
+ self.post_message(EditItemRequested(self, "code"))
+
+ def action_define_func(self) -> None:
+ self.post_message(EditItemRequested(self, "func"))
+
+ def action_undefine(self) -> None:
+ self.post_message(EditItemRequested(self, "undef"))
+
def action_cursor_down(self) -> None:
self._move(1)
@@ -2654,6 +2678,51 @@ class IdaTui(App):
self._dirty = True
self._status(f"renamed {old} → {new} (Ctrl+S to save)")
+ # -- item structure edits (IDA c/p/u) --------------------------------- #
+ def on_edit_item_requested(self, msg: EditItemRequested) -> None:
+ ea = msg.view._cursor_ea() if isinstance(msg.view, DisasmView) else None
+ if ea is None:
+ self._status("no address on this line to (re)define")
+ return
+ self._do_edit_item(msg.kind, ea)
+
+ @work(thread=True, exclusive=True, group="edititem")
+ def _do_edit_item(self, kind: str, ea: int) -> None: # worker context
+ assert self.program is not None
+ verb = {"code": "defined code", "func": "created function",
+ "undef": "undefined"}[kind]
+ try:
+ if kind == "code":
+ self.program.define_code(ea)
+ elif kind == "func":
+ self.program.define_func(ea)
+ else:
+ self.program.undefine(ea)
+ except Exception as e: # noqa: BLE001 -- surface soft/hard tool errors
+ self.app.call_from_thread(self._status, f"{kind}: {e}")
+ return
+ # Structure changed everywhere: drop all item/function/decomp caches.
+ self.program.bump_items()
+ # Re-resolve: a define_func upgrades the region to a real function view;
+ # anything else re-reads the (still function-less) listing in place.
+ fn = self.program.function_of(ea)
+ if fn is not None:
+ model = self.program.disasm(fn.addr, fn.name)
+ idx = 0 if ea == fn.addr else model.index_of_ea(ea)
+ self.app.call_from_thread(
+ self._open_at, fn.addr, fn.name, idx, False, -1, 0, False)
+ else:
+ name = self.program.region_label(ea)
+ model = self.program.disasm(ea, name)
+ idx = max(model.index_of_ea(ea), 0)
+ self.app.call_from_thread(
+ self._open_at, ea, name, idx, False, -1, 0, True)
+ self.app.call_from_thread(self._edit_item_done, verb, ea)
+
+ def _edit_item_done(self, verb: str, ea: int) -> None:
+ self._dirty = True
+ self._status(f"{verb} @ {ea:#x} (Ctrl+S to save)")
+
@work(thread=True, exclusive=True, group="save")
def _save(self) -> None:
assert self.program is not None
@@ -2682,7 +2751,13 @@ class IdaTui(App):
assert self.program is not None
fn = self.program.function_of(ea)
if fn is None:
- self.app.call_from_thread(self._status, f"no function contains {ea:#x}")
+ # Not inside a function: open a flat listing (region) anchored here
+ # rather than refusing. This is where you land on code IDA didn't
+ # mark, or on data — use c/p to define it (see _do_edit_item).
+ name = self.program.region_label(ea)
+ self.program.disasm(ea, name) # warm the block cache off the UI loop
+ self.app.call_from_thread(
+ self._open_at, ea, name, 0, push, -1, 0, True)
return
model = self.program.disasm(fn.addr, fn.name)
idx = 0 if ea == fn.addr else model.index_of_ea(ea)
@@ -2737,10 +2812,11 @@ class IdaTui(App):
return best_idx
def _open_at(self, ea: int, name: str, cursor: int, push: bool,
- dec_cursor: int = -1, dec_cursor_x: int = 0) -> None:
+ dec_cursor: int = -1, dec_cursor_x: int = 0,
+ is_region: bool = False) -> None:
if push:
self._save_current_pos()
- entry = NavEntry(ea=ea, name=name, cursor=cursor)
+ entry = NavEntry(ea=ea, name=name, cursor=cursor, is_region=is_region)
if dec_cursor >= 0:
entry.dec_cursor = dec_cursor
entry.dec_cursor_x = dec_cursor_x
@@ -2919,7 +2995,8 @@ class IdaTui(App):
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
+ # A region (not inside a function) has no pseudocode -> force disasm.
+ self._active = "disasm" if entry.is_region else 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(
diff --git a/idatui/domain.py b/idatui/domain.py
index c374b71..53fd907 100644
--- a/idatui/domain.py
+++ b/idatui/domain.py
@@ -853,6 +853,68 @@ class Program:
for m in models:
m.invalidate()
+ def bump_items(self) -> None:
+ """Signal that item/function STRUCTURE changed (define code/data/func,
+ undefine). Unlike a rename this can move instruction boundaries and
+ change function membership anywhere, so drop the disasm block caches,
+ the decompilation cache and the cached function indices outright, and
+ bump the name generation too (labels/names may appear or vanish)."""
+ with self._lock:
+ self._name_gen += 1
+ self._indices.clear()
+ self._decomp.clear()
+ models = list(self._disasm.values())
+ self._disasm.clear()
+ for m in models:
+ m.invalidate()
+
+ # -- item / function structure edits (IDA c/d/u/p) --------------------- #
+ @staticmethod
+ def _first_result(payload) -> dict:
+ """Unwrap the first row of a batch tool response ({result:[...]} or a
+ bare list); soft per-item ``error`` fields ride along in the dict."""
+ data = payload.get("result", payload) if isinstance(payload, dict) else payload
+ if isinstance(data, list):
+ return data[0] if data and isinstance(data[0], dict) else {}
+ return data if isinstance(data, dict) else {}
+
+ def define_code(self, ea: int) -> None:
+ """Convert the bytes at ``ea`` into a code instruction (IDA's 'c').
+ Undefine first so it works even when the bytes are currently part of a
+ data/align item — ``create_insn`` refuses to carve into a live item."""
+ try:
+ self.client.call("undefine", items=[{"addr": hex(ea)}])
+ except IDAToolError:
+ pass # nothing defined here yet -> just try to create the insn
+ res = self._first_result(
+ self.client.call("define_code", items=[{"addr": hex(ea)}]))
+ if res.get("error"):
+ raise IDAToolError(f"define code @ {ea:#x}: {res['error']}")
+
+ def define_func(self, ea: int) -> None:
+ """Create a function starting at ``ea`` (IDA's 'p')."""
+ res = self._first_result(
+ self.client.call("define_func", items=[{"addr": hex(ea)}]))
+ if res.get("error"):
+ raise IDAToolError(f"create function @ {ea:#x}: {res['error']}")
+
+ def undefine(self, ea: int, size: int | None = None) -> None:
+ """Undefine the item at ``ea`` back to raw bytes (IDA's 'u')."""
+ item: dict = {"addr": hex(ea)}
+ if size:
+ item["size"] = int(size)
+ res = self._first_result(self.client.call("undefine", items=[item]))
+ if res.get("error"):
+ raise IDAToolError(f"undefine @ {ea:#x}: {res['error']}")
+
+ def region_label(self, ea: int) -> str:
+ """Display name for a non-function address (segment-qualified)."""
+ try:
+ sec = self.section_of(ea)
+ except Exception: # noqa: BLE001
+ sec = None
+ return f"{sec} @ {ea:#x}" if sec else f"<no function> @ {ea:#x}"
+
@staticmethod
def _fetch_output(url: str, timeout: float = 15.0):
"""GET the server's cached full-output blob (plain HTTP, not MCP)."""
diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py
index b16cd98..97ee74b 100644
--- a/tests/test_scenarios.py
+++ b/tests/test_scenarios.py
@@ -1254,6 +1254,56 @@ async def s_rename_history(c: Ctx):
app.program.client.call("rename", batch={"func": {"addr": hex(htarget), "name": hsym}})
+@scenario("region_define")
+async def s_region_define(c: Ctx):
+ """A non-function address opens a flat listing (region) instead of being
+ refused, and 'p' there creates a function and upgrades the view."""
+ app = c.app
+ fn = c.find_func(lambda f: 0x10 <= f.size <= 0x60 and f.name.startswith("sub_"))
+ if fn is None:
+ c.check("found a small sub_ function for the region test", False)
+ return
+ addr, size = fn.addr, fn.size
+ try:
+ # setup: undefine the whole function so [addr, addr+size) is a bare region
+ c.prog.undefine(addr, size=size)
+ c.prog.bump_items()
+ c.check("function removed by undefine",
+ c.prog.function_of(addr) is None, "still a function")
+
+ # navigate there via the real 'g' prompt -> opens a REGION, not refused
+ await c.goto_ui(hex(addr))
+ await c.wait(lambda: app._cur is not None and app._cur.ea == addr, 25)
+ c.check("goto to a non-function address opens a region (not refused)",
+ app._cur is not None and app._cur.is_region
+ and app._active == "disasm",
+ f"cur={app._cur} active={app._active} status={c.status()!r}")
+ await c.wait(lambda: c.dis.total > 0 and c.dis._cursor_ea() is not None, 25)
+ c.check("region listing renders lines with a resolvable cursor address",
+ c.dis.total > 0 and c.dis._cursor_ea() == addr,
+ f"total={c.dis.total} cur_ea={c.dis._cursor_ea()}")
+
+ # cursor on the entry line; 'p' (re)creates the function
+ c.dis.focus()
+ c.dis.cursor, c.dis.cursor_x = 0, 0
+ await c.pause(0.05)
+ await c.press("p")
+ await c.wait(lambda: c.prog.function_of(addr) is not None
+ and app._cur is not None and not app._cur.is_region, 25)
+ c.check("'p' creates a function and upgrades the region to a function view",
+ c.prog.function_of(addr) is not None
+ and not app._cur.is_region and app._cur.ea == addr,
+ f"fn={c.prog.function_of(addr)} cur={app._cur}")
+ finally:
+ # idempotency: guarantee the function is back even if a check failed
+ if c.prog.function_of(addr) is None:
+ try:
+ c.prog.define_func(addr)
+ except Exception: # noqa: BLE001
+ pass
+ c.prog.bump_items()
+
+
# --------------------------------------------------------------------------- #
# Runner
# --------------------------------------------------------------------------- #