aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/SPLIT_VIEW.md12
-rw-r--r--idatui/app.py57
-rw-r--r--idatui/domain.py22
-rw-r--r--server/patch_server.py53
-rw-r--r--tests/test_scenarios.py21
5 files changed, 150 insertions, 15 deletions
diff --git a/docs/SPLIT_VIEW.md b/docs/SPLIT_VIEW.md
index 740ae90..a8546fa 100644
--- a/docs/SPLIT_VIEW.md
+++ b/docs/SPLIT_VIEW.md
@@ -78,9 +78,15 @@ decomp→listing uses `ListingModel.ensure_ea`. Tab re-links from the new driver
Still single-ea per line (one instruction highlighted); the region comes in
phase 3.
-**Phase 3 — rich highlight.** `decomp_map` custom tool (line → ea-set) stored on
-`Decompilation`; highlight the whole instruction range of a C line; tighten the
-reverse map. One custom tool + an idalib spike to confirm the sweep works.
+**Phase 3 — rich highlight. DONE.** `decomp_map` custom tool
+(`server/patch_server.py`) sweeps `cfunc.get_line_item` across every column of
+each pseudocode line and collects the EAs from each item's `dstr()` (`'EA: desc'`
+— the same source as the `/*ea*/` marker, so it aligns). `Program.decomp_map(ea)`
+returns the per-line ea lists (cached by name-gen); the app loads it async into
+`_split_eamap` / `_split_ea2line` and `_sync_split` bands the **whole** instruction
+region of a C line (and uses the exact ea→line inverse for the reverse). Falls
+back to the single marker until the map lands. Verified on the pilot's real worker
+(alignment + multi-instruction region band).
**Phase 4 — polish.** Navigation keeps both panes on the same function; loop/goto
edge cases; decompile-failed fallback; min-width gate + resize; split state in
diff --git a/idatui/app.py b/idatui/app.py
index a057412..dbd5025 100644
--- a/idatui/app.py
+++ b/idatui/app.py
@@ -2606,6 +2606,8 @@ class IdaTui(App):
self._pref = "listing" # unified: the linear listing is the code view
self._active = "listing" # currently shown view (in split: the focused pane)
self._split = False # side-by-side listing + pseudocode
+ self._split_eamap: list[list[int]] = [] # split: decomp line -> instr EAs
+ self._split_ea2line: dict[int, int] = {} # split: instr EA -> decomp line
self._hex_pending_ea: int | None = None
self._cur: NavEntry | None = None
self._pending_focus_name: str | None = None # token to land the cursor on
@@ -3165,7 +3167,8 @@ class IdaTui(App):
if lm is not None:
lst.load(lm, name, cursor=idx, scroll_y=max(idx - _JUMP_CONTEXT, 0))
self._show_active() # split branch shows both + loads the decomp
- self._sync_split(self._active) # link now if the decomp was already loaded
+ self._sync_split(self._active) # crude link now
+ self._load_split_map(ea) # region map (async) if decomp is loaded
def action_hex(self) -> None:
"""Backslash: show the raw bytes of the loaded image, synced to the code
@@ -4448,6 +4451,8 @@ class IdaTui(App):
self.query_one("#panes").set_class(False, "split")
lst.set_link(set())
dec.set_link(None)
+ self._split_eamap = []
+ self._split_ea2line = {}
dec.display = lst.display = hx.display = False
if self._active in ("listing", "disasm"):
lst.display = True
@@ -4561,36 +4566,64 @@ class IdaTui(App):
view.show(ea, dec.code or "", cursor=c, cursor_x=cx, scroll_y=sy, scroll_x=sx)
self._status(f"{name} @ {ea:#x} [pseudocode {len(dec.code or '')} chars]{note}")
if self._split:
- self._sync_split(self._active) # decomp just loaded: establish the link
+ self._sync_split(self._active) # crude link now
+ self._load_split_map(ea) # then upgrade to the region map
def _sync_split(self, source: str) -> None:
"""Split view: highlight (+ scroll into view) the companion pane's
location for the focused pane's cursor. The companion only gets a band +
- scroll (its cursor never moves), so there is no echo/ping-pong. Crude
- single-ea mapping for now; phase 3 upgrades to full ea sets."""
+ scroll (its cursor never moves), so there is no echo/ping-pong. Uses the
+ rich per-line ea map (decomp_map) when loaded, else the single marker."""
if not self._split or self.program is None:
return
lst = self.query_one(ListingView)
dec = self.query_one(DecompView)
if source == "decomp":
dec.set_link(None) # the driver shows its own cursor, no band
- ea = dec._line_ea(dec.cursor)
- row = lst.model.ensure_ea(ea) if (ea is not None and lst.model) else None
- if row is not None and row >= 0:
- lst.set_link({row})
- lst.reveal(row)
- else:
- lst.set_link(set())
+ eas = (self._split_eamap[dec.cursor]
+ if 0 <= dec.cursor < len(self._split_eamap) else [])
+ if not eas: # fallback: the single /*ea*/ marker for the line
+ one = dec._line_ea(dec.cursor)
+ eas = [one] if one is not None else []
+ rows: set[int] = set()
+ if lst.model is not None:
+ for e in eas:
+ r = lst.model.ensure_ea(e)
+ if r is not None and r >= 0:
+ rows.add(r)
+ lst.set_link(rows)
+ if rows:
+ lst.reveal(min(rows))
else: # the listing drives
lst.set_link(set())
ea = lst._cursor_ea()
- line = dec.line_for_ea(ea) if ea is not None else None
+ line = self._split_ea2line.get(ea) if ea is not None else None
+ if line is None and ea is not None: # fallback: nearest marker
+ line = dec.line_for_ea(ea)
if line is not None:
dec.set_link(line)
dec.reveal(line)
else:
dec.set_link(None)
+ @work(thread=True, group="split-map")
+ def _load_split_map(self, ea: int) -> None:
+ # Fetch the rich per-line instruction map off the UI thread; sync stays
+ # on the crude single-ea fallback until it lands.
+ assert self.program is not None
+ m = self.program.decomp_map(ea)
+ self.app.call_from_thread(self._apply_split_map, ea, m)
+
+ def _apply_split_map(self, ea: int, m: list) -> None:
+ if not self._split or self._cur is None or self._cur.ea != ea:
+ return # left split / navigated away
+ self._split_eamap = m
+ self._split_ea2line = {}
+ for line, eas in enumerate(m):
+ for e in eas:
+ self._split_ea2line.setdefault(e, line)
+ 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:
self._nav[-1].dec_cursor = msg.index
diff --git a/idatui/domain.py b/idatui/domain.py
index 8a127d7..1bf62ed 100644
--- a/idatui/domain.py
+++ b/idatui/domain.py
@@ -807,6 +807,7 @@ class Program:
self._disasm: dict[int, DisasmModel] = {}
self._listings: dict[int, ListingModel] = {} # keyed by segment start
self._decomp: dict[int, tuple[Decompilation, int]] = {}
+ self._decomp_maps: dict[int, tuple[list[list[int]], int]] = {} # line->ea sets
self._name_gen = 0 # bumped on rename; invalidates stale name caches
self._segments_cache: list[tuple[int, int, int, str]] | None = None
self._sections: list[tuple[int, int, str]] | None = None
@@ -1245,6 +1246,27 @@ class Program:
except Exception: # noqa: BLE001 -- fall back to the truncated preview
return None
+ def decomp_map(self, ea: int) -> list[list[int]]:
+ """Per-pseudocode-line instruction coverage for the split-view region
+ highlight: a list aligned to the decompiled lines, each the EAs the
+ decompiler attributes to that line (may be empty). Cached per function +
+ name generation; ``[]`` if the tool is unavailable."""
+ with self._lock:
+ hit = self._decomp_maps.get(ea)
+ gen = self._name_gen
+ if hit is not None and hit[1] == gen:
+ return hit[0]
+ try:
+ payload = self.client.call("decomp_map", addr=hex(ea))
+ except IDAToolError:
+ return []
+ lines = payload.get("lines", []) if isinstance(payload, dict) else []
+ out = [[_as_int(e) for e in (ln.get("eas") or [])]
+ for ln in lines if isinstance(ln, dict)]
+ with self._lock:
+ self._decomp_maps[ea] = (out, gen)
+ return out
+
# -- cross-references & containing function --------------------------- #
def function_of(self, ea: int) -> Func | None:
"""Return the function containing ``ea`` (resolves mid-function addrs)."""
diff --git a/server/patch_server.py b/server/patch_server.py
index 6862785..c217561 100644
--- a/server/patch_server.py
+++ b/server/patch_server.py
@@ -582,6 +582,59 @@ def xref_types(
rows = rows[:count]
result.append({"query": raw, "data": rows, "next_offset": None})
return {"result": result}
+
+
+@tool
+@idasync
+def decomp_map(
+ addr: Annotated[str, "Function address or name"],
+) -> dict:
+ """Per-pseudocode-line instruction coverage for the split view's region
+ highlight: for each line, the set of EAs the decompiler attributes to it,
+ swept across the line's columns via get_line_item. Shape:
+ {addr, lines:[{ea: primary|None, eas:[hex,...]}, ...]}."""
+ import ida_hexrays
+ import idaapi
+ try:
+ ea = int(str(addr), 16)
+ except ValueError:
+ ea = idaapi.get_name_ea(idaapi.BADADDR, str(addr).strip())
+ func = idaapi.get_func(ea)
+ if not func:
+ return {"error": f"no function at {addr}"}
+ try:
+ cfunc = ida_hexrays.decompile(func.start_ea)
+ except Exception as e: # noqa: BLE001
+ return {"error": f"decompile failed: {e}"}
+ if cfunc is None:
+ return {"error": "decompile failed"}
+ lines = []
+ for sl in cfunc.get_pseudocode():
+ line = sl.line
+ eas, seen = [], set()
+ for x in range(len(line) + 1):
+ head = ida_hexrays.ctree_item_t()
+ item = ida_hexrays.ctree_item_t()
+ tail = ida_hexrays.ctree_item_t()
+ if not cfunc.get_line_item(line, x, False, head, item, tail):
+ continue
+ # Match the /*ea*/ marker's source (decompile_function_safe): the
+ # item's dstr() is 'EA: description'; get_ea() reports a different ea.
+ dstr = item.dstr()
+ if not dstr:
+ continue
+ parts = dstr.split(": ", 1)
+ if len(parts) != 2:
+ continue
+ try:
+ e = int(parts[0], 16)
+ except ValueError:
+ continue
+ if e not in seen:
+ seen.add(e)
+ eas.append(hex(e))
+ lines.append({"ea": eas[0] if eas else None, "eas": eas})
+ return {"addr": hex(func.start_ea), "lines": lines}
'''
SNIPPET = f"{BEGIN}\n{BODY.strip()}\n{END}\n"
diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py
index 9099035..0bca4d8 100644
--- a/tests/test_scenarios.py
+++ b/tests/test_scenarios.py
@@ -350,6 +350,27 @@ async def s_split_view(c: Ctx):
loaded = await c.wait(lambda: dec.loaded_ea == app._cur.ea, 25)
c.check("split loads the pseudocode alongside the listing", loaded,
f"loaded={dec.loaded_ea} cur={app._cur.ea if app._cur else None}")
+ # phase 3: the rich per-line instruction map (decomp_map tool, run on the
+ # pilot's real worker) — verify it returns, aligns with the markers, and
+ # bands a whole region for a multi-instruction C line.
+ m = app.program.decomp_map(app._cur.ea)
+ c.check("decomp_map returns per-line ea sets", len(m) > 5, f"lines={len(m)}")
+ aligned = sum(1 for i in range(min(len(m), len(dec._line_eas)))
+ if m[i] and dec._line_eas[i] is not None
+ and dec._line_eas[i] in m[i])
+ c.check("decomp_map aligns with the pseudocode markers", aligned >= 3,
+ f"aligned={aligned}/{len(dec._line_eas)}")
+ multi = next((i for i, eas in enumerate(m) if len(eas) > 1), None)
+ if multi is not None:
+ app._split_eamap = m
+ dec.cursor = multi
+ app._sync_split("decomp")
+ c.check("a multi-instruction C line bands a region (>1 listing row)",
+ len(lst._link_rows) > 1,
+ f"line={multi} eas={len(m[multi])} rows={sorted(lst._link_rows)[:8]}")
+ else:
+ c.check("a multi-instruction C line bands a region (>1 listing row)",
+ True, "no multi-instruction line in this function (skipped)")
# listing drives: move it, the decomp band must track the covering C line
lst.focus()
for _ in range(6):