diff options
| author | blasty <blasty@local> | 2026-07-24 23:38:30 +0200 |
|---|---|---|
| committer | blasty <blasty@local> | 2026-07-24 23:38:30 +0200 |
| commit | de2fa194278bb132e23edddd65bdad84f32ec37d (patch) | |
| tree | 67d18789676b3377892d3edf4a6fbe4d497967af | |
| parent | split-view phase 2: cursor sync + linked-row highlight (diff) | |
| download | ida-tui-de2fa194278bb132e23edddd65bdad84f32ec37d.tar.gz ida-tui-de2fa194278bb132e23edddd65bdad84f32ec37d.tar.xz ida-tui-de2fa194278bb132e23edddd65bdad84f32ec37d.zip | |
split-view phase 3: rich per-line instruction region highlight
The Ghidra "region band": moving the pseudocode cursor now lights up EVERY
instruction that C line owns, not just one.
* server/patch_server.py: new decomp_map tool — sweeps cfunc.get_line_item across
each pseudocode line's columns and collects the ea from each item's dstr()
('EA: desc', matching the /*ea*/ marker source so it aligns with the display
lines). Returns {addr, lines:[{ea, eas:[...]}]}. (First tried item.get_ea(),
which reports a different ea and didn't align — dstr() is the right source.)
* domain: Program.decomp_map(ea) -> per-line ea lists, cached by name-gen.
* app: _load_split_map fetches it off-thread into _split_eamap/_split_ea2line;
_sync_split bands the full instruction region for a C line (decomp drives) and
uses the exact ea->line inverse (listing drives), falling back to the single
marker until the map lands. Maps cleared on leaving split.
Pilot split_view gains: decomp_map returns/aligns with the markers, and a
multi-instruction C line bands >1 listing row (13/13). Full suite 154/2-flake.
The idalib spike ran on the pilot's own worker (the standalone worker kept
getting reaped in this sandbox).
| -rw-r--r-- | docs/SPLIT_VIEW.md | 12 | ||||
| -rw-r--r-- | idatui/app.py | 57 | ||||
| -rw-r--r-- | idatui/domain.py | 22 | ||||
| -rw-r--r-- | server/patch_server.py | 53 | ||||
| -rw-r--r-- | tests/test_scenarios.py | 21 |
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): |
