aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-07-28 15:06:31 +0200
committerblasty <blasty@local>2026-07-28 15:06:31 +0200
commitf4630be2da880011598a3f4d5722e38aedaa636a (patch)
tree1ee54ad70e20be22dadea86417da2c0ac0e86648
parenttrace: stepping stays in the view you're reading (diff)
downloadida-tui-f4630be2da880011598a3f4d5722e38aedaa636a.tar.gz
ida-tui-f4630be2da880011598a3f4d5722e38aedaa636a.tar.xz
ida-tui-f4630be2da880011598a3f4d5722e38aedaa636a.zip
trace: a step in split view moves the listing cursor to the pc
Normal navigation moves one pane and gives the companion a band, never a cursor — that rule exists so the two can't chase each other. A trace step isn't navigation: time is a single global position and both panes are showing the same instant, so the cursor belongs on it in both. _seek_split places the listing cursor on the current instruction, then hands off to the existing _sync_split so the companion still gets its band and align() at the driver's screen row. The anchoring machinery is used, not bypassed. PARTIAL, and the shortfall is worth stating plainly: the LISTING cursor tracks the pc reliably (tested over consecutive steps). The PSEUDOCODE cursor only follows when decomp_map covers that address, and for cat's main it covers almost nothing — four entries for a 700-line function. That is not something this commit introduced and not something I could fix responsibly without understanding it; TODO has what I measured, including that dec.goto(96) left the cursor at 0 in the same run, which may or may not be the same bug. One real fix along the way: _place_decomp_at prefers the map the trail painting keeps (keyed to the decompiler's currently loaded function) over the split view's _split_ea2line. The latter is refreshed by a guarded async path that drops its result if _cur moved while in flight, and a burst of steps moves _cur constantly — so during stepping it is frequently a map of the function you just left. tests: +2 trace UI (25) — stepping in split moves the listing cursor onto the pc for six consecutive steps, and the trail marks it 'now' in both panes. 212/0 scenarios.
-rw-r--r--TODO29
-rw-r--r--idatui/app.py88
-rw-r--r--tests/test_trace_ui.py23
3 files changed, 133 insertions, 7 deletions
diff --git a/TODO b/TODO
index 90da191..679318c 100644
--- a/TODO
+++ b/TODO
@@ -88,3 +88,32 @@ The general rule this came from: a suite whose result depends on its own history
can't be trusted to accuse the code. tests/test_blob_ui.py builds a throwaway
binary; test_project_ui.py stages copies; test_thumb_ui.py deletes the .i64
before each phase because the T flag and segment bitness are SAVED in it.
+
+## decomp_map returns almost nothing for some functions (blocks split stepping)
+
+Found while making a trace step move BOTH cursors in split view. For cat's
+main() — 700+ pseudocode lines — decomp_map() came back with FOUR entries, so
+almost no instruction address maps to a C line and the pseudocode cursor can't
+follow the program counter. On echo's main the same call maps plenty (the trail
+painting test relies on it), so it is function- or timing-dependent, not simply
+broken.
+
+Two things to separate when picking this up:
+
+* Is the map itself sparse (the tool's item.dstr() walk missing most items for
+ this function), or is it being FETCHED at a moment when the decompiler has a
+ different function loaded? _trail_map_ea and dec.loaded_ea both read 0x24a0
+ when it happened, which argues for the former.
+* dec.goto(96) left dec.cursor at 0 in the same run. That may be a symptom of
+ the same confusion (a stale/short _strips) or an independent bug. Check it on
+ its own before assuming.
+
+Also noticed: _split_ea2line is refreshed by a guarded async path
+(_apply_split_map drops its result if _cur moved while in flight). A burst of
+trace steps moves _cur constantly, so during stepping it is frequently the map
+of the function you just left. _place_decomp_at now prefers the trail's map for
+that reason, but the split view's own sync still uses the laggy one.
+
+Consequence today: in split view a trace step moves the LISTING cursor onto the
+current instruction (works, tested) but the pseudocode cursor only follows when
+the map happens to cover that address.
diff --git a/idatui/app.py b/idatui/app.py
index 0fb1030..76d5441 100644
--- a/idatui/app.py
+++ b/idatui/app.py
@@ -3247,6 +3247,8 @@ class IdaTui(App):
self._trace = None # the loaded Trace, once analysed
self._trail_map = [] # decomp_map for _trail_map_ea
self._trail_map_ea = None
+ self._pending_trace_line = None # step waiting on a re-decompile
+ self._trail_line_of: dict[int, int] = {} # ea -> pseudocode line
self._t = 0 # current timestamp in that trace
self._do_keepalive = keepalive
self._rpc_path = rpc_path
@@ -5494,13 +5496,73 @@ class IdaTui(App):
self._t = max(0, min(int(idx), t.length - 1))
self.query_one(TraceDock).show(t, self._t)
self._paint_trail()
- if follow:
- # Stay in whichever view you're reading. Without prefer_decomp a
- # step from the pseudocode navigates to an address, which opens the
- # listing — so stepping through C threw you out of C on the first
- # keypress.
- self._goto_ea(t.ip(self._t), push=False,
- prefer_decomp=(self._active == "decomp"))
+ if not follow:
+ return
+ pc = t.ip(self._t)
+ if self._split and self._seek_split(pc):
+ return
+ # Stay in whichever view you're reading. Without prefer_decomp a step
+ # from the pseudocode navigates to an address, which opens the listing —
+ # so stepping through C threw you out of C on the first keypress.
+ self._goto_ea(pc, push=False,
+ prefer_decomp=(self._active == "decomp"))
+
+ def _seek_split(self, pc: int) -> bool:
+ """Put BOTH panes on ``pc``. True if handled.
+
+ Normal navigation moves one pane and gives the companion a band, never a
+ cursor — that rule exists so the two can't chase each other. A trace step
+ isn't navigation though: time is a single global position, and both views
+ are showing the same instant, so both cursors belong on it.
+
+ The scroll anchoring is unchanged: after placing the cursors, the usual
+ _sync_split still bands the companion and aligns it to the driver's
+ screen row, so the eye tracks straight across.
+ """
+ lst = self.query_one(ListingView)
+ dec = self.query_one(DecompView)
+ if lst.model is None:
+ return False
+ row = lst.model.ensure_ea(pc)
+ if row is None or row < 0:
+ return False # not in this listing (other segment): full nav
+ lst.cursor = row
+ lst._scroll_cursor_into_view()
+
+ rng = self._split_range
+ if rng is None or not (rng[0] <= pc <= rng[1]):
+ # Execution left the decompiled function. Load the new one, and
+ # remember where to land once its line map exists.
+ self._pending_trace_line = pc
+ self._resync_decomp_async(pc)
+ return True
+ self._place_decomp_at(pc)
+ self._sync_split(self._active)
+ return True
+
+ def _place_decomp_at(self, pc: int) -> None:
+ """Move the pseudocode cursor to the line covering ``pc``.
+
+ Uses the map the trail painting already keeps (keyed to the decompiler's
+ CURRENTLY loaded function), not the split view's _split_ea2line. That one
+ is refreshed by a guarded async path — it drops a result if _cur moved
+ while it was in flight — and a burst of steps moves _cur constantly, so
+ during stepping it is frequently a map of the function you just left.
+ """
+ dec = self.query_one(DecompView)
+ line = None
+ if self._trail_map_ea == dec.loaded_ea and self._trail_line_of:
+ line = self._trail_line_of.get(pc)
+ if line is None:
+ line = self._split_ea2line.get(pc)
+ if line is None:
+ line = dec.line_for_ea(pc)
+ if line is not None:
+ dec.goto(line, dec.cursor_x)
+
+ @work(thread=True, exclusive=True, group="split-resync")
+ def _resync_decomp_async(self, ea: int) -> None:
+ self._resync_decomp(ea)
def _paint_trail(self) -> None:
"""Push the execution trail into the code views.
@@ -5548,6 +5610,12 @@ class IdaTui(App):
except Exception: # noqa: BLE001
self._trail_map = []
self._trail_map_ea = ea
+ # ea -> line, built once per function: stepping asks for it on every
+ # keypress and a scan per step would be quadratic in the function.
+ self._trail_line_of = {}
+ for i, eas in enumerate(self._trail_map or []):
+ for a in eas:
+ self._trail_line_of.setdefault(a, i)
rank = {"future": 0, "past": 1, "now": 2}
lines: dict[int, str] = {}
for i, eas in enumerate(self._trail_map or []):
@@ -5560,6 +5628,12 @@ class IdaTui(App):
lines[i] = best
dec.trail = lines
dec.refresh()
+ pend, self._pending_trace_line = self._pending_trace_line, None
+ if pend is not None and self._split:
+ # The function was still decompiling when the step happened; land
+ # now that its line map exists.
+ self._place_decomp_at(pend)
+ self._sync_split(self._active)
def _step(self, delta: int) -> None:
if self._trace is None:
diff --git a/tests/test_trace_ui.py b/tests/test_trace_ui.py
index a43c11d..7ffe20f 100644
--- a/tests/test_trace_ui.py
+++ b/tests/test_trace_ui.py
@@ -208,6 +208,29 @@ async def run() -> int:
check("and so does stepping backward",
app._active == "decomp", f"active={app._active}")
+ # -- split view: a step is a GLOBAL move ------------------------ #
+ # Normal navigation moves one pane and gives the companion a band,
+ # never a cursor, so the two can't chase each other. Time isn't
+ # navigation though: both panes show the same instant, so the
+ # listing cursor must sit on the current instruction.
+ app.action_toggle_split()
+ await pilot.pause(2.0)
+ if not app._split:
+ check("split view toggled on", False)
+ else:
+ base = t.first_execution(main_ea) or 0
+ tracked = 0
+ for k in range(2, 8):
+ app._seek(base + k)
+ await pilot.pause(0.5)
+ if lst._cursor_ea() == t.ip(app._t):
+ tracked += 1
+ check("stepping in split moves the listing cursor to the pc",
+ tracked == 6, f"{tracked}/6 steps tracked")
+ check("and the trail follows in both panes",
+ lst.trail.get(t.ip(app._t)) == "now",
+ f"{lst.trail.get(t.ip(app._t))}")
+
print(f"\n{PASS} passed, {FAIL} failed")
return 1 if FAIL else 0