aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-07-27 21:50:07 +0200
committerblasty <blasty@local>2026-07-27 21:50:07 +0200
commit1e84c0e79ed76b19923caab250189c17464a5287 (patch)
treedb6f415c3a32fc9cd5eb74bd67479b469fe0854e
parenttrace: docked registers + timeline, and stepping through time (diff)
downloadida-tui-1e84c0e79ed76b19923caab250189c17464a5287.tar.gz
ida-tui-1e84c0e79ed76b19923caab250189c17464a5287.tar.xz
ida-tui-1e84c0e79ed76b19923caab250189c17464a5287.zip
trace: paint the execution trail — including on the decompiler
Both code views now show where you came from and where you're going: the instruction you're on ('now'), the ~96 steps behind it ('past', warm) and the ~96 ahead ('future', cool). A trail, not all of history. Painting every address the trace ever touched says almost nothing on a loop-heavy program; the last and next few dozen steps say how you GOT here. Where an address appears on both sides — a loop body, which is most of them — the nearer side wins, because that's the one explaining the step you just took or are about to. **The pseudocode is painted too**, which is the reason to build this here rather than use Tenet. A trace records instructions, so that's what Tenet paints. We already have decomp_map from the split-view work, saying which instructions each C line covers, so the same trail lands on the decompilation: line 46 now | v3 = getenv("POSIXLY_CORRECT"); line 47 future | v4 = (__int64)*a2; line 49 future | if ( v3 ) A C line covers many instructions, so it takes the strongest kind present: now beats past beats future — if the instruction you're standing on belongs to this line, this line is where you are. Two things kept cheap: the trail is recomputed per SEEK rather than per repaint (~200 lookups, and repaints vastly outnumber steps), and decomp_map is cached per function because it's an RPC and stepping is interactive. The colours sit deliberately under the code palette — the trail says "you came through here", the text still has to read as code. tests: +8 UI (21) — the listing carries now/past/future and it reaches the screen; pseudocode is painted; exactly ONE C line is 'now' and it is the line covering the current instruction (not merely some executed line, which is the mistake this check exists to catch). 209/0 scenarios, 27/0 model, 10/0 diff.
-rw-r--r--README.md5
-rw-r--r--idatui/app.py85
-rw-r--r--idatui/trace.py39
-rw-r--r--tests/test_trace_ui.py56
4 files changed, 184 insertions, 1 deletions
diff --git a/README.md b/README.md
index 42370bc..2d400e5 100644
--- a/README.md
+++ b/README.md
@@ -126,6 +126,11 @@ ones the current instruction wrote are highlighted) and a timeline. `]` and `[`
step one instruction forward and back; `}` and `{` step over a call by following
the stack pointer. The code view follows.
+Both code views are painted with the execution trail: where you just came from,
+where you're about to go, and the instruction you're standing on. The pseudocode
+view is painted too — a trace records instructions, but `decomp_map` says which
+instructions each C line covers, so the same trail lands on the decompilation.
+
Trace addresses are rebased onto the database automatically — a traced process
is relocated, so nothing lines up until that's solved.
diff --git a/idatui/app.py b/idatui/app.py
index 59e3f26..71cd9c4 100644
--- a/idatui/app.py
+++ b/idatui/app.py
@@ -91,6 +91,13 @@ _ASM_KEYWORDS = frozenset({
"gs", "ss", "align", "public", "assume", "end",
})
_S_CURSOR = Style(bgcolor="#2a313c")
+#: Execution trails. Deliberately faint: they sit UNDER the code palette and
+#: must not compete with it — the trail says "you came through here", the text
+#: still has to be readable as code. Now is the loudest because there is exactly
+#: one of it.
+_S_TRAIL_NOW = Style(bgcolor="#3f3410")
+_S_TRAIL_PAST = Style(bgcolor="#2b1c17") # warm: behind you
+_S_TRAIL_FUTURE = Style(bgcolor="#152230") # cool: ahead of you
_S_DIM = Style(color="#7c8b9e", italic=True)
_S_MATCH = Style(bgcolor="#7a5c00") # all search matches
_S_MATCH_CUR = Style(bgcolor="#d0a215", color="#12161c") # the current match
@@ -777,6 +784,8 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru
self._search_loading = False
self._search_pending: list = [] # done-callbacks awaiting the load
self._link_rows: set[int] = set() # split-view: linked instruction rows
+ #: {address: 'now'|'past'|'future'} painted under the code (trace mode).
+ self.trail: dict[int, str] = {}
# -- text helpers ------------------------------------------------------ #
def _head(self, idx: int) -> Head | None:
@@ -1045,6 +1054,12 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru
linked = idx in self._link_rows
if linked:
strip = strip.apply_style(_S_LINK) # split-view companion band
+ if self.trail and h is not None:
+ kind = self.trail.get(h.ea)
+ if kind is not None:
+ strip = strip.apply_style(
+ _S_TRAIL_NOW if kind == "now" else
+ _S_TRAIL_PAST if kind == "past" else _S_TRAIL_FUTURE)
plain = self._line_plain(idx) if (self._hl_word or idx == self.cursor) else None
if idx in self._ranges:
strip = _overlay_ranges(strip, self._ranges[idx], self._match_style(idx))
@@ -1253,6 +1268,9 @@ class DecompView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True
self._gutter = 0 # line-number gutter width (cells)
self._line_eas: list[int | None] = [] # per-line address (marker stripped)
self._link_line: int | None = None # split-view: linked pseudocode line
+ #: {line index: 'now'|'past'|'future'} — the execution trail, mapped from
+ #: instructions onto pseudocode via decomp_map.
+ self.trail: dict[int, str] = {}
self._term = ""
self._matches: list[int] = []
self._ranges: dict[int, list[tuple[int, int]]] = {}
@@ -1386,6 +1404,11 @@ class DecompView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True
base = self._strips[idx]
if linked:
base = base.apply_style(_S_LINK) # split-view companion band
+ kind = self.trail.get(idx) if self.trail else None
+ if kind is not None:
+ base = base.apply_style(
+ _S_TRAIL_NOW if kind == "now" else
+ _S_TRAIL_PAST if kind == "past" else _S_TRAIL_FUTURE)
if idx in self._ranges:
base = _overlay_ranges(base, self._ranges[idx], self._match_style(idx))
if self._hl_word:
@@ -3221,6 +3244,8 @@ class IdaTui(App):
self._load_args = load_args or "" # IDA switches for a headerless blob
self._trace_path = trace_path or "" # Tenet execution trace to explore
self._trace = None # the loaded Trace, once analysed
+ self._trail_map = [] # decomp_map for _trail_map_ea
+ self._trail_map_ea = None
self._t = 0 # current timestamp in that trace
self._do_keepalive = keepalive
self._rpc_path = rpc_path
@@ -5462,9 +5487,69 @@ class IdaTui(App):
return
self._t = max(0, min(int(idx), t.length - 1))
self.query_one(TraceDock).show(t, self._t)
+ self._paint_trail()
if follow:
self._goto_ea(t.ip(self._t), push=False)
+ def _paint_trail(self) -> None:
+ """Push the execution trail into the code views.
+
+ Recomputed per seek rather than per repaint: it's ~200 lookups, and a
+ repaint happens far more often than a step.
+ """
+ t = self._trace
+ if t is None:
+ return
+ trail = t.trail(self._t)
+ try:
+ lst = self.query_one(ListingView)
+ lst.trail = trail
+ lst.refresh()
+ except Exception: # noqa: BLE001 -- view not mounted yet
+ pass
+ self._paint_trail_decomp(trail)
+
+ def _paint_trail_decomp(self, trail: dict) -> None:
+ """Map the instruction trail onto pseudocode lines.
+
+ This is the thing Tenet can't do: it paints disassembly, because that's
+ where a trace's addresses live. We already have decomp_map (built for
+ the split view) saying which instructions each pseudocode line covers,
+ so the same trail lands on C.
+
+ A line covers many instructions, so it takes the strongest kind present:
+ 'now' wins over 'past' wins over 'future' — if the instruction you are
+ standing on is part of this line, this line is where you are.
+ """
+ try:
+ dec = self.query_one(DecompView)
+ except Exception: # noqa: BLE001
+ return
+ ea = dec.loaded_ea
+ if not dec.display or ea is None or self.program is None:
+ dec.trail = {}
+ return
+ if self._trail_map_ea != ea:
+ # Cached per function: decomp_map is an RPC and stepping is
+ # interactive, so paying it per keystroke would be felt.
+ try:
+ self._trail_map = self.program.decomp_map(ea)
+ except Exception: # noqa: BLE001
+ self._trail_map = []
+ self._trail_map_ea = ea
+ rank = {"future": 0, "past": 1, "now": 2}
+ lines: dict[int, str] = {}
+ for i, eas in enumerate(self._trail_map or []):
+ best = None
+ for a in eas:
+ k = trail.get(a)
+ if k is not None and (best is None or rank[k] > rank[best]):
+ best = k
+ if best is not None:
+ lines[i] = best
+ dec.trail = lines
+ dec.refresh()
+
def _step(self, delta: int) -> None:
if self._trace is None:
self._status("no trace loaded (--trace FILE)")
diff --git a/idatui/trace.py b/idatui/trace.py
index 514a97a..2afc8f6 100644
--- a/idatui/trace.py
+++ b/idatui/trace.py
@@ -296,6 +296,45 @@ class Trace:
out[ea] = len(ts)
return out
+ def prev_ips(self, idx: int, n: int) -> list[int]:
+ """Addresses executed in the ``n`` steps before ``idx`` (nearest first).
+
+ A trail, not all of history: showing every address the trace ever
+ touched says almost nothing on a loop-heavy program, whereas the last
+ few dozen steps say how you GOT here.
+ """
+ lo = max(idx - n, 0)
+ return [self.ips[i] + self.slide for i in range(idx - 1, lo - 1, -1)]
+
+ def next_ips(self, idx: int, n: int) -> list[int]:
+ """Addresses executed in the ``n`` steps after ``idx`` (nearest first)."""
+ hi = min(idx + n + 1, self.length)
+ return [self.ips[i] + self.slide for i in range(idx + 1, hi)]
+
+ def trail(self, idx: int, n: int = 96) -> dict[int, str]:
+ """{address: 'now' | 'past' | 'future'} around ``idx``.
+
+ Where an address appears on both sides — a loop body, which is most of
+ them — the nearer side wins, because that's the one that explains the
+ step you are about to take or just took.
+ """
+ out: dict[int, str] = {}
+ for k, ea in enumerate(self.next_ips(idx, n)):
+ out.setdefault(ea, "future")
+ for k, ea in enumerate(self.prev_ips(idx, n)):
+ prev = out.get(ea)
+ if prev is None:
+ out[ea] = "past"
+ elif prev == "future":
+ # Same distance rule as above, resolved by which loop found it
+ # first would be arbitrary; compare real distances instead.
+ fwd = next((i for i, a in enumerate(self.next_ips(idx, n)) if a == ea), n)
+ if k < fwd:
+ out[ea] = "past"
+ if 0 <= idx < self.length:
+ out[self.ips[idx] + self.slide] = "now"
+ return out
+
def first_execution(self, ea: int) -> int | None:
ts = self.executions(ea)
return ts[0] if ts else None
diff --git a/tests/test_trace_ui.py b/tests/test_trace_ui.py
index a30362f..a85c089 100644
--- a/tests/test_trace_ui.py
+++ b/tests/test_trace_ui.py
@@ -16,7 +16,8 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from textual.widgets import Static # noqa: E402
-from idatui.app import IdaTui, ListingView, TraceDock # noqa: E402
+from idatui.app import (DecompView, IdaTui, ListingView, # noqa: E402
+ TraceDock)
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TRACER = os.path.expanduser(
@@ -140,6 +141,59 @@ async def run() -> int:
app._t == ret, f"{i} -> {app._t}, expected {ret}")
check("which is further than a plain step", app._t > i + 1)
+ # -- trails ------------------------------------------------------ #
+ # Not "every address the trace ever touched": on a loop-heavy
+ # program that's almost everything and says nothing. The last/next
+ # few dozen steps say how you got here and where you're going.
+ app._seek(min(40, t.length - 1))
+ await pilot.pause(0.6)
+ trail = lst.trail
+ kinds = {k for k in trail.values()}
+ check("the listing is painted with an execution trail",
+ {"now", "past", "future"} <= kinds, f"{sorted(kinds)}")
+ check("'now' is the instruction we're standing on",
+ trail.get(t.ip(app._t)) == "now", f"{trail.get(t.ip(app._t))}")
+ check("the step behind is past, the step ahead is future",
+ trail.get(t.ip(app._t - 1)) == "past"
+ and trail.get(t.ip(app._t + 1)) == "future",
+ f"{trail.get(t.ip(app._t - 1))}, {trail.get(t.ip(app._t + 1))}")
+ painted = [y for y in range(min(lst.size.height, 30))
+ if any(seg.style and seg.style.bgcolor
+ for seg in lst.render_line(y))]
+ check("and it actually reaches the screen", painted, "no tinted rows")
+
+ # -- the same trail on PSEUDOCODE -------------------------------- #
+ # The point of doing this in our app rather than using Tenet: a
+ # trace's addresses are instructions, but decomp_map (built for the
+ # split view) says which instructions each pseudocode line covers,
+ # so the trail lands on C.
+ main_ea = app.program.resolve("main")
+ first = t.first_execution(main_ea)
+ if first is None:
+ check("main was executed in the trace", False)
+ else:
+ app._seek(first + 12)
+ await wait(lambda: app._t == first + 12, pilot, 20)
+ lst.focus()
+ await pilot.press("tab")
+ got = await wait(lambda: app.query_one(DecompView).display
+ and app.query_one(DecompView)._texts, pilot, 120)
+ dec = app.query_one(DecompView)
+ check("pseudocode is available for the traced function", got)
+ app._seek(first + 12)
+ await pilot.pause(1.0)
+ check("pseudocode lines are painted with the trail",
+ len(dec.trail) > 2, f"{len(dec.trail)} lines")
+ now = [i for i, k in dec.trail.items() if k == "now"]
+ check("exactly one pseudocode line is 'now'",
+ len(now) == 1, f"{now}")
+ # The 'now' line must be the one covering the current
+ # instruction, not merely some executed line.
+ covered = app._trail_map[now[0]] if now and app._trail_map else []
+ check("and it's the line covering the current instruction",
+ t.ip(app._t) in covered,
+ f"pc={t.ip(app._t):#x} line covers {[hex(a) for a in covered][:4]}")
+
print(f"\n{PASS} passed, {FAIL} failed")
return 1 if FAIL else 0