aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--.auto/bench.py633
-rw-r--r--.auto/cfg-corpus.json1
-rw-r--r--.auto/check_edit.py126
-rw-r--r--.auto/check_rename.py120
-rw-r--r--.auto/check_search.py148
-rwxr-xr-x.auto/checks.sh81
-rw-r--r--.auto/diff_spans.py116
-rw-r--r--.auto/ideas.md331
-rw-r--r--.auto/log.jsonl53
-rwxr-xr-x.auto/measure.sh14
-rw-r--r--.auto/parked/check_decomp.py110
-rw-r--r--.auto/parked/fast_decompile.patch122
-rw-r--r--.auto/parked/fast_pc_nums.patch52
-rw-r--r--.auto/prompt.md275
-rw-r--r--.auto/wip-chunk.patch53
-rw-r--r--.auto/wip-decompmap.patch49
-rw-r--r--.auto/wip-digest.patch162
-rw-r--r--.auto/wip-renamekeep.patch211
-rw-r--r--.fastfeedback/SPEED.md297
-rw-r--r--.git-blame-ignore-revs5
-rwxr-xr-x.githooks/pre-commit53
-rw-r--r--.gitignore6
-rw-r--r--CONTRIBUTING.md89
-rw-r--r--LICENSE21
-rw-r--r--README.md142
-rw-r--r--TODO9
-rw-r--r--docs/CODEMODE_UPSTREAM.md269
-rw-r--r--docs/GRAPH_VIEW.md106
-rw-r--r--docs/NEXUS_UPSTREAM.md385
-rw-r--r--docs/PAGING_FINDINGS.md14
-rw-r--r--docs/PROJECTS.md10
-rw-r--r--docs/SPLIT_VIEW.md8
-rw-r--r--docs/TEXTUAL_NOTES.md9
-rw-r--r--experiments/bench_ops.py116
-rw-r--r--experiments/bench_pack_trace.py79
-rw-r--r--experiments/call_census.py174
-rw-r--r--experiments/cfg_dump.py41
-rw-r--r--experiments/graph_shot.py39
-rw-r--r--experiments/graph_smoke.py35
-rw-r--r--experiments/graph_spike.py70
-rw-r--r--experiments/inproc_spike.py112
-rw-r--r--experiments/modal_shot.py70
-rw-r--r--experiments/opfmt_tools.py241
-rw-r--r--experiments/profile_client.py76
-rw-r--r--experiments/profile_remote.py73
-rw-r--r--experiments/splash_place_count.py102
-rw-r--r--experiments/worker_smoke.py15
-rwxr-xr-xida-tui2
-rw-r--r--idatui/__init__.py36
-rw-r--r--idatui/__main__.py1
-rw-r--r--idatui/_sync.py1
-rw-r--r--idatui/app.py2524
-rw-r--r--idatui/codemode_client.py1550
-rw-r--r--idatui/diag.py10
-rw-r--r--idatui/domain.py839
-rw-r--r--idatui/drive.py87
-rw-r--r--idatui/edit_ctl.py207
-rw-r--r--idatui/errors.py3
-rw-r--r--idatui/findings.py140
-rw-r--r--idatui/formats.py2
-rw-r--r--idatui/graph.py323
-rw-r--r--idatui/graph_triskel.py516
-rw-r--r--idatui/highlight.py36
-rw-r--r--idatui/index.py26
-rw-r--r--idatui/journal.py18
-rw-r--r--idatui/kittygfx.py70
-rw-r--r--idatui/launch.py149
-rw-r--r--idatui/nexus_client.py551
-rw-r--r--idatui/pane.py330
-rw-r--r--idatui/pool.py99
-rw-r--r--idatui/project.py104
-rw-r--r--idatui/prompt.py3
-rw-r--r--idatui/remote_ops.py1742
-rw-r--r--idatui/remote_tools.py973
-rw-r--r--idatui/rpc.py725
-rw-r--r--idatui/rpcclient.py9
-rw-r--r--idatui/search.py9
-rw-r--r--idatui/trace.py47
-rw-r--r--idatui/trace_ctl.py87
-rw-r--r--plan/rpc.md20
-rw-r--r--pyproject.toml14
-rw-r--r--rehearsed-engineer.md6
-rw-r--r--ruff.toml40
-rw-r--r--tests/_fixtures.py13
-rwxr-xr-xtests/run.py97
-rw-r--r--tests/test_blob_ui.py284
-rw-r--r--tests/test_codemode_client.py162
-rw-r--r--tests/test_diag.py93
-rw-r--r--tests/test_findings.py219
-rw-r--r--tests/test_formats.py170
-rw-r--r--tests/test_graph.py269
-rw-r--r--tests/test_index.py283
-rw-r--r--tests/test_kittygfx.py258
-rw-r--r--tests/test_launch.py30
-rw-r--r--tests/test_nexus_client.py499
-rw-r--r--tests/test_pool.py183
-rw-r--r--tests/test_project.py231
-rw-r--r--tests/test_project_ui.py341
-rw-r--r--tests/test_rawimage_rpc.py196
-rw-r--r--tests/test_scenarios.py3675
-rw-r--r--tests/test_search.py109
-rw-r--r--tests/test_thumb_ui.py218
-rw-r--r--tests/test_trace.py248
-rw-r--r--tests/test_trace_rpc.py259
-rw-r--r--tests/test_trace_ui.py358
-rw-r--r--tests/test_trace_vs_tenet.py64
-rw-r--r--tools/demo.py75
-rw-r--r--tools/make_logo_ans.py28
-rw-r--r--tools/verify_procs.py6
-rw-r--r--uv.lock363
110 files changed, 15494 insertions, 9859 deletions
diff --git a/.auto/bench.py b/.auto/bench.py
deleted file mode 100644
index b4f5793..0000000
--- a/.auto/bench.py
+++ /dev/null
@@ -1,633 +0,0 @@
-#!/usr/bin/env python3
-"""ida-tui performance benchmark.
-
-Boots the real Textual app over a real idalib worker (a staged copy of a target,
-seeded from a pristine .i64 so auto-analysis is not paid per run) and times a
-scripted, deterministic set of the things a user actually waits on:
-
- boot worker spawn + db open + function index complete + usable
- listing sweeping the unified listing (cold pages, then warm)
- render render_line throughput on a warm listing
- decomp F5 -> decompile -> highlight -> paint, per function
- graph flowchart -> Sugiyama layout -> paint, per function
- search incremental search over the whole segment
- index function index load_all (3 filters) + fuzzy palette filter
- hex hex view scroll sweep
- pure_graph offline layout over a frozen CFG corpus (no IDA at all)
-
-Two targets are benched so nothing tunes for one scale: a small binary (echo,
-~128 funcs) and a big one (bash, ~2100 funcs).
-
-Every phase is deterministic: the function set is "largest by size, ties by
-address, skipping any the graph view refuses" -- never "whatever is biggest
-right now", which drifts with analysis.
-
-Prints `METRIC name=value` lines; the primary metric is `total_ms`. Exits
-non-zero if any phase failed to do its work (a phase that silently no-ops would
-otherwise look like a huge speedup).
-
- ~/ida-venv/bin/python .auto/bench.py
-"""
-from __future__ import annotations
-
-import argparse
-import asyncio
-import json
-import os
-import shutil
-import statistics
-import sys
-import tempfile
-import time
-
-HERE = os.path.dirname(os.path.abspath(__file__))
-ROOT = os.path.dirname(HERE)
-sys.path.insert(0, ROOT)
-sys.path.insert(0, os.path.join(ROOT, "tests"))
-
-from idatui.app import ( # noqa: E402
- DecompView, GraphView, HexView, IdaTui, ListingView,
-)
-from idatui._sync import wait_for # noqa: E402
-
-SIZE = (140, 44)
-TIMES: dict[str, list[float]] = {}
-NOTES: dict[str, object] = {}
-FAILS: list[str] = []
-PREFIX = ""
-
-
-class _T:
- def __init__(self, key):
- self.key = PREFIX + key
-
- def __enter__(self):
- self.t = time.perf_counter()
- return self
-
- def __exit__(self, *a):
- TIMES.setdefault(self.key, []).append((time.perf_counter() - self.t) * 1000)
- return False
-
-
-def note(k, v):
- NOTES[PREFIX + k] = v
-
-
-def fail(msg):
- FAILS.append(PREFIX + msg)
-
-
-#: Poll interval for every "has it landed yet" wait. The phases time the wait,
-#: so the interval is measurement overhead: at the 10ms this used to use, a
-#: graph open that really took 30ms was charged up to 40, and the graph phase
-#: (24 timed waits) carried ~12% of pure quantisation.
-_STEP = 0.002
-
-
-async def _wait(pilot, pred, t=60.0, step=_STEP):
- return await wait_for(pred, pilot.pause, t, step)
-
-
-def _paint(view) -> int:
- """Render every visible row; returns total cells painted."""
- n = 0
- for y in range(view.size.height):
- n += len(view.render_line(y).text)
- return n
-
-
-# --------------------------------------------------------------------------- #
-# phases
-# --------------------------------------------------------------------------- #
-async def phase_nav(app, pilot, funcs):
- """Jump to each function's entry in the unified listing, cold.
-
- This is `g <addr>`, follow, and every xref landing: the listing model has to
- have walked heads as far as the target before the cursor can sit on it. The
- order deliberately alternates far and near (last, first, second-last, ...)
- so it measures random access, not one tidy forward sweep.
- """
- lst = app.query_one(ListingView)
- order = []
- lo, hi = 0, len(funcs) - 1
- while lo <= hi:
- order.append(funcs[hi])
- if lo != hi:
- order.append(funcs[lo])
- lo, hi = lo + 1, hi - 1
- worst = 0.0
- with _T("nav_ms"):
- for fn in order:
- t0 = time.perf_counter()
- app._open_function(fn.addr, fn.name)
- if not await _wait(pilot, lambda fn=fn: app._cur is not None
- and app._cur.ea == fn.addr and lst.total > 0
- and lst._cursor_ea() == fn.addr, 120):
- fail(f"nav:{fn.name}")
- worst = max(worst, (time.perf_counter() - t0) * 1000)
- note("nav_worst_ms", round(worst, 1))
- note("nav_rows", lst.total)
-
-
-async def phase_listing(app, pilot, funcs, pages, first):
- """Sweep the unified listing, painting each viewport. The first pass pulls
- cold pages over the worker; the second is all-cached, i.e. pure python."""
- lst = app.query_one(ListingView)
- fn = funcs[0]
- app._open_function(fn.addr, fn.name)
- if not await _wait(pilot, lambda: app._cur is not None and app._cur.ea == fn.addr
- and lst.total > 0, 60):
- fail("listing:open")
- return
- app._active = "listing"
- app._show_active()
- lst.focus()
- await pilot.pause(0.02)
- height = max(lst.size.height, 1)
-
- async def sweep():
- cells = 0
- for i in range(pages):
- lst.model.ensure(i * height + height + 1)
- lst.cursor = min(max(lst.total - 1, 0), i * height)
- lst._scroll_cursor_into_view()
- await pilot.pause(0)
- cells += _paint(lst)
- return cells
-
- if first:
- # Only on the first repetition: after it the pages are cached and this
- # would be a second warm sweep wearing the cold sweep's name.
- with _T("listing_cold_ms"):
- c = await sweep()
- note("listing_rows", lst.total)
- note("listing_cells", c)
- else:
- await sweep()
- with _T("listing_warm_ms"):
- await sweep()
-
-
-async def phase_render(app, pilot, funcs, frames):
- """render_line throughput on a warm listing with the cursor moving every
- frame -- the steady state of holding down j."""
- lst = app.query_one(ListingView)
- app._active = "listing"
- app._show_active()
- lst.focus()
- await pilot.pause(0.02)
- n = 0
- with _T("render_ms"):
- for i in range(frames):
- lst.cursor = (i * 7) % max(lst.total, 1)
- for y in range(lst.size.height):
- n += len(lst.render_line(y).text)
- note("render_cells", n)
-
-
-async def phase_decomp(app, pilot, funcs):
- """F5 out of the listing into the pseudocode, then read down it.
-
- Navigation is done OUTSIDE the timer -- phase_nav already charges for that,
- and counting it twice would let a nav win look like a decompiler win.
- """
- dv = app.query_one(DecompView)
- lst = app.query_one(ListingView)
- ok = lines = 0
- spent = 0.0
- for fn in funcs:
- app._open_function(fn.addr, fn.name)
- if not await _wait(pilot, lambda fn=fn: app._cur is not None
- and app._cur.ea == fn.addr and lst.total > 0
- and lst._cursor_ea() == fn.addr, 120):
- fail(f"decomp:open:{fn.name}")
- continue
- app._active = "listing"
- app._show_active()
- lst.focus()
- await pilot.pause(0.02)
- t0 = time.perf_counter()
- app.action_toggle_view()
- if not await _wait(pilot, lambda fn=fn: dv.loaded_ea == fn.addr, 60):
- fail(f"decomp:{fn.name}")
- continue
- app._active = "decomp"
- app._show_active()
- await pilot.pause(0)
- for _ in range(4): # scroll the body, painting each page
- _paint(dv)
- dv.cursor = min(max(dv.total - 1, 0), dv.cursor + dv.size.height)
- dv._scroll_cursor_into_view()
- await pilot.pause(0)
- spent += (time.perf_counter() - t0) * 1000
- ok += 1
- lines += dv.total
- TIMES.setdefault(PREFIX + "decomp_ms", []).append(spent)
- app._active = "listing"
- app._show_active()
- note("decomp_ok", ok)
- note("decomp_lines", lines)
-
-
-async def phase_graph(app, pilot, funcs):
- gv = app.query_one(GraphView)
- ok = blocks = 0
- spent = 0.0
- # Space on a function you have not graphed before is the case that hurts:
- # it pays the flowchart tool, a heads walk over the function's extent, and
- # the layout. Program caches flowcharts per function, so without this the
- # phase would time a dict lookup.
- app.program._flowcharts.clear()
- for fn in funcs:
- app._open_function(fn.addr, fn.name)
- if not await _wait(pilot, lambda fn=fn: app._cur is not None
- and app._cur.ea == fn.addr, 120):
- fail(f"graph:open:{fn.name}")
- continue
- app._graph_sticky = True
- gv.fc = None
- gv.lay = None
- t0 = time.perf_counter()
- app._load_graph(fn.addr, fn.addr)
- got = await _wait(pilot, lambda fn=fn: gv.fc is not None
- and gv.fc.func_ea == fn.addr and gv.lay is not None, 60)
- app._graph_sticky = False
- if not got:
- fail(f"graph:{fn.name}")
- continue
- app._active = "graph"
- app._show_active()
- await pilot.pause(0)
- _paint(gv)
- for _ in range(3): # pan the canvas, painting each frame
- gv.scroll_to(y=min(gv.lay.height, round(gv.scroll_offset.y)
- + gv.size.height), animate=False)
- await pilot.pause(0)
- _paint(gv)
- spent += (time.perf_counter() - t0) * 1000
- ok += 1
- blocks += len(gv.lay.nodes)
- TIMES.setdefault(PREFIX + "graph_ms", []).append(spent)
- app._active = "listing"
- app._show_active()
- note("graph_ok", ok)
- note("graph_blocks", blocks)
-
-
-async def phase_split(app, pilot, funcs):
- """`s` — listing and pseudocode side by side, cursor-linked.
-
- The link is driven by ``decomp_map``: for every pseudocode line, the set of
- instructions the decompiler attributes to it. That is the whole cost of the
- feature and the bench did not cover it at all.
- """
- dv = app.query_one(DecompView)
- lst = app.query_one(ListingView)
- ok = mapped = 0
- spent = 0.0
- for fn in funcs:
- app._open_function(fn.addr, fn.name)
- if not await _wait(pilot, lambda fn=fn: app._cur is not None
- and app._cur.ea == fn.addr and lst.total > 0
- and lst._cursor_ea() == fn.addr, 120):
- fail(f"split:open:{fn.name}")
- continue
- app._active = "listing"
- app._show_active()
- lst.focus()
- await pilot.pause(0.02)
- t0 = time.perf_counter()
- app.action_toggle_split()
- got = await _wait(pilot, lambda fn=fn: app._split and lst.display
- and dv.display and dv.loaded_ea == fn.addr, 120)
- # The region map is what the split view is FOR; wait for it, not just
- # for two panes to appear.
- m = app.program.decomp_map(fn.addr)
- spent += (time.perf_counter() - t0) * 1000
- if not got:
- fail(f"split:{fn.name}")
- else:
- ok += 1
- mapped += sum(1 for eas in m if eas)
- _paint(lst)
- _paint(dv)
- if app._split:
- app.action_toggle_split()
- await _wait(pilot, lambda: not app._split, 30)
- TIMES.setdefault(PREFIX + "split_ms", []).append(spent)
- app._active = "listing"
- app._show_active()
- note("split_ok", ok)
- note("split_mapped_lines", mapped)
-
-
-async def phase_rename(app, pilot, funcs):
- """Rename a function and get the listing back — the commonest RE operation.
-
- A rename invalidates cached names everywhere, and the question this measures
- is what the *listing* then costs: the cursor has to land back on the same row
- with the new name showing. Renames are undone afterwards so the .i64 the
- bench stages from is never left edited.
- """
- lst = app.query_one(ListingView)
- ed = app.program
- done = 0
- spent = 0.0
- tag = f"_bench_{os.getpid()}"
- for k, fn in enumerate(funcs[:6]):
- app._open_function(fn.addr, fn.name)
- if not await _wait(pilot, lambda fn=fn: app._cur is not None
- and app._cur.ea == fn.addr and lst.total > 0
- and lst._cursor_ea() == fn.addr, 120):
- fail(f"rename:open:{fn.name}")
- continue
- row = lst.cursor
- new = f"{tag}_{k}"
- t0 = time.perf_counter()
- try:
- ed.client.call("rename",
- batch={"func": [{"addr": hex(fn.addr), "name": new}]})
- except Exception: # noqa: BLE001
- fail(f"rename:call:{fn.name}")
- continue
- ed.bump_names()
- # What the app does next: reopen the listing where it was and paint it.
- lm = ed.listing(fn.addr)
- idx = max(lm.ensure_ea(fn.addr), 0) if lm is not None else 0
- lst.load(lm, new, cursor=idx, scroll_y=max(idx - 6, 0))
- got = await _wait(pilot, lambda fn=fn: lst.total > 0
- and lst._cursor_ea() == fn.addr, 120)
- await pilot.pause(0)
- _paint(lst)
- spent += (time.perf_counter() - t0) * 1000
- # The name lives on the `proc` banner row, a few rows above the code row
- # ensure_ea lands on (banner rows aren't address-indexed), so look at the
- # window rather than the single row.
- shows_new = False
- if lm is not None:
- for k2 in range(max(idx - 4, 0), idx + 2):
- h = lm.get(k2)
- if h is not None and (new in (h.text or "")
- or new == (h.name or "")):
- shows_new = True
- break
- if not (got and idx == row and shows_new):
- fail(f"rename:{fn.name}:row={idx}/{row} shows_new={shows_new}")
- else:
- done += 1
- try: # put it back, whatever happened above
- ed.client.call("rename",
- batch={"func": [{"addr": hex(fn.addr),
- "name": fn.name}]})
- ed.bump_names()
- except Exception: # noqa: BLE001
- fail(f"rename:undo:{fn.name}")
- TIMES.setdefault(PREFIX + "rename_ms", []).append(spent)
- note("rename_ok", done)
-
-
-async def phase_search(app, pilot, funcs, terms):
- """The real incremental-search path: search_begin, then one search_update per
- typed character, exactly as the Input's on_changed drives it."""
- lst = app.query_one(ListingView)
- app._active = "listing"
- app._show_active()
- lst.focus()
- await pilot.pause(0.02)
- hits = 0
- with _T("search_ms"):
- for term in terms:
- lst.search_begin(1)
- for i in range(1, len(term) + 1):
- lst.search_update(term[:i])
- if not await _wait(pilot, lambda: bool(lst._matches), 180):
- fail(f"search:{term}")
- hits += len(lst._matches)
- lst.search_cancel()
- await pilot.pause(0)
- note("search_hits", hits)
-
-
-async def phase_index(app, pilot, funcs):
- from idatui.app import _fuzzy # noqa: PLC0415
- prog = app.program
- with _T("index_ms"):
- for filt in (None, "sub_*", "*a*"):
- prog.functions(filt).load_all()
- n = len(prog.functions())
- note("index_n", n)
- if n == 0:
- fail("index:empty")
- names = [f.name for f in prog.functions().all_loaded()]
- with _T("palette_ms"):
- tot = 0
- for q in ("m", "mn", "sub", "prnt", "ab", "str"):
- for nm in names:
- tot += 1 if _fuzzy(nm, q) else 0
- note("palette_hits", tot)
-
-
-async def phase_hex(app, pilot, funcs, frames):
- hx = app.query_one(HexView)
- fn = funcs[0]
- app._open_function(fn.addr, fn.name)
- await _wait(pilot, lambda: app._cur is not None and app._cur.ea == fn.addr, 60)
- app.action_hex()
- if not await _wait(pilot, lambda: app._active == "hex" and hx.model is not None, 60):
- fail("hex:open")
- return
- await pilot.pause(0.05)
- height = max(hx.size.height, 1)
- with _T("hex_ms"):
- for i in range(frames):
- row = (i * height) % max(hx.total, 1)
- hx.cursor = row * 16
- hx.model.ensure(row, height)
- hx._apply_scroll(row)
- await pilot.pause(0)
- _paint(hx)
- app._active = "listing"
- app._show_active()
-
-
-def phase_pure_graph(corpus):
- from idatui import graph as G # noqa: PLC0415
- with open(corpus) as fh:
- recs = json.load(fh)
- n = 0
- with _T("pure_graph_ms"): # called three times; the median is taken
- for rec in recs:
- texts = {b["id"]: list(b["lines"]) for b in rec["blocks"]}
- blocks = [G.Block(id=b["id"], start=b["start"], end=b["end"],
- succs=[(d, k) for d, k in b["succs"]])
- for b in rec["blocks"]]
-
- def sizer(b, texts=texts):
- lines = texts[b.id]
- label = f"loc_{b.start:X}"
- widest = max([len(label) + 4] + [len(ln) for ln in lines] or [4])
- return (widest + 4, max(len(lines), 1) + 2)
-
- lay = G.layout(blocks, sizer)
- n += len(lay.nodes)
- NOTES["pure_graph_nodes"] = n
- if n == 0:
- FAILS.append("pure_graph:empty")
-
-
-# --------------------------------------------------------------------------- #
-def stage(binary):
- """A temp-dir copy of ``binary`` seeded from its pre-analysed database.
-
- Prefers ``<bin>.pristine.i64`` (what tests/_fixtures builds) and falls back
- to a tracked ``<bin>.i64``. Never touches either.
- """
- d = tempfile.mkdtemp(prefix="idatui-bench-")
- tgt = os.path.join(d, os.path.basename(binary))
- shutil.copy2(binary, tgt)
- for cand in (binary + ".pristine.i64", binary + ".i64"):
- if os.path.exists(cand):
- shutil.copy2(cand, tgt + ".i64")
- break
- else:
- raise SystemExit(f"{binary}: no pre-analysed .i64 -- bench would time "
- f"auto-analysis, not ida-tui")
- return d, tgt
-
-
-async def run_target(binary, reps, nfuncs, pages, frames, terms, skip=0):
- d, target = stage(binary)
- try:
- app = IdaTui(open_path=target, keepalive=False)
- t0 = time.perf_counter()
- async with app.run_test(size=SIZE) as pilot:
- # Usable = index complete AND the loading overlay handed the screen
- # back (auto-land fired). Benching with the modal still up silently
- # disables every priority binding, so nothing a phase presses lands.
- if not await _wait(pilot, lambda: app._func_index is not None
- and app._func_index.complete, 600):
- fail("boot:index")
- return
- await _wait(pilot, lambda: app._loading_screen is None
- and len(app.screen_stack) == 1, 120)
- TIMES.setdefault(PREFIX + "boot_ms", []).append(
- (time.perf_counter() - t0) * 1000)
- idx = app._func_index
- idx.load_all()
- allf = idx.all_loaded()
- note("funcs", len(allf))
- # Largest by size, ties by address, skipping any function the graph
- # view refuses (>GRAPH_MAX_BLOCKS) -- otherwise the graph phase sits
- # in a timeout instead of doing work.
- # ``skip`` drops the very largest: on a big binary the top few are
- # multi-thousand-line monsters whose Hex-Rays cost is 30s of pure
- # backend time, which would swamp every phase we can actually make
- # faster. A band just below the top is still large and realistic.
- big = []
- for f in sorted(allf, key=lambda f: (-f.size, f.addr))[skip:]:
- if len(big) >= nfuncs:
- break
- try:
- # The raw tool, NOT Program.flowchart: the latter caches, and
- # picking the fixtures through it left phase_graph measuring
- # cache hits instead of the work a user waits for.
- payload = app.program.client.call("flowchart",
- addr=hex(f.addr))
- except Exception: # noqa: BLE001
- continue
- if not isinstance(payload, dict) or payload.get("error"):
- continue
- nblocks = len(payload.get("blocks") or [])
- if not nblocks or nblocks > IdaTui.GRAPH_MAX_BLOCKS:
- continue
- big.append(f)
- if len(big) < nfuncs:
- fail(f"fixture:only {len(big)}/{nfuncs} functions")
- big = sorted(big, key=lambda f: f.addr)
- note("fixed_set", [f.name for f in big])
-
- # Cold, once: the listing model starts empty and nav is the thing
- # that has to walk it. Repeating it would only measure a warm cache.
- await phase_nav(app, pilot, big)
-
- # Phases split two ways. The repeatable ones (paint throughput, and
- # the graph, which clears its own cache) are run every repetition so
- # the median settles. The cold-sensitive ones -- decompile, search
- # and the function index all cache their answer, and re-running them
- # would report a dict lookup under the name of the thing a user
- # waits for -- are run ONCE.
- for rep in range(reps):
- first = rep == 0
- await phase_listing(app, pilot, big, pages, first)
- await phase_render(app, pilot, big, frames)
- if first:
- await phase_decomp(app, pilot, big)
- await phase_graph(app, pilot, big)
- if first:
- await phase_rename(app, pilot, big)
- await phase_split(app, pilot, big)
- await phase_search(app, pilot, big, terms)
- await phase_index(app, pilot, big)
- await phase_hex(app, pilot, big, frames // 5)
- app.exit()
- finally:
- shutil.rmtree(d, ignore_errors=True)
-
-
-def main():
- global PREFIX
- ap = argparse.ArgumentParser()
- ap.add_argument("--corpus", default=os.path.join(HERE, "cfg-corpus.json"))
- ap.add_argument("--only", default="", help="only this target tag")
- ap.add_argument("--reps", type=int, default=0, help="override reps")
- a = ap.parse_args()
-
- plan = [
- # tag, binary, reps, nfuncs, pages, frames, terms, skip
- ("sm_", "targets/echo", 2, 12, 40, 300, ("mov", "call", "lea"), 0),
- # Two reps on the big target too: one sample of a 2.5s Hex-Rays phase
- # swings by 10%, which was drowning changes worth more than that.
- # (phase_nav stays single -- it is a COLD walk by definition.)
- ("lg_", "targets/bash", 2, 12, 60, 300, ("mov", "call"), 120),
- ]
- if a.only:
- plan = [p for p in plan if p[0].startswith(a.only)]
-
- async def go():
- global PREFIX
- for tag, binary, reps, nfuncs, pages, frames, terms, skip in plan:
- PREFIX = tag
- await run_target(os.path.join(ROOT, binary), a.reps or reps,
- nfuncs, pages, frames, terms, skip)
- PREFIX = ""
-
- asyncio.run(go())
- if os.path.exists(a.corpus):
- # Three times, for the median. This phase is pure CPU with no I/O and
- # was the single jumpiest number in the suite (239 <-> 524 with
- # identical code) purely from being descheduled -- which is noise the
- # primary metric was carrying for no reason.
- for _ in range(3):
- phase_pure_graph(a.corpus)
- else:
- FAILS.append("pure_graph:no corpus")
-
- out = {}
- for k, v in TIMES.items():
- out[k] = statistics.median(v) if len(v) > 2 else min(v)
- total = sum(out.values())
- print()
- for k in sorted(out):
- print(f"METRIC {k}={out[k]:.1f}")
- print(f"METRIC total_ms={total:.1f}")
- print(f"METRIC fails={len(FAILS)}")
- print("NOTES " + json.dumps(NOTES)[:3000])
- if FAILS:
- print("FAILS " + "; ".join(FAILS[:20]))
- return 1
- return 0
-
-
-if __name__ == "__main__":
- sys.exit(main())
diff --git a/.auto/cfg-corpus.json b/.auto/cfg-corpus.json
deleted file mode 100644
index cf747f9..0000000
--- a/.auto/cfg-corpus.json
+++ /dev/null
@@ -1 +0,0 @@
-[{"name": "sub_3720", "ea": 14112, "blocks": [{"id": 0, "start": 14112, "end": 14208, "lines": ["push rbp", "mov rbp, rsp", "push r15", "mov r15, rsi", "push r14", "push r13", "push r12", "push rbx", "mov rbx, rdi", "sub rsp, 0B8h", "mov rax, [rbp+arg_0]", "mov [rbp+var_58], rdx", "mov [rbp+var_E0], rdi", "mov r13, [rbp+arg_8]", "mov [rbp+var_7C], r8d", "mov [rbp+var_9C], r9d", "mov [rbp+var_70], rax", "mov r14, fs:28h", "mov [rbp+var_38], r14", "mov r14, [rbp+arg_10]", "mov [rbp+var_D8], r13", "mov [rbp+s], r14", "mov r14, rcx", "xchg ax, ax"], "succs": [[1, "fall"]]}, {"id": 1, "start": 14208, "end": 14285, "lines": ["call cs:__ctype_get_mb_cur_max_ptr", "mov [rbp+var_63], 1", "mov r8, r14", "mov r12, rbx", "mov edx, [rbp+var_9C]", "mov [rbp+var_B0], rax", "mov [rbp+var_7E], 0", "shr edx, 1", "mov [rbp+var_65], 0", "mov eax, edx", "mov [rbp+n], 0", "mov [rbp+s2], 0", "and eax, 1", "mov [rbp+var_61], al", "mov [rbp+var_A8], 0"], "succs": [[2, "fall"]]}, {"id": 2, "start": 14285, "end": 14297, "lines": ["mov eax, [rbp+var_7C]", "cmp eax, 0Ah; switch 11 cases", "ja def_37E7; jumptable 00000000000037E7 default case"], "succs": [[3, "fall"], [423, "jump"]]}, {"id": 3, "start": 14297, "end": 14314, "lines": ["lea rdi, jpt_37E7", "movsxd rax, ds:(jpt_37E7 - 8C00h)[rdi+rax*4]", "add rax, rdi", "jmp rax; switch jump"], "succs": [[4, "switch"], [118, "switch"], [119, "switch"], [121, "switch"], [122, "switch"], [126, "switch"], [127, "switch"], [131, "switch"], [133, "switch"]]}, {"id": 4, "start": 14320, "end": 14328, "lines": ["mov ebx, [rbp+var_7C]; jumptable 00000000000037E7 cases 8-10", "cmp ebx, 0Ah", "jz short loc_382A"], "succs": [[5, "fall"], [6, "jump"]]}, {"id": 5, "start": 14328, "end": 14378, "lines": ["mov esi, ebx", "lea rdi, asc_8348+4; msgid", "mov [rbp+var_60], r8", "call sub_3640", "mov esi, ebx", "lea rdi, asc_8348+2; msgid", "mov [rbp+var_D8], rax", "call sub_3640", "mov r8, [rbp+var_60]", "mov [rbp+s], rax"], "succs": [[6, "fall"]]}, {"id": 6, "start": 14378, "end": 14390, "lines": ["xor ebx, ebx", "cmp [rbp+var_61], 0", "jz loc_4DA5"], "succs": [[7, "fall"], [394, "jump"]]}, {"id": 7, "start": 14390, "end": 14464, "lines": ["mov r14, [rbp+s]", "mov [rbp+var_60], r8", "mov rdi, r14; s", "call cs:strlen_ptr", "mov [rbp+s2], r14", "mov r8, [rbp+var_60]", "cmp rax, 1", "mov [rbp+n], rax", "mov [rbp+var_66], 0", "mov [rbp+var_67], 0", "mov [rbp+var_64], 0", "mov [rbp+var_9D], 1", "mov [rbp+var_65], 1", "setnz [rbp+var_7D]", "test rax, rax", "setnz [rbp+var_62]", "nop"], "succs": [[8, "fall"]]}, {"id": 8, "start": 14464, "end": 14470, "lines": ["xor r13d, r13d", "xor r11d, r11d"], "succs": [[9, "fall"]]}, {"id": 9, "start": 14470, "end": 14487, "lines": ["cmp r8, r13", "setnz r14b", "cmp r8, 0FFFFFFFFFFFFFFFFh", "jz loc_39B3"], "succs": [[10, "fall"], [39, "jump"]]}, {"id": 10, "start": 14487, "end": 14496, "lines": ["nop word ptr [rax+rax+00000000h]"], "succs": [[11, "fall"]]}, {"id": 11, "start": 14496, "end": 14505, "lines": ["test r14b, r14b", "jz loc_39D0"], "succs": [[12, "fall"], [40, "jump"]]}, {"id": 12, "start": 14505, "end": 14523, "lines": ["mov rax, [rbp+var_58]", "lea r10, [rax+r13]", "cmp [rbp+var_62], 0", "jnz loc_3FB0"], "succs": [[13, "fall"], [134, "jump"]]}, {"id": 13, "start": 14523, "end": 14537, "lines": ["movzx r9d, byte ptr [r10]", "cmp r9b, 3Fh ; '?'", "jg loc_43C0"], "succs": [[14, "fall"], [191, "jump"]]}, {"id": 14, "start": 14537, "end": 14546, "lines": ["test r9b, r9b", "js def_38EE; jumptable 00000000000038EE default case, cases 1-6,14-31"], "succs": [[15, "fall"], [47, "jump"]]}, {"id": 15, "start": 14546, "end": 14556, "lines": ["cmp r9b, 3Fh; switch 64 cases", "ja def_38EE; jumptable 00000000000038EE default case, cases 1-6,14-31"], "succs": [[16, "fall"], [47, "jump"]]}, {"id": 16, "start": 14556, "end": 14577, "lines": ["lea rsi, jpt_38EE", "movzx eax, r9b", "movsxd rax, ds:(jpt_38EE - 8C2Ch)[rsi+rax*4]", "add rax, rsi", "jmp rax; switch jump"], "succs": [[17, "switch"], [47, "switch"], [63, "switch"], [68, "switch"], [73, "switch"], [74, "switch"], [75, "switch"], [77, "switch"], [82, "switch"], [86, "switch"], [88, "switch"], [108, "switch"], [109, "switch"], [112, "switch"], [114, "switch"]]}, {"id": 17, "start": 14584, "end": 14594, "lines": ["cmp [rbp+var_66], 0; jumptable 00000000000038EE cases 37,43-58", "jnz loc_46B0"], "succs": [[18, "fall"], [253, "jump"]]}, {"id": 18, "start": 14594, "end": 14605, "lines": ["cmp [rbp+var_70], 0", "jz loc_4600"], "succs": [[19, "fall"], [237, "jump"]]}, {"id": 19, "start": 14605, "end": 14612, "lines": ["mov byte ptr [rbp+var_60], 0", "mov edx, r14d"], "succs": [[20, "fall"]]}, {"id": 20, "start": 14612, "end": 14631, "lines": ["mov eax, r9d", "mov esi, r9d", "shr al, 5", "and esi, 1Fh", "movzx eax, al", "shl rax, 2"], "succs": [[21, "fall"]]}, {"id": 21, "start": 14631, "end": 14647, "lines": ["mov rdi, [rbp+var_70]", "mov eax, [rdi+rax]", "bt eax, esi", "jnb loc_3AB8"], "succs": [[22, "fall"], [54, "jump"]]}, {"id": 22, "start": 14647, "end": 14657, "lines": ["cmp [rbp+var_61], 0", "jnz loc_45EA"], "succs": [[23, "fall"], [236, "jump"]]}, {"id": 23, "start": 14657, "end": 14661, "lines": ["lea rcx, [r13+1]"], "succs": [[24, "fall"]]}, {"id": 24, "start": 14661, "end": 14676, "lines": ["and [rbp+var_63], dl", "mov eax, r11d", "mov r13, rcx", "xor eax, 1", "and al, [rbp+var_64]"], "succs": [[25, "fall"]]}, {"id": 25, "start": 14676, "end": 14680, "lines": ["test al, al", "jz short loc_3987"], "succs": [[26, "fall"], [33, "jump"]]}, {"id": 26, "start": 14680, "end": 14685, "lines": ["cmp rbx, r15", "jnb short loc_3962"], "succs": [[27, "fall"], [28, "jump"]]}, {"id": 27, "start": 14685, "end": 14690, "lines": ["mov byte ptr [r12+rbx], 27h ; '''"], "succs": [[28, "fall"]]}, {"id": 28, "start": 14690, "end": 14699, "lines": ["lea rax, [rbx+1]", "cmp rax, r15", "jnb short loc_3971"], "succs": [[29, "fall"], [30, "jump"]]}, {"id": 29, "start": 14699, "end": 14705, "lines": ["mov byte ptr [r12+rbx+1], 24h ; '$'"], "succs": [[30, "fall"]]}, {"id": 30, "start": 14705, "end": 14714, "lines": ["lea rax, [rbx+2]", "cmp rax, r15", "jnb short loc_3980"], "succs": [[31, "fall"], [32, "jump"]]}, {"id": 31, "start": 14714, "end": 14720, "lines": ["mov byte ptr [r12+rbx+2], 27h ; '''"], "succs": [[32, "fall"]]}, {"id": 32, "start": 14720, "end": 14727, "lines": ["add rbx, 3", "mov r11d, r14d"], "succs": [[33, "fall"]]}, {"id": 33, "start": 14727, "end": 14732, "lines": ["cmp rbx, r15", "jnb short loc_3991"], "succs": [[34, "fall"], [35, "jump"]]}, {"id": 34, "start": 14732, "end": 14737, "lines": ["mov byte ptr [r12+rbx], 5Ch ; '\\'"], "succs": [[35, "fall"]]}, {"id": 35, "start": 14737, "end": 14741, "lines": ["add rbx, 1"], "succs": [[36, "fall"]]}, {"id": 36, "start": 14741, "end": 14746, "lines": ["cmp rbx, r15", "jnb short loc_399E"], "succs": [[37, "fall"], [38, "jump"]]}, {"id": 37, "start": 14746, "end": 14750, "lines": ["mov [r12+rbx], r9b"], "succs": [[38, "fall"]]}, {"id": 38, "start": 14750, "end": 14771, "lines": ["add rbx, 1", "cmp r8, r13", "setnz r14b", "cmp r8, 0FFFFFFFFFFFFFFFFh", "jnz loc_38A0"], "succs": [[11, "jump"], [39, "fall"]]}, {"id": 39, "start": 14771, "end": 14800, "lines": ["mov rax, [rbp+var_58]", "mov r8, 0FFFFFFFFFFFFFFFFh", "cmp byte ptr [rax+r13], 0", "setnz r14b", "test r14b, r14b", "jnz loc_38A9"], "succs": [[12, "jump"], [40, "fall"]]}, {"id": 40, "start": 14800, "end": 14809, "lines": ["test rbx, rbx", "jnz loc_4950"], "succs": [[41, "fall"], [307, "jump"]]}, {"id": 41, "start": 14809, "end": 14819, "lines": ["cmp [rbp+var_64], 0", "jz loc_4950"], "succs": [[42, "fall"], [307, "jump"]]}, {"id": 42, "start": 14819, "end": 14829, "lines": ["cmp [rbp+var_61], 0", "jnz loc_3B0C"], "succs": [[43, "fall"], [65, "jump"]]}, {"id": 43, "start": 14829, "end": 14839, "lines": ["cmp [rbp+var_7E], 0", "jz loc_4EAF"], "succs": [[44, "fall"], [414, "jump"]]}, {"id": 44, "start": 14839, "end": 14849, "lines": ["cmp [rbp+var_63], 0", "jnz loc_4E96"], "succs": [[45, "fall"], [413, "jump"]]}, {"id": 45, "start": 14849, "end": 14874, "lines": ["test r15, r15", "setz al", "cmp [rbp+var_A8], 0", "setnz dl", "and al, dl", "jz loc_4E8A"], "succs": [[46, "fall"], [412, "jump"]]}, {"id": 46, "start": 14874, "end": 14893, "lines": ["mov [rbp+var_7E], al", "mov r15, [rbp+var_A8]", "mov [rbp+var_61], 0", "jmp loc_37CD"], "succs": [[2, "jump"]]}, {"id": 47, "start": 14896, "end": 14900, "lines": ["mov byte ptr [rbp+var_60], 0; jumptable 00000000000038EE default case, cases 1-6,14-31"], "succs": [[48, "fall"]]}, {"id": 48, "start": 14900, "end": 14914, "lines": ["cmp [rbp+var_B0], 1", "jnz loc_40BD"], "succs": [[49, "fall"], [148, "jump"]]}, {"id": 49, "start": 14914, "end": 14998, "lines": ["mov [rbp+var_B8], r8", "mov byte ptr [rbp+var_90], r11b", "mov byte ptr [rbp+var_88], r9b", "call cs:__ctype_b_loc_ptr", "movzx r11d, byte ptr [rbp+var_90]", "mov ecx, 1", "mov r8, [rbp+var_B8]", "mov rdx, rax", "movzx eax, byte ptr [rbp+var_88]", "mov rdx, [rdx]", "mov r9, rax", "movzx edx, word ptr [rdx+rax*2]", "and dx, 4000h", "shr dx, 0Eh", "mov eax, edx", "xor eax, 1", "and al, [rbp+var_65]"], "succs": [[50, "fall"]]}, {"id": 50, "start": 14998, "end": 15006, "lines": ["test al, al", "jnz loc_47C0"], "succs": [[51, "fall"], [273, "jump"]]}, {"id": 51, "start": 15006, "end": 15008, "lines": ["xchg ax, ax"], "succs": [[52, "fall"]]}, {"id": 52, "start": 15008, "end": 15021, "lines": ["mov ecx, r9d", "cmp [rbp+var_66], 0", "jnz loc_3B6C"], "succs": [[53, "fall"], [70, "jump"]]}, {"id": 53, "start": 15021, "end": 15032, "lines": ["cmp [rbp+var_70], 0", "jnz loc_3914"], "succs": [[20, "jump"], [54, "fall"]]}, {"id": 54, "start": 15032, "end": 15034, "lines": ["xor eax, eax"], "succs": [[55, "fall"]]}, {"id": 55, "start": 15034, "end": 15044, "lines": ["cmp byte ptr [rbp+var_60], 0", "jnz loc_3937"], "succs": [[22, "jump"], [56, "fall"]]}, {"id": 56, "start": 15044, "end": 15057, "lines": ["and [rbp+var_63], dl", "xor eax, 1", "add r13, 1", "and eax, r11d"], "succs": [[57, "fall"]]}, {"id": 57, "start": 15057, "end": 15065, "lines": ["test al, al", "jz loc_3995"], "succs": [[36, "jump"], [58, "fall"]]}, {"id": 58, "start": 15065, "end": 15070, "lines": ["cmp rbx, r15", "jnb short loc_3AE3"], "succs": [[59, "fall"], [60, "jump"]]}, {"id": 59, "start": 15070, "end": 15075, "lines": ["mov byte ptr [r12+rbx], 27h ; '''"], "succs": [[60, "fall"]]}, {"id": 60, "start": 15075, "end": 15084, "lines": ["lea rax, [rbx+1]", "cmp rax, r15", "jnb short loc_3AF2"], "succs": [[61, "fall"], [62, "jump"]]}, {"id": 61, "start": 15084, "end": 15090, "lines": ["mov byte ptr [r12+rbx+1], 27h ; '''"], "succs": [[62, "fall"]]}, {"id": 62, "start": 15090, "end": 15102, "lines": ["add rbx, 2", "xor r11d, r11d", "jmp loc_3995"], "succs": [[36, "jump"]]}, {"id": 63, "start": 15104, "end": 15110, "lines": ["mov byte ptr [rbp+var_60], 0; jumptable 00000000000038EE cases 33,34,36,38,40-42,59-62", "xor edx, edx"], "succs": [[64, "fall"]]}, {"id": 64, "start": 15110, "end": 15116, "lines": ["cmp [rbp+var_67], 0", "jz short loc_3AA0"], "succs": [[52, "jump"], [65, "fall"]]}, {"id": 65, "start": 15116, "end": 15136, "lines": ["mov [rbp+var_7C], 2", "mov r14, r8", "mov rbx, r12", "nop dword ptr [rax+00000000h]"], "succs": [[66, "fall"]]}, {"id": 66, "start": 15136, "end": 15152, "lines": ["cmp [rbp+var_65], 0", "mov eax, 4", "cmovz eax, [rbp+var_7C]", "mov [rbp+var_7C], eax"], "succs": [[67, "fall"]]}, {"id": 67, "start": 15152, "end": 15172, "lines": ["and [rbp+var_9C], 0FFFFFFFDh", "mov [rbp+var_70], 0", "jmp loc_3780"], "succs": [[1, "jump"]]}, {"id": 68, "start": 15176, "end": 15200, "lines": ["mov byte ptr [rbp+var_60], 0; jumptable 00000000000038EE case 12", "mov ecx, 0Ch", "mov r9d, 66h ; 'f'", "nop word ptr [rax+rax+00000000h]"], "succs": [[69, "fall"]]}, {"id": 69, "start": 15200, "end": 15212, "lines": ["xor edx, edx", "cmp [rbp+var_65], 0", "jnz loc_3937"], "succs": [[22, "jump"], [70, "fall"]]}, {"id": 70, "start": 15212, "end": 15222, "lines": ["cmp [rbp+var_61], 0", "jz loc_4300"], "succs": [[71, "fall"], [178, "jump"]]}, {"id": 71, "start": 15222, "end": 15233, "lines": ["cmp [rbp+var_70], 0", "jz loc_45E0"], "succs": [[72, "fall"], [235, "jump"]]}, {"id": 72, "start": 15233, "end": 15258, "lines": ["mov eax, ecx", "mov esi, ecx", "mov r9d, ecx", "shr al, 5", "and esi, 1Fh", "movzx eax, al", "shl rax, 2", "jmp loc_3927"], "succs": [[21, "jump"]]}, {"id": 73, "start": 15264, "end": 15281, "lines": ["mov byte ptr [rbp+var_60], 0; jumptable 00000000000038EE case 11", "mov ecx, 0Bh", "mov r9d, 76h ; 'v'", "jmp short loc_3B60"], "succs": [[69, "jump"]]}, {"id": 74, "start": 15288, "end": 15305, "lines": ["mov byte ptr [rbp+var_60], 0; jumptable 00000000000038EE case 8", "mov ecx, 8", "mov r9d, 62h ; 'b'", "jmp short loc_3B60"], "succs": [[69, "jump"]]}, {"id": 75, "start": 15312, "end": 15322, "lines": ["cmp [rbp+var_7C], 2; jumptable 00000000000038EE case 39", "jz loc_46F0"], "succs": [[76, "fall"], [259, "jump"]]}, {"id": 76, "start": 15322, "end": 15344, "lines": ["mov [rbp+var_7E], r14b", "mov edx, r14d", "mov r9d, 27h ; '''", "mov byte ptr [rbp+var_60], 0", "jmp loc_3AA0"], "succs": [[52, "jump"]]}, {"id": 77, "start": 15344, "end": 15354, "lines": ["cmp [rbp+var_65], 0; jumptable 00000000000038EE case 0", "jnz loc_44D8; jumptable 000000000000446B case 0"], "succs": [[78, "fall"], [212, "jump"]]}, {"id": 78, "start": 15354, "end": 15367, "lines": ["test byte ptr [rbp+var_9C], 1", "jnz loc_4A0B"], "succs": [[79, "fall"], [324, "jump"]]}, {"id": 79, "start": 15367, "end": 15377, "lines": ["cmp [rbp+var_61], 0", "jz loc_4A2F"], "succs": [[80, "fall"], [327, "jump"]]}, {"id": 80, "start": 15377, "end": 15388, "lines": ["cmp [rbp+var_70], 0", "jz loc_4BCB"], "succs": [[81, "fall"], [354, "jump"]]}, {"id": 81, "start": 15388, "end": 15401, "lines": ["mov byte ptr [rbp+var_60], 0", "xor edx, edx", "xor ecx, ecx", "jmp loc_3B81"], "succs": [[72, "jump"]]}, {"id": 82, "start": 15408, "end": 15412, "lines": ["mov byte ptr [rbp+var_60], 0; jumptable 00000000000038EE case 13"], "succs": [[83, "fall"]]}, {"id": 83, "start": 15412, "end": 15423, "lines": ["mov ecx, 0Dh", "mov r9d, 72h ; 'r'"], "succs": [[84, "fall"]]}, {"id": 84, "start": 15423, "end": 15433, "lines": ["cmp [rbp+var_67], 0", "jz loc_3B60"], "succs": [[69, "jump"], [85, "fall"]]}, {"id": 85, "start": 15433, "end": 15438, "lines": ["jmp loc_3B0C"], "succs": [[65, "jump"]]}, {"id": 86, "start": 15440, "end": 15444, "lines": ["mov byte ptr [rbp+var_60], 0; jumptable 00000000000038EE case 9"], "succs": [[87, "fall"]]}, {"id": 87, "start": 15444, "end": 15457, "lines": ["mov ecx, 9", "mov r9d, 74h ; 't'", "jmp short loc_3C3F"], "succs": [[84, "jump"]]}, {"id": 88, "start": 15464, "end": 15478, "lines": ["mov byte ptr [rbp+var_60], 0; jumptable 00000000000038EE case 63", "cmp [rbp+var_7C], 2", "jz loc_4B6E"], "succs": [[89, "fall"], [350, "jump"]]}, {"id": 89, "start": 15478, "end": 15488, "lines": ["cmp [rbp+var_7C], 5", "jnz loc_4640"], "succs": [[90, "fall"], [244, "jump"]]}, {"id": 90, "start": 15488, "end": 15501, "lines": ["test byte ptr [rbp+var_9C], 4", "jz loc_4640"], "succs": [[91, "fall"], [244, "jump"]]}, {"id": 91, "start": 15501, "end": 15522, "lines": ["lea rsi, [r13+2]", "mov r9d, 3Fh ; '?'", "xor edx, edx", "cmp rsi, r8", "jnb loc_3AA0"], "succs": [[52, "jump"], [92, "fall"]]}, {"id": 92, "start": 15522, "end": 15538, "lines": ["mov rax, [rbp+var_58]", "cmp byte ptr [rax+r13+1], 3Fh ; '?'", "jnz loc_3AA0"], "succs": [[52, "jump"], [93, "fall"]]}, {"id": 93, "start": 15538, "end": 15555, "lines": ["movzx ecx, byte ptr [rax+r13+2]", "lea eax, [rcx-21h]", "cmp al, 1Dh", "ja loc_3AA0"], "succs": [[52, "jump"], [94, "fall"]]}, {"id": 94, "start": 15555, "end": 15575, "lines": ["mov edx, 380051C1h", "bt rdx, rax", "setb dl", "test dl, dl", "jz loc_3AA0"], "succs": [[52, "jump"], [95, "fall"]]}, {"id": 95, "start": 15575, "end": 15585, "lines": ["cmp [rbp+var_61], 0", "jnz loc_4427"], "succs": [[96, "fall"], [199, "jump"]]}, {"id": 96, "start": 15585, "end": 15590, "lines": ["cmp rbx, r15", "jnb short loc_3CEB"], "succs": [[97, "fall"], [98, "jump"]]}, {"id": 97, "start": 15590, "end": 15595, "lines": ["mov byte ptr [r12+rbx], 3Fh ; '?'"], "succs": [[98, "fall"]]}, {"id": 98, "start": 15595, "end": 15604, "lines": ["lea rax, [rbx+1]", "cmp rax, r15", "jnb short loc_3CFA"], "succs": [[99, "fall"], [100, "jump"]]}, {"id": 99, "start": 15604, "end": 15610, "lines": ["mov byte ptr [r12+rbx+1], 22h ; '\"'"], "succs": [[100, "fall"]]}, {"id": 100, "start": 15610, "end": 15619, "lines": ["lea rax, [rbx+2]", "cmp rax, r15", "jnb short loc_3D09"], "succs": [[101, "fall"], [102, "jump"]]}, {"id": 101, "start": 15619, "end": 15625, "lines": ["mov byte ptr [r12+rbx+2], 22h ; '\"'"], "succs": [[102, "fall"]]}, {"id": 102, "start": 15625, "end": 15634, "lines": ["lea rax, [rbx+3]", "cmp rax, r15", "jnb short loc_3D18"], "succs": [[103, "fall"], [104, "jump"]]}, {"id": 103, "start": 15634, "end": 15640, "lines": ["mov byte ptr [r12+rbx+3], 3Fh ; '?'"], "succs": [[104, "fall"]]}, {"id": 104, "start": 15640, "end": 15654, "lines": ["add rbx, 4", "cmp [rbp+var_65], 0", "jnz loc_4E3B"], "succs": [[105, "fall"], [406, "jump"]]}, {"id": 105, "start": 15654, "end": 15677, "lines": ["mov r13, rsi", "mov [rbp+var_63], 0", "xor eax, eax", "add r13, 1", "cmp byte ptr [rbp+var_60], 0", "jnz loc_4313"], "succs": [[106, "fall"], [180, "jump"]]}, {"id": 106, "start": 15677, "end": 15680, "lines": ["nop dword ptr [rax]"], "succs": [[107, "fall"]]}, {"id": 107, "start": 15680, "end": 15694, "lines": ["xor eax, 1", "mov r9d, ecx", "and eax, r11d", "jmp loc_3AD1"], "succs": [[57, "jump"]]}, {"id": 108, "start": 15694, "end": 15714, "lines": ["mov byte ptr [rbp+var_60], 0; jumptable 00000000000038EE case 10", "mov ecx, 0Ah", "mov r9d, 6Eh ; 'n'", "jmp loc_3C3F"], "succs": [[84, "jump"]]}, {"id": 109, "start": 15714, "end": 15728, "lines": ["mov byte ptr [rbp+var_60], 0; jumptable 00000000000038EE case 35", "mov r9d, 23h ; '#'", "nop dword ptr [rax+00h]"], "succs": [[110, "fall"]]}, {"id": 110, "start": 15728, "end": 15733, "lines": ["test r13, r13", "jz short loc_3D90"], "succs": [[111, "fall"], [113, "jump"]]}, {"id": 111, "start": 15733, "end": 15740, "lines": ["xor edx, edx", "jmp loc_3AA0"], "succs": [[52, "jump"]]}, {"id": 112, "start": 15740, "end": 15760, "lines": ["mov byte ptr [rbp+var_60], 0; jumptable 00000000000038EE case 32", "mov r9d, 20h ; ' '", "nop word ptr [rax+rax+00000000h]"], "succs": [[113, "fall"]]}, {"id": 113, "start": 15760, "end": 15768, "lines": ["mov edx, r14d", "jmp loc_3B06"], "succs": [[64, "jump"]]}, {"id": 114, "start": 15768, "end": 15778, "lines": ["cmp [rbp+var_65], 0; jumptable 00000000000038EE case 7", "jnz loc_4BE1"], "succs": [[115, "fall"], [355, "jump"]]}, {"id": 115, "start": 15778, "end": 15788, "lines": ["cmp [rbp+var_61], 0", "jz loc_4BAE"], "succs": [[116, "fall"], [353, "jump"]]}, {"id": 116, "start": 15788, "end": 15799, "lines": ["cmp [rbp+var_70], 0", "jz loc_4DFC"], "succs": [[117, "fall"], [402, "jump"]]}, {"id": 117, "start": 15799, "end": 15815, "lines": ["mov byte ptr [rbp+var_60], 0", "xor edx, edx", "mov ecx, 7", "jmp loc_3B81"], "succs": [[72, "jump"]]}, {"id": 118, "start": 15815, "end": 15886, "lines": ["lea rax, asc_8348; jumptable 00000000000037E7 case 6", "mov [rbp+var_62], 1", "xor ebx, ebx", "mov [rbp+var_66], 0", "mov [rbp+var_67], 0", "mov [rbp+var_64], 0", "mov [rbp+var_7D], 0", "mov [rbp+var_9D], 1", "mov [rbp+var_61], 1", "mov [rbp+var_65], 1", "mov [rbp+n], 1", "mov [rbp+s2], rax", "mov [rbp+var_7C], 5", "jmp loc_3880"], "succs": [[8, "jump"]]}, {"id": 119, "start": 15886, "end": 15917, "lines": ["mov [rbp+var_62], 0; jumptable 00000000000037E7 case 1", "mov [rbp+var_66], 1", "mov [rbp+var_67], 1", "mov [rbp+var_64], 1", "mov [rbp+var_7D], 0", "mov [rbp+var_9D], 0", "mov [rbp+var_61], 1"], "succs": [[120, "fall"]]}, {"id": 120, "start": 15917, "end": 15953, "lines": ["lea rax, asc_8348+2; \"'`\"", "mov [rbp+n], 1", "xor ebx, ebx", "mov [rbp+s2], rax", "mov [rbp+var_7C], 2", "jmp loc_3880"], "succs": [[8, "jump"]]}, {"id": 121, "start": 15953, "end": 16013, "lines": ["movzx ecx, [rbp+var_65]; jumptable 00000000000037E7 case 0", "mov rdi, [rbp+n]", "mov [rbp+var_67], 0", "mov [rbp+var_64], 0", "cmp rdi, 1", "mov eax, ecx", "mov [rbp+var_9D], cl", "setnz [rbp+var_7D]", "xor eax, 1", "test rdi, rdi", "mov [rbp+var_66], al", "setnz al", "xor ebx, ebx", "and eax, ecx", "mov [rbp+var_61], 0", "mov [rbp+var_62], al", "jmp loc_3880"], "succs": [[8, "jump"]]}, {"id": 122, "start": 16013, "end": 16023, "lines": ["cmp [rbp+var_61], 0; jumptable 00000000000037E7 case 2", "jnz loc_4CA2"], "succs": [[123, "fall"], [372, "jump"]]}, {"id": 123, "start": 16023, "end": 16028, "lines": ["test r15, r15", "jz short loc_3EA1"], "succs": [[124, "fall"], [125, "jump"]]}, {"id": 124, "start": 16028, "end": 16033, "lines": ["mov byte ptr [r12], 27h ; '''"], "succs": [[125, "fall"]]}, {"id": 125, "start": 16033, "end": 16103, "lines": ["lea rax, asc_8348+2; \"'`\"", "mov [rbp+var_62], 0", "mov ebx, 1", "mov [rbp+var_66], 1", "mov [rbp+var_67], 0", "mov [rbp+var_64], 1", "mov [rbp+var_7D], 0", "mov [rbp+var_9D], 0", "mov [rbp+var_61], 0", "mov [rbp+n], 1", "mov [rbp+s2], rax", "mov [rbp+var_7C], 2", "jmp loc_3880"], "succs": [[8, "jump"]]}, {"id": 126, "start": 16103, "end": 16156, "lines": ["mov rax, [rbp+n]; jumptable 00000000000037E7 case 7", "mov [rbp+var_66], 0", "mov [rbp+var_67], 0", "cmp rax, 1", "mov [rbp+var_64], 0", "setnz [rbp+var_7D]", "test rax, rax", "setnz [rbp+var_62]", "xor ebx, ebx", "mov [rbp+var_9D], 1", "mov [rbp+var_61], 0", "mov [rbp+var_65], 1", "jmp loc_3880"], "succs": [[8, "jump"]]}, {"id": 127, "start": 16156, "end": 16166, "lines": ["cmp [rbp+var_61], 0; jumptable 00000000000037E7 case 5", "jnz loc_3DC7; jumptable 00000000000037E7 case 6"], "succs": [[118, "jump"], [128, "fall"]]}, {"id": 128, "start": 16166, "end": 16171, "lines": ["test r15, r15", "jz short loc_3F30"], "succs": [[129, "fall"], [130, "jump"]]}, {"id": 129, "start": 16171, "end": 16176, "lines": ["mov byte ptr [r12], 22h ; '\"'"], "succs": [[130, "fall"]]}, {"id": 130, "start": 16176, "end": 16239, "lines": ["lea rax, asc_8348; \"\\\"'`\"", "mov [rbp+var_66], 0", "mov ebx, 1", "mov [rbp+var_67], 0", "mov [rbp+var_64], 0", "mov [rbp+var_7D], 0", "mov [rbp+var_62], 1", "mov [rbp+var_9D], 1", "mov [rbp+var_65], 1", "mov [rbp+n], 1", "mov [rbp+s2], rax", "jmp loc_3880"], "succs": [[8, "jump"]]}, {"id": 131, "start": 16239, "end": 16249, "lines": ["cmp [rbp+var_61], 0; jumptable 00000000000037E7 case 4", "jnz loc_3E0E; jumptable 00000000000037E7 case 1"], "succs": [[119, "jump"], [132, "fall"]]}, {"id": 132, "start": 16249, "end": 16258, "lines": ["mov [rbp+var_65], 1", "jmp loc_3E97"], "succs": [[123, "jump"]]}, {"id": 133, "start": 16258, "end": 16298, "lines": ["mov [rbp+var_62], 0; jumptable 00000000000037E7 case 3", "mov [rbp+var_66], 1", "mov [rbp+var_67], 1", "mov [rbp+var_64], 1", "mov [rbp+var_7D], 0", "mov [rbp+var_9D], 0", "mov [rbp+var_61], 1", "mov [rbp+var_65], 1", "jmp loc_3E2D"], "succs": [[120, "jump"]]}, {"id": 134, "start": 16304, "end": 16318, "lines": ["mov rax, [rbp+n]", "lea rdx, [rax+r13]", "cmp r8, 0FFFFFFFFFFFFFFFFh", "jnz short loc_3FF6"], "succs": [[135, "fall"], [137, "jump"]]}, {"id": 135, "start": 16318, "end": 16324, "lines": ["cmp [rbp+var_7D], 0", "jz short loc_3FF6"], "succs": [[136, "fall"], [137, "jump"]]}, {"id": 136, "start": 16324, "end": 16374, "lines": ["mov byte ptr [rbp+var_90], r11b", "mov rdi, [rbp+var_58]; s", "mov [rbp+var_88], r10", "mov [rbp+var_60], rdx", "call cs:strlen_ptr", "mov r10, [rbp+var_88]", "mov rdx, [rbp+var_60]", "movzx r11d, byte ptr [rbp+var_90]", "mov r8, rax"], "succs": [[137, "fall"]]}, {"id": 137, "start": 16374, "end": 16383, "lines": ["cmp r8, rdx", "jb loc_4438"], "succs": [[138, "fall"], [200, "jump"]]}, {"id": 138, "start": 16383, "end": 16448, "lines": ["mov [rbp+var_90], r8", "mov rdi, r10; s1", "mov rdx, [rbp+n]; n", "mov byte ptr [rbp+var_88], r11b", "mov rsi, [rbp+s2]; s2", "mov [rbp+var_60], r10", "call cs:memcmp_ptr", "mov r10, [rbp+var_60]", "movzx r11d, byte ptr [rbp+var_88]", "test eax, eax", "mov r8, [rbp+var_90]", "jnz loc_4470"], "succs": [[139, "fall"], [204, "jump"]]}, {"id": 139, "start": 16448, "end": 16458, "lines": ["cmp [rbp+var_61], 0", "jnz loc_4A49"], "succs": [[140, "fall"], [328, "jump"]]}, {"id": 140, "start": 16458, "end": 16472, "lines": ["movzx r9d, byte ptr [r10]", "cmp r9b, 3Fh ; '?'", "jg loc_48A0"], "succs": [[141, "fall"], [296, "jump"]]}, {"id": 141, "start": 16472, "end": 16477, "lines": ["test r9b, r9b", "js short def_4075; jumptable 0000000000004075 default case, cases 1-6,14-31"], "succs": [[142, "fall"], [147, "jump"]]}, {"id": 142, "start": 16477, "end": 16483, "lines": ["cmp r9b, 3Fh; switch 64 cases", "ja short def_4075; jumptable 0000000000004075 default case, cases 1-6,14-31"], "succs": [[143, "fall"], [147, "jump"]]}, {"id": 143, "start": 16483, "end": 16504, "lines": ["lea rdx, jpt_4075", "movzx eax, r9b", "movsxd rax, ds:(jpt_4075 - 8D2Ch)[rdx+rax*4]", "add rax, rdx", "jmp rax; switch jump"], "succs": [[144, "switch"], [147, "switch"], [158, "switch"], [160, "switch"], [161, "switch"], [162, "switch"], [163, "switch"], [164, "switch"], [165, "switch"], [176, "switch"], [181, "switch"], [183, "switch"], [185, "switch"], [187, "switch"], [189, "switch"]]}, {"id": 144, "start": 16512, "end": 16522, "lines": ["cmp [rbp+var_66], 0; jumptable 0000000000004075 cases 37,43-58", "jnz loc_4D99"], "succs": [[145, "fall"], [393, "jump"]]}, {"id": 145, "start": 16522, "end": 16537, "lines": ["movzx edx, [rbp+var_62]", "cmp [rbp+var_70], 0", "jz loc_3941"], "succs": [[23, "jump"], [146, "fall"]]}, {"id": 146, "start": 16537, "end": 16545, "lines": ["mov byte ptr [rbp+var_60], dl", "jmp loc_3914"], "succs": [[20, "jump"]]}, {"id": 147, "start": 16552, "end": 16573, "lines": ["movzx eax, [rbp+var_62]; jumptable 0000000000004075 default case, cases 1-6,14-31", "mov byte ptr [rbp+var_60], al", "cmp [rbp+var_B0], 1", "jz loc_3A42"], "succs": [[49, "jump"], [148, "fall"]]}, {"id": 148, "start": 16573, "end": 16591, "lines": ["mov [rbp+var_40], 0", "lea rcx, [rbp+var_40]", "cmp r8, 0FFFFFFFFFFFFFFFFh", "jnz short loc_4116"], "succs": [[149, "fall"], [150, "jump"]]}, {"id": 149, "start": 16591, "end": 16662, "lines": ["mov byte ptr [rbp+var_C0], r11b", "mov rdi, [rbp+var_58]; s", "mov [rbp+var_B8], rcx", "mov [rbp+var_90], r10", "mov byte ptr [rbp+var_88], r9b", "call cs:strlen_ptr", "movzx r11d, byte ptr [rbp+var_C0]", "mov rcx, [rbp+var_B8]", "mov r10, [rbp+var_90]", "movzx r9d, byte ptr [rbp+var_88]", "mov r8, rax"], "succs": [[150, "fall"]]}, {"id": 150, "start": 16662, "end": 16754, "lines": ["mov rax, r8", "mov byte ptr [rbp+var_B8], r11b", "lea rdi, [rbp+wc]; pc32", "mov rsi, r10; s", "sub rax, r13", "mov byte ptr [rbp+var_90], r9b", "mov [rbp+var_88], r8", "mov rdx, rax", "mov [rbp+var_C8], rax", "mov [rbp+var_C0], r10", "call sub_69C0", "mov r8, [rbp+var_88]", "movzx r9d, byte ptr [rbp+var_90]", "test rax, rax", "movzx r11d, byte ptr [rbp+var_B8]", "mov rcx, rax", "jz loc_41F0"], "succs": [[151, "fall"], [157, "jump"]]}, {"id": 151, "start": 16754, "end": 16771, "lines": ["cmp rax, 0FFFFFFFFFFFFFFFFh", "mov r10, [rbp+var_C0]", "jz loc_4B1A"], "succs": [[152, "fall"], [342, "jump"]]}, {"id": 152, "start": 16771, "end": 16781, "lines": ["cmp rax, 0FFFFFFFFFFFFFFFEh", "jz loc_4B27"], "succs": [[153, "fall"], [343, "jump"]]}, {"id": 153, "start": 16781, "end": 16791, "lines": ["cmp [rbp+var_67], 0", "jnz loc_4C5D"], "succs": [[154, "fall"], [366, "jump"]]}, {"id": 154, "start": 16791, "end": 16866, "lines": ["mov [rbp+var_C0], r8", "mov edi, [rbp+wc]; wc", "mov byte ptr [rbp+var_B8], r11b", "mov byte ptr [rbp+var_90], r9b", "mov [rbp+var_88], rax", "call cs:iswprint_ptr", "mov rcx, [rbp+var_88]", "movzx r9d, byte ptr [rbp+var_90]", "test eax, eax", "movzx r11d, byte ptr [rbp+var_B8]", "mov r8, [rbp+var_C0]", "jz loc_4B50"], "succs": [[155, "fall"], [347, "jump"]]}, {"id": 155, "start": 16866, "end": 16876, "lines": ["cmp rcx, 1", "jnz loc_4E34"], "succs": [[156, "fall"], [405, "jump"]]}, {"id": 156, "start": 16876, "end": 16880, "lines": ["nop dword ptr [rax+00h]"], "succs": [[157, "fall"]]}, {"id": 157, "start": 16880, "end": 16888, "lines": ["mov edx, r14d", "jmp loc_3AA0"], "succs": [[52, "jump"]]}, {"id": 158, "start": 16888, "end": 16895, "lines": ["movzx eax, [rbp+var_62]; jumptable 0000000000004075 cases 33,34,36,38,40-42,59-62", "mov byte ptr [rbp+var_60], al"], "succs": [[159, "fall"]]}, {"id": 159, "start": 16895, "end": 16902, "lines": ["xor edx, edx", "jmp loc_3B06"], "succs": [[64, "jump"]]}, {"id": 160, "start": 16902, "end": 16920, "lines": ["movzx eax, [rbp+var_62]; jumptable 0000000000004075 case 35", "mov r9d, 23h ; '#'", "mov byte ptr [rbp+var_60], al", "jmp loc_3D70"], "succs": [[110, "jump"]]}, {"id": 161, "start": 16920, "end": 16941, "lines": ["movzx eax, [rbp+var_62]; jumptable 0000000000004075 case 32", "mov r9d, 20h ; ' '", "mov edx, r14d", "mov byte ptr [rbp+var_60], al", "jmp loc_3B06"], "succs": [[64, "jump"]]}, {"id": 162, "start": 16941, "end": 16953, "lines": ["movzx eax, [rbp+var_62]; jumptable 0000000000004075 case 13", "mov byte ptr [rbp+var_60], al", "jmp loc_3C34"], "succs": [[83, "jump"]]}, {"id": 163, "start": 16953, "end": 16976, "lines": ["movzx eax, [rbp+var_62]; jumptable 0000000000004075 case 10", "mov ecx, 0Ah", "mov r9d, 6Eh ; 'n'", "mov byte ptr [rbp+var_60], al", "jmp loc_3C3F"], "succs": [[84, "jump"]]}, {"id": 164, "start": 16976, "end": 16988, "lines": ["movzx eax, [rbp+var_62]; jumptable 0000000000004075 case 9", "mov byte ptr [rbp+var_60], al", "jmp loc_3C54"], "succs": [[87, "jump"]]}, {"id": 165, "start": 16988, "end": 17010, "lines": ["movzx eax, [rbp+var_62]; jumptable 0000000000004075 case 0", "mov byte ptr [rbp+var_60], al", "mov eax, r11d", "xor eax, 1", "and al, [rbp+var_64]", "jz loc_44F5"], "succs": [[166, "fall"], [214, "jump"]]}, {"id": 166, "start": 17010, "end": 17024, "lines": ["nop dword ptr [rax]", "nop word ptr [rax+rax+00000000h]"], "succs": [[167, "fall"]]}, {"id": 167, "start": 17024, "end": 17029, "lines": ["cmp rbx, r15", "jnb short loc_428A"], "succs": [[168, "fall"], [169, "jump"]]}, {"id": 168, "start": 17029, "end": 17034, "lines": ["mov byte ptr [r12+rbx], 27h ; '''"], "succs": [[169, "fall"]]}, {"id": 169, "start": 17034, "end": 17043, "lines": ["lea rdx, [rbx+1]", "cmp rdx, r15", "jnb short loc_4299"], "succs": [[170, "fall"], [171, "jump"]]}, {"id": 170, "start": 17043, "end": 17049, "lines": ["mov byte ptr [r12+rbx+1], 24h ; '$'"], "succs": [[171, "fall"]]}, {"id": 171, "start": 17049, "end": 17058, "lines": ["lea rdx, [rbx+2]", "cmp rdx, r15", "jnb short loc_42A8"], "succs": [[172, "fall"], [173, "jump"]]}, {"id": 172, "start": 17058, "end": 17064, "lines": ["mov byte ptr [r12+rbx+2], 27h ; '''"], "succs": [[173, "fall"]]}, {"id": 173, "start": 17064, "end": 17081, "lines": ["lea rdx, [rbx+3]", "add rbx, 4", "cmp rdx, r15", "jnb loc_4A5B"], "succs": [[174, "fall"], [329, "jump"]]}, {"id": 174, "start": 17081, "end": 17100, "lines": ["mov byte ptr [r12+rdx], 5Ch ; '\\'", "add r13, 1", "cmp byte ptr [rbp+var_60], 0", "jz loc_4BEE"], "succs": [[175, "fall"], [356, "jump"]]}, {"id": 175, "start": 17100, "end": 17120, "lines": ["mov [rbp+var_63], 0", "movzx r11d, byte ptr [rbp+var_60]", "mov r9d, 30h ; '0'", "jmp loc_3987"], "succs": [[33, "jump"]]}, {"id": 176, "start": 17120, "end": 17130, "lines": ["cmp [rbp+var_65], 0; jumptable 0000000000004075 case 12", "jnz loc_4EEB"], "succs": [[177, "fall"], [418, "jump"]]}, {"id": 177, "start": 17130, "end": 17152, "lines": ["movzx eax, [rbp+var_62]", "xor edx, edx", "mov ecx, 0Ch", "mov byte ptr [rbp+var_60], al", "nop dword ptr [rax+rax+00000000h]"], "succs": [[178, "fall"]]}, {"id": 178, "start": 17152, "end": 17154, "lines": ["xor eax, eax"], "succs": [[179, "fall"]]}, {"id": 179, "start": 17154, "end": 17171, "lines": ["and [rbp+var_63], dl", "add r13, 1", "cmp byte ptr [rbp+var_60], 0", "jz loc_3D40"], "succs": [[107, "jump"], [180, "fall"]]}, {"id": 180, "start": 17171, "end": 17188, "lines": ["mov eax, r11d", "mov r9d, ecx", "xor eax, 1", "and al, [rbp+var_64]", "jmp loc_3954"], "succs": [[25, "jump"]]}, {"id": 181, "start": 17188, "end": 17198, "lines": ["cmp [rbp+var_65], 0; jumptable 0000000000004075 case 11", "jnz loc_4EBC"], "succs": [[182, "fall"], [415, "jump"]]}, {"id": 182, "start": 17198, "end": 17214, "lines": ["movzx eax, [rbp+var_62]", "xor edx, edx", "mov ecx, 0Bh", "mov byte ptr [rbp+var_60], al", "jmp short loc_4300"], "succs": [[178, "jump"]]}, {"id": 183, "start": 17214, "end": 17231, "lines": ["movzx eax, [rbp+var_62]; jumptable 0000000000004075 case 63", "mov byte ptr [rbp+var_60], al", "cmp [rbp+var_7C], 2", "jnz loc_3C76"], "succs": [[89, "jump"], [184, "fall"]]}, {"id": 184, "start": 17231, "end": 17246, "lines": ["mov [rbp+var_63], 0", "add r13, 1", "mov ecx, 3Fh ; '?'", "jmp short loc_4313"], "succs": [[180, "jump"]]}, {"id": 185, "start": 17246, "end": 17260, "lines": ["movzx eax, [rbp+var_62]; jumptable 0000000000004075 case 39", "cmp [rbp+var_7C], 2", "jz loc_4E63"], "succs": [[186, "fall"], [409, "jump"]]}, {"id": 186, "start": 17260, "end": 17279, "lines": ["mov byte ptr [rbp+var_60], al", "mov edx, eax", "mov r9d, 27h ; '''", "mov [rbp+var_7E], al", "jmp loc_3AA0"], "succs": [[52, "jump"]]}, {"id": 187, "start": 17279, "end": 17289, "lines": ["cmp [rbp+var_65], 0; jumptable 0000000000004075 case 8", "jnz loc_4ECD"], "succs": [[188, "fall"], [416, "jump"]]}, {"id": 188, "start": 17289, "end": 17308, "lines": ["movzx eax, [rbp+var_62]", "xor edx, edx", "mov ecx, 8", "mov byte ptr [rbp+var_60], al", "jmp loc_4300"], "succs": [[178, "jump"]]}, {"id": 189, "start": 17308, "end": 17318, "lines": ["cmp [rbp+var_65], 0; jumptable 0000000000004075 case 7", "jnz loc_4EDE"], "succs": [[190, "fall"], [417, "jump"]]}, {"id": 190, "start": 17318, "end": 17336, "lines": ["mov [rbp+var_63], 0", "add r13, 1", "mov ecx, 7", "jmp loc_4313"], "succs": [[180, "jump"]]}, {"id": 191, "start": 17344, "end": 17354, "lines": ["cmp r9b, 7Ah ; 'z'", "jg loc_45A0"], "succs": [[192, "fall"], [228, "jump"]]}, {"id": 192, "start": 17354, "end": 17364, "lines": ["cmp r9b, 40h ; '@'", "jz def_38EE; jumptable 00000000000038EE default case, cases 1-6,14-31"], "succs": [[47, "jump"], [193, "fall"]]}, {"id": 193, "start": 17364, "end": 17395, "lines": ["lea ecx, [r9-41h]", "mov eax, 1", "mov rdx, 3FFFFFF53FFFFFFh", "shl rax, cl", "test rax, rdx", "jnz loc_38F8; jumptable 00000000000038EE cases 37,43-58"], "succs": [[17, "jump"], [194, "fall"]]}, {"id": 194, "start": 17395, "end": 17406, "lines": ["test eax, 0A4000000h", "jnz loc_3B00; jumptable 00000000000038EE cases 33,34,36,38,40-42,59-62"], "succs": [[63, "jump"], [195, "fall"]]}, {"id": 195, "start": 17406, "end": 17416, "lines": ["cmp [rbp+var_7C], 2", "jz loc_4650"], "succs": [[196, "fall"], [245, "jump"]]}, {"id": 196, "start": 17416, "end": 17426, "lines": ["cmp [rbp+var_65], 0", "jz loc_4917"], "succs": [[197, "fall"], [304, "jump"]]}, {"id": 197, "start": 17426, "end": 17436, "lines": ["cmp [rbp+var_61], 0", "jz loc_4AA7"], "succs": [[198, "fall"], [335, "jump"]]}, {"id": 198, "start": 17436, "end": 17447, "lines": ["cmp [rbp+n], 0", "jnz loc_465A"], "succs": [[199, "fall"], [246, "jump"]]}, {"id": 199, "start": 17447, "end": 17458, "lines": ["mov r14, r8", "mov rbx, r12", "jmp loc_3B30"], "succs": [[67, "jump"]]}, {"id": 200, "start": 17464, "end": 17478, "lines": ["movzx r9d, byte ptr [r10]", "cmp r9b, 3Fh ; '?'", "jg loc_4610"], "succs": [[201, "fall"], [238, "jump"]]}, {"id": 201, "start": 17478, "end": 17487, "lines": ["test r9b, r9b", "js def_38EE; jumptable 00000000000038EE default case, cases 1-6,14-31"], "succs": [[47, "jump"], [202, "fall"]]}, {"id": 202, "start": 17487, "end": 17497, "lines": ["cmp r9b, 3Fh; switch 64 cases", "ja def_38EE; jumptable 00000000000038EE default case, cases 1-6,14-31"], "succs": [[47, "jump"], [203, "fall"]]}, {"id": 203, "start": 17497, "end": 17518, "lines": ["lea rcx, jpt_446B", "movzx eax, r9b", "movsxd rax, ds:(jpt_446B - 8E2Ch)[rcx+rax*4]", "add rax, rcx", "jmp rax; switch jump"], "succs": [[47, "switch"], [63, "switch"], [68, "switch"], [73, "switch"], [74, "switch"], [75, "switch"], [82, "switch"], [86, "switch"], [88, "switch"], [209, "switch"], [212, "switch"], [223, "switch"], [224, "switch"], [225, "switch"], [226, "switch"]]}, {"id": 204, "start": 17520, "end": 17530, "lines": ["movzx r9d, byte ptr [r10]", "cmp r9b, 3Fh ; '?'", "jle short loc_4446"], "succs": [[201, "jump"], [205, "fall"]]}, {"id": 205, "start": 17530, "end": 17540, "lines": ["cmp r9b, 7Ah ; 'z'", "jg loc_467D"], "succs": [[206, "fall"], [249, "jump"]]}, {"id": 206, "start": 17540, "end": 17550, "lines": ["cmp r9b, 40h ; '@'", "jz def_38EE; jumptable 00000000000038EE default case, cases 1-6,14-31"], "succs": [[47, "jump"], [207, "fall"]]}, {"id": 207, "start": 17550, "end": 17581, "lines": ["lea ecx, [r9-41h]", "mov eax, 1", "shl rax, cl", "mov rcx, 3FFFFFF53FFFFFFh", "test rax, rcx", "jz loc_43F3"], "succs": [[194, "jump"], [208, "fall"]]}, {"id": 208, "start": 17581, "end": 17584, "lines": ["nop dword ptr [rax]"], "succs": [[209, "fall"]]}, {"id": 209, "start": 17584, "end": 17594, "lines": ["cmp [rbp+var_66], 0; jumptable 000000000000446B cases 37,43-58", "jnz loc_4AD8"], "succs": [[210, "fall"], [337, "jump"]]}, {"id": 210, "start": 17594, "end": 17605, "lines": ["cmp [rbp+var_70], 0", "jz loc_4928"], "succs": [[211, "fall"], [305, "jump"]]}, {"id": 211, "start": 17605, "end": 17618, "lines": ["mov byte ptr [rbp+var_60], 0", "movzx edx, [rbp+var_62]", "jmp loc_3914"], "succs": [[20, "jump"]]}, {"id": 212, "start": 17624, "end": 17634, "lines": ["cmp [rbp+var_61], 0; jumptable 000000000000446B case 0", "jnz loc_4A49"], "succs": [[213, "fall"], [328, "jump"]]}, {"id": 213, "start": 17634, "end": 17653, "lines": ["mov eax, r11d", "mov byte ptr [rbp+var_60], 0", "xor eax, 1", "and al, [rbp+var_64]", "jnz loc_4280"], "succs": [[167, "jump"], [214, "fall"]]}, {"id": 214, "start": 17653, "end": 17658, "lines": ["cmp rbx, r15", "jnb short loc_44FF"], "succs": [[215, "fall"], [216, "jump"]]}, {"id": 215, "start": 17658, "end": 17663, "lines": ["mov byte ptr [r12+rbx], 5Ch ; '\\'"], "succs": [[216, "fall"]]}, {"id": 216, "start": 17663, "end": 17684, "lines": ["lea rsi, [rbx+1]", "lea rcx, [r13+1]", "cmp [rbp+var_9D], 0", "jz loc_4E6B"], "succs": [[217, "fall"], [410, "jump"]]}, {"id": 217, "start": 17684, "end": 17689, "lines": ["cmp rcx, r8", "jnb short loc_4534"], "succs": [[218, "fall"], [219, "jump"]]}, {"id": 218, "start": 17689, "end": 17716, "lines": ["mov rax, [rbp+var_58]", "movzx eax, byte ptr [rax+r13+1]", "mov byte ptr [rbp+var_88], al", "sub eax, 30h ; '0'", "cmp al, 9", "jbe loc_4C00"], "succs": [[219, "fall"], [357, "jump"]]}, {"id": 219, "start": 17716, "end": 17729, "lines": ["mov rax, [rbp+var_70]", "test rax, rax", "jz loc_4AFB"], "succs": [[220, "fall"], [340, "jump"]]}, {"id": 220, "start": 17729, "end": 17741, "lines": ["xor edx, edx", "test byte ptr [rax+6], 1", "jz loc_4AC3"], "succs": [[221, "fall"], [336, "jump"]]}, {"id": 221, "start": 17741, "end": 17744, "lines": ["mov rbx, rsi"], "succs": [[222, "fall"]]}, {"id": 222, "start": 17744, "end": 17755, "lines": ["mov r9d, 30h ; '0'", "jmp loc_3945"], "succs": [[24, "jump"]]}, {"id": 223, "start": 17755, "end": 17764, "lines": ["mov byte ptr [rbp+var_60], 0; jumptable 000000000000446B case 35", "jmp loc_3D70"], "succs": [[110, "jump"]]}, {"id": 224, "start": 17764, "end": 17776, "lines": ["mov byte ptr [rbp+var_60], 0; jumptable 000000000000446B case 32", "mov edx, r14d", "jmp loc_3B06"], "succs": [[64, "jump"]]}, {"id": 225, "start": 17776, "end": 17794, "lines": ["mov ecx, r9d; jumptable 000000000000446B case 10", "mov byte ptr [rbp+var_60], 0", "mov r9d, 6Eh ; 'n'", "jmp loc_3C3F"], "succs": [[84, "jump"]]}, {"id": 226, "start": 17794, "end": 17804, "lines": ["cmp [rbp+var_65], 0; jumptable 000000000000446B case 7", "jz loc_3DA2"], "succs": [[115, "jump"], [227, "fall"]]}, {"id": 227, "start": 17804, "end": 17817, "lines": ["mov r9d, 61h ; 'a'", "xor edx, edx", "jmp loc_3937"], "succs": [[22, "jump"]]}, {"id": 228, "start": 17824, "end": 17834, "lines": ["cmp r9b, 7Dh ; '}'", "jz loc_4690"], "succs": [[229, "fall"], [251, "jump"]]}, {"id": 229, "start": 17834, "end": 17844, "lines": ["mov byte ptr [rbp+var_60], 0", "jg loc_4630"], "succs": [[230, "fall"], [242, "jump"]]}, {"id": 230, "start": 17844, "end": 17854, "lines": ["cmp r9b, 7Bh ; '{'", "jnz loc_41FF"], "succs": [[159, "jump"], [231, "fall"]]}, {"id": 231, "start": 17854, "end": 17864, "lines": ["cmp r8, 0FFFFFFFFFFFFFFFFh", "jz loc_469E"], "succs": [[232, "fall"], [252, "jump"]]}, {"id": 232, "start": 17864, "end": 17871, "lines": ["cmp r8, 1", "setnz al"], "succs": [[233, "fall"]]}, {"id": 233, "start": 17871, "end": 17879, "lines": ["test al, al", "jz loc_3D70"], "succs": [[110, "jump"], [234, "fall"]]}, {"id": 234, "start": 17879, "end": 17886, "lines": ["xor edx, edx", "jmp loc_3AA0"], "succs": [[52, "jump"]]}, {"id": 235, "start": 17888, "end": 17898, "lines": ["cmp byte ptr [rbp+var_60], 0", "jz loc_46D6"], "succs": [[236, "fall"], [258, "jump"]]}, {"id": 236, "start": 17898, "end": 17916, "lines": ["movzx ecx, [rbp+var_64]", "mov r14, r8", "and [rbp+var_65], cl", "mov rbx, r12", "jmp loc_3B20"], "succs": [[66, "jump"]]}, {"id": 237, "start": 17920, "end": 17930, "lines": ["mov edx, r14d", "xor eax, eax", "jmp loc_3AC4"], "succs": [[56, "jump"]]}, {"id": 238, "start": 17936, "end": 17946, "lines": ["cmp r9b, 7Ah ; 'z'", "jle loc_4484"], "succs": [[206, "jump"], [239, "fall"]]}, {"id": 239, "start": 17946, "end": 17956, "lines": ["cmp r9b, 7Dh ; '}'", "jz loc_4B65"], "succs": [[240, "fall"], [349, "jump"]]}, {"id": 240, "start": 17956, "end": 17966, "lines": ["mov byte ptr [rbp+var_60], 0", "cmp r9b, 7Dh ; '}'", "jle short loc_45B4"], "succs": [[230, "jump"], [241, "fall"]]}, {"id": 241, "start": 17966, "end": 17968, "lines": ["xchg ax, ax"], "succs": [[242, "fall"]]}, {"id": 242, "start": 17968, "end": 17978, "lines": ["cmp r9b, 7Eh ; '~'", "jz loc_3D70"], "succs": [[110, "jump"], [243, "fall"]]}, {"id": 243, "start": 17978, "end": 17983, "lines": ["jmp loc_3A34"], "succs": [[48, "jump"]]}, {"id": 244, "start": 17984, "end": 17997, "lines": ["mov r9d, 3Fh ; '?'", "xor edx, edx", "jmp loc_3AA0"], "succs": [[52, "jump"]]}, {"id": 245, "start": 18000, "end": 18010, "lines": ["cmp [rbp+var_61], 0", "jnz loc_475F"], "succs": [[246, "fall"], [270, "jump"]]}, {"id": 246, "start": 18010, "end": 18023, "lines": ["add r13, 1", "test r11b, r11b", "jnz loc_4908"], "succs": [[247, "fall"], [303, "jump"]]}, {"id": 247, "start": 18023, "end": 18032, "lines": ["cmp rbx, r15", "jb loc_4A6A"], "succs": [[248, "fall"], [330, "jump"]]}, {"id": 248, "start": 18032, "end": 18045, "lines": ["mov [rbp+var_63], 0", "add rbx, 1", "jmp loc_3886"], "succs": [[9, "jump"]]}, {"id": 249, "start": 18045, "end": 18051, "lines": ["cmp r9b, 7Dh ; '}'", "jnz short loc_4624"], "succs": [[240, "jump"], [250, "fall"]]}, {"id": 250, "start": 18051, "end": 18064, "lines": ["xchg ax, ax", "nop word ptr [rax+rax+00000000h]"], "succs": [[251, "fall"]]}, {"id": 251, "start": 18064, "end": 18078, "lines": ["mov byte ptr [rbp+var_60], 0", "cmp r8, 0FFFFFFFFFFFFFFFFh", "jnz loc_45C8"], "succs": [[232, "jump"], [252, "fall"]]}, {"id": 252, "start": 18078, "end": 18094, "lines": ["mov rax, [rbp+var_58]", "cmp byte ptr [rax+1], 0", "setnz al", "jmp loc_45CF"], "succs": [[233, "jump"]]}, {"id": 253, "start": 18096, "end": 18108, "lines": ["movzx edx, [rbp+var_61]", "test dl, dl", "jz loc_4938"], "succs": [[254, "fall"], [306, "jump"]]}, {"id": 254, "start": 18108, "end": 18115, "lines": ["cmp [rbp+var_70], 0", "jz short loc_46CF"], "succs": [[255, "fall"], [256, "jump"]]}, {"id": 255, "start": 18115, "end": 18127, "lines": ["mov byte ptr [rbp+var_60], 0", "mov ecx, r9d", "jmp loc_3B81"], "succs": [[72, "jump"]]}, {"id": 256, "start": 18127, "end": 18130, "lines": ["mov ecx, r9d"], "succs": [[257, "fall"]]}, {"id": 257, "start": 18130, "end": 18134, "lines": ["movzx edx, [rbp+var_61]"], "succs": [[258, "fall"]]}, {"id": 258, "start": 18134, "end": 18152, "lines": ["and [rbp+var_63], dl", "add r13, 1", "mov eax, r11d", "mov r9d, ecx", "jmp loc_3AD1"], "succs": [[57, "jump"]]}, {"id": 259, "start": 18160, "end": 18166, "lines": ["cmp [rbp+var_61], 0", "jnz short loc_475F"], "succs": [[260, "fall"], [270, "jump"]]}, {"id": 260, "start": 18166, "end": 18170, "lines": ["mov [rbp+var_7E], 0"], "succs": [[261, "fall"]]}, {"id": 261, "start": 18170, "end": 18195, "lines": ["test r15, r15", "setnz al", "cmp [rbp+var_A8], 0", "setz dl", "and al, dl", "jnz loc_49DF"], "succs": [[262, "fall"], [322, "jump"]]}, {"id": 262, "start": 18195, "end": 18200, "lines": ["cmp rbx, r15", "jnb short loc_471D"], "succs": [[263, "fall"], [264, "jump"]]}, {"id": 263, "start": 18200, "end": 18205, "lines": ["mov byte ptr [r12+rbx], 27h ; '''"], "succs": [[264, "fall"]]}, {"id": 264, "start": 18205, "end": 18214, "lines": ["lea rax, [rbx+1]", "cmp rax, r15", "jnb short loc_472C"], "succs": [[265, "fall"], [266, "jump"]]}, {"id": 265, "start": 18214, "end": 18220, "lines": ["mov byte ptr [r12+rbx+1], 5Ch ; '\\'"], "succs": [[266, "fall"]]}, {"id": 266, "start": 18220, "end": 18229, "lines": ["lea rax, [rbx+2]", "cmp rax, r15", "jnb short loc_473B"], "succs": [[267, "fall"], [268, "jump"]]}, {"id": 267, "start": 18229, "end": 18235, "lines": ["mov byte ptr [r12+rbx+2], 27h ; '''"], "succs": [[268, "fall"]]}, {"id": 268, "start": 18235, "end": 18259, "lines": ["add rbx, 3", "add r13, 1", "mov r9d, 27h ; '''", "cmp [rbp+var_7E], 0", "jnz loc_3958"], "succs": [[26, "jump"], [269, "fall"]]}, {"id": 269, "start": 18259, "end": 18271, "lines": ["mov [rbp+var_7E], r14b", "xor r11d, r11d", "jmp loc_3995"], "succs": [[36, "jump"]]}, {"id": 270, "start": 18271, "end": 18282, "lines": ["mov r14, r8", "mov rbx, r12", "jmp loc_3B20"], "succs": [[66, "jump"]]}, {"id": 271, "start": 18282, "end": 18357, "lines": ["mov [rbp+var_C0], r8", "mov edi, [rbp+wc]; wc", "mov byte ptr [rbp+var_B8], r11b", "mov byte ptr [rbp+var_90], r9b", "mov [rbp+var_88], rcx", "call cs:iswprint_ptr", "mov rcx, [rbp+var_88]", "movzx r9d, byte ptr [rbp+var_90]", "test eax, eax", "movzx r11d, byte ptr [rbp+var_B8]", "mov r8, [rbp+var_C0]", "jnz loc_4E34"], "succs": [[272, "fall"], [405, "jump"]]}, {"id": 272, "start": 18357, "end": 18368, "lines": ["nop word ptr [rax+rax+00000000h]"], "succs": [[273, "fall"]]}, {"id": 273, "start": 18368, "end": 18375, "lines": ["movzx eax, [rbp+var_65]", "xor r14d, r14d"], "succs": [[274, "fall"]]}, {"id": 274, "start": 18375, "end": 18391, "lines": ["lea rdx, [rcx+r13]", "add r13, 1", "test al, al", "jz loc_4CDB"], "succs": [[275, "fall"], [373, "jump"]]}, {"id": 275, "start": 18391, "end": 18401, "lines": ["cmp [rbp+var_61], 0", "jnz loc_4A49"], "succs": [[276, "fall"], [328, "jump"]]}, {"id": 276, "start": 18401, "end": 18411, "lines": ["movzx ecx, [rbp+var_64]", "mov rsi, [rbp+var_58]", "jmp short loc_4806"], "succs": [[280, "jump"]]}, {"id": 277, "start": 18416, "end": 18421, "lines": ["cmp rax, r15", "jnb short loc_47F9"], "succs": [[278, "fall"], [279, "jump"]]}, {"id": 278, "start": 18421, "end": 18425, "lines": ["mov [r12+rax], r9b"], "succs": [[279, "fall"]]}, {"id": 279, "start": 18425, "end": 18438, "lines": ["movzx r9d, byte ptr [rsi+r13]", "add rbx, 4", "add r13, 1"], "succs": [[280, "fall"]]}, {"id": 280, "start": 18438, "end": 18448, "lines": ["mov eax, r11d", "xor eax, 1", "and al, cl", "jz short loc_483F"], "succs": [[281, "fall"], [288, "jump"]]}, {"id": 281, "start": 18448, "end": 18453, "lines": ["cmp rbx, r15", "jnb short loc_481A"], "succs": [[282, "fall"], [283, "jump"]]}, {"id": 282, "start": 18453, "end": 18458, "lines": ["mov byte ptr [r12+rbx], 27h ; '''"], "succs": [[283, "fall"]]}, {"id": 283, "start": 18458, "end": 18467, "lines": ["lea rdi, [rbx+1]", "cmp rdi, r15", "jnb short loc_4829"], "succs": [[284, "fall"], [285, "jump"]]}, {"id": 284, "start": 18467, "end": 18473, "lines": ["mov byte ptr [r12+rbx+1], 24h ; '$'"], "succs": [[285, "fall"]]}, {"id": 285, "start": 18473, "end": 18482, "lines": ["lea rdi, [rbx+2]", "cmp rdi, r15", "jnb short loc_4838"], "succs": [[286, "fall"], [287, "jump"]]}, {"id": 286, "start": 18482, "end": 18488, "lines": ["mov byte ptr [r12+rbx+2], 27h ; '''"], "succs": [[287, "fall"]]}, {"id": 287, "start": 18488, "end": 18495, "lines": ["add rbx, 3", "mov r11d, eax"], "succs": [[288, "fall"]]}, {"id": 288, "start": 18495, "end": 18500, "lines": ["cmp rbx, r15", "jnb short loc_4849"], "succs": [[289, "fall"], [290, "jump"]]}, {"id": 289, "start": 18500, "end": 18505, "lines": ["mov byte ptr [r12+rbx], 5Ch ; '\\'"], "succs": [[290, "fall"]]}, {"id": 290, "start": 18505, "end": 18514, "lines": ["lea rax, [rbx+1]", "cmp rax, r15", "jnb short loc_4860"], "succs": [[291, "fall"], [292, "jump"]]}, {"id": 291, "start": 18514, "end": 18528, "lines": ["mov eax, r9d", "shr al, 6", "add eax, 30h ; '0'", "mov [r12+rbx+1], al"], "succs": [[292, "fall"]]}, {"id": 292, "start": 18528, "end": 18537, "lines": ["lea rax, [rbx+2]", "cmp rax, r15", "jnb short loc_487A"], "succs": [[293, "fall"], [294, "jump"]]}, {"id": 293, "start": 18537, "end": 18554, "lines": ["mov eax, r9d", "shr al, 3", "and eax, 7", "add eax, 30h ; '0'", "mov [r12+rbx+2], al"], "succs": [[294, "fall"]]}, {"id": 294, "start": 18554, "end": 18575, "lines": ["and r9d, 7", "lea rax, [rbx+3]", "add r9d, 30h ; '0'", "cmp r13, rdx", "jb loc_47F0"], "succs": [[277, "jump"], [295, "fall"]]}, {"id": 295, "start": 18575, "end": 18587, "lines": ["mov [rbp+var_63], 0", "mov rbx, rax", "jmp loc_3995"], "succs": [[36, "jump"]]}, {"id": 296, "start": 18592, "end": 18602, "lines": ["cmp r9b, 7Ah ; 'z'", "jg loc_4A79"], "succs": [[297, "fall"], [331, "jump"]]}, {"id": 297, "start": 18602, "end": 18612, "lines": ["cmp r9b, 40h ; '@'", "jz def_4075; jumptable 0000000000004075 default case, cases 1-6,14-31"], "succs": [[147, "jump"], [298, "fall"]]}, {"id": 298, "start": 18612, "end": 18643, "lines": ["lea ecx, [r9-41h]", "mov eax, 1", "shl rax, cl", "mov rcx, 3FFFFFF53FFFFFFh", "test rax, rcx", "jnz loc_4080; jumptable 0000000000004075 cases 37,43-58"], "succs": [[144, "jump"], [299, "fall"]]}, {"id": 299, "start": 18643, "end": 18654, "lines": ["test eax, 0A4000000h", "jnz loc_41F8; jumptable 0000000000004075 cases 33,34,36,38,40-42,59-62"], "succs": [[158, "jump"], [300, "fall"]]}, {"id": 300, "start": 18654, "end": 18664, "lines": ["cmp [rbp+var_7C], 2", "jz loc_465A"], "succs": [[246, "jump"], [301, "fall"]]}, {"id": 301, "start": 18664, "end": 18674, "lines": ["cmp [rbp+var_65], 0", "jnz loc_4AA7"], "succs": [[302, "fall"], [335, "jump"]]}, {"id": 302, "start": 18674, "end": 18693, "lines": ["movzx eax, [rbp+var_62]", "mov ecx, r9d", "xor edx, edx", "mov byte ptr [rbp+var_60], al", "xor eax, eax", "jmp loc_4302"], "succs": [[179, "jump"]]}, {"id": 303, "start": 18696, "end": 18711, "lines": ["mov [rbp+var_63], 0", "mov r9d, 5Ch ; '\\'", "jmp loc_3AD9"], "succs": [[58, "jump"]]}, {"id": 304, "start": 18711, "end": 18725, "lines": ["mov byte ptr [rbp+var_60], 0", "mov ecx, r9d", "xor edx, edx", "jmp loc_3B6C"], "succs": [[70, "jump"]]}, {"id": 305, "start": 18728, "end": 18739, "lines": ["movzx edx, [rbp+var_62]", "xor eax, eax", "jmp loc_3AC4"], "succs": [[56, "jump"]]}, {"id": 306, "start": 18744, "end": 18760, "lines": ["mov byte ptr [rbp+var_60], 0", "movzx edx, [rbp+var_66]", "mov ecx, r9d", "jmp loc_4300"], "succs": [[178, "jump"]]}, {"id": 307, "start": 18768, "end": 18777, "lines": ["movzx edx, [rbp+var_61]", "xor dl, 1", "jz short loc_4963"], "succs": [[308, "fall"], [309, "jump"]]}, {"id": 308, "start": 18777, "end": 18787, "lines": ["cmp [rbp+var_64], 0", "jnz loc_39ED"], "succs": [[43, "jump"], [309, "fall"]]}, {"id": 309, "start": 18787, "end": 18792, "lines": ["mov eax, edx", "mov r11, rbx"], "succs": [[310, "fall"]]}, {"id": 310, "start": 18792, "end": 18804, "lines": ["mov rdi, [rbp+s2]", "test rdi, rdi", "jz short loc_49A6"], "succs": [[311, "fall"], [318, "jump"]]}, {"id": 311, "start": 18804, "end": 18808, "lines": ["test al, al", "jz short loc_49A6"], "succs": [[312, "fall"], [318, "jump"]]}, {"id": 312, "start": 18808, "end": 18818, "lines": ["movzx ecx, byte ptr [rdi]", "mov rax, rdi", "test cl, cl", "jz short loc_49A6"], "succs": [[313, "fall"], [318, "jump"]]}, {"id": 313, "start": 18818, "end": 18831, "lines": ["mov rsi, [rbp+var_E0]", "mov rdx, r11", "sub rax, r11"], "succs": [[314, "fall"]]}, {"id": 314, "start": 18831, "end": 18836, "lines": ["cmp rdx, r15", "jnb short loc_4997"], "succs": [[315, "fall"], [316, "jump"]]}, {"id": 315, "start": 18836, "end": 18839, "lines": ["mov [rsi+rdx], cl"], "succs": [[316, "fall"]]}, {"id": 316, "start": 18839, "end": 18851, "lines": ["add rdx, 1", "movzx ecx, byte ptr [rax+rdx]", "test cl, cl", "jnz short loc_498F"], "succs": [[314, "jump"], [317, "fall"]]}, {"id": 317, "start": 18851, "end": 18854, "lines": ["mov r11, rdx"], "succs": [[318, "fall"]]}, {"id": 318, "start": 18854, "end": 18859, "lines": ["cmp r11, r15", "jnb short loc_49B7"], "succs": [[319, "fall"], [320, "jump"]]}, {"id": 319, "start": 18859, "end": 18871, "lines": ["mov rax, [rbp+var_E0]", "mov byte ptr [rax+r11], 0"], "succs": [[320, "fall"]]}, {"id": 320, "start": 18871, "end": 18890, "lines": ["mov rax, [rbp+var_38]", "sub rax, fs:28h", "jnz loc_4F5D"], "succs": [[321, "fall"], [422, "jump"]]}, {"id": 321, "start": 18890, "end": 18911, "lines": ["add rsp, 0B8h", "mov rax, r11", "pop rbx", "pop r12", "pop r13", "pop r14", "pop r15", "pop rbp", "retn"], "succs": []}, {"id": 322, "start": 18911, "end": 18925, "lines": ["add r13, 1", "cmp [rbp+var_7E], 0", "jz loc_4B95"], "succs": [[323, "fall"], [352, "jump"]]}, {"id": 323, "start": 18925, "end": 18955, "lines": ["mov [rbp+var_A8], r15", "movzx r11d, [rbp+var_7E]", "add rbx, 6", "xor r15d, r15d", "mov r9d, 27h ; '''", "jmp loc_3991"], "succs": [[35, "jump"]]}, {"id": 324, "start": 18955, "end": 18969, "lines": ["add r13, 1", "cmp r8, 0FFFFFFFFFFFFFFFFh", "jz loc_39B3"], "succs": [[39, "jump"], [325, "fall"]]}, {"id": 325, "start": 18969, "end": 18978, "lines": ["cmp r13, r8", "jz loc_39D0"], "succs": [[40, "jump"], [326, "fall"]]}, {"id": 326, "start": 18978, "end": 18991, "lines": ["mov rax, [rbp+var_58]", "lea r10, [rax+r13]", "jmp loc_38BB"], "succs": [[13, "jump"]]}, {"id": 327, "start": 18991, "end": 19017, "lines": ["xor eax, eax", "xor ecx, ecx", "mov [rbp+var_63], 0", "add r13, 1", "xor eax, 1", "mov r9d, ecx", "and eax, r11d", "jmp loc_3AD1"], "succs": [[57, "jump"]]}, {"id": 328, "start": 19017, "end": 19035, "lines": ["movzx eax, [rbp+var_64]", "mov r14, r8", "mov rbx, r12", "mov [rbp+var_65], al", "jmp loc_3B20"], "succs": [[66, "jump"]]}, {"id": 329, "start": 19035, "end": 19050, "lines": ["mov r11d, eax", "mov ecx, 30h ; '0'", "xor edx, edx", "jmp loc_4302"], "succs": [[179, "jump"]]}, {"id": 330, "start": 19050, "end": 19065, "lines": ["mov [rbp+var_63], 0", "mov r9d, 5Ch ; '\\'", "jmp loc_399A"], "succs": [[37, "jump"]]}, {"id": 331, "start": 19065, "end": 19082, "lines": ["movzx eax, [rbp+var_62]", "mov byte ptr [rbp+var_60], al", "cmp r9b, 7Dh ; '}'", "jz loc_45BE"], "succs": [[231, "jump"], [332, "fall"]]}, {"id": 332, "start": 19082, "end": 19088, "lines": ["jg loc_4C48"], "succs": [[333, "fall"], [364, "jump"]]}, {"id": 333, "start": 19088, "end": 19098, "lines": ["cmp r9b, 7Bh ; '{'", "jz loc_45BE"], "succs": [[231, "jump"], [334, "fall"]]}, {"id": 334, "start": 19098, "end": 19111, "lines": ["xor edx, edx", "mov r9d, 7Ch ; '|'", "jmp loc_3B06"], "succs": [[64, "jump"]]}, {"id": 335, "start": 19111, "end": 19139, "lines": ["mov eax, r11d", "mov [rbp+var_63], 0", "add r13, 1", "mov r9d, 5Ch ; '\\'", "xor eax, 1", "and al, [rbp+var_64]", "jmp loc_3954"], "succs": [[25, "jump"]]}, {"id": 336, "start": 19139, "end": 19160, "lines": ["movzx eax, [rbp+var_9D]", "mov rbx, rsi", "mov r9d, 30h ; '0'", "jmp loc_3ABA"], "succs": [[55, "jump"]]}, {"id": 337, "start": 19160, "end": 19172, "lines": ["movzx edx, [rbp+var_61]", "test dl, dl", "jz loc_4938"], "succs": [[306, "jump"], [338, "fall"]]}, {"id": 338, "start": 19172, "end": 19186, "lines": ["mov ecx, r9d", "cmp [rbp+var_70], 0", "jz loc_46D2"], "succs": [[257, "jump"], [339, "fall"]]}, {"id": 339, "start": 19186, "end": 19195, "lines": ["mov byte ptr [rbp+var_60], 0", "jmp loc_3B81"], "succs": [[72, "jump"]]}, {"id": 340, "start": 19195, "end": 19205, "lines": ["cmp byte ptr [rbp+var_60], 0", "jz loc_4D84"], "succs": [[341, "fall"], [392, "jump"]]}, {"id": 341, "start": 19205, "end": 19226, "lines": ["mov [rbp+var_63], 0", "mov r13, rcx", "mov rbx, rsi", "mov r9d, 30h ; '0'", "jmp loc_3987"], "succs": [[33, "jump"]]}, {"id": 342, "start": 19226, "end": 19239, "lines": ["movzx eax, [rbp+var_65]", "xor edx, edx", "xor ecx, ecx", "jmp loc_3A96"], "succs": [[50, "jump"]]}, {"id": 343, "start": 19239, "end": 19253, "lines": ["mov rax, [rbp+var_C8]", "xor ecx, ecx", "cmp r13, r8", "jb short loc_4B49"], "succs": [[344, "fall"], [346, "jump"]]}, {"id": 344, "start": 19253, "end": 19258, "lines": ["jmp loc_4F52"], "succs": [[421, "jump"]]}, {"id": 345, "start": 19264, "end": 19273, "lines": ["add rcx, 1", "cmp rax, rcx", "jz short loc_4B50"], "succs": [[346, "fall"], [347, "jump"]]}, {"id": 346, "start": 19273, "end": 19280, "lines": ["cmp byte ptr [r10+rcx], 0", "jnz short loc_4B40"], "succs": [[345, "jump"], [347, "fall"]]}, {"id": 347, "start": 19280, "end": 19294, "lines": ["movzx eax, [rbp+var_65]", "cmp rcx, 1", "ja loc_4DF4"], "succs": [[348, "fall"], [401, "jump"]]}, {"id": 348, "start": 19294, "end": 19301, "lines": ["xor edx, edx", "jmp loc_3A96"], "succs": [[50, "jump"]]}, {"id": 349, "start": 19301, "end": 19310, "lines": ["mov byte ptr [rbp+var_60], 0", "jmp loc_45C8"], "succs": [[232, "jump"]]}, {"id": 350, "start": 19310, "end": 19320, "lines": ["cmp [rbp+var_61], 0", "jnz loc_475F"], "succs": [[270, "jump"], [351, "fall"]]}, {"id": 351, "start": 19320, "end": 19349, "lines": ["xor eax, eax", "mov ecx, 3Fh ; '?'", "mov [rbp+var_63], 0", "add r13, 1", "xor eax, 1", "mov r9d, ecx", "and eax, r11d", "jmp loc_3AD1"], "succs": [[57, "jump"]]}, {"id": 352, "start": 19349, "end": 19374, "lines": ["mov [rbp+var_A8], r15", "add rbx, 3", "xor r11d, r11d", "xor r15d, r15d", "mov [rbp+var_7E], al", "jmp loc_399E"], "succs": [[38, "jump"]]}, {"id": 353, "start": 19374, "end": 19403, "lines": ["xor eax, eax", "mov ecx, 7", "mov [rbp+var_63], 0", "add r13, 1", "xor eax, 1", "mov r9d, ecx", "and eax, r11d", "jmp loc_3AD1"], "succs": [[57, "jump"]]}, {"id": 354, "start": 19403, "end": 19425, "lines": ["xor ecx, ecx", "xor edx, edx", "add r13, 1", "and [rbp+var_63], dl", "mov eax, r11d", "mov r9d, ecx", "jmp loc_3AD1"], "succs": [[57, "jump"]]}, {"id": 355, "start": 19425, "end": 19438, "lines": ["xor edx, edx", "mov r9d, 61h ; 'a'", "jmp loc_3937"], "succs": [[22, "jump"]]}, {"id": 356, "start": 19438, "end": 19456, "lines": ["mov [rbp+var_63], 0", "mov r11d, eax", "mov r9d, 30h ; '0'", "jmp loc_3995"], "succs": [[36, "jump"]]}, {"id": 357, "start": 19456, "end": 19461, "lines": ["cmp rsi, r15", "jnb short loc_4C0A"], "succs": [[358, "fall"], [359, "jump"]]}, {"id": 358, "start": 19461, "end": 19466, "lines": ["mov byte ptr [r12+rsi], 30h ; '0'"], "succs": [[359, "fall"]]}, {"id": 359, "start": 19466, "end": 19475, "lines": ["lea rax, [rbx+2]", "cmp rax, r15", "jnb short loc_4C19"], "succs": [[360, "fall"], [361, "jump"]]}, {"id": 360, "start": 19475, "end": 19481, "lines": ["mov byte ptr [r12+rbx+2], 30h ; '0'"], "succs": [[361, "fall"]]}, {"id": 361, "start": 19481, "end": 19498, "lines": ["mov rax, [rbp+var_70]", "add rbx, 3", "test rax, rax", "jz loc_4E15"], "succs": [[362, "fall"], [403, "jump"]]}, {"id": 362, "start": 19498, "end": 19510, "lines": ["xor edx, edx", "test byte ptr [rax+6], 1", "jnz loc_4550"], "succs": [[222, "jump"], [363, "fall"]]}, {"id": 363, "start": 19510, "end": 19528, "lines": ["movzx eax, [rbp+var_9D]", "mov r9d, 30h ; '0'", "jmp loc_3ABA"], "succs": [[55, "jump"]]}, {"id": 364, "start": 19528, "end": 19538, "lines": ["cmp r9b, 7Eh ; '~'", "jz loc_3D70"], "succs": [[110, "jump"], [365, "fall"]]}, {"id": 365, "start": 19538, "end": 19549, "lines": ["mov r9d, 7Fh", "jmp loc_3A34"], "succs": [[48, "jump"]]}, {"id": 366, "start": 19549, "end": 19559, "lines": ["cmp rax, 1", "jz loc_4EFC"], "succs": [[367, "fall"], [419, "jump"]]}, {"id": 367, "start": 19559, "end": 19583, "lines": ["mov rax, [rbp+var_58]", "add r10, rcx", "mov rsi, 20000002Bh", "lea rdx, [rax+r13+1]", "jmp short loc_4C8D"], "succs": [[369, "jump"]]}, {"id": 368, "start": 19584, "end": 19597, "lines": ["add rdx, 1", "cmp rdx, r10", "jz loc_476A"], "succs": [[271, "jump"], [369, "fall"]]}, {"id": 369, "start": 19597, "end": 19607, "lines": ["movzx eax, byte ptr [rdx]", "sub eax, 5Bh ; '['", "cmp al, 21h ; '!'", "ja short loc_4C80"], "succs": [[368, "jump"], [370, "fall"]]}, {"id": 370, "start": 19607, "end": 19613, "lines": ["bt rsi, rax", "jnb short loc_4C80"], "succs": [[368, "jump"], [371, "fall"]]}, {"id": 371, "start": 19613, "end": 19618, "lines": ["jmp loc_3B0C"], "succs": [[65, "jump"]]}, {"id": 372, "start": 19618, "end": 19675, "lines": ["movzx eax, [rbp+var_61]", "mov [rbp+var_62], 0", "xor ebx, ebx", "mov [rbp+var_7D], 0", "mov [rbp+var_66], al", "mov [rbp+var_67], al", "mov [rbp+var_64], al", "lea rax, asc_8348+2; \"'`\"", "mov [rbp+var_9D], 0", "mov [rbp+n], 1", "mov [rbp+s2], rax", "jmp loc_3880"], "succs": [[8, "jump"]]}, {"id": 373, "start": 19675, "end": 19684, "lines": ["mov eax, r11d", "cmp byte ptr [rbp+var_60], 0", "jz short loc_4CF2"], "succs": [[374, "fall"], [377, "jump"]]}, {"id": 374, "start": 19684, "end": 19689, "lines": ["cmp rbx, r15", "jnb short loc_4CEE"], "succs": [[375, "fall"], [376, "jump"]]}, {"id": 375, "start": 19689, "end": 19694, "lines": ["mov byte ptr [r12+rbx], 5Ch ; '\\'"], "succs": [[376, "fall"]]}, {"id": 376, "start": 19694, "end": 19698, "lines": ["add rbx, 1"], "succs": [[377, "fall"]]}, {"id": 377, "start": 19698, "end": 19703, "lines": ["cmp r13, rdx", "jnb short loc_4D54"], "succs": [[378, "fall"], [388, "jump"]]}, {"id": 378, "start": 19703, "end": 19707, "lines": ["test al, al", "jz short loc_4D35"], "succs": [[379, "fall"], [386, "jump"]]}, {"id": 379, "start": 19707, "end": 19712, "lines": ["cmp rbx, r15", "jnb short loc_4D05"], "succs": [[380, "fall"], [381, "jump"]]}, {"id": 380, "start": 19712, "end": 19717, "lines": ["mov byte ptr [r12+rbx], 27h ; '''"], "succs": [[381, "fall"]]}, {"id": 381, "start": 19717, "end": 19726, "lines": ["lea rax, [rbx+1]", "cmp rax, r15", "jnb short loc_4D14"], "succs": [[382, "fall"], [383, "jump"]]}, {"id": 382, "start": 19726, "end": 19732, "lines": ["mov byte ptr [r12+rbx+1], 27h ; '''"], "succs": [[383, "fall"]]}, {"id": 383, "start": 19732, "end": 19741, "lines": ["lea rax, [rbx+2]", "cmp rax, r15", "jb short loc_4D60"], "succs": [[384, "fall"], [390, "jump"]]}, {"id": 384, "start": 19741, "end": 19763, "lines": ["mov rax, [rbp+var_58]", "add rbx, 3", "movzx r9d, byte ptr [rax+r13]", "add r13, 1", "cmp r13, rdx", "jnb short loc_4D7D"], "succs": [[385, "fall"], [391, "jump"]]}, {"id": 385, "start": 19763, "end": 19765, "lines": ["xor eax, eax"], "succs": [[386, "fall"]]}, {"id": 386, "start": 19765, "end": 19774, "lines": ["cmp rbx, r15", "jb loc_4DDA"], "succs": [[387, "fall"], [400, "jump"]]}, {"id": 387, "start": 19774, "end": 19796, "lines": ["mov rcx, [rbp+var_58]", "add rbx, 1", "movzx r9d, byte ptr [rcx+r13]", "add r13, 1", "cmp r13, rdx", "jb short loc_4CF7"], "succs": [[378, "jump"], [388, "fall"]]}, {"id": 388, "start": 19796, "end": 19799, "lines": ["mov r11d, eax"], "succs": [[389, "fall"]]}, {"id": 389, "start": 19799, "end": 19808, "lines": ["and [rbp+var_63], r14b", "jmp loc_3AD1"], "succs": [[57, "jump"]]}, {"id": 390, "start": 19808, "end": 19837, "lines": ["mov rax, [rbp+var_58]", "mov [r12+rbx+2], r9b", "add rbx, 3", "movzx r9d, byte ptr [rax+r13]", "add r13, 1", "xor eax, eax", "jmp loc_4CF2"], "succs": [[377, "jump"]]}, {"id": 391, "start": 19837, "end": 19844, "lines": ["xor r11d, r11d", "xor eax, eax", "jmp short loc_4D57"], "succs": [[389, "jump"]]}, {"id": 392, "start": 19844, "end": 19865, "lines": ["mov [rbp+var_63], 0", "mov r13, rcx", "mov rbx, rsi", "mov r9d, 30h ; '0'", "jmp loc_3995"], "succs": [[36, "jump"]]}, {"id": 393, "start": 19865, "end": 19877, "lines": ["add r13, 1", "mov ecx, r9d", "jmp loc_4313"], "succs": [[180, "jump"]]}, {"id": 394, "start": 19877, "end": 19895, "lines": ["mov rdx, [rbp+var_D8]", "movzx eax, byte ptr [rdx]", "test al, al", "jz loc_3836"], "succs": [[7, "jump"], [395, "fall"]]}, {"id": 395, "start": 19895, "end": 19904, "lines": ["nop word ptr [rax+rax+00000000h]"], "succs": [[396, "fall"]]}, {"id": 396, "start": 19904, "end": 19909, "lines": ["cmp rbx, r15", "jnb short loc_4DC9"], "succs": [[397, "fall"], [398, "jump"]]}, {"id": 397, "start": 19909, "end": 19913, "lines": ["mov [r12+rbx], al"], "succs": [[398, "fall"]]}, {"id": 398, "start": 19913, "end": 19925, "lines": ["add rbx, 1", "movzx eax, byte ptr [rdx+rbx]", "test al, al", "jnz short loc_4DC0"], "succs": [[396, "jump"], [399, "fall"]]}, {"id": 399, "start": 19925, "end": 19930, "lines": ["jmp loc_3836"], "succs": [[7, "jump"]]}, {"id": 400, "start": 19930, "end": 19956, "lines": ["mov rdi, [rbp+var_58]", "mov [r12+rbx], r9b", "add rbx, 1", "movzx r9d, byte ptr [rdi+r13]", "add r13, 1", "jmp loc_4CF2"], "succs": [[377, "jump"]]}, {"id": 401, "start": 19956, "end": 19964, "lines": ["xor r14d, r14d", "jmp loc_47C7"], "succs": [[274, "jump"]]}, {"id": 402, "start": 19964, "end": 19989, "lines": ["mov ecx, 7", "xor edx, edx", "add r13, 1", "and [rbp+var_63], dl", "mov eax, r11d", "mov r9d, ecx", "jmp loc_3AD1"], "succs": [[57, "jump"]]}, {"id": 403, "start": 19989, "end": 20002, "lines": ["mov rsi, rbx", "cmp byte ptr [rbp+var_60], 0", "jnz loc_4B05"], "succs": [[341, "jump"], [404, "fall"]]}, {"id": 404, "start": 20002, "end": 20020, "lines": ["mov [rbp+var_63], 0", "mov r13, rcx", "mov r9d, 30h ; '0'", "jmp loc_3995"], "succs": [[36, "jump"]]}, {"id": 405, "start": 20020, "end": 20027, "lines": ["xor eax, eax", "jmp loc_47C7"], "succs": [[274, "jump"]]}, {"id": 406, "start": 20027, "end": 20046, "lines": ["mov r9d, ecx", "xor edx, edx", "mov r13, rsi", "cmp [rbp+var_70], 0", "jnz loc_3914"], "succs": [[20, "jump"], [407, "fall"]]}, {"id": 407, "start": 20046, "end": 20058, "lines": ["xor eax, eax", "cmp byte ptr [rbp+var_60], 0", "jz loc_3AC4"], "succs": [[56, "jump"], [408, "fall"]]}, {"id": 408, "start": 20058, "end": 20067, "lines": ["lea rcx, [rsi+1]", "jmp loc_3945"], "succs": [[24, "jump"]]}, {"id": 409, "start": 20067, "end": 20075, "lines": ["mov [rbp+var_7E], al", "jmp loc_46FA"], "succs": [[261, "jump"]]}, {"id": 410, "start": 20075, "end": 20101, "lines": ["cmp byte ptr [rbp+var_60], 0", "mov [rbp+var_63], 0", "mov r13, rcx", "mov rbx, rsi", "mov r9d, 30h ; '0'", "jnz loc_3987"], "succs": [[33, "jump"], [411, "fall"]]}, {"id": 411, "start": 20101, "end": 20106, "lines": ["jmp loc_3995"], "succs": [[36, "jump"]]}, {"id": 412, "start": 20106, "end": 20118, "lines": ["movzx eax, [rbp+var_7E]", "mov r11, rbx", "jmp loc_4968"], "succs": [[310, "jump"]]}, {"id": 413, "start": 20118, "end": 20143, "lines": ["mov [rbp+var_7C], 5", "mov r15, [rbp+var_A8]", "mov r14, r8", "mov rbx, r12", "jmp loc_3780"], "succs": [[1, "jump"]]}, {"id": 414, "start": 20143, "end": 20156, "lines": ["mov r11, rbx", "mov eax, 1", "jmp loc_4968"], "succs": [[310, "jump"]]}, {"id": 415, "start": 20156, "end": 20173, "lines": ["xor edx, edx", "mov r9d, 76h ; 'v'", "lea rcx, [r13+1]", "jmp loc_3945"], "succs": [[24, "jump"]]}, {"id": 416, "start": 20173, "end": 20190, "lines": ["xor edx, edx", "mov r9d, 62h ; 'b'", "lea rcx, [r13+1]", "jmp loc_3945"], "succs": [[24, "jump"]]}, {"id": 417, "start": 20190, "end": 20203, "lines": ["xor edx, edx", "mov r9d, 61h ; 'a'", "jmp loc_3941"], "succs": [[23, "jump"]]}, {"id": 418, "start": 20203, "end": 20220, "lines": ["xor edx, edx", "mov r9d, 66h ; 'f'", "lea rcx, [r13+1]", "jmp loc_3945"], "succs": [[24, "jump"]]}, {"id": 419, "start": 20220, "end": 20288, "lines": ["mov [rbp+var_C0], r8", "mov edi, [rbp+wc]; wc", "mov byte ptr [rbp+var_B8], r11b", "mov byte ptr [rbp+var_90], r9b", "mov [rbp+var_88], rax", "call cs:iswprint_ptr", "movzx r9d, byte ptr [rbp+var_90]", "movzx r11d, byte ptr [rbp+var_B8]", "test eax, eax", "mov r8, [rbp+var_C0]", "jnz loc_41F0"], "succs": [[157, "jump"], [420, "fall"]]}, {"id": 420, "start": 20288, "end": 20306, "lines": ["movzx eax, [rbp+var_65]", "mov rcx, [rbp+var_88]", "xor edx, edx", "jmp loc_3A96"], "succs": [[50, "jump"]]}, {"id": 421, "start": 20306, "end": 20317, "lines": ["movzx eax, [rbp+var_65]", "xor edx, edx", "jmp loc_3A96"], "succs": [[50, "jump"]]}, {"id": 422, "start": 20317, "end": 20323, "lines": ["call cs:__stack_chk_fail_ptr"], "succs": []}, {"id": 423, "start": 9275, "end": 9281, "lines": ["call cs:abort_ptr; jumptable 00000000000037E7 default case"], "succs": []}]}, {"name": "sub_69C0", "ea": 27072, "blocks": [{"id": 0, "start": 27072, "end": 27115, "lines": ["endbr64", "push rbp", "mov rbp, rsp", "push r14", "push r13", "push r12", "push rbx", "sub rsp, 20h", "mov rax, fs:28h", "mov [rbp+var_28], rax", "xor eax, eax", "test rsi, rsi", "jz loc_6C28"], "succs": [[1, "fall"], [41, "jump"]]}, {"id": 1, "start": 27115, "end": 27130, "lines": ["mov r12, rdi", "mov rbx, rsi", "test rdx, rdx", "jz loc_6D80"], "succs": [[2, "fall"], [65, "jump"]]}, {"id": 2, "start": 27130, "end": 27161, "lines": ["test rcx, rcx", "lea rax, unk_C278", "cmovnz rax, rcx", "mov r13, rax", "mov eax, cs:dword_C0A0", "test eax, eax", "js loc_6C40"], "succs": [[3, "fall"], [42, "jump"]]}, {"id": 3, "start": 27161, "end": 27169, "lines": ["test eax, eax", "jz loc_6C61"], "succs": [[4, "fall"], [44, "jump"]]}, {"id": 4, "start": 27169, "end": 27189, "lines": ["mov eax, [r13+0]", "mov r9d, eax", "mov r8d, eax", "and r9d, 7", "and r8d, 7", "jnz short loc_6A80"], "succs": [[5, "fall"], [12, "jump"]]}, {"id": 5, "start": 27189, "end": 27202, "lines": ["movsx ecx, byte ptr [rbx]", "mov eax, ecx", "test cl, cl", "jns loc_6D38"], "succs": [[6, "fall"], [58, "jump"]]}, {"id": 6, "start": 27202, "end": 27211, "lines": ["cmp cl, 0C1h", "jbe loc_6BE0"], "succs": [[7, "fall"], [37, "jump"]]}, {"id": 7, "start": 27211, "end": 27220, "lines": ["cmp cl, 0DFh", "ja loc_6B30"], "succs": [[8, "fall"], [20, "jump"]]}, {"id": 8, "start": 27220, "end": 27230, "lines": ["cmp rdx, 1", "jz loc_6EF0"], "succs": [[9, "fall"], [97, "jump"]]}, {"id": 9, "start": 27230, "end": 27246, "lines": ["movzx edx, byte ptr [rbx+1]", "add edx, 0FFFFFF80h", "cmp dl, 3Fh ; '?'", "ja loc_6BE0"], "succs": [[10, "fall"], [37, "jump"]]}, {"id": 10, "start": 27246, "end": 27255, "lines": ["test r12, r12", "jnz loc_6E00"], "succs": [[11, "fall"], [75, "jump"]]}, {"id": 11, "start": 27255, "end": 27260, "lines": ["jmp loc_6E11"], "succs": [[76, "jump"]]}, {"id": 12, "start": 27264, "end": 27283, "lines": ["mov r11d, eax", "sar r11d, 8", "movsxd rsi, r11d", "cmp r9, rsi", "jnb loc_6D20"], "succs": [[13, "fall"], [57, "jump"]]}, {"id": 13, "start": 27283, "end": 27293, "lines": ["cmp rsi, 4", "ja loc_6D20"], "succs": [[14, "fall"], [57, "jump"]]}, {"id": 14, "start": 27293, "end": 27339, "lines": ["mov r10d, [r13+4]", "lea ecx, [rsi+rsi*2-3]", "mov edi, 100h", "add ecx, ecx", "mov r14d, r10d", "shr r14d, cl", "mov ecx, r11d", "sar edi, cl", "neg edi", "or edi, r14d", "mov [rbp+var_2C], dil", "movzx edi, byte ptr [rbx]", "test al, 6", "jz loc_6D90"], "succs": [[15, "fall"], [66, "jump"]]}, {"id": 15, "start": 27339, "end": 27369, "lines": ["lea ecx, [rsi+rsi*2-6]", "mov eax, r10d", "add ecx, ecx", "shr eax, cl", "and eax, 3Fh", "or eax, 0FFFFFF80h", "mov [rbp+var_2B], al", "cmp r8d, 2", "jle loc_6D90"], "succs": [[16, "fall"], [66, "jump"]]}, {"id": 16, "start": 27369, "end": 27410, "lines": ["lea ecx, [rsi+rsi*2-9]", "add ecx, ecx", "shr r10d, cl", "and r10d, 3Fh", "or r10d, 0FFFFFF80h", "mov [rbp+var_2A], r10b", "mov [rbp+r9+var_2C], dil", "movzx eax, [rbp+var_2C]", "movsx ecx, al", "test al, al", "jns loc_6D38"], "succs": [[17, "fall"], [58, "jump"]]}, {"id": 17, "start": 27410, "end": 27418, "lines": ["cmp al, 0C1h", "jbe loc_6BE0"], "succs": [[18, "fall"], [37, "jump"]]}, {"id": 18, "start": 27418, "end": 27426, "lines": ["cmp al, 0DFh", "jbe loc_6EB4"], "succs": [[19, "fall"], [91, "jump"]]}, {"id": 19, "start": 27426, "end": 27440, "lines": ["lea rdx, [r9+1]", "lea rbx, [rbp+var_2C]", "nop word ptr [rax+rax+00h]"], "succs": [[20, "fall"]]}, {"id": 20, "start": 27440, "end": 27448, "lines": ["cmp al, 0EFh", "jbe loc_6E20"], "succs": [[21, "fall"], [77, "jump"]]}, {"id": 21, "start": 27448, "end": 27456, "lines": ["cmp al, 0F4h", "ja loc_6BE0"], "succs": [[22, "fall"], [37, "jump"]]}, {"id": 22, "start": 27456, "end": 27466, "lines": ["cmp rdx, 1", "jz loc_6F38"], "succs": [[23, "fall"], [102, "jump"]]}, {"id": 23, "start": 27466, "end": 27483, "lines": ["movzx ecx, byte ptr [rbx+1]", "lea esi, [rcx-80h]", "cmp sil, 3Fh ; '?'", "ja loc_6BE0"], "succs": [[24, "fall"], [37, "jump"]]}, {"id": 24, "start": 27483, "end": 27487, "lines": ["cmp al, 0F0h", "jnz short loc_6B64"], "succs": [[25, "fall"], [26, "jump"]]}, {"id": 25, "start": 27487, "end": 27492, "lines": ["cmp cl, 8Fh", "jbe short loc_6BE0"], "succs": [[26, "fall"], [37, "jump"]]}, {"id": 26, "start": 27492, "end": 27496, "lines": ["cmp al, 0F4h", "jnz short loc_6B6D"], "succs": [[27, "fall"], [28, "jump"]]}, {"id": 27, "start": 27496, "end": 27501, "lines": ["cmp cl, 8Fh", "ja short loc_6BE0"], "succs": [[28, "fall"], [37, "jump"]]}, {"id": 28, "start": 27501, "end": 27511, "lines": ["cmp rdx, 2", "jz loc_6FA4"], "succs": [[29, "fall"], [105, "jump"]]}, {"id": 29, "start": 27511, "end": 27523, "lines": ["movzx edi, byte ptr [rbx+2]", "lea ecx, [rdi-80h]", "cmp cl, 3Fh ; '?'", "ja short loc_6BE0"], "succs": [[30, "fall"], [37, "jump"]]}, {"id": 30, "start": 27523, "end": 27533, "lines": ["cmp rdx, 3", "jz loc_6F71"], "succs": [[31, "fall"], [104, "jump"]]}, {"id": 31, "start": 27533, "end": 27545, "lines": ["movzx edx, byte ptr [rbx+3]", "add edx, 0FFFFFF80h", "cmp dl, 3Fh ; '?'", "ja short loc_6BE0"], "succs": [[32, "fall"], [37, "jump"]]}, {"id": 32, "start": 27545, "end": 27550, "lines": ["test r12, r12", "jz short loc_6BC0"], "succs": [[33, "fall"], [34, "jump"]]}, {"id": 33, "start": 27550, "end": 27584, "lines": ["shl eax, 12h", "movsx edx, dl", "movsx esi, sil", "movsx ecx, cl", "shl esi, 0Ch", "and eax, 1C0000h", "shl ecx, 6", "or eax, edx", "or eax, esi", "or eax, ecx", "mov [r12], eax"], "succs": [[34, "fall"]]}, {"id": 34, "start": 27584, "end": 27599, "lines": ["mov eax, 4", "mov edx, 4", "jmp loc_6D4D"], "succs": [[61, "jump"]]}, {"id": 35, "start": 27600, "end": 27615, "lines": ["mov rdx, rsi", "lea rbx, [rbp+var_2C]", "cmp al, 0F4h", "jbe loc_6B4A"], "succs": [[23, "jump"], [36, "fall"]]}, {"id": 36, "start": 27615, "end": 27616, "lines": ["nop"], "succs": [[37, "fall"]]}, {"id": 37, "start": 27616, "end": 27628, "lines": ["call cs:__errno_location_ptr", "mov dword ptr [rax], 54h ; 'T'"], "succs": [[38, "fall"]]}, {"id": 38, "start": 27628, "end": 27648, "lines": ["mov rdx, 0FFFFFFFFFFFFFFFFh", "xchg ax, ax", "nop word ptr [rax+rax+00000000h]"], "succs": [[39, "fall"]]}, {"id": 39, "start": 27648, "end": 27667, "lines": ["mov rax, [rbp+var_28]", "sub rax, fs:28h", "jnz loc_6FD2"], "succs": [[40, "fall"], [107, "jump"]]}, {"id": 40, "start": 27667, "end": 27683, "lines": ["add rsp, 20h", "mov rax, rdx", "pop rbx", "pop r12", "pop r13", "pop r14", "pop rbp", "retn"], "succs": []}, {"id": 41, "start": 27688, "end": 27708, "lines": ["xor r12d, r12d", "mov edx, 1", "lea rbx, accept+3; \"\"", "jmp loc_69FA"], "succs": [[2, "jump"]]}, {"id": 42, "start": 27712, "end": 27735, "lines": ["mov [rbp+n], rdx", "call sub_70A0", "mov rdx, [rbp+n]; n", "cmp byte ptr [rax], 55h ; 'U'", "jz loc_6CE0"], "succs": [[43, "fall"], [52, "jump"]]}, {"id": 43, "start": 27735, "end": 27745, "lines": ["mov cs:dword_C0A0, 0"], "succs": [[44, "fall"]]}, {"id": 44, "start": 27745, "end": 27766, "lines": ["mov rcx, r13; p", "mov rsi, rbx; s", "mov rdi, r12; pc32", "call cs:mbrtoc32_ptr", "cmp rax, 0FFFFFFFFFFFFFFFCh", "jbe short loc_6CB0"], "succs": [[45, "fall"], [50, "jump"]]}, {"id": 45, "start": 27766, "end": 27776, "lines": ["cmp rax, 0FFFFFFFFFFFFFFFDh", "jz loc_2487"], "succs": [[46, "fall"], [108, "jump"]]}, {"id": 46, "start": 27776, "end": 27800, "lines": ["xor edi, edi", "mov [rbp+n], rax", "call sub_7020", "mov rdx, [rbp+n]", "test al, al", "jnz loc_6C00"], "succs": [[39, "jump"], [47, "fall"]]}, {"id": 47, "start": 27800, "end": 27805, "lines": ["test r12, r12", "jz short loc_6CA4"], "succs": [[48, "fall"], [49, "jump"]]}, {"id": 48, "start": 27805, "end": 27812, "lines": ["movzx eax, byte ptr [rbx]", "mov [r12], eax"], "succs": [[49, "fall"]]}, {"id": 49, "start": 27812, "end": 27822, "lines": ["mov edx, 1", "jmp loc_6C00"], "succs": [[39, "jump"]]}, {"id": 50, "start": 27824, "end": 27849, "lines": ["mov [rbp+n], rax", "mov rdi, r13; ps", "call cs:mbsinit_ptr", "mov rdx, [rbp+n]", "test eax, eax", "jnz loc_6C00"], "succs": [[39, "jump"], [51, "fall"]]}, {"id": 51, "start": 27849, "end": 27862, "lines": ["mov qword ptr [r13+0], 0", "jmp loc_6C00"], "succs": [[39, "jump"]]}, {"id": 52, "start": 27872, "end": 27882, "lines": ["cmp byte ptr [rax+1], 54h ; 'T'", "jnz loc_6C57"], "succs": [[43, "jump"], [53, "fall"]]}, {"id": 53, "start": 27882, "end": 27892, "lines": ["cmp byte ptr [rax+2], 46h ; 'F'", "jnz loc_6C57"], "succs": [[43, "jump"], [54, "fall"]]}, {"id": 54, "start": 27892, "end": 27902, "lines": ["cmp byte ptr [rax+3], 2Dh ; '-'", "jnz loc_6C57"], "succs": [[43, "jump"], [55, "fall"]]}, {"id": 55, "start": 27902, "end": 27912, "lines": ["cmp byte ptr [rax+4], 38h ; '8'", "jnz loc_6C57"], "succs": [[43, "jump"], [56, "fall"]]}, {"id": 56, "start": 27912, "end": 27933, "lines": ["cmp byte ptr [rax+5], 0", "setz al", "movzx eax, al", "mov cs:dword_C0A0, eax", "jmp loc_6A19"], "succs": [[3, "jump"]]}, {"id": 57, "start": 27936, "end": 27953, "lines": ["call cs:__errno_location_ptr", "mov dword ptr [rax], 16h", "jmp loc_6BEC"], "succs": [[38, "jump"]]}, {"id": 58, "start": 27960, "end": 27965, "lines": ["test r12, r12", "jz short loc_6D41"], "succs": [[59, "fall"], [60, "jump"]]}, {"id": 59, "start": 27965, "end": 27969, "lines": ["mov [r12], ecx"], "succs": [[60, "fall"]]}, {"id": 60, "start": 27969, "end": 27981, "lines": ["cmp al, 1", "mov eax, 1", "sbb edx, edx", "add edx, 1"], "succs": [[61, "fall"]]}, {"id": 61, "start": 27981, "end": 27990, "lines": ["cmp r9, rax", "jnb loc_2487"], "succs": [[62, "fall"], [108, "jump"]]}, {"id": 62, "start": 27990, "end": 28009, "lines": ["sub edx, r8d", "mov dword ptr [r13+0], 0", "movsxd rdx, edx", "jmp loc_6C00"], "succs": [[39, "jump"]]}, {"id": 63, "start": 28009, "end": 28025, "lines": ["mov dword ptr [r13+0], 301h", "shl eax, 0Ch", "and eax, 0F000h"], "succs": [[64, "fall"]]}, {"id": 64, "start": 28025, "end": 28032, "lines": ["mov [r13+4], eax", "nop dword ptr [rax]"], "succs": [[65, "fall"]]}, {"id": 65, "start": 28032, "end": 28044, "lines": ["mov rdx, 0FFFFFFFFFFFFFFFEh", "jmp loc_6C00"], "succs": [[39, "jump"]]}, {"id": 66, "start": 28048, "end": 28070, "lines": ["mov [rbp+r9+var_2C], dil", "movzx eax, [rbp+var_2C]", "movsx ecx, al", "cmp rdx, 1", "jz loc_6EC0"], "succs": [[67, "fall"], [92, "jump"]]}, {"id": 67, "start": 28070, "end": 28093, "lines": ["movzx edi, byte ptr [rbx+1]", "lea rsi, [r9+2]", "mov [rbp+r9+var_2B], dil", "cmp rdx, 2", "jz loc_6EA0"], "succs": [[68, "fall"], [88, "jump"]]}, {"id": 68, "start": 28093, "end": 28103, "lines": ["cmp rsi, 4", "jz loc_6EA0"], "succs": [[69, "fall"], [88, "jump"]]}, {"id": 69, "start": 28103, "end": 28118, "lines": ["movzx edx, byte ptr [rbx+2]", "mov [rbp+var_29], dl", "test al, al", "jns loc_6D38"], "succs": [[58, "jump"], [70, "fall"]]}, {"id": 70, "start": 28118, "end": 28126, "lines": ["cmp al, 0C1h", "jbe loc_6BE0"], "succs": [[37, "jump"], [71, "fall"]]}, {"id": 71, "start": 28126, "end": 28134, "lines": ["cmp al, 0DFh", "ja loc_6F10"], "succs": [[72, "fall"], [98, "jump"]]}, {"id": 72, "start": 28134, "end": 28150, "lines": ["movzx ebx, [rbp+var_2B]", "lea edx, [rbx-80h]", "cmp dl, 3Fh ; '?'", "ja loc_6BE0"], "succs": [[37, "jump"], [73, "fall"]]}, {"id": 73, "start": 28150, "end": 28159, "lines": ["test r12, r12", "jz loc_6FC8"], "succs": [[74, "fall"], [106, "jump"]]}, {"id": 74, "start": 28159, "end": 28160, "lines": ["nop"], "succs": [[75, "fall"]]}, {"id": 75, "start": 28160, "end": 28177, "lines": ["shl eax, 6", "movsx edx, dl", "and eax, 7C0h", "or eax, edx", "mov [r12], eax"], "succs": [[76, "fall"]]}, {"id": 76, "start": 28177, "end": 28192, "lines": ["mov eax, 2", "mov edx, 2", "jmp loc_6D4D"], "succs": [[61, "jump"]]}, {"id": 77, "start": 28192, "end": 28202, "lines": ["cmp rdx, 1", "jz loc_6D69"], "succs": [[63, "jump"], [78, "fall"]]}, {"id": 78, "start": 28202, "end": 28219, "lines": ["movzx ecx, byte ptr [rbx+1]", "lea esi, [rcx-80h]", "cmp sil, 3Fh ; '?'", "ja loc_6BE0"], "succs": [[37, "jump"], [79, "fall"]]}, {"id": 79, "start": 28219, "end": 28223, "lines": ["cmp al, 0E0h", "jnz short loc_6E48"], "succs": [[80, "fall"], [81, "jump"]]}, {"id": 80, "start": 28223, "end": 28232, "lines": ["cmp cl, 9Fh", "jbe loc_6BE0"], "succs": [[37, "jump"], [81, "fall"]]}, {"id": 81, "start": 28232, "end": 28236, "lines": ["cmp al, 0EDh", "jnz short loc_6E55"], "succs": [[82, "fall"], [83, "jump"]]}, {"id": 82, "start": 28236, "end": 28245, "lines": ["cmp cl, 9Fh", "ja loc_6BE0"], "succs": [[37, "jump"], [83, "fall"]]}, {"id": 83, "start": 28245, "end": 28255, "lines": ["cmp rdx, 2", "jz loc_6F4D"], "succs": [[84, "fall"], [103, "jump"]]}, {"id": 84, "start": 28255, "end": 28271, "lines": ["movzx edx, byte ptr [rbx+2]", "add edx, 0FFFFFF80h", "cmp dl, 3Fh ; '?'", "ja loc_6BE0"], "succs": [[37, "jump"], [85, "fall"]]}, {"id": 85, "start": 28271, "end": 28276, "lines": ["test r12, r12", "jz short loc_6E8E"], "succs": [[86, "fall"], [87, "jump"]]}, {"id": 86, "start": 28276, "end": 28302, "lines": ["shl eax, 0Ch", "movsx edx, dl", "movsx esi, sil", "and eax, 0F000h", "shl esi, 6", "or eax, edx", "or eax, esi", "mov [r12], eax"], "succs": [[87, "fall"]]}, {"id": 87, "start": 28302, "end": 28317, "lines": ["mov eax, 3", "mov edx, 3", "jmp loc_6D4D"], "succs": [[61, "jump"]]}, {"id": 88, "start": 28320, "end": 28328, "lines": ["test al, al", "jns loc_6D38"], "succs": [[58, "jump"], [89, "fall"]]}, {"id": 89, "start": 28328, "end": 28336, "lines": ["cmp al, 0C1h", "jbe loc_6BE0"], "succs": [[37, "jump"], [90, "fall"]]}, {"id": 90, "start": 28336, "end": 28340, "lines": ["cmp al, 0DFh", "ja short loc_6ED8"], "succs": [[91, "fall"], [95, "jump"]]}, {"id": 91, "start": 28340, "end": 28349, "lines": ["lea rbx, [rbp+var_2C]", "jmp loc_6A5E"], "succs": [[9, "jump"]]}, {"id": 92, "start": 28352, "end": 28360, "lines": ["test al, al", "jns loc_6D38"], "succs": [[58, "jump"], [93, "fall"]]}, {"id": 93, "start": 28360, "end": 28368, "lines": ["cmp al, 0C1h", "jbe loc_6BE0"], "succs": [[37, "jump"], [94, "fall"]]}, {"id": 94, "start": 28368, "end": 28376, "lines": ["lea rsi, [r9+1]", "cmp al, 0DFh", "jbe short loc_6EB4"], "succs": [[91, "jump"], [95, "fall"]]}, {"id": 95, "start": 28376, "end": 28384, "lines": ["cmp al, 0EFh", "ja loc_6BD0"], "succs": [[35, "jump"], [96, "fall"]]}, {"id": 96, "start": 28384, "end": 28396, "lines": ["mov rdx, rsi", "lea rbx, [rbp+var_2C]", "jmp loc_6E2A"], "succs": [[78, "jump"]]}, {"id": 97, "start": 28400, "end": 28426, "lines": ["shl ecx, 6", "mov dword ptr [r13+0], 201h", "and ecx, 7C0h", "mov [r13+4], ecx", "jmp loc_6D80"], "succs": [[65, "jump"]]}, {"id": 98, "start": 28432, "end": 28436, "lines": ["cmp al, 0EFh", "ja short loc_6F22"], "succs": [[99, "fall"], [100, "jump"]]}, {"id": 99, "start": 28436, "end": 28450, "lines": ["lea rbx, [rbp+var_2C]", "mov edx, 4", "jmp loc_6E2A"], "succs": [[78, "jump"]]}, {"id": 100, "start": 28450, "end": 28467, "lines": ["lea rbx, [rbp+var_2C]", "mov edx, 4", "cmp al, 0F4h", "jbe loc_6B4A"], "succs": [[23, "jump"], [101, "fall"]]}, {"id": 101, "start": 28467, "end": 28472, "lines": ["jmp loc_6BE0"], "succs": [[37, "jump"]]}, {"id": 102, "start": 28472, "end": 28493, "lines": ["shl eax, 12h", "mov dword ptr [r13+0], 401h", "and eax, 1C0000h", "jmp loc_6D79"], "succs": [[64, "jump"]]}, {"id": 103, "start": 28493, "end": 28529, "lines": ["mov dword ptr [r13+0], 302h", "movzx edx, byte ptr [rbx+1]", "shl eax, 0Ch", "and eax, 0F000h", "shl edx, 6", "and edx, 0FC0h", "or eax, edx", "jmp loc_6D79"], "succs": [[64, "jump"]]}, {"id": 104, "start": 28529, "end": 28580, "lines": ["mov dword ptr [r13+0], 403h", "movzx edx, byte ptr [rbx+1]", "shl eax, 12h", "movzx ecx, byte ptr [rbx+2]", "and eax, 1C0000h", "shl edx, 0Ch", "shl ecx, 6", "and edx, 3F000h", "and ecx, 0FC0h", "or edx, ecx", "or eax, edx", "jmp loc_6D79"], "succs": [[64, "jump"]]}, {"id": 105, "start": 28580, "end": 28616, "lines": ["mov dword ptr [r13+0], 402h", "movzx edx, byte ptr [rbx+1]", "shl eax, 12h", "and eax, 1C0000h", "shl edx, 0Ch", "and edx, 3F000h", "or eax, edx", "jmp loc_6D79"], "succs": [[64, "jump"]]}, {"id": 106, "start": 28616, "end": 28626, "lines": ["mov edx, 2", "jmp loc_6D56"], "succs": [[62, "jump"]]}, {"id": 107, "start": 28626, "end": 28632, "lines": ["call cs:__stack_chk_fail_ptr"], "succs": []}, {"id": 108, "start": 9351, "end": 9357, "lines": ["call cs:abort_ptr"], "succs": []}]}, {"name": "main", "ea": 9360, "blocks": [{"id": 0, "start": 9360, "end": 9414, "lines": ["endbr64", "push rbp", "mov rbp, rsp", "push r15", "push r14", "mov r14, rsi", "push r13", "push r12", "push rbx", "mov ebx, edi", "lea rdi, name; \"POSIXLY_CORRECT\"", "sub rsp, 18h", "call cs:getenv_ptr", "mov r13, [r14]", "mov r12, rax", "test rax, rax", "jz loc_2926"], "succs": [[1, "fall"], [69, "jump"]]}, {"id": 1, "start": 9414, "end": 9423, "lines": ["cmp ebx, 1", "jle loc_262F"], "succs": [[2, "fall"], [23, "jump"]]}, {"id": 2, "start": 9423, "end": 9525, "lines": ["mov rdi, [r14+8]; s1", "lea rsi, s2; \"-n\"", "call cs:strcmp_ptr", "mov rdi, r13", "mov r15d, eax", "call sub_3500", "lea rsi, accept+3; locale", "mov edi, 6; category", "call cs:setlocale_ptr", "lea rsi, dirname; \"/usr/share/locale\"", "lea rdi, aGnuCoreutils+4; domainname", "call cs:bindtextdomain_ptr", "lea rdi, aGnuCoreutils+4; domainname", "call cs:textdomain_ptr", "lea rdi, sub_3430", "call sub_71C0", "cmp ebx, 2", "jnz loc_26B8"], "succs": [[3, "fall"], [28, "jump"]]}, {"id": 3, "start": 9525, "end": 9534, "lines": ["test r15d, r15d", "jnz loc_26B8"], "succs": [[4, "fall"], [28, "jump"]]}, {"id": 4, "start": 9534, "end": 9562, "lines": ["mov rbx, [r14+8]", "lea rsi, aHelp; \"--help\"", "mov rdi, rbx; s1", "call cs:strcmp_ptr", "test eax, eax", "jz loc_2ACF"], "succs": [[5, "fall"], [86, "jump"]]}, {"id": 5, "start": 9562, "end": 9586, "lines": ["lea rsi, aVersion; \"--version\"", "mov rdi, rbx; s1", "call cs:strcmp_ptr", "test eax, eax", "jz loc_2A2C"], "succs": [[6, "fall"], [81, "jump"]]}, {"id": 6, "start": 9586, "end": 9596, "lines": ["add r14, 8", "mov r9d, 1"], "succs": [[7, "fall"]]}, {"id": 7, "start": 9596, "end": 9621, "lines": ["test r12, r12", "mov ebx, 1", "mov r8, 20100000001h", "setnz r11b", "xor r10d, r10d"], "succs": [[8, "fall"]]}, {"id": 8, "start": 9621, "end": 9633, "lines": ["mov rdi, [r14]", "cmp byte ptr [rdi], 2Dh ; '-'", "jnz loc_28C2"], "succs": [[9, "fall"], [62, "jump"]]}, {"id": 9, "start": 9633, "end": 9645, "lines": ["movzx ecx, byte ptr [rdi+1]", "test cl, cl", "jz loc_28C2"], "succs": [[10, "fall"], [62, "jump"]]}, {"id": 10, "start": 9645, "end": 9664, "lines": ["lea rdx, [rdi+2]", "mov eax, ecx", "xchg ax, ax", "nop word ptr [rax+rax+00000000h]"], "succs": [[11, "fall"]]}, {"id": 11, "start": 9664, "end": 9675, "lines": ["sub eax, 45h ; 'E'", "cmp al, 29h ; ')'", "ja loc_28C2"], "succs": [[12, "fall"], [62, "jump"]]}, {"id": 12, "start": 9675, "end": 9692, "lines": ["bt r8, rax", "setb sil", "test sil, sil", "jz loc_28C2"], "succs": [[13, "fall"], [62, "jump"]]}, {"id": 13, "start": 9692, "end": 9703, "lines": ["movzx eax, byte ptr [rdx]", "add rdx, 1", "test al, al", "jnz short loc_25C0"], "succs": [[11, "jump"], [14, "fall"]]}, {"id": 14, "start": 9703, "end": 9709, "lines": ["lea rax, [rdi+1]", "jmp short loc_2600"], "succs": [[17, "jump"]]}, {"id": 15, "start": 9712, "end": 9721, "lines": ["cmp cl, 45h ; 'E'", "setnz dl", "and r10d, edx"], "succs": [[16, "fall"]]}, {"id": 16, "start": 9721, "end": 9728, "lines": ["movzx ecx, byte ptr [rax]", "test cl, cl", "jz short loc_261B"], "succs": [[17, "fall"], [20, "jump"]]}, {"id": 17, "start": 9728, "end": 9741, "lines": ["add rax, 1", "cmp cl, 65h ; 'e'", "jz loc_26B0"], "succs": [[18, "fall"], [27, "jump"]]}, {"id": 18, "start": 9741, "end": 9746, "lines": ["cmp cl, 6Eh ; 'n'", "jnz short loc_25F0"], "succs": [[15, "jump"], [19, "fall"]]}, {"id": 19, "start": 9746, "end": 9755, "lines": ["movzx ecx, byte ptr [rax]", "xor ebx, ebx", "test cl, cl", "jnz short loc_2600"], "succs": [[17, "jump"], [20, "fall"]]}, {"id": 20, "start": 9755, "end": 9769, "lines": ["add r14, 8", "sub r9d, 1", "jnz loc_2595"], "succs": [[8, "jump"], [21, "fall"]]}, {"id": 21, "start": 9769, "end": 9773, "lines": ["test bl, bl", "jz short loc_2698; jumptable 000000000000275A case 99"], "succs": [[22, "fall"], [26, "jump"]]}, {"id": 22, "start": 9773, "end": 9775, "lines": ["jmp short loc_2678"], "succs": [[24, "jump"]]}, {"id": 23, "start": 9775, "end": 9848, "lines": ["mov rdi, r13", "call sub_3500", "lea rsi, accept+3; locale", "mov edi, 6; category", "call cs:setlocale_ptr", "lea rsi, dirname; \"/usr/share/locale\"", "lea rdi, aGnuCoreutils+4; domainname", "call cs:bindtextdomain_ptr", "lea rdi, aGnuCoreutils+4; domainname", "call cs:textdomain_ptr", "lea rdi, sub_3430", "call sub_71C0"], "succs": [[24, "fall"]]}, {"id": 24, "start": 9848, "end": 9869, "lines": ["mov rdi, cs:stdout; _IO_FILE *", "mov rax, [rdi+28h]", "cmp rax, [rdi+30h]", "jnb loc_2AB3"], "succs": [[25, "fall"], [84, "jump"]]}, {"id": 25, "start": 9869, "end": 9880, "lines": ["lea rdx, [rax+1]", "mov [rdi+28h], rdx", "mov byte ptr [rax], 0Ah"], "succs": [[26, "fall"]]}, {"id": 26, "start": 9880, "end": 9897, "lines": ["lea rsp, [rbp-28h]; jumptable 000000000000275A case 99", "xor eax, eax", "pop rbx", "pop r12", "pop r13", "pop r14", "pop r15", "pop rbp", "retn"], "succs": []}, {"id": 27, "start": 9904, "end": 9912, "lines": ["mov r10d, esi", "jmp loc_25F9"], "succs": [[16, "jump"]]}, {"id": 28, "start": 9912, "end": 9929, "lines": ["lea r9d, [rbx-1]", "add r14, 8", "test r15d, r15d", "jz loc_257C"], "succs": [[7, "jump"], [29, "fall"]]}, {"id": 29, "start": 9929, "end": 9934, "lines": ["mov ebx, 1"], "succs": [[30, "fall"]]}, {"id": 30, "start": 9934, "end": 9949, "lines": ["lea eax, [r9-1]", "lea r15, jpt_275A", "lea r12, [r14+rax*8]"], "succs": [[31, "fall"]]}, {"id": 31, "start": 9949, "end": 9965, "lines": ["mov r13, [r14]", "movzx eax, byte ptr [r13+0]", "lea rdx, [r13+1]", "test al, al", "jnz short loc_272E"], "succs": [[32, "fall"], [36, "jump"]]}, {"id": 32, "start": 9965, "end": 9970, "lines": ["jmp loc_2890"], "succs": [[59, "jump"]]}, {"id": 33, "start": 9976, "end": 9982, "lines": ["movzx ecx, al", "mov r13, rdx"], "succs": [[34, "fall"]]}, {"id": 34, "start": 9982, "end": 10003, "lines": ["mov rdi, cs:stdout; _IO_FILE *", "mov rdx, [rdi+28h]", "cmp rdx, [rdi+30h]", "jnb loc_2870"], "succs": [[35, "fall"], [57, "jump"]]}, {"id": 35, "start": 10003, "end": 10030, "lines": ["lea rcx, [rdx+1]", "mov [rdi+28h], rcx", "mov [rdx], al", "movzx eax, byte ptr [r13+0]", "lea rdx, [r13+1]", "test al, al", "jz loc_2890"], "succs": [[36, "fall"], [59, "jump"]]}, {"id": 36, "start": 10030, "end": 10034, "lines": ["cmp al, 5Ch ; '\\'", "jnz short loc_26F8"], "succs": [[33, "jump"], [37, "fall"]]}, {"id": 37, "start": 10034, "end": 10048, "lines": ["movzx r8d, byte ptr [r13+1]", "test r8b, r8b", "jz loc_2860"], "succs": [[38, "fall"], [56, "jump"]]}, {"id": 38, "start": 10048, "end": 10064, "lines": ["lea edx, [r8-30h]; switch 73 cases", "lea r9, [r13+2]", "mov ecx, r8d", "cmp dl, 48h", "ja short def_275A; jumptable 000000000000275A default case, cases 56-91,93-96,100,103-109,111-113,115,117,119"], "succs": [[39, "fall"], [42, "jump"]]}, {"id": 39, "start": 10064, "end": 10077, "lines": ["movzx edx, dl", "movsxd rdx, ds:(jpt_275A - 809Ch)[r15+rdx*4]", "add rdx, r15", "jmp rdx; switch jump"], "succs": [[26, "switch"], [40, "switch"], [42, "switch"], [45, "switch"], [47, "switch"], [49, "switch"], [50, "switch"], [51, "switch"], [52, "switch"], [53, "switch"], [54, "switch"], [55, "switch"], [78, "switch"], [79, "switch"]]}, {"id": 40, "start": 10077, "end": 10090, "lines": ["movzx eax, byte ptr [r13+2]; jumptable 000000000000275A case 120", "lea edx, [rax-30h]", "cmp dl, 36h ; '6'", "ja short def_275A; jumptable 000000000000275A default case, cases 56-91,93-96,100,103-109,111-113,115,117,119"], "succs": [[41, "fall"], [42, "jump"]]}, {"id": 41, "start": 10090, "end": 10110, "lines": ["mov rsi, 7E0000007E03FFh", "bt rsi, rdx", "jb loc_29B2"], "succs": [[42, "fall"], [74, "jump"]]}, {"id": 42, "start": 10110, "end": 10135, "lines": ["mov rdi, cs:stdout; jumptable 000000000000275A default case, cases 56-91,93-96,100,103-109,111-113,115,117,119", "movzx ecx, r8b", "mov rax, [rdi+28h]", "cmp rax, [rdi+30h]", "jnb loc_2A8C"], "succs": [[43, "fall"], [83, "jump"]]}, {"id": 43, "start": 10135, "end": 10146, "lines": ["lea rdx, [rax+1]", "mov [rdi+28h], rdx", "mov byte ptr [rax], 5Ch ; '\\'"], "succs": [[44, "fall"]]}, {"id": 44, "start": 10146, "end": 10157, "lines": ["mov eax, r8d", "mov r13, r9", "jmp loc_26FE"], "succs": [[34, "jump"]]}, {"id": 45, "start": 10157, "end": 10173, "lines": ["movzx ecx, byte ptr [r13+2]; jumptable 000000000000275A case 48", "lea eax, [rcx-30h]", "cmp al, 7", "ja loc_29F1"], "succs": [[46, "fall"], [77, "jump"]]}, {"id": 46, "start": 10173, "end": 10177, "lines": ["lea r9, [r13+3]"], "succs": [[47, "fall"]]}, {"id": 47, "start": 10177, "end": 10196, "lines": ["movzx edx, byte ptr [r9]; jumptable 000000000000275A cases 49-55", "lea eax, [rcx-30h]", "lea ecx, [rdx-30h]", "cmp cl, 7", "jbe loc_298E"], "succs": [[48, "fall"], [72, "jump"]]}, {"id": 48, "start": 10196, "end": 10207, "lines": ["movzx ecx, al", "mov r13, r9", "jmp loc_26FE"], "succs": [[34, "jump"]]}, {"id": 49, "start": 10207, "end": 10225, "lines": ["mov r13, r9; jumptable 000000000000275A case 101", "mov ecx, 1Bh", "mov eax, 1Bh", "jmp loc_26FE"], "succs": [[34, "jump"]]}, {"id": 50, "start": 10225, "end": 10243, "lines": ["mov r13, r9; jumptable 000000000000275A case 98", "mov ecx, 8", "mov eax, 8", "jmp loc_26FE"], "succs": [[34, "jump"]]}, {"id": 51, "start": 10243, "end": 10261, "lines": ["mov r13, r9; jumptable 000000000000275A case 116", "mov ecx, 9", "mov eax, 9", "jmp loc_26FE"], "succs": [[34, "jump"]]}, {"id": 52, "start": 10261, "end": 10279, "lines": ["mov r13, r9; jumptable 000000000000275A case 114", "mov ecx, 0Dh", "mov eax, 0Dh", "jmp loc_26FE"], "succs": [[34, "jump"]]}, {"id": 53, "start": 10279, "end": 10297, "lines": ["mov r13, r9; jumptable 000000000000275A case 110", "mov ecx, 0Ah", "mov eax, 0Ah", "jmp loc_26FE"], "succs": [[34, "jump"]]}, {"id": 54, "start": 10297, "end": 10315, "lines": ["mov r13, r9; jumptable 000000000000275A case 102", "mov ecx, 0Ch", "mov eax, 0Ch", "jmp loc_26FE"], "succs": [[34, "jump"]]}, {"id": 55, "start": 10315, "end": 10333, "lines": ["mov r13, r9; jumptable 000000000000275A case 118", "mov ecx, 0Bh", "mov eax, 0Bh", "jmp loc_26FE"], "succs": [[34, "jump"]]}, {"id": 56, "start": 10336, "end": 10349, "lines": ["mov r13, rdx", "mov ecx, 5Ch ; '\\'", "jmp loc_26FE"], "succs": [[34, "jump"]]}, {"id": 57, "start": 10352, "end": 10377, "lines": ["mov esi, ecx; int", "call cs:__overflow_ptr", "movzx eax, byte ptr [r13+0]", "lea rdx, [r13+1]", "test al, al", "jnz loc_272E"], "succs": [[36, "jump"], [58, "fall"]]}, {"id": 58, "start": 10377, "end": 10384, "lines": ["nop dword ptr [rax+00000000h]"], "succs": [[59, "fall"]]}, {"id": 59, "start": 10384, "end": 10393, "lines": ["cmp r14, r12", "jz loc_2629"], "succs": [[21, "jump"], [60, "fall"]]}, {"id": 60, "start": 10393, "end": 10418, "lines": ["mov rdi, cs:stdout; _IO_FILE *", "add r14, 8", "mov rax, [rdi+28h]", "cmp rax, [rdi+30h]", "jnb loc_2A1C"], "succs": [[61, "fall"], [80, "jump"]]}, {"id": 61, "start": 10418, "end": 10434, "lines": ["lea rdx, [rax+1]", "mov [rdi+28h], rdx", "mov byte ptr [rax], 20h ; ' '", "jmp loc_26DD"], "succs": [[31, "jump"]]}, {"id": 62, "start": 10434, "end": 10443, "lines": ["test r11b, r11b", "jnz loc_26CE"], "succs": [[30, "jump"], [63, "fall"]]}, {"id": 63, "start": 10443, "end": 10452, "lines": ["test r10b, r10b", "jnz loc_26CE"], "succs": [[30, "jump"], [64, "fall"]]}, {"id": 64, "start": 10452, "end": 10462, "lines": ["lea eax, [r9-1]", "lea r12, [r14+rax*8]", "jmp short loc_28EB"], "succs": [[66, "jump"]]}, {"id": 65, "start": 10464, "end": 10475, "lines": ["lea rdx, [rax+1]", "mov [rdi+28h], rdx", "mov byte ptr [rax], 20h ; ' '"], "succs": [[66, "fall"]]}, {"id": 66, "start": 10475, "end": 10500, "lines": ["mov rdi, [r14]; s", "mov rsi, cs:stdout; stream", "call cs:fputs_unlocked_ptr", "cmp r12, r14", "jz loc_2629"], "succs": [[21, "jump"], [67, "fall"]]}, {"id": 67, "start": 10500, "end": 10521, "lines": ["mov rdi, cs:stdout; _IO_FILE *", "add r14, 8", "mov rax, [rdi+28h]", "cmp rax, [rdi+30h]", "jb short loc_28E0"], "succs": [[65, "jump"], [68, "fall"]]}, {"id": 68, "start": 10521, "end": 10534, "lines": ["mov esi, 20h ; ' '; int", "call cs:__overflow_ptr", "jmp short loc_28EB"], "succs": [[66, "jump"]]}, {"id": 69, "start": 10534, "end": 10616, "lines": ["mov rdi, r13", "call sub_3500", "lea rsi, accept+3; locale", "mov edi, 6; category", "call cs:setlocale_ptr", "lea rsi, dirname; \"/usr/share/locale\"", "lea rdi, aGnuCoreutils+4; domainname", "call cs:bindtextdomain_ptr", "lea rdi, aGnuCoreutils+4; domainname", "call cs:textdomain_ptr", "lea rdi, sub_3430", "call sub_71C0", "cmp ebx, 2", "jz loc_253E"], "succs": [[4, "jump"], [70, "fall"]]}, {"id": 70, "start": 10616, "end": 10629, "lines": ["lea r9d, [rbx-1]", "test r9d, r9d", "jle loc_2678"], "succs": [[24, "jump"], [71, "fall"]]}, {"id": 71, "start": 10629, "end": 10638, "lines": ["add r14, 8", "jmp loc_257C"], "succs": [[7, "jump"]]}, {"id": 72, "start": 10638, "end": 10659, "lines": ["movzx esi, byte ptr [r9+1]", "lea eax, [rdx+rax*8-30h]", "lea edx, [rsi-30h]", "cmp dl, 7", "ja loc_2AC3"], "succs": [[73, "fall"], [85, "jump"]]}, {"id": 73, "start": 10659, "end": 10674, "lines": ["lea eax, [rdx+rax*8]", "lea r13, [r9+2]", "movzx ecx, al", "jmp loc_26FE"], "succs": [[34, "jump"]]}, {"id": 74, "start": 10674, "end": 10702, "lines": ["movsx edi, al", "call sub_2C00", "movzx edx, byte ptr [r13+3]", "mov ecx, eax", "lea edi, [rdx-30h]", "cmp dil, 36h ; '6'", "ja loc_2A83"], "succs": [[75, "fall"], [82, "jump"]]}, {"id": 75, "start": 10702, "end": 10712, "lines": ["bt rsi, rdi", "jnb loc_2A83"], "succs": [[76, "fall"], [82, "jump"]]}, {"id": 76, "start": 10712, "end": 10737, "lines": ["movsx edi, dl", "shl ecx, 4", "add r13, 4", "call sub_2C00", "add eax, ecx", "movzx ecx, al", "jmp loc_26FE"], "succs": [[34, "jump"]]}, {"id": 77, "start": 10737, "end": 10749, "lines": ["mov r13, r9", "xor ecx, ecx", "xor eax, eax", "jmp loc_26FE"], "succs": [[34, "jump"]]}, {"id": 78, "start": 10749, "end": 10767, "lines": ["mov r13, r9; jumptable 000000000000275A case 97", "mov ecx, 7", "mov eax, 7", "jmp loc_26FE"], "succs": [[34, "jump"]]}, {"id": 79, "start": 10767, "end": 10780, "lines": ["mov r13, r9; jumptable 000000000000275A case 92", "mov ecx, 5Ch ; '\\'", "jmp loc_26FE"], "succs": [[34, "jump"]]}, {"id": 80, "start": 10780, "end": 10796, "lines": ["mov esi, 20h ; ' '; int", "call cs:__overflow_ptr", "jmp loc_26DD"], "succs": [[31, "jump"]]}, {"id": 81, "start": 10796, "end": 10883, "lines": ["lea rsi, aChetRamey; \"Chet Ramey\"", "mov rdi, rsi; msgid", "call sub_3590", "lea rsi, aBrianFox; \"Brian Fox\"", "mov rdi, rsi; msgid", "mov rbx, rax", "call sub_3590", "lea rdx, aGnuCoreutils; \"GNU coreutils\"", "mov r9, rbx", "lea rsi, aEcho; \"echo\"", "mov r8, rax", "push rax", "xor eax, eax", "mov rdi, cs:stdout", "push 0", "mov rcx, cs:off_C018; \"9.11\"", "call sub_6280", "pop rdx", "pop rcx", "jmp loc_2698; jumptable 000000000000275A case 99"], "succs": [[26, "jump"]]}, {"id": 82, "start": 10883, "end": 10892, "lines": ["add r13, 3", "jmp loc_26FE"], "succs": [[34, "jump"]]}, {"id": 83, "start": 10892, "end": 10931, "lines": ["mov [rbp+var_40], r9", "mov esi, 5Ch ; '\\'; int", "mov [rbp+var_38], ecx", "mov [rbp+var_31], r8b", "call cs:__overflow_ptr", "mov r9, [rbp+var_40]", "mov ecx, [rbp+var_38]", "movzx r8d, [rbp+var_31]", "jmp loc_27A2"], "succs": [[44, "jump"]]}, {"id": 84, "start": 10931, "end": 10947, "lines": ["mov esi, 0Ah; int", "call cs:__overflow_ptr", "jmp loc_2698; jumptable 000000000000275A case 99"], "succs": [[26, "jump"]]}, {"id": 85, "start": 10947, "end": 10959, "lines": ["lea r13, [r9+1]", "movzx ecx, al", "jmp loc_26FE"], "succs": [[34, "jump"]]}, {"id": 86, "start": 10959, "end": 10966, "lines": ["xor edi, edi", "call sub_2FF0"], "succs": []}]}, {"name": "sub_2C90", "ea": 11408, "blocks": [{"id": 0, "start": 11408, "end": 11443, "lines": ["push rbp", "mov rbp, rsp", "push r15", "push r14", "push r13", "push r12", "push rbx", "mov rbx, rdi", "sub rsp, 28h", "mov eax, cs:dword_C010", "cmp eax, 0FFFFFFFFh", "jz loc_2F5E"], "succs": [[1, "fall"], [30, "jump"]]}, {"id": 1, "start": 11443, "end": 11462, "lines": ["mov rcx, cs:stdout", "mov [rbp+stream], rcx", "test eax, eax", "jnz loc_2FAD"], "succs": [[2, "fall"], [34, "jump"]]}, {"id": 2, "start": 11462, "end": 11512, "lines": ["lea rsi, accept; \" \\t\\n\"", "mov rdi, rbx; s", "call cs:strspn_ptr", "mov esi, 2Dh ; '-'; c", "mov rdi, rbx; s", "mov [rbp+n], rax", "lea r12, [rbx+rax]", "call cs:strchr_ptr", "mov r13, rax", "test rax, rax", "jz loc_2F53"], "succs": [[3, "fall"], [29, "jump"]]}, {"id": 3, "start": 11512, "end": 11517, "lines": ["cmp r12, rax", "jz short loc_2D74"], "succs": [[4, "fall"], [8, "jump"]]}, {"id": 4, "start": 11517, "end": 11519, "lines": ["jnb short loc_2D74"], "succs": [[5, "fall"], [8, "jump"]]}, {"id": 5, "start": 11519, "end": 11584, "lines": ["call cs:__ctype_b_loc_ptr", "mov rdx, r12", "xor esi, esi", "mov rdi, [rax]", "nop dword ptr [rax+00000000h]", "nop word ptr [rax+rax+00000000h]", "nop word ptr [rax+rax+00000000h]", "nop word ptr [rax+rax+00000000h]", "nop word ptr [rax+rax+00000000h]"], "succs": [[6, "fall"]]}, {"id": 6, "start": 11584, "end": 11626, "lines": ["movsx rax, byte ptr [rdx]", "add rdx, 1", "movzx eax, byte ptr [rdi+rax*2+1]", "shr al, 5", "and eax, 1", "add rsi, rax", "cmp rdx, r13", "setb cl", "cmp rsi, 2", "setnz al", "and cl, al", "mov r14d, ecx", "jnz short loc_2D40"], "succs": [[7, "fall"], [6, "jump"]]}, {"id": 7, "start": 11626, "end": 11636, "lines": ["cmp rsi, 2", "jz loc_2FE7"], "succs": [[8, "fall"], [36, "jump"]]}, {"id": 8, "start": 11636, "end": 11642, "lines": ["mov r14d, 1"], "succs": [[9, "fall"]]}, {"id": 9, "start": 11642, "end": 11675, "lines": ["lea rsi, reject; \",=[ \\n\"", "mov rdi, r13; s", "call cs:strcspn_ptr", "lea r15, [r13+rax+0]", "mov [rbp+var_48], rax", "movzx edx, byte ptr [r15]", "test dl, dl", "jz short loc_2E10"], "succs": [[10, "fall"], [22, "jump"]]}, {"id": 10, "start": 11675, "end": 11680, "lines": ["cmp dl, 0Ah", "jz short loc_2E10"], "succs": [[11, "fall"], [22, "jump"]]}, {"id": 11, "start": 11680, "end": 11698, "lines": ["mov [rbp+var_49], dl", "call cs:__ctype_b_loc_ptr", "movzx edx, [rbp+var_49]", "mov rsi, [rax]", "jmp short loc_2DD3"], "succs": [[16, "jump"]]}, {"id": 12, "start": 11704, "end": 11713, "lines": ["test ah, 20h", "jnz loc_2F40"], "succs": [[13, "fall"], [27, "jump"]]}, {"id": 13, "start": 11713, "end": 11718, "lines": ["movzx edx, byte ptr [r15+1]"], "succs": [[14, "fall"]]}, {"id": 14, "start": 11718, "end": 11726, "lines": ["add r15, 1", "test dl, dl", "jz short loc_2E10"], "succs": [[15, "fall"], [22, "jump"]]}, {"id": 15, "start": 11726, "end": 11731, "lines": ["cmp dl, 0Ah", "jz short loc_2E10"], "succs": [[16, "fall"], [22, "jump"]]}, {"id": 16, "start": 11731, "end": 11744, "lines": ["movsx rax, dl", "movzx eax, word ptr [rsi+rax*2]", "cmp dl, 2Dh ; '-'", "jnz short loc_2DB8"], "succs": [[12, "jump"], [17, "fall"]]}, {"id": 17, "start": 11744, "end": 11764, "lines": ["movzx edx, byte ptr [r15+1]", "cmp dl, 2Dh ; '-'", "setnz dil", "and r14d, edi", "test ah, 20h", "jz short loc_2DC6"], "succs": [[14, "jump"], [18, "fall"]]}, {"id": 18, "start": 11764, "end": 11775, "lines": ["movsx rax, dl", "test byte ptr [rsi+rax*2+1], 20h", "jnz short loc_2E10"], "succs": [[19, "fall"], [22, "jump"]]}, {"id": 19, "start": 11775, "end": 11780, "lines": ["test r14b, r14b", "jnz short loc_2DC6"], "succs": [[14, "jump"], [20, "fall"]]}, {"id": 20, "start": 11780, "end": 11785, "lines": ["cmp dl, 2Dh ; '-'", "jz short loc_2DC6"], "succs": [[14, "jump"], [21, "fall"]]}, {"id": 21, "start": 11785, "end": 11792, "lines": ["nop dword ptr [rax+00000000h]"], "succs": [[22, "fall"]]}, {"id": 22, "start": 11792, "end": 11839, "lines": ["mov rdx, [rbp+n]; n", "mov esi, 1; size", "mov rdi, rbx; ptr", "mov rcx, [rbp+stream]; stream", "call cs:fwrite_unlocked_ptr", "mov edx, 6; n", "lea rsi, aHelp; \"--help\"", "mov rdi, r13; s1", "call cs:strncmp_ptr", "test eax, eax", "jz short loc_2E5C"], "succs": [[23, "fall"], [24, "jump"]]}, {"id": 23, "start": 11839, "end": 11868, "lines": ["mov edx, 9; n", "lea rsi, aVersion; \"--version\"", "mov rdi, r13; s1", "call cs:strncmp_ptr", "test eax, eax", "jnz loc_2FB9"], "succs": [[24, "fall"], [35, "jump"]]}, {"id": 24, "start": 11868, "end": 11914, "lines": ["lea r8, aEcho; \"echo\"", "lea rdx, aHttpsWwwGnuOrg; \"https://www.gnu.org/software/coreutils/\"", "mov edi, 2", "push rax", "mov rcx, r8", "push r13", "lea rsi, a8SSSS; \"\\x1B]8;;%s%s#%s%.*s\"", "mov r9d, dword ptr [rbp+var_48]", "xor eax, eax", "call cs:__printf_chk_ptr", "pop rdx", "pop rcx"], "succs": [[25, "fall"]]}, {"id": 25, "start": 11914, "end": 12071, "lines": ["mov rcx, cs:stdout; stream", "mov edx, 2; n", "mov esi, 1; size", "lea rdi, a8+5; ptr", "call cs:fwrite_unlocked_ptr", "mov edx, 4; n", "mov rcx, cs:stdout; stream", "mov esi, 1; size", "lea rdi, a1m; \"\\x1B[1m\"", "call cs:fwrite_unlocked_ptr", "mov rdx, r15", "mov esi, 1; size", "mov rdi, r12; ptr", "mov rcx, cs:stdout; stream", "sub rdx, r12; n", "call cs:fwrite_unlocked_ptr", "mov edx, 4; n", "mov rcx, cs:stdout; stream", "mov esi, 1; size", "lea rdi, a0m; \"\\x1B[0m\"", "call cs:fwrite_unlocked_ptr", "mov esi, 1; size", "mov edx, 7; n", "lea rdi, a8; \"\\x1B]8;;\\x1B\\\\\"", "mov rcx, cs:stdout; stream", "call cs:fwrite_unlocked_ptr", "mov rsi, cs:stdout; stream", "mov rdi, r15; s"], "succs": [[26, "fall"]]}, {"id": 26, "start": 12071, "end": 12091, "lines": ["lea rsp, [rbp-28h]", "pop rbx", "pop r12", "pop r13", "pop r14", "pop r15", "pop rbp", "jmp cs:fputs_unlocked_ptr"], "succs": [[37, "jump"]]}, {"id": 27, "start": 12096, "end": 12105, "lines": ["cmp dl, 9", "jz loc_2E10"], "succs": [[22, "jump"], [28, "fall"]]}, {"id": 28, "start": 12105, "end": 12115, "lines": ["movzx edx, byte ptr [r15+1]", "jmp loc_2DF4"], "succs": [[18, "jump"]]}, {"id": 29, "start": 12115, "end": 12126, "lines": ["mov r13, r12", "xor r14d, r14d", "jmp loc_2D7A"], "succs": [[9, "jump"]]}, {"id": 30, "start": 12126, "end": 12144, "lines": ["lea rdi, aTerm; \"TERM\"", "call cs:getenv_ptr", "test rax, rax", "jz short loc_2F98"], "succs": [[31, "fall"], [33, "jump"]]}, {"id": 31, "start": 12144, "end": 12149, "lines": ["cmp byte ptr [rax], 0", "jz short loc_2F98"], "succs": [[32, "fall"], [33, "jump"]]}, {"id": 32, "start": 12149, "end": 12184, "lines": ["lea rsi, aDumb; \"dumb\"", "mov rdi, rax; s1", "call cs:strcmp_ptr", "test eax, eax", "setz al", "movzx eax, al", "mov cs:dword_C010, eax", "jmp loc_2CB3"], "succs": [[1, "jump"]]}, {"id": 33, "start": 12184, "end": 12205, "lines": ["mov rax, cs:stdout", "mov cs:dword_C010, 1", "mov [rbp+stream], rax"], "succs": [[34, "fall"]]}, {"id": 34, "start": 12205, "end": 12217, "lines": ["mov rsi, [rbp+stream]", "mov rdi, rbx", "jmp loc_2F27"], "succs": [[26, "jump"]]}, {"id": 35, "start": 12217, "end": 12263, "lines": ["mov r8d, dword ptr [rbp+var_48]", "mov r9, r13", "lea rcx, aEcho; \"echo\"", "xor eax, eax", "lea rdx, aHttpsWwwGnuOrg_0; \"https://www.gnu.org/software/coreutils/\"...", "lea rsi, a8SSS; \"\\x1B]8;;%s#%s%.*s\"", "mov edi, 2", "call cs:__printf_chk_ptr", "jmp loc_2E8A"], "succs": [[25, "jump"]]}, {"id": 36, "start": 12263, "end": 12271, "lines": ["mov r13, r12", "jmp loc_2D7A"], "succs": [[9, "jump"]]}, {"id": 37, "start": 50000, "end": 50000, "lines": [], "succs": []}]}, {"name": "sub_5CA0", "ea": 23712, "blocks": [{"id": 0, "start": 23712, "end": 23751, "lines": ["endbr64", "push rbp", "mov rbp, rsp", "push r15", "push r14", "push r13", "mov r13, r9", "push r12", "mov r12, r8", "push rbx", "mov rbx, rdi", "sub rsp, 38h", "test rsi, rsi", "jz loc_5E30"], "succs": [[1, "fall"], [7, "jump"]]}, {"id": 1, "start": 23751, "end": 23780, "lines": ["mov r9, rcx", "mov r8, rdx", "mov rcx, rsi", "xor eax, eax", "lea rdx, aSSS; \"%s (%s) %s\\n\"", "mov esi, 2", "call cs:__fprintf_chk_ptr"], "succs": [[2, "fall"]]}, {"id": 2, "start": 23780, "end": 23926, "lines": ["mov edx, 5; category", "lea rsi, aC; \"(C)\"", "lea rdi, domainname; \"gnulib\"", "call cs:dcgettext_ptr", "mov r8d, 7EAh", "mov esi, 2", "mov rdi, rbx", "mov rcx, rax", "lea rdx, aCopyrightSDFre; \"Copyright %s %d Free Software Foundatio\"...", "xor eax, eax", "call cs:__fprintf_chk_ptr", "mov rsi, rbx; stream", "mov edi, 0Ah; c", "call cs:fputc_unlocked_ptr", "mov edx, 5; category", "lea rsi, aLicenseGplv3Gn; \"License GPLv3+: GNU GPL version 3 or la\"...", "lea rdi, domainname; \"gnulib\"", "call cs:dcgettext_ptr", "mov esi, 2", "mov rdi, rbx", "lea rcx, aHttpsGnuOrgLic; \"https://gnu.org/licenses/gpl.html\"", "mov rdx, rax", "xor eax, eax", "call cs:__fprintf_chk_ptr", "mov rsi, rbx; stream", "mov edi, 0Ah; c", "call cs:fputc_unlocked_ptr", "cmp r13, 9; switch 10 cases", "ja def_5D84; jumptable 0000000000005D84 default case"], "succs": [[3, "fall"], [19, "jump"]]}, {"id": 3, "start": 23926, "end": 23943, "lines": ["lea rdx, jpt_5D84", "movsxd rax, ds:(jpt_5D84 - 8F80h)[rdx+r13*4]", "add rax, rdx", "jmp rax; switch jump"], "succs": [[4, "switch"], [6, "switch"], [8, "switch"], [10, "switch"], [11, "switch"], [12, "switch"], [13, "switch"], [15, "switch"], [16, "switch"], [18, "switch"]]}, {"id": 4, "start": 23952, "end": 24065, "lines": ["mov r11, [r12+30h]; jumptable 0000000000005D84 case 8", "mov r10, [r12+28h]", "mov edx, 5; category", "lea rsi, aWrittenBySSSSS; \"Written by %s, %s, %s,\\n%s, %s, %s, %s,\"...", "mov r8, [r12+20h]", "mov r9, [r12+10h]", "lea rdi, domainname; \"gnulib\"", "mov r14, [r12+38h]", "mov r13, [r12+18h]", "mov [rbp+var_50], r11", "mov r15, [r12+8]", "mov r12, [r12]", "mov [rbp+var_48], r10", "mov [rbp+var_40], r8", "mov [rbp+var_38], r9", "call cs:dcgettext_ptr", "sub rsp, 8", "push r14", "mov r11, [rbp+var_50]", "push r11", "mov r10, [rbp+var_48]", "push r10", "mov r8, [rbp+var_40]", "push r8", "mov r8, r15", "push r13", "mov r9, [rbp+var_38]"], "succs": [[5, "fall"]]}, {"id": 5, "start": 24065, "end": 24091, "lines": ["mov rdx, rax", "mov rcx, r12", "mov esi, 2", "mov rdi, rbx", "xor eax, eax", "call cs:__fprintf_chk_ptr", "add rsp, 30h"], "succs": [[6, "fall"]]}, {"id": 6, "start": 24091, "end": 24106, "lines": ["lea rsp, [rbp-28h]; jumptable 0000000000005D84 case 0", "pop rbx", "pop r12", "pop r13", "pop r14", "pop r15", "pop rbp", "retn"], "succs": []}, {"id": 7, "start": 24112, "end": 24143, "lines": ["mov r8, rcx", "mov esi, 2", "mov rcx, rdx", "xor eax, eax", "lea rdx, aSS_0; \"%s %s\\n\"", "call cs:__fprintf_chk_ptr", "jmp loc_5CE4"], "succs": [[2, "jump"]]}, {"id": 8, "start": 24144, "end": 24220, "lines": ["mov rax, [r12+30h]; jumptable 0000000000005D84 case 9", "mov r11, [r12+28h]", "mov edx, 5; category", "lea rsi, aWrittenBySSSSS_0; \"Written by %s, %s, %s,\\n%s, %s, %s, %s,\"...", "mov r10, [r12+20h]", "mov r9, [r12+10h]", "mov r8, [r12+8]", "mov r15, [r12+40h]", "mov [rbp+var_38], rax", "mov r14, [r12+38h]", "mov r13, [r12+18h]", "mov [rbp+var_58], r11", "mov [rbp+var_50], r10", "mov r12, [r12]", "mov [rbp+var_48], r9", "mov [rbp+var_40], r8"], "succs": [[9, "fall"]]}, {"id": 9, "start": 24220, "end": 24267, "lines": ["lea rdi, domainname; \"gnulib\"", "call cs:dcgettext_ptr", "push r15", "push r14", "push [rbp+var_38]", "mov r11, [rbp+var_58]", "push r11", "mov r10, [rbp+var_50]", "push r10", "push r13", "mov r9, [rbp+var_48]", "mov r8, [rbp+var_40]", "jmp loc_5E01"], "succs": [[5, "jump"]]}, {"id": 10, "start": 24272, "end": 24337, "lines": ["mov r12, [r12]; jumptable 0000000000005D84 case 1", "mov edx, 5; category", "lea rsi, aWrittenByS; \"Written by %s.\\n\"", "lea rdi, domainname; \"gnulib\"", "call cs:dcgettext_ptr", "lea rsp, [rbp-28h]", "mov rdi, rbx", "mov esi, 2", "pop rbx", "mov rcx, r12", "mov rdx, rax", "pop r12", "xor eax, eax", "pop r13", "pop r14", "pop r15", "pop rbp", "jmp cs:__fprintf_chk_ptr"], "succs": [[20, "jump"]]}, {"id": 11, "start": 24344, "end": 24417, "lines": ["mov r13, [r12+8]; jumptable 0000000000005D84 case 2", "mov r12, [r12]", "mov edx, 5; category", "lea rsi, aWrittenBySAndS; \"Written by %s and %s.\\n\"", "lea rdi, domainname; \"gnulib\"", "call cs:dcgettext_ptr", "lea rsp, [rbp-28h]", "mov r8, r13", "mov rcx, r12", "mov rdx, rax", "mov rdi, rbx", "mov esi, 2", "pop rbx", "xor eax, eax", "pop r12", "pop r13", "pop r14", "pop r15", "pop rbp", "jmp cs:__fprintf_chk_ptr"], "succs": [[20, "jump"]]}, {"id": 12, "start": 24424, "end": 24510, "lines": ["mov r9, [r12+10h]; jumptable 0000000000005D84 case 3", "mov r13, [r12+8]", "mov edx, 5; category", "lea rsi, aWrittenBySSAnd; \"Written by %s, %s, and %s.\\n\"", "mov r12, [r12]", "lea rdi, domainname; \"gnulib\"", "mov [rbp+var_38], r9", "call cs:dcgettext_ptr", "mov r9, [rbp+var_38]", "lea rsp, [rbp-28h]", "mov r8, r13", "mov rcx, r12", "mov rdx, rax", "mov rdi, rbx", "mov esi, 2", "pop rbx", "xor eax, eax", "pop r12", "pop r13", "pop r14", "pop r15", "pop rbp", "jmp cs:__fprintf_chk_ptr"], "succs": [[20, "jump"]]}, {"id": 13, "start": 24512, "end": 24578, "lines": ["mov r9, [r12+10h]; jumptable 0000000000005D84 case 4", "mov r8, [r12+8]", "mov edx, 5; category", "lea rsi, aWrittenBySSSAn; \"Written by %s, %s, %s,\\nand %s.\\n\"", "mov r13, [r12+18h]", "lea rdi, domainname; \"gnulib\"", "mov r12, [r12]", "mov [rbp+var_40], r9", "mov [rbp+var_38], r8", "call cs:dcgettext_ptr", "sub rsp, 8", "push r13", "mov r9, [rbp+var_40]", "mov r8, [rbp+var_38]"], "succs": [[14, "fall"]]}, {"id": 14, "start": 24578, "end": 24617, "lines": ["mov rdx, rax", "mov rcx, r12", "mov rdi, rbx", "mov esi, 2", "xor eax, eax", "call cs:__fprintf_chk_ptr", "pop rax", "pop rdx", "lea rsp, [rbp-28h]", "pop rbx", "pop r12", "pop r13", "pop r14", "pop r15", "pop rbp", "retn"], "succs": []}, {"id": 15, "start": 24624, "end": 24698, "lines": ["mov r8, [r12+20h]; jumptable 0000000000005D84 case 5", "mov r9, [r12+10h]", "mov edx, 5; category", "lea rsi, aWrittenBySSSSA; \"Written by %s, %s, %s,\\n%s, and %s.\\n\"", "mov r13, [r12+18h]", "mov r14, [r12+8]", "lea rdi, domainname; \"gnulib\"", "mov [rbp+var_40], r8", "mov r12, [r12]", "mov [rbp+var_38], r9", "call cs:dcgettext_ptr", "mov r8, [rbp+var_40]", "push r8", "mov r8, r14", "push r13", "mov r9, [rbp+var_38]", "jmp short loc_6002"], "succs": [[14, "jump"]]}, {"id": 16, "start": 24704, "end": 24774, "lines": ["mov r10, [r12+28h]; jumptable 0000000000005D84 case 6", "mov r8, [r12+20h]", "mov edx, 5; category", "lea rsi, aWrittenBySSSSS_1; \"Written by %s, %s, %s,\\n%s, %s, and %s.\"...", "mov r9, [r12+10h]", "mov r13, [r12+18h]", "lea rdi, domainname; \"gnulib\"", "mov r14, [r12+8]", "mov r12, [r12]", "mov [rbp+var_48], r10", "mov [rbp+var_40], r8", "mov [rbp+var_38], r9", "call cs:dcgettext_ptr", "sub rsp, 8"], "succs": [[17, "fall"]]}, {"id": 17, "start": 24774, "end": 24836, "lines": ["mov r10, [rbp+var_48]", "mov rcx, r12", "mov rdx, rax", "mov rdi, rbx", "mov esi, 2", "xor eax, eax", "push r10", "mov r8, [rbp+var_40]", "push r8", "mov r8, r14", "push r13", "mov r9, [rbp+var_38]", "call cs:__fprintf_chk_ptr", "add rsp, 20h", "lea rsp, [rbp-28h]", "pop rbx", "pop r12", "pop r13", "pop r14", "pop r15", "pop rbp", "retn"], "succs": []}, {"id": 18, "start": 24840, "end": 24926, "lines": ["mov r11, [r12+30h]; jumptable 0000000000005D84 case 7", "mov r10, [r12+28h]", "mov edx, 5; category", "lea rsi, aWrittenBySSSSS_2; \"Written by %s, %s, %s,\\n%s, %s, %s, and\"...", "mov r8, [r12+20h]", "mov r9, [r12+10h]", "lea rdi, domainname; \"gnulib\"", "mov r13, [r12+18h]", "mov r14, [r12+8]", "mov [rbp+var_50], r11", "mov [rbp+var_48], r10", "mov r12, [r12]", "mov [rbp+var_40], r8", "mov [rbp+var_38], r9", "call cs:dcgettext_ptr", "mov r11, [rbp+var_50]", "push r11", "jmp loc_60C6"], "succs": [[17, "jump"]]}, {"id": 19, "start": 24926, "end": 25007, "lines": ["mov rax, [r12+30h]; jumptable 0000000000005D84 default case", "mov r11, [r12+28h]", "mov edx, 5", "lea rsi, aWrittenBySSSSS_3; \"Written by %s, %s, %s,\\n%s, %s, %s, %s,\"...", "mov r10, [r12+20h]", "mov r9, [r12+10h]", "mov r8, [r12+8]", "mov r15, [r12+40h]", "mov [rbp+var_38], rax", "mov r14, [r12+38h]", "mov r13, [r12+18h]", "mov [rbp+var_58], r11", "mov [rbp+var_50], r10", "mov r12, [r12]", "mov [rbp+var_48], r9", "mov [rbp+var_40], r8", "jmp loc_5E9C"], "succs": [[9, "jump"]]}, {"id": 20, "start": 50120, "end": 50120, "lines": [], "succs": []}]}, {"name": "sub_6670", "ea": 26224, "blocks": [{"id": 0, "start": 26224, "end": 26262, "lines": ["endbr64", "push rbp", "mov r10, rcx", "mov r9, rdx", "mov rbp, rsp", "push r12", "mov r12, rsi", "push rbx", "mov rcx, [rsi]", "mov rbx, rcx", "sar rbx, 1", "add rbx, rcx", "jo loc_6747"], "succs": [[1, "fall"], [17, "jump"]]}, {"id": 1, "start": 26262, "end": 26292, "lines": ["cmp rbx, r10", "mov rax, r10", "cmovle rax, rbx", "test r10, r10", "cmovns rbx, rax", "mov rax, rbx", "imul rax, r8", "jo loc_673B"], "succs": [[2, "fall"], [16, "jump"]]}, {"id": 2, "start": 26292, "end": 26298, "lines": ["cmp rax, 7Fh", "jle short loc_6710"], "succs": [[3, "fall"], [12, "jump"]]}, {"id": 3, "start": 26298, "end": 26303, "lines": ["test rdi, rdi", "jz short loc_672B"], "succs": [[4, "fall"], [14, "jump"]]}, {"id": 4, "start": 26303, "end": 26304, "lines": ["nop"], "succs": [[5, "fall"]]}, {"id": 5, "start": 26304, "end": 26315, "lines": ["mov rdx, rbx", "sub rdx, rcx", "cmp rdx, r9", "jge short loc_66E6"], "succs": [[6, "fall"], [10, "jump"]]}, {"id": 6, "start": 26315, "end": 26320, "lines": ["add rcx, r9", "jo short loc_6735"], "succs": [[7, "fall"], [15, "jump"]]}, {"id": 7, "start": 26320, "end": 26328, "lines": ["mov rbx, rcx", "test r10, r10", "js short loc_66DD"], "succs": [[8, "fall"], [9, "jump"]]}, {"id": 8, "start": 26328, "end": 26333, "lines": ["cmp rcx, r10", "jg short loc_6735"], "succs": [[9, "fall"], [15, "jump"]]}, {"id": 9, "start": 26333, "end": 26342, "lines": ["mov rax, rcx", "imul rax, r8", "jo short loc_6735"], "succs": [[10, "fall"], [15, "jump"]]}, {"id": 10, "start": 26342, "end": 26365, "lines": ["test rax, rax", "mov esi, 1", "cmovnz rsi, rax; size", "call cs:realloc_ptr", "test rax, rax", "jz short loc_6735"], "succs": [[11, "fall"], [15, "jump"]]}, {"id": 11, "start": 26365, "end": 26374, "lines": ["mov [r12], rbx", "pop rbx", "pop r12", "pop rbp", "retn"], "succs": []}, {"id": 12, "start": 26384, "end": 26389, "lines": ["mov esi, 80h"], "succs": [[13, "fall"]]}, {"id": 13, "start": 26389, "end": 26411, "lines": ["mov rax, rsi", "cqo", "idiv r8", "mov rbx, rax", "mov rax, rsi", "sub rax, rdx", "test rdi, rdi", "jnz short loc_66C0"], "succs": [[5, "jump"], [14, "fall"]]}, {"id": 14, "start": 26411, "end": 26421, "lines": ["mov qword ptr [r12], 0", "jmp short loc_66C0"], "succs": [[5, "jump"]]}, {"id": 15, "start": 26421, "end": 26427, "lines": ["call sub_6900"], "succs": []}, {"id": 16, "start": 26427, "end": 26439, "lines": ["mov rsi, 7FFFFFFFFFFFFFFFh", "jmp short loc_6715"], "succs": [[13, "jump"]]}, {"id": 17, "start": 26439, "end": 26454, "lines": ["mov rbx, 7FFFFFFFFFFFFFFFh", "jmp loc_6696"], "succs": [[1, "jump"]]}]}, {"name": "sub_2FF0", "ea": 12272, "blocks": [{"id": 0, "start": 12272, "end": 12371, "lines": ["endbr64", "push rbp", "mov rbp, rsp", "push r12", "push rbx", "sub rsp, 0C0h", "movq xmm4, cs:off_BBE0; \"sha224sum\"", "movq xmm3, cs:off_BBE8; \"sha256sum\"", "mov rax, fs:28h", "mov [rbp+var_18], rax", "lea rax, aSha2Utilities; \"sha2 utilities\"", "movq xmm2, cs:off_BBF0; \"sha384sum\"", "movq xmm1, cs:off_BBF8; \"sha512sum\"", "movq xmm0, rax", "punpcklqdq xmm4, xmm0", "punpcklqdq xmm3, xmm0", "punpcklqdq xmm2, xmm0", "punpcklqdq xmm1, xmm0", "test edi, edi", "jnz loc_2040"], "succs": [[1, "fall"], [16, "jump"]]}, {"id": 1, "start": 12371, "end": 12960, "lines": ["mov rbx, cs:qword_C120", "movaps [rbp+var_D0], xmm1", "xor edi, edi; domainname", "mov edx, 5; category", "movaps [rbp+var_C0], xmm2", "lea rsi, aUsageSShortOpt; \"Usage: %s [SHORT-OPTION]... [STRING]...\"...", "movaps [rbp+var_B0], xmm3", "movaps [rbp+var_A0], xmm4", "call cs:dcgettext_ptr", "mov rcx, rbx", "mov rdx, rbx", "mov edi, 2", "mov rsi, rax", "xor eax, eax", "call cs:__printf_chk_ptr", "mov rbx, cs:stdout", "xor edi, edi; domainname", "mov edx, 5; category", "lea rsi, aEchoTheStringS; \"Echo the STRING(s) to standard output.\"...", "call cs:dcgettext_ptr", "mov rsi, rbx; stream", "mov rdi, rax; s", "call cs:fputs_unlocked_ptr", "mov edx, 5; category", "lea rsi, aNDoNotOutputTh; \" -n do not output the trailing new\"...", "xor edi, edi; domainname", "call cs:dcgettext_ptr", "mov rdi, rax; ptr", "call sub_2C90", "mov edx, 5; category", "lea rsi, aEEnableInterpr; \" -e enable interpretation of backs\"...", "xor edi, edi; domainname", "call cs:dcgettext_ptr", "mov rdi, rax; ptr", "call sub_2C90", "mov edx, 5; category", "lea rsi, aEDisableInterp; \" -E disable interpretation of back\"...", "xor edi, edi; domainname", "call cs:dcgettext_ptr", "mov rdi, rax; ptr", "call sub_2C90", "mov edx, 5; category", "lea rsi, aHelpDisplayThi; \" --help\\n display this hel\"...", "xor edi, edi; domainname", "call cs:dcgettext_ptr", "mov rdi, rax; ptr", "call sub_2C90", "mov edx, 5; category", "lea rsi, aVersionOutputV; \" --version\\n output versio\"...", "xor edi, edi; domainname", "call cs:dcgettext_ptr", "mov rdi, rax; ptr", "call sub_2C90", "mov rbx, cs:stdout", "xor edi, edi; domainname", "mov edx, 5; category", "lea rsi, aIfEIsInEffectT; \"\\nIf -e is in effect, the following seq\"...", "call cs:dcgettext_ptr", "mov rsi, rbx; stream", "mov rdi, rax; s", "call cs:fputs_unlocked_ptr", "mov rbx, cs:stdout", "xor edi, edi; domainname", "mov edx, 5; category", "lea rsi, aBackslashAAler; \" \\\\\\\\ backslash\\n \\\\a alert\"...", "call cs:dcgettext_ptr", "mov rsi, rbx; stream", "mov rdi, rax; s", "call cs:fputs_unlocked_ptr", "mov rbx, cs:stdout", "xor edi, edi; domainname", "mov edx, 5; category", "lea rsi, a0nnnByteWithOc; \" \\\\0NNN byte with octal value NNN (1\"...", "call cs:dcgettext_ptr", "mov rsi, rbx; stream", "mov rdi, rax; s", "call cs:fputs_unlocked_ptr", "mov edx, 5; category", "lea rsi, aYourShellMayHa; \"\\nYour shell may have its own version o\"...", "xor edi, edi; domainname", "call cs:dcgettext_ptr", "lea rdx, aEcho; \"echo\"", "mov edi, 2", "mov rsi, rax", "xor eax, eax", "call cs:__printf_chk_ptr", "mov rbx, cs:stdout", "mov edx, 5; category", "xor edi, edi; domainname", "lea rsi, aConsiderUsingT; \"\\nConsider using the printf(1) command \"...", "call cs:dcgettext_ptr", "mov rsi, rbx; stream", "lea rbx, [rbp+var_90]", "mov rdi, rax; s", "call cs:fputs_unlocked_ptr", "lea rax, aTestInvocation; \"test invocation\"", "movq xmm0, cs:off_BC00; \"[\"", "movdqa xmm4, [rbp+var_A0]", "movq xmm5, rax", "lea rax, aMultiCallInvoc; \"Multi-call invocation\"", "movdqa xmm3, [rbp+var_B0]", "movdqa xmm2, [rbp+var_C0]", "punpcklqdq xmm0, xmm5", "movq xmm6, rax", "movdqa xmm1, [rbp+var_D0]", "movaps [rbp+var_70], xmm4", "movaps [rbp+var_90], xmm0", "movq xmm0, cs:off_BC08; \"coreutils\"", "movaps [rbp+var_60], xmm3", "punpcklqdq xmm0, xmm6", "movaps [rbp+var_50], xmm2", "movaps [rbp+var_80], xmm0", "pxor xmm0, xmm0", "movaps [rbp+var_40], xmm1", "movaps [rbp+var_30], xmm0", "nop", "nop word ptr [rax+rax+00000000h]"], "succs": [[2, "fall"]]}, {"id": 2, "start": 12960, "end": 12973, "lines": ["mov rsi, [rbx+10h]; s2", "add rbx, 10h", "test rsi, rsi", "jz short loc_32BE"], "succs": [[3, "fall"], [4, "jump"]]}, {"id": 3, "start": 12973, "end": 12990, "lines": ["lea rdi, aEcho; \"echo\"", "call cs:strcmp_ptr", "test eax, eax", "jnz short loc_32A0"], "succs": [[2, "jump"], [4, "fall"]]}, {"id": 4, "start": 12990, "end": 13003, "lines": ["mov rbx, [rbx+8]", "test rbx, rbx", "jz loc_33A7"], "succs": [[5, "fall"], [13, "jump"]]}, {"id": 5, "start": 13003, "end": 13027, "lines": ["call sub_6360", "xor esi, esi; locale", "mov edi, 5; category", "call cs:setlocale_ptr", "test rax, rax", "jz short loc_3313"], "succs": [[6, "fall"], [8, "jump"]]}, {"id": 6, "start": 13027, "end": 13036, "lines": ["cmp byte ptr [rax], 65h ; 'e'", "jz loc_338E"], "succs": [[7, "fall"], [10, "jump"]]}, {"id": 7, "start": 13036, "end": 13075, "lines": ["mov r12, cs:stdout", "lea rsi, aReportAnyTrans; \"Report any translation bugs to <https:/\"...", "xor edi, edi; domainname", "mov edx, 5; category", "call cs:dcgettext_ptr", "mov rdi, rax; s", "mov rsi, r12; stream", "call cs:fputs_unlocked_ptr"], "succs": [[8, "fall"]]}, {"id": 8, "start": 13075, "end": 13148, "lines": ["mov edx, 5; category", "lea rsi, aFullDocumentat; \"Full documentation <%s%s>\\n\"", "xor edi, edi; domainname", "call cs:dcgettext_ptr", "lea rcx, aEcho; \"echo\"", "lea rdx, aHttpsWwwGnuOrg; \"https://www.gnu.org/software/coreutils/\"", "mov edi, 2", "mov rsi, rax", "xor eax, eax", "lea r12, accept+3; \"\"", "call cs:__printf_chk_ptr", "lea rax, aEcho; \"echo\"", "cmp rbx, rax", "jz loc_33FC"], "succs": [[9, "fall"], [15, "jump"]]}, {"id": 9, "start": 13148, "end": 13198, "lines": ["mov edx, 5; category", "lea rsi, aOrAvailableLoc; \"or available locally via: info '(coreut\"...", "xor edi, edi; domainname", "call cs:dcgettext_ptr", "mov edi, 2", "mov rcx, r12", "mov rdx, rbx", "mov rsi, rax", "xor eax, eax", "call cs:__printf_chk_ptr", "xor edi, edi; status", "call cs:exit_ptr"], "succs": []}, {"id": 10, "start": 13198, "end": 13208, "lines": ["cmp byte ptr [rax+1], 6Eh ; 'n'", "jnz loc_32EC"], "succs": [[7, "jump"], [11, "fall"]]}, {"id": 11, "start": 13208, "end": 13218, "lines": ["cmp byte ptr [rax+2], 5Fh ; '_'", "jz loc_3313"], "succs": [[8, "jump"], [12, "fall"]]}, {"id": 12, "start": 13218, "end": 13223, "lines": ["jmp loc_32EC"], "succs": [[7, "jump"]]}, {"id": 13, "start": 13223, "end": 13258, "lines": ["call sub_6360", "xor esi, esi; locale", "mov edi, 5; category", "lea rbx, aEcho; \"echo\"", "call cs:setlocale_ptr", "test rax, rax", "jnz loc_32E3"], "succs": [[6, "jump"], [14, "fall"]]}, {"id": 14, "start": 13258, "end": 13308, "lines": ["mov edx, 5; category", "lea rsi, aFullDocumentat; \"Full documentation <%s%s>\\n\"", "xor edi, edi; domainname", "call cs:dcgettext_ptr", "lea rcx, aEcho; \"echo\"", "mov edi, 2", "lea rdx, aHttpsWwwGnuOrg; \"https://www.gnu.org/software/coreutils/\"", "mov rsi, rax", "xor eax, eax", "call cs:__printf_chk_ptr"], "succs": [[15, "fall"]]}, {"id": 15, "start": 13308, "end": 13327, "lines": ["lea rbx, aEcho; \"echo\"", "lea r12, aMultiCallInvoc+0Ah; \" invocation\"", "jmp loc_335C"], "succs": [[9, "jump"]]}, {"id": 16, "start": 8256, "end": 8288, "lines": ["lea rcx, function; \"usage\"", "mov edx, 29h ; ')'; line", "lea rsi, file; \"src/echo.c\"", "lea rdi, assertion; \"status == 0\"", "call cs:__assert_fail_ptr"], "succs": []}]}, {"name": "sub_2297", "ea": 8855, "blocks": [{"id": 0, "start": 8855, "end": 8903, "lines": ["endbr64", "push rbp", "mov rbp, rsp", "push r15", "mov r15, r9", "push r14", "mov r14, r8", "push r13", "mov r13d, esi", "push r12", "mov r12d, ecx", "push rbx", "mov rbx, rdx", "sub rsp, 18h", "cmp cs:error_one_per_line, 0", "mov [rbp+status], edi", "jz short loc_2308"], "succs": [[1, "fall"], [7, "jump"]]}, {"id": 1, "start": 8903, "end": 8911, "lines": ["cmp cs:dword_C118, ecx", "jnz short loc_22FA"], "succs": [[2, "fall"], [6, "jump"]]}, {"id": 2, "start": 8911, "end": 8927, "lines": ["mov rdi, cs:s1; s1", "cmp rdi, rdx", "jz loc_238D"], "succs": [[3, "fall"], [13, "jump"]]}, {"id": 3, "start": 8927, "end": 8932, "lines": ["test rdx, rdx", "jz short loc_22FA"], "succs": [[4, "fall"], [6, "jump"]]}, {"id": 4, "start": 8932, "end": 8937, "lines": ["test rdi, rdi", "jz short loc_22FA"], "succs": [[5, "fall"], [6, "jump"]]}, {"id": 5, "start": 8937, "end": 8954, "lines": ["mov rsi, rdx; s2", "call cs:strcmp_ptr", "test eax, eax", "jz loc_238D"], "succs": [[6, "fall"], [13, "jump"]]}, {"id": 6, "start": 8954, "end": 8968, "lines": ["mov cs:s1, rbx", "mov cs:dword_C118, r12d"], "succs": [[7, "fall"]]}, {"id": 7, "start": 8968, "end": 8985, "lines": ["call sub_2155", "mov rax, cs:error_print_progname", "test rax, rax", "jz short loc_231D"], "succs": [[8, "fall"], [9, "jump"]]}, {"id": 8, "start": 8985, "end": 8989, "lines": ["call rax ; error_print_progname", "jmp short loc_2341"], "succs": [[10, "jump"]]}, {"id": 9, "start": 8989, "end": 9025, "lines": ["call sub_34F0", "mov rdi, cs:stderr", "mov esi, 2", "lea rdx, aS; \"%s:\"", "mov rcx, rax", "xor eax, eax", "call cs:__fprintf_chk_ptr"], "succs": [[10, "fall"]]}, {"id": 10, "start": 9025, "end": 9037, "lines": ["lea rdx, aS_0+3; \" \"", "test rbx, rbx", "jz short loc_2354"], "succs": [[11, "fall"], [12, "jump"]]}, {"id": 11, "start": 9037, "end": 9044, "lines": ["lea rdx, aSU; \"%s:%u: \""], "succs": [[12, "fall"]]}, {"id": 12, "start": 9044, "end": 9101, "lines": ["mov rcx, rbx", "mov rdi, cs:stderr", "mov r8d, r12d", "xor eax, eax", "mov esi, 2", "call cs:__fprintf_chk_ptr", "mov edi, [rbp+status]; status", "add rsp, 18h", "mov rcx, r15", "pop rbx", "mov rdx, r14", "pop r12", "mov esi, r13d", "pop r13", "pop r14", "pop r15", "pop rbp", "jmp sub_20DD"], "succs": [[14, "jump"]]}, {"id": 13, "start": 9101, "end": 9116, "lines": ["add rsp, 18h", "pop rbx", "pop r12", "pop r13", "pop r14", "pop r15", "pop rbp", "retn"], "succs": []}, {"id": 14, "start": 8413, "end": 8413, "lines": [], "succs": []}]}, {"name": "sub_4F70", "ea": 20336, "blocks": [{"id": 0, "start": 20336, "end": 20413, "lines": ["push rbp", "mov rbp, rsp", "push r15", "push r14", "push r13", "push r12", "push rbx", "movsxd rbx, edi", "sub rsp, 38h", "mov [rbp+var_48], rsi", "mov [rbp+var_50], rdx", "mov r15, fs:28h", "mov [rbp+var_38], r15", "mov r15, rcx", "call cs:__errno_location_ptr", "mov r14, cs:ptr", "mov r12, rax", "mov eax, [rax]", "mov [rbp+var_54], eax", "cmp ebx, 7FFFFFFEh", "ja loc_2441"], "succs": [[1, "fall"], [13, "jump"]]}, {"id": 1, "start": 20413, "end": 20423, "lines": ["mov eax, cs:dword_C078", "cmp eax, ebx", "jg short loc_5035"], "succs": [[2, "fall"], [5, "jump"]]}, {"id": 2, "start": 20423, "end": 20456, "lines": ["movsxd rdx, eax", "mov [rbp+var_40], rdx", "mov edx, ebx", "sub edx, eax", "lea rax, xmmword_C090", "add edx, 1", "movsxd rdx, edx", "cmp r14, rax", "jz loc_5110"], "succs": [[3, "fall"], [11, "jump"]]}, {"id": 3, "start": 20456, "end": 20490, "lines": ["mov rdi, r14", "lea rsi, [rbp+var_40]", "mov r8d, 10h", "mov ecx, 7FFFFFFFh", "call sub_6670", "mov cs:ptr, rax", "mov r14, rax"], "succs": [[4, "fall"]]}, {"id": 4, "start": 20490, "end": 20533, "lines": ["movsxd rdi, cs:dword_C078", "mov rdx, [rbp+var_40]", "xor esi, esi; c", "sub rdx, rdi", "shl rdi, 4", "shl rdx, 4; n", "add rdi, r14; s", "call cs:memset_ptr", "mov rax, [rbp+var_40]", "mov cs:dword_C078, eax"], "succs": [[5, "fall"]]}, {"id": 5, "start": 20533, "end": 20611, "lines": ["mov eax, [r15+4]", "shl rbx, 4", "sub rsp, 8", "mov r8d, [r15]", "add rbx, r14", "lea r14, [r15+8]", "mov rcx, [rbp+var_50]", "mov rdx, [rbp+var_48]", "or eax, 1", "mov rsi, [rbx]", "mov r13, [rbx+8]", "mov [rbp+var_58], eax", "mov r9d, eax", "push qword ptr [r15+30h]", "mov rdi, r13", "push qword ptr [r15+28h]", "push r14", "mov [rbp+var_60], rsi", "call sub_3720", "add rsp, 20h", "cmp rax, [rbp+var_60]", "jb short loc_50E8"], "succs": [[6, "fall"], [9, "jump"]]}, {"id": 6, "start": 20611, "end": 20630, "lines": ["lea rsi, [rax+1]", "lea rax, unk_C140", "mov [rbx], rsi", "cmp r13, rax", "jz short loc_50A7"], "succs": [[7, "fall"], [8, "jump"]]}, {"id": 7, "start": 20630, "end": 20647, "lines": ["mov [rbp+var_60], rsi", "mov rdi, r13; ptr", "call cs:free_ptr", "mov rsi, [rbp+var_60]"], "succs": [[8, "fall"]]}, {"id": 8, "start": 20647, "end": 20712, "lines": ["mov rdi, rsi", "mov [rbp+var_60], rsi", "call sub_6480", "sub rsp, 8", "mov r9d, [rbp+var_58]", "mov r8d, [r15]", "mov [rbx+8], rax", "mov rcx, [rbp+var_50]", "mov rdi, rax", "mov r13, rax", "mov rdx, [rbp+var_48]", "mov rsi, [rbp+var_60]", "push qword ptr [r15+30h]", "push qword ptr [r15+28h]", "push r14", "call sub_3720", "add rsp, 20h"], "succs": [[9, "fall"]]}, {"id": 9, "start": 20712, "end": 20734, "lines": ["mov eax, [rbp+var_54]", "mov [r12], eax", "mov rax, [rbp+var_38]", "sub rax, fs:28h", "jnz short loc_5141"], "succs": [[10, "fall"], [12, "jump"]]}, {"id": 10, "start": 20734, "end": 20752, "lines": ["lea rsp, [rbp-28h]", "mov rax, r13", "pop rbx", "pop r12", "pop r13", "pop r14", "pop r15", "pop rbp", "retn"], "succs": []}, {"id": 11, "start": 20752, "end": 20801, "lines": ["lea rsi, [rbp+var_40]", "mov r8d, 10h", "mov ecx, 7FFFFFFFh", "xor edi, edi", "call sub_6670", "movdqa xmm0, cs:xmmword_C090", "mov cs:ptr, rax", "mov r14, rax", "movups xmmword ptr [rax], xmm0", "jmp loc_500A"], "succs": [[4, "jump"]]}, {"id": 12, "start": 20801, "end": 20807, "lines": ["call cs:__stack_chk_fail_ptr"], "succs": []}, {"id": 13, "start": 9281, "end": 9287, "lines": ["call cs:abort_ptr"], "succs": []}]}, {"name": "sub_3500", "ea": 13568, "blocks": [{"id": 0, "start": 13568, "end": 13606, "lines": ["endbr64", "push rbp", "mov esi, 2Fh ; '/'; c", "mov rbp, rsp", "push rbx", "mov rbx, rdi", "sub rsp, 8", "call cs:strrchr_ptr", "mov rdx, rax", "mov rax, rbx", "test rdx, rdx", "jz short loc_353F"], "succs": [[1, "fall"], [3, "jump"]]}, {"id": 1, "start": 13606, "end": 13622, "lines": ["lea rax, [rdx+1]", "mov rcx, rax", "sub rcx, rbx", "cmp rcx, 6", "jle short loc_353F"], "succs": [[2, "fall"], [3, "jump"]]}, {"id": 2, "start": 13622, "end": 13631, "lines": ["cmp dword ptr [rdx-6], 696C2E2Fh", "jz short loc_3560"], "succs": [[3, "fall"], [4, "jump"]]}, {"id": 3, "start": 13631, "end": 13658, "lines": ["mov cs:qword_C120, rbx", "mov cs:__progname_full, rbx", "mov rbx, [rbp+var_8]", "mov cs:__progname, rax", "leave", "retn"], "succs": []}, {"id": 4, "start": 13664, "end": 13673, "lines": ["cmp dword ptr [rdx-3], 2F736269h", "jnz short loc_353F"], "succs": [[3, "jump"], [5, "fall"]]}, {"id": 5, "start": 13673, "end": 13679, "lines": ["cmp byte ptr [rdx+1], 6Ch ; 'l'", "jnz short loc_3588"], "succs": [[6, "fall"], [9, "jump"]]}, {"id": 6, "start": 13679, "end": 13685, "lines": ["cmp byte ptr [rax+1], 74h ; 't'", "jnz short loc_3588"], "succs": [[7, "fall"], [9, "jump"]]}, {"id": 7, "start": 13685, "end": 13691, "lines": ["cmp byte ptr [rax+2], 2Dh ; '-'", "jnz short loc_3588"], "succs": [[8, "fall"], [9, "jump"]]}, {"id": 8, "start": 13691, "end": 13700, "lines": ["lea rbx, [rdx+4]", "mov rax, rbx", "jmp short loc_353F"], "succs": [[3, "jump"]]}, {"id": 9, "start": 13704, "end": 13709, "lines": ["mov rbx, rax", "jmp short loc_353F"], "succs": [[3, "jump"]]}]}, {"name": "sub_7110", "ea": 28944, "blocks": [{"id": 0, "start": 28944, "end": 28980, "lines": ["endbr64", "push rbp", "mov rbp, rsp", "push r13", "mov r13, rsi", "xor esi, esi; locale", "push r12", "mov r12, rdx", "push rbx", "sub rsp, 8", "call cs:setlocale_ptr", "test rax, rax", "jz short loc_71A0"], "succs": [[1, "fall"], [7, "jump"]]}, {"id": 1, "start": 28980, "end": 28997, "lines": ["mov rbx, rax", "mov rdi, rax; s", "call cs:strlen_ptr", "cmp rax, r12", "jb short loc_7160"], "succs": [[2, "fall"], [5, "jump"]]}, {"id": 2, "start": 28997, "end": 29002, "lines": ["test r12, r12", "jnz short loc_7180"], "succs": [[3, "fall"], [6, "jump"]]}, {"id": 3, "start": 29002, "end": 29007, "lines": ["mov eax, 22h ; '\"'"], "succs": [[4, "fall"]]}, {"id": 4, "start": 29007, "end": 29018, "lines": ["add rsp, 8", "pop rbx", "pop r12", "pop r13", "pop rbp", "retn"], "succs": []}, {"id": 5, "start": 29024, "end": 29053, "lines": ["mov rsi, rbx; src", "mov rdi, r13; dest", "lea rdx, [rax+1]; n", "call cs:memcpy_ptr", "add rsp, 8", "xor eax, eax", "pop rbx", "pop r12", "pop r13", "pop rbp", "retn"], "succs": []}, {"id": 6, "start": 29056, "end": 29081, "lines": ["lea rdx, [r12-1]; n", "mov rsi, rbx; src", "mov rdi, r13; dest", "call cs:memcpy_ptr", "mov byte ptr [r13+r12-1], 0", "jmp short loc_714A"], "succs": [[3, "jump"]]}, {"id": 7, "start": 29088, "end": 29093, "lines": ["test r12, r12", "jz short loc_71AA"], "succs": [[8, "fall"], [9, "jump"]]}, {"id": 8, "start": 29093, "end": 29098, "lines": ["mov byte ptr [r13+0], 0"], "succs": [[9, "fall"]]}, {"id": 9, "start": 29098, "end": 29105, "lines": ["mov eax, 16h", "jmp short loc_714F"], "succs": [[4, "jump"]]}]}, {"name": "sub_2C00", "ea": 11264, "blocks": [{"id": 0, "start": 11264, "end": 11271, "lines": ["lea eax, [rdi-41h]; switch 38 cases", "cmp al, 25h", "ja short def_2C18; jumptable 0000000000002C18 default case, cases 71-96"], "succs": [[1, "fall"], [2, "jump"]]}, {"id": 1, "start": 11271, "end": 11291, "lines": ["lea rdx, jpt_2C18", "movzx eax, al", "movsxd rax, ds:(jpt_2C18 - 8004h)[rdx+rax*4]", "add rax, rdx", "jmp rax; switch jump"], "succs": [[2, "switch"], [3, "switch"], [4, "switch"], [5, "switch"], [6, "switch"], [7, "switch"], [8, "switch"]]}, {"id": 2, "start": 11296, "end": 11304, "lines": ["movzx edi, dil; jumptable 0000000000002C18 default case, cases 71-96", "lea eax, [rdi-30h]", "retn"], "succs": []}, {"id": 3, "start": 11312, "end": 11318, "lines": ["mov eax, 0Ch; jumptable 0000000000002C18 cases 67,99", "retn"], "succs": []}, {"id": 4, "start": 11328, "end": 11334, "lines": ["mov eax, 0Dh; jumptable 0000000000002C18 cases 68,100", "retn"], "succs": []}, {"id": 5, "start": 11344, "end": 11350, "lines": ["mov eax, 0Eh; jumptable 0000000000002C18 cases 69,101", "retn"], "succs": []}, {"id": 6, "start": 11360, "end": 11366, "lines": ["mov eax, 0Fh; jumptable 0000000000002C18 cases 70,102", "retn"], "succs": []}, {"id": 7, "start": 11376, "end": 11382, "lines": ["mov eax, 0Bh; jumptable 0000000000002C18 cases 66,98", "retn"], "succs": []}, {"id": 8, "start": 11392, "end": 11398, "lines": ["mov eax, 0Ah; jumptable 0000000000002C18 cases 65,97", "retn"], "succs": []}]}, {"name": "sub_3430", "ea": 13360, "blocks": [{"id": 0, "start": 13360, "end": 13388, "lines": ["endbr64", "push rbp", "mov rdi, cs:stdout", "mov rbp, rsp", "push r14", "push rbx", "call sub_6950", "test eax, eax", "jz short loc_3460"], "succs": [[1, "fall"], [3, "jump"]]}, {"id": 1, "start": 13388, "end": 13397, "lines": ["cmp cs:byte_C0F0, 0", "jz short loc_3476"], "succs": [[2, "fall"], [5, "jump"]]}, {"id": 2, "start": 13397, "end": 13408, "lines": ["call cs:__errno_location_ptr", "cmp dword ptr [rax], 20h ; ' '", "jnz short loc_3476"], "succs": [[3, "fall"], [5, "jump"]]}, {"id": 3, "start": 13408, "end": 13425, "lines": ["mov rdi, cs:stderr", "call sub_6950", "test eax, eax", "jnz short loc_34C6"], "succs": [[4, "fall"], [7, "jump"]]}, {"id": 4, "start": 13425, "end": 13430, "lines": ["pop rbx", "pop r14", "pop rbp", "retn"], "succs": []}, {"id": 5, "start": 13430, "end": 13470, "lines": ["lea rdi, domainname; \"gnulib\"", "mov edx, 5; category", "lea rsi, aWriteError; \"write error\"", "call cs:dcgettext_ptr", "mov rdi, cs:qword_C0F8", "mov rbx, rax", "test rdi, rdi", "jz short loc_34D2"], "succs": [[6, "fall"], [8, "jump"]]}, {"id": 6, "start": 13470, "end": 13510, "lines": ["call sub_5820", "mov r14, rax", "call cs:__errno_location_ptr", "mov r8, rbx", "mov rcx, r14", "xor edi, edi", "mov esi, [rax]", "lea rdx, aSS; \"%s: %s\"", "xor eax, eax", "call error"], "succs": [[7, "fall"]]}, {"id": 7, "start": 13510, "end": 13522, "lines": ["mov edi, cs:status; status", "call cs:_exit_ptr"], "succs": []}, {"id": 8, "start": 13522, "end": 13552, "lines": ["call cs:__errno_location_ptr", "mov rcx, rbx", "lea rdx, aSS+4; \"%s\"", "xor edi, edi", "mov esi, [rax]", "xor eax, eax", "call error", "jmp short loc_34C6"], "succs": [[7, "jump"]]}]}, {"name": "sub_61D0", "ea": 25040, "blocks": [{"id": 0, "start": 25040, "end": 25090, "lines": ["endbr64", "push rbp", "mov r10, rsi", "mov r11, rdx", "xor r9d, r9d", "mov rbp, rsp", "push rbx", "mov rbx, rcx", "sub rsp, 68h", "mov rcx, fs:28h", "mov [rbp+var_18], rcx", "mov rcx, r8", "lea r8, [rbp+var_70]", "mov rsi, r8", "jmp short loc_622C"], "succs": [[3, "jump"]]}, {"id": 1, "start": 25096, "end": 25118, "lines": ["mov edx, eax", "add eax, 8", "add rdx, [rcx+10h]", "mov [rcx], eax", "mov rax, [rdx]", "mov [rsi], rax", "test rax, rax", "jz short loc_6250"], "succs": [[2, "fall"], [6, "jump"]]}, {"id": 2, "start": 25118, "end": 25132, "lines": ["add r9, 1", "add rsi, 8", "cmp r9, 0Ah", "jz short loc_6250"], "succs": [[3, "fall"], [6, "jump"]]}, {"id": 3, "start": 25132, "end": 25139, "lines": ["mov eax, [rcx]", "cmp eax, 2Fh ; '/'", "jbe short loc_6208"], "succs": [[1, "jump"], [4, "fall"]]}, {"id": 4, "start": 25139, "end": 25162, "lines": ["mov rdx, [rcx+8]", "lea rax, [rdx+8]", "mov [rcx+8], rax", "mov rax, [rdx]", "mov [rsi], rax", "test rax, rax", "jnz short loc_621E"], "succs": [[2, "jump"], [5, "fall"]]}, {"id": 5, "start": 25162, "end": 25168, "lines": ["nop word ptr [rax+rax+00h]"], "succs": [[6, "fall"]]}, {"id": 6, "start": 25168, "end": 25197, "lines": ["mov rcx, rbx", "mov rdx, r11", "mov rsi, r10", "call sub_5CA0", "mov rax, [rbp+var_18]", "sub rax, fs:28h", "jnz short loc_6273"], "succs": [[7, "fall"], [8, "jump"]]}, {"id": 7, "start": 25197, "end": 25203, "lines": ["mov rbx, [rbp+var_8]", "leave", "retn"], "succs": []}, {"id": 8, "start": 25203, "end": 25209, "lines": ["call cs:__stack_chk_fail_ptr"], "succs": []}]}, {"name": "sub_6280", "ea": 25216, "blocks": [{"id": 0, "start": 25216, "end": 25320, "lines": ["endbr64", "push rbp", "mov r10, rsi", "mov r11, rdx", "mov rbp, rsp", "push rbx", "mov rbx, rcx", "mov ecx, 20h ; ' '", "lea rsi, [rbp+arg_0]", "sub rsp, 0B8h", "mov [rbp+var_20], r8", "lea r8, [rbp+var_A0]", "mov [rbp+var_18], r9", "xor r9d, r9d", "mov rax, fs:28h", "mov [rbp+var_48], rax", "xor eax, eax", "lea rax, [rbp+arg_0]", "mov [rbp+var_B8], 20h ; ' '", "mov [rbp+var_B0], rax", "lea rax, [rbp+var_40]", "mov [rbp+var_A8], rax", "mov rax, r8", "jmp short loc_6313"], "succs": [[3, "jump"]]}, {"id": 1, "start": 25328, "end": 25349, "lines": ["mov edx, ecx", "add ecx, 8", "lea rdx, [rbp+rdx+var_40]", "mov rdx, [rdx]", "mov [rax], rdx", "test rdx, rdx", "jz short loc_6330"], "succs": [[2, "fall"], [6, "jump"]]}, {"id": 2, "start": 25349, "end": 25363, "lines": ["add r9, 1", "add rax, 8", "cmp r9, 0Ah", "jz short loc_6330"], "succs": [[3, "fall"], [6, "jump"]]}, {"id": 3, "start": 25363, "end": 25368, "lines": ["cmp ecx, 2Fh ; '/'", "jbe short loc_62F0"], "succs": [[1, "jump"], [4, "fall"]]}, {"id": 4, "start": 25368, "end": 25386, "lines": ["mov rdx, rsi", "add rsi, 8", "mov rdx, [rdx]", "mov [rax], rdx", "test rdx, rdx", "jnz short loc_6305"], "succs": [[2, "jump"], [5, "fall"]]}, {"id": 5, "start": 25386, "end": 25392, "lines": ["nop word ptr [rax+rax+00h]"], "succs": [[6, "fall"]]}, {"id": 6, "start": 25392, "end": 25421, "lines": ["mov rcx, rbx", "mov rdx, r11", "mov rsi, r10", "call sub_5CA0", "mov rax, [rbp+var_48]", "sub rax, fs:28h", "jnz short loc_6353"], "succs": [[7, "fall"], [8, "jump"]]}, {"id": 7, "start": 25421, "end": 25427, "lines": ["mov rbx, [rbp+var_8]", "leave", "retn"], "succs": []}, {"id": 8, "start": 25427, "end": 25433, "lines": ["call cs:__stack_chk_fail_ptr"], "succs": []}]}, {"name": "sub_7020", "ea": 28704, "blocks": [{"id": 0, "start": 28704, "end": 28759, "lines": ["endbr64", "push rbp", "mov edx, 101h", "mov rbp, rsp", "sub rsp, 110h", "mov rsi, fs:28h", "mov [rbp+var_8], rsi", "lea rsi, [rbp+var_110]", "call sub_70E0", "test eax, eax", "mov eax, 0", "jnz short loc_7072"], "succs": [[1, "fall"], [4, "jump"]]}, {"id": 1, "start": 28759, "end": 28769, "lines": ["cmp word ptr [rbp+var_110], 43h ; 'C'", "jz short loc_7072"], "succs": [[2, "fall"], [4, "jump"]]}, {"id": 2, "start": 28769, "end": 28781, "lines": ["cmp [rbp+var_110], 49534F50h", "jz short loc_7088"], "succs": [[3, "fall"], [6, "jump"]]}, {"id": 3, "start": 28781, "end": 28786, "lines": ["mov eax, 1"], "succs": [[4, "fall"]]}, {"id": 4, "start": 28786, "end": 28801, "lines": ["mov rdx, [rbp+var_8]", "sub rdx, fs:28h", "jnz short loc_7096"], "succs": [[5, "fall"], [8, "jump"]]}, {"id": 5, "start": 28801, "end": 28803, "lines": ["leave", "retn"], "succs": []}, {"id": 6, "start": 28808, "end": 28820, "lines": ["xor eax, eax", "cmp [rbp+var_10C], 58h ; 'X'", "jnz short loc_706D"], "succs": [[3, "jump"], [7, "fall"]]}, {"id": 7, "start": 28820, "end": 28822, "lines": ["jmp short loc_7072"], "succs": [[4, "jump"]]}, {"id": 8, "start": 28822, "end": 28828, "lines": ["call cs:__stack_chk_fail_ptr"], "succs": []}]}, {"name": "sub_20DD", "ea": 8413, "blocks": [{"id": 0, "start": 8413, "end": 8454, "lines": ["push rbp", "mov rbp, rsp", "push r12", "mov r12d, esi", "mov esi, 2", "push rbx", "mov ebx, edi", "mov rdi, cs:stderr", "call cs:__vfprintf_chk_ptr", "inc cs:error_message_count", "test r12d, r12d", "jz short loc_210E"], "succs": [[1, "fall"], [2, "jump"]]}, {"id": 1, "start": 8454, "end": 8462, "lines": ["mov edi, r12d", "call sub_2060"], "succs": [[2, "fall"]]}, {"id": 2, "start": 8462, "end": 8479, "lines": ["mov rdi, cs:stderr; _IO_FILE *", "mov rax, [rdi+28h]", "cmp rax, [rdi+30h]", "jb short loc_212C"], "succs": [[3, "fall"], [4, "jump"]]}, {"id": 3, "start": 8479, "end": 8492, "lines": ["mov esi, 0Ah; int", "call cs:__overflow_ptr", "jmp short loc_2137"], "succs": [[5, "jump"]]}, {"id": 4, "start": 8492, "end": 8503, "lines": ["lea rdx, [rax+1]", "mov [rdi+28h], rdx", "mov byte ptr [rax], 0Ah"], "succs": [[5, "fall"]]}, {"id": 5, "start": 8503, "end": 8520, "lines": ["mov rdi, cs:stderr; stream", "call cs:fflush_unlocked_ptr", "test ebx, ebx", "jz short loc_2150"], "succs": [[6, "fall"], [7, "jump"]]}, {"id": 6, "start": 8520, "end": 8528, "lines": ["mov edi, ebx; status", "call cs:exit_ptr"], "succs": []}, {"id": 7, "start": 8528, "end": 8533, "lines": ["pop rbx", "pop r12", "pop rbp", "retn"], "succs": []}]}, {"name": "sub_3640", "ea": 13888, "blocks": [{"id": 0, "start": 13888, "end": 13947, "lines": ["push rbp", "mov edx, 5; category", "mov rbp, rsp", "push r14", "push rbx", "mov rbx, rdi", "sub rsp, 30h", "mov r14, fs:28h", "mov [rbp+var_18], r14", "mov r14d, esi", "mov rsi, rdi; msgid", "lea rdi, domainname; \"gnulib\"", "call cs:dcgettext_ptr", "mov r8, rax", "cmp rbx, rax", "jz short loc_36A0"], "succs": [[1, "fall"], [3, "jump"]]}, {"id": 1, "start": 13947, "end": 13966, "lines": ["mov rax, [rbp+var_18]", "sub rax, fs:28h", "jnz loc_3712"], "succs": [[2, "fall"], [7, "jump"]]}, {"id": 2, "start": 13966, "end": 13978, "lines": ["add rsp, 30h", "mov rax, r8", "pop rbx", "pop r14", "pop rbp", "retn"], "succs": []}, {"id": 3, "start": 13984, "end": 14032, "lines": ["mov [rbp+var_38], rax", "lea rcx, [rbp+var_20]", "lea rdi, [rbp+pc32]; pc32", "mov edx, 3", "mov [rbp+var_20], 0", "lea rsi, byte_8F30; s", "call sub_69C0", "mov r8, [rbp+var_38]", "cmp rax, 3", "jz short loc_36F0"], "succs": [[4, "fall"], [5, "jump"]]}, {"id": 4, "start": 14032, "end": 14056, "lines": ["lea r8, asc_8348+2; \"'`\"", "cmp r14d, 9", "lea rax, asc_8348; \"\\\"'`\"", "cmovz r8, rax", "jmp short loc_367B"], "succs": [[1, "jump"]]}, {"id": 5, "start": 14064, "end": 14073, "lines": ["cmp [rbp+pc32], 2018h", "jnz short loc_36D0"], "succs": [[4, "jump"], [6, "fall"]]}, {"id": 6, "start": 14073, "end": 14098, "lines": ["xor eax, eax", "cmp byte ptr [r8], 27h ; '''", "lea rcx, byte_8F30", "setz al", "lea r8, [rcx+rax*4]", "jmp loc_367B"], "succs": [[1, "jump"]]}, {"id": 7, "start": 14098, "end": 14104, "lines": ["call cs:__stack_chk_fail_ptr"], "succs": []}]}, {"name": "sub_5400", "ea": 21504, "blocks": [{"id": 0, "start": 21504, "end": 21539, "lines": ["endbr64", "push rbp", "mov rbp, rsp", "push r13", "push r12", "push rbx", "sub rsp, 8", "mov eax, cs:dword_C078", "mov r13, cs:ptr", "cmp eax, 1", "jle short loc_5452"], "succs": [[1, "fall"], [3, "jump"]]}, {"id": 1, "start": 21539, "end": 21568, "lines": ["sub eax, 2", "lea rbx, [r13+18h]", "shl rax, 4", "lea r12, [r13+rax+28h]", "xchg ax, ax", "nop word ptr [rax+rax+00000000h]"], "succs": [[2, "fall"]]}, {"id": 2, "start": 21568, "end": 21586, "lines": ["mov rdi, [rbx]; ptr", "add rbx, 10h", "call cs:free_ptr", "cmp rbx, r12", "jnz short loc_5440"], "succs": [[3, "fall"], [2, "jump"]]}, {"id": 3, "start": 21586, "end": 21602, "lines": ["mov rdi, [r13+8]; ptr", "lea rbx, unk_C140", "cmp rdi, rbx", "jz short loc_547A"], "succs": [[4, "fall"], [5, "jump"]]}, {"id": 4, "start": 21602, "end": 21626, "lines": ["call cs:free_ptr", "mov qword ptr cs:xmmword_C090+8, rbx", "mov qword ptr cs:xmmword_C090, 100h"], "succs": [[5, "fall"]]}, {"id": 5, "start": 21626, "end": 21638, "lines": ["lea rbx, xmmword_C090", "cmp r13, rbx", "jz short loc_5496"], "succs": [[6, "fall"], [7, "jump"]]}, {"id": 6, "start": 21638, "end": 21654, "lines": ["mov rdi, r13; ptr", "call cs:free_ptr", "mov cs:ptr, rbx"], "succs": [[7, "fall"]]}, {"id": 7, "start": 21654, "end": 21675, "lines": ["mov cs:dword_C078, 1", "add rsp, 8", "pop rbx", "pop r12", "pop r13", "pop rbp", "retn"], "succs": []}]}, {"name": "sub_6950", "ea": 26960, "blocks": [{"id": 0, "start": 26960, "end": 27007, "lines": ["endbr64", "push rbp", "mov rbp, rsp", "push r14", "push rbx", "sub rsp, 10h", "mov [rbp+stream], rdi", "call cs:__fpending_ptr", "mov rdi, [rbp+stream]; stream", "mov r14, rax", "mov ebx, [rdi]", "and ebx, 20h", "call cs:fclose_ptr", "test ebx, ebx", "jnz short loc_69A8"], "succs": [[1, "fall"], [5, "jump"]]}, {"id": 1, "start": 27007, "end": 27011, "lines": ["test eax, eax", "jz short loc_6999"], "succs": [[2, "fall"], [4, "jump"]]}, {"id": 2, "start": 27011, "end": 27016, "lines": ["test r14, r14", "jnz short loc_69B8"], "succs": [[3, "fall"], [7, "jump"]]}, {"id": 3, "start": 27016, "end": 27033, "lines": ["call cs:__errno_location_ptr", "cmp dword ptr [rax], 9", "setnz al", "movzx eax, al", "neg eax"], "succs": [[4, "fall"]]}, {"id": 4, "start": 27033, "end": 27042, "lines": ["add rsp, 10h", "pop rbx", "pop r14", "pop rbp", "retn"], "succs": []}, {"id": 5, "start": 27048, "end": 27052, "lines": ["test eax, eax", "jnz short loc_69B8"], "succs": [[6, "fall"], [7, "jump"]]}, {"id": 6, "start": 27052, "end": 27064, "lines": ["call cs:__errno_location_ptr", "mov dword ptr [rax], 0"], "succs": [[7, "fall"]]}, {"id": 7, "start": 27064, "end": 27071, "lines": ["mov eax, 0FFFFFFFFh", "jmp short loc_6999"], "succs": [[4, "jump"]]}]}, {"name": "sub_6600", "ea": 26112, "blocks": [{"id": 0, "start": 26112, "end": 26137, "lines": ["endbr64", "push rbp", "mov rcx, rdx", "mov rbp, rsp", "push r12", "mov r12, rsi", "push rbx", "mov rbx, [rsi]", "test rdi, rdi", "jz short loc_6648"], "succs": [[1, "fall"], [4, "jump"]]}, {"id": 1, "start": 26137, "end": 26152, "lines": ["mov rax, rbx", "shr rax, 1", "add rax, 1", "add rbx, rax", "jb short loc_6669"], "succs": [[2, "fall"], [6, "jump"]]}, {"id": 2, "start": 26152, "end": 26169, "lines": ["mov rdx, rcx", "mov rsi, rbx", "call sub_6FE0", "test rax, rax", "jz short loc_6669"], "succs": [[3, "fall"], [6, "jump"]]}, {"id": 3, "start": 26169, "end": 26178, "lines": ["mov [r12], rbx", "pop rbx", "pop r12", "pop rbp", "retn"], "succs": []}, {"id": 4, "start": 26184, "end": 26189, "lines": ["test rbx, rbx", "jnz short loc_6628"], "succs": [[2, "jump"], [5, "fall"]]}, {"id": 5, "start": 26189, "end": 26217, "lines": ["xor edx, edx", "mov eax, 80h", "div rcx", "xor edx, edx", "cmp rcx, 80h", "setnbe dl", "lea rbx, [rax+rdx]", "jmp short loc_6628"], "succs": [[2, "jump"]]}, {"id": 6, "start": 26217, "end": 26223, "lines": ["call sub_6900"], "succs": []}]}, {"name": "sub_3590", "ea": 13712, "blocks": [{"id": 0, "start": 13712, "end": 13770, "lines": ["endbr64", "push rbp", "mov edx, 5; category", "mov rbp, rsp", "push r14", "push rbx", "mov rbx, rdi", "sub rsp, 30h", "mov r14, fs:28h", "mov [rbp+var_18], r14", "mov r14, rsi", "mov rsi, rdi; msgid", "xor edi, edi; domainname", "call cs:dcgettext_ptr", "mov r8, rax", "cmp rbx, rax", "jz short loc_35E8"], "succs": [[1, "fall"], [3, "jump"]]}, {"id": 1, "start": 13770, "end": 13785, "lines": ["mov rax, [rbp+var_18]", "sub rax, fs:28h", "jnz short loc_3625"], "succs": [[2, "fall"], [5, "jump"]]}, {"id": 2, "start": 13785, "end": 13797, "lines": ["add rsp, 30h", "mov rax, r8", "pop rbx", "pop r14", "pop rbp", "retn"], "succs": []}, {"id": 3, "start": 13800, "end": 13848, "lines": ["mov [rbp+var_38], rax", "lea rcx, [rbp+p]; p", "lea rdi, [rbp+pc32]; pc32", "mov edx, 2; n", "mov qword ptr [rbp+p.__count], 0", "lea rsi, s; s", "call cs:mbrtoc32_ptr", "mov r8, [rbp+var_38]", "cmp rax, 2", "jnz short loc_35CA"], "succs": [[1, "jump"], [4, "fall"]]}, {"id": 4, "start": 13848, "end": 13861, "lines": ["cmp [rbp+pc32], 7FFh", "cmovz r8, r14", "jmp short loc_35CA"], "succs": [[1, "jump"]]}, {"id": 5, "start": 13861, "end": 13867, "lines": ["call cs:__stack_chk_fail_ptr"], "succs": []}]}, {"name": "sub_59B0", "ea": 22960, "blocks": [{"id": 0, "start": 22960, "end": 23051, "lines": ["endbr64", "push rbp", "mov rbp, rsp", "sub rsp, 40h", "movdqa xmm0, cs:xmmword_C240", "mov rax, fs:28h", "mov [rbp+var_8], rax", "mov rax, rcx", "mov rcx, cs:qword_C270", "movaps [rbp+var_40], xmm0", "movdqa xmm0, cs:xmmword_C250", "mov [rbp+var_10], rcx", "movaps [rbp+var_30], xmm0", "movdqa xmm0, cs:xmmword_C260", "mov dword ptr [rbp+var_40], 0Ah", "movaps [rbp+var_20], xmm0", "test rsi, rsi", "jz loc_246F"], "succs": [[1, "fall"], [5, "jump"]]}, {"id": 1, "start": 23051, "end": 23060, "lines": ["test rdx, rdx", "jz loc_246F"], "succs": [[2, "fall"], [5, "jump"]]}, {"id": 2, "start": 23060, "end": 23102, "lines": ["mov qword ptr [rbp+var_20+8], rsi", "lea rcx, [rbp+var_40]", "mov rsi, rax", "mov [rbp+var_10], rdx", "mov rdx, 0FFFFFFFFFFFFFFFFh", "call sub_4F70", "mov rdx, [rbp+var_8]", "sub rdx, fs:28h", "jnz short loc_5A40"], "succs": [[3, "fall"], [4, "jump"]]}, {"id": 3, "start": 23102, "end": 23104, "lines": ["leave", "retn"], "succs": []}, {"id": 4, "start": 23104, "end": 23110, "lines": ["call cs:__stack_chk_fail_ptr"], "succs": []}, {"id": 5, "start": 9327, "end": 9333, "lines": ["call cs:abort_ptr"], "succs": []}]}, {"name": "sub_5A50", "ea": 23120, "blocks": [{"id": 0, "start": 23120, "end": 23211, "lines": ["endbr64", "push rbp", "mov rbp, rsp", "sub rsp, 40h", "movdqa xmm0, cs:xmmword_C240", "mov rax, fs:28h", "mov [rbp+var_8], rax", "mov rax, rcx", "mov rcx, cs:qword_C270", "movaps [rbp+var_40], xmm0", "movdqa xmm0, cs:xmmword_C250", "mov [rbp+var_10], rcx", "movaps [rbp+var_30], xmm0", "movdqa xmm0, cs:xmmword_C260", "mov dword ptr [rbp+var_40], 0Ah", "movaps [rbp+var_20], xmm0", "test rsi, rsi", "jz loc_2475"], "succs": [[1, "fall"], [5, "jump"]]}, {"id": 1, "start": 23211, "end": 23220, "lines": ["test rdx, rdx", "jz loc_2475"], "succs": [[2, "fall"], [5, "jump"]]}, {"id": 2, "start": 23220, "end": 23258, "lines": ["mov qword ptr [rbp+var_20+8], rsi", "lea rcx, [rbp+var_40]", "mov rsi, rax", "mov [rbp+var_10], rdx", "mov rdx, r8", "call sub_4F70", "mov rdx, [rbp+var_8]", "sub rdx, fs:28h", "jnz short loc_5ADC"], "succs": [[3, "fall"], [4, "jump"]]}, {"id": 3, "start": 23258, "end": 23260, "lines": ["leave", "retn"], "succs": []}, {"id": 4, "start": 23260, "end": 23266, "lines": ["call cs:__stack_chk_fail_ptr"], "succs": []}, {"id": 5, "start": 9333, "end": 9339, "lines": ["call cs:abort_ptr"], "succs": []}]}, {"name": "sub_5AF0", "ea": 23280, "blocks": [{"id": 0, "start": 23280, "end": 23371, "lines": ["endbr64", "push rbp", "mov rbp, rsp", "sub rsp, 40h", "movdqa xmm0, cs:xmmword_C240", "mov rax, fs:28h", "mov [rbp+var_8], rax", "mov rax, rdx", "mov rdx, cs:qword_C270", "movaps [rbp+var_40], xmm0", "movdqa xmm0, cs:xmmword_C250", "mov [rbp+var_10], rdx", "movaps [rbp+var_30], xmm0", "movdqa xmm0, cs:xmmword_C260", "mov dword ptr [rbp+var_40], 0Ah", "movaps [rbp+var_20], xmm0", "test rdi, rdi", "jz loc_247B"], "succs": [[1, "fall"], [5, "jump"]]}, {"id": 1, "start": 23371, "end": 23380, "lines": ["test rsi, rsi", "jz loc_247B"], "succs": [[2, "fall"], [5, "jump"]]}, {"id": 2, "start": 23380, "end": 23424, "lines": ["mov qword ptr [rbp+var_20+8], rdi", "mov rdx, 0FFFFFFFFFFFFFFFFh", "xor edi, edi", "lea rcx, [rbp+var_40]", "mov [rbp+var_10], rsi", "mov rsi, rax", "call sub_4F70", "mov rdx, [rbp+var_8]", "sub rdx, fs:28h", "jnz short loc_5B82"], "succs": [[3, "fall"], [4, "jump"]]}, {"id": 3, "start": 23424, "end": 23426, "lines": ["leave", "retn"], "succs": []}, {"id": 4, "start": 23426, "end": 23432, "lines": ["call cs:__stack_chk_fail_ptr"], "succs": []}, {"id": 5, "start": 9339, "end": 9345, "lines": ["call cs:abort_ptr"], "succs": []}]}, {"name": "sub_5B90", "ea": 23440, "blocks": [{"id": 0, "start": 23440, "end": 23534, "lines": ["endbr64", "push rbp", "mov rax, rdx", "mov rbp, rsp", "sub rsp, 40h", "movdqa xmm0, cs:xmmword_C240", "mov rdx, fs:28h", "mov [rbp+var_8], rdx", "mov rdx, rcx", "mov rcx, cs:qword_C270", "movaps [rbp+var_40], xmm0", "movdqa xmm0, cs:xmmword_C250", "mov [rbp+var_10], rcx", "movaps [rbp+var_30], xmm0", "movdqa xmm0, cs:xmmword_C260", "mov dword ptr [rbp+var_40], 0Ah", "movaps [rbp+var_20], xmm0", "test rdi, rdi", "jz loc_2481"], "succs": [[1, "fall"], [5, "jump"]]}, {"id": 1, "start": 23534, "end": 23543, "lines": ["test rsi, rsi", "jz loc_2481"], "succs": [[2, "fall"], [5, "jump"]]}, {"id": 2, "start": 23543, "end": 23580, "lines": ["mov qword ptr [rbp+var_20+8], rdi", "lea rcx, [rbp+var_40]", "xor edi, edi", "mov [rbp+var_10], rsi", "mov rsi, rax", "call sub_4F70", "mov rdx, [rbp+var_8]", "sub rdx, fs:28h", "jnz short loc_5C1E"], "succs": [[3, "fall"], [4, "jump"]]}, {"id": 3, "start": 23580, "end": 23582, "lines": ["leave", "retn"], "succs": []}, {"id": 4, "start": 23582, "end": 23588, "lines": ["call cs:__stack_chk_fail_ptr"], "succs": []}, {"id": 5, "start": 9345, "end": 9351, "lines": ["call cs:abort_ptr"], "succs": []}]}, {"name": "sub_65A0", "ea": 26016, "blocks": [{"id": 0, "start": 26016, "end": 26038, "lines": ["endbr64", "push rbp", "mov rbp, rsp", "push r12", "mov r12, rsi", "push rbx", "mov rbx, [rsi]", "test rdi, rdi", "jz short loc_65E8"], "succs": [[1, "fall"], [4, "jump"]]}, {"id": 1, "start": 26038, "end": 26053, "lines": ["mov rax, rbx", "shr rax, 1", "add rax, 1", "add rbx, rax", "jb short loc_65F6"], "succs": [[2, "fall"], [5, "jump"]]}, {"id": 2, "start": 26053, "end": 26072, "lines": ["mov edx, 1", "mov rsi, rbx", "call sub_6FE0", "test rax, rax", "jz short loc_65F6"], "succs": [[3, "fall"], [5, "jump"]]}, {"id": 3, "start": 26072, "end": 26081, "lines": ["mov [r12], rbx", "pop rbx", "pop r12", "pop rbp", "retn"], "succs": []}, {"id": 4, "start": 26088, "end": 26102, "lines": ["test rbx, rbx", "mov eax, 80h", "cmovz rbx, rax", "jmp short loc_65C5"], "succs": [[2, "jump"]]}, {"id": 5, "start": 26102, "end": 26108, "lines": ["call sub_6900"], "succs": []}]}, {"name": "sub_2060", "ea": 8288, "blocks": [{"id": 0, "start": 8288, "end": 8335, "lines": ["push rbp", "mov edx, 400h; buflen", "mov rbp, rsp", "sub rsp, 410h", "mov rsi, fs:28h", "mov [rbp+var_8], rsi", "lea rsi, [rbp+buf]; buf", "call cs:strerror_r_ptr", "test rax, rax", "jnz short loc_20A8"], "succs": [[1, "fall"], [2, "jump"]]}, {"id": 1, "start": 8335, "end": 8360, "lines": ["mov edx, 5; category", "lea rsi, msgid; \"Unknown system error\"", "lea rdi, domainname; \"gnulib\"", "call cs:dcgettext_ptr"], "succs": [[2, "fall"]]}, {"id": 2, "start": 8360, "end": 8405, "lines": ["mov rcx, rax", "mov rdi, cs:stderr", "xor eax, eax", "lea rdx, aSS+2; \": %s\"", "mov esi, 2", "call cs:__fprintf_chk_ptr", "mov rax, [rbp+var_8]", "sub rax, fs:28h", "jz short locret_20DB"], "succs": [[3, "fall"], [4, "jump"]]}, {"id": 3, "start": 8405, "end": 8411, "lines": ["call cs:__stack_chk_fail_ptr"], "succs": []}, {"id": 4, "start": 8411, "end": 8413, "lines": ["leave", "retn"], "succs": []}]}, {"name": "sub_217F", "ea": 8575, "blocks": [{"id": 0, "start": 8575, "end": 8618, "lines": ["endbr64", "push rbp", "mov rbp, rsp", "push r14", "mov r14, rcx", "push r13", "mov r13, rdx", "push r12", "mov r12d, esi", "push rbx", "mov ebx, edi", "call sub_2155", "mov rax, cs:error_print_progname", "test rax, rax", "jz short loc_21AE"], "succs": [[1, "fall"], [2, "jump"]]}, {"id": 1, "start": 8618, "end": 8622, "lines": ["call rax ; error_print_progname", "jmp short loc_21D2"], "succs": [[3, "jump"]]}, {"id": 2, "start": 8622, "end": 8658, "lines": ["call sub_34F0", "mov rdi, cs:stderr", "mov esi, 2", "lea rdx, aS_0; \"%s: \"", "mov rcx, rax", "xor eax, eax", "call cs:__fprintf_chk_ptr"], "succs": [[3, "fall"]]}, {"id": 3, "start": 8658, "end": 8682, "lines": ["mov rcx, r14", "mov rdx, r13", "mov esi, r12d", "mov edi, ebx; status", "pop rbx", "pop r12", "pop r13", "pop r14", "pop rbp", "jmp sub_20DD"], "succs": [[4, "jump"]]}, {"id": 4, "start": 8413, "end": 8413, "lines": [], "succs": []}]}, {"name": "error", "ea": 8682, "blocks": [{"id": 0, "start": 8682, "end": 8722, "lines": ["endbr64", "push rbp", "mov rbp, rsp", "sub rsp, 0D0h", "mov [rbp+var_98], rcx", "mov [rbp+var_90], r8", "mov [rbp+var_88], r9", "test al, al", "jz short loc_2232"], "succs": [[1, "fall"], [2, "jump"]]}, {"id": 1, "start": 8722, "end": 8754, "lines": ["movaps [rbp+var_80], xmm0", "movaps [rbp+var_70], xmm1", "movaps [rbp+var_60], xmm2", "movaps [rbp+var_50], xmm3", "movaps [rbp+var_40], xmm4", "movaps [rbp+var_30], xmm5", "movaps [rbp+var_20], xmm6", "movaps [rbp+var_10], xmm7"], "succs": [[2, "fall"]]}, {"id": 2, "start": 8754, "end": 8847, "lines": ["mov rax, fs:28h", "mov [rbp+var_B8], rax", "xor eax, eax", "lea rax, [rbp+arg_0]", "lea rcx, [rbp+var_D0]", "mov [rbp+var_D0], 18h", "mov [rbp+var_C8], rax", "lea rax, [rbp+var_B0]", "mov [rbp+var_C0], rax", "mov [rbp+var_CC], 30h ; '0'", "call sub_217F", "mov rax, [rbp+var_B8]", "sub rax, fs:28h", "jz short locret_2295"], "succs": [[3, "fall"], [4, "jump"]]}, {"id": 3, "start": 8847, "end": 8853, "lines": ["call cs:__stack_chk_fail_ptr"], "succs": []}, {"id": 4, "start": 8853, "end": 8855, "lines": ["leave", "retn"], "succs": []}]}, {"name": "error_at_line", "ea": 9116, "blocks": [{"id": 0, "start": 9116, "end": 9142, "lines": ["endbr64", "push rbp", "mov rbp, rsp", "sub rsp, 0D0h", "mov [rbp+var_88], r9", "test al, al", "jz short loc_23D6"], "succs": [[1, "fall"], [2, "jump"]]}, {"id": 1, "start": 9142, "end": 9174, "lines": ["movaps [rbp+var_80], xmm0", "movaps [rbp+var_70], xmm1", "movaps [rbp+var_60], xmm2", "movaps [rbp+var_50], xmm3", "movaps [rbp+var_40], xmm4", "movaps [rbp+var_30], xmm5", "movaps [rbp+var_20], xmm6", "movaps [rbp+var_10], xmm7"], "succs": [[2, "fall"]]}, {"id": 2, "start": 9174, "end": 9267, "lines": ["mov rax, fs:28h", "mov [rbp+var_B8], rax", "xor eax, eax", "lea rax, [rbp+arg_0]", "lea r9, [rbp+var_D0]", "mov [rbp+var_D0], 28h ; '('", "mov [rbp+var_C8], rax", "lea rax, [rbp+var_B0]", "mov [rbp+var_C0], rax", "mov [rbp+var_CC], 30h ; '0'", "call sub_2297", "mov rax, [rbp+var_B8]", "sub rax, fs:28h", "jz short locret_2439"], "succs": [[3, "fall"], [4, "jump"]]}, {"id": 3, "start": 9267, "end": 9273, "lines": ["call cs:__stack_chk_fail_ptr"], "succs": []}, {"id": 4, "start": 9273, "end": 9275, "lines": ["leave", "retn"], "succs": []}]}, {"name": "sub_2B10", "ea": 11024, "blocks": [{"id": 0, "start": 11024, "end": 11043, "lines": ["lea rdi, unk_C0A8", "lea rax, unk_C0A8", "cmp rax, rdi", "jz short locret_2B38"], "succs": [[1, "fall"], [3, "jump"]]}, {"id": 1, "start": 11043, "end": 11055, "lines": ["mov rax, cs:_ITM_deregisterTMCloneTable_ptr", "test rax, rax", "jz short locret_2B38"], "succs": [[2, "fall"], [3, "jump"]]}, {"id": 2, "start": 11055, "end": 11057, "lines": ["jmp rax"], "succs": [[4, "jump"]]}, {"id": 3, "start": 11064, "end": 11065, "lines": ["retn"], "succs": []}, {"id": 4, "start": 50168, "end": 50168, "lines": [], "succs": []}]}, {"name": "sub_2B40", "ea": 11072, "blocks": [{"id": 0, "start": 11072, "end": 11108, "lines": ["lea rdi, unk_C0A8", "lea rsi, unk_C0A8", "sub rsi, rdi", "mov rax, rsi", "shr rsi, 3Fh", "sar rax, 3", "add rsi, rax", "sar rsi, 1", "jz short locret_2B78"], "succs": [[1, "fall"], [3, "jump"]]}, {"id": 1, "start": 11108, "end": 11120, "lines": ["mov rax, cs:_ITM_registerTMCloneTable_ptr", "test rax, rax", "jz short locret_2B78"], "succs": [[2, "fall"], [3, "jump"]]}, {"id": 2, "start": 11120, "end": 11122, "lines": ["jmp rax"], "succs": [[4, "jump"]]}, {"id": 3, "start": 11128, "end": 11129, "lines": ["retn"], "succs": []}, {"id": 4, "start": 50184, "end": 50184, "lines": [], "succs": []}]}, {"name": "sub_2B80", "ea": 11136, "blocks": [{"id": 0, "start": 11136, "end": 11149, "lines": ["endbr64", "cmp cs:byte_C0E8, 0", "jnz short locret_2BC0"], "succs": [[1, "fall"], [4, "jump"]]}, {"id": 1, "start": 11149, "end": 11163, "lines": ["push rbp", "cmp cs:__cxa_finalize_ptr, 0", "mov rbp, rsp", "jz short loc_2BA8"], "succs": [[2, "fall"], [3, "jump"]]}, {"id": 2, "start": 11163, "end": 11176, "lines": ["mov rdi, cs:lpdso_handle; void *", "call cs:__cxa_finalize_ptr"], "succs": [[3, "fall"]]}, {"id": 3, "start": 11176, "end": 11190, "lines": ["call sub_2B10", "mov cs:byte_C0E8, 1", "pop rbp", "retn"], "succs": []}, {"id": 4, "start": 11200, "end": 11201, "lines": ["retn"], "succs": []}]}, {"name": "sub_5520", "ea": 21792, "blocks": [{"id": 0, "start": 21792, "end": 21829, "lines": ["endbr64", "push rbp", "mov rbp, rsp", "sub rsp, 40h", "mov rax, fs:28h", "mov [rbp+var_8], rax", "mov rax, rdx", "cmp esi, 0Ah", "jz loc_2451"], "succs": [[1, "fall"], [4, "jump"]]}, {"id": 1, "start": 21829, "end": 21893, "lines": ["pxor xmm0, xmm0", "mov [rbp+var_40], esi", "lea rcx, [rbp+var_40]", "mov rdx, 0FFFFFFFFFFFFFFFFh", "movups [rbp+var_38], xmm0", "mov rsi, rax", "movups [rbp+var_28], xmm0", "pxor xmm0, xmm0", "mov [rbp+var_3C], 0", "movups [rbp+var_18], xmm0", "call sub_4F70", "mov rdx, [rbp+var_8]", "sub rdx, fs:28h", "jnz short loc_5587"], "succs": [[2, "fall"], [3, "jump"]]}, {"id": 2, "start": 21893, "end": 21895, "lines": ["leave", "retn"], "succs": []}, {"id": 3, "start": 21895, "end": 21901, "lines": ["call cs:__stack_chk_fail_ptr"], "succs": []}, {"id": 4, "start": 9297, "end": 9303, "lines": ["call cs:abort_ptr"], "succs": []}]}, {"name": "sub_5590", "ea": 21904, "blocks": [{"id": 0, "start": 21904, "end": 21944, "lines": ["endbr64", "push rbp", "mov rax, rdx", "mov rbp, rsp", "sub rsp, 40h", "mov rdx, fs:28h", "mov [rbp+var_8], rdx", "mov rdx, rcx", "cmp esi, 0Ah", "jz loc_2457"], "succs": [[1, "fall"], [4, "jump"]]}, {"id": 1, "start": 21944, "end": 22001, "lines": ["pxor xmm0, xmm0", "mov [rbp+var_40], esi", "lea rcx, [rbp+var_40]", "mov rsi, rax", "movups [rbp+var_38], xmm0", "movups [rbp+var_28], xmm0", "pxor xmm0, xmm0", "mov [rbp+var_3C], 0", "movups [rbp+var_18], xmm0", "call sub_4F70", "mov rdx, [rbp+var_8]", "sub rdx, fs:28h", "jnz short loc_55F3"], "succs": [[2, "fall"], [3, "jump"]]}, {"id": 2, "start": 22001, "end": 22003, "lines": ["leave", "retn"], "succs": []}, {"id": 3, "start": 22003, "end": 22009, "lines": ["call cs:__stack_chk_fail_ptr"], "succs": []}, {"id": 4, "start": 9303, "end": 9309, "lines": ["call cs:abort_ptr"], "succs": []}]}, {"name": "sub_5600", "ea": 22016, "blocks": [{"id": 0, "start": 22016, "end": 22052, "lines": ["endbr64", "push rbp", "mov rbp, rsp", "sub rsp, 40h", "mov rax, fs:28h", "mov [rbp+var_8], rax", "xor eax, eax", "cmp edi, 0Ah", "jz loc_245D"], "succs": [[1, "fall"], [4, "jump"]]}, {"id": 1, "start": 22052, "end": 22115, "lines": ["pxor xmm0, xmm0", "mov [rbp+var_40], edi", "lea rcx, [rbp+var_40]", "xor edi, edi", "movups [rbp+var_38], xmm0", "mov rdx, 0FFFFFFFFFFFFFFFFh", "movups [rbp+var_28], xmm0", "pxor xmm0, xmm0", "mov [rbp+var_3C], 0", "movups [rbp+var_18], xmm0", "call sub_4F70", "mov rdx, [rbp+var_8]", "sub rdx, fs:28h", "jnz short loc_5665"], "succs": [[2, "fall"], [3, "jump"]]}, {"id": 2, "start": 22115, "end": 22117, "lines": ["leave", "retn"], "succs": []}, {"id": 3, "start": 22117, "end": 22123, "lines": ["call cs:__stack_chk_fail_ptr"], "succs": []}, {"id": 4, "start": 9309, "end": 9315, "lines": ["call cs:abort_ptr"], "succs": []}]}, {"name": "sub_5670", "ea": 22128, "blocks": [{"id": 0, "start": 22128, "end": 22164, "lines": ["endbr64", "push rbp", "mov rbp, rsp", "sub rsp, 40h", "mov rax, fs:28h", "mov [rbp+var_8], rax", "xor eax, eax", "cmp edi, 0Ah", "jz loc_2463"], "succs": [[1, "fall"], [4, "jump"]]}, {"id": 1, "start": 22164, "end": 22220, "lines": ["pxor xmm0, xmm0", "mov [rbp+var_40], edi", "xor edi, edi", "lea rcx, [rbp+var_40]", "movups [rbp+var_38], xmm0", "movups [rbp+var_28], xmm0", "pxor xmm0, xmm0", "mov [rbp+var_3C], 0", "movups [rbp+var_18], xmm0", "call sub_4F70", "mov rdx, [rbp+var_8]", "sub rdx, fs:28h", "jnz short loc_56CE"], "succs": [[2, "fall"], [3, "jump"]]}, {"id": 2, "start": 22220, "end": 22222, "lines": ["leave", "retn"], "succs": []}, {"id": 3, "start": 22222, "end": 22228, "lines": ["call cs:__stack_chk_fail_ptr"], "succs": []}, {"id": 4, "start": 9315, "end": 9321, "lines": ["call cs:abort_ptr"], "succs": []}]}, {"name": "sub_5930", "ea": 22832, "blocks": [{"id": 0, "start": 22832, "end": 22869, "lines": ["endbr64", "push rbp", "mov rbp, rsp", "sub rsp, 40h", "mov rax, fs:28h", "mov [rbp+var_8], rax", "mov rax, rdx", "cmp esi, 0Ah", "jz loc_2469"], "succs": [[1, "fall"], [4, "jump"]]}, {"id": 1, "start": 22869, "end": 22941, "lines": ["pxor xmm0, xmm0", "mov [rbp+var_40], esi", "lea rcx, [rbp+var_40]", "mov rdx, 0FFFFFFFFFFFFFFFFh", "movups [rbp+var_18], xmm0", "movdqa xmm0, cs:xmmword_8F70", "mov rsi, rax", "mov [rbp+var_3C], 0", "movups [rbp+var_38], xmm0", "pxor xmm0, xmm0", "movups [rbp+var_28], xmm0", "call sub_4F70", "mov rdx, [rbp+var_8]", "sub rdx, fs:28h", "jnz short loc_599F"], "succs": [[2, "fall"], [3, "jump"]]}, {"id": 2, "start": 22941, "end": 22943, "lines": ["leave", "retn"], "succs": []}, {"id": 3, "start": 22943, "end": 22949, "lines": ["call cs:__stack_chk_fail_ptr"], "succs": []}, {"id": 4, "start": 9321, "end": 9327, "lines": ["call cs:abort_ptr"], "succs": []}]}, {"name": "sub_2155", "ea": 8533, "blocks": [{"id": 0, "start": 8533, "end": 8559, "lines": ["push rbp", "xor eax, eax", "mov esi, 3; cmd", "mov edi, 1; fd", "mov rbp, rsp", "call cs:fcntl_ptr", "test eax, eax", "js short loc_217D"], "succs": [[1, "fall"], [2, "jump"]]}, {"id": 1, "start": 8559, "end": 8573, "lines": ["mov rdi, cs:stdout; stream", "pop rbp", "jmp cs:fflush_unlocked_ptr"], "succs": [[3, "jump"]]}, {"id": 2, "start": 8573, "end": 8575, "lines": ["pop rbp", "retn"], "succs": []}, {"id": 3, "start": 50128, "end": 50128, "lines": [], "succs": []}]}, {"name": "sub_67E0", "ea": 26592, "blocks": [{"id": 0, "start": 26592, "end": 26623, "lines": ["endbr64", "push rbp", "mov rbp, rsp", "push r12", "mov r12, rdi", "mov rdi, rsi; size", "push rbx", "mov rbx, rsi", "call cs:malloc_ptr", "test rax, rax", "jz short loc_6815"], "succs": [[1, "fall"], [2, "jump"]]}, {"id": 1, "start": 26623, "end": 26645, "lines": ["mov rcx, rbx", "mov rdx, rbx", "mov rsi, r12", "pop rbx", "mov rdi, rax", "pop r12", "pop rbp", "jmp cs:__memcpy_chk_ptr"], "succs": [[3, "jump"]]}, {"id": 2, "start": 26645, "end": 26651, "lines": ["call sub_6900"], "succs": []}, {"id": 3, "start": 50032, "end": 50032, "lines": [], "succs": []}]}, {"name": "sub_6820", "ea": 26656, "blocks": [{"id": 0, "start": 26656, "end": 26687, "lines": ["endbr64", "push rbp", "mov rbp, rsp", "push r12", "mov r12, rdi", "mov rdi, rsi; size", "push rbx", "mov rbx, rsi", "call cs:malloc_ptr", "test rax, rax", "jz short loc_6855"], "succs": [[1, "fall"], [2, "jump"]]}, {"id": 1, "start": 26687, "end": 26709, "lines": ["mov rcx, rbx", "mov rdx, rbx", "mov rsi, r12", "pop rbx", "mov rdi, rax", "pop r12", "pop rbp", "jmp cs:__memcpy_chk_ptr"], "succs": [[3, "jump"]]}, {"id": 2, "start": 26709, "end": 26715, "lines": ["call sub_6900"], "succs": []}, {"id": 3, "start": 50032, "end": 50032, "lines": [], "succs": []}]}, {"name": "sub_6860", "ea": 26720, "blocks": [{"id": 0, "start": 26720, "end": 26758, "lines": ["endbr64", "push rbp", "mov rbp, rsp", "push r13", "mov r13, rdi", "lea rdi, [rsi+1]; size", "push r12", "push rbx", "mov rbx, rsi", "sub rsp, 8", "call cs:malloc_ptr", "test rax, rax", "jz short loc_68A7"], "succs": [[1, "fall"], [2, "jump"]]}, {"id": 1, "start": 26758, "end": 26791, "lines": ["mov byte ptr [rax+rbx], 0", "add rsp, 8", "lea rcx, [rbx+1]", "mov rdx, rbx", "mov rsi, r13", "pop rbx", "mov rdi, rax", "pop r12", "pop r13", "pop rbp", "jmp cs:__memcpy_chk_ptr"], "succs": [[3, "jump"]]}, {"id": 2, "start": 26791, "end": 26797, "lines": ["call sub_6900"], "succs": []}, {"id": 3, "start": 50032, "end": 50032, "lines": [], "succs": []}]}, {"name": "sub_68B0", "ea": 26800, "blocks": [{"id": 0, "start": 26800, "end": 26838, "lines": ["endbr64", "push rbp", "mov rbp, rsp", "push r12", "mov r12, rdi", "push rbx", "call cs:strlen_ptr", "lea rbx, [rax+1]", "mov rdi, rbx; size", "call cs:malloc_ptr", "test rax, rax", "jz short loc_68EC"], "succs": [[1, "fall"], [2, "jump"]]}, {"id": 1, "start": 26838, "end": 26860, "lines": ["mov rcx, rbx", "mov rdx, rbx", "mov rsi, r12", "pop rbx", "mov rdi, rax", "pop r12", "pop rbp", "jmp cs:__memcpy_chk_ptr"], "succs": [[3, "jump"]]}, {"id": 2, "start": 26860, "end": 26866, "lines": ["call sub_6900"], "succs": []}, {"id": 3, "start": 50032, "end": 50032, "lines": [], "succs": []}]}, {"name": "sub_6FE0", "ea": 28640, "blocks": [{"id": 0, "start": 28640, "end": 28652, "lines": ["endbr64", "mov rax, rsi", "mul rdx", "jo short loc_7001"], "succs": [[1, "fall"], [2, "jump"]]}, {"id": 1, "start": 28652, "end": 28673, "lines": ["mov rsi, rax", "test rax, rax", "mov eax, 1", "cmovz rsi, rax; size", "jmp cs:realloc_ptr"], "succs": [[3, "jump"]]}, {"id": 2, "start": 28673, "end": 28693, "lines": ["push rbp", "mov rbp, rsp", "call cs:__errno_location_ptr", "mov dword ptr [rax], 0Ch", "xor eax, eax", "pop rbp", "retn"], "succs": []}, {"id": 3, "start": 50080, "end": 50080, "lines": [], "succs": []}]}, {"name": ".init_proc", "ea": 8192, "blocks": [{"id": 0, "start": 8192, "end": 8212, "lines": ["endbr64", "sub rsp, 8", "mov rax, cs:__gmon_start___ptr", "test rax, rax", "jz short loc_2016"], "succs": [[1, "fall"], [2, "jump"]]}, {"id": 1, "start": 8212, "end": 8214, "lines": ["call rax ; __gmon_start__"], "succs": [[2, "fall"]]}, {"id": 2, "start": 8214, "end": 8219, "lines": ["add rsp, 8", "retn"], "succs": []}]}, {"name": "sub_5300", "ea": 21248, "blocks": [{"id": 0, "start": 21248, "end": 21455, "lines": ["endbr64", "push rbp", "lea rax, xmmword_C240", "mov rbp, rsp", "push r15", "mov r15, rsi", "push r14", "mov r14, rdi", "push r13", "push r12", "mov r12, rdx", "push rbx", "sub rsp, 28h", "test rcx, rcx", "cmovnz rax, rcx", "mov rbx, rax", "call cs:__errno_location_ptr", "xor r9d, r9d", "test r12, r12", "lea r10, [rbx+8]", "mov r13, rax", "mov eax, [rax]", "setz r9b", "sub rsp, 8", "or r9d, [rbx+4]", "mov r8d, [rbx]", "mov rcx, r15", "mov rdx, r14", "mov [rbp+var_34], eax", "xor esi, esi", "xor edi, edi", "push qword ptr [rbx+30h]", "push qword ptr [rbx+28h]", "push r10", "mov [rbp+var_48], r10", "mov [rbp+var_38], r9d", "call sub_3720", "add rsp, 20h", "lea rsi, [rax+1]", "mov [rbp+var_50], rax", "mov rdi, rsi", "mov [rbp+var_40], rsi", "call sub_6480", "mov r10, [rbp+var_48]", "sub rsp, 8", "mov r8d, [rbx]", "push qword ptr [rbx+30h]", "mov rsi, [rbp+var_40]", "mov rdi, rax", "mov rcx, r15", "mov r9d, [rbp+var_38]", "push qword ptr [rbx+28h]", "mov rdx, r14", "push r10", "mov [rbp+var_40], rax", "call sub_3720", "mov eax, [rbp+var_34]", "add rsp, 20h", "test r12, r12", "mov rdi, [rbp+var_40]", "mov [r13+0], eax", "jz short loc_53D7"], "succs": [[1, "fall"], [2, "jump"]]}, {"id": 1, "start": 21455, "end": 21463, "lines": ["mov r11, [rbp+var_50]", "mov [r12], r11"], "succs": [[2, "fall"]]}, {"id": 2, "start": 21463, "end": 21481, "lines": ["lea rsp, [rbp-28h]", "mov rax, rdi", "pop rbx", "pop r12", "pop r13", "pop r14", "pop r15", "pop rbp", "retn"], "succs": []}]}, {"name": "sub_56E0", "ea": 22240, "blocks": [{"id": 0, "start": 22240, "end": 22381, "lines": ["endbr64", "push rbp", "mov ecx, edx", "and ecx, 1Fh", "mov rbp, rsp", "sub rsp, 40h", "movdqa xmm0, cs:xmmword_C240", "mov rax, cs:qword_C270", "mov r8, fs:28h", "mov [rbp+var_8], r8", "mov r8, rsi", "mov [rbp+var_10], rax", "mov eax, edx", "movaps [rbp+var_40], xmm0", "movdqa xmm0, cs:xmmword_C250", "shr al, 5", "movzx eax, al", "movaps [rbp+var_30], xmm0", "movdqa xmm0, cs:xmmword_C260", "lea rsi, [rbp+rax*4+var_40+8]", "xor eax, eax", "movaps [rbp+var_20], xmm0", "mov edx, [rsi]", "bt edx, ecx", "setnb al", "shl eax, cl", "lea rcx, [rbp+var_40]", "xor eax, edx", "mov rdx, r8", "mov [rsi], eax", "mov rsi, rdi", "xor edi, edi", "call sub_4F70", "mov rdx, [rbp+var_8]", "sub rdx, fs:28h", "jnz short loc_576F"], "succs": [[1, "fall"], [2, "jump"]]}, {"id": 1, "start": 22381, "end": 22383, "lines": ["leave", "retn"], "succs": []}, {"id": 2, "start": 22383, "end": 22389, "lines": ["call cs:__stack_chk_fail_ptr"], "succs": []}]}, {"name": "sub_5780", "ea": 22400, "blocks": [{"id": 0, "start": 22400, "end": 22544, "lines": ["endbr64", "push rbp", "mov ecx, esi", "and ecx, 1Fh", "mov rbp, rsp", "sub rsp, 40h", "movdqa xmm0, cs:xmmword_C240", "mov rax, fs:28h", "mov [rbp+var_8], rax", "xor eax, eax", "mov rax, cs:qword_C270", "movaps [rbp+var_40], xmm0", "movdqa xmm0, cs:xmmword_C250", "mov [rbp+var_10], rax", "mov eax, esi", "shr al, 5", "movaps [rbp+var_30], xmm0", "movdqa xmm0, cs:xmmword_C260", "movzx eax, al", "lea rdx, [rbp+rax*4+var_40+8]", "movaps [rbp+var_20], xmm0", "xor eax, eax", "mov esi, [rdx]", "bt esi, ecx", "setnb al", "shl eax, cl", "lea rcx, [rbp+var_40]", "xor eax, esi", "mov rsi, rdi", "xor edi, edi", "mov [rdx], eax", "mov rdx, 0FFFFFFFFFFFFFFFFh", "call sub_4F70", "mov rdx, [rbp+var_8]", "sub rdx, fs:28h", "jnz short loc_5812"], "succs": [[1, "fall"], [2, "jump"]]}, {"id": 1, "start": 22544, "end": 22546, "lines": ["leave", "retn"], "succs": []}, {"id": 2, "start": 22546, "end": 22552, "lines": ["call cs:__stack_chk_fail_ptr"], "succs": []}]}, {"name": "sub_5820", "ea": 22560, "blocks": [{"id": 0, "start": 22560, "end": 22682, "lines": ["endbr64", "push rbp", "mov rdx, 0FFFFFFFFFFFFFFFFh", "mov rbp, rsp", "sub rsp, 40h", "movdqa xmm0, cs:xmmword_C240", "mov rax, cs:qword_C270", "mov rsi, fs:28h", "mov [rbp+var_8], rsi", "mov rsi, rdi", "lea rcx, [rbp+var_40]", "xor edi, edi", "movaps [rbp+var_40], xmm0", "movdqa xmm0, cs:xmmword_C250", "mov [rbp+var_10], rax", "mov eax, dword ptr cs:xmmword_C240+0Ch", "movaps [rbp+var_30], xmm0", "movdqa xmm0, cs:xmmword_C260", "or eax, 4000000h", "mov dword ptr [rbp+var_40+0Ch], eax", "movaps [rbp+var_20], xmm0", "call sub_4F70", "mov rdx, [rbp+var_8]", "sub rdx, fs:28h", "jnz short loc_589C"], "succs": [[1, "fall"], [2, "jump"]]}, {"id": 1, "start": 22682, "end": 22684, "lines": ["leave", "retn"], "succs": []}, {"id": 2, "start": 22684, "end": 22690, "lines": ["call cs:__stack_chk_fail_ptr"], "succs": []}]}, {"name": "sub_58B0", "ea": 22704, "blocks": [{"id": 0, "start": 22704, "end": 22822, "lines": ["endbr64", "push rbp", "mov rbp, rsp", "sub rsp, 40h", "movdqa xmm0, cs:xmmword_C240", "mov rax, cs:qword_C270", "mov rdx, fs:28h", "mov [rbp+var_8], rdx", "mov rdx, rsi", "lea rcx, [rbp+var_40]", "mov rsi, rdi", "xor edi, edi", "movaps [rbp+var_40], xmm0", "movdqa xmm0, cs:xmmword_C250", "mov [rbp+var_10], rax", "mov eax, dword ptr cs:xmmword_C240+0Ch", "movaps [rbp+var_30], xmm0", "movdqa xmm0, cs:xmmword_C260", "or eax, 4000000h", "mov dword ptr [rbp+var_40+0Ch], eax", "movaps [rbp+var_20], xmm0", "call sub_4F70", "mov rdx, [rbp+var_8]", "sub rdx, fs:28h", "jnz short loc_5928"], "succs": [[1, "fall"], [2, "jump"]]}, {"id": 1, "start": 22822, "end": 22824, "lines": ["leave", "retn"], "succs": []}, {"id": 2, "start": 22824, "end": 22830, "lines": ["call cs:__stack_chk_fail_ptr"], "succs": []}]}, {"name": "sub_6420", "ea": 25632, "blocks": [{"id": 0, "start": 25632, "end": 25651, "lines": ["endbr64", "push rbp", "mov rbp, rsp", "call sub_6FE0", "test rax, rax", "jz short loc_6435"], "succs": [[1, "fall"], [2, "jump"]]}, {"id": 1, "start": 25651, "end": 25653, "lines": ["pop rbp", "retn"], "succs": []}, {"id": 2, "start": 25653, "end": 25659, "lines": ["call sub_6900"], "succs": []}]}, {"name": "sub_6440", "ea": 25664, "blocks": [{"id": 0, "start": 25664, "end": 25683, "lines": ["endbr64", "push rbp", "mov rbp, rsp", "call cs:malloc_ptr", "test rax, rax", "jz short loc_6455"], "succs": [[1, "fall"], [2, "jump"]]}, {"id": 1, "start": 25683, "end": 25685, "lines": ["pop rbp", "retn"], "succs": []}, {"id": 2, "start": 25685, "end": 25691, "lines": ["call sub_6900"], "succs": []}]}, {"name": "sub_6460", "ea": 25696, "blocks": [{"id": 0, "start": 25696, "end": 25715, "lines": ["endbr64", "push rbp", "mov rbp, rsp", "call cs:malloc_ptr", "test rax, rax", "jz short loc_6475"], "succs": [[1, "fall"], [2, "jump"]]}, {"id": 1, "start": 25715, "end": 25717, "lines": ["pop rbp", "retn"], "succs": []}, {"id": 2, "start": 25717, "end": 25723, "lines": ["call sub_6900"], "succs": []}]}, {"name": "sub_6480", "ea": 25728, "blocks": [{"id": 0, "start": 25728, "end": 25747, "lines": ["endbr64", "push rbp", "mov rbp, rsp", "call cs:malloc_ptr", "test rax, rax", "jz short loc_6495"], "succs": [[1, "fall"], [2, "jump"]]}, {"id": 1, "start": 25747, "end": 25749, "lines": ["pop rbp", "retn"], "succs": []}, {"id": 2, "start": 25749, "end": 25755, "lines": ["call sub_6900"], "succs": []}]}, {"name": "sub_64A0", "ea": 25760, "blocks": [{"id": 0, "start": 25760, "end": 25791, "lines": ["endbr64", "push rbp", "test rsi, rsi", "mov eax, 1", "cmovz rsi, rax; size", "mov rbp, rsp", "call cs:realloc_ptr", "test rax, rax", "jz short loc_64C1"], "succs": [[1, "fall"], [2, "jump"]]}, {"id": 1, "start": 25791, "end": 25793, "lines": ["pop rbp", "retn"], "succs": []}, {"id": 2, "start": 25793, "end": 25799, "lines": ["call sub_6900"], "succs": []}]}, {"name": "sub_64D0", "ea": 25808, "blocks": [{"id": 0, "start": 25808, "end": 25839, "lines": ["endbr64", "push rbp", "test rsi, rsi", "mov eax, 1", "cmovz rsi, rax; size", "mov rbp, rsp", "call cs:realloc_ptr", "test rax, rax", "jz short loc_64F1"], "succs": [[1, "fall"], [2, "jump"]]}, {"id": 1, "start": 25839, "end": 25841, "lines": ["pop rbp", "retn"], "succs": []}, {"id": 2, "start": 25841, "end": 25847, "lines": ["call sub_6900"], "succs": []}]}, {"name": "sub_6500", "ea": 25856, "blocks": [{"id": 0, "start": 25856, "end": 25875, "lines": ["endbr64", "push rbp", "mov rbp, rsp", "call sub_6FE0", "test rax, rax", "jz short loc_6515"], "succs": [[1, "fall"], [2, "jump"]]}, {"id": 1, "start": 25875, "end": 25877, "lines": ["pop rbp", "retn"], "succs": []}, {"id": 2, "start": 25877, "end": 25883, "lines": ["call sub_6900"], "succs": []}]}, {"name": "sub_6520", "ea": 25888, "blocks": [{"id": 0, "start": 25888, "end": 25907, "lines": ["endbr64", "push rbp", "mov rbp, rsp", "call sub_6FE0", "test rax, rax", "jz short loc_6535"], "succs": [[1, "fall"], [2, "jump"]]}, {"id": 1, "start": 25907, "end": 25909, "lines": ["pop rbp", "retn"], "succs": []}, {"id": 2, "start": 25909, "end": 25915, "lines": ["call sub_6900"], "succs": []}]}, {"name": "sub_6540", "ea": 25920, "blocks": [{"id": 0, "start": 25920, "end": 25947, "lines": ["endbr64", "push rbp", "mov rdx, rsi", "mov rsi, rdi", "xor edi, edi", "mov rbp, rsp", "call sub_6FE0", "test rax, rax", "jz short loc_655D"], "succs": [[1, "fall"], [2, "jump"]]}, {"id": 1, "start": 25947, "end": 25949, "lines": ["pop rbp", "retn"], "succs": []}, {"id": 2, "start": 25949, "end": 25955, "lines": ["call sub_6900"], "succs": []}]}, {"name": "sub_6570", "ea": 25968, "blocks": [{"id": 0, "start": 25968, "end": 25995, "lines": ["endbr64", "push rbp", "mov rdx, rsi", "mov rsi, rdi", "xor edi, edi", "mov rbp, rsp", "call sub_6FE0", "test rax, rax", "jz short loc_658D"], "succs": [[1, "fall"], [2, "jump"]]}, {"id": 1, "start": 25995, "end": 25997, "lines": ["pop rbp", "retn"], "succs": []}, {"id": 2, "start": 25997, "end": 26003, "lines": ["call sub_6900"], "succs": []}]}, {"name": "sub_6760", "ea": 26464, "blocks": [{"id": 0, "start": 26464, "end": 26488, "lines": ["endbr64", "push rbp", "mov esi, 1; size", "mov rbp, rsp", "call cs:calloc_ptr", "test rax, rax", "jz short loc_677A"], "succs": [[1, "fall"], [2, "jump"]]}, {"id": 1, "start": 26488, "end": 26490, "lines": ["pop rbp", "retn"], "succs": []}, {"id": 2, "start": 26490, "end": 26496, "lines": ["call sub_6900"], "succs": []}]}, {"name": "sub_6780", "ea": 26496, "blocks": [{"id": 0, "start": 26496, "end": 26520, "lines": ["endbr64", "push rbp", "mov esi, 1; size", "mov rbp, rsp", "call cs:calloc_ptr", "test rax, rax", "jz short loc_679A"], "succs": [[1, "fall"], [2, "jump"]]}, {"id": 1, "start": 26520, "end": 26522, "lines": ["pop rbp", "retn"], "succs": []}, {"id": 2, "start": 26522, "end": 26528, "lines": ["call sub_6900"], "succs": []}]}, {"name": "sub_67A0", "ea": 26528, "blocks": [{"id": 0, "start": 26528, "end": 26547, "lines": ["endbr64", "push rbp", "mov rbp, rsp", "call cs:calloc_ptr", "test rax, rax", "jz short loc_67B5"], "succs": [[1, "fall"], [2, "jump"]]}, {"id": 1, "start": 26547, "end": 26549, "lines": ["pop rbp", "retn"], "succs": []}, {"id": 2, "start": 26549, "end": 26555, "lines": ["call sub_6900"], "succs": []}]}, {"name": "sub_67C0", "ea": 26560, "blocks": [{"id": 0, "start": 26560, "end": 26579, "lines": ["endbr64", "push rbp", "mov rbp, rsp", "call cs:calloc_ptr", "test rax, rax", "jz short loc_67D5"], "succs": [[1, "fall"], [2, "jump"]]}, {"id": 1, "start": 26579, "end": 26581, "lines": ["pop rbp", "retn"], "succs": []}, {"id": 2, "start": 26581, "end": 26587, "lines": ["call sub_6900"], "succs": []}]}, {"name": "sub_70A0", "ea": 28832, "blocks": [{"id": 0, "start": 28832, "end": 28866, "lines": ["endbr64", "push rbp", "mov edi, 0Eh; item", "mov rbp, rsp", "call nl_langinfo", "mov rdx, rax", "lea rax, aAscii; \"ASCII\"", "test rdx, rdx", "jz short loc_70C9"], "succs": [[1, "fall"], [2, "jump"]]}, {"id": 1, "start": 28866, "end": 28873, "lines": ["cmp byte ptr [rdx], 0", "cmovnz rax, rdx"], "succs": [[2, "fall"]]}, {"id": 2, "start": 28873, "end": 28875, "lines": ["pop rbp", "retn"], "succs": []}]}, {"name": "sub_2BD0", "ea": 11216, "blocks": [{"id": 0, "start": 11216, "end": 11225, "lines": ["endbr64", "jmp sub_2B40"], "succs": [[1, "jump"]]}, {"id": 1, "start": 11072, "end": 11072, "lines": [], "succs": []}]}, {"name": "sub_6360", "ea": 25440, "blocks": [{"id": 0, "start": 25440, "end": 25618, "lines": ["endbr64", "push rbp", "mov rsi, cs:stdout; stream", "mov edi, 0Ah; c", "mov rbp, rsp", "call cs:fputc_unlocked_ptr", "mov edx, 5; category", "lea rsi, aReportBugsToS; \"Report bugs to: %s\\n\"", "lea rdi, domainname; \"gnulib\"", "call cs:dcgettext_ptr", "lea rdx, aBugCoreutilsGn; \"bug-coreutils@gnu.org\"", "mov edi, 2", "mov rsi, rax", "xor eax, eax", "call cs:__printf_chk_ptr", "mov edx, 5; category", "lea rsi, aSHomePageS; \"%s home page: <%s>\\n\"", "lea rdi, domainname; \"gnulib\"", "call cs:dcgettext_ptr", "lea rcx, aHttpsWwwGnuOrg; \"https://www.gnu.org/software/coreutils/\"", "mov edi, 2", "lea rdx, aGnuCoreutils; \"GNU coreutils\"", "mov rsi, rax", "xor eax, eax", "call cs:__printf_chk_ptr", "mov edx, 5; category", "lea rsi, aGeneralHelpUsi; \"General help using GNU software: <%s>\\n\"", "lea rdi, domainname; \"gnulib\"", "call cs:dcgettext_ptr", "lea rdx, aHttpsWwwGnuOrg_1; \"https://www.gnu.org/gethelp/\"", "mov edi, 2", "pop rbp", "mov rsi, rax", "xor eax, eax", "jmp cs:__printf_chk_ptr"], "succs": [[1, "jump"]]}, {"id": 1, "start": 50096, "end": 50096, "lines": [], "succs": []}]}, {"name": "nl_langinfo", "ea": 28880, "blocks": [{"id": 0, "start": 28880, "end": 28890, "lines": ["endbr64", "jmp cs:nl_langinfo_ptr"], "succs": [[1, "jump"]]}, {"id": 1, "start": 50064, "end": 50064, "lines": [], "succs": []}]}, {"name": "sub_70E0", "ea": 28896, "blocks": [{"id": 0, "start": 28896, "end": 28905, "lines": ["endbr64", "jmp sub_7110"], "succs": [[1, "jump"]]}, {"id": 1, "start": 28944, "end": 28944, "lines": [], "succs": []}]}, {"name": "sub_71C0", "ea": 29120, "blocks": [{"id": 0, "start": 29120, "end": 29139, "lines": ["endbr64", "mov rdx, cs:lpdso_handle; lpdso_handle", "xor esi, esi; obj", "jmp cs:__cxa_atexit_ptr"], "succs": [[1, "jump"]]}, {"id": 1, "start": 50104, "end": 50104, "lines": [], "succs": []}]}, {"name": "sub_2447", "ea": 9287, "blocks": [{"id": 0, "start": 9287, "end": 9297, "lines": ["push rbp", "mov rbp, rsp", "call cs:abort_ptr"], "succs": []}]}, {"name": "start", "ea": 10976, "blocks": [{"id": 0, "start": 10976, "end": 11014, "lines": ["endbr64", "xor ebp, ebp", "mov r9, rdx; rtld_fini", "pop rsi; argc", "mov rdx, rsp; ubp_av", "and rsp, 0FFFFFFFFFFFFFFF0h", "push rax", "push rsp; stack_end", "xor r8d, r8d; fini", "xor ecx, ecx; init", "lea rdi, main; main", "call cs:__libc_start_main_ptr", "hlt"], "succs": []}]}, {"name": "sub_34F0", "ea": 13552, "blocks": [{"id": 0, "start": 13552, "end": 13564, "lines": ["endbr64", "mov rax, cs:__progname", "retn"], "succs": []}]}, {"name": "sub_5150", "ea": 20816, "blocks": [{"id": 0, "start": 20816, "end": 20891, "lines": ["endbr64", "push rbp", "mov rbp, rsp", "push r13", "push r12", "push rbx", "mov rbx, rdi", "sub rsp, 8", "call cs:__errno_location_ptr", "test rbx, rbx", "mov esi, 38h ; '8'", "mov r13d, [rax]", "mov r12, rax", "lea rax, xmmword_C240", "cmovnz rax, rbx", "mov rdi, rax", "call sub_67E0", "mov [r12], r13d", "add rsp, 8", "pop rbx", "pop r12", "pop r13", "pop rbp", "retn"], "succs": []}]}, {"name": "sub_5280", "ea": 21120, "blocks": [{"id": 0, "start": 21120, "end": 21241, "lines": ["endbr64", "push rbp", "lea rax, xmmword_C240", "mov rbp, rsp", "push r13", "push r12", "push rbx", "sub rsp, 28h", "test r8, r8", "mov [rbp+var_40], rdi", "cmovnz rax, r8", "mov [rbp+var_38], rsi", "mov [rbp+var_30], rdx", "mov rbx, rax", "mov [rbp+var_28], rcx", "call cs:__errno_location_ptr", "add rbx, 8", "sub rsp, 8", "mov r9d, [rbx-4]", "mov r13d, [rax]", "mov r8d, [rbx-8]", "push qword ptr [rbx+28h]", "mov r12, rax", "mov rcx, [rbp+var_28]", "push qword ptr [rbx+20h]", "mov rdx, [rbp+var_30]", "push rbx", "mov rsi, [rbp+var_38]", "mov rdi, [rbp+var_40]", "call sub_3720", "mov [r12], r13d", "lea rsp, [rbp-18h]", "pop rbx", "pop r12", "pop r13", "pop rbp", "retn"], "succs": []}]}, {"name": "sub_6900", "ea": 26880, "blocks": [{"id": 0, "start": 26880, "end": 26952, "lines": ["endbr64", "push rbp", "mov edx, 5; category", "lea rsi, aMemoryExhauste; \"memory exhausted\"", "lea rdi, domainname; \"gnulib\"", "mov rbp, rsp", "push rbx", "sub rsp, 8", "mov ebx, cs:status", "call cs:dcgettext_ptr", "lea rdx, aSS+4; \"%s\"", "xor esi, esi", "mov rcx, rax", "mov edi, ebx", "xor eax, eax", "call error", "call cs:abort_ptr"], "succs": []}]}, {"name": ".term_proc", "ea": 29140, "blocks": [{"id": 0, "start": 29140, "end": 29153, "lines": ["endbr64", "sub rsp, 8", "add rsp, 8", "retn"], "succs": []}]}, {"name": "getenv", "ea": 49792, "blocks": [{"id": 0, "start": 49792, "end": 49800, "lines": ["extrn getenv:near"], "succs": []}]}, {"name": "free", "ea": 49800, "blocks": [{"id": 0, "start": 49800, "end": 49808, "lines": ["extrn free:near"], "succs": []}]}, {"name": "__vfprintf_chk", "ea": 49808, "blocks": [{"id": 0, "start": 49808, "end": 49816, "lines": ["extrn __vfprintf_chk:near"], "succs": []}]}, {"name": "__libc_start_main", "ea": 49816, "blocks": [{"id": 0, "start": 49816, "end": 49824, "lines": ["extrn __libc_start_main:near"], "succs": []}]}, {"name": "abort", "ea": 49824, "blocks": [{"id": 0, "start": 49824, "end": 49832, "lines": ["extrn abort:near"], "succs": []}]}, {"name": "__errno_location", "ea": 49832, "blocks": [{"id": 0, "start": 49832, "end": 49840, "lines": ["extrn __errno_location:near"], "succs": []}]}, {"name": "strncmp", "ea": 49840, "blocks": [{"id": 0, "start": 49840, "end": 49848, "lines": ["extrn strncmp:near"], "succs": []}]}, {"name": "_exit", "ea": 49848, "blocks": [{"id": 0, "start": 49848, "end": 49856, "lines": ["extrn _exit:near"], "succs": []}]}, {"name": "__fpending", "ea": 49856, "blocks": [{"id": 0, "start": 49856, "end": 49864, "lines": ["extrn __fpending:near"], "succs": []}]}, {"name": "fcntl", "ea": 49864, "blocks": [{"id": 0, "start": 49864, "end": 49872, "lines": ["extrn fcntl:near"], "succs": []}]}, {"name": "textdomain", "ea": 49872, "blocks": [{"id": 0, "start": 49872, "end": 49880, "lines": ["extrn textdomain:near"], "succs": []}]}, {"name": "fclose", "ea": 49880, "blocks": [{"id": 0, "start": 49880, "end": 49888, "lines": ["extrn fclose:near"], "succs": []}]}, {"name": "bindtextdomain", "ea": 49888, "blocks": [{"id": 0, "start": 49888, "end": 49896, "lines": ["extrn bindtextdomain:near"], "succs": []}]}, {"name": "dcgettext", "ea": 49896, "blocks": [{"id": 0, "start": 49896, "end": 49904, "lines": ["extrn dcgettext:near"], "succs": []}]}, {"name": "__ctype_get_mb_cur_max", "ea": 49904, "blocks": [{"id": 0, "start": 49904, "end": 49912, "lines": ["extrn __ctype_get_mb_cur_max:near"], "succs": []}]}, {"name": "strlen", "ea": 49912, "blocks": [{"id": 0, "start": 49912, "end": 49920, "lines": ["extrn strlen:near"], "succs": []}]}, {"name": "__stack_chk_fail", "ea": 49920, "blocks": [{"id": 0, "start": 49920, "end": 49928, "lines": ["extrn __stack_chk_fail:near"], "succs": []}]}, {"name": "strchr", "ea": 49928, "blocks": [{"id": 0, "start": 49928, "end": 49936, "lines": ["extrn strchr:near"], "succs": []}]}, {"name": "__overflow", "ea": 49936, "blocks": [{"id": 0, "start": 49936, "end": 49944, "lines": ["extrn __overflow:near"], "succs": []}]}, {"name": "strrchr", "ea": 49944, "blocks": [{"id": 0, "start": 49944, "end": 49952, "lines": ["extrn strrchr:near"], "succs": []}]}, {"name": "__assert_fail", "ea": 49952, "blocks": [{"id": 0, "start": 49952, "end": 49960, "lines": ["extrn __assert_fail:near"], "succs": []}]}, {"name": "memset", "ea": 49960, "blocks": [{"id": 0, "start": 49960, "end": 49968, "lines": ["extrn memset:near"], "succs": []}]}, {"name": "mbrtoc32", "ea": 49968, "blocks": [{"id": 0, "start": 49968, "end": 49976, "lines": ["extrn mbrtoc32:near"], "succs": []}]}, {"name": "strspn", "ea": 49976, "blocks": [{"id": 0, "start": 49976, "end": 49984, "lines": ["extrn strspn:near"], "succs": []}]}, {"name": "strcspn", "ea": 49984, "blocks": [{"id": 0, "start": 49984, "end": 49992, "lines": ["extrn strcspn:near"], "succs": []}]}, {"name": "memcmp", "ea": 49992, "blocks": [{"id": 0, "start": 49992, "end": 50000, "lines": ["extrn memcmp:near"], "succs": []}]}, {"name": "fputs_unlocked", "ea": 50000, "blocks": [{"id": 0, "start": 50000, "end": 50008, "lines": ["extrn fputs_unlocked:near"], "succs": []}]}, {"name": "calloc", "ea": 50008, "blocks": [{"id": 0, "start": 50008, "end": 50016, "lines": ["extrn calloc:near"], "succs": []}]}, {"name": "strcmp", "ea": 50016, "blocks": [{"id": 0, "start": 50016, "end": 50024, "lines": ["extrn strcmp:near"], "succs": []}]}, {"name": "fputc_unlocked", "ea": 50024, "blocks": [{"id": 0, "start": 50024, "end": 50032, "lines": ["extrn fputc_unlocked:near"], "succs": []}]}, {"name": "__memcpy_chk", "ea": 50032, "blocks": [{"id": 0, "start": 50032, "end": 50040, "lines": ["extrn __memcpy_chk:near"], "succs": []}]}, {"name": "memcpy", "ea": 50040, "blocks": [{"id": 0, "start": 50040, "end": 50048, "lines": ["extrn memcpy:near"], "succs": []}]}, {"name": "strerror_r", "ea": 50048, "blocks": [{"id": 0, "start": 50048, "end": 50056, "lines": ["extrn strerror_r:near"], "succs": []}]}, {"name": "malloc", "ea": 50056, "blocks": [{"id": 0, "start": 50056, "end": 50064, "lines": ["extrn malloc:near"], "succs": []}]}, {"name": "__imp_nl_langinfo", "ea": 50064, "blocks": [{"id": 0, "start": 50064, "end": 50072, "lines": ["extrn __imp_nl_langinfo:near"], "succs": []}]}, {"name": "fwrite_unlocked", "ea": 50072, "blocks": [{"id": 0, "start": 50072, "end": 50080, "lines": ["extrn fwrite_unlocked:near"], "succs": []}]}, {"name": "realloc", "ea": 50080, "blocks": [{"id": 0, "start": 50080, "end": 50088, "lines": ["extrn realloc:near"], "succs": []}]}, {"name": "setlocale", "ea": 50088, "blocks": [{"id": 0, "start": 50088, "end": 50096, "lines": ["extrn setlocale:near"], "succs": []}]}, {"name": "__printf_chk", "ea": 50096, "blocks": [{"id": 0, "start": 50096, "end": 50104, "lines": ["extrn __printf_chk:near"], "succs": []}]}, {"name": "__cxa_atexit", "ea": 50104, "blocks": [{"id": 0, "start": 50104, "end": 50112, "lines": ["extrn __cxa_atexit:near"], "succs": []}]}, {"name": "exit", "ea": 50112, "blocks": [{"id": 0, "start": 50112, "end": 50120, "lines": ["extrn exit:near"], "succs": []}]}, {"name": "__fprintf_chk", "ea": 50120, "blocks": [{"id": 0, "start": 50120, "end": 50128, "lines": ["extrn __fprintf_chk:near"], "succs": []}]}, {"name": "fflush_unlocked", "ea": 50128, "blocks": [{"id": 0, "start": 50128, "end": 50136, "lines": ["extrn fflush_unlocked:near"], "succs": []}]}, {"name": "mbsinit", "ea": 50136, "blocks": [{"id": 0, "start": 50136, "end": 50144, "lines": ["extrn mbsinit:near"], "succs": []}]}, {"name": "iswprint", "ea": 50144, "blocks": [{"id": 0, "start": 50144, "end": 50152, "lines": ["extrn iswprint:near"], "succs": []}]}, {"name": "__cxa_finalize", "ea": 50152, "blocks": [{"id": 0, "start": 50152, "end": 50160, "lines": ["extrn __cxa_finalize:near ; weak"], "succs": []}]}, {"name": "__ctype_b_loc", "ea": 50160, "blocks": [{"id": 0, "start": 50160, "end": 50168, "lines": ["extrn __ctype_b_loc:near"], "succs": []}]}, {"name": "_ITM_deregisterTMCloneTable", "ea": 50168, "blocks": [{"id": 0, "start": 50168, "end": 50176, "lines": ["extrn _ITM_deregisterTMCloneTable:near ; weak"], "succs": []}]}, {"name": "__gmon_start__", "ea": 50176, "blocks": [{"id": 0, "start": 50176, "end": 50184, "lines": ["extrn __gmon_start__:near ; weak"], "succs": []}]}, {"name": "_ITM_registerTMCloneTable", "ea": 50184, "blocks": [{"id": 0, "start": 50184, "end": 50192, "lines": ["extrn _ITM_registerTMCloneTable:near ; weak"], "succs": []}]}] \ No newline at end of file
diff --git a/.auto/check_edit.py b/.auto/check_edit.py
deleted file mode 100644
index 2472c84..0000000
--- a/.auto/check_edit.py
+++ /dev/null
@@ -1,126 +0,0 @@
-#!/usr/bin/env python3
-"""Correctness gate for the listing after an item edit (run by .auto/checks.sh).
-
-An item edit (`c`/`d`/`u`/`p`) changes structure, but only locally: every head
-in front of it keeps its address and its row number. So `Program.bump_items(ea)`
-keeps the walk up to there instead of discarding the model — worth 257x on a big
-binary (4.9s to make one byte into data, against 19ms).
-
-Keeping *anything* across a structural edit is the risky half of that, and it
-fails silently: the pane shows rows that are no longer what the database says.
-So this drives real edits and compares the kept model against one built from
-scratch, row for row — narrow reads (what painting does) and wide ones (what
-building the search body does).
-
-The staged database is a throwaway copy and is never saved, so the edits here do
-not need undoing and can be as destructive as they like.
-
- ~/ida-venv/bin/python .auto/check_edit.py [targets/echo]
-"""
-from __future__ import annotations
-
-import os
-import shutil
-import sys
-
-HERE = os.path.dirname(os.path.abspath(__file__))
-ROOT = os.path.dirname(HERE)
-sys.path.insert(0, ROOT)
-sys.path.insert(0, HERE)
-
-from bench import stage # noqa: E402
-from idatui.domain import Program # noqa: E402
-from idatui.worker_client import WorkerClient # noqa: E402
-
-
-def snapshot(model, total: int, wide: bool):
- if wide:
- rows = []
- for base in range(0, total, 4096):
- rows.extend(model.window(base, min(4096, total - base)))
- else:
- rows = [model.get(i) for i in range(total)]
- return [(h.ea, h.kind, h.size, h.text, h.name) if h else None for h in rows]
-
-
-def main() -> int:
- target = sys.argv[1] if len(sys.argv) > 1 else "targets/echo"
- d, path = stage(os.path.join(ROOT, target))
- fails: list[str] = []
- client = WorkerClient(path)
- try:
- prog = Program(client)
- idx = prog.functions()
- idx.load_all()
- funcs = sorted(idx.all_loaded(), key=lambda f: f.addr)
- if len(funcs) < 8:
- print(f"{target}: too few functions to check")
- return 1
- seg = funcs[len(funcs) // 2].addr
- model = prog.listing(seg)
- model.load_all()
- total = len(model)
-
- # Edits at a spread of positions: near the end (where truncation keeps
- # nearly everything), the middle, and early on (where it must give up).
- spots = [funcs[int(len(funcs) * f)].addr for f in (0.9, 0.5, 0.05)]
- for k, ea in enumerate(spots):
- # Undefine, at three sizes. This is the edit that can coalesce
- # BACKWARDS into the undefined run in front of it, which is the
- # reason truncate_from drops two pages rather than one.
- size = (1, 4, 16)[k % 3]
- kind = f"undefine {size}B"
- try:
- prog.undefine(ea, size)
- except Exception as e: # noqa: BLE001
- fails.append(f"{kind} at {ea:#x} failed: {e}")
- continue
- prog.bump_items(ea)
-
- kept = prog.listing(seg)
- kept_total = None
- got_wide = None
- if kept is not None:
- kept.load_all()
- kept_total = len(kept)
- got_wide = snapshot(kept, kept_total, wide=True)
- got_narrow = snapshot(kept, kept_total, wide=False)
-
- # ... against a model that knows nothing about what came before.
- prog._listings.clear()
- fresh = prog.listing(seg)
- fresh.load_all()
- want = snapshot(fresh, len(fresh), wide=False)
-
- if kept_total != len(fresh):
- fails.append(f"{kind} at {ea:#x}: kept model has {kept_total} "
- f"rows, a rebuild has {len(fresh)}")
- elif got_narrow != want or got_wide != want:
- which = "narrow" if got_narrow != want else "wide"
- bad = next((i for i, (a, b) in
- enumerate(zip(got_narrow if which == "narrow"
- else got_wide, want)) if a != b), None)
- fails.append(
- f"{kind} at {ea:#x}: {which} read differs from a rebuild at "
- f"row {bad}: {(got_narrow if which == 'narrow' else got_wide)[bad]}"
- f" vs {want[bad]}")
-
- model = prog.listing(seg)
- model.load_all()
- total = len(model)
-
- print(f"item edits: {len(spots)} edits checked against a rebuild "
- f"({total} rows), {len(fails)} problems")
- for f in fails:
- print(" FAIL", f)
- return 1 if fails else 0
- finally:
- try:
- client.close()
- except Exception: # noqa: BLE001
- pass
- shutil.rmtree(d, ignore_errors=True)
-
-
-if __name__ == "__main__":
- sys.exit(main())
diff --git a/.auto/check_rename.py b/.auto/check_rename.py
deleted file mode 100644
index c617aa6..0000000
--- a/.auto/check_rename.py
+++ /dev/null
@@ -1,120 +0,0 @@
-#!/usr/bin/env python3
-"""Correctness gate for the listing's rename handling (run by .auto/checks.sh).
-
-A rename does not move any listing row, so ``Program.bump_names`` keeps the
-segment's walk and only marks the rendered text stale; ``ListingModel`` re-renders
-a block at a time as rows are read. That is worth 500x on a big binary (the
-alternative re-walks the whole segment to find a row the cursor was already on),
-and it is exactly the kind of optimisation that fails *quietly*: the pane keeps
-showing the old name and nothing errors.
-
-Two things are checked, because they exercise different paths and only the first
-was ever caught by accident:
-
-* a NARROW read (``get`` per row, what painting does), and
-* a WIDE read (``window`` over thousands of rows, what search's body build does)
-
-both have to come back with the new name -- and the whole model has to match one
-built from scratch, row for row.
-
- ~/ida-venv/bin/python .auto/check_rename.py [targets/echo]
-"""
-from __future__ import annotations
-
-import os
-import shutil
-import sys
-
-HERE = os.path.dirname(os.path.abspath(__file__))
-ROOT = os.path.dirname(HERE)
-sys.path.insert(0, ROOT)
-sys.path.insert(0, HERE)
-
-from bench import stage # noqa: E402
-from idatui.domain import Program # noqa: E402
-from idatui.worker_client import WorkerClient # noqa: E402
-
-
-def rename(client, ea: int, name: str) -> None:
- client.call("rename", batch={"func": [{"addr": hex(ea), "name": name}]})
-
-
-def main() -> int:
- target = sys.argv[1] if len(sys.argv) > 1 else "targets/echo"
- d, path = stage(os.path.join(ROOT, target))
- fails: list[str] = []
- client = WorkerClient(path)
- try:
- prog = Program(client)
- idx = prog.functions()
- idx.load_all()
- funcs = idx.all_loaded()
- if len(funcs) < 4:
- print(f"{target}: too few functions to check")
- return 1
- seg = sorted(funcs, key=lambda f: f.addr)[len(funcs) // 2].addr
- model = prog.listing(seg)
- model.load_all()
- total = len(model)
-
- for k, victim in enumerate(sorted(funcs, key=lambda f: -f.size)[:2]):
- new = f"_check_rename_{os.getpid()}_{k}"
- rename(client, victim.addr, new)
- prog.bump_names()
-
- # WIDE read -- what building the search body does. This is the one
- # that used to come back with the old names: an oversized refetch
- # overflowed the heads tool's row cap, failed its sequence check and
- # left the block untouched.
- wide = []
- for base in range(0, total, 4096):
- wide.extend(model.window(base, min(4096, total - base)))
- # "Is the old name gone" is not a sound test -- `main` is a token of
- # `__libc_start_main` and can legitimately appear in a comment or a
- # string. That the NEW name arrived proves the refresh ran; that the
- # model matches a rebuild, below, proves it ran correctly.
- shown = sum(1 for h in wide if h is not None
- and (new in (h.text or "") or new == (h.name or "")))
- if not shown:
- fails.append(f"wide read after renaming {victim.name} -> {new}: "
- f"no row shows the new name")
-
- # NARROW read -- what painting does -- and the whole model against a
- # rebuild, row for row.
- kept = [(h.ea, h.kind, h.text, h.name) if h else None
- for h in (model.get(i) for i in range(total))]
- prog._listings.clear()
- fresh_model = prog.listing(seg)
- fresh_model.load_all()
- fresh = [(h.ea, h.kind, h.text, h.name) if h else None
- for h in (fresh_model.get(i) for i in range(len(fresh_model)))]
- if kept != fresh:
- bad = next((i for i, (a, b) in enumerate(zip(kept, fresh))
- if a != b), None)
- fails.append(f"kept model != rebuilt model after renaming "
- f"{victim.name}: first difference at row {bad}: "
- f"{kept[bad] if bad is not None else None} vs "
- f"{fresh[bad] if bad is not None else None}")
-
- rename(client, victim.addr, victim.name)
- prog.bump_names()
- prog._listings.clear()
- model = prog.listing(seg)
- model.load_all()
- total = len(model)
-
- print(f"rename handling: {total} rows checked wide and narrow, "
- f"{len(fails)} problems")
- for f in fails:
- print(" FAIL", f)
- return 1 if fails else 0
- finally:
- try:
- client.close()
- except Exception: # noqa: BLE001
- pass
- shutil.rmtree(d, ignore_errors=True)
-
-
-if __name__ == "__main__":
- sys.exit(main())
diff --git a/.auto/check_search.py b/.auto/check_search.py
deleted file mode 100644
index 74ce719..0000000
--- a/.auto/check_search.py
+++ /dev/null
@@ -1,148 +0,0 @@
-#!/usr/bin/env python3
-"""Correctness gate for the search fast paths (run by .auto/checks.sh).
-
-`SearchMixin` grew two optimisations that are invisible to the scenario suite
-because they produce the *same answer* when they work:
-
-* **prefix narrowing** — typing a character onto the term rescans only the
- previous hits, because a line holding "mov" holds "mo";
-* **the joined haystack** — the whole body is concatenated once so a term is
- found with a C-level `str.find` walk instead of a python loop over every row.
-
-Both are cache-shaped, so the way they break is *staleness*, not a crash. This
-drives the real `ListingView` and `DecompView` and asserts that, for every
-prefix of a set of terms, the fast path returns exactly the matches and
-highlight ranges the plain per-line loop does — including after the things that
-are meant to invalidate them (ending a search, toggling the opcode column,
-navigating).
-
- ~/ida-venv/bin/python .auto/check_search.py [targets/echo]
-"""
-from __future__ import annotations
-
-import asyncio
-import os
-import shutil
-import sys
-
-HERE = os.path.dirname(os.path.abspath(__file__))
-ROOT = os.path.dirname(HERE)
-sys.path.insert(0, ROOT)
-sys.path.insert(0, os.path.join(ROOT, "tests"))
-sys.path.insert(0, HERE)
-
-from bench import stage # noqa: E402
-from idatui._sync import wait_for # noqa: E402
-from idatui.app import DecompView, IdaTui, ListingView # noqa: E402
-
-TERMS = ("mov", "call", "rsp", "Mov", "1a", "push", "e", "lea", "sub_", "0",
- " ", "if", "v1", "]")
-
-
-def _reference(view, term: str):
- """(matches, ranges) from the plain per-line loop, with every cache off."""
- cls = view.__class__
- saved = cls._search_haystack
- cls._search_haystack = lambda self, c, s: None
- try:
- view._reset_search_cache(body=True)
- view._term = term
- view._ci = term.islower()
- view._compute_matches()
- return (list(view._matches),
- {k: list(v) for k, v in view._ranges.items()})
- finally:
- cls._search_haystack = saved
- view._reset_search_cache(body=True)
-
-
-def _fast(view, term: str):
- view._term = term
- view._ci = term.islower()
- view._compute_matches()
- return (list(view._matches), {k: list(v) for k, v in view._ranges.items()})
-
-
-def _check(view, name: str, fails: list) -> int:
- """Type every prefix of every term; compare the fast path to the loop."""
- checked = 0
- for word in TERMS:
- view.search_begin(1)
- for i in range(1, len(word) + 1):
- term = word[:i]
- got = _fast(view, term) # may narrow from the previous term
- want = _reference(view, term)
- checked += 1
- if got != want:
- a, b = set(got[0]), set(want[0])
- fails.append(
- f"{name} {term!r}: fast={len(got[0])} loop={len(want[0])} "
- f"only-fast={sorted(a - b)[:4]} only-loop={sorted(b - a)[:4]}")
- view.search_cancel()
- return checked
-
-
-async def main() -> int:
- target = sys.argv[1] if len(sys.argv) > 1 else "targets/echo"
- fails: list[str] = []
- d, path = stage(os.path.join(ROOT, target))
- app = IdaTui(open_path=path, keepalive=False)
- try:
- async with app.run_test(size=(140, 44)) as pilot:
- async def w(pred, t=300.0):
- return await wait_for(pred, pilot.pause, t, 0.02)
-
- if not await w(lambda: app._func_index is not None
- and app._func_index.complete):
- fails.append("boot: function index never completed")
- return 1
- await w(lambda: app._loading_screen is None
- and len(app.screen_stack) == 1, 120)
- funcs = app._func_index.all_loaded()
- lst = app.query_one(ListingView)
- fn = funcs[min(5, len(funcs) - 1)]
- app._open_function(fn.addr, fn.name)
- await w(lambda: lst.total > 0 and lst._cursor_ea() == fn.addr, 120)
- lst.model.load_all()
- lst.total = len(lst.model)
-
- n = _check(lst, "listing", fails)
-
- # The opcode-bytes column is searchable text and changes width
- # without changing the row count or the model -- the one thing the
- # haystack's key cannot see.
- app.action_toggle_view.__self__ # noqa: B018 - keep app referenced
- lst.action_toggle_opcodes()
- n += _check(lst, "listing/opcodes", fails)
- lst.action_toggle_opcodes()
-
- # Navigating inside the same segment must NOT invalidate the body,
- # and must not leave it stale either.
- other = funcs[min(9, len(funcs) - 1)]
- app._open_function(other.addr, other.name)
- await w(lambda: lst.total > 0
- and lst._cursor_ea() == other.addr, 120)
- lst.model.load_all()
- lst.total = len(lst.model)
- n += _check(lst, "listing/after-nav", fails)
-
- # The pseudocode view uses a different line source.
- app._active = "listing"
- app._show_active()
- lst.focus()
- await pilot.pause(0.05)
- app.action_toggle_view()
- dv = app.query_one(DecompView)
- if await w(lambda: dv.total > 0, 60):
- n += _check(dv, "decomp", fails)
- app.exit()
- print(f"search fast paths: {n} prefixes checked, {len(fails)} mismatches")
- for f in fails[:10]:
- print(" FAIL", f)
- return 1 if fails else 0
- finally:
- shutil.rmtree(d, ignore_errors=True)
-
-
-if __name__ == "__main__":
- sys.exit(asyncio.run(main()))
diff --git a/.auto/checks.sh b/.auto/checks.sh
deleted file mode 100755
index 4b9778a..0000000
--- a/.auto/checks.sh
+++ /dev/null
@@ -1,81 +0,0 @@
-#!/bin/bash
-# Correctness gate: no perf win is allowed to cost functionality.
-#
-# 1. .auto/check_search.py -- the search fast paths against the plain loop.
-# Caches that go stale still return AN answer, so no scenario test can see
-# them; this compares fast and slow directly, for every typed prefix.
-# 2. .auto/check_rename.py -- a rename keeps the listing's walk and only
-# re-renders its text. When that goes wrong the pane simply keeps showing
-# the old name, which nothing else notices. Checks a NARROW read (what
-# painting does) and a WIDE one (what building the search body does), and
-# the whole model against a rebuild.
-# 3. .auto/check_edit.py -- the listing after an ITEM edit. bump_items(ea)
-# keeps the walk in front of the edit rather than discarding it; keeping
-# anything across a structural change is the risky half and it fails
-# silently, so the kept model is compared against a rebuild row for row.
-# 4. tests/run.py -- the project's own front door: every suite, pure and IDA,
-# ~185s. It used to be only the scenario suite here, and that gap cost a
-# real regression: a faster worker connect left the loading overlay up a
-# moment longer relative to the index finishing, and project mode's first
-# keypress landed on the overlay. Only test_project_ui.py covers that, and
-# it was not being run.
-#
-# Only failures reach stdout: the agent sees the last 80 lines on failure, and a
-# wall of "ok" would push the actual break out of view. A file that fails is
-# re-run ALONE before it counts -- the IDA suites share a loaded box, and a
-# worker that got CPU-starved mid-analysis reads as a failure but is not one
-# (see the note in tests/run.py).
-set -euo pipefail
-cd "$(dirname "$0")/.."
-
-PY="${IDATUI_PYTHON:-$HOME/ida-venv/bin/python}"
-
-search=$("$PY" .auto/check_search.py targets/echo 2>&1) || {
- echo "--- search fast paths disagree with the plain loop ---"
- echo "$search" | tail -20
- exit 1
-}
-echo "$search" | tail -1
-
-rn=$("$PY" .auto/check_rename.py targets/echo 2>&1) || {
- echo "--- the listing is wrong after a rename ---"
- echo "$rn" | tail -20
- exit 1
-}
-echo "$rn" | tail -1
-
-ed=$("$PY" .auto/check_edit.py targets/echo 2>&1) || {
- echo "--- the listing is wrong after an item edit ---"
- echo "$ed" | tail -20
- exit 1
-}
-echo "$ed" | tail -1
-
-out=$(python3 tests/run.py 2>&1) || true
-echo "$out" | tail -2
-
-# Files run.py marked bad, e.g. " FAIL scenarios 301 passed, 2 failed"
-# run.py's summary says "N passed[, M failed]..."; no "failed" clause == green.
-if ! echo "$out" | tail -3 | grep -q "failed"; then
- exit 0
-fi
-
-echo "--- first pass failures ---"
-echo "$out" | grep -E "^ FAIL" | head -20
-# run.py names them itself: "failing files: formats scenarios" (colourised).
-files=$(echo "$out" | sed -e 's/\x1b\[[0-9;]*m//g' \
- | sed -n 's/^failing files: *//p' | tr '\n' ' ')
-if [ -z "$files" ]; then
- echo "--- could not identify the failing file; full tail ---"
- echo "$out" | tail -30
- exit 1
-fi
-echo "--- retrying alone: $files ---"
-retry=$(python3 tests/run.py $files 2>&1) || true
-if echo "$retry" | tail -3 | grep -q "failed"; then
- echo "--- REAL regression ---"
- echo "$retry" | grep -E "^ FAIL" | head -30
- echo "$retry" | tail -3
- exit 1
-fi
-echo "flake: [$files] pass in isolation"
diff --git a/.auto/diff_spans.py b/.auto/diff_spans.py
deleted file mode 100644
index aad4ebc..0000000
--- a/.auto/diff_spans.py
+++ /dev/null
@@ -1,116 +0,0 @@
-#!/usr/bin/env python3
-"""Differential check: the current `_idatui_spans` vs the one at a git ref.
-
-The span walker turns IDA's colour-tagged disassembly line into (spans, ops).
-It is on the hot path of every listing row, so it is worth optimising -- but its
-output drives highlighting, operand marking and the cursor's column arithmetic,
-so "faster" is only acceptable if it is byte-identical.
-
-This pulls both implementations out of `server/patch_server.py` (the current
-working tree, and whatever `--ref` names), runs them over every tagged line of
-a real binary, and reports the first disagreement.
-
- /usr/bin/python3 .auto/diff_spans.py [--ref HEAD] [--target targets/bash]
- [--limit 60000]
-"""
-from __future__ import annotations
-
-import argparse
-import os
-import shutil
-import subprocess
-import sys
-import tempfile
-
-ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
-
-
-def load_impl(path: str, name: str):
- """Exec just the span-walker section of a patch_server.py's injected BODY.
-
- BODY is a normal (non-raw) triple-quoted string, so the escapes in it are
- resolved once by importing the module -- slicing the file text instead would
- compile the *undecoded* source and silently test a different program (an
- escaped backslash became a literal one, and every 's' in a comment turned
- into a space).
- """
- import importlib.util
- spec = importlib.util.spec_from_file_location(f"_ps_{name}", path)
- mod = importlib.util.module_from_spec(spec)
- spec.loader.exec_module(mod) # IDA-free at import time
- body = mod.BODY
- a = body.index("def _idatui_head_row")
- b = body.index("def _idatui_struct_member_rows")
- g = {"__name__": name}
- exec(compile(body[a:b], name, "exec"), g) # noqa: S102
- return g
-
-
-def main() -> int:
- ap = argparse.ArgumentParser()
- ap.add_argument("--ref", default="HEAD")
- ap.add_argument("--target", default="targets/bash")
- ap.add_argument("--limit", type=int, default=60000)
- a = ap.parse_args()
-
- d = tempfile.mkdtemp(prefix="diffspans-")
- old_path = os.path.join(d, "patch_server_old.py")
- with open(old_path, "w") as fh:
- fh.write(subprocess.run(
- ["git", "-C", ROOT, "show", f"{a.ref}:server/patch_server.py"],
- capture_output=True, text=True, check=True).stdout)
- gnew = load_impl(os.path.join(ROOT, "server", "patch_server.py"), "new")
- gold = load_impl(old_path, "old")
- new, old = gnew["_idatui_spans"], gold["_idatui_spans"]
- new_row, old_row = gnew["_idatui_head_row"], gold["_idatui_head_row"]
-
- binary = os.path.join(ROOT, a.target)
- tgt = os.path.join(d, os.path.basename(binary))
- shutil.copy2(binary, tgt)
- shutil.copy2(binary + ".i64", tgt + ".i64")
- try:
- import idapro
- if idapro.open_database(tgt, run_auto_analysis=False) != 0:
- raise SystemExit("could not open database")
- import ida_bytes
- import ida_lines
- import ida_segment
- import idaapi
-
- checked = bad = 0
- for si in range(ida_segment.get_segm_qty()):
- seg = ida_segment.getnseg(si)
- if seg is None:
- continue
- ea = seg.start_ea
- while ea < seg.end_ea and checked < a.limit:
- line = ida_lines.generate_disasm_line(ea, 0)
- if line:
- checked += 1
- ra, rb = old(line), new(line)
- if ra != rb:
- bad += 1
- if bad <= 3:
- print(f"SPAN MISMATCH @ {ea:#x}\n line={line!r}\n"
- f" old={ra!r}\n new={rb!r}")
- # The whole row, not just the spans: `text`, the spans/text
- # agreement guard and the name all moved around too.
- ra, rb = old_row(ea), new_row(ea)
- if ra != rb:
- bad += 1
- if bad <= 3:
- print(f"ROW MISMATCH @ {ea:#x}\n"
- f" old={ra!r}\n new={rb!r}")
- nxt = ida_bytes.get_item_end(ea)
- ea = nxt if nxt > ea else ea + 1
- if checked >= a.limit:
- break
- print(f"checked {checked} lines, {bad} mismatches")
- idapro.close_database(False)
- return 1 if bad else 0
- finally:
- shutil.rmtree(d, ignore_errors=True)
-
-
-if __name__ == "__main__":
- sys.exit(main())
diff --git a/.auto/ideas.md b/.auto/ideas.md
deleted file mode 100644
index 133993c..0000000
--- a/.auto/ideas.md
+++ /dev/null
@@ -1,331 +0,0 @@
-# Ideas backlog
-
-## Perf, not yet tried
-
-- **Skeleton walk for `ensure_ea`** — navigation only needs the *row index* of an
- address, yet `ListingModel` walks the segment loading fully-rendered rows.
- **Costed, and it only nets ~5%**: a text-free walk would be ~3 µs/row instead
- of ~23, but search then has to fetch the text anyway (it builds its haystack
- from `_line_plain`), so most of the saving moves rather than disappears.
- Worth it only if the goal changes from "total session time" to "no single
- foreground wait over 200 ms" — which is arguably the better goal for a TUI.
-- **`_grow` should start from where the user is**, not sweep from the segment
- start, so a jump into the middle doesn't wait behind everything before it.
-- **Prefetch the decompilation of adjacent/called functions** while the user
- reads the current one. The worker is idle then and `Program.decompile` caches.
-- **Persist the worker's `_idatui_line_parts` cache** — it is warm only within
- one worker, and the same binary is reopened constantly during a session.
-
-## Bugs found while optimising (not perf work)
-
-- **`idatui/graph.py` edge routing is non-deterministic.** Laying out the *same*
- function twice with the *unchanged* engine gives different
- `painting.vruns` for 71 of 128 corpus functions. Node placement is stable;
- only the routing moves. So a graph redraws differently when you reopen it, and
- any old-vs-new painting diff is worthless as a regression test (old-vs-old
- fails it too). Find the set/dict iteration or `id()`-keyed order behind it.
-- **Sticky graph mode makes a keypress ambiguous.** With `_graph_sticky` on, a
- navigation schedules the next function's graph asynchronously; until it lands
- the app is in the listing. So `space` right after a jump either enters or
- leaves the graph depending on which won. `graph_minimap` was silently relying
- on losing that race. A fix would be to enter graph mode immediately, with a
- loading state, when a sticky navigation starts.
-- **`domain.decomp_map` costs ~280 ms per function** — more than the decompile
- itself — and is on the split-view (`s`) path, which the bench doesn't cover.
-
-## Verification patterns that worked (reuse them)
-
-- **Differential against a git ref.** `.auto/diff_spans.py` loads the *current*
- and a *past* `server/patch_server.py`, execs the same slice of each `BODY`,
- and compares outputs over every disassembly line of a real binary. Catch:
- `BODY` is a normal triple-quoted string, so you must import the module and
- read `mod.BODY` — slicing the file text tests an undecoded program.
-- **Compare what reaches the screen, not the data structure.** Merging Segments
- is *supposed* to change the segments; expand both to `(char, style)` per cell
- and compare that (`/tmp/hexeq.py`, `/tmp/gveq.py`).
-- **Check the old code against itself first.** The graph routing diff looked
- like a regression until old-vs-old failed identically.
-- **`.auto/check_search.py`** is the permanent version of this idea and runs in
- `checks.sh`: it compares the search fast paths against the plain per-line loop
- for every typed prefix. Cache staleness returns a *plausible wrong answer*,
- which no scenario test can catch.
-- The bench's `NOTES` counters (`search_hits`, `graph_blocks`, `decomp_ok`,
- `render_cells`, `nav_rows`, `listing_cells`) are the standing guard against
- "faster because it did less".
-
-## Two "predict what changed" schemes, both measured and rejected
-
-Both would have made a rename nearly free. Both fail for the same reason: **IDA
-and Hex-Rays drift on their own**, so "what the edit changed" is not the same
-question as "what is different now".
-
-- **Listing rows, predicted from `xrefs_to` + the function's extent.**
- `/tmp/whatchanges.py` rebuilds the segment before and after and diffs every
- row. echo: 19/19 changed rows covered. ls_ttl: 53/54 — the miss was
- `lea rcx, unk_1D7A0` → `byte_1D7A0`, which the rename did not cause; IDA's own
- analysis defined that byte.
-- **Decompilations, predicted from "the old name appears in the cached text".**
- `/tmp/decchanges.py` decompiles 25 functions, renames one, recompiles all and
- diffs. **16 misses over 4 renames, every one of them Hex-Rays' type inference
- moving** — e.g. `unsigned __int64 f(..., unsigned int a4)` → `..., int a4)` in
- functions with no connection to the rename.
-
-The lesson generalises: predicting the effect of an edit on a database that has
-its own opinions is unsound. Verify instead — `heads(digest=True)` works because
-it asks what the row renders as *now*, not what should have changed.
-
-(This also explains the `lg_decomp_lines` drift blamed on CPU starvation in v5
-#4: it is probably the same Hex-Rays instability.)
-
-## Added late in the session
-
-- ~~**Refresh only the rows a rename actually changed** (xrefs-driven).~~
- **MEASURED AND REJECTED.** `/tmp/whatchanges.py` rebuilds the whole segment
- before and after a rename and diffs every row. echo: all 19 changed rows over
- 4 renames were covered by (function extent + `xrefs_to`). ls_ttl: 53 of 54 —
- and the one that was not, `lea rcx, unk_1D7A0` → `byte_1D7A0`, **was not
- caused by the rename at all**: IDA's own analysis defined that byte. Any
- address-predicted invalidation leaves such a row stale for good. Superseded by
- the digest scheme, which is exact because it looks at the rendered line.
-- **Features the bench still doesn't drive end to end**, in the order they seem
- worth probing: xrefs (`x`), the strings browser (`"`), literal formats (`o`),
- make-code/data/function edits, history (`back`), execution traces, the RPC
- layer. Use the `/tmp/featprobe.py` shape: call the domain API for each and look
- for a number that is absurd for the work done. That is how the flowchart hull,
- `decomp_map` and the rename walk were all found.
-
-## The UI is much slower while a big segment streams (measured, not fixed)
-
-Opening a big binary leaves `ListingView._grow` streaming the segment in the
-background for ~7s. During that window every UI action is several times slower:
-an xrefs dialog measured **691ms while streaming against 105ms after** on bash.
-Two mechanisms, both confirmed:
-
-* **`@work(exclusive=True)` does not stop a thread worker that is already
- running**, and navigating inside the same segment re-primes against the SAME
- model — so every jump leaves another streamer behind, all queueing on the
- model's load lock. Five jumps into bash meant five streamers.
-* Each streamer reports growth to the UI every four pages; a report is a thread
- hop, a `virtual_size` change and a full repaint.
-
-Two fixes were tried and **both measured worse**, so neither was kept:
-a time-based report throttle (10/s) made it worse because the count is per
-*streamer* and there are several; adding a token so only the newest streamer
-survives did not reduce the report count either, which means the retirement is
-not happening where it looks like it should — worth understanding before trying
-again. Probe: `/tmp/streamresp.py` (navigate + press `x` repeatedly while
-`lst.model.complete` is still False, counting `_grew` calls).
-
-Caveat: these numbers were taken on a loaded box and the probe re-primes the
-view on every iteration, which is itself what spawns the extra streamers. Build
-a cleaner probe first.
-
-## Pre-existing crash, unrelated to performance
-
-`StringsPalette.on_mount` calls `self._apply("")`, which does
-`query_one(OptionList)` before `compose`'s children are mounted:
-`NoMatches: No nodes match 'OptionList' on StringsPalette()`. Reproduces 3/3 on
-`targets/bash` with `/tmp/strcrash.py`, **and 3/3 on the pre-autoresearch commit
-2b0ae8d** — so it is not something this work introduced. `ProjectPalette` has the
-same shape and the same latent race.
-
-## Trace memory reads scale with trace LENGTH (measured, not fixed)
-
-`idatui/trace.py` loads linearly (36.6 / 73.3 / 143.9 / 280.7 ms for 20k / 40k /
-80k / 160k rows — x1.95 per doubling, exactly right) and `register_state` is
-effectively O(1). But `Trace.memory(addr, length, idx)` costs 16.3 / 31.7 / 63.4
-/ 125.8 ms for 200 calls over those same traces: **linear in trace length per
-call.**
-
-`_mem_index` sorts accesses by address and bisects to the window, which is the
-right idea — but it then iterates *every* access in that address window across
-all time, filtering by `t > idx`. A hot stack slot in a loop is written once per
-iteration, so the stack pane's cost grows with how long the trace ran. On a
-10M-instruction trace a single step could scan millions of entries.
-
-The fix is to find, per byte, the latest access with `t <= idx` rather than
-scanning them all. The sort is already stable, so entries within one address are
-in time order — but accesses have variable length and overlap, so grouping is
-not trivial. Probe: `/tmp/traceprof.py`.
-
-**Do not attempt this until `tests/test_trace_vs_tenet.py` can run** — it is the
-differential against Tenet's own reference reader and it is currently skipped
-here, which leaves `tests/test_trace.py`'s 35 synthetic checks as the only guard
-on a subtle indexing change.
-
-## A bench phase for item edits hangs (attempted, reverted)
-
-`bump_items(ea)` keeping the listing's walk is worth 257x (4890ms → 19ms on
-bash) but is **not visible in total_ms**, because the bench has no item-edit
-phase. One was written and reverted: driving `undefine` from inside the pilot
-hangs the run (no output, killed at the timeout), while the identical sequence
-against `Program` directly is fine, and the same sequence with a `print` between
-`prog.listing(ea)` and `ensure_ea` is also fine.
-
-**That explanation was wrong** — corrected by a stack dump
-(`faulthandler.dump_traceback_later`, `/tmp/hangdiag2.py`). At the moment of the
-hang there are **no idatui threads at all**: the main thread is idle in
-`selectors.select()` and the only others are idle asyncio executor threads. The
-app is stuck *before* `app.run_test()` even returns — nothing to do with
-`bump_items`, `_prime`/`_grow`, or `_load_lock`.
-
-It is **pilot start-up flakiness**, and it is partly environmental: several
-orphaned `idatui/worker.py` processes had accumulated from runs killed by
-`timeout`, and clearing them (`pkill -f idatui/worker.py`) made the next run
-boot fine — but it recurred afterwards, so that is not the whole story. Not the
-kitty-graphics query either (`IDATUI_KITTY=0` still hangs).
-
-Two things to take from it: **kill stray workers between probe runs**, and a
-bench phase should not be built on this until app start-up under the pilot is
-reliable. The item-edit win is carried by `/tmp/itemedit.py` (direct
-measurement) and `.auto/check_edit.py` (correctness, in the gate).
-
-## decomp_map: what is actually left (measured, corrects run #30)
-
-Run #30 said "what is left in `decomp_map` is `ida_hexrays.decompile`, which
-duplicates the decompile the view already did". **That is wrong.** A warm
-`ida_hexrays.decompile()` is **0.01 ms** (`/tmp/hxcache.py`) — Hex-Rays' own
-cache is free, and the duplicate costs nothing.
-
-The sweep's real split, over 15 417 lines of bash (`/tmp/sweepprof.py`):
-
-| part | cost | calls |
-|---|---|---|
-| `dstr()` | **2 581 ms (79%)** | 106 594 @ 24.2 µs |
-| `get_line_item` | 691 ms | 445 337 @ 1.55 µs |
-| `tag_remove` for the length | 14 ms | 15 417 |
-
-Fixed by memoising `obj_id -> ea` for the whole function (v7 #44). What remains
-is `get_line_item` per column, which is a real probe per screen column.
-
-Ideas for the remainder, in order of appeal:
-
-- **Map only the lines the split view can show.** The pane paints ~40 lines but
- the map is built for all 3 486. This is the same "compute on demand" shape
- that won for search highlight ranges (v5 #6). It needs a windowed tool
- (`first`/`count`) and a lazy container, because `app.py` and `trace_ctl.py`
- both index the whole list.
-- Stepping over columns instead of probing each one is **not** safe: an item
- occupying one or two columns (a single-character variable) would be skipped
- entirely, silently dropping an EA from the region highlight.
-
-## Re-printing instead of re-decompiling after a rename (measured, NOT applied)
-
-`cfunc.refresh_func_ctext()` on a cached ctree is **46x faster** than the
-recompile a rename currently forces (31 ms vs 1 432 ms for ten functions), and
-it is arguably what a user expects: only the name changes.
-
-**Not applied, because it changes what is on screen.** Only 2 of 10 functions
-re-printed to the same text as a real recompile; the other eight differ by
-Hex-Rays *type inference*: `char *` vs `const char *`, `__int64` vs
-`signed __int64`, `unsigned int a4` vs `int a4`. Same drift already recorded
-above — a full recompile re-runs inference with more accumulated knowledge, so
-the two disagree even where the rename is irrelevant. Choosing the stabler text
-is a product decision about what the pseudocode pane should show, not a
-performance change, so it needs a human call. Probe: `/tmp/reprint.py`.
-
-Also checked and **not** a bug: the split view's text and its `decomp_map` do
-come from the same ctree. `Program.decompile` calls the `force_recompile` tool
-(which does exist) before refetching, so the tool's plain
-`ida_hexrays.decompile()` repopulates the cache and `decomp_map` then hits it.
-`/tmp/mapalign.py` appeared to show a mismatch only because the probe itself
-used `DECOMP_NO_CACHE`, which the app never does.
-
-## PARKED: a 3.6x faster decompile_function_safe (measured, byte-identical, discarded on the metric)
-
-ida-pro-mcp's `decompile_function_safe` — the function that produces the
-pseudocode the pane shows — has the **same three faults** that were fixed in
-`decomp_map`:
-
-* it allocates **three** `ctree_item_t` SWIG objects per pseudocode line, and
- `_head` and `_tail` are never read (`get_line_item` takes None for both);
-* it calls `dstr()` per line to recover the `/*0xEA*/` marker — 24 µs a call —
- where consecutive lines of a multi-line expression report the same ctree item,
- so memoising by `obj_id` (unique within a cfunc) skips most of them.
-
-Measured, on a **warm** cfunc (so this is pure post-processing, no Hex-Rays):
-
-| workload | before | after |
-|---|---|---|
-| bash's 8 largest, 18 991 lines (`/tmp/decprof.py`) | 2 302 ms (121.2 µs/line) | 429 ms (22.6 µs/line) |
-| the same through the real worker (`/tmp/verifybind.py`) | 2 695 ms | 746 ms |
-| echo's 12 largest, 3 298 lines (`/tmp/splitprof.py`) | 219 ms | 120 ms (30 µs/line) |
-
-Byte-identical: `check_decomp.py` runs both implementations against the same
-cfunc with `include_addresses` both ways — **128/128 echo and 372/372 ls_ttl**.
-It is a real gate: keying the memo on `it.op` instead of `it.obj_id` fails 69 of
-128.
-
-**Discarded anyway**, because it does not move `total_ms`. Three runs with it
-(25 367 / 25 058 / 25 171) against three without (24 514 / 25 122 / 25 783) —
-the means are 25 199 vs 25 140, i.e. indistinguishable. The reason is in the
-third row of the table: the saving is 98 µs/line on bash's *largest* functions
-but only 30 µs/line on small ones, and the bench's fixed set averages 286 lines
-a function. Expected effect ~150–250 ms against a run-to-run spread of ±400–600
-on this box.
-
-**It is still a real win for the operation a user waits on** — an F5 on a
-2 374-line function drops 287 ms → 54 ms of post-processing — so it is parked
-rather than deleted:
-
-* `.auto/parked/fast_decompile.patch` (applies to `server/patch_server.py` and
- `idatui/worker.py`)
-* `.auto/parked/check_decomp.py` (re-wire into `checks.sh` if the patch is
- re-applied; it will crash if run without it, since it slices the function out
- of `BODY`)
-
-Re-apply it if the benchmark ever decompiles large functions, or if the goal
-moves from total session time to per-operation latency. Do **not** re-shape the
-bench's fixture set to make this win visible — that would be fitting the
-benchmark to the change.
-
-## PARKED: pc_nums, the third instance of the same bug (2.03x, byte-identical)
-
-`_idatui_pc_nums` allocated **three `ctree_item_t` SWIG objects per candidate
-column** — inside a scan that probes every literal-looking character of every
-pseudocode line. `a`–`f` are hex digits, so `a1`, `v6` and `sub_1F4C0` all
-qualify and most columns of a line get probed. `head` and `tail` were never
-read. This is the same fault as `decomp_map`'s sweep and
-`decompile_function_safe`'s loop — **three instances of one bug**.
-
-It is on the F5 path: `app.py:_load_decomp` fetches `pc_nums` after every
-successful decompile so the view can mark the literal under the cursor without a
-round trip per keypress.
-
-Measured warm (Hex-Rays already cached), bash's 8 largest, 18 991 lines:
-**1 247 ms → 614 ms (2.03x), 66 → 32 µs/line**, with the literal count identical
-(7 015). Also stopped `tag_remove` running twice over every line (`pc_nums` and
-`_idatui_pc_nums` each called it); worth ~nothing on its own but it is strictly
-less work.
-
-Equivalence: the tool's whole output dumped per function and compared across
-revisions (`/tmp/pcnumdump.py`, cross-process — an in-process differential
-**segfaults**, two SWIG item objects over one cfunc). echo: 128 functions,
-1 463 literals, **0 mismatches**. `tests/test_scenarios.py` also drives literal
-cycling through these exact column extents.
-
-Parked at `.auto/parked/fast_pc_nums.patch` for the same reason as the decompile
-patch: real work removed, but ~170 ms against a ±500 ms run-to-run spread.
-
-## Both parked patches, measured together
-
-Applied together (they are both on the F5 path) over four runs: 25 367 / 25 058 /
-25 171 / 24 585, against three without: 24 514 / 25 122 / 25 783. Means **25 045
-with, 25 140 without** — a 95 ms edge inside a 500 ms spread, i.e. still not
-resolvable. Discarded on the metric, kept on disk.
-
-Apply both if the goal moves to per-operation latency: an F5 on a 2 374-line
-function loses ~233 ms of text post-processing and halves its `pc_nums` cost.
-
-## A 27 283 ms outlier, and how to recognise one
-
-One run came back at 27 283 (against ~24 500) with `lg_split` 2 200 → 6 917 and
-`lg_search` 3 441 → 840. It was NOT the change under test: the **work counters
-moved with it** — `lg_decomp_lines` 3 436 → 3 233, `lg_split_mapped_lines`
-2 070 → 1 993, `lg_search_hits` 91 783 → 92 733. Hex-Rays decompiled bash
-differently that run (the drift documented above), which changed how much of the
-post-rename re-render the split phase absorbed before search got to it. The next
-run reproduced 24 585 with every counter back to its usual value.
-
-**The NOTES counters are what tell an outlier from a regression.** A real
-regression moves the time and leaves the work alone.
diff --git a/.auto/log.jsonl b/.auto/log.jsonl
deleted file mode 100644
index 1b24d9e..0000000
--- a/.auto/log.jsonl
+++ /dev/null
@@ -1,53 +0,0 @@
-{"type":"config","name":"ida-tui performance: cut the latency of navigation, listing, decomp, graph and search","metricName":"total_ms","metricUnit":"ms","bestDirection":"lower"}
-{"run":1,"commit":"2910c93","metric":46572.1,"metrics":{"lg_boot_ms":863.5,"lg_decomp_ms":2881.3,"lg_graph_ms":951.7,"lg_hex_ms":900.9,"lg_index_ms":219.3,"lg_listing_cold_ms":434.4,"lg_listing_warm_ms":552.3,"lg_nav_ms":29106.9,"lg_palette_ms":4.8,"lg_render_ms":231.2,"lg_search_ms":5590.1,"pure_graph_ms":236.2,"sm_boot_ms":539.4,"sm_decomp_ms":621.4,"sm_graph_ms":702,"sm_hex_ms":850.5,"sm_index_ms":0,"sm_listing_cold_ms":270.8,"sm_listing_warm_ms":270.6,"sm_nav_ms":875.1,"sm_palette_ms":0.3,"sm_render_ms":272.3,"sm_search_ms":197.1,"fails":0},"status":"checks_failed","description":"Baseline run of the new bench harness. Benchmark clean (fails=0) but the pilot scenario suite reported 300 passed / 1 failed with ZERO code changes -> flaky, and checks.sh printed the tail instead of the FAIL line so the name is unknown.","timestamp":1786058161497,"segment":0,"confidence":null,"asi":{"hypothesis":"establish a baseline for total_ms","bottleneck":"lg_nav_ms=29107 is 62% of total_ms; lg_nav_worst_ms=28274 is ONE cold jump to a high address in bash. ListingModel.ensure_ea walks the segment forward in 500-head pages from seg_start, so landing near the end of a 224k-row listing costs ~450 sequential worker round trips.","second_bottleneck":"lg_search_ms=5590 (91783 hits over the whole segment)","cheap_phases":"palette/index/render/pure_graph are all <600ms; not where the time is","rollback_reason":"checks.sh flagged 1 scenario failure with no code change (flake)","next_action_hint":"make checks.sh print the FAIL line name on non-zero exit, re-run baseline, then attack ListingModel address->row lookup (needs a backend primitive in server/patch_server.py: heads walking anchored at an address, or a segment head-index built in one call)"}}
-{"run":2,"commit":"a3f3400","metric":46685.5,"metrics":{"lg_boot_ms":885.7,"lg_decomp_ms":2871.8,"lg_graph_ms":930.9,"lg_hex_ms":947.3,"lg_index_ms":207.8,"lg_listing_cold_ms":564.3,"lg_listing_warm_ms":454.7,"lg_nav_ms":29018.5,"lg_palette_ms":4.6,"lg_render_ms":223.6,"lg_search_ms":5448.7,"pure_graph_ms":535.4,"sm_boot_ms":537.9,"sm_decomp_ms":643.8,"sm_graph_ms":680.4,"sm_hex_ms":854.9,"sm_index_ms":0,"sm_listing_cold_ms":265.5,"sm_listing_warm_ms":263.7,"sm_nav_ms":887.2,"sm_palette_ms":0.3,"sm_render_ms":264.1,"sm_search_ms":194.5,"fails":0},"status":"discard","description":"Baseline re-run with the fixed checks gate. Checks pass; total_ms reproduces to within 0.24% of run #1 (46572 -> 46686), so the noise floor is ~115ms on a 46.6s metric.","timestamp":1786058370297,"segment":0,"confidence":null,"asi":{"hypothesis":"confirm the baseline is reproducible and the checks gate is green","noise_floor_ms":115,"reproducibility":"run1 46572 / run2 46686 -> 0.24% spread; pure_graph_ms is the jumpiest single phase (236 -> 535, it is CPU-only and gets descheduled)","checks":"flaky-scenario retry logic works; suite green on a clean tree","next_action_hint":"attack ListingModel.ensure_ea / the heads tool: 29s of 46.6s is one cold address->row walk over bash"}}
-{"run":3,"commit":"93240e2","metric":26923.9,"metrics":{"lg_boot_ms":762.2,"lg_decomp_ms":2631.7,"lg_graph_ms":941.8,"lg_hex_ms":1052,"lg_index_ms":67.2,"lg_listing_cold_ms":440.6,"lg_listing_warm_ms":530.5,"lg_nav_ms":10598.3,"lg_palette_ms":4.6,"lg_render_ms":227.8,"lg_search_ms":5265.5,"pure_graph_ms":237.9,"sm_boot_ms":535.3,"sm_decomp_ms":631.8,"sm_graph_ms":702.1,"sm_hex_ms":841.1,"sm_index_ms":0,"sm_listing_cold_ms":268.2,"sm_listing_warm_ms":269.4,"sm_nav_ms":443.6,"sm_palette_ms":0.3,"sm_render_ms":277.8,"sm_search_ms":194.4,"fails":0},"status":"keep","description":"Stop ida-pro-mcp installing a sys.setprofile hook around every tool call. Its deadline mechanism profiles every python call/return so a pure-python tool loop can be interrupted; our tools are call-heavy, so it taxed the whole backend 3.3x. Worker now sets IDA_MCP_TOOL_TIMEOUT_SEC=0 and arms the deadline itself with one polling watchdog thread + ida_kernwin.set_cancelled() (the half that actually frees the IDA main thread). Also rewrote _idatui_spans to jump between colour tags instead of walking characters (byte-identical over 258k real lines).","timestamp":1786059117542,"segment":0,"confidence":173.264550264548,"asi":{"hypothesis":"the heads tool is not IDA-bound; the ida-pro-mcp sync wrapper's sys.setprofile deadline is the tax","evidence":"in-process A/B on targets/bash: heads(count=500,annotate) 92.2us/row with IDA_MCP_TOOL_TIMEOUT_SEC unset vs 27.5us/row with it 0; domain ListingModel._load_next_page 116 -> 37 us/row","where_it_lives":"site-packages/ida_pro_mcp/ida_mcp/sync.py sync_wrapper() installs profilefunc via sys.setprofile whenever timeout>0; default _DEFAULT_TOOL_TIMEOUT_SEC=60","what_we_kept":"the native half: threading watchdog + ida_kernwin.set_cancelled(), which is what decompile/auto_wait/find_bytes actually poll. Only the interruption of a pure-python loop inside a tool body is gone, and every idatui tool loop is bounded by its count argument (<=2000).","watchdog_design":"polling at 0.25s instead of signalling, because arm/disarm is on the path of EVERY worker call (a scroll is dozens); arming is two attribute writes + clr_cancelled()","spans_rewrite":"_idatui_spans now re.finditer's over the 3 control chars instead of walking characters. Verified byte-identical vs HEAD over 258k real disasm lines across echo/ls_ttl/bash/libcrypto (.auto/diff_spans.py). Standalone it measured 12.5 -> 13.6 us/line i.e. slightly SLOWER, so it is probably a small net loss that the setprofile win is hiding -- A/B it on its own next.","gains":"total 46572 -> 26924 (-42%); lg_nav 29107 -> 10598; lg_nav_worst 28274 -> 10170; sm_nav_worst 656 -> 224; lg_index 219 -> 67","work_unchanged":"nav_rows/decomp_ok/graph_ok/search_hits/render_cells all identical -> the speedup is not less work","next_action_hint":"1) A/B the _idatui_spans rewrite alone (suspect regression). 2) lg_nav is still 10.6s: ListingModel.ensure_ea is O(distance) at ~34us/row; a skeleton (ea,size,kind only) walk or a bigger PAGE would cut it further. 3) lg_search_ms 5265 is now the #2 term."}}
-{"run":4,"commit":"b6a95ec","metric":26050.2,"metrics":{"lg_boot_ms":727.4,"lg_decomp_ms":2618.1,"lg_graph_ms":935.1,"lg_hex_ms":954.2,"lg_index_ms":67.4,"lg_listing_cold_ms":459.3,"lg_listing_warm_ms":545.7,"lg_nav_ms":9257.8,"lg_palette_ms":4.7,"lg_render_ms":238.8,"lg_search_ms":5472.5,"pure_graph_ms":530.7,"sm_boot_ms":534.6,"sm_decomp_ms":635.8,"sm_graph_ms":748.1,"sm_hex_ms":858.7,"sm_index_ms":0,"sm_listing_cold_ms":268.7,"sm_listing_warm_ms":269.2,"sm_nav_ms":441.6,"sm_palette_ms":0.3,"sm_render_ms":281.7,"sm_search_ms":200,"fails":0},"status":"keep","description":"_idatui_spans: one capturing re.split over the tag pairs instead of finditer+char-slicing, and collapse whitespace with ' '.join(txt.split()) instead of a regex sub. 13.15 -> 11.07 us/line (the previous finditer attempt was 14.4, i.e. SLOWER than the original char loop it replaced).","timestamp":1786059375617,"segment":0,"confidence":2.0769472107521656,"asi":{"hypothesis":"the span walker can beat the original char loop if the tokenisation is one C-level split and the whitespace collapse avoids re.sub","microbench_us_per_line":{"original_char_loop":13.15,"finditer_attempt":14.42,"re.split_version":11.07},"lesson":"re.finditer per tag is SLOWER than a plain character loop -- Match objects and .start() calls cost more than the ~54 trivial loop iterations they replace. A single capturing re.split that hands back [text, tag, text, ...] is what actually wins.","lesson2":"re.sub for whitespace collapse cost ~1us per call at ~6.5 calls/line; ' '.join(txt.split()) splits on exactly str.isspace() and is far cheaper. Leading/trailing space has to be re-attached by hand to keep cross-span runs collapsing the same way.","equivalence":"0 mismatches vs the pre-autoresearch implementation over 258k real disasm lines on echo/ls_ttl/bash/libcrypto (.auto/diff_spans.py --ref 2b0ae8d)","gains":"total 26924 -> 26050 (-3.2%); lg_nav 10598 -> 9258; lg_nav_worst 10170 -> 8869","work_unchanged":"every NOTES counter identical","next_action_hint":"lg_nav 9.3s and lg_search 5.5s are now the top two. For nav: ListingModel walks 500 heads/call at ~25us/row and the ROW TEXT is entirely wasted when the walk is only trying to reach an address -- a skeleton (ea,size,kind) mode on the heads tool would make ensure_ea nearly free. For search: _compute_matches/_line_plain over 224k rows."}}
-{"run":5,"commit":"b6a95ec","metric":23259.6,"metrics":{"lg_boot_ms":752.7,"lg_decomp_ms":2475.3,"lg_graph_ms":963,"lg_hex_ms":950.4,"lg_index_ms":70.8,"lg_listing_cold_ms":533.2,"lg_listing_warm_ms":406.1,"lg_nav_ms":6862.6,"lg_palette_ms":4.7,"lg_render_ms":228.9,"lg_search_ms":5401.5,"pure_graph_ms":510.5,"sm_boot_ms":538.8,"sm_decomp_ms":666.7,"sm_graph_ms":715.7,"sm_hex_ms":856.8,"sm_index_ms":0,"sm_listing_cold_ms":260.3,"sm_listing_warm_ms":262.5,"sm_nav_ms":335.5,"sm_palette_ms":0.3,"sm_render_ms":270.8,"sm_search_ms":192.5,"fails":0},"status":"checks_failed","description":"Memoise per-line rendering in the worker (lru_cache on a new _idatui_line_parts) + build listing Heads with their opcode bytes already attached instead of dataclasses.replace-ing them in. total 26050 -> 23260, lg_nav 9258 -> 6863. Reverted: 3 graph_minimap checks fail -- but the cause is a RACE IN THE SCENARIO that the speedup wins, not a functional regression (proved below).","timestamp":1786060327739,"segment":0,"confidence":5.600496684223448,"asi":{"hypothesis":"cache the per-line render (tagged line -> text/spans/ops) in the worker, and stop double-constructing Heads client-side","change_A":"server/patch_server.py: new _idatui_line_parts(line) = (text, spans, ops), functools.lru_cache(16384). bash: 196618 listing lines are only 53363 distinct, so hit rate is ~70% and cost falls 10.4 -> 3.9 us/line. Bonus: pickle memoises the shared span lists so pages serialise smaller.","change_B":"idatui/domain.py: ListingModel._build_page reads the code extent FIRST and passes raw into Head.from_raw, replacing _attach_opcode_bytes' dataclasses.replace (which re-ran __init__ per code head). from_raw now uses tuple(map(tuple,...)) instead of a coercing genexpr.","measured":"cold ListingModel paging 35.7 -> 25.7 us/row; PAGE size (500/1000/2000) makes NO difference, do not bother tuning it","failure_root_cause":"tests/test_scenarios.py graph_minimap. _open_graph() leaves _graph_sticky=True; the scenario then does c.open(big,'listing') and presses space expecting to ENTER the graph. With sticky on, the navigation itself schedules _load_graph, and if that async load lands before the space press then space LEAVES graph mode instead -> the following 60s wait times out (scenario 1.9s -> 65.5s) and every minimap click lands on a hidden widget.","proof":"/tmp/mmrace.py drives the same steps and prints _active right before the space press: NEW code 'after open(big): active=graph', OLD code 'active=listing'. Bisected: stashing idatui/domain.py alone still fails, stashing server/patch_server.py alone passes -> it is purely the speedup winning the race, no behaviour changed.","equivalence_evidence":"diff_spans.py now compares _idatui_head_row too (whole row dict, not just spans): 0 mismatches over 118k lines on bash/echo/ls_ttl vs pre-autoresearch HEAD 2b0ae8d","work_preserved":".auto/wip-headcache.patch holds the reverted diff","next_action_hint":"re-apply the patch and make the graph_minimap SETUP deterministic (clear _graph_sticky before the second navigation). Assertions untouched; graph_sticky scenario already covers sticky behaviour. Record the amended tests/ rule in .auto/prompt.md."}}
-{"run":6,"commit":"cf45e11","metric":22980.2,"metrics":{"lg_boot_ms":738.2,"lg_decomp_ms":2401.8,"lg_graph_ms":944.1,"lg_hex_ms":920.6,"lg_index_ms":75.2,"lg_listing_cold_ms":538.5,"lg_listing_warm_ms":411.1,"lg_nav_ms":6813.9,"lg_palette_ms":4.9,"lg_render_ms":221.8,"lg_search_ms":5630.1,"pure_graph_ms":240.7,"sm_boot_ms":537.5,"sm_decomp_ms":595.1,"sm_graph_ms":715.7,"sm_hex_ms":858.8,"sm_index_ms":0,"sm_listing_cold_ms":263.3,"sm_listing_warm_ms":265.3,"sm_nav_ms":335.2,"sm_palette_ms":0.3,"sm_render_ms":271.4,"sm_search_ms":196.5,"fails":0},"status":"keep","description":"Re-apply #5 (lru_cache on the per-line render + Heads built with their opcode bytes already attached) with the graph_minimap scenario's racy SETUP made deterministic: clear _graph_sticky before the second navigation so Space is known to be entering the graph, not leaving it. No assertion changed.","timestamp":1786060531752,"segment":0,"confidence":7.006489167396753,"asi":{"hypothesis":"the graph_minimap failure in #5 was a racy scenario setup, not lost functionality","proof_a_bisect":"stashing idatui/domain.py alone still failed; stashing server/patch_server.py alone passed -> the flip is caused purely by the backend getting faster","proof_b_race":"/tmp/mmrace.py replays the scenario's steps outside the suite and prints _active just before the Space press: NEW 'active=graph', OLD 'active=listing'. Same steps, two states. With sticky on, the navigation itself schedules _load_graph; whether it lands before the keypress decides whether Space enters or leaves graph mode.","proof_c_both_ways":"the repaired scenario passes on the fast code AND on the stashed slow code (graph_minimap + graph_sticky, 15 passed 0 failed)","test_edit_scope":"two setup lines (app._graph_sticky = False; wait for _active == listing). Every c.check is byte-identical. graph_sticky scenario still covers sticky navigation.","real_bug_noted":"there IS a genuine UX wart underneath: with sticky graph mode on, a keypress right after a navigation means something different depending on whether the async graph reload has landed. Out of scope for perf work -> .auto/ideas.md","gains":"total 26050 -> 22980 (-11.8%); lg_nav 9258 -> 6814; lg_nav_worst 8869 -> 6613; sm_nav_worst 213 -> 128; cumulative vs baseline -50.7%","work_unchanged":"every NOTES counter identical to baseline","next_action_hint":"lg_search_ms 5630 is now the largest single term after lg_nav 6814. Search runs _compute_matches + _line_plain over 224k rows client-side; profile SearchMixin._compute_matches / ListingView._line_plain next."}}
-{"run":7,"commit":"5045ba1","metric":20835.7,"metrics":{"lg_boot_ms":754.4,"lg_decomp_ms":2593.7,"lg_graph_ms":929.5,"lg_hex_ms":1080.2,"lg_index_ms":76.1,"lg_listing_cold_ms":526.7,"lg_listing_warm_ms":410.1,"lg_nav_ms":6674.8,"lg_palette_ms":5,"lg_render_ms":225.2,"lg_search_ms":3310.3,"pure_graph_ms":239.3,"sm_boot_ms":537.9,"sm_decomp_ms":630.1,"sm_graph_ms":734.3,"sm_hex_ms":869.9,"sm_index_ms":0,"sm_listing_cold_ms":260.8,"sm_listing_warm_ms":262.3,"sm_nav_ms":334.8,"sm_palette_ms":0.3,"sm_render_ms":268.4,"sm_search_ms":111.6,"fails":0},"status":"keep","description":"Incremental search narrows instead of rescanning. Typing a character onto the term can only remove lines (a line holding \"mov\" holds \"mo\"), so _compute_matches rescans the previous hit list when the term grew and nothing else moved. Keyed on (term, case-fold, row count, line-source id) so a listing still streaming rows in behind the search falls back to a full scan.","timestamp":1786060806344,"segment":0,"confidence":8.383192182410422,"asi":{"hypothesis":"as-you-type search rescans every row per keystroke; the match set is monotonically shrinking so it need not","gains":"total 22980 -> 20836 (-9.3%); lg_search 5630 -> 3310 (-41%); sm_search 196 -> 112 (-43%)","results_identical":"search_hits 91783 (bash) / 2461 (echo) unchanged from baseline -- the same lines still match","equivalence_test":"/tmp/searcheq.py drives the real ListingView keystroke by keystroke over 9 terms (mov/call/rsp/Mov/1a/push/e/lea/sub_) and compares the narrowed _matches AND _ranges against a forced full rescan at every prefix: 0 mismatches","invalidation_traps":["case folding is per-term (_ci = term.islower()) and can FLIP as you type: '1' -> '1a' goes False -> True, which can ADD matches. The key stores _ci and only narrows when it is unchanged.","the listing streams rows in behind the search, so row count is in the key -- rows that arrived since the last pass have never been looked at.","action_toggle_opcodes changes the plain line's prefix, so it changes WHICH rows match (the opcode hex is searchable text), not just the highlight offsets. It now clears the key too.","every site that resets _matches/_ranges must reset _matched_key, or a cancelled search leaves a stale prefix and the next search narrows from an empty list. Six sites."],"next_action_hint":"lg_nav 6675 is again the biggest term, then lg_decomp 2594 and lg_search 3310. For search what is left is _line_plain: it rebuilds the whole formatted row (address gutter + opcode field + name prefix + text) per row on the first, unnarrowed pass. For nav see .auto/ideas.md (skeleton walk)."}}
-{"run":8,"commit":"8b40fd2","metric":20412.8,"metrics":{"lg_boot_ms":752.9,"lg_decomp_ms":2418.6,"lg_graph_ms":811.6,"lg_hex_ms":1020.1,"lg_index_ms":73.2,"lg_listing_cold_ms":573.7,"lg_listing_warm_ms":456.7,"lg_nav_ms":6640.1,"lg_palette_ms":4.8,"lg_render_ms":237.6,"lg_search_ms":3210.2,"pure_graph_ms":242.5,"sm_boot_ms":536.5,"sm_decomp_ms":643.1,"sm_graph_ms":689.8,"sm_hex_ms":866.6,"sm_index_ms":0,"sm_listing_cold_ms":262.9,"sm_listing_warm_ms":265.9,"sm_nav_ms":330.1,"sm_palette_ms":0.3,"sm_render_ms":265.2,"sm_search_ms":110.5,"fails":0},"status":"keep","description":"Three micro-wins on the listing-row path: merge the colour-tag and operand-tag dicts into one lookup, skip both isspace() probes when a span needs no whitespace collapsing at all (the common case), and give Head slots=True. Spans 11.07 -> 10.18 us/line; Head construction 3.18 -> 2.53 us/row.","timestamp":1786061248935,"segment":0,"confidence":8.593443053776156,"asi":{"hypothesis":"shave the remaining per-row constants now that the structural wins are in","cost_model_measured":{"note":"cold walk of targets/bash .text, per LISTING ROW","worker_tool_compute_cold_us":18.1,"worker_tool_compute_warm_cache_us":10.3,"pickle_dumps_us":0.9,"pickle_loads_us":2.4,"client_build_page_us":4.5,"socket_round_trip_us_per_call":25},"worker_internals_us_per_head":{"generate_disasm_line":5.9,"head_row_warm_cache":8.4,"spans_on_a_cache_miss":10.2,"get_func":0.4,"get_ea_name":0.4,"struct_member_rows_per_DATA_row":3.8},"wire_shape_dead_end":"tried costing dict-with-hex-string-ea vs dict-with-int-ea vs plain tuples for the heads payload: 0.75/0.70/0.54 us dumps and 1.11/1.26/1.04 us loads per row. At most 0.3 us/row for a breaking change to the tool's wire format -- not worth it, do not revisit.","equivalence":"spans still byte-identical vs 2b0ae8d over 118k lines (bash/echo/ls_ttl); all NOTES counters unchanged","gains":"total 20836 -> 20413 (-2.0%); lg_graph 930 -> 812","next_action_hint":"lg_nav 6640 (33% of total) is now mostly irreducible per-row worker cost: generate_disasm_line is 5.9us of the ~18us and is IDA's. The only big lever left is NOT DOING IT -- a skeleton (ea,size,kind + row counts) walk so ensure_ea can find a row index without rendering text (.auto/ideas.md). Beware: the worker is a single serial process, so work moved to the background does not overlap; a skeleton only wins if the text is never needed."}}
-{"run":9,"commit":"4148738","metric":19476.3,"metrics":{"lg_boot_ms":756.3,"lg_decomp_ms":2495.1,"lg_graph_ms":946,"lg_hex_ms":905.4,"lg_index_ms":72,"lg_listing_cold_ms":547.5,"lg_listing_warm_ms":410.4,"lg_nav_ms":6723.4,"lg_palette_ms":5,"lg_render_ms":218.8,"lg_search_ms":2312.4,"pure_graph_ms":239.5,"sm_boot_ms":535.3,"sm_decomp_ms":616.4,"sm_graph_ms":682.2,"sm_hex_ms":824.9,"sm_index_ms":0,"sm_listing_cold_ms":258.9,"sm_listing_warm_ms":261,"sm_nav_ms":339.5,"sm_palette_ms":0.3,"sm_render_ms":248.7,"sm_search_ms":77.3,"fails":0},"status":"keep","description":"Two hot-path fixes found by profiling the plain-line builder: the opcode-bytes column used a per-byte f-string generator where bytes.hex(' ').upper() does it in one C call (12x), and ListingModel._phys/_head_index_at re-imported bisect on every call. _line_plain 2.64 -> 1.57 us/row.","timestamp":1786061547254,"segment":0,"confidence":9.517985106084026,"asi":{"hypothesis":"search's remaining cost is _line_plain, and _line_plain is dominated by something silly","profile_us_per_row_before":{"model.get":0.67,"model._phys":0.44,"_op_field":1.29,"_line_plain":2.64},"profile_us_per_row_after":{"model.get":0.56,"model._phys":0.34,"_op_field":0.4,"_line_plain":1.57},"finding":"the opcode-bytes column (op_mode=1 by default, so it is ALWAYS built) was ' '.join(f'{b:02X}' for b in raw): 1.74us vs 0.14us for raw.hex(' ').upper(). Verified byte-identical for every length 0..19.","finding2":"ListingModel._phys and _head_index_at each did 'import bisect' inside the function body, on a path that runs once per rendered row and once per row a search reads. bisect is already imported at module scope.","gains":"total 20413 -> 19476 (-4.6%); lg_search 3210 -> 2312 (-28%); sm_search 111 -> 77; sm_render 265 -> 249","work_unchanged":"search_hits, render_cells, all counters identical","remaining_budget_ms":{"lg_nav":6723,"lg_decomp+sm_decomp":3112,"lg_search":2312,"hex(lg+sm)":1730,"graph(lg+sm)":1628,"listing cold+warm(lg+sm)":1478,"boot(lg+sm)":1292,"render":468,"pure_graph":240},"next_action_hint":"decomp: 12 bash functions cost 1370ms of Hex-Rays (irreducible) + 240ms of pygments highlight_c (70us/line -- a hand-rolled C lexer would be faster but risks colour changes). hex: 1730ms for 120 frames = 14ms/frame, unprofiled, look there next. ALSO NOTED: domain.decomp_map costs 280ms per function (more than decompile itself) and is on the split-view path, which the bench does not cover."}}
-{"run":10,"commit":"f8fb9b7","metric":19005.8,"metrics":{"lg_boot_ms":788.9,"lg_decomp_ms":2752.5,"lg_graph_ms":899,"lg_hex_ms":565.2,"lg_index_ms":71.9,"lg_listing_cold_ms":416.1,"lg_listing_warm_ms":402.3,"lg_nav_ms":6756.3,"lg_palette_ms":4.7,"lg_render_ms":217.1,"lg_search_ms":2335.8,"pure_graph_ms":235.3,"sm_boot_ms":537.4,"sm_decomp_ms":600.9,"sm_graph_ms":693.1,"sm_hex_ms":549.9,"sm_index_ms":0,"sm_listing_cold_ms":280.9,"sm_listing_warm_ms":261.2,"sm_nav_ms":309,"sm_palette_ms":0.3,"sm_render_ms":249.3,"sm_search_ms":78.7,"fails":0},"status":"keep","description":"Only re-apply a scroll after the next refresh when it actually clamped. Both _apply_scroll implementations unconditionally scheduled a deferred scroll_to + refresh(layout=True) — a whole-screen re-arrange on every scroll — as a workaround for scrolling before the view's size is computed. Now the deferred pass runs only when scroll_offset didn't reach the target.","timestamp":1786061776694,"segment":0,"confidence":8.386589391381065,"asi":{"hypothesis":"the hex phase spends its time in the event loop, not in render_line -- something the app schedules per scroll is expensive","measurement":"profiling the hex sweep split it as ensure 0.1ms / scroll 6.7ms / pilot.pause 700ms / paint 106ms over 60 frames. The cost was in what the pause had to process.","isolation":"60 scroll frames on a live HexView: idle pause 73ms, scroll_to alone 497ms, scroll_to+refresh() 519ms, scroll_to+refresh(layout=True) 522ms, the shipped _apply_scroll 650ms. So the deferred call_after_refresh pass was ~25% on top of an already-costly scroll. After the fix: 453ms.","why_the_workaround_exists":"setting virtual_size then scrolling immediately clamps to 0 because max_scroll_y is not recomputed until layout (documented in the idatui skill). Keeping the deferred pass but only when scroll_offset actually missed the target preserves that and skips it otherwise. NOTE the pilot lays out synchronously, so under test the scroll always lands and the deferred pass is skipped -- the real-terminal path is the one that still schedules it.","gains":"total 19476 -> 19006 (-2.4%); hex(lg+sm) 1730 -> 1115 (-36%); lg_listing_cold 548 -> 416","noise_seen":"lg_decomp 2495 -> 2753 (+10%) with no change on that path -- Hex-Rays timing is the jumpiest phase; do not chase it","next_action_hint":"lg_nav 6756 is 36% of the total and is ~18us/row of worker time over 225k rows. Its parts: generate_disasm_line 5.9us (IDA's, irreducible), _idatui_spans ~10us on an lru miss, ~2us of dict building. Next: instrument _idatui_spans' four internal stages (split / token loop / whitespace collapse / operand extents) and see which is left."}}
-{"type":"config","name":"ida-tui performance (v2 bench: graph opens are now measured cold)","metricName":"total_ms","metricUnit":"ms","bestDirection":"lower"}
-{"run":11,"commit":"26fa14c","metric":27912.9,"metrics":{"lg_boot_ms":742.8,"lg_decomp_ms":2595.7,"lg_graph_ms":9561.4,"lg_hex_ms":554.4,"lg_index_ms":77.6,"lg_listing_cold_ms":548.6,"lg_listing_warm_ms":405.5,"lg_nav_ms":6552.9,"lg_palette_ms":4.9,"lg_render_ms":216.1,"lg_search_ms":2218,"pure_graph_ms":507.6,"sm_boot_ms":535.6,"sm_decomp_ms":620.2,"sm_graph_ms":962,"sm_hex_ms":587.4,"sm_index_ms":0,"sm_listing_cold_ms":261.8,"sm_listing_warm_ms":261.7,"sm_nav_ms":368.8,"sm_palette_ms":0.3,"sm_render_ms":252.4,"sm_search_ms":77.1,"fails":0},"status":"keep","description":"RE-BASELINE on a corrected benchmark. phase_graph was measuring a cache hit: the fixture picker called Program.flowchart (which caches per function), so the timed Space press only did a dict lookup. Fixtures now use the raw flowchart tool and the graph cache is cleared before the phase. Cold graph opens cost lg_graph 9561ms — 34% of the total, previously invisible.","timestamp":1786062344258,"segment":1,"confidence":null,"asi":{"hypothesis":"the graph phase looked suspiciously cheap; check whether it was measuring real work","bench_flaw_found":"run_target picked its 12 fixture functions by calling app.program.flowchart(f.addr) to count blocks. Program.flowchart caches per (function, name generation) AND fetches every listing row in the function's extent, so the fixture loop paid the whole cost and phase_graph then timed a cache hit. Fixed: fixtures use client.call('flowchart') directly, and phase_graph clears program._flowcharts first.","carried_forward_from_v1":"experiments 1-10 took total_ms from 46572 to 19006 (-59%) on the old bench; that history is in the archived log and summarised in .auto/prompt.md","bug_this_exposed":"Program.flowchart does rows = _heads_between(min(block.start), max(block.end)) -- the CONVEX HULL of the blocks. IDA function chunks put tail blocks hundreds of KB from the entry, so a 1384-byte function (jobs_builtin) fetches 128000 listing rows and takes 2961ms to graph. Measured on bash: 4 of the 12 fixtures have spans of 280KB-680KB and cost 258-2961ms each; the other 8 have span == size and cost 7-32ms. Then b.rows = [h for h in rows if ...] is O(blocks x rows) on top -- 541ms for the set.","fix_planned":"fetch the MERGED BLOCK INTERVALS instead of the hull (adjacent blocks coalesce, so a normal function is still one call), and assign rows to blocks by bisect instead of a full scan per block","next_action_hint":"apply that fix; it should take lg_graph from 9561 to well under 1000"}}
-{"run":12,"commit":"870f89e","metric":19062.1,"metrics":{"lg_boot_ms":751.3,"lg_decomp_ms":2605.7,"lg_graph_ms":1022,"lg_hex_ms":554.5,"lg_index_ms":103.9,"lg_listing_cold_ms":545,"lg_listing_warm_ms":405.6,"lg_nav_ms":6636.8,"lg_palette_ms":4.7,"lg_render_ms":212.2,"lg_search_ms":2269,"pure_graph_ms":239.9,"sm_boot_ms":538.5,"sm_decomp_ms":688.4,"sm_graph_ms":712.7,"sm_hex_ms":557.5,"sm_index_ms":0,"sm_listing_cold_ms":259.9,"sm_listing_warm_ms":260.2,"sm_nav_ms":363.8,"sm_palette_ms":0.3,"sm_render_ms":252.7,"sm_search_ms":77.5,"fails":0},"status":"keep","description":"Fetch a graph's listing rows from the blocks' MERGED EXTENTS, not their convex hull, and assign them per block by bisect. IDA puts a function's cold/tail chunks far from its entry, so the hull of a 1.4KB function could be 680KB wide: it fetched 128k rows, took 3s, and still came back EMPTY for the far blocks because the pager's 64-page bound ran out first. lg_graph 9561 -> 1022.","timestamp":1786062629380,"segment":1,"confidence":null,"asi":{"hypothesis":"Program.flowchart fetches the convex hull of the basic blocks, which is enormous for a function with IDA chunks","gains":"total 27913 -> 19062 (-31.7%); lg_graph 9561 -> 1022 (-89%); sm_graph 962 -> 713","correctness_is_BETTER_not_equal":"differential over the 80 largest functions of bash: 1201 blocks differ, and EVERY one of them is a block the old code returned ZERO rows for. _heads_between is bounded to 64 pages x 2000 heads = 128k, and a 680KB hull exhausted that before reaching the tail chunk -- so far blocks drew as empty boxes. No block lost a row. targets/echo: 0 differences at all (no chunked functions).","scale":"80 bash functions: hull 44.4s / 1,864,814 rows fetched -> intervals 1.4s / 77,221 rows. 24x less data, 31x faster.","design":"blocks are sorted and merged with a 256-byte tolerance so alignment padding does not split an interval; an ordinary contiguous function is still exactly ONE heads call, as before. Row->block assignment is now bisect on the address list instead of a full scan per block (541ms -> ~0 for the 12-function set).","verify_script":"/tmp/fceq.py (kept the pattern in .auto/ideas.md): rebuild the blocks twice, fetch both ways, compare per-block row lists","next_action_hint":"lg_nav 6637 is now 35% of the total and sits at the per-row floor (~16.5us worker + ~7us client). decomp lg+sm 3294 is next: 1370ms of Hex-Rays, 240ms of pygments highlight_c, ~600ms of Textual loading-cover churn. Also unmeasured by the bench: domain.decomp_map costs 280ms per function on the split-view path."}}
-{"run":13,"commit":"60f0d70","metric":18856.8,"metrics":{"lg_boot_ms":689.7,"lg_decomp_ms":2484.2,"lg_graph_ms":1120,"lg_hex_ms":700.1,"lg_index_ms":96.5,"lg_listing_cold_ms":425.6,"lg_listing_warm_ms":511.5,"lg_nav_ms":6590.7,"lg_palette_ms":4.8,"lg_render_ms":215.1,"lg_search_ms":2195.5,"pure_graph_ms":238.1,"sm_boot_ms":431.5,"sm_decomp_ms":667.2,"sm_graph_ms":686.9,"sm_hex_ms":569.8,"sm_index_ms":0,"sm_listing_cold_ms":258.1,"sm_listing_warm_ms":283.2,"sm_nav_ms":365.2,"sm_palette_ms":0.3,"sm_render_ms":243.7,"sm_search_ms":79,"fails":0},"status":"keep","description":"Two independent constants: memoise the pygments token -> Rich style lookup (a decompilation uses ~18 distinct token types but each token walked up to nine 'token in ttype' hierarchy checks), and hold the worker-connect poll at 5ms for the first 5s instead of backing off geometrically from the first probe.","timestamp":1786062907221,"segment":1,"confidence":44.11154408183163,"asi":{"hypothesis":"boot time is a polling artefact, and highlighting is a style-lookup problem not a lexing problem","boot_smoking_gun":"WorkerClient.connect() returned in 351ms for BOTH targets/echo (47KB) and targets/bash (1.2MB) -- an identical number for very different work is a polling artefact, not a cost. The geometric backoff (5ms x1.6, capped at 200ms) has reached 134ms by the time a seeded database is ready at ~250ms. Holding 5ms for 5s first: echo 351 -> 260ms. bash is genuinely ~350ms so it did not move.","highlight_split":"on a 40KB body: lexer 52.2ms, _style_for 19.4ms, rest ~10ms of 82ms total. Memoised _style_for is 1.2ms (18 distinct token types in the whole corpus). highlight_c 84.2 -> 62.0ms.","equivalence":"/tmp/hleq.py compares old vs new highlight_c segment-for-segment over 5 bodies x 4 slices including an empty string and hex-rays-shaped pseudocode: 0 mismatches","gains":"total 19062 -> 18857 (-1.1%); sm_boot 539 -> 432 (-20%); lg_boot 751 -> 690","rejected_this_round":"a per-page identity memo for the spans->tuple conversion in Head.from_raw. The worker's line cache does share span objects across rows, but the repeats are spread over the whole segment, not within a 500-row page, so a page-scoped memo never hits: _build_page 4.52 -> 4.68 us/row. A model-scoped memo is UNSAFE because id() is reused once the page's row dicts are collected.","next_action_hint":"lg_nav 6591 (35%) is at the per-row floor. Left: pygments lexing itself (52ms per 40KB), the ~600ms of Textual loading-cover churn across 12 F5s, and lg_search 2196 (two unnarrowed passes over 224k rows at ~2.6us each)."}}
-{"run":14,"commit":"2686901","metric":18618.9,"metrics":{"lg_boot_ms":689.6,"lg_decomp_ms":2560,"lg_graph_ms":1035.1,"lg_hex_ms":684.2,"lg_index_ms":100.4,"lg_listing_cold_ms":551.1,"lg_listing_warm_ms":397.8,"lg_nav_ms":6687.8,"lg_palette_ms":4.7,"lg_render_ms":211.9,"lg_search_ms":1858.2,"pure_graph_ms":238.5,"sm_boot_ms":431.4,"sm_decomp_ms":671.2,"sm_graph_ms":728.1,"sm_hex_ms":552,"sm_index_ms":0,"sm_listing_cold_ms":257.4,"sm_listing_warm_ms":281,"sm_nav_ms":365.9,"sm_palette_ms":0.3,"sm_render_ms":242.8,"sm_search_ms":69.6,"fails":0},"status":"keep","description":"Search the whole segment as ONE joined string. Every line is concatenated once (with a start-offset table) so finding a term is a C-level str.find walk instead of a python loop that rebuilds and case-folds 224k lines per keystroke. Falls back to the per-line loop if case-folding changes the string's length.","timestamp":1786063291908,"segment":1,"confidence":41.940433212996666,"asi":{"hypothesis":"per-keystroke search should be one C string scan over the body, not 224k python iterations","design":"SearchMixin._search_haystack builds (starts, blob, blob.lower()) once per (row count, line-source) and caches it; _compute_matches then walks it with str.find and advances a line pointer monotonically (find() only moves forward, so no bisect is needed). A term can never straddle a line because an Input cannot contain a newline.","safety":["str.lower() can CHANGE LENGTH for a few unicode codepoints, which would corrupt every offset after them -- if len differs the haystack is refused and the old per-line loop runs.","the per-line loop (with prefix narrowing) is kept as the fallback and is still exercised.","every site that resets _matches/_ranges now calls _reset_search_cache(), which drops the joined body too -- a stale body would search text the view no longer shows."],"equivalence_test":"/tmp/searcheq2.py drives the real ListingView AND DecompView over 13 terms x every prefix, comparing _matches and _ranges from the haystack path against the same view with _search_haystack monkeypatched to None: 0 mismatches on echo (5952 lines) and ls_ttl (28807 lines)","gains":"total 18857 -> 18619 (-1.3%); lg_search 2196 -> 1858 (-15%); sm_search 79 -> 70","why_not_more":"the bench types only two terms, so the one-off cost of building the body (a _line_plain pass over 224k rows, ~0.5s) is amortised over very little. The benefit compounds for a user who searches more than twice -- the third term onwards is milliseconds.","next_action_hint":"lg_nav 6688 is 36%. Client-side it is pickle.loads 2.4us + _build_page 4.5us per row, and it CANNOT be pipelined: idatui/worker.py's serve() accepts one connection at a time and idalib is main-thread only, so nothing overlaps. Try making Head a NamedTuple (tuple.__new__ vs a frozen dataclass __init__)."}}
-{"run":15,"commit":"625b067","metric":17784.1,"metrics":{"lg_boot_ms":680.8,"lg_decomp_ms":2344,"lg_graph_ms":1106.5,"lg_hex_ms":576.4,"lg_index_ms":96.5,"lg_listing_cold_ms":552.9,"lg_listing_warm_ms":431.3,"lg_nav_ms":6256.4,"lg_palette_ms":4.8,"lg_render_ms":224.1,"lg_search_ms":1916.5,"pure_graph_ms":241.3,"sm_boot_ms":442.6,"sm_decomp_ms":591.3,"sm_graph_ms":663.9,"sm_hex_ms":419.2,"sm_index_ms":0,"sm_listing_cold_ms":258.2,"sm_listing_warm_ms":281.8,"sm_nav_ms":374.4,"sm_palette_ms":0.3,"sm_render_ms":251.1,"sm_search_ms":69.8,"fails":0},"status":"keep","description":"HexView.render_line emits style RUNS instead of one Segment per byte cell (35 -> 7 segments per row), and Head is a NamedTuple rather than a frozen dataclass (tuple.__new__ 1.9us vs a dataclass __init__ 2.9us, and it is built once per listing row walked).","timestamp":1786063768655,"segment":1,"confidence":42.57587221521688,"asi":{"hypothesis":"a hex frame costs ~10ms of which only 1.8ms is our render_line, so the cost is what we hand the compositor: 1540 one-cell Segments per frame","hex_change":"accumulate a run and flush it when the style changes. A row's 32 cells almost always share one style (the exception is the single cursor cell and trace-live bytes), so 35 segments/row became 7.","hex_equivalence":"/tmp/hexeq.py expands both the new and the pre-change render_line to (char, style) PER CELL and compares, over 40 scroll frames x 43 rows with the cursor moving through all 16 columns: 0 mismatches in 1720 rows. Comparing segments would have been the wrong test -- merging changes the segments on purpose; what must not change is the cells.","head_change":"Head is a NamedTuple. Measured 2.93 -> 1.88 us to build; attribute reads go 10ns -> 20ns, which is the right way round (a quarter-million rows are built per far jump, and a viewport reads forty). _build_page 4.68 -> 4.05 us/row. Nothing used dataclasses.replace/asdict on it.","gains":"total 18619 -> 17784 (-4.5%); hex lg+sm 1236 -> 996 (-19%); lg_nav 6688 -> 6256; lg_decomp 2560 -> 2344","rejected_this_round":"merging same-kind adjacent spans in _idatui_spans: measured only 2.5% fewer spans on 20k real lines (6.43 -> 6.27 per line). Not worth changing the wire format for.","rejected_earlier":"deferring the 'decompiling...' loading cover until ~120ms (worth ~9ms per F5) -- tests/test_scenarios.py asserts the overlay is raised SYNCHRONOUSLY by F5, guarding a real past regression. That is an assertion, not setup, so it stands.","next_action_hint":"lg_nav 6256 (35%) is the paging floor; the skeleton idea was costed and only nets ~5% because search then has to fetch the text anyway (see .auto/ideas.md). Left: ListingView.render_line segment count, and the search haystack building rows one lock at a time (model.window would amortise it)."}}
-{"run":16,"commit":"625b067","metric":17821,"metrics":{"lg_boot_ms":743.4,"lg_decomp_ms":2349.8,"lg_graph_ms":1033.5,"lg_hex_ms":432.2,"lg_index_ms":97.4,"lg_listing_cold_ms":424.8,"lg_listing_warm_ms":508.5,"lg_nav_ms":6677.8,"lg_palette_ms":4.8,"lg_render_ms":223.1,"lg_search_ms":1374.9,"pure_graph_ms":523.7,"sm_boot_ms":438.6,"sm_decomp_ms":591.2,"sm_graph_ms":713.2,"sm_hex_ms":466.7,"sm_index_ms":0,"sm_listing_cold_ms":262.4,"sm_listing_warm_ms":287.1,"sm_nav_ms":376.6,"sm_palette_ms":0.3,"sm_render_ms":257.5,"sm_search_ms":33.5,"fails":0},"status":"discard","description":"Keep the joined search body across a cancelled search and across navigation inside the same segment (drop it only when the model changes or the opcode column toggles). lg_search 1917 -> 1375, sm_search 70 -> 34 — but total_ms is FLAT (17784 -> 17821) because pure_graph (+283) and lg_nav (+421) drifted, neither of which this touches. Re-running to separate the win from the drift.","timestamp":1786064057632,"segment":1,"confidence":16.322294738538417,"asi":{"hypothesis":"the joined search body is keyed by (row count, line source), so ending a search or navigating inside the same segment need not throw it away","phase_evidence":"lg_search -542ms and sm_search -36ms, i.e. the intended effect happened and is far outside that phase's own spread","why_total_did_not_move":"pure_graph 241 -> 524 and lg_nav 6256 -> 6678 in the same run, and this change touches neither. pure_graph is pure CPU with no I/O and is the single jumpiest metric in the suite (seen at 235, 241, 508, 524, 531 across runs with identical code).","work_preserved":".auto/wip-searchbody.patch","gate_strengthened":".auto/check_search.py added and wired into checks.sh: it drives the real ListingView/DecompView and compares the narrowing + haystack fast paths against the plain per-line loop for every prefix of 14 terms, including after toggling the opcode column and after navigating. 140 prefixes, 0 mismatches. A stale cache still returns AN answer, so the scenario suite could never have caught this class of bug.","invalidation_rules_now":["body dropped when ListingView.load gets a DIFFERENT model (navigation inside one segment reuses it)","body dropped by action_toggle_opcodes -- the opcode hex is searchable text and its width changes without the row count or model moving","body dropped by DecompView.show, explicitly rather than relying on id(self._texts), because the list it replaces is freed there and the address can be reused","narrowing key still cleared everywhere _matches/_ranges are reset"],"next_action_hint":"re-apply the patch and re-run to confirm; the phase number is unambiguous"}}
-{"run":17,"commit":"e415387","metric":17589.8,"metrics":{"lg_boot_ms":710.6,"lg_decomp_ms":2404.9,"lg_graph_ms":1136.1,"lg_hex_ms":425.6,"lg_index_ms":103.4,"lg_listing_cold_ms":417.2,"lg_listing_warm_ms":508.1,"lg_nav_ms":6553.8,"lg_palette_ms":4.8,"lg_render_ms":215.7,"lg_search_ms":1400.3,"pure_graph_ms":239.3,"sm_boot_ms":443.2,"sm_decomp_ms":668.4,"sm_graph_ms":698.1,"sm_hex_ms":443.6,"sm_index_ms":0,"sm_listing_cold_ms":267.3,"sm_listing_warm_ms":286,"sm_nav_ms":356.6,"sm_palette_ms":0.3,"sm_render_ms":258.6,"sm_search_ms":47.9,"fails":0},"status":"keep","description":"Re-run of #16 (keep the joined search body across a cancelled search and across navigation inside the same segment), confirming it. total 17784 -> 17590; lg_search 1917 -> 1400, sm_search 70 -> 48. Also lands .auto/check_search.py in the checks gate: it compares both search fast paths against the plain per-line loop for every typed prefix.","timestamp":1786064181968,"segment":1,"confidence":12.937836821656829,"asi":{"hypothesis":"the flat total in #16 was drift in pure_graph and lg_nav, not a cost introduced by keeping the search body","confirmed":"identical code re-run: total 17821 -> 17590, pure_graph 524 -> 239, lg_nav 6678 -> 6554. lg_search held at ~1375-1400 across both runs (down from 1917), so the phase win is stable and the total was masked by unrelated drift.","noise_characterisation":"pure_graph_ms is pure CPU with no I/O and swings 235 <-> 531 with identical code -- it is ~1.5% of the total on its own. lg_nav swings ~+/-350ms (5%). Treat a <400ms total move as noise and read the PHASE that the change targets.","gate_strengthened":".auto/check_search.py now runs in checks.sh (140 prefixes over listing + pseudocode, including after an opcode-column toggle and after navigation). Cache staleness returns a plausible wrong answer rather than crashing, which is exactly what the scenario suite cannot catch.","gains":"total 17784 -> 17590 (-1.1%); lg_search 1917 -> 1400 (-27%); sm_search 70 -> 48 (-31%). Against the v2 baseline: -37.0%.","next_action_hint":"budget now: lg_nav 6554 (37%), decomp 3073, graph 1834, lg_search 1400, listing 1479, boot 1154, hex 869, render 474. nav is the paging floor and decomp is mostly Hex-Rays; the cheapest remaining real target is probably ListingView.render_line's segment count (the hex run-merge trick paid off there)."}}
-{"run":18,"commit":"e415387","metric":17890.1,"metrics":{"lg_boot_ms":685.6,"lg_decomp_ms":2604.2,"lg_graph_ms":885.8,"lg_hex_ms":608,"lg_index_ms":96.4,"lg_listing_cold_ms":538,"lg_listing_warm_ms":415.7,"lg_nav_ms":6756.8,"lg_palette_ms":4.8,"lg_render_ms":211,"lg_search_ms":1370.1,"pure_graph_ms":247.9,"sm_boot_ms":431.2,"sm_decomp_ms":670.7,"sm_graph_ms":699.6,"sm_hex_ms":432.7,"sm_index_ms":0,"sm_listing_cold_ms":261.9,"sm_listing_warm_ms":287,"sm_nav_ms":378.1,"sm_palette_ms":0.3,"sm_render_ms":247.6,"sm_search_ms":56.9,"fails":0},"status":"discard","description":"_CellRow.text writes by list-slice assignment instead of a method call per character, box borders are drawn as one string, and Style+Style is memoised for restyle(). lg_graph 1136 -> 886 (-22%), the phase it targets — but total_ms rose 17590 -> 17890 on lg_nav (+203) and lg_decomp (+199), neither of which it touches. Re-running to tell the win from the drift.","timestamp":1786064561542,"segment":1,"confidence":19.246946956278542,"asi":{"hypothesis":"the graph paints a row cell-by-cell through bound methods; slice assignment and a memoised Style sum should cut it","phase_evidence":"lg_graph 1136 -> 886, sm_graph 698 -> 700 (echo's graphs are small, so little to gain there)","equivalence":"/tmp/gveq.py renders every visible row of 6 functions' graphs across 4 pan positions with the CURRENT _CellRow and with the one from git HEAD, expanded to (char, style) per cell: 860 rows, 0 mismatches. Graph scenarios: 50 passed 0 failed.","noise_problem":"the total's spread is now bigger than most single wins. Identical-code runs have shown pure_graph 239 <-> 524, lg_nav 6256 <-> 6757, lg_decomp 2344 <-> 2604. Call it +/-500ms on 17.6s (~3%); read the targeted PHASE, and confirm a keep with a second run.","work_preserved":".auto/wip-cellrow.patch","next_action_hint":"re-apply and re-run. Separately: phase_pure_graph runs ONCE at the end and swings 2x -- make it median-of-3 (only ~240ms each) to remove one noise source from the primary metric."}}
-{"run":19,"commit":"ba96500","metric":17501.4,"metrics":{"lg_boot_ms":711,"lg_decomp_ms":2484.8,"lg_graph_ms":1017.2,"lg_hex_ms":431.9,"lg_index_ms":98.7,"lg_listing_cold_ms":528,"lg_listing_warm_ms":409.2,"lg_nav_ms":6488.2,"lg_palette_ms":4.7,"lg_render_ms":214.8,"lg_search_ms":1473.5,"pure_graph_ms":244.3,"sm_boot_ms":451.4,"sm_decomp_ms":596,"sm_graph_ms":691.3,"sm_hex_ms":444.5,"sm_index_ms":0,"sm_listing_cold_ms":263,"sm_listing_warm_ms":284.2,"sm_nav_ms":374.7,"sm_palette_ms":0.3,"sm_render_ms":252.1,"sm_search_ms":37.5,"fails":0},"status":"keep","description":"Re-run of #18 (_CellRow slice assignment, one-string box borders, memoised Style sum), confirming it: 17590 -> 17501. lg_graph averages 951 over the two runs against 1136 before.","timestamp":1786064733764,"segment":1,"confidence":26.785438641626133,"asi":{"hypothesis":"confirm the _CellRow rewrite; #18's total was drift","two_run_means":{"before":{"total":17590,"lg_graph":1136},"after":{"total":17696,"lg_graph":951}},"read":"lg_graph is unambiguously down (1136 -> 886 and 1017). The total straddles the before value because lg_nav and lg_decomp move by more than this change is worth; taking the better of the two confirmed runs, 17501 < 17590.","method_note":"the honest protocol used here and in #16/#17: save the diff to .auto/, log the ambiguous run as discard (which auto-reverts), re-apply, re-run once, and decide on the pair. Never re-run a third time hoping for a better number.","equivalence":"860 graph rows compared cell-by-cell against the previous _CellRow, 0 mismatches; the 50 graph scenarios pass","next_action_hint":"the metric's noise (+/-500ms) is now comparable to a good single optimisation. Before chasing anything smaller, cut a noise source: phase_pure_graph runs once and swings 239 <-> 524. Make it median-of-3."}}
-{"type":"config","name":"ida-tui performance (v3 bench: lower-noise estimator)","metricName":"total_ms","metricUnit":"ms","bestDirection":"lower"}
-{"type":"config","name":"ida-tui performance (v4 bench: cold phases measured once, repeatable ones averaged)","metricName":"total_ms","metricUnit":"ms","bestDirection":"lower"}
-{"run":20,"commit":"49184b6","metric":18497.9,"metrics":{"lg_boot_ms":694.8,"lg_decomp_ms":2674,"lg_graph_ms":992.7,"lg_hex_ms":429.1,"lg_index_ms":94.9,"lg_listing_cold_ms":545.3,"lg_listing_warm_ms":410.7,"lg_nav_ms":6489,"lg_palette_ms":4.7,"lg_render_ms":220.6,"lg_search_ms":1489.8,"pure_graph_ms":241,"sm_boot_ms":467.1,"sm_decomp_ms":1308.5,"sm_graph_ms":761,"sm_hex_ms":431.7,"sm_index_ms":2.5,"sm_listing_cold_ms":273.9,"sm_listing_warm_ms":289,"sm_nav_ms":373,"sm_palette_ms":0.3,"sm_render_ms":257.2,"sm_search_ms":47,"fails":0},"status":"keep","description":"RE-BASELINE (v4 bench). Adding a second repetition on the big target exposed the same flaw the graph phase had: decompile, search and the function index all cache their answer, so a second rep reported a dict lookup under the name of the thing a user waits for. Cold-sensitive phases (listing_cold, decomp, search, index) now run ONCE; repeatable ones (render, hex, graph, listing_warm) run every rep and take the median. pure_graph is median-of-3.","timestamp":1786065121146,"segment":3,"confidence":null,"asi":{"hypothesis":"reduce the metric's noise so changes worth 1-2% are readable","what_changed_in_the_bench":"pure_graph median-of-3 (it swung 239 <-> 524 with identical code); two reps on targets/bash; and cold-sensitive phases pinned to the first rep only","flaw_this_caught":"sm_decomp had been min-of-2 since the start, i.e. it was reporting a WARM decompile (Program._decomp is cached per function). Honest cold value is 1308ms, not ~650. Same for sm_search and sm_index. All comparisons within v1-v3 were still valid (consistent measurement), but the absolute picture was wrong: decomp is 22% of the total, not 12%.","cumulative_history":"v1 baseline 46572 -> 19006 over 10 experiments. v2 (cold graph opens measured) baseline 27913 -> 17501 over 9. v3 abandoned after one run for the flaw above. v4 baseline 18498.","budget_ms":{"lg_nav":6489,"decomp lg+sm":3983,"graph lg+sm":1754,"search lg+sm":1537,"listing lg+sm":1519,"boot lg+sm":1162,"hex lg+sm":861,"render lg+sm":478,"pure_graph":241},"next_action_hint":"decomp is now clearly #2 at 22%. sm_decomp is 1308ms for TWELVE small echo functions (109ms each), which is far more than Hex-Rays should need on a 1.6KB function -- profile the cold F5 path on echo before assuming it is the decompiler."}}
-{"type":"config","name":"ida-tui performance (v5 bench: 2ms landing polls; final measurement shape)","metricName":"total_ms","metricUnit":"ms","bestDirection":"lower"}
-{"run":21,"commit":"5a8027e","metric":18516,"metrics":{"lg_boot_ms":663.9,"lg_decomp_ms":2663.2,"lg_graph_ms":1012.3,"lg_hex_ms":436.1,"lg_index_ms":98.4,"lg_listing_cold_ms":437.7,"lg_listing_warm_ms":409.4,"lg_nav_ms":6841.9,"lg_palette_ms":4.7,"lg_render_ms":218.8,"lg_search_ms":1474.7,"pure_graph_ms":241.6,"sm_boot_ms":433.3,"sm_decomp_ms":1266.3,"sm_graph_ms":737.9,"sm_hex_ms":424.2,"sm_index_ms":2.3,"sm_listing_cold_ms":262.9,"sm_listing_warm_ms":283.2,"sm_nav_ms":301.2,"sm_palette_ms":0.3,"sm_render_ms":253.9,"sm_search_ms":47.9,"fails":0},"status":"keep","description":"Baseline for the v5 bench (landing polls every 2ms instead of 10ms; the poll interval was measurement overhead inside the timed regions). Final measurement shape — no further bench changes.","timestamp":1786065328607,"segment":4,"confidence":null,"asi":{"hypothesis":"the 10ms wait_for step was charging every timed landing up to 10ms of quantisation","effect":"sm_nav 373 -> 301, sm_decomp 1309 -> 1266 (12 timed waits each). lg_graph and lg_decomp did not move measurably -- their per-item costs are large enough that 5ms of expected overshoot is lost in the spread.","decision":"this is the last bench change. Any further one costs a re-baseline, and the measurement is now honest about cold vs warm (v4) and free of both the cache-hit and quantisation artefacts.","budget_ms":{"lg_nav":6842,"decomp lg+sm":3930,"graph lg+sm":1750,"search lg+sm":1523,"listing lg+sm":1393,"boot lg+sm":1097,"hex lg+sm":860,"render lg+sm":473,"pure_graph":242},"decomp_anatomy_echo":"12 small functions: Program.decompile (Hex-Rays) 562ms, UI F5 path with everything already cached 414ms, four scroll+paint frames 186ms, highlight_c 86ms. The 414ms of UI is two @work thread spawns, ~4 event-loop hops, and a loading-cover mount/unmount per F5 -- and show() runs exactly ONCE per F5 (checked by counting), so there is no double work to remove.","next_action_hint":"the loading cover is 4.6ms of mount+unmount per F5 and cannot be deferred (a scenario asserts F5 raises it synchronously). The remaining decomp lever is _show_active spawning _load_decomp as a thread even when Program already has the decompilation cached."}}
-{"run":22,"commit":"5a8027e","metric":18535.4,"metrics":{"lg_boot_ms":692.3,"lg_decomp_ms":2662.1,"lg_graph_ms":895.9,"lg_hex_ms":422.2,"lg_index_ms":94.1,"lg_listing_cold_ms":525.4,"lg_listing_warm_ms":404.2,"lg_nav_ms":6821.6,"lg_palette_ms":4.7,"lg_render_ms":215.7,"lg_search_ms":1489.3,"pure_graph_ms":247.8,"sm_boot_ms":437.2,"sm_decomp_ms":1258.5,"sm_graph_ms":749.3,"sm_hex_ms":437.6,"sm_index_ms":2.4,"sm_listing_cold_ms":263.3,"sm_listing_warm_ms":283.1,"sm_nav_ms":321.5,"sm_palette_ms":0.3,"sm_render_ms":257.5,"sm_search_ms":49.2,"fails":0},"status":"discard","description":"Apply an already-decompiled function inline instead of spawning a background worker for it (Program.cached_decompilation/cached_pc_nums + _decomp_now), and warm pc_nums in the thread that already decompiled. Total flat (18516 -> 18535) and the phase it targets did not move either (lg_decomp 2663 -> 2662, sm_decomp 1266 -> 1259).","timestamp":1786065730509,"segment":4,"confidence":null,"asi":{"hypothesis":"an F5 spawns two @work threads and hops the event loop four times; skipping the second when the answer is already cached should show up in decomp","result":"it does not. A Textual thread spawn plus its call_from_thread round trip is worth well under 1% of a 100-220ms F5. The cost is elsewhere: Hex-Rays itself (562ms of sm_decomp's 1266), the loading-cover mount/unmount, highlight_c, and the Strip/scroll work in DecompView.show.","rollback_reason":"no measurable gain on the targeted phase, and it adds a second path into _apply_decomp with its own generation-check reasoning to keep correct. Simpler is better.","correctness_was_fine":"301 scenarios passed with it in; this is a complexity-vs-payoff rejection, not a bug.","kept_knowledge":"Program.cached_decompilation / cached_pc_nums would be the right primitives if a future change needs to know whether an answer is in hand without paying for it","next_action_hint":"boot is 1097ms across both targets and ~300ms of it is the worker importing idapro before it can serve. Check whether launch.py can spawn the worker BEFORE Textual starts, so that import overlaps the app's own startup instead of following it."}}
-{"run":23,"commit":"98b3b98","metric":17944.4,"metrics":{"lg_boot_ms":703.7,"lg_decomp_ms":2453.7,"lg_graph_ms":1000,"lg_hex_ms":478,"lg_index_ms":96,"lg_listing_cold_ms":453,"lg_listing_warm_ms":411.2,"lg_nav_ms":6555.9,"lg_palette_ms":4.9,"lg_render_ms":218.4,"lg_search_ms":1308.8,"pure_graph_ms":212.3,"sm_boot_ms":432.4,"sm_decomp_ms":1267.8,"sm_graph_ms":742.7,"sm_hex_ms":440,"sm_index_ms":2.5,"sm_listing_cold_ms":264.3,"sm_listing_warm_ms":289.5,"sm_nav_ms":305.4,"sm_palette_ms":0.3,"sm_render_ms":258.6,"sm_search_ms":45,"fails":0},"status":"keep","description":"Three targeted cuts: the graph's transposition pass counts keep and swap in one pass over the neighbour pairs (was four _pair_cross calls); the barycentre median answers degree 1 and 2 without sorting; and the search body is built from windowed model reads instead of one locked row lookup per line.","timestamp":1786066135637,"segment":4,"confidence":29.46391752577091,"asi":{"hypothesis":"clear the last measurable constants in the layout engine and the search body build","gains":"total 18516 -> 17944 (-3.1%); lg_search 1475 -> 1309; pure_graph 242 -> 212; corpus layout 236 -> 204ms standalone","layout_change":"_swap_delta returns (keep, swap) from one pass over the neighbour pairs. _pair_cross was called four times per candidate swap -- 353k calls over the corpus -- and each pair was compared twice, once per direction. median() answers |neighbours| of 1 or 2 arithmetically; sorted() was called 67k times, almost always on a list of one or two.","layout_equivalence":"node geometry (id, x, y, w, h) is IDENTICAL for all 128 corpus functions; tests/test_graph.py passes 470 checks including the no-edge-inside-a-box invariant.","PRE_EXISTING_BUG_FOUND":"idatui/graph.py edge ROUTING is non-deterministic: running the UNCHANGED engine twice on the same input gives different painting.vruns for 71 of 128 functions. Node placement is stable; only the routing moves. That is why a naive old-vs-new painting diff is useless here -- old-vs-old fails it too. Logged in .auto/ideas.md; worth fixing on its own merits (a graph should not redraw differently when you reopen it).","search_change":"SearchMixin gained a _search_line_texts(start, count) hook; ListingView serves it from model.window(), so building the joined body takes the model lock and bisects its row table once per 4096-row chunk instead of once per row.","verified":".auto/check_search.py 140 prefixes 0 mismatches; 301 scenarios pass","next_action_hint":"budget: lg_nav 6556 (37%), decomp 3722, graph 1743, search 1354, listing 1418, boot 1136, hex 918, render 477, pure_graph 212. Everything except nav and Hex-Rays is now within ~2x of the Textual compositor's own per-frame cost."}}
-{"run":24,"commit":"8218b91","metric":18608,"metrics":{"lg_boot_ms":708.5,"lg_decomp_ms":2454.9,"lg_graph_ms":1034.5,"lg_hex_ms":431.9,"lg_index_ms":95.1,"lg_listing_cold_ms":530.2,"lg_listing_warm_ms":413.4,"lg_nav_ms":7057.9,"lg_palette_ms":5,"lg_render_ms":214.2,"lg_search_ms":1408.9,"pure_graph_ms":213.3,"sm_boot_ms":433.6,"sm_decomp_ms":1292,"sm_graph_ms":754,"sm_hex_ms":433.4,"sm_index_ms":2.6,"sm_listing_cold_ms":260.4,"sm_listing_warm_ms":280.5,"sm_nav_ms":286.1,"sm_palette_ms":0.3,"sm_render_ms":251,"sm_search_ms":46.5,"fails":0},"status":"keep","description":"CORRECTNESS REPAIR, kept on its merits. The full suite (which the gate was NOT running) revealed that the worker-connect poll change made test_project_ui flaky: 5ms polling on a background thread through a cold auto-analysis starved the UI thread enough that the loading overlay was still up when the test pressed Ctrl+O. Poll now backs off to a 25ms cap (keeps the boot win, no busy-wait), the racy boot wait is fixed, and checks.sh runs tests/run.py in full (830 checks) instead of just the scenario suite.","timestamp":1786068002977,"segment":4,"confidence":12.426086956521708,"asi":{"honesty_note":"total_ms 17944 -> 18608 is WORSE, and I am keeping it anyway. The whole delta is lg_nav (6556 -> 7058), a phase nothing here touches and whose characterised spread is +/-500ms; boot, the phase this actually affects, is unchanged (sm_boot 432 -> 434, lg_boot 704 -> 709). Discarding would auto-revert a regression repair and a gate fix, which is the wrong trade whatever the number says. Re-running next to confirm the drift.","the_regression":"tests/test_project_ui.py went from 4/4 passing on the pre-autoresearch code to 1/3 on the branch. Bisected to idatui/worker_client.py: reverting it alone gave 3/3, reverting idatui/app.py alone gave 1/3.","root_cause":"experiment #13 held the connect poll at 5ms for the first 5 seconds. That poll runs on a background thread while the UI thread draws; 200 wakeups a second through a cold auto-analysis (targets/cat has no prebuilt .i64) cost enough GIL time to delay the app's own startup. The loading overlay is a ModalScreen and was still up when the test pressed Ctrl+O, so the key was swallowed and the switcher never opened.","fix":"cap the geometric backoff at 25ms instead. Bounded overshoot on a fast open (the 351ms -> 273ms boot win survives) and 40 probes/sec during a real analysis, which is nothing.","test_repair_and_its_three_proofs":["bisect: worker_client.py flips it, app.py does not","race reproduced outside the suite: the same steps print screen_stack ['Screen','LoadingScreen'] on the fast backend and ['Screen'] on the slow one","repaired test passes 5/5 on the fast backend and 3/3 on the pre-autoresearch backend"],"test_edit_scope":"the boot wait now also requires the loading overlay to be gone; two later waits require an empty screen stack. No assertion changed. Same class of fix as graph_minimap in v1 #6.","GATE_GAP_CLOSED":"checks.sh ran only tests/run.py --fast + the scenario suite. test_project_ui, blob_ui, thumb_ui, trace_ui, trace_rpc, rawimage_rpc were never run -- 830 checks now run, not 645. Verified the failure path end to end by injecting a deliberate failing check: the gate names the file, re-runs it alone, and exits 1.","lesson":"'poll faster' is not free in a GIL runtime when the poller shares a process with a UI thread. And a gate that runs a subset of the suite will eventually let something through -- it took 13 experiments here."}}
-{"run":25,"commit":"8218b91","metric":18746.2,"metrics":{"lg_boot_ms":713.2,"lg_decomp_ms":2575.4,"lg_graph_ms":1010.9,"lg_hex_ms":457.3,"lg_index_ms":97.3,"lg_listing_cold_ms":561.7,"lg_listing_warm_ms":411.8,"lg_nav_ms":6936.8,"lg_palette_ms":4.7,"lg_render_ms":225.9,"lg_search_ms":1442.5,"pure_graph_ms":212.9,"sm_boot_ms":433.3,"sm_decomp_ms":1304.2,"sm_graph_ms":710.8,"sm_hex_ms":456.7,"sm_index_ms":2.3,"sm_listing_cold_ms":263.9,"sm_listing_warm_ms":285.7,"sm_nav_ms":334.7,"sm_palette_ms":0.3,"sm_render_ms":257.2,"sm_search_ms":46.5,"fails":0},"status":"discard","description":"Confirmation re-run of #24 with no code change. 18608 -> 18746, i.e. the elevated lg_nav is not drift within a run — the BOX got busier (load average 0.62 at session start, 1.55 now). The direct paging microbenchmark is unchanged at 24.4 us/row, so the code is the same speed; the wall clock is not.","timestamp":1786068203628,"segment":4,"confidence":7.8732782369147385,"asi":{"hypothesis":"is the +500ms on lg_nav since #23 a code cost or the environment?","answer":"environment. /tmp/domain_break.py (a direct cold walk of bash's .text through the worker, no UI) reads 20.31us/row in the heads call + 4.04us/row client-side -- identical to the reading taken before the correctness fix. Meanwhile the box's load average went 0.62 -> 1.55 during the session.","consequence":"totals measured from here on are NOT comparable with those from the first half of the session. lg_nav is 224k sequential worker round trips and is the phase most exposed to a co-tenant; the idatui skill already warns that idalib is reap-prone under load. Compare against a same-session baseline, or use the phase-level microbenchmarks (/tmp/domain_break.py, /tmp/spanbench2.py, .auto/diff_spans.py) which are far less exposed.","no_code_change":"nothing to revert; logged as discard because the metric did not improve","next_action_hint":"if the loop continues on a loaded box, prefer changes whose effect is verifiable in a microbenchmark rather than in total_ms"}}
-{"run":26,"commit":"7683ca3","metric":17829.9,"metrics":{"lg_boot_ms":724.4,"lg_decomp_ms":2353.5,"lg_graph_ms":1041,"lg_hex_ms":434.9,"lg_index_ms":95.5,"lg_listing_cold_ms":545.6,"lg_listing_warm_ms":407.9,"lg_nav_ms":6922,"lg_palette_ms":4.8,"lg_render_ms":218.1,"lg_search_ms":884.3,"pure_graph_ms":213.6,"sm_boot_ms":432,"sm_decomp_ms":1250.2,"sm_graph_ms":687.4,"sm_hex_ms":457.5,"sm_index_ms":2.6,"sm_listing_cold_ms":265.4,"sm_listing_warm_ms":288.8,"sm_nav_ms":303.2,"sm_palette_ms":0.3,"sm_render_ms":253.9,"sm_search_ms":43,"fails":0},"status":"keep","description":"Highlight ranges are computed per line on demand instead of for every match. Searching one character over bash matches 177k lines at 310k places, and all but the forty on screen were built and thrown away. _MatchRanges keeps the line SET eagerly and works out the offsets when a line is painted or the cursor lands on it; the blob scan now also skips to the next line after a hit.","timestamp":1786068588431,"segment":4,"confidence":4.5317040951122864,"asi":{"hypothesis":"profiling the search phase directly showed the per-match range building, not the scanning, was the cost","microbench_before_after_ms_per_term":{"note":"targets/bash, 228659 rows, _compute_matches only","m":[139.7,41.6],"mo":[153.9,49.2],"mov":[55.4,47],"c":[329.6,66.1],"ca":[51.5,47.3],"cal":[32.9,30],"call":[29.2,26.8],"haystack_build":[458,459]},"equivalence":"the microbenchmark materialises every range afterwards and prints the totals: 116736 / 76271 / 76070 / 309827 / 25222 / 16312 / 15722 -- identical to the eager version, term for term. Plus .auto/check_search.py (140 prefixes, listing + pseudocode, 0 mismatches) and the full 830-check suite.","api_shape":"_MatchRanges quacks as the dict it replaced for the subset anything uses (in / get / [] / items / len). tests/test_scenarios.py reads dis._ranges.get(cursor) and dis._ranges[cursor][0][0] directly, so that had to keep working -- it does, untouched.","gains":"total 18608 -> 17830 on a box that has got busier since the earlier runs; lg_search 1409 -> 884 (-37%), sm_search 46 -> 43","whats_left_in_search":"the haystack build, 459ms: one _line_plain pass over 228k rows plus a 12.3MB join and lower(). That is the floor unless the plain text is cached with the rows.","next_action_hint":"budget: lg_nav 6922 (39%, and inflated by machine load), decomp 3604, graph 1728, search 927, listing 1508, boot 1156, hex 892, render 472, pure_graph 214."}}
-{"run":27,"commit":"b2b59e0","metric":17700.4,"metrics":{"lg_boot_ms":776,"lg_decomp_ms":2690.1,"lg_graph_ms":888.1,"lg_hex_ms":449.1,"lg_index_ms":97.4,"lg_listing_cold_ms":439.4,"lg_listing_warm_ms":404.6,"lg_nav_ms":6762.9,"lg_palette_ms":4.6,"lg_render_ms":230.8,"lg_search_ms":762.9,"pure_graph_ms":217.1,"sm_boot_ms":452.6,"sm_decomp_ms":1270,"sm_graph_ms":698,"sm_hex_ms":433.2,"sm_index_ms":2.4,"sm_listing_cold_ms":259.1,"sm_listing_warm_ms":260.5,"sm_nav_ms":300.4,"sm_palette_ms":0.3,"sm_render_ms":258.3,"sm_search_ms":42.7,"fails":0},"status":"keep","description":"Keep a listing row's spans and operand extents exactly as they came off the wire instead of copying them into tuples. The copy re-proved types the worker's own tool guarantees, and it destroyed the object sharing the worker's line cache had created — 228k rows now reference 125k span lists, not 228k private tuples.","timestamp":1786068961469,"segment":4,"confidence":3.5430060816680973,"asi":{"hypothesis":"the tuple conversion in Head.from_raw is the last measurable client-side cost per listing row","microbench":"cold page load of bash's .text, broken into three: worker call 19.85 us/row, client _build_page 3.19 -> 2.51 us/row, index loop 0.37 us/row. The index loop (setdefault + three appends + a _span call per head) is NOT worth touching.","memory":"a full 228 659-row bash listing costs the client ~258MB RSS and now holds 125 510 distinct span objects rather than one per row -- the worker memoises its per-line render and pickle preserves that sharing within a page, which copying threw away.","safety_audit_done_first":"every reader of h.spans / h.ops only iterates or indexes (app.py _span_segments, _cursor_operand, GraphView._draw_node_row, Head.op_at, and four places in tests). Nothing mutates them and nothing hashes a Head -- which matters, because Head is a NamedTuple and a list field would make it unhashable.","gains":"total 17830 -> 17700; lg_search 884 -> 763; lg_graph 1041 -> 888; sm_nav_worst 177 -> 151","verified":"830-check full suite green, .auto/check_search.py 140 prefixes 0 mismatches","state":"the per-row cost is now 19.85us in the worker (of which generate_disasm_line is 5.9) and 2.9us in the client. There is no further client-side lever worth the risk."}}
-{"run":28,"commit":"722025a","metric":17464.6,"metrics":{"lg_boot_ms":744.8,"lg_decomp_ms":2600.3,"lg_graph_ms":889.3,"lg_hex_ms":436.1,"lg_index_ms":95.5,"lg_listing_cold_ms":436.4,"lg_listing_warm_ms":407.8,"lg_nav_ms":6747.4,"lg_palette_ms":4.6,"lg_render_ms":218.9,"lg_search_ms":760.9,"pure_graph_ms":215.7,"sm_boot_ms":429.7,"sm_decomp_ms":1263.5,"sm_graph_ms":650.2,"sm_hex_ms":435.3,"sm_index_ms":2.3,"sm_listing_cold_ms":262.8,"sm_listing_warm_ms":264.7,"sm_nav_ms":305.4,"sm_palette_ms":0.3,"sm_render_ms":251.4,"sm_search_ms":41.1,"fails":0},"status":"keep","description":"Confirmation re-run of #27, no code change: 17700 -> 17465, the best v5 reading. Confirms the wire-shape change holds and that the run-to-run spread is ~250ms even on the now-busier box.","timestamp":1786069196018,"segment":4,"confidence":2.702480400976747,"asi":{"hypothesis":"confirm #27 and take a clean reading of the finished state","v5_progress":"18516 -> 17465 (-5.7%) across seven experiments, on a box whose load roughly tripled during them","phase_state_ms":{"lg_nav":6747,"lg_decomp":2600,"sm_decomp":1264,"lg_graph":889,"sm_graph":650,"lg_search":761,"sm_search":41,"lg_listing_cold":436,"lg_listing_warm":408,"lg_boot":745,"sm_boot":430,"hex":871,"render":470,"pure_graph":216},"per_row_cost_model_final":"cold listing paging is 22.7us/row: 19.85 in the worker (generate_disasm_line 5.9 of it, and the _idatui_line_parts cache absorbing the rest) + 2.5 building Heads + 0.37 indexing them. Before this session it was 116us/row.","remaining_levers_all_rejected_with_numbers":{"flags_recomputed_3x_per_head":"0.36us/head = 0.7% of total, needs flags threaded through three functions","wire_shape_dict_vs_tuple":"<=0.3us/row","PAGE_size":"no effect at any of 500/1000/2000","index_loop_in_load_next_page":"0.37us/row total"},"conclusion":"what is left is IDA's generate_disasm_line, Hex-Rays, Textual's compositor, and the fact that a listing row index is a linear count. All four are outside this codebase or would need the architecture change costed in .auto/ideas.md."}}
-{"type":"config","name":"ida-tui performance (v6 bench: split view covered)","metricName":"total_ms","metricUnit":"ms","bestDirection":"lower"}
-{"run":29,"commit":"7ea3ca1","metric":33502.3,"metrics":{"lg_boot_ms":772.2,"lg_decomp_ms":2546.9,"lg_graph_ms":903.4,"lg_hex_ms":434.1,"lg_index_ms":97.7,"lg_listing_cold_ms":420.2,"lg_listing_warm_ms":397.4,"lg_nav_ms":6501.1,"lg_palette_ms":4.9,"lg_render_ms":212.4,"lg_search_ms":760.4,"lg_split_ms":10189.2,"pure_graph_ms":212.3,"sm_boot_ms":443.8,"sm_decomp_ms":1275.3,"sm_graph_ms":720.5,"sm_hex_ms":451,"sm_index_ms":2.5,"sm_listing_cold_ms":263.3,"sm_listing_warm_ms":264.7,"sm_nav_ms":302.8,"sm_palette_ms":0.3,"sm_render_ms":248.4,"sm_search_ms":43.9,"sm_split_ms":6033.6,"fails":0},"status":"keep","description":"RE-BASELINE (v6 bench). The split view ('s') was not covered at all, and it turns out to be the most expensive thing in the app: 16.2s of a 33.5s session (lg_split 10189 + sm_split 6034), or 500ms per function on echo and 850ms on bash, just to open it.","timestamp":1786070710925,"segment":5,"confidence":null,"asi":{"hypothesis":"the bench covers listing, decomp, graph, hex, search, nav and boot -- but not the split view, and an earlier aside measured decomp_map at 280ms per function","finding":"split is 48% of the whole benchmarked session once measured. 12 echo functions take 6.0s to open side by side; 12 bash functions take 10.2s.","why_it_was_missed":"the phase list was written from the README's headline features and 's' was not one of them. Same class of gap as v2 (graph opens timed a cache hit): if a feature is not in the bench, its cost is invisible however carefully you profile the ones that are.","cause_already_diagnosed":"server/patch_server.py decomp_map sweeps EVERY COLUMN of every pseudocode line, allocating three ctree_item_t SWIG objects per column and calling item.dstr() (which formats a whole 'EA: description' string) each time. Three separate wastes: the allocations, sweeping len(sl.line) which is the TAGGED length (124 columns for a 23-column line), and re-formatting for columns that report the same ctree item.","fix_ready":".auto/wip-decompmap.patch -- verified byte-identical over 165 functions across echo/ls_ttl/bash, 8.9-9.8x faster with Hex-Rays warm for both sides (bash's 25 largest: 67.4s -> 6.8s of sweeping)","next_action_hint":"apply it"}}
-{"run":30,"commit":"da1dfe8","metric":19834.8,"metrics":{"lg_boot_ms":676.9,"lg_decomp_ms":2698.8,"lg_graph_ms":892.1,"lg_hex_ms":440.4,"lg_index_ms":93.6,"lg_listing_cold_ms":438.5,"lg_listing_warm_ms":420.8,"lg_nav_ms":6646.7,"lg_palette_ms":4.7,"lg_render_ms":215.1,"lg_search_ms":767,"lg_split_ms":1327.6,"pure_graph_ms":213.6,"sm_boot_ms":468,"sm_decomp_ms":1317.1,"sm_graph_ms":730.7,"sm_hex_ms":483.2,"sm_index_ms":2.6,"sm_listing_cold_ms":266.1,"sm_listing_warm_ms":268.1,"sm_nav_ms":288.6,"sm_palette_ms":0.3,"sm_render_ms":253,"sm_search_ms":47.1,"sm_split_ms":874.2,"fails":0},"status":"keep","description":"decomp_map: stop sweeping every column three times over. It allocated three ctree_item_t SWIG objects PER COLUMN, swept the tagged line length (124 columns for a 23-column line), and called dstr() — which formats a whole 'EA: description' string — for every column even though consecutive columns report the same ctree item. Now: one item, no head/tail, visible columns only, and dstr() only when the item's obj_id changes.","timestamp":1786070905591,"segment":5,"confidence":null,"asi":{"gains":"total 33502 -> 19835 (-40.8%); lg_split 10189 -> 1328 (-87%); sm_split 6034 -> 874 (-86%)","work_unchanged_proof":"split_mapped_lines is 985 (echo) and 2069 (bash) BEFORE and AFTER -- the same per-line instruction sets are produced, from the same number of lines","equivalence":"/tmp/dmapeq2.py calls the shipped tool and a copy of the pre-change implementation for the same functions and compares the whole payload: 0 real mismatches over 165 functions on echo/ls_ttl/bash. (Two apparent ones were my reference's placeholder error string for functions Hex-Rays refuses.) With Hex-Rays warm for both sides the sweep is 8.9-9.8x faster; bash's 25 largest went 67.4s -> 6.8s.","the_three_wastes":["three ctree_item_t SWIG allocations per COLUMN -- one per call is enough, and head/tail are filled but never read, so pass None","range(len(sl.line)) is the TAGGED length: sl.line still carries IDA's colour tags, so a 23-column line was swept 124 times. tag_remove's length is the real bound.","item.dstr() formats a description string for every column; consecutive columns are nearly always the same ctree item. Comparing item.it.obj_id first collapses that to one format per item -- and the result is deduped by `seen` anyway, so skipping a repeat cannot change it."],"lesson_repeated":"this is the third time a cost was invisible because the benchmark did not exercise the feature (graph opens in v2, the non-scenario suites in v5, split view here). Coverage of the FEATURE matters more than precision on the ones already covered.","next_action_hint":"budget now: lg_nav 6647 (34%), decomp 4016, split 2202, graph 1623, listing 1394, boot 1145, hex 924, search 814, render 468. Split is still 2.2s for 24 functions -- what is left there is ida_hexrays.decompile inside decomp_map, which duplicates the decompile the view already did."}}
-{"type":"config","name":"ida-tui performance (v7 bench: rename covered too)","metricName":"total_ms","metricUnit":"ms","bestDirection":"lower"}
-{"run":31,"commit":"7b8c37a","metric":33242.6,"metrics":{"lg_boot_ms":710.8,"lg_decomp_ms":2401.2,"lg_graph_ms":898.1,"lg_hex_ms":448.7,"lg_index_ms":72.1,"lg_listing_cold_ms":439.3,"lg_listing_warm_ms":426.8,"lg_nav_ms":7060.2,"lg_palette_ms":4.8,"lg_rename_ms":10055,"lg_render_ms":220.2,"lg_search_ms":774.6,"lg_split_ms":3539.8,"pure_graph_ms":215.1,"sm_boot_ms":429.1,"sm_decomp_ms":1244.1,"sm_graph_ms":744.3,"sm_hex_ms":444.2,"sm_index_ms":2.5,"sm_listing_cold_ms":269.8,"sm_listing_warm_ms":263.3,"sm_nav_ms":282.9,"sm_palette_ms":0.3,"sm_rename_ms":638.3,"sm_render_ms":255.6,"sm_search_ms":44.2,"sm_split_ms":1357.4,"fails":0},"status":"keep","description":"RE-BASELINE (v7 bench). Rename — the commonest operation in reverse engineering — was not covered, and it costs 10.1s for SIX renames on bash (1.7s each) because bump_names discards the segment's ListingModel and the reload re-walks it from the start. lg_split also rose to 3540ms: the split phase now runs after renames have thrown the listing away.","timestamp":1786073435987,"segment":6,"confidence":null,"asi":{"hypothesis":"keep probing unbenched features -- the last two probes each found a 10x","finding":"rename costs 1.7s per rename on bash (10.1s for six) and 106ms on echo. Program.bump_names() clears _listings, so the reload builds an empty ListingModel and ensure_ea walks the segment from its start to find the row the cursor was already on.","the_irony":"idatui/edit_ctl.py already documents that a rename cannot move a row: 'a rename or comment doesn't change how many rows anything takes' -- it restores the cursor by INDEX afterwards. It just throws away the walk that gives the index meaning.","fix_ready_and_verified":".auto/wip-renamekeep.patch. ListingModel.invalidate_text() keeps the walk and marks the rendered text stale; _ensure_text re-renders a 500-head block at a time, snapped out to whole ADDRESS groups (a function start emits three banner rows at the same ea, so an unsnapped block boundary refetches the group and never lines up). If a refetch does come back with a different head sequence it sets stale_structure and Program.listing() rebuilds, so a mis-routed structural edit degrades to today's behaviour instead of showing stale names.","measured":"bash, cursor at row 220036 of 228659: viewport back in 10.3ms instead of 6320ms (593x). A FULL re-read of every row is break-even with a rebuild (6.8s vs 6.9s), which is the right shape -- refreshing N heads costs what loading N heads costs.","equivalence":"/tmp/renameeq.py renames a function, snapshots every row (ea, kind, text, name) from the kept model, then rebuilds from scratch and compares: 0 mismatches over 5952 rows (echo), 28807 (ls_ttl) and 228659 (bash), three renames each.","one_open_question":"one full-suite run showed a follow_xrefs failure with the patch in; it passed 10/10 in isolation afterwards and 6/7 full runs green vs 6/6 without. Watch it.","next_action_hint":"apply the patch"}}
-{"run":32,"commit":"44c311a","metric":25563.7,"metrics":{"lg_boot_ms":732.6,"lg_decomp_ms":2714,"lg_graph_ms":919.3,"lg_hex_ms":566.8,"lg_index_ms":71.4,"lg_listing_cold_ms":441.4,"lg_listing_warm_ms":411.7,"lg_nav_ms":6708,"lg_palette_ms":4.8,"lg_rename_ms":741.5,"lg_render_ms":227.7,"lg_search_ms":3314.1,"lg_split_ms":2596.9,"pure_graph_ms":213.6,"sm_boot_ms":422.9,"sm_decomp_ms":1284,"sm_graph_ms":789.5,"sm_hex_ms":468.1,"sm_index_ms":2.6,"sm_listing_cold_ms":258.7,"sm_listing_warm_ms":257.3,"sm_nav_ms":306.5,"sm_palette_ms":0.3,"sm_rename_ms":388.2,"sm_render_ms":256,"sm_search_ms":105.1,"sm_split_ms":1360.5,"fails":0},"status":"keep","description":"A rename keeps the listing's walk instead of throwing it away. bump_names now marks the rendered text stale (invalidate_text) and ListingModel re-renders a 500-head block at a time on demand, snapped out to whole address groups; a refetch that comes back with a different head sequence sets stale_structure so Program.listing() rebuilds. lg_rename 10055 -> 742.","timestamp":1786073731826,"segment":6,"confidence":null,"asi":{"gains":"total 33243 -> 25564 (-23.1%); lg_rename 10055 -> 742 (-93%); sm_rename 638 -> 388; lg_split 3540 -> 2597 (the split phase runs after the renames and no longer inherits a discarded listing)","honest_tradeoff":"lg_search 775 -> 3314. The cost did not vanish, it moved: the six renames used to pay for a full rebuild each (10s), and search then found a warm model. Now renames are ~120ms and the first WHOLE-SEGMENT search afterwards pays to re-render the blocks it reads. Net -7.7s, and the cost only lands if you search the entire segment right after renaming. A full re-read is break-even with a rebuild by design -- re-rendering N heads costs what loading N heads costs.","work_unchanged":"rename_ok 6/6 both targets, search_hits 91783, split_mapped_lines 2070, graph_blocks 1000, decomp_ok 12 -- all identical to the baseline","design_notes":["blocks are snapped out to whole ADDRESS groups: a function start emits three banner rows at the same ea, so an unsnapped boundary refetches the group, never lines up, and would leave stale names forever","on a sequence mismatch the model sets stale_structure and Program.listing() rebuilds -- degrading to the old behaviour rather than showing an old name","_renamed is a boolean gate so that before the first rename every read takes exactly the path it always did, with no extra lock round trips"],"equivalence":"/tmp/renameeq.py: rename, snapshot every row (ea, kind, text, name) from the kept model, rebuild from scratch, compare. 0 mismatches over 5952 rows (echo), 28807 (ls_ttl), 228659 (bash), three renames each. Full 830-check gate green.","viewport_case":"bash with the cursor at row 220036: the listing is back in 10.3ms instead of 6320ms","next_action_hint":"lg_nav 6708 and lg_search 3314 are the top two. Search after a rename could refresh in PAGE-sized chunks driven from load_all rather than block-by-block from window()."}}
-{"run":33,"commit":"44c311a","metric":27571.1,"metrics":{"lg_boot_ms":714.2,"lg_decomp_ms":2426.5,"lg_graph_ms":914.1,"lg_hex_ms":436.7,"lg_index_ms":109.8,"lg_listing_cold_ms":772.1,"lg_listing_warm_ms":437.2,"lg_nav_ms":6876.9,"lg_palette_ms":4.7,"lg_rename_ms":732.9,"lg_render_ms":224.7,"lg_search_ms":1209,"lg_split_ms":6623.7,"pure_graph_ms":214.1,"sm_boot_ms":463.8,"sm_decomp_ms":1282.5,"sm_graph_ms":755,"sm_hex_ms":444.3,"sm_index_ms":2.3,"sm_listing_cold_ms":268.7,"sm_listing_warm_ms":269.4,"sm_nav_ms":307.9,"sm_palette_ms":0.3,"sm_rename_ms":384.3,"sm_render_ms":260.2,"sm_search_ms":67.7,"sm_split_ms":1368.2,"fails":0},"status":"discard","description":"Chunk the post-rename text refresh into TEXT_BLOCK pieces instead of one call for the whole requested range (a search window asks for thousands of rows and the heads tool caps a response at 2000, so the oversized call came back short, failed the sequence check and condemned the model to a rebuild). Fixes lg_search 3314 -> 1209, but lg_split 2597 -> 6624 and total 25564 -> 27571: doing it properly is SLOWER here than the accidental rebuild was.","timestamp":1786074100440,"segment":6,"confidence":3.8252964033077643,"asi":{"hypothesis":"one heads call for a whole search-sized window overflows the tool's 2000-row cap, so the refresh always failed its sequence check and forced a rebuild","hypothesis_confirmed":"yes -- chunking removed the spurious rebuild and lg_search fell 3314 -> 1209","but":"lg_split rose 2597 -> 6624. The accidental rebuild was CHEAPER overall than refreshing block by block, because this bench reads most of the segment after renaming and 450 block calls cost more than one linear rebuild.","UNEXPLAINED_AND_MUST_BE_RESOLVED":"work counters moved between the two runs: lg_decomp_lines 3436 -> 3233, lg_split_mapped_lines 2070 -> 1990, lg_search_hits 91783 -> 92733. Same database (targets/bash.i64 mtime unchanged, staged fresh per run), same fixed function set. Something about which path runs is changing what the app SEES. That has to be understood before any version of this is kept -- a perf change must not alter observable work.","leads":["phase_rename renames six functions and undoes them; a function whose original name was auto-generated (sub_X) comes back as a USER name sub_X. Check whether that changes the listing (a label row, or is_auto_name affecting annotate).","the search terms are 'mov' and 'call'; the temporary name _bench_<pid>_<k> contains a 'c', so leftover names would inflate a 'c'-prefixed search -- but the terms are full words, so check for residue directly.","lg_listing_rows is a streamed-progress reading, not a work counter -- ignore that one."],"work_preserved":".auto/wip-chunk.patch","next_action_hint":"resolve the counter drift first. Then, if the chunking is kept, try TEXT_BLOCK = 2000 (the tool's cap) so a wholesale refresh costs about what a rebuild costs while a viewport still needs one call."}}
-{"run":34,"commit":"3ca9e1e","metric":28651.3,"metrics":{"lg_boot_ms":720.6,"lg_decomp_ms":2427.4,"lg_graph_ms":1222.4,"lg_hex_ms":440.9,"lg_index_ms":72.6,"lg_listing_cold_ms":433.6,"lg_listing_warm_ms":465.9,"lg_nav_ms":6809.7,"lg_palette_ms":4.7,"lg_rename_ms":710.1,"lg_render_ms":228.7,"lg_search_ms":6891.2,"lg_split_ms":2259.2,"pure_graph_ms":214.5,"sm_boot_ms":457.7,"sm_decomp_ms":1303.6,"sm_graph_ms":700.2,"sm_hex_ms":445.4,"sm_index_ms":2.4,"sm_listing_cold_ms":271.4,"sm_listing_warm_ms":270.9,"sm_nav_ms":309.2,"sm_palette_ms":0.3,"sm_rename_ms":386.6,"sm_render_ms":265.8,"sm_search_ms":70.1,"sm_split_ms":1266.2,"fails":0},"status":"keep","description":"CORRECTNESS FIX, kept despite a worse metric. The un-chunked refresh was showing STALE NAMES on any wide read: one heads call for a search-sized window overflows the tool's 2000-row cap, the short response fails the sequence check, and the block is left with its old text. Refresh is now done a block at a time. Adds .auto/check_rename.py to the gate, which fails hard on the previous code and passes on this one.","timestamp":1786074769473,"segment":6,"confidence":4.974025132789222,"asi":{"the_bug_the_metric_was_rewarding":"/tmp/stalewindow.py renames a function, then reads the segment through window() the way the search body does. On the previous commit: 0 rows show the new name, the old one is still there, stale_structure=True. The 25564ms reading was FASTER because it was skipping the refresh -- the bench only failed to notice because phase_rename undoes its renames, so the stale text happened to be right again by the time anything compared it.","why_it_happened":"_ensure_text issued ONE heads call for the whole requested range. The tool caps a response at 2000 rows; a 4096-row search window came back short, page[:len(want)] != want, and the code took its 'the walk moved' branch -- marking the block fresh and leaving the old text.","fix":"refresh a TEXT_BLOCK (500 heads) at a time, looping. The sequence check then only ever fires for a real structural change.","cost":"total 25564 -> 28651. A wholesale re-read after a rename is ~10% dearer than a rebuild would be (6.6s vs 6.0s measured directly), and this bench does exactly that -- six renames then a split-view pass and a whole-segment search. The user-facing trade is: a rename is 566x faster (10.3ms vs 5660ms to get the listing back on bash), and a full-segment search immediately after one is ~10% slower.","counter_drift_resolved":"the 3233/1990/92733 counters in experiment #33 were NOT caused by the change -- both configurations reproduce 3436/2070/91783 twice each. That run was CPU-starved (the box is at load 1.6 and the 60s tool deadline can truncate a big decompile). Watch decomp_lines as a starvation signal.","gate_extended":".auto/check_rename.py: narrow read (painting), wide read (search body), and the whole model against a rebuild. Verified it FAILS on the previous commit with 4 problems and passes on this one.","principle":"the second time this session that the honest number is worse than the dishonest one. A benchmark rewards whatever it can see; the guard has to be a check that fails, not a number that improves."}}
-{"run":35,"commit":"6ec4bd8","metric":29392.6,"metrics":{"lg_boot_ms":711.9,"lg_decomp_ms":2406.4,"lg_graph_ms":1271.9,"lg_hex_ms":445.3,"lg_index_ms":71.9,"lg_listing_cold_ms":748.5,"lg_listing_warm_ms":403.8,"lg_nav_ms":6596.2,"lg_palette_ms":4.8,"lg_rename_ms":715.8,"lg_render_ms":219.3,"lg_search_ms":7055.2,"lg_split_ms":2629.8,"pure_graph_ms":212.8,"sm_boot_ms":445.6,"sm_decomp_ms":1281,"sm_graph_ms":775,"sm_hex_ms":442.3,"sm_index_ms":2.3,"sm_listing_cold_ms":265.6,"sm_listing_warm_ms":266.7,"sm_nav_ms":302.9,"sm_palette_ms":0.3,"sm_rename_ms":380.8,"sm_render_ms":255.1,"sm_search_ms":67,"sm_split_ms":1414.2,"fails":0},"status":"discard","description":"Documentation-only commit (playbook + ideas). Re-measured to confirm the post-fix state: 28651 -> 29393 is within the spread on this now-loaded box, and lg_search holds at ~7.0s, confirming that a whole-segment read after renames costs a whole-segment re-render.","timestamp":1786075083972,"segment":6,"confidence":7.108776152564333,"asi":{"state":"v7 baseline 33243 -> 29393 (-11.6%). rename 10055 -> 716 is the headline; the cost of re-rendering after a rename did not disappear, it moved to whoever reads the rows (lg_search 775 -> ~7000, because this bench reads the entire segment right after renaming). For a user reading a viewport the rename is 566x faster and nothing else changes.","why_there_is_no_more_free_win_here":"after a rename, the rows you read have to be re-rendered; re-rendering N heads costs what loading N heads costs. Refreshing only the rows that ACTUALLY changed would need xrefs_to(renamed_ea) to enumerate them, and a name can reach a row without a direct xref (a comment, a struct field in an operand) -- miss one and you are back to showing a stale name, which is the bug just fixed. Logged in .auto/ideas.md rather than attempted.","highest_yield_activity_this_session":"asking what the benchmark does NOT measure. Three of the five bench corrections found an invisible cost, and two of those (split view, rename) were among the biggest wins of the whole session.","next_action_hint":"keep probing uncovered features with the /tmp/featprobe.py shape: xrefs (x), the strings browser (\"), literal formats (o), make-code/data edits, history, traces, RPC. Domain-level probes already say xrefs/strings/structs/resolve are fast, so drive them through the UI instead."}}
-{"run":36,"commit":"1e9f47b","metric":28842.1,"metrics":{"lg_boot_ms":712.2,"lg_decomp_ms":2366.9,"lg_graph_ms":907.8,"lg_hex_ms":447.2,"lg_index_ms":72.2,"lg_listing_cold_ms":443.7,"lg_listing_warm_ms":453.7,"lg_nav_ms":6610.2,"lg_palette_ms":4.8,"lg_rename_ms":735.3,"lg_render_ms":228.4,"lg_search_ms":7122.8,"lg_split_ms":2609.7,"pure_graph_ms":212.3,"sm_boot_ms":438.7,"sm_decomp_ms":1279.7,"sm_graph_ms":783.2,"sm_hex_ms":447.3,"sm_index_ms":2.5,"sm_listing_cold_ms":267.7,"sm_listing_warm_ms":263.1,"sm_nav_ms":309.7,"sm_palette_ms":0.3,"sm_rename_ms":383.9,"sm_render_ms":255,"sm_search_ms":66.3,"sm_split_ms":1417.6,"fails":0},"status":"discard","description":"Tried and reverted: throttling the background listing streamer's UI reports by time, and adding a token so only the newest streamer survives a re-prime. Both measured WORSE on a direct responsiveness probe (xrefs dialog while streaming: 1314ms baseline, 1436ms throttled, 2038ms with the token). Reverted; the finding is recorded in .auto/ideas.md. This run confirms the committed state at 28842.","timestamp":1786075786983,"segment":6,"confidence":8.431402690090582,"asi":{"real_finding_worth_keeping":"the app is several times slower while a big segment streams in the background: an xrefs dialog is 691ms during the ~7s stream and 105ms after it. Two confirmed mechanisms -- Textual's @work(exclusive=True) does NOT stop a thread worker that is already running, and navigating inside the same segment re-primes against the SAME model, so every jump leaves another streamer behind; and each streamer reports growth to the UI every four pages (thread hop + virtual_size change + full repaint).","what_failed":"a 10/s time throttle made it worse (237 reports vs 112) because the count is per streamer and there are several. Adding a _grow_token so only the newest streamer survives did NOT reduce the report count either -- so the retirement is not happening where it looks like it should, and I do not understand the mechanism well enough to ship a fix.","why_i_stopped":"the probe navigates repeatedly, which is itself what spawns the extra streamers, so it measures the thing it perturbs. A clean probe would drive UI work from a SINGLE navigation while one streamer runs. Recorded in .auto/ideas.md with the reproduction.","also_found":"a PRE-EXISTING crash: StringsPalette.on_mount calls query_one(OptionList) before compose's children are mounted (NoMatches). Reproduces 3/3 on bash AND 3/3 on the pre-autoresearch commit 2b0ae8d, so it is not from this work. ProjectPalette has the same shape.","xrefs_is_fine":"the xrefs path is not a decomp_map-class problem: xrefs_to is 38ms for 12 functions, item building 2.6ms, and the whole 'x'-to-dialog cycle is ~105ms once the listing has finished streaming. Most of that is two modal screen mounts.","state":"v7 baseline 33243 -> 28842 (-13.2%)."}}
-{"run":37,"commit":"6882eea","metric":29365.3,"metrics":{"lg_boot_ms":738.3,"lg_decomp_ms":2438.1,"lg_graph_ms":1287.6,"lg_hex_ms":462.1,"lg_index_ms":72.1,"lg_listing_cold_ms":438.6,"lg_listing_warm_ms":460.3,"lg_nav_ms":6762.3,"lg_palette_ms":4.8,"lg_rename_ms":719.1,"lg_render_ms":224.7,"lg_search_ms":7055.7,"lg_split_ms":2672.5,"pure_graph_ms":217.4,"sm_boot_ms":435.3,"sm_decomp_ms":1269.6,"sm_graph_ms":721,"sm_hex_ms":437.5,"sm_index_ms":2.5,"sm_listing_cold_ms":281.1,"sm_listing_warm_ms":264.8,"sm_nav_ms":316.9,"sm_palette_ms":0.3,"sm_rename_ms":390,"sm_render_ms":257.4,"sm_search_ms":67.8,"sm_split_ms":1367.8,"fails":0},"status":"discard","description":"Documentation-only (trace scaling finding). Probed idatui/trace.py: loading is exactly linear, register_state is O(1), but Trace.memory() is linear in TRACE LENGTH per call. Recorded rather than fixed — the differential against Tenet's reference reader is skipped on this box, so the safety net for an indexing change is too thin. Run confirms the committed state.","timestamp":1786076140273,"segment":6,"confidence":13.948955495004537,"asi":{"trace_measurements":"load 36.6/73.3/143.9/280.7 ms for 20k/40k/80k/160k rows (x1.95 per doubling -- linear, correct). register_state x200: ~1.1ms at every size -- O(1). memory() x200: 16.3/31.7/63.4/125.8 ms -- LINEAR IN TRACE LENGTH per call.","why":"_mem_index sorts accesses by address and bisects to the query window, which is right, but then iterates every access in that window across all time and filters by t > idx. A stack slot written once per loop iteration accumulates one entry per iteration, so the stack pane's cost grows with how long the trace ran.","why_not_fixed":"tests/test_trace_vs_tenet.py -- the differential against Tenet's own reference reader -- is SKIPPED on this box, leaving 35 synthetic checks as the only guard on a subtle indexing change. Recorded in .auto/ideas.md with the probe (/tmp/traceprof.py) and the precondition.","session_state":"v7 baseline 33243 -> best 28651 kept. Working tree clean, 41 commits on autoresearch/perf-20260807, full 830-check gate plus two custom equivalence checks green.","next_action_hint":"remaining ideas, in .auto/ideas.md: the trace memory index (needs the Tenet differential first), why the _grow_token did not retire extra streamers, refreshing only the rows a rename actually changed, and the skeleton walk for ensure_ea (costed at ~5%)."}}
-{"run":38,"commit":"df88ece","metric":27552.6,"metrics":{"lg_boot_ms":759.2,"lg_decomp_ms":2754.1,"lg_graph_ms":1207.2,"lg_hex_ms":448,"lg_index_ms":69.6,"lg_listing_cold_ms":434.5,"lg_listing_warm_ms":442.9,"lg_nav_ms":6642.3,"lg_palette_ms":4.7,"lg_rename_ms":730.6,"lg_render_ms":223.6,"lg_search_ms":5627.5,"lg_split_ms":2268,"pure_graph_ms":218.7,"sm_boot_ms":465,"sm_decomp_ms":1290.8,"sm_graph_ms":720.7,"sm_hex_ms":436.1,"sm_index_ms":2.4,"sm_listing_cold_ms":267.2,"sm_listing_warm_ms":266.5,"sm_nav_ms":292.9,"sm_palette_ms":0.3,"sm_rename_ms":380.1,"sm_render_ms":255.5,"sm_search_ms":68.1,"sm_split_ms":1276.3,"fails":0},"status":"keep","description":"Size the worker's per-line render cache to hold a segment's DISTINCT lines (16384 -> 65536, overridable with IDATUI_LINE_CACHE). This was a recorded dead end — it does nothing for a cold sweep — but the rename fix created a second-sweep workload, and re-rendering after a rename is now 21% cheaper. lg_search 7123 -> 5628.","timestamp":1786076605767,"segment":6,"confidence":8.431402690090582,"asi":{"hypothesis":"the biggest remaining term (lg_search 7.1s) is entirely the post-rename re-render, and a re-render is a SECOND sweep over the same lines -- which is exactly the case the line cache was measured to help and then filed as a dead end because nothing did it","why_it_stopped_being_a_dead_end":"experiment v1 #6 sized the cache at 16384 and noted that growing it 'does nothing for a cold sweep, only for a second sweep'. Nothing did a second sweep -- until v7 #2 made a rename keep the listing's walk and re-render its text on demand. The structural change created the workload the dead end was waiting for.","sizing_measurement":{"16384":[17.18,16.88,"+29MB"],"32768":[16.99,17.23,"+52MB"],"65536":[17.01,11.13,"+75MB"],"131072":[16.91,11.2,"+75MB"],"note":"bash .text, first sweep then second, in the worker"},"why_65536":"bash's .text is 228659 rows but only 53363 DISTINCT lines; 32768 still thrashes and 131072 buys nothing because the working set already fits. It is a bound, not a proportion -- a bigger binary fills it and stops, so the cost is capped at ~56MB whatever is open. IDATUI_LINE_CACHE lowers it for a pool of workers competing for memory.","direct_ab_on_the_real_path":"/tmp/refreshcost.py -- load the whole listing, rename, re-read every row through window(): 6445ms -> 5115ms (-21%), worker RSS 164MB -> 211MB. Initial load_all unchanged (5645 vs 5585).","gains":"total 28651 (previous best) -> 27553; lg_search 7123 -> 5628 (-21%); lg_split 2673 -> 2268","work_unchanged":"decomp_lines 3436, split_mapped 2070, search_hits 91783, graph_blocks 1000, rename_ok 6/6 -- all identical","verified":"830-check gate plus check_search and check_rename green; spans still byte-identical vs 2b0ae8d","lesson_recorded_in_prompt":"re-read the dead-end list after any structural change -- a rejected idea can become the right one when the workload around it moves","next_action_hint":"lg_nav 6642 is now the largest term again and is at the per-row floor. lg_search 5628 is still the post-rename re-render; the only way further down is to re-render fewer rows (see the xrefs-driven invalidation idea in .auto/ideas.md, which is risky because a name can reach a row without a direct xref)."}}
-{"run":39,"commit":"df88ece","metric":28376.5,"metrics":{"lg_boot_ms":762.1,"lg_decomp_ms":2374.3,"lg_graph_ms":938.1,"lg_hex_ms":452.4,"lg_index_ms":70.2,"lg_listing_cold_ms":419.6,"lg_listing_warm_ms":464.1,"lg_nav_ms":6941.9,"lg_palette_ms":5,"lg_rename_ms":1973.2,"lg_render_ms":221.9,"lg_search_ms":4117.3,"lg_split_ms":2333.3,"pure_graph_ms":214.6,"sm_boot_ms":453.7,"sm_decomp_ms":1270.5,"sm_graph_ms":726.9,"sm_hex_ms":440.6,"sm_index_ms":2.4,"sm_listing_cold_ms":283,"sm_listing_warm_ms":284.3,"sm_nav_ms":310.4,"sm_palette_ms":0.3,"sm_rename_ms":1575.9,"sm_render_ms":257.6,"sm_search_ms":59.6,"sm_split_ms":1423.7,"fails":0},"status":"checks_failed","description":"Digest mode for `heads`: the worker answers \"does this page still render exactly as it did?\" for the cost of the render alone, so a post-rename refresh skips shipping, unpickling and rebuilding pages that did not change. lg_search 5628 -> 4117 — but it BREAKS 'O' cycles back, and it made rename 2-4x slower. Both causes understood.","timestamp":1786077531326,"segment":6,"confidence":10.35869418588966,"asi":{"hypothesis_first_tested_empirically":"before building anything I checked the parked xrefs-driven idea: /tmp/whatchanges.py rebuilds the whole segment before and after a rename and diffs every row. On echo, all 19 changed rows over 4 renames were covered by (function extent + xrefs_to). On ls_ttl, 53 of 54 were -- the one that was not is 'lea rcx, unk_1D7A0' -> 'byte_1D7A0', which the RENAME DID NOT CAUSE: IDA's own analysis defined that byte. An address-predicted invalidation would leave that row stale for good, so the idea is now measured-and-rejected rather than assumed-risky.","what_i_built_instead":"an exact check: `heads(..., digest=True)` builds the rows as usual but returns only hash+count instead of the rows. The client stores the digest each page came back with and asks 'still the same?' before re-fetching. Uses the interpreter's own hash deliberately -- it never has to mean anything outside the worker process, the client is only a courier.","measured_win":"post-rename whole-segment re-read 5115ms -> 3833ms (-25%); lg_search 5628 -> 4117","BUG_1_correctness":"the stored digest describes the page AS LOADED, not as the client currently holds it. After a full refetch the client's rows change but _page_digest is not updated -- so when a literal format cycles hex -> dec -> ... -> hex, the worker's digest matches the ORIGINAL stored one, the page is declared unchanged, and the row keeps the intermediate decimal text. That is exactly the failure: opfmt_listing ''O' cycles back' got 'sub rsp, 184' wanting 'sub rsp, 0B8h'.","BUG_2_performance":"_ensure_text_from tests page freshness with all(_head_gen[k] == gen for k in the page), and get() calls _ensure_text per ROW -- so every row scanned a whole page's gen array. rename went 380 -> 1576ms (sm) and 730 -> 1973ms (lg).","fix_for_both":"refresh at PAGE granularity end to end instead of the snapped TEXT_BLOCK: a page is exactly what the tool produced from (addr, count=PAGE), so refetching with the same parameters reproduces the same sequence with no snapping, the stored digest can be updated whenever the page's rows are replaced, and every head in a page shares one gen value so freshness is a single probe rather than a scan.","work_preserved":".auto/wip-digest.patch","gate_worked":"check_rename and check_search both passed -- neither exercises a format cycle. The scenario suite caught it. That is the third time a cache-shaped change failed in a way only one specific test could see."}}
-{"run":40,"commit":"d9e8fdb","metric":26491.7,"metrics":{"lg_boot_ms":808.6,"lg_decomp_ms":2600.2,"lg_graph_ms":901.2,"lg_hex_ms":448.7,"lg_index_ms":70.3,"lg_listing_cold_ms":432.8,"lg_listing_warm_ms":445.5,"lg_nav_ms":7004.8,"lg_palette_ms":4.8,"lg_rename_ms":758.6,"lg_render_ms":231.3,"lg_search_ms":3959.2,"lg_split_ms":2659.2,"pure_graph_ms":214.7,"sm_boot_ms":432.6,"sm_decomp_ms":1305.1,"sm_graph_ms":758.3,"sm_hex_ms":431.8,"sm_index_ms":2.3,"sm_listing_cold_ms":275.4,"sm_listing_warm_ms":289.4,"sm_nav_ms":295.6,"sm_palette_ms":0.3,"sm_rename_ms":424.3,"sm_render_ms":263.6,"sm_search_ms":61.1,"sm_split_ms":1411.9,"fails":0},"status":"keep","description":"heads(digest=True): the worker answers \"does this page still render exactly as you hold it?\" with a hash and a count instead of the page. After a rename nearly every page is unchanged, so the pickling, transfer, unpickling and Head rebuild are all skipped. Redone at PAGE granularity end to end, which fixes both bugs of the first attempt. lg_search 5628 -> 3959.","timestamp":1786077952004,"segment":6,"confidence":8.431402690090582,"asi":{"gains":"total 27553 (previous best) -> 26492 (-3.9%); lg_search 5628 -> 3959 (-30%); post-rename whole-segment re-read 5115 -> 3720ms measured directly","cost":"lg_rename 731 -> 759 and sm_rename 380 -> 424: a page now costs one extra round trip to probe before it is either accepted or refetched. Getting a viewport back after a rename on bash is 21.5ms instead of 10.3ms -- still 230x better than the 4941ms rebuild it replaced.","what_the_two_bugs_were":{"correctness":"the first attempt stored the digest a page LOADED with and never updated it when the page was refetched. A literal format cycling hex -> dec -> hex then hashed back to the original while the client still held the decimal text: opfmt_listing ''O' cycles back' got 'sub rsp, 184' wanting '0B8h'.","performance":"freshness was tested with all(_head_gen[k] == gen for k in the page) while get() called _ensure_text per ROW, so every row scanned a whole page's gen array -- rename went 2-4x slower."},"the_fix_was_one_idea":"refresh at PAGE granularity instead of the snapped TEXT_BLOCK. A page is exactly what heads(addr, count=PAGE) produced, so re-asking with the same arguments reproduces the same sequence -- no snapping to address groups needed, the stored digest can be updated whenever the rows are replaced, and every head in a page shares one generation marker so freshness is a single probe. TEXT_BLOCK is gone.","why_hash_and_not_a_stable_digest":"the value never has to mean anything outside the worker process -- the client stores what a page hashed to and hands the same number back. One worker, one process, one hash seed. It covers ea/kind/size/text/name AND the colour spans, so two lines that collapse to the same text but colour differently are not confused.","empirical_work_that_shaped_this":"before building anything I tested the parked xrefs-driven idea with /tmp/whatchanges.py (rebuild the segment before and after a rename, diff every row). It fails: on ls_ttl one changed row was 'lea rcx, unk_1D7A0' -> 'byte_1D7A0', which the rename did not cause -- IDA's own analysis defined that byte. Address-predicted invalidation would leave it stale forever. Recorded as measured-and-rejected in .auto/ideas.md.","work_unchanged":"decomp_lines 3436, split_mapped 2070, search_hits 91783, graph_blocks 1000, rename_ok 6/6, decomp_ok 12 -- identical","verified":"830-check gate, check_search (140 prefixes), check_rename on echo AND ls_ttl (5952 and 28807 rows, wide and narrow, against a rebuild), and the 19 opfmt scenarios that caught the first attempt","next_action_hint":"lg_nav 7005 is the largest term and is the cold walk at its per-row floor. lg_search 3959 is now mostly the _line_plain pass plus the pages that genuinely changed. Next best unexplored: why the digest probe costs a whole extra round trip per page -- it could ride along with the first refetch request rather than preceding it."}}
-{"run":41,"commit":"7e4f086","metric":25814,"metrics":{"lg_boot_ms":714.9,"lg_decomp_ms":2376.2,"lg_graph_ms":948.8,"lg_hex_ms":458.6,"lg_index_ms":69.3,"lg_listing_cold_ms":448.5,"lg_listing_warm_ms":482.1,"lg_nav_ms":6808,"lg_palette_ms":4.7,"lg_rename_ms":752,"lg_render_ms":219.4,"lg_search_ms":4008.3,"lg_split_ms":2275.5,"pure_graph_ms":225.2,"sm_boot_ms":456.1,"sm_decomp_ms":1292.4,"sm_graph_ms":795.8,"sm_hex_ms":475.2,"sm_index_ms":2.5,"sm_listing_cold_ms":269.1,"sm_listing_warm_ms":272.2,"sm_nav_ms":320.2,"sm_palette_ms":0.3,"sm_rename_ms":446.3,"sm_render_ms":264.8,"sm_search_ms":60.6,"sm_split_ms":1366.9,"fails":0},"status":"keep","description":"Three redundancies in the heads walk: item flags were fetched three times per head (row builder, _is_unknown via _advance, and _rows_for), get_func was called per head where a head is nearly always in the same function as the one before it, and the page digest rebuilt a tuple-of-tuples per row where one spans list is shared by ~45% of them. Cold heads 18.62 -> 17.77 us/row, warm 11.53 -> 10.87.","timestamp":1786078727527,"segment":6,"confidence":7.765877831715214,"asi":{"gains":"total 26492 (previous best) -> 25814 (-2.6%); lg_split 2659 -> 2276; sm_nav_worst 158 -> 151. Microbenchmarked rather than trusted to total_ms, since ~0.8us/row over ~500k row-renders is 0.4s and the box's spread is ~500ms: cold heads 18.62 -> 17.77 us/row, warm 11.53 -> 10.87, digest 0.75 -> 0.62.","equivalence":"/tmp/headsdump.py runs the tool under both code versions in separate processes and compares whole payloads: 109 004 rows over 294 payloads (bash + ls_ttl, BOTH annotate modes, plus the offset/end/back/count variants that share the walk) -- 0 mismatches. diff_spans still byte-identical vs 2b0ae8d on three binaries. 830-check gate, check_search and check_rename green.","get_func_cache_was_validated_not_assumed":"I had rejected this earlier on a hunch about IDA function CHUNKS -- a tail chunk of B inside A's [start_ea, end_ea) would be misattributed. /tmp/funccache.py walks every head of bash/ls_ttl/echo comparing the cached answer against get_func: 437 324 heads, 0 disagreements. Hit rate is only ~50% across all segments (a head outside any function cannot be cached by range) but much higher inside .text, which is what the listing walks.","TWO_SCHEMES_MEASURED_AND_REJECTED_FIRST":"before micro-optimising I tested the two big parked ideas, both of which would have made a rename nearly free. Listing rows predicted from xrefs_to + function extent: ls_ttl had 1 of 54 changed rows uncovered, 'lea rcx, unk_1D7A0' -> 'byte_1D7A0', which the rename did not cause -- IDA's own analysis defined that byte. Decompilations predicted from 'the old name appears in the cached text': 16 misses over 4 renames, EVERY one Hex-Rays type inference moving (unsigned int a4 -> int a4) in functions unrelated to the rename. Both recorded in .auto/ideas.md.","generalised_lesson":"predicting the effect of an edit on a database that has its own opinions is unsound. Verify instead -- which is exactly why heads(digest=True) works: it asks what a row renders as NOW, not what should have changed. This probably also explains the lg_decomp_lines drift blamed on CPU starvation in v5 #4.","things_checked_and_found_not_worth_it":{"digest_on_the_normal_load_path":"suspected a regression from v7 #10; it is 0.62-0.75 us/row, not the 1.9 the noisy call timing suggested","folding_the_opcode_byte_read_into_heads":"the extra read_bytes round trip is 0.35 us/row, ~81ms over a whole bash segment","pipelining_client_parsing_with_worker_compute":"blocked -- cursor.next is inside the pickle, so the next request cannot be issued before unpickling, and _build_page itself needs the socket for opcode bytes"},"state":"generate_disasm_line is now 6.09 of the 10.87 us/row warm cost (56%) and is IDA's. What is left of ours is ~4.8 us/row spread thin across a dozen places.","next_action_hint":"lg_nav 6808 (26%) and lg_search 4008 (16%) are both at their per-row floors now. The unexplored areas are the ones .auto/ideas.md lists as blocked or unmeasured: the trace memory index (needs test_trace_vs_tenet runnable), the streaming-responsiveness question, and the features the bench still does not drive (xrefs dialog, strings browser, struct editor, make-code edits, history, traces, RPC)."}}
-{"run":42,"commit":"16318e4","metric":25783,"metrics":{"lg_boot_ms":777.9,"lg_decomp_ms":2381.9,"lg_graph_ms":1209.5,"lg_hex_ms":450,"lg_index_ms":68,"lg_listing_cold_ms":434.6,"lg_listing_warm_ms":405.2,"lg_nav_ms":6801.2,"lg_palette_ms":4.7,"lg_rename_ms":744.7,"lg_render_ms":222.4,"lg_search_ms":3885.6,"lg_split_ms":2261.4,"pure_graph_ms":216.4,"sm_boot_ms":463.6,"sm_decomp_ms":1304.6,"sm_graph_ms":740.9,"sm_hex_ms":438.4,"sm_index_ms":2.4,"sm_listing_cold_ms":270.3,"sm_listing_warm_ms":267.2,"sm_nav_ms":312.4,"sm_palette_ms":0.3,"sm_rename_ms":415.8,"sm_render_ms":257.7,"sm_search_ms":60.8,"sm_split_ms":1385.2,"fails":0},"status":"keep","description":"An item edit (c/d/u/p) keeps the listing's walk in front of it instead of discarding the model. bump_items now takes the edited address; rows before an edit keep their addresses and their row numbers, so only the pages from the edit onward are re-walked. Getting the listing back after undefining at the cursor on bash: 4890ms -> 19ms (257x). Adds .auto/check_edit.py to the gate. total_ms is flat — the bench has no item-edit phase, and the one I wrote hangs (reverted, cause recorded).","timestamp":1786082485850,"segment":6,"confidence":5.464825819307547,"asi":{"how_it_was_found":"kept probing features the bench does not drive -- the pattern that produced the flowchart hull, decomp_map and the rename walk. /tmp/itemedit.py: the undefine tool call is 1ms and getting the listing back is 4890ms, because bump_items cleared _listings and the reload re-walked the segment. Same bug as the rename one, in the sibling path.","measured":"edit at the cursor (96% into bash's .text): 4890 -> 19ms. Edit early in the segment: 5130 -> 4542ms, which is the honest case -- everything after an edit really does have to be re-walked.","why_it_is_safe":"truncate_from drops two pages rather than one (undefining can coalesce backwards into the run in front of it), and the kept prefix is then marked text-stale -- so every kept page is digest-checked on the next read, and a page that really did move fails its (ea, kind) sequence check and sets stale_structure, which makes Program.listing rebuild. Safe by construction rather than by argument about how far a reflow can reach.","gate_added_and_proven":".auto/check_edit.py drives undefine at 1/4/16 bytes at three positions (90%, 50%, 5% through the segment) and compares the kept model against a rebuild, narrow and wide. 0 problems on echo and ls_ttl. Proven to FAIL by deliberately making truncate_from too permissive: it reported 'kept model has 5952 rows, a rebuild has 5950'.","HONEST_LIMITATION":"total_ms 25814 -> 25783 is flat: nothing in the bench edits an item. I wrote a phase for it and reverted it because it HANGS -- driving undefine from inside the pilot produces no output and is killed at the timeout, while the identical sequence against Program directly is fine, and the same sequence with a print between prog.listing(ea) and ensure_ea is also fine. That points at the app's background threads (after bump_items deletes the model, a _prime/_grow worker still in flight can re-register a fresh one and hold its _load_lock), which is the same area as the unexplained streaming-responsiveness item already in .auto/ideas.md. Shipping a hanging bench phase would be worse than not having one.","what_i_did_not_do":"I did not weaken the phase until it passed, and I did not claim the win in total_ms. It is carried by a direct measurement and a permanent gate.","next_action_hint":"the concurrency question is now blocking two things (a bench phase for edits, and the streaming-responsiveness fix). Working out why @work(exclusive=True) leaves thread workers running -- and giving _prime/_grow a way to actually retire -- is the highest-value next step, and it is a correctness/responsiveness matter as much as a perf one."}}
-{"run":43,"commit":"b6646aa","metric":25121.9,"metrics":{"lg_boot_ms":694.8,"lg_decomp_ms":2370.8,"lg_graph_ms":917.1,"lg_hex_ms":456.1,"lg_index_ms":69.4,"lg_listing_cold_ms":442.7,"lg_listing_warm_ms":498.8,"lg_nav_ms":6548.8,"lg_palette_ms":4.8,"lg_rename_ms":711.4,"lg_render_ms":224.9,"lg_search_ms":3484.7,"lg_split_ms":2619.3,"pure_graph_ms":220.7,"sm_boot_ms":448.6,"sm_decomp_ms":1292.5,"sm_graph_ms":748.8,"sm_hex_ms":438.3,"sm_index_ms":2.4,"sm_listing_cold_ms":274.3,"sm_listing_warm_ms":266.5,"sm_nav_ms":302.7,"sm_palette_ms":0.3,"sm_rename_ms":392.5,"sm_render_ms":260.2,"sm_search_ms":59.6,"sm_split_ms":1370.9,"fails":0},"status":"keep","description":"The page-freshness check carries the digest the client already holds (heads(expect=...)) instead of asking first and fetching afterwards. A page that has NOT changed costs one round trip as before; a page that HAS changed now costs one instead of two. Also corrects the record: the item-edit bench hang is pilot start-up flakiness, not the _prime/_grow concurrency I blamed it on — proved with a stack dump.","timestamp":1786083427343,"segment":6,"confidence":4.621649308519722,"asi":{"gains":"total 25783 -> 25122 (-2.6%, best v7); lg_search 3886 -> 3485; sm_rename 416 -> 393. Direct measurements: post-rename whole-segment re-read 3720 -> 3411ms; getting the listing back after a rename on bash 21.5 -> 15.6ms (319x vs a rebuild); after an item edit 19 -> 17ms.","the_change":"heads gains `expect` (the digest a caller already holds) in place of the boolean `digest` flag. The worker builds the rows either way and omits them only when they still hash to `expect`. Sending the expectation rather than asking first is what removes the second round trip on a changed page -- which was the regression I introduced in v7 #10 (rename 380 -> 424ms) and flagged in my own next_action_hint.","equivalence":"normal calls (no expect) compared payload-for-payload against the previous commit across processes: 166 payloads, 63 964 rows, 0 mismatches. check_rename and check_edit both clean, 830-check gate green.","DIAGNOSIS_CORRECTED":"last iteration I logged that the item-edit bench phase hangs because 'after bump_items deletes the model, a _prime/_grow worker still in flight can re-register a fresh one and hold its _load_lock'. A stack dump (faulthandler.dump_traceback_later) says otherwise: at the moment of the hang there are NO idatui threads at all -- the main thread is idle in selectors.select() and everything else is an idle asyncio executor thread, and the app is stuck BEFORE app.run_test() returns. It is pilot start-up flakiness, nothing to do with bump_items or _load_lock. Ruled out: the kitty-graphics query (IDATUI_KITTY=0 still hangs). Partly environmental: orphaned idatui/worker.py processes accumulate from runs killed by `timeout`, and clearing them let the next run boot -- but it recurred, so that is not the whole story.","operational_note":"kill stray workers between probe runs (pkill -f idatui/worker.py). Several of this session's confusing measurements were taken with orphans competing for the box.","what_this_means_for_the_backlog":"the 'why does @work(exclusive=True) leave thread workers running' item is NOT what blocks the item-edit bench phase. The two are separate: the streaming-responsiveness question is still open on its own evidence (112 vs 237 _grew reports), but the bench phase is blocked on pilot start-up reliability instead.","state":"v7 baseline 33243 -> 25122 (-24.4%). lg_nav 6549 and lg_search 3485 are both at their per-row floors; what is left of the worker's cost is 56% generate_disasm_line."}}
-{"run":44,"commit":"7e4b593","metric":24513.8,"metrics":{"lg_boot_ms":693.2,"lg_decomp_ms":2347.3,"lg_graph_ms":931.2,"lg_hex_ms":460.3,"lg_index_ms":69.1,"lg_listing_cold_ms":434.2,"lg_listing_warm_ms":462.4,"lg_nav_ms":6639.6,"lg_palette_ms":4.7,"lg_rename_ms":699.9,"lg_render_ms":219.7,"lg_search_ms":3441.1,"lg_split_ms":2218,"pure_graph_ms":216.5,"sm_boot_ms":436.9,"sm_decomp_ms":1264.9,"sm_graph_ms":758.2,"sm_hex_ms":437.9,"sm_index_ms":2.3,"sm_listing_cold_ms":256.8,"sm_listing_warm_ms":256,"sm_nav_ms":309.5,"sm_palette_ms":0.3,"sm_rename_ms":379.1,"sm_render_ms":249.5,"sm_search_ms":59.2,"sm_split_ms":1266,"fails":0},"status":"keep","description":"decomp_map: memoise obj_id -> ea for the whole function instead of only comparing against the previous column. dstr() was 79% of the tool (24us a call) and items interleave, so foo(a, b) flips call->arg->call and re-formatted an item already seen: 106594 calls for 15417 lines of bash. Also corrects run #30's claim that the duplicate ida_hexrays.decompile is what costs -- a warm decompile is 0.01ms.","timestamp":1786084349637,"segment":6,"confidence":4.950123344769902,"asi":{"hypothesis":"the split view is the 3rd largest term and run #30 left a named suspect behind; profile decomp_map's sweep instead of trusting the note","gains":"total 25122 -> 24514 (-2.4%, best v7). lg_split 2619 -> 2218, sm_split 1371 -> 1266. Direct: decomp_map over bash's 12 largest 4783 -> 2972ms, echo's 12 431 -> 345ms.","profile_that_drove_it":"/tmp/sweepprof.py over 15417 lines of bash: dstr() 2581ms (79%, 106594 calls @ 24.2us), get_line_item 691ms (445337 @ 1.55us), tag_remove 14ms. The existing dedupe only compared against the PREVIOUS column's obj_id, but ctree items interleave -- foo(a, b) alternates call/arg/call/arg -- so every flip re-formatted an item already seen. obj_id is unique within a cfunc, so a function-scoped memo is exact.","equivalence":"/tmp/mapdiff.py execs the decoded BODY of the current and the previous commit against the same cfunc and compares the whole map: echo 62 functions 0 mismatches, bash 250 functions 0 mismatches. NOTES counters unchanged (sm_split_mapped_lines 984, lg_split_mapped_lines 2070).","CORRECTS_THE_RECORD_1":"run #30 said what is left in decomp_map is the duplicate ida_hexrays.decompile. Wrong: /tmp/hxcache.py shows a warm decompile is 0.01ms -- Hex-Rays' own cache is free and the duplicate costs nothing. The 2970ms I first attributed to it was the decompile TOOL's own text/spans building.","CORRECTS_THE_RECORD_2":"I suspected the split view's text and its decomp_map came from different ctrees after a rename (/tmp/mapalign.py showed 6 of 8 differing). Not a bug: the probe used DECOMP_NO_CACHE, which the app never does. Program.decompile calls the force_recompile tool (which does exist) before refetching, so the plain decompile in decomp_map then hits the repopulated cache.","rejected_and_why":"cfunc.refresh_func_ctext() after a rename is 46x faster than the forced recompile (31ms vs 1432ms for ten functions) but only 2/10 reproduce the recompile's text -- the rest differ by Hex-Rays TYPE INFERENCE (char* vs const char*, unsigned int a4 vs int a4). That is a change to what is on screen, so it is a product decision, not a perf change. Recorded in .auto/ideas.md with the probe.","next_action_hint":"the remaining decomp_map cost is get_line_item per screen column (691ms/15k lines), and the sound way down is to map only the ~40 lines the pane can show -- the same lazy shape that won for search highlight ranges. It needs a windowed tool plus a lazy container because app.py and trace_ctl.py both index the whole list. Do NOT step over columns: a one-character variable would be skipped and its EA silently lost."}}
-{"run":45,"commit":"pending","metric":25367.3,"metrics":{"lg_boot_ms":740.4,"lg_decomp_ms":2307.8,"lg_graph_ms":1227.6,"lg_hex_ms":450.2,"lg_index_ms":81,"lg_listing_cold_ms":425.7,"lg_listing_warm_ms":403.5,"lg_nav_ms":6778.2,"lg_palette_ms":4.9,"lg_rename_ms":734.7,"lg_render_ms":220.1,"lg_search_ms":3619.6,"lg_split_ms":2589.3,"pure_graph_ms":216.6,"sm_boot_ms":441.2,"sm_decomp_ms":1210,"sm_graph_ms":727,"sm_hex_ms":421.5,"sm_index_ms":2.4,"sm_listing_cold_ms":258.1,"sm_listing_warm_ms":257.3,"sm_nav_ms":300.8,"sm_palette_ms":0.3,"sm_rename_ms":377.4,"sm_render_ms":249.3,"sm_search_ms":58.9,"sm_split_ms":1263.5,"fails":0},"status":"discard","description":"Replace ida-pro-mcp's decompile_function_safe with a loop that allocates one ctree_item_t instead of three per line and memoises the per-line dstr() by obj_id. Directly measured through the real worker at 3.6x (2695 -> 746ms for the post-processing of bash's 8 largest), and byte-identical over 500 functions — but total_ms rose 24514 -> 25367 on phases this cannot touch (graph +297, nav +138, search +179, boot +47) with the box at load 2.70 vs 2.16. Re-running to separate the win from the drift.","timestamp":1786085063251,"segment":6,"confidence":4.932640144665465,"asi":{"hypothesis":"the decompile TOOL's post-processing is a large hidden cost: a warm Hex-Rays decompile is 0.01ms, yet re-running the tool on bash's 12 largest still cost 2951ms. The culprit is ida-pro-mcp's decompile_function_safe, which has the SAME three faults I fixed in decomp_map -- three ctree_item_t SWIG allocations per line (two never read) and a dstr() per line at 24us.","direct_measurement":"/tmp/decprof.py: 18991 lines of bash, 2302.6ms -> 429.0ms (5.37x), 121.2 -> 22.6 us/line, 0 mismatches. End to end through the real worker (/tmp/verifybind.py, the decompile TOOL with Hex-Rays warm, bash's 8 largest): 2695ms -> 746ms, i.e. 3.6x, so the rebinding definitely takes effect.","equivalence":".auto/check_decomp.py runs BOTH implementations against the same cfunc with include_addresses both ways: 128/128 echo and 372/372 ls_ttl byte-identical. Proven to be a real gate by keying the memo on it.op instead of it.obj_id: 69 problems, 59/128 passing.","why_discarded":"total_ms 24514 -> 25367. The phases this change touches improved (lg_decomp 2347 -> 2308, sm_decomp 1265 -> 1210), but graph +297, nav +138, search +179, boot +47 and index +12 all moved too, and NONE of them go through decompile_function_safe. Load average was 2.70 at the start of this run against 2.16 for the previous one. Per the playbook, totals are not comparable across a load change.","rollback_reason":"primary metric worse, but attributed to machine load rather than the change -- the same shape as #16/#18, both of which were confirmed wins on a re-run.","next_action_hint":"re-apply from /tmp/fastdecomp.patch (git diff saved before the auto-revert; .auto/check_decomp.py also copied to /tmp) and re-run. Load was 0.84 immediately after this run, so the box has calmed. NOTE: checks.sh now calls .auto/check_decomp.py, and .auto files survive the revert -- so the gate will CRASH until the patch is re-applied, because _idatui_decompile_function_safe will not exist in BODY."}}
-{"run":46,"commit":"pending","metric":25171.3,"metrics":{"lg_boot_ms":693.5,"lg_decomp_ms":2317.5,"lg_graph_ms":879.1,"lg_hex_ms":449.8,"lg_index_ms":73.6,"lg_listing_cold_ms":437.1,"lg_listing_warm_ms":461.9,"lg_nav_ms":6738.4,"lg_palette_ms":4.8,"lg_rename_ms":702.8,"lg_render_ms":226.2,"lg_search_ms":3639,"lg_split_ms":2578,"pure_graph_ms":216.2,"sm_boot_ms":435.2,"sm_decomp_ms":1278.7,"sm_graph_ms":742.7,"sm_hex_ms":436.6,"sm_index_ms":2.5,"sm_listing_cold_ms":268.2,"sm_listing_warm_ms":263.5,"sm_nav_ms":289.5,"sm_palette_ms":0.3,"sm_rename_ms":383.3,"sm_render_ms":262.9,"sm_search_ms":58.1,"sm_split_ms":1332.1,"fails":0},"status":"discard","description":"Re-run of #45 (fast decompile_function_safe) with the rebinding no longer importing a module to patch it. Confirms the change does NOT move total_ms: three runs with it (25367/25058/25171) against three without (24514/25122/25783), means 25199 vs 25140. The 3.6x is real but lands on large functions (98us/line saved) while the bench decompiles 286-line ones (30us/line). Parked in .auto/parked/ rather than deleted.","timestamp":1786085672802,"segment":6,"confidence":5.315956151035322,"asi":{"hypothesis":"ida-pro-mcp's decompile_function_safe has the same three faults I fixed in decomp_map: three ctree_item_t SWIG allocations per line (two never read) and a dstr() per line at 24us","confirmed_true_but_small":"the optimisation itself is real and verified three ways -- /tmp/decprof.py 2302 -> 429ms over 18991 lines of bash (121.2 -> 22.6 us/line); through the REAL worker /tmp/verifybind.py 2695 -> 746ms; echo 219 -> 120ms. Byte-identical over 128/128 echo and 372/372 ls_ttl functions with include_addresses both ways.","why_it_does_not_show":"the saving is 98us/line on bash's LARGEST functions but only 30us/line on small ones, because dstr() cost and memo hit rate both scale with ctree size. The bench's fixed set averages 286 lines a function, so the expected effect is 150-250ms against a run-to-run spread of 400-600ms on this box. Three runs with (25367/25058/25171) vs three without (24514/25122/25783): means 25199 vs 25140.","rollback_reason":"primary metric unchanged within noise, and the best single run remains one without the change. Rules say discard on worse-or-unchanged, and unlike #42 the bench DOES cover this path -- it simply covers it with functions too small for the win to matter.","what_i_refused_to_do":"the obvious way to make this show up is to point the bench's fixture picker at larger functions. That is fitting the benchmark to the change, so I did not do it, and I recorded the prohibition next to the parked patch.","preserved":".auto/parked/fast_decompile.patch and .auto/parked/check_decomp.py, with the full measurement table in .auto/ideas.md. I also UNWIRED check_decomp.py from checks.sh before logging: .auto files survive the auto-revert but BODY does not, so the gate would have crashed on every subsequent run.","still_a_user_win":"an F5 on a 2374-line function loses 233ms of post-processing (287 -> 54). Worth re-applying if the goal moves from total session time to per-operation latency.","next_action_hint":"stop mining the decompile path -- what is left there is Hex-Rays. The largest terms are lg_nav 6700 and lg_search 3600, both at their per-row floors, so the next real find is likely another unbenched feature (xrefs dialog, strings browser, history, literal formats) probed with the /tmp/featprobe.py shape."}}
diff --git a/.auto/measure.sh b/.auto/measure.sh
deleted file mode 100755
index 49b16bc..0000000
--- a/.auto/measure.sh
+++ /dev/null
@@ -1,14 +0,0 @@
-#!/bin/bash
-# ida-tui performance benchmark driver.
-#
-# Fast pre-checks first (a syntax error should cost a second, not a worker boot
-# and two database opens), then the real bench.
-set -euo pipefail
-cd "$(dirname "$0")/.."
-
-PY="${IDATUI_PYTHON:-$HOME/ida-venv/bin/python}"
-
-# ~0.4s: catches a syntax error / bad import before we pay for idalib.
-python3 -m compileall -q idatui server tests .auto/bench.py >/dev/null
-
-exec "$PY" .auto/bench.py "$@"
diff --git a/.auto/parked/check_decomp.py b/.auto/parked/check_decomp.py
deleted file mode 100644
index 0f30a03..0000000
--- a/.auto/parked/check_decomp.py
+++ /dev/null
@@ -1,110 +0,0 @@
-#!/usr/bin/env python3
-"""Differential gate for the fast decompile_function_safe (run by checks.sh).
-
-`idatui/worker.py` rebinds ida-pro-mcp's `decompile_function_safe` to our own
-loop, which skips two of the three SWIG allocations per line and memoises the
-per-line `dstr()` by ctree obj_id. That is a pure speed change and the text it
-returns is what the pseudocode pane shows, markers and all -- so it has to be
-byte-identical, not merely similar.
-
-This runs BOTH implementations against the same cfunc for every function of a
-real binary and compares the strings, with `include_addresses` both ways (the
-marker path is the whole point, and the no-marker path must not regress either).
-
- ~/ida-venv/bin/python .auto/check_decomp.py [targets/echo] [max_funcs]
-"""
-from __future__ import annotations
-
-import os
-import shutil
-import sys
-
-HERE = os.path.dirname(os.path.abspath(__file__))
-ROOT = os.path.dirname(HERE)
-sys.path.insert(0, ROOT)
-sys.path.insert(0, HERE)
-
-from bench import stage # noqa: E402
-
-
-def _add_mcp_path() -> None:
- """ida-pro-mcp is installed for the interpreter the WORKER runs, which is
- not necessarily the one running this check."""
- import glob
- for pat in ("/home/user/.local/lib/python3.*/site-packages",
- os.path.expanduser("~/.local/lib/python3.*/site-packages")):
- for d in glob.glob(pat):
- if os.path.isdir(os.path.join(d, "ida_pro_mcp")) and d not in sys.path:
- sys.path.append(d)
-
-
-def main() -> int:
- target = sys.argv[1] if len(sys.argv) > 1 else "targets/echo"
- limit = int(sys.argv[2]) if len(sys.argv) > 2 else 400
- d, path = stage(os.path.join(ROOT, target))
- os.environ["IDA_MCP_TOOL_TIMEOUT_SEC"] = "0"
- _add_mcp_path()
- try:
- import idapro
- idapro.open_database(path, run_auto_analysis=True)
- try:
- import ida_funcs
- import ida_hexrays
- import idautils
- from ida_pro_mcp.ida_mcp import utils
-
- original = utils.decompile_function_safe
- sys.path.insert(0, os.path.join(ROOT, "server"))
- import patch_server
- # exec only our function out of the decoded BODY: the rest of it
- # needs api_types' namespace (@tool, @idasync, ...).
- body = patch_server.BODY
- i = body.find("def _idatui_decompile_function_safe(")
- j = body.find("\n_idatui_strings_cache", i)
- assert i > 0 and j > i, "could not slice the function out of BODY"
- g = {}
- exec(body[i:j], g)
- fast = g["_idatui_decompile_function_safe"]
-
- ida_hexrays.init_hexrays_plugin()
- fails: list[str] = []
- n = ok = 0
- for ea in idautils.Functions():
- f = ida_funcs.get_func(ea)
- if not f:
- continue
- n += 1
- if n > limit:
- break
- for markers in (True, False):
- a, ea_err = original(f.start_ea, include_addresses=markers)
- b, eb_err = fast(f.start_ea, include_addresses=markers)
- if a != b or (ea_err is None) != (eb_err is None):
- fails.append(f"{f.start_ea:#x} (markers={markers})")
- if len(fails) <= 3:
- la = (a or "").splitlines()
- lb = (b or "").splitlines()
- i = next((k for k, (x, y) in enumerate(zip(la, lb))
- if x != y), None)
- if i is None:
- print(f" FAIL {f.start_ea:#x}: {len(la)} lines "
- f"vs {len(lb)}, errs {ea_err!r}/{eb_err!r}")
- else:
- print(f" FAIL {f.start_ea:#x} line {i}:")
- print(f" original: {la[i][:100]!r}")
- print(f" fast : {lb[i][:100]!r}")
- break
- else:
- ok += 1
- print(f"decompile text: {ok}/{min(n, limit)} functions of {target} "
- f"byte-identical to ida-pro-mcp's own loop, "
- f"{len(fails)} problems")
- return 1 if fails else 0
- finally:
- idapro.close_database(save=False)
- finally:
- shutil.rmtree(d, ignore_errors=True)
-
-
-if __name__ == "__main__":
- sys.exit(main())
diff --git a/.auto/parked/fast_decompile.patch b/.auto/parked/fast_decompile.patch
deleted file mode 100644
index c5a4ceb..0000000
--- a/.auto/parked/fast_decompile.patch
+++ /dev/null
@@ -1,122 +0,0 @@
-diff --git a/idatui/worker.py b/idatui/worker.py
-index 556e69a..04252cc 100644
---- a/idatui/worker.py
-+++ b/idatui/worker.py
-@@ -119,6 +119,36 @@ def recv(sock: socket.socket):
- # --------------------------------------------------------------------------- #
- # worker
- # --------------------------------------------------------------------------- #
-+def _use_fast_decompile() -> None:
-+ """Point ida-pro-mcp's decompile tools at our per-line loop.
-+
-+ The shipped ``decompile_function_safe`` allocates three ctree_item_t SWIG
-+ objects per pseudocode line (two of which it never reads) and formats an
-+ item description per line to recover the ``/*0xEA*/`` marker: 121us a line,
-+ which on a warm cfunc is most of what the tool costs. The replacement lives
-+ in server/patch_server.py and is differentially checked against the original
-+ by .auto/check_decomp.py.
-+
-+ Every consumer binds the name at import time (``from .utils import ...``),
-+ so rebinding it on ``utils`` alone would miss them; rebind on each module
-+ that imported it, and leave anything unexpected exactly as it was.
-+ """
-+ try:
-+ from ida_pro_mcp.ida_mcp import api_types, utils
-+ fast = api_types._idatui_decompile_function_safe
-+ except Exception: # noqa: BLE001 -- never let this stop the worker booting
-+ return
-+ utils.decompile_function_safe = fast
-+ # Rebind only on modules that are ALREADY imported. Importing one to rebind
-+ # it would be work the worker had not chosen to do, on the boot path.
-+ prefix = "ida_pro_mcp.ida_mcp."
-+ for name, mod in list(sys.modules.items()):
-+ if not name.startswith(prefix) or mod is None:
-+ continue
-+ if getattr(mod, "decompile_function_safe", None) is not None:
-+ mod.decompile_function_safe = fast
-+
-+
- def _ensure_tools_injected() -> None:
- """Inject idatui's custom tools (heads/read_raw/resolve_names/func_types/...)
- into the installed ida_pro_mcp, idempotently, so the worker is self-sufficient
-@@ -190,6 +220,8 @@ def _open_and_register(binpath: str, load_args: str = ""):
- # importing the package registers all api_*/patched tools against MCP_SERVER
- from ida_pro_mcp.ida_mcp import MCP_SERVER # noqa: WPS433
-
-+ _use_fast_decompile()
-+
- import ida_nalt
- module = os.path.basename(ida_nalt.get_root_filename() or binpath)
-
-diff --git a/server/patch_server.py b/server/patch_server.py
-index 6667e12..64424e4 100644
---- a/server/patch_server.py
-+++ b/server/patch_server.py
-@@ -1039,6 +1039,67 @@ def decomp_map(
- return {"addr": hex(func.start_ea), "lines": lines}
-
-
-+def _idatui_decompile_function_safe(ea, include_addresses=True):
-+ """ida-pro-mcp's ``decompile_function_safe``, with the three costs the same
-+ sweep had in ``decomp_map`` taken out. Byte-identical output -- it is
-+ differentially checked against the original over every function of a real
-+ binary by ``.auto/check_decomp.py``.
-+
-+ The shipped version costs 121us per pseudocode line, which is more than the
-+ line's share of Hex-Rays itself on a warm cfunc:
-+
-+ * it allocates THREE ctree_item_t SWIG objects per line, and ``_head`` and
-+ ``_tail`` are never read -- ``get_line_item`` accepts None for both.
-+ * it calls ``dstr()`` per line. That formats a whole 'EA: description'
-+ string at 24us a call, and consecutive lines of a multi-line expression
-+ report the same ctree item, so memoising by ``obj_id`` (unique within a
-+ cfunc) skips most of them.
-+
-+ 18 991 lines of bash: 2 302ms -> 429ms.
-+ """
-+ import ida_lines
-+ import ida_hexrays as _hx
-+ from ida_pro_mcp.ida_mcp.utils import compact_whitespace, decompile_checked
-+ from ida_pro_mcp.ida_mcp.sync import IDAError
-+ try:
-+ cfunc = decompile_checked(ea)
-+ item = _hx.ctree_item_t()
-+ get_line_item = cfunc.get_line_item
-+ tag_remove = ida_lines.tag_remove
-+ ea_of_id = {}
-+ lines = []
-+ for sl in cfunc.get_pseudocode():
-+ line = sl.line
-+ line_ea = None
-+ if include_addresses and get_line_item(line, 0, False, None,
-+ item, None):
-+ it = item.it
-+ oid = it.obj_id if it is not None else None
-+ if oid is not None and oid in ea_of_id:
-+ line_ea = ea_of_id[oid]
-+ else:
-+ dstr = item.dstr()
-+ if dstr:
-+ ds = dstr.split(": ")
-+ if len(ds) == 2:
-+ try:
-+ line_ea = int(ds[0], 16)
-+ except ValueError:
-+ pass
-+ if oid is not None:
-+ ea_of_id[oid] = line_ea
-+ text = compact_whitespace(tag_remove(line))
-+ if line_ea is not None:
-+ lines.append(f"{text} /*{line_ea:#x}*/")
-+ else:
-+ lines.append(text)
-+ return "\\n".join(lines), None
-+ except IDAError as e:
-+ return None, str(e)
-+ except Exception as e:
-+ return None, f"Decompilation failed at {hex(ea)}: {e}"
-+
-+
- _idatui_strings_cache = {}
-
-
diff --git a/.auto/parked/fast_pc_nums.patch b/.auto/parked/fast_pc_nums.patch
deleted file mode 100644
index f06bfe1..0000000
--- a/.auto/parked/fast_pc_nums.patch
+++ /dev/null
@@ -1,52 +0,0 @@
-diff --git a/server/patch_server.py b/server/patch_server.py
-index 6667e12..5e20671 100644
---- a/server/patch_server.py
-+++ b/server/patch_server.py
-@@ -1900,7 +1900,7 @@ def _idatui_lit_extent(plain, x):
- return (lo, hi)
-
-
--def _idatui_pc_nums(cf, sl):
-+def _idatui_pc_nums(cf, sl, plain=None):
- """Every number literal on one pseudocode line, as
- [{x0, x1, ea, opnum, value, nbytes, fmt}].
-
-@@ -1912,16 +1912,26 @@ def _idatui_pc_nums(cf, sl):
- import ida_lines
- import idaapi
-
-- plain = ida_lines.tag_remove(sl.line)
-+ # ``plain`` is the untagged line; callers that already have it pass it in
-+ # rather than making tag_remove run twice over every line of the function.
-+ if plain is None:
-+ plain = ida_lines.tag_remove(sl.line)
- out = []
- x = 0
-+ # One ctree_item_t for the whole line, and no head/tail at all. They are
-+ # SWIG allocations in the innermost loop of a scan that probes every
-+ # literal-looking character -- and 'a' to 'f' are hex digits, so `a1`, `v6`
-+ # and `sub_1F4C0` all qualify and most columns of a line get probed. head
-+ # and tail were never read. (Same three costs as decomp_map's sweep.)
-+ item = ida_hexrays.ctree_item_t()
-+ line = sl.line
-+ get_line_item = cf.get_line_item
- while x < len(plain):
- ch = plain[x]
- if ch not in _IDATUI_LIT_CHARS and ch != "'":
- x += 1
- continue
-- head, item, tail = (ida_hexrays.ctree_item_t() for _ in range(3))
-- if not cf.get_line_item(sl.line, x, True, head, item, tail):
-+ if not get_line_item(line, x, True, None, item, None):
- x += 1
- continue
- if item.citype != ida_hexrays.VDI_EXPR:
-@@ -1997,7 +2007,7 @@ def pc_nums(
- for i in range(len(sv)):
- plain = ida_lines.tag_remove(sv[i].line)
- compact = _idatui_compact(plain)
-- for rec in _idatui_pc_nums(cf, sv[i]):
-+ for rec in _idatui_pc_nums(cf, sv[i], plain):
- out.append({
- "line": i,
- "x0": _idatui_compact_col(plain, compact, rec["x0"]),
diff --git a/.auto/prompt.md b/.auto/prompt.md
deleted file mode 100644
index 8437b38..0000000
--- a/.auto/prompt.md
+++ /dev/null
@@ -1,275 +0,0 @@
-# Autoresearch: make ida-tui faster, without losing anything
-
-## Objective
-
-Reduce the wall-clock latency of the operations an ida-tui user actually waits
-on, on both a small binary (`targets/echo`, 128 funcs) and a real-world one
-(`targets/bash`, 2099 funcs). No feature may be removed, no output may change,
-no test may break.
-
-The app is a Textual TUI over a private idalib worker process (unix socket,
-length-prefixed pickle). Three layers, kept separate:
-
-- `idatui/worker.py` + `idatui/worker_client.py` — backend; one call = one
- round trip to a process that owns the IDA database.
-- `idatui/domain.py` — paging/caching over the client (`FunctionIndex`,
- `ListingModel`, `DisasmModel`, `HexModel`, `decompile`, xrefs, resolve).
-- `idatui/app.py` — the Textual app; views are line-virtualized `ScrollView`s.
-- `idatui/graph.py` — pure-python Sugiyama layout for the CFG view.
-- `server/patch_server.py` — the extra `@tool`s the worker injects into
- ida-pro-mcp (`heads`, `read_raw`, `resolve_names`, …). This is where a new
- backend capability goes; it runs INSIDE the worker with full idalib access.
-
-## Metrics
-
-- **Primary**: `total_ms` (ms, lower is better) — the sum of every phase median.
- Re-baselined once (experiment v2 #1) when `phase_graph` was found to be timing
- a cache hit; the v1 history below is still the record of what was learned.
-- **Secondary** (all in ms, per target: `sm_` = echo, `lg_` = bash):
- - `nav_ms` — jump to a function's entry in the listing, cold. **The single
- biggest term today** (`lg_nav_ms` ≈ 28 s of a 46 s total, and
- `lg_nav_worst_ms` ≈ 28 s for ONE jump).
- - `search_ms` — incremental search over a whole segment.
- - `decomp_ms` — F5 → decompile → highlight → paint (nav excluded).
- - `graph_ms` — flowchart → layout → paint.
- - `boot_ms`, `listing_cold_ms`, `listing_warm_ms`, `render_ms`, `hex_ms`,
- `index_ms`, `palette_ms`, `pure_graph_ms`.
- - `fails` — **must stay 0.** A phase that silently stops doing its work would
- otherwise read as an enormous speedup.
-- `NOTES` on each run carries the work actually done (`decomp_ok`, `graph_ok`,
- `graph_blocks`, `search_hits`, `listing_rows`, `nav_rows`, `render_cells`, …).
- **If a metric drops and its NOTES counter drops with it, that is not a win.**
-
-## How to Run
-
-`./.auto/measure.sh` (~45 s). Prints `METRIC name=value` lines.
-`./.auto/measure.sh --only sm` benches just the small target while iterating.
-
-`./.auto/checks.sh` runs automatically after every passing benchmark (~160 s):
-
-1. `.auto/check_search.py` — the search fast paths against the plain per-line
- loop, for every typed prefix.
-2. `.auto/check_rename.py` — the listing after a rename, read narrow (painting)
- and wide (search body), and the whole model against a rebuild.
-3. `tests/run.py` — every suite, 830 checks.
-
-**The two `.auto/check_*.py` scripts exist because the things they guard fail
-silently.** A stale cache still returns *an* answer, and the benchmark rewards
-it for being fast. Twice this session the honest change measured worse than the
-broken one. If you optimise a cache, write the check that fails on the old code
-first.
-
-## Files in Scope
-
-Anything under `idatui/` and `server/patch_server.py`. In rough order of
-expected payoff:
-
-- `idatui/domain.py` — `ListingModel` is where nav time lives. It walks the
- segment forward in 500-head pages from `seg_start`, so `ensure_ea(ea)` is
- O(distance from the start of the segment): landing on a function near the end
- of bash costs ~440 sequential worker round trips. `DisasmModel`,
- `HexModel`, `FunctionIndex` are the other paging/caching classes.
-- `server/patch_server.py` — the `heads` tool the listing pages over. A better
- backend primitive (address-anchored start, bigger/denser pages, a count-only
- or index mode) is fair game and probably the real fix.
-- `idatui/app.py` — `render_line` of `ListingView`/`DecompView`/`HexView`/
- `GraphView`, the search mixin (`_compute_matches`, `_line_plain`), `_grow`,
- `_prime`.
-- `idatui/graph.py` — layout; already Fenwick-optimised once (see the idatui
- skill), so the easy win is gone.
-- `idatui/worker.py`, `idatui/worker_client.py` — transport (pickle framing,
- per-call overhead).
-- `idatui/highlight.py` — Pygments C lexing per decompilation.
-
-## Off Limits
-
-- `tests/**` — the correctness gate. Do not weaken, skip, shorten or "fix" a
- test to make a change pass. If a test fails, **assume the change is wrong**.
-
- Two narrow exceptions have been used, each with proof recorded in the log:
- the racy-setup one below, and adding `.auto/check_*.py` gates (which only ever
- *add* coverage).
-
- The racy-setup exception, and what it costs you: a scenario whose *setup* is
- racy, where the speedup merely decides which of two async loads lands first.
- Before touching it you must (a) bisect to show which change flips it, (b)
- reproduce the race outside the suite, showing the app reaching two different
- states from the same steps, and (c) show the repaired scenario passing on
- BOTH the fast and the slow code. Only the setup may change — every `c.check`
- stays exactly as it was — and the ASI must record all three proofs. Done once
- so far, for `graph_minimap` (experiment #6).
-- `.auto/bench.py` may only be changed to add *more* signal (extra metrics,
- extra NOTES). Never to do less work, shorten a sweep, drop a phase, loosen a
- wait, or pick easier functions. If you change what it measures, say so in the
- log and re-baseline with `init_experiment`.
-- `targets/**` binaries and their `.i64`/`.pristine.i64` databases.
-
-## Constraints
-
-- **No functionality may be lost.** Same rendering, same colours, same
- behaviour. `checks.sh` must pass.
-- **No new third-party dependencies.** `domain.py` and `worker_client.py` are
- deliberately stdlib-only (the TUI layer may use Textual/Rich/Pygments, which
- are already dependencies).
-- **No caching that can go stale silently.** Renames bump `Program._name_gen`
- and disasm caches are cleared for a reason; a new cache must have an
- invalidation story or it will show stale names after an edit.
-- The worker is single-threaded and main-thread-only for idalib. Parallelism
- has to come from batching calls, not from calling IDA concurrently.
-- Do not tune constants to the two benchmark binaries. A change must be a
- structural improvement that holds for a 10 MB firmware image too.
-
-## What's Been Tried
-
-v1 bench baseline `total_ms` ≈ 46 600 → 18 900 after thirteen experiments
-(−59% on v1, then a re-baseline at 27 913 and −32% on v2).
-
-**Wins, biggest first**
-
-1. *(v1 #3, −42%)* **ida-pro-mcp installs a `sys.setprofile` hook around every
- tool call.** Its deadline mechanism profiles every python call/return so a
- pure-python tool body can be interrupted — a 3.3× tax on a backend whose
- tools are call-heavy (`heads`: 92 → 28 µs/row without it). `worker.py` now
- sets `IDA_MCP_TOOL_TIMEOUT_SEC=0` and arms the deadline itself with one
- polling watchdog thread + `ida_kernwin.set_cancelled()` — the half that
- actually frees the IDA main thread.
-2. *(v6 #2, −41%)* **`decomp_map` swept every column three times over.** Three
- SWIG allocations per column, the TAGGED line length as the bound (124 columns
- for a 23-column line), and a `dstr()` format per column when consecutive
- columns are the same ctree item. bash's 25 largest: 67.4 s → 6.8 s. This is
- the split view's whole cost.
-3. *(v7 #2, −23%)* **A rename kept the listing's walk.** `bump_names` discarded
- the segment model, so the reload re-walked it to find a row the cursor was
- already on — 1.7 s per rename on bash. Now the walk stays and the text is
- re-rendered a block at a time: 10.3 ms.
-4. *(v2 #2, −32%)* **`Program.flowchart` fetched the convex hull of the basic
- blocks.** IDA function chunks live far from the entry, so a 1.4 KB function
- could span 680 KB: 128 000 rows fetched, 3 s to draw, and the far blocks
- came back *empty* because the pager's 64-page bound ran out first. Now it
- fetches the merged block intervals and assigns rows by bisect.
-5. *(v1 #6)* **`lru_cache` on the per-line render** (`_idatui_line_parts`):
- 196 k listing lines of bash are only 53 k distinct, 26.7 → 16.5 µs/row.
-6. *(v1 #7, −9%)* **Incremental search narrows instead of rescanning** — typing
- a character can only remove lines.
-7. *(v5 #6)* **Highlight ranges are computed per line on demand.** Searching
- one character over bash matches 177 k lines at 310 k places; all but the
- forty on screen were built and thrown away.
-8. *(v7 #10, −4%)* **`heads(digest=True)`** — ask whether a page still renders
- as you hold it, rather than fetching it to find out.
-9. *(v7 #12, −2.6%)* Three redundancies in the `heads` walk: item flags fetched
- three times per head, `get_func` per head where a head is nearly always in
- the same function as the one before it, and the page digest rebuilding a
- tuple-of-tuples per row where one spans list is shared by ~45% of them.
-10. *(v1 #9/#10/#13, v2 #3, v5 #3)* Constants: `bytes.hex(" ")` for the opcode
- column (12×), `bisect` imported at module scope, the deferred
- `refresh(layout=True)` only when a scroll actually clamped, a memoised
- pygments token→style lookup, `_CellRow` writing by slice, `HexView` emitting
- style runs instead of a Segment per byte, `Head` as a `NamedTuple`, and the
- graph's transposition counting keep and swap in one pass.
-
-**Dead ends / things not to re-try**
-
-- `re.finditer` per tag in the span walker is *slower* than a plain character
- loop (14.4 vs 13.2 µs/line): Match objects cost more than the ~54 trivial
- iterations they replace. One capturing `re.split` is what wins (10.2).
-- `ListingModel.PAGE` (500 / 1000 / 2000) makes no measurable difference —
- the cost is per row, not per round trip. Don't tune it.
-- Changing the `heads` wire shape (int `ea`, tuples instead of dicts) buys at
- most 0.3 µs/row. Measured; not worth a breaking change.
-- A page-scoped identity memo for the spans→tuple conversion never hits: the
- repeats are spread across the segment, not within a 500-row page. A
- model-scoped one is unsafe (`id()` is reused once the page dicts die).
-- Growing the line cache past 16 384 does nothing for a *cold* sweep (17.0
- µs/row at 16 k, 32 k, 64 k and 128 k alike). It only helps a *second* sweep —
- which was a dead end until the rename fix created one, and then it was worth
- 21%. **Re-read the dead ends after a structural change: this one stopped being
- one.** Sized at 65 536 now (bash's .text has 53 363 distinct lines; 32 768
- still thrashes). Costs +47 MB of worker RSS, and it is a bound rather than a
- proportion — a bigger binary fills it and stops.
-- Merging same-kind adjacent spans in `_idatui_spans`: only 2.5% fewer spans on
- 20 k real lines. Not worth a wire-format change.
-- Applying an already-decompiled function inline instead of via a `@work`
- thread: a Textual thread spawn plus its `call_from_thread` is worth well under
- 1% of an F5. Measured flat; reverted for complexity.
-- Deferring the "decompiling…" loading cover until ~120 ms (worth ~9 ms per F5)
- is blocked: a scenario asserts F5 raises it **synchronously**, guarding a real
- past regression. That is an assertion, not setup, so it stands.
-- Polling the worker socket faster than ~25 ms is actively harmful: the poll
- runs on a background thread and starves the UI thread through a cold
- auto-analysis (see v5 #4).
-
-**Where the time is now (28 842 ms on the v7 bench)**
-
-`lg_search` 7 123 · `lg_nav` 6 610 · decomp lg+sm 3 647 · split lg+sm 4 027 ·
-graph lg+sm 1 691 · boot lg+sm 1 151 · listing lg+sm 1 428 · rename lg+sm 1 119 ·
-hex lg+sm 895 · render 483 · pure_graph 212.
-
-`lg_search` is large *because of where it sits in the session*: the bench renames
-six functions and then searches the whole 228 k-row segment, so it pays to
-re-render everything the rename staled. Before the rename fix that same cost was
-paid up front, inside the renames (`lg_rename` was 10 055 ms). Re-rendering N
-heads costs what loading N heads costs; the win was in not doing it for rows
-nobody reads.
-
-**Five things are at a floor that is not ours to move:**
-
-- `nav` — `generate_disasm_line` is 5.9 µs of the ~16.5 µs/row the worker
- spends, and the walk is inherently linear.
-- `decomp` — the raw `decompile` tool is 1 711 ms cold for echo's twelve largest
- functions and 218 ms warm; pickling the result is 0.1 ms. It is Hex-Rays.
-- `search` — after a rename, what you read has to be re-rendered. The worker
- still has to *render* a page to know it is unchanged (`generate_disasm_line`
- is the floor), but since v7 #10 it no longer has to ship it: `heads(...,
- digest=True)` returns hash+count, and the client keeps the page it already
- has. That is ~40% of a page's cost, and after a rename nearly every page is
- unchanged.
-- `listing`/`hex`/`graph`/`render` — mostly Textual's own compositing, ~6 ms per
- full-screen frame. Our `render_line` is ~1.8 ms of a ~10 ms hex frame.
-- `boot` — ~150 ms of it is the worker importing `idapro`.
-
-**Benchmark history.** The bench was corrected five times. Three of those found
-a cost that was *entirely invisible*, and two of those three turned out to be
-among the largest wins of the whole session. Every re-baseline is in
-`.auto/log.jsonl`:
-
-| bench | baseline | best | what changed |
-|---|---|---|---|
-| v1 | 46 572 | 19 006 | — |
-| v2 | 27 913 | 17 501 | graph opens were timing a **cache hit** |
-| v4 | 18 498 | — | decomp/search/index reps were timing cache hits |
-| v5 | 18 516 | 17 465 | landing polls every 2 ms, not 10 |
-| v6 | 33 502 | 19 835 | **split view was not covered at all** (48% of a session) |
-| v7 | 33 243 | 28 651 | **rename was not covered at all** (1.7 s each on bash) |
-
-Headline user-facing numbers, measured directly rather than through the bench:
-a cold jump to a far address on bash 28.3 s → 6.4 s; drawing a chunked
-function's graph 3.0 s → 0.01 s (and its far blocks are no longer empty);
-opening the split view 850 ms → 110 ms; getting the listing back after a rename
-5.7 s → 10 ms.
-
-**The most productive thing in this session was asking what the bench does not
-measure.** Features still uncovered: xrefs (`x`), the strings browser (`"`), the
-struct editor, literal formats (`o`), make-code/data edits, history, execution
-traces, the RPC layer. Domain-level probes say xrefs/strings/structs/resolve are
-all fast (`/tmp/featprobe.py` pattern), but nothing has driven them end to end.
-
-**Measurement traps**
-
-- **Check `uptime` before believing a number.** This box started the session at
- load 0.6 and drifted to 1.6; `lg_nav` (224 000 sequential worker round trips)
- moved 6 556 → 7 058 with no code change, while the direct paging microbenchmark
- stayed at 24.4 µs/row. Totals are not comparable across a load change — rebase
- on a fresh same-session run, or verify in a microbenchmark:
- `/tmp/domain_break.py` (cold paging), `.auto/diff_spans.py` (span walker),
- `tests/test_graph.py <corpus>` (layout).
-
-- `cProfile` massively distorts this code (it is call-heavy): it reported
- `_idatui_spans` at 68% of the `heads` tool when the real share was ~10%.
- A/B with `time.perf_counter` in one process instead.
-- Anything that walks the listing twice in one process is measuring a warm
- `_idatui_line_parts` cache the second time. Run cold cases first, or in
- separate processes.
-- Anything that goes through `Program.flowchart`, `Program.decompile` or
- `Program.functions` twice is measuring a cache. Clear it or use the raw tool.
-- **The worker is a single serial process.** Work "moved to the background"
- does not overlap with anything; only doing less work helps.
diff --git a/.auto/wip-chunk.patch b/.auto/wip-chunk.patch
deleted file mode 100644
index 4e03aa7..0000000
--- a/.auto/wip-chunk.patch
+++ /dev/null
@@ -1,53 +0,0 @@
-diff --git a/idatui/domain.py b/idatui/domain.py
-index 386b3e8..1f8263d 100644
---- a/idatui/domain.py
-+++ b/idatui/domain.py
-@@ -919,24 +919,37 @@ class ListingModel:
- def _ensure_text(self, j0: int, j1: int) -> None:
- """Re-render physical heads [j0, j1) if a rename staled them.
-
-- The block is snapped out to whole ADDRESS groups. A function start emits
-- three banner rows and its code row at the same ea, and a labelled
-- instruction emits two -- so a block boundary that fell inside one of
-- those groups would refetch the whole group and never line up again.
-+ Done a block at a time. One call for the whole range would be simpler but
-+ the ``heads`` tool caps a response at 2000 rows, so a wide request (the
-+ search body asks for thousands at once) would come back short, fail the
-+ sequence check, and condemn the model to a rebuild it did not need.
-+ """
-+ blk = self.TEXT_BLOCK
-+ with self._lock:
-+ n = len(self._heads)
-+ start = (max(j0, 0) // blk) * blk
-+ while start < min(j1, n):
-+ self._ensure_text_block(start, min(start + blk, n))
-+ start += blk
-+
-+ def _ensure_text_block(self, j0: int, j1: int) -> None:
-+ """Re-render one block, snapped out to whole ADDRESS groups.
-+
-+ A function start emits three banner rows and its code row at the same ea,
-+ and a labelled instruction emits two -- so a boundary falling inside one
-+ of those groups would refetch the whole group, never line up, and leave
-+ the old names on screen for good.
- """
- with self._lock:
- gen = self._text_gen
- n = len(self._heads)
-- j0 = max(j0, 0)
-- j1 = min(j1, n)
-- if j1 <= j0:
-+ a = max(j0, 0)
-+ b = min(j1, n)
-+ if b <= a:
- return
- head_gen = self._head_gen
-- if all(head_gen[j] == gen for j in range(j0, j1)):
-+ if all(head_gen[j] == gen for j in range(a, b)):
- return
-- blk = self.TEXT_BLOCK
-- a = (j0 // blk) * blk
-- b = min(((j1 - 1) // blk + 1) * blk, n)
- eas = self._head_eas
- while a > 0 and eas[a - 1] == eas[a]:
- a -= 1
diff --git a/.auto/wip-decompmap.patch b/.auto/wip-decompmap.patch
deleted file mode 100644
index a11db98..0000000
--- a/.auto/wip-decompmap.patch
+++ /dev/null
@@ -1,49 +0,0 @@
-diff --git a/server/patch_server.py b/server/patch_server.py
-index fe16ede..b8f5453 100644
---- a/server/patch_server.py
-+++ b/server/patch_server.py
-@@ -884,16 +884,39 @@ def decomp_map(
- return {"error": f"decompile failed: {e}"}
- if cfunc is None:
- return {"error": "decompile failed"}
-+ import ida_lines
-+ # Three things this loop must not do, each measured on real functions (the 25
-+ # largest of bash went 68.3s -> 6.5s; echo's 60 largest 5.4s -> 0.6s, with
-+ # byte-identical output):
-+ #
-+ # * allocate ctree_item_t's per COLUMN. They are SWIG objects and this is
-+ # the innermost loop; one per call is enough, and head/tail are never
-+ # read, so don't ask for them at all.
-+ # * sweep the TAGGED length. ``x`` is a screen column but ``sl.line`` still
-+ # carries IDA's colour tags, so a 23-column line was swept 124 times.
-+ # * call dstr() per column. It formats a whole 'EA: description' string, and
-+ # consecutive columns are nearly always the same ctree item -- so ask the
-+ # item for its id first and only format when it changes. (The result is
-+ # deduped by ``seen`` anyway, so skipping a repeat cannot change it.)
-+ item = ida_hexrays.ctree_item_t()
-+ tag_remove = ida_lines.tag_remove
-+ get_line_item = cfunc.get_line_item
- 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):
-+ prev_id = None
-+ for x in range(len(tag_remove(line)) + 1):
-+ if not get_line_item(line, x, False, None, item, None):
- continue
-+ it = item.it
-+ if it is not None:
-+ oid = it.obj_id
-+ if oid == prev_id:
-+ continue
-+ prev_id = oid
-+ else:
-+ prev_id = None
- # 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()
diff --git a/.auto/wip-digest.patch b/.auto/wip-digest.patch
deleted file mode 100644
index d4d23e8..0000000
--- a/.auto/wip-digest.patch
+++ /dev/null
@@ -1,162 +0,0 @@
-diff --git a/idatui/domain.py b/idatui/domain.py
-index 1f8263d..9cf539f 100644
---- a/idatui/domain.py
-+++ b/idatui/domain.py
-@@ -673,6 +673,14 @@ class ListingModel:
- #: Whether a rename has ever staled this model. Until one has, every
- #: read takes exactly the path it always did.
- self._renamed = False
-+ #: Where each loaded page starts, so a stale-text refresh can ask the
-+ #: worker "is this page still what I have?" over exactly the extent the
-+ #: worker itself produced. Parallel lists: first head index, the address
-+ #: it was fetched from, the digest it came back with, and its row count.
-+ self._page_head: list[int] = []
-+ self._page_addr: list[int] = []
-+ self._page_digest: list[object] = []
-+ self._page_rows: list[int] = []
- #: Set if a text refresh came back with a different head sequence, which
- #: means something DID move the walk. Program.listing() throws the model
- #: away when it sees this, so the next read rebuilds from scratch.
-@@ -758,6 +766,11 @@ class ListingModel:
- page = self._build_page(rows)
- with self._lock:
- gen = self._text_gen
-+ self._page_head.append(len(self._heads))
-+ self._page_addr.append(frm)
-+ self._page_digest.append(payload.get("digest")
-+ if isinstance(payload, dict) else None)
-+ self._page_rows.append(len(rows))
- for h in page:
- # Banner/label rows (function headers, separators, code labels)
- # are display-only; don't index them so navigation lands on the
-@@ -924,13 +937,65 @@ class ListingModel:
- search body asks for thousands at once) would come back short, fail the
- sequence check, and condemn the model to a rebuild it did not need.
- """
-- blk = self.TEXT_BLOCK
- with self._lock:
- n = len(self._heads)
-- start = (max(j0, 0) // blk) * blk
-- while start < min(j1, n):
-- self._ensure_text_block(start, min(start + blk, n))
-- start += blk
-+ j0 = max(j0, 0)
-+ j1 = min(j1, n)
-+ while j0 < j1:
-+ j0 = self._ensure_text_from(j0, j1, n)
-+
-+ def _ensure_text_from(self, j0: int, j1: int, n: int) -> int:
-+ """Freshen from head ``j0`` and return where to carry on.
-+
-+ Tries the page ``j0`` falls in first: the worker can say whether that
-+ page still renders exactly as it did, for the cost of the render alone
-+ -- no rows on the wire, none unpickled, no Heads rebuilt. After a rename
-+ nearly every page comes back identical, and that is 40% of what asking
-+ for it again would have cost. Falls back to the block refetch when the
-+ page has genuinely changed (or predates the digest).
-+ """
-+ with self._lock:
-+ p = bisect.bisect_right(self._page_head, j0) - 1
-+ usable = (0 <= p < len(self._page_head)
-+ and self._page_digest[p] is not None)
-+ if usable:
-+ p_lo = self._page_head[p]
-+ p_hi = (self._page_head[p + 1] if p + 1 < len(self._page_head)
-+ else len(self._heads))
-+ gen = self._text_gen
-+ fresh = all(self._head_gen[k] == gen for k in range(p_lo, p_hi))
-+ addr, want_dig = self._page_addr[p], self._page_digest[p]
-+ want_rows = self._page_rows[p]
-+ if usable and fresh:
-+ return p_hi
-+ if usable and self._verify_page(p, p_lo, p_hi, addr, want_dig,
-+ want_rows, gen):
-+ return p_hi
-+ blk = self.TEXT_BLOCK
-+ end = min(j0 + blk, j1 if usable else n)
-+ self._ensure_text_block(j0, max(end, j0 + 1))
-+ return max(end, j0 + 1)
-+
-+ def _verify_page(self, p: int, p_lo: int, p_hi: int, addr: int,
-+ want_dig: object, want_rows: int, gen: int) -> bool:
-+ """Ask whether page ``p`` still renders as it did; mark it fresh if so."""
-+ try:
-+ payload = self._prog.client.call(
-+ "heads", addr=hex(addr), count=self.PAGE, annotate=True,
-+ digest=True)
-+ except Exception: # noqa: BLE001 -- an older worker has no digest mode
-+ return False
-+ if not isinstance(payload, dict):
-+ return False
-+ got = payload.get("digest")
-+ if got is None or got != want_dig or payload.get("count") != want_rows:
-+ return False
-+ with self._lock:
-+ if self._text_gen != gen or len(self._heads) < p_hi:
-+ return False
-+ for k in range(p_lo, p_hi):
-+ self._head_gen[k] = gen
-+ return True
-
- def _ensure_text_block(self, j0: int, j1: int) -> None:
- """Re-render one block, snapped out to whole ADDRESS groups.
-diff --git a/server/patch_server.py b/server/patch_server.py
-index f3cbe35..a34a4a9 100644
---- a/server/patch_server.py
-+++ b/server/patch_server.py
-@@ -576,6 +576,28 @@ def _idatui_spans(line):
- return [[k, t] for k, t, _o in out], trimmed
-
-
-+def _idatui_rows_digest(rows):
-+ """A value that changes whenever any of ``rows`` would render differently.
-+
-+ Covers everything a client keeps off a row: address, kind, size, the plain
-+ text, the symbol name and the colour spans (which is what makes it exact
-+ rather than a heuristic -- two lines can collapse to the same text and still
-+ be coloured differently).
-+
-+ Uses the interpreter's own ``hash``, deliberately. It never has to mean
-+ anything outside this process: the client stores what a page hashed to when
-+ it loaded it and hands the same number back to ask whether the page still
-+ hashes to that. One worker, one process, one hash seed.
-+ """
-+ acc = 0
-+ for r in rows:
-+ sp = r.get("spans")
-+ acc = hash((acc, r.get("ea"), r.get("kind"), r.get("size"),
-+ r.get("text"), r.get("name"),
-+ tuple(map(tuple, sp)) if sp else None))
-+ return acc
-+
-+
- def _idatui_unknown_row(ea, size):
- """One collapsed row for a run of ``size`` undefined bytes starting at
- ``ea``. A single byte is rendered normally (shows its value); a longer run
-@@ -661,6 +683,7 @@ def heads(
- end: Annotated[str, "Optional exclusive end address; default = segment end"] = "",
- back: Annotated[bool, "Walk backwards: return the count heads ENDING just before addr, in forward order"] = False,
- annotate: Annotated[bool, "Emit IDA-style function boundary banner rows (kind sep/funchdr)"] = False,
-+ digest: Annotated[bool, "Return only a digest+count of the rows, not the rows themselves"] = False,
- ) -> dict:
- """Walk item heads from ``addr`` as a flat listing: every head is rendered
- (code OR data OR undefined) via generate_disasm_line and stepped with
-@@ -767,7 +790,17 @@ def heads(
- rows.extend(_rows_for(ea)) # a struct head expands into member rows
- ea = _advance(ea)
- cursor = {"next": hex(ea)} if more else {"done": True}
-- return {"addr": str(addr), "heads": rows, "cursor": cursor}
-+ out = {"addr": str(addr), "cursor": cursor,
-+ "digest": _idatui_rows_digest(rows), "count": len(rows)}
-+ # ``digest`` mode answers "is this page still exactly what you have?" without
-+ # shipping it. The rows are built either way -- generate_disasm_line is the
-+ # floor and there is no way to know a line is unchanged without rendering it
-+ # -- but pickling several hundred rows with their colour spans, unpickling
-+ # them and rebuilding Heads is about 40% of what a page costs, and after a
-+ # rename almost every page comes back identical.
-+ if not digest:
-+ out["heads"] = rows
-+ return out
-
-
- @tool
diff --git a/.auto/wip-renamekeep.patch b/.auto/wip-renamekeep.patch
deleted file mode 100644
index 2c3344c..0000000
--- a/.auto/wip-renamekeep.patch
+++ /dev/null
@@ -1,211 +0,0 @@
-diff --git a/idatui/domain.py b/idatui/domain.py
-index b5081cf..386b3e8 100644
---- a/idatui/domain.py
-+++ b/idatui/domain.py
-@@ -642,6 +642,11 @@ class ListingModel:
- """
-
- PAGE = 500 # heads per server call (well under the tool's 2000 cap)
-+ #: Heads refreshed together when a rename makes their text stale. One server
-+ #: call per block, so a viewport costs one round trip rather than forty --
-+ #: and the same size as a load page, so refreshing everything costs about
-+ #: what rebuilding everything would have.
-+ TEXT_BLOCK = 500
-
- def __init__(self, program: "Program", seg_start: int, seg_end: int,
- name: str | None = None):
-@@ -659,6 +664,19 @@ class ListingModel:
- # demand. _row_at[i] is the logical row where physical head i starts.
- self._row_at: list[int] = []
- self._head_eas: list[int] = [] # parallel to _heads, for bisect
-+ #: Which name generation each head's TEXT was rendered at, parallel to
-+ #: _heads. A rename bumps :attr:`_text_gen`; the rows themselves stay
-+ #: (their addresses and row numbers are unchanged) and are re-rendered a
-+ #: block at a time when something asks for them. See invalidate_text.
-+ self._head_gen: list[int] = []
-+ self._text_gen = 0
-+ #: Whether a rename has ever staled this model. Until one has, every
-+ #: read takes exactly the path it always did.
-+ self._renamed = False
-+ #: Set if a text refresh came back with a different head sequence, which
-+ #: means something DID move the walk. Program.listing() throws the model
-+ #: away when it sees this, so the next read rebuilds from scratch.
-+ self.stale_structure = False
- self._rows = 0 # total logical rows loaded
- self._ubytes: dict[int, bytes] = {} # lazily-read bytes for those rows
- self._next: int | None = seg_start # next address to fetch from
-@@ -739,6 +757,7 @@ class ListingModel:
- cur = payload.get("cursor", {}) if isinstance(payload, dict) else {}
- page = self._build_page(rows)
- with self._lock:
-+ gen = self._text_gen
- for h in page:
- # Banner/label rows (function headers, separators, code labels)
- # are display-only; don't index them so navigation lands on the
-@@ -747,6 +766,7 @@ class ListingModel:
- self._by_ea.setdefault(h.ea, self._rows)
- self._row_at.append(self._rows)
- self._head_eas.append(h.ea)
-+ self._head_gen.append(gen)
- self._heads.append(h)
- self._rows += self._span(h)
- nxt = cur.get("next")
-@@ -878,6 +898,78 @@ class ListingModel:
- def __len__(self) -> int:
- return self.loaded()
-
-+ def invalidate_text(self) -> None:
-+ """A rename changed how rows READ, not which rows exist.
-+
-+ Item boundaries are untouched by a rename, so every row keeps its
-+ address and its row number — which the edit path already relies on, since
-+ it restores the cursor by INDEX afterwards. Dropping the whole model
-+ instead means the next jump re-walks the segment from its start: 6.4
-+ seconds on bash's .text, after every single rename.
-+
-+ So keep the walk and mark the rendered text stale; :meth:`_ensure_text`
-+ re-renders a block at a time, and refuses to splice anything back if the
-+ head sequence has moved under it (which a rename cannot do, but a
-+ mis-routed structural edit could).
-+ """
-+ with self._lock:
-+ self._text_gen += 1
-+ self._renamed = True
-+
-+ def _ensure_text(self, j0: int, j1: int) -> None:
-+ """Re-render physical heads [j0, j1) if a rename staled them.
-+
-+ The block is snapped out to whole ADDRESS groups. A function start emits
-+ three banner rows and its code row at the same ea, and a labelled
-+ instruction emits two -- so a block boundary that fell inside one of
-+ those groups would refetch the whole group and never line up again.
-+ """
-+ with self._lock:
-+ gen = self._text_gen
-+ n = len(self._heads)
-+ j0 = max(j0, 0)
-+ j1 = min(j1, n)
-+ if j1 <= j0:
-+ return
-+ head_gen = self._head_gen
-+ if all(head_gen[j] == gen for j in range(j0, j1)):
-+ return
-+ blk = self.TEXT_BLOCK
-+ a = (j0 // blk) * blk
-+ b = min(((j1 - 1) // blk + 1) * blk, n)
-+ eas = self._head_eas
-+ while a > 0 and eas[a - 1] == eas[a]:
-+ a -= 1
-+ while b < n and eas[b - 1] == eas[b]:
-+ b += 1
-+ last = self._heads[b - 1]
-+ lo = eas[a]
-+ hi = last.ea + max(last.size, 1)
-+ want = [(h.ea, h.kind) for h in self._heads[a:b]]
-+ try:
-+ payload = self._prog.client.call(
-+ "heads", addr=hex(lo), end=hex(hi),
-+ count=min(len(want) + 64, 2000), annotate=True)
-+ except Exception: # noqa: BLE001 -- keep the old text rather than blank
-+ return
-+ rows = payload.get("heads", []) if isinstance(payload, dict) else []
-+ page = self._build_page(rows)[:len(want)]
-+ with self._lock:
-+ if self._text_gen != gen or len(self._heads) < b:
-+ return
-+ if [(h.ea, h.kind) for h in page] != want:
-+ # Something moved the walk, which a rename cannot do -- so this
-+ # was not one. Say so and let Program.listing() rebuild, rather
-+ # than sit here re-fetching a block that will never line up (and
-+ # showing the old names while doing it).
-+ self.stale_structure = True
-+ for j in range(a, b):
-+ self._head_gen[j] = gen
-+ return
-+ self._heads[a:b] = page
-+ for j in range(a, b):
-+ self._head_gen[j] = gen
-+
- def get(self, i: int) -> Head | None:
- with self._lock:
- if not (0 <= i < self._rows):
-@@ -885,8 +977,23 @@ class ListingModel:
- j, off = self._phys(i)
- if j < 0:
- return None
-- span = self._span(self._heads[j])
-- h = self._heads[j]
-+ stale = self._renamed and self._head_gen[j] != self._text_gen
-+ if not stale:
-+ span = self._span(self._heads[j])
-+ h = self._heads[j]
-+ if stale:
-+ # A rename staled this row's text; re-render its block (one call for
-+ # the block around it, so a viewport costs one round trip). Only
-+ # this path re-takes the lock -- the ordinary read stays atomic.
-+ self._ensure_text(j, j + 1)
-+ with self._lock:
-+ if not (0 <= i < self._rows):
-+ return None
-+ j, off = self._phys(i)
-+ if j < 0:
-+ return None
-+ span = self._span(self._heads[j])
-+ h = self._heads[j]
- # Synthesis reads bytes, so do it OUTSIDE the lock: an RPC under the
- # model lock deadlocks the page loader that is filling it.
- return self._row_head(j, off) if span > 1 else h
-@@ -894,6 +1001,16 @@ class ListingModel:
- def window(self, start: int, count: int) -> list[Head]:
- """``count`` logical rows from ``start`` (synthesising undefined ones)."""
- self.ensure(start + count)
-+ with self._lock:
-+ # _renamed stays set once a rename has happened; _ensure_text then
-+ # does the precise, range-limited staleness check. Before the first
-+ # rename this is one boolean and the read is exactly as it was.
-+ dirty = self._renamed
-+ if dirty:
-+ j0 = max(self._phys(max(start, 0))[0], 0)
-+ j1 = self._phys(max(min(self._rows, start + count) - 1, 0))[0] + 1
-+ if dirty:
-+ self._ensure_text(j0, j1)
- with self._lock:
- rows = min(self._rows, start + count)
- spans = [self._phys(i) for i in range(max(start, 0), max(rows, 0))]
-@@ -1223,6 +1340,8 @@ class Program:
- start, end, name = seg
- with self._lock:
- m = self._listings.get(start)
-+ if m is not None and m.stale_structure:
-+ m = None # a refresh found the walk had moved; start over
- if m is None:
- m = ListingModel(self, start, end, name)
- self._listings[start] = m
-@@ -1406,15 +1525,24 @@ class Program:
-
- def bump_names(self) -> None:
- """Signal that symbol names changed (a rename). Disasm/listing names are
-- live in the IDB, so clearing the cached rows is enough for those;
-- decompilation is generation-checked and force-recompiled lazily."""
-+ live in the IDB, so the cached rows have to be re-rendered; decompilation
-+ is generation-checked and force-recompiled lazily.
-+
-+ The listing keeps its WALK. A rename cannot move an item boundary, so
-+ every row keeps its address and its row number -- the edit path already
-+ assumes exactly that, since it restores the cursor by index afterwards.
-+ Dropping the segment model instead made the reload re-walk it from the
-+ start, which is 6.4 seconds on bash after every rename.
-+ """
- with self._lock:
- self._name_gen += 1
- models = list(self._disasm.values())
-- self._listings.clear() # listing head rows cache names -> refetch
-+ listings = list(self._listings.values())
- self._pc_nums.clear() # a reformat moves every literal on its line
- for m in models:
- m.invalidate()
-+ for lm in listings:
-+ lm.invalidate_text()
-
- def bump_items(self) -> None:
- """Signal that item/function STRUCTURE changed (define code/data/func,
diff --git a/.fastfeedback/SPEED.md b/.fastfeedback/SPEED.md
deleted file mode 100644
index 996aefe..0000000
--- a/.fastfeedback/SPEED.md
+++ /dev/null
@@ -1,297 +0,0 @@
-# SPEED — ida-tui-maybe
-
-_Read this BEFORE running anything slow._ This repo does **not** use pytest for its
-suites; every suite is a standalone script with its own runner and its own tally line
-(`N passed, M failed` / `N checks, M failed`). `speedscan`'s generic pytest template is
-wrong here — these are the real commands.
-
----
-
-## The two interpreters
-
-| python | has | use for |
-|---|---|---|
-| `python3` (system) | stdlib only | every **pure** suite. No IDA, no Code Mode. |
-| `~/ida-venv/bin/python` | textual + idapro + ida_codemode | anything marked `ida`, and the TUI itself |
-
-`tests/run.py --list` prints `pure` / `ida` per file. A `pure` file must keep running
-under system `python3` — that is a house rule, and it is why `idatui/codemode_client.py`
-defers its `ida_codemode` import instead of doing it at module top.
-
----
-
-## Escalation ladder for THIS repo
-
-**Cheap gates (seconds, run these first)**
-```bash
-python3 -c "import ast;ast.parse(open('idatui/codemode_client.py').read())" # ~0.05s syntax
-python3 tests/run.py --fast # every pure suite, 302 checks, 0.6s
-python3 tests/run.py graph -x # one suite by substring, fail-fast
-```
-
-**Narrow test — DEFAULT RUNG.** The pilot suite takes `--only <substr,...>`:
-```bash
-~/ida-venv/bin/python tests/test_scenarios.py /tmp/scratch_bin --only rename # ~2s
-~/ida-venv/bin/python tests/test_scenarios.py --list # scenario names
-~/ida-venv/bin/python tests/test_scenarios.py targets/echo --profile # where the time went
-```
-
-**`--profile` is how you find the next 20 seconds.** It reports, per scenario,
-seconds spent settling / in `wait()` / in keystrokes, and — the important one —
-any wait that **EXPIRED**, with its line number. An expired wait costs its whole
-timeout *and* means the check after it passed vacuously. Do not optimise this
-suite by guessing; run `--profile` and fix the top line.
-`--only` matches substrings, so `--only listing` runs listing_view + listing_name_addr +
-listing_make_string + listing_struct_expand. Name the scenario exactly to stay narrow.
-
-**Full gate — BACKGROUND ONLY, once, at the end** (see next section for the launch form):
-```bash
-~/ida-venv/bin/python tests/run.py # every suite, serial on purpose
-```
-
----
-
-## Coverage map — which suite catches what
-
-The pilot suite is big and slow and it is **not** the whole story. Two backend bugs
-this port shipped were invisible to `test_scenarios.py` and only fell out of the
-smaller RPC/UI suites:
-
-| suite | ~time | catches |
-|---|---|---|
-| `test_scenarios.py` | 166s | the TUI end to end: nav, views, listing, graph, opfmt, rename-one |
-| `test_rawimage_rpc.py` | ~60s | **`rename_many` (batch rename), define/blob workflow, opfmt over RPC** |
-| `test_blob_ui.py` | ~40s | raw-image/blob UI |
-| `test_project_ui.py` | ~8s | multi-binary project UI |
-| `test_trace_rpc.py` / `test_trace_ui.py` | ~30s | Tenet trace integration |
-
-**If you touch a backend operation, grep for its callers and run the suite that owns
-them — not just the pilot.** `rename` is exercised one-edit-at-a-time by the pilot and
-as a *batch* only by `test_rawimage_rpc.py`, which is exactly where the list-vs-dict
-bug hid.
-
----
-
-## Backgrounding in THIS harness — the gotcha that cost a turn
-
-`bgrun run ... ` **blocks the agent tool call** even though bgrun itself returns in ms.
-`bgrun` line 64 is `( "$@" >"$LOG" 2>&1; echo $? >"$EXIT" ) &` — the command's own stdio
-goes to the log, but the *backgrounded subshell* still holds the inherited stdout fd, and
-the harness's bash waits for EOF on that pipe, not for the parent to exit.
-
-**Always launch detached with all three fds redirected:**
-```bash
-SK="$HOME/.the assistant/agent/skills/fast-feedback/scripts"
-timeout 20 setsid "$SK/bgrun" run fullgate -- ~/ida-venv/bin/python tests/run.py \
- </dev/null >/tmp/bgrun.out 2>&1 ; cat /tmp/bgrun.out # returns in ~7ms
-timeout 20 "$SK/bgrun" check fullgate # non-blocking, safe
-```
-`bgrun check` / `bgrun list` do **not** need this treatment; only `run` does.
-
-**Never `pkill -f <pattern>` here.** The agent's own shell command line contains the
-pattern you are matching, so `pkill -f "tests/run.py"` kills the very shell issuing it
-and the rest of the command silently never runs. Kill by **pid** (`bgrun list`,
-`pgrep -af` first), then gate on it with `waitfor pid-gone <pid>`.
-
-**Killing a pilot leaves a Code Mode worker behind** for its `--lease-grace` (20s); it
-self-exits, and it is holding only that run's temp `.i64`, so it does not block a new
-run. Do not `kill -9` it — a hard-killed IDA wedges its database.
-
----
-
-## Simulated keypresses cost 85ms each unless you patch Textual
-
-The single biggest cost in this repo's UI suites was **Textual's own key path**,
-not our code. `Pilot.press` → `App._press_keys` calls `wait_for_idle` *twice per
-key*, and that helper sleeps in 20ms granules until *process* time stops
-advancing — a CPU-load heuristic for "the state is predictable now", which takes
-more granules the busier the box is. Measured on this box: **84ms per keypress**,
-and the pilot presses enough keys for that to be **23s of its 43s**.
-
-`tests/_fixtures.py: fast_keys()` replaces it (call it at import; every UI suite
-does). Two halves, and the second is the point:
-
-- `textual.app.wait_for_idle` → a bare `await asyncio.sleep(0)`.
-- `Pilot.press` → send the keys, then **`settle(app)`** — pump drained, workers
- finished. Deleting the heuristic *without* this broke nine checks, so it was
- doing a job, badly; `settle` is strictly stronger and ~2ms.
-
-**What `settle` still cannot see — and what to do instead:**
-
-| driven by | example | gate |
-|---|---|---|
-| a worker | decompile, navigation, index load | `settle(app, pred)` |
-| a **timer** | the function filter's `set_timer(0.08)` debounce | `wait(lambda: rows < full)` |
-| a **frame** | `widget.region` / `size` / a repaint trace | `wait(lambda: inp.region.height >= 1)` |
-
-A settled app has not necessarily been laid out or painted. Every site in this
-repo that needs a frame or a debounce is commented as such — if a check that
-reads geometry or a filtered row count starts flaking, that is the reason, and
-the fix is a wait on the effect, never a longer sleep.
-
-The frame-dependent ones found this way, as a shopping list of what to suspect:
-`si.region` after the search prompt opens, `gv._minimap_rect()` / `gv.size` after
-the graph opens, `gv.render_line()` scraping box glyphs, and a `render_line`
-trace asserting a repaint happened at a restored scroll. All four passed for
-years on the 85ms-per-key sleep and failed within three runs without it — they
-were always races, just paid-for ones.
-
-## The four ways a test here wastes minutes
-
-Every slow suite in this repo was slow for one of these, not for doing real work.
-Check them before optimising anything else.
-
-1. **A wait on a signal that can no longer happen.** `wait(lambda: lst.model is not
- old, ..., 60)` was the idiom for "the edit landed". The perf work made edits KEEP
- the listing's walk and re-render in place, so the model object is never replaced:
- every one of those waits sat out its full timeout and the check afterwards passed
- **vacuously**. This alone was 30s in blob_ui and 4x60s in thumb_ui.
- → Gate on what the check is about (the row is code, the status says Thumb, the
- function is in the index), via `settle(app, pred)` from `idatui._sync`.
-2. **A wait on a signal that is set too early.** `app._t` is assigned when the key is
- handled; the navigation it starts runs in a worker. Waiting on it and then reading
- the cursor is a race master won (single-digit ms backend) and Code Mode loses.
- → `settle()` gates on quiescence *and* the predicate, which is what you want.
-3. **Regenerated fixtures.** `os.urandom` into a fresh `TemporaryDirectory` means new
- bytes at a new path every run, so the pristine-database cache can never apply and
- full auto-analysis is paid forever. → `_fixtures.synthetic(name, build)` writes
- deterministic bytes to a stable path; `staged()` then caches the analysis.
- Determinism is also correctness: "this blob has no functions" must not depend on luck.
-4. **Deleting a database and reopening the same path.** Safe when the TUI owned a
- private worker; under Code Mode the previous phase's worker still holds the lease
- for its grace period, so the delete races a live owner and the reopen yields no
- listing. → Give each phase its own temp copy (`fresh_copy`). Never `os.remove` an
- `.i64` a suite is about to reopen.
-
-5. **A flat `pause(d)` where a gate belongs.** `Ctx.pause` in the pilot *is*
- `settle` now (`d` is the upper bound, not the cost), which took 20.4s of
- fixed sleeping down to 2.8s across ~140 call sites. `Ctx.sleep(d)` is the
- escape hatch for the genuinely timer-driven; reach for it only after
- checking the table above. In the other suites the same conversion took
- `test_trace_ui` from 19.6s to 5.5s — it was 13.5s of `pilot.pause(1.0)`.
-
-**`settle(app, pred, timeout=...)`** (`idatui/_sync.py`) is the one true gate: it drains
-the message pump, waits for workers, and returns the moment `pred` holds. It is what the
-live RPC layer uses, so tests and driver agree on what "done" means. A bare
-`settle(app)` (no pred) means "the app finished reacting" — the right gate when the edit
-may legitimately do nothing (e.g. carving random bytes).
-
-## Measured timings (2026-08-07, this box, warm; keypress work 2026-08-08)
-
-| command | time | notes |
-|---|---|---|
-| `python3 tests/run.py --fast` | **0.6s** | 302 checks, all pure suites |
-| `python3 tests/test_graph.py` | ~1s | 86 checks, pure layout engine |
-| `python3 tests/test_codemode_client.py` | ~0.1s | 14 checks, fakes the DatabaseHandle |
-| pilot boot (first scenario) | ~1.7s | opens the DB; seeded from `.pristine.i64` |
-| pilot `--only rename` | ~2s | 11 checks |
-| **pilot full (`test_scenarios.py`)** | **21.2s** | 57 scenarios, 313 checks (was 166s, then 62s) |
-| `test_rawimage_rpc.py` | 7.5s | 21 checks, owns batch rename |
-| `test_trace_ui.py` | **5.5s** | 39 checks — was 19.6s |
-| `test_trace_rpc.py` | 5.1s | 45 checks |
-| `test_project_ui.py` | **3.8s** | 30 checks — was 8.1s |
-| `test_thumb_ui.py` | **3.6s** | 20 checks — was 313s AND crashing |
-| `test_blob_ui.py` | **1.3s** | 30 checks — was 39.8s |
-| `tests/run.py` (everything) | **49s** | 800 checks. was ~9m20s, then 117s |
-
-IDA-suite total: ~9m21s → 117s (the four wastes below) → **49s** (keypresses and
-the pause→settle conversion). No check was removed to get there; the suite gained
-12 and the two runs behind these numbers were 49.2s and 48.9s, 800/800 both.
-
-Where the pilot's remaining ~20s goes (`--profile`): 10.8s keystrokes (which now
-includes the real work each key triggers, since `press` settles), 2.9s settling,
-2.1s waits, ~5s scenario bodies. The rest of the gate is dominated by per-suite
-IDA boot — 7 processes, each opening its own database.
-
-### Backend performance (worker vs Code Mode), after the two transport fixes
-
-| op | worker | codemode | |
-|---|---|---|---|
-| `heads` 200 rows | 2.65ms | 5.85ms | 2.2x |
-| `heads` expect-hit | 2.16ms | 4.61ms | 2.1x |
-| `disasm` 200 | 9.03ms | 5.25ms | **0.6x** |
-| `decompile` cold | 162.6ms | 30.7ms | **0.2x** |
-| `decomp_map` | 45.8ms | 47.9ms | 1.0x |
-| empty round trip | ~0.07ms | **2.0ms** | the floor |
-
-Two things dominated and are fixed (see `idatui/codemode_client.py:_script`):
-`to_jsonable` walking every returned object (snippets now return one
-pre-serialised JSON string), and `sys.settrace` — the runtime installs a trace
-that returns itself, i.e. LINE tracing in every frame, which made
-`ida_bytes.get_flags` 52x slower than native. The snippet detaches it and
-restores it in a finally; `IDATUI_CODEMODE_TRACE=1` keeps the stock behaviour.
-
-**What is left is the 2ms round-trip floor, and it is NOT ours.** Measured against
-the same worker, same connection:
-
-| | cost | whose |
-|---|---|---|
-| `GET /health` (no execute_sync) | 0.165ms | HTTP transport |
-| `execute_python("result = 1")` | 2.025ms | + `ida_kernwin.execute_sync` |
-
-So HTTP is 7% of the floor and marshalling an operation onto IDA's main thread is
-92%. The worker runs IDA's own `kernwin.serve()` dispatch loop, so that latency is
-inside IDA, not something Code Mode exposes a knob for.
-
-**Do not re-chase this by batching operations.** The call volume is already
-minimal, measured on `targets/bash`:
-
-| flow | wall | calls |
-|---|---|---|
-| open a listing | 3.2ms | 1 |
-| scroll 2000 rows | 87.9ms | 8 |
-| rename + re-render those rows | 36.2ms | 4 |
-| graph of a **1060-block** function | 171.0ms | 4 |
-| decompile a 17785-byte function | 10806ms | 1 |
-
-Block-coalescing, the page digest and the prefetch caches already collapse the
-bursts, so a batch endpoint would save single-digit milliseconds on flows that
-cost hundreds. And the big number is pure Hex-Rays: that same decompile is
-10723ms on the worker backend (0.8% apart) — there is no transport in it at all.
-
-
-_Superseded note:_ **What is left is the 2ms round-trip floor.** A trivial op (`data_type`,
-`force_recompile`, one xref query) is ~2.5ms wall clock and looks like 40x
-against an in-process worker. That is fixed by making FEWER calls, not faster
-ones — which is what the digest/`expect` path does for the listing.
-
-Benchmark harness: `/tmp/cmport/bench.py` + `compare.py` (backend-agnostic; it
-picks whichever client the checked-out tree has, so it runs on master too).
-
----
-
-## Known-flaky
-
-- _(fixed 2026-08-08)_ `test_scenarios.py --only listing_view` used to fail about one
- run in three: `listing shows data heads (not just code)` reported
- `{'label','sep','funchdr','code'}`, i.e. the model was read before its pages had
- materialised. The scenario waited on `c.lst.total > 0`, which is computed from the
- segment size and is true **before any row exists** — the textbook "wait on a signal
- that is set too early". It now waits for a materialised data row. Nothing else is
- known-flaky; if something starts flaking, check the timer/frame table above first.
-
----
-
-## Slow traps
-
-- **Never run the pilot suite against `targets/echo` directly.** It edits the database.
- The suite already stages a scratch copy (`tests/_fixtures.py: staged()`), seeded from
- `<binary>.pristine.i64` which nothing writes back to. Pass a **copy** in `/tmp` when
- invoking by hand, or you are testing your own history.
-- **`tests/run.py` runs suites serially on purpose.** Running them 4-up took the suite
- from 153s to 296s and got three killed mid-analysis (idalib contends hard). Do not
- "optimise" it with parallelism.
-- **Cold analysis dominates a first run.** `.pristine.i64` turns ~30s of auto-analysis
- into a file copy; if it is missing or older than the binary it is rebuilt. A suite that
- suddenly takes minutes longer is usually rebuilding that cache.
-- **Under load, idalib gets SIGKILLed mid-analysis** and it looks like a hang or empty
- output. Check `uptime` before believing a failure.
-- **`experiments/*.py` need `PYTHONPATH=$PWD`** under `~/ida-venv/bin/python` (they are
- scripts, not a package entry point).
-
-## Comparing against master (backend A/B)
-
-The port replaces the whole backend, so "is this a regression?" means running the same
-scenario on both. `git stash -u; git checkout master; <run>; git checkout -; git stash pop`
-works, but **use a different scratch binary per branch** (`/tmp/x_master`, `/tmp/x_port`)
-so the two runs never share a `.pristine.i64` or a live Code Mode instance.
diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs
new file mode 100644
index 0000000..8ceb914
--- /dev/null
+++ b/.git-blame-ignore-revs
@@ -0,0 +1,5 @@
+# Mechanical reformat commits, hidden from blame:
+# git config blame.ignoreRevsFile .git-blame-ignore-revs
+
+# reformat: ruff format + import sort (adoption of ruff.toml)
+02d02417800184fb76cd0245cdaa94c437aa4081
diff --git a/.githooks/pre-commit b/.githooks/pre-commit
new file mode 100755
index 0000000..39cd4a5
--- /dev/null
+++ b/.githooks/pre-commit
@@ -0,0 +1,53 @@
+#!/bin/sh
+# Format the Python being committed, so what lands in git is already formatted.
+# Imports sorted, then laid out -- both by ruff (ruff.toml pins the version).
+#
+# Install with `git config core.hooksPath .githooks`. Hooks are not versioned
+# by git, so a hook in a repo has to be a file somebody opts into -- there is
+# no way to ship one that runs on clone, and a repo that could would be a repo
+# that runs code on clone.
+#
+# Skip a commit with `git commit --no-verify` when you mean to.
+set -e
+
+py_files=$(git diff --cached --name-only --diff-filter=ACMR | grep -E '\.py$' || true)
+[ -n "$py_files" ] || exit 0
+
+# Where ruff is. The repo-local venv first, before $PATH: ruff.toml pins a
+# version, and a system ruff of the wrong one would refuse the commit while
+# the venv sitting right there has the version it asked for. Off the toplevel,
+# so this works from a subdirectory.
+root=$(git rev-parse --show-toplevel)
+if [ -x "$root/.venv/bin/ruff" ]; then
+ ruff="$root/.venv/bin/ruff"
+else
+ ruff=$(command -v ruff 2>/dev/null || true)
+fi
+if [ -z "$ruff" ]; then
+ echo "pre-commit: ruff is not installed, and this commit touches Python." >&2
+ echo " uv sync --extra dev (a venv in .venv, version pinned)" >&2
+ echo " ...or commit with --no-verify if you know what you are doing." >&2
+ exit 1
+fi
+
+# A file with unstaged changes is the one case where formatting in place is
+# dangerous: the formatter rewrites the *working tree*, and re-staging
+# afterwards would commit work that was deliberately left out of the index.
+# So say so and stop, rather than quietly widening a commit somebody built
+# with `git add -p`.
+partial=""
+for f in $py_files; do
+ if ! git diff --quiet -- "$f"; then partial="$partial $f"; fi
+done
+if [ -n "$partial" ]; then
+ echo "pre-commit: these files are only partly staged, so formatting them" >&2
+ echo " in place would add work you left out of the commit:" >&2
+ for f in $partial; do echo " $f" >&2; done
+ echo " stage them fully, stash the rest, or use --no-verify." >&2
+ exit 1
+fi
+
+cd "$root"
+"$ruff" check --select I --fix --quiet $py_files
+"$ruff" format --quiet $py_files
+git add $py_files
diff --git a/.gitignore b/.gitignore
index bdd79d1..3987fd2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,3 +24,9 @@ tests/.synthetic/
# the upstream port patch, kept locally for reference only
ida-codemode-mcp.patch
+
+# local-only working artifacts (never published)
+.auto/
+.agents/
+plan/
+rehearsed-engineer.md
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..5cf7765
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,89 @@
+# Contributing to ida-tui
+
+Thanks for taking a look. Bug reports, patches and ideas are all welcome.
+
+## What you need
+
+- **Python ≥ 3.11**
+- **IDA Pro 9.4+ with idalib** — ida-tui is a frontend; IDA does the analysis.
+
+There is no way around the IDA requirement for most of the codebase. However,
+a good chunk of the test suite is *pure* (stdlib only, no IDA) and runs
+anywhere — see below. Small fixes to the pure layers are perfectly reviewable
+without a licence.
+
+## Setup
+
+```sh
+uv sync
+```
+
+That pulls [ida-nexus](https://github.com/HexRaysSA/ida-nexus) from PyPI,
+which is how ida-tui talks to IDA. To also attach to databases open in the IDA
+GUI:
+
+```sh
+uvx ida-hcli plugin install ida-nexus
+```
+
+## Running the tests
+
+This repo does **not** use pytest. Every suite is a standalone script with its
+own runner and its own tally line. `tests/run.py --list` prints `pure` or `ida`
+for each file.
+
+```sh
+python3 tests/run.py --fast # every pure suite — stdlib only, ~1s
+python3 tests/run.py graph -x # one suite by substring, fail-fast
+python3 tests/run.py # everything (needs IDA)
+```
+
+The IDA-backed suites need an interpreter that has `textual`, `idapro` and
+`ida_nexus` on it:
+
+```sh
+<ida-python> tests/test_scenarios.py /path/to/binary --only rename
+<ida-python> tests/test_scenarios.py --list # scenario names
+```
+
+**House rule:** a suite marked `pure` must keep running under a plain system
+`python3`. This is why `idatui/nexus_client.py` defers its `ida_nexus`
+import instead of doing it at module top. Please don't break that — it's what
+keeps the fast gate fast and lets people without IDA contribute at all.
+
+## Formatting
+
+Python is formatted by ruff (`ruff.toml` pins the exact version; the config is
+deliberately default: black-style layout plus import sorting, nothing else).
+Install the pre-commit hook once and forget about it:
+
+```bash
+uv sync --extra dev # puts the pinned ruff in .venv
+git config core.hooksPath .githooks # formats what you stage
+```
+
+Mechanical reformat commits are listed in `.git-blame-ignore-revs`;
+`git config blame.ignoreRevsFile .git-blame-ignore-revs` keeps blame useful.
+
+## Sending a change
+
+- Run at least `python3 tests/run.py --fast` before you push. If your change
+ touches an IDA-backed path, run the relevant scenario suite too and say so
+ in the PR.
+- Keep commits focused, and write a subject line that says what changed and
+ why. Look at `git log` for the house style — it favours a concrete claim
+ ("listing: `c` disassembles until something stops it") over a vague one.
+- If you found a behaviour by measuring it, put the numbers in the commit
+ message. Several of the perf commits here are only reviewable because they
+ did.
+
+## Reporting bugs
+
+Include the binary/architecture if you can share it, the IDA version, and what
+you expected the pane to show versus what it showed. A screenshot of the TUI
+is worth a lot — it's a terminal app, so a copy-pasted pane usually works fine.
+
+## Licence
+
+By contributing you agree that your contributions are licensed under the MIT
+Licence, the same as the rest of the project.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..c6dcdcd
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 blasty <peter@haxx.in>
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index bee852d..9a751a1 100644
--- a/README.md
+++ b/README.md
@@ -6,17 +6,6 @@
---
-```
-┌ ida-tui ─────────────────────────────────────────────────────────────────────┐
-│ .text:00002490 ; ---------- S U B R O U T I N E ---------- │
-│ .text:00002490 main proc near │
-│ .text:00002490 endbr64 │
-│ .text:00002494 push rbp ; ← cursor │
-│ .text:00002495 mov rbp, rsp │
-│ .text:00002498 sub rsp, 0B0h ; `o` → 176 │
-└──────────────────────────────────────────────────────────────────────────────┘
-```
-
> **Status: personal project, actively hacked on.** No packaging, no versioning,
> no support. It assumes a licensed IDA Pro and a venv at `~/ida-venv`. Things
> move and break. Poke around; don't file expectations.
@@ -38,18 +27,18 @@ Needs **Python ≥ 3.11** and **IDA Pro 9.4+ with idalib**.
uv sync
```
-That pulls [ida-codemode](https://github.com/HexRaysSA/ida-codemode) from PyPI,
+That pulls [ida-nexus](https://github.com/HexRaysSA/ida-nexus) from PyPI,
which is how ida-tui talks to IDA. To also attach to databases you have open in
the IDA GUI, install its plugin:
```sh
-uvx --prerelease=allow --from ida-codemode ida-codemode-mcp --install-plugin
+uvx ida-hcli plugin install ida-nexus
```
-Hacking on ida-codemode itself? Point at a checkout instead:
+Hacking on ida-nexus itself? Point at a checkout instead:
```sh
-uv add --editable ../ida-codemode
+uv add --editable ../ida-nexus
```
## Run
@@ -59,20 +48,32 @@ uv add --editable ../ida-codemode
./ida-tui # attach, when exactly one database is registered
```
-ida-tui never owns an IDA process. It takes a **lease**: a matching database open
-in the IDA GUI is reused, otherwise Code Mode starts or shares a managed idalib
+ida-tui never owns an IDA process — it takes a **lease**. A matching database open
+in the IDA GUI is reused, otherwise IDA Nexus starts or shares a managed idalib
worker. Quitting drops the lease and leaves everyone else alone.
+Changes made in the GUI or another client arrive over IDA Nexus's IDB event
+stream; ida-tui debounces bursts and refreshes its cached views automatically.
+On quit with unsaved changes, a final managed-worker lease can discard the
+session without saving; GUI-backed or still-shared sessions leave that final
+decision with their owner or remaining clients.
+If the owning GUI or worker closes, ida-tui never replaces it by spawning a
+headless worker implicitly. It keeps the cached view disconnected until a
+matching owner is reopened and an attach-only rediscovery succeeds.
+Remote operations are typed, source-backed Python functions. ida-nexus installs
+their content-addressed modules once per IDA Python interpreter, so ida-tui keeps
+normal refactorable source without paying to resend hot listing/decompiler code.
+Operation attribution is also a per-call provider rather than a fixed string, so
+it can evolve from `IDA TUI` to labels such as `IDA TUI: alice`.
-Headerless blobs have no format to detect — IDA falls back to x86 at address 0
-and analyses nothing, so say what it is:
+Headerless blobs need a hint, or IDA assumes x86 at address 0 and analyses nothing:
```sh
./ida-tui fw.bin --processor arm --base 0x8000000
```
-`--base` is a real address. These apply only when a database is being **created**;
-an existing IDB already records them. On ARM, `t` toggles ARM/Thumb decoding at
-the cursor and `T` scans a vector table for Thumb entry points.
+These apply only when the database is **created** — an existing IDB already records
+them. On ARM, `t` toggles ARM/Thumb at the cursor, `T` scans a vector table for
+Thumb entry points.
## Keys
@@ -88,94 +89,66 @@ the cursor and `T` scans a vector table for Thumb entry points.
| `o` `O` `B` | cycle this literal's format · reverse · opcode bytes |
| `\` `"` `ctrl+t` | hex · strings · structs |
| `ctrl+n` `ctrl+p` | symbol palette · command palette |
-| `ctrl+s` `ctrl+l` `q` | save · reload as… · quit |
+| `ctrl+r` `ctrl+s` `ctrl+l` `q` | refresh view · save · reload as… · quit |
| `F1` | all of them |
## What's in it
-**Listing** — one continuous IDA-style view: code, data and undefined runs
-together, with IDA's own colour tags and per-operand marks. Line-virtualized, so
-a 400 MB binary scrolls like a text file.
+**Listing** — code, data and undefined runs in one continuous view, with IDA's own
+colours. Line-virtualized: a 400 MB binary scrolls like a text file.
-**Decompiler** — Hex-Rays pseudocode with syntax highlighting, per-line address
-anchors, and rename/retype/comment that write back.
+**Decompiler** (`tab`) — Hex-Rays pseudocode, highlighted, with per-line address
+anchors. Renames, retypes and comments write back.
-**Search** (`ctrl+f`) — the whole database, two ways: **text** through the
-rendered disassembly (`call cs:`, `xor eax, eax`) and **bytes** with IDA's
-pattern language, wildcards included (`48 8b ?? c3`, nibbles like `8?`, quoted
-literals). Which one you meant is guessed from the query — a hex-looking *word*
-like `dead` stays a text search — and `hex:`/`text:` or F2 override the guess.
-Enter searches, then Enter opens the hit.
+**Graph** (`space`) — basic blocks laid out with a real layered (Sugiyama) algorithm
+and routed, colour-coded edges. The boxes hold the *same rows* as the listing, so
+renames and xrefs work inside them.
-**Findings export** (`ctrl+e`) — the session as a markdown writeup: your
-comments grouped by function, the names and prototypes you set, the types you
-declared. A `.i64` does not record *who* wrote a comment — IDA's own analyzer
-uses the same call — so idatui journals its edits into the database as it makes
-them, and the report is built from that. Also `python -m idatui.drive export`.
+**Split view** (`s`) — listing and pseudocode side by side, cursor-synced. The
+focused pane drives; the other highlights the instructions the current C line owns.
-**Structs / types** (`ctrl+t`) — local types as plain C: the list on the left,
-an editable, syntax-highlighted definition on the right. `Ctrl+S` declares it
-back into the database and reformats to IDA's own layout, `Ctrl+N` starts a new
-one, `d` deletes, and `/` fuzzy-filters the list.
+**Search** (`ctrl+f`) — the whole database, as **text** through the disassembly or as
+**bytes** with IDA's wildcard patterns (`48 8b ?? c3`). It guesses which you meant;
+`hex:`/`text:` overrides.
-**Graph** (`space`) — the current function's basic blocks, laid out with a real
-layered (Sugiyama) algorithm and routed edges: green taken, red fall-through,
-blue unconditional, purple loop. The boxes hold the *same rows* as the listing,
-so highlighting, renames and xrefs work inside them. Above 400 blocks it declines
-and says so, because nothing readable comes out at that size.
-→ [`docs/GRAPH_VIEW.md`](docs/GRAPH_VIEW.md)
+**Structs / types** (`ctrl+t`) — local types as plain C, editable and highlighted.
+`ctrl+s` declares it straight back into the database.
-**Split view** (`s`) — listing and pseudocode side by side. The focused pane
-drives; the other highlights every instruction the current C line owns.
+**Literal formats** (`o`) — hex → decimal → binary → char → offset, IDA's own key.
+Skips the stops that wouldn't change anything, so no press is a silent no-op.
-**Literal formats** (`o`) — hex → decimal → binary → char → offset, IDA's own
-key. Only stops that change what you see are visited, so no press is a silent
-no-op. The literal under the cursor is *marked*, and the mark is what changes —
-it keeps up as the text reflows. Works on Hex-Rays' separate number formats too.
+**Findings export** (`ctrl+e`) — the session as a markdown writeup: your comments,
+names and prototypes, grouped by function. idatui journals its own edits, so the
+report is *yours*, not IDA's analyzer's.
-**Execution traces** — load a [Tenet](https://github.com/gaasedelen/tenet) trace
-and move through time:
+**Execution traces** — load a [Tenet](https://github.com/gaasedelen/tenet) trace and
+move through time. Both code views paint the execution trail; the dock shows
+registers and stack as of that instant.
```sh
-./ida-tui /path/to/binary --trace trace.0.log
+./ida-tui /path/to/binary --trace trace.0.log # ] [ step · } { step over
```
-`]`/`[` step, `}`/`{` step over. Both code views are painted with the execution
-trail — including the pseudocode, since `decomp_map` knows which instructions
-each C line covers. The dock shows registers and the stack *as of that instant*;
-bytes the trace never saw print as `??`, not zeros. Trace addresses are rebased
-onto the database automatically.
-
**RPC** — drive the live TUI from another process (agent-driven RE, livestreams):
```sh
./ida-tui /abs/path/bin --rpc /tmp/ida.sock
-python -m idatui.drive where # terse-text helper
python -m idatui.drive pc main # pseudocode of main
python -m idatui.drive rename sub_5BE0 foo # goto + rename
```
-→ [`docs/RPC.md`](docs/RPC.md)
+Scripted feature tour, for screen recordings: `python tools/demo.py --spawn`
-There's a scripted feature tour on top of it, for screen recordings — it spawns
-its own pane on a scratch copy, drives ten scenes through the real prompts, and
-reverts its own edits:
-
-```sh
-python tools/demo.py --spawn # --speed 0.5 to rehearse, --list for the scenes
-```
-
-**Splash** — the logo renders as a real image on terminals that speak the kitty
-graphics protocol, `logo.ans` everywhere else. Support is detected by *asking the
-terminal*, not by sniffing `$TERM` (under a multiplexer, every variable you'd
-test is empty while the protocol works fine).
+**Also** — hex view (`\`), strings (`"`), symbol and command palettes
+(`ctrl+n`/`ctrl+p`), multi-binary projects, and a splash that renders as a real
+image on terminals speaking the kitty graphics protocol.
## Tests
```sh
-python3 tests/run.py --fast # 302 checks, <1s, any python3 — between edits
+python3 tests/run.py --fast # 380 checks, <1s, any python3 — between edits
python3 tests/run.py --list # what runs, and what needs IDA
-python3 tests/run.py # 788 checks, ~2m — before a commit
+python3 tests/run.py # 1031 checks, ~50s — before a commit
```
Every suite declares `NEEDS_IDA`; `--fast` runs only the pure ones (stdlib, no
@@ -186,15 +159,6 @@ database. Iterate on one with `--only`:
~/ida-venv/bin/python tests/test_scenarios.py targets/echo --only hex,rename
```
-Before optimising or debugging a slow run, read
-[`.fastfeedback/SPEED.md`](.fastfeedback/SPEED.md) — per-suite timings, the known
-flake, and the four ways a test here wastes minutes.
-
-## Docs
-
-- [`docs/RPC.md`](docs/RPC.md) — the RPC protocol, verb by verb
-- [`docs/GRAPH_VIEW.md`](docs/GRAPH_VIEW.md) — how the graph is laid out
-
[sl0p.foo](https://sl0p.foo)
diff --git a/TODO b/TODO
index 726e2b1..5ccd162 100644
--- a/TODO
+++ b/TODO
@@ -4,12 +4,15 @@ TODO:
[x] PORT TO ida-codemode-mcp as a library dependency
[x] DatabaseHandle discovery prefers registered GUI sessions
[x] shared managed idalib workers + SSE lease lifecycle
- [x] domain operations execute against ida-domain through Code Mode
+ [x] domain operations execute against ida-domain through IDA Nexus
[x] delete the private pickle worker and ida-pro-mcp patch injection
[x] stop sweeping/reaping resources that may belong to another client
[ ] run the full live Pilot suite against both GUI and managed backends
- [ ] add database revision/change notifications for cross-client cache invalidation
- [ ] decide how "discard changes" should work (Code Mode final workers save)
+ [x] add database revision/change notifications for cross-client cache invalidation
+ -> PR #1: subscribe_idb_events -> IDBEventListener -> _refresh_idb_events
+ [x] decide how "discard changes" should work (IDA Nexus final workers save)
+ -> PR #1: shutdown_database(save=False) when ours is the final lease;
+ otherwise the lease transfers finalization (pool.discard_changes)
- [x] add support for toggling literal types, ala `o` in IDA. (decimal to hex to
reference etc.)
diff --git a/docs/CODEMODE_UPSTREAM.md b/docs/CODEMODE_UPSTREAM.md
deleted file mode 100644
index f903599..0000000
--- a/docs/CODEMODE_UPSTREAM.md
+++ /dev/null
@@ -1,269 +0,0 @@
-# Findings from porting a real client to IDA Code Mode
-
-Notes for the `ida-codemode` maintainers, gathered while porting **ida-tui** (a
-Textual TUI frontend for IDA) from a private idalib worker to
-`ida_codemode.client.DatabaseHandle`.
-
-Everything below is measured, not inferred. Where we worked around something, the
-workaround is named so you can judge whether the library should make it
-unnecessary.
-
-**Environment:** ida-codemode 0.3.1, IDA 9.4 (idalib), Linux, single managed
-worker backend, quiet box. Target for timings: `targets/echo` unless stated.
-
-**What the client does**, for scale: it renders a continuous disassembly listing,
-pseudocode, a CFG graph view and a hex view, paging over the database as the user
-scrolls. It is latency-sensitive in a way an agent-driven MCP client is not — a
-keypress must repaint. It issues ~1–8 operations per user action.
-
----
-
-## 1. `timeout_trace` enables line tracing in every frame — 52x on IDA calls
-
-**Highest-impact item by a wide margin.**
-
-`runtime.py` wraps every `execute_python` in `sys.settrace(timeout_trace)` to
-enforce the deadline. `timeout_trace` ends with `return timeout_trace`, and
-returning a trace function from a `'call'` event asks CPython to trace **every
-line of that frame**. So every line of every function the snippet touches pays a
-Python-level callback, and the specialising interpreter is disabled throughout.
-
-Measured inside the worker, same process, same database:
-
-| | traced (stock) | untraced | native idalib |
-|---|---|---|---|
-| `ida_bytes.get_flags(ea)` | 5.49 µs | 0.106 µs | 0.119 µs |
-| our 200-row listing page | 20.2 ms | 2.0 ms | — |
-
-Untraced matches a plain idalib process, so the trace hook accounts for
-essentially all of it. For us this was the single largest cost in the port —
-larger than HTTP, serialisation and IDA itself combined.
-
-Reproduce inside any `execute_python`:
-
-```python
-import sys, time, ida_bytes
-def bench():
- t = time.perf_counter()
- for _ in range(20000): ida_bytes.get_flags(0x1000)
- return (time.perf_counter() - t) / 20000 * 1e6
-traced = bench()
-old = sys.gettrace(); sys.settrace(None)
-try: untraced = bench()
-finally: sys.settrace(old)
-result = {"traced_us": traced, "untraced_us": untraced}
-```
-
-**Suggested fixes, cheapest first**
-
-1. `return None` from `timeout_trace` instead of itself. You keep `'call'`-event
- deadline checks — which is enough to interrupt anything that calls a function
- — and drop per-line tracing entirely.
-2. On 3.12+, use `sys.monitoring` with only the events you need; it is designed
- for exactly this and is far cheaper than `settrace`.
-3. Or drop the trace and rely on the `threading.Timer` →
- `ida_kernwin.set_cancelled()` path you already have, accepting that a
- pure-Python loop with no calls in it cannot be interrupted.
-
-**Our workaround** (we would rather not ship it): the snippet detaches the trace
-and restores it in a `finally`. That gives up deadline enforcement for
-pure-Python loops inside our own code; your native cancel timer is unaffected and
-still fires. Every client that does real work per call will eventually find this
-and do the same, which is an argument for fixing it in the runtime.
-
----
-
-## 2. `to_jsonable` dominates any large result
-
-`execute_python` runs `to_jsonable()` over whatever the snippet returns. Our
-answers are already JSON-safe and they are big — a 200-row listing page is
-roughly 10k small objects.
-
-| | cost |
-|---|---|
-| `to_jsonable(page)` | 66.2 ms |
-| `json.dumps(page, separators=(",",":"))` — same data | 0.58 ms |
-| serialised size | 34.9 KB |
-
-That is 114x, and it was 72% of the page's total cost before we changed it.
-
-**Suggested fixes**
-
-- Fast-path values that are already JSON-safe (a cheap recursive type check that
- bails to the original object beats rebuilding it), or
-- let a snippet opt out by returning an already-serialised payload — a documented
- envelope such as `{"__json__": "<...>"}`, or simply passing `str`/`bytes`
- through untouched.
-
-**Our workaround:** snippets `json.dumps` inside the database process and return
-one string, which the client parses. `to_jsonable` then walks a single scalar.
-Cost went 66.2 ms → ~0.6 ms. It works, but every client with a large result set
-has to discover and re-implement it.
-
----
-
-## 3. The per-operation floor is `execute_sync`, not HTTP
-
-Same worker, same connection, 200 iterations:
-
-| | cost |
-|---|---|
-| `GET /health` (no `execute_sync`) | **0.165 ms** |
-| `execute_python("result = 1")` | **2.025 ms** |
-
-HTTP framing is ~7% of the floor; marshalling the operation onto IDA's main
-thread is the other ~93%. The worker runs IDA's own `kernwin.serve()`, so this is
-plausibly IDA's dispatch latency rather than anything you control — but it is
-worth **documenting**, because it sets a hard 2 ms per-operation budget that
-shapes how a client must be designed.
-
-It did not hurt us (our call volume is 1–8 per user action; 4 calls to build a
-1060-block graph), but a client that makes one call per row or per symbol will be
-20–100x slower than an in-process one and the authors will not know why.
-
-**Suggested fixes:** document the floor; and consider a batch endpoint — accept
-`[{op, args}, ...]` and dispatch them within a single `execute_sync` — which
-would let chatty clients amortise it without redesigning around it.
-
----
-
-## 4. Loader switches on an existing database are a FATAL, not an error
-
-Opening a target that already has an `.i64`, while passing spawn-only options,
-kills the worker:
-
-```
-FATAL ERROR: @0:636[]
-Switch '-b400' can be used only when loading a new file
-```
-
-The client sees only:
-
-```
-IDAConnectionError: idalib worker launcher <pid> exited with status 1
-```
-
-This is easy to hit and hard to diagnose: it is the natural second run of
-anything that opens a raw blob (`processor=`/`image_base=`/`file_type=` are
-recorded in the database the first run produced). Our test suite hit it as a
-crash five minutes into a run.
-
-**Suggested fixes**
-
-- In `DatabaseHandle.open()`, when the resolved IDB already exists and
- `new_database` is not set, either ignore the spawn-only options or raise a
- typed error naming them — before handing them to IDA.
-- Propagate the worker's fatal text into the client exception. The message
- already exists on the worker's stderr; losing it turns a one-line fix into a
- bisect.
-
-**Our workaround:** the client checks whether the expected IDB exists and drops
-`processor`/`image_base`/`file_type` when it does.
-
----
-
-## 5. Deleting or replacing an IDB under a live lease fails silently
-
-A suite that did "delete the `.i64`, reopen the same path" (safe when it owned a
-private worker) now races the previous worker's lease grace. The reopen produced
-a handle that never became usable, with no error — just a database with no
-listing, and every wait timing out.
-
-**Suggested fixes**
-
-- Detect that the IDB backing a registered instance has been removed or replaced
- and fail loudly (the registry already holds `idb_key`).
-- Expose a **public** "wait until this database is released" primitive. We needed
- one and ended up reaching into `registry.REGISTRY_DIR` and `FileLock` to build
- it, which is not an API we should be depending on.
-- Document the lease-grace window as part of the lifecycle contract.
-
----
-
-## 6. No close-without-save, and no rollback
-
-A managed worker saves when its final lease closes. A GUI handle leaves GUI state
-as-is. Neither gives a client a way to say "discard what I did".
-
-ida-tui had a "discard & quit" that we could not port; it is now "leave as-is &
-quit", and we cannot honestly promise the user their edits are not persisted.
-
-**Suggested fixes:** a close policy on a lease the client created
-(`close(save=False)`), or a transaction/rollback API, or a documented
-disposable-copy pattern that clients can follow.
-
----
-
-## 7. No change notification for shared databases
-
-The lease reports liveness, not mutations. If a GUI user or another Code Mode
-client renames or retypes while we are attached, our materialised caches (name
-generation, decompilation, listing pages) are silently stale. Our own edits
-invalidate correctly; someone else's cannot.
-
-**Suggested fix — cheap and sufficient:** a monotonic database revision counter,
-bumped on any mutating operation and exposed on `/health` (and ideally on the
-lease event stream). Clients can then invalidate by comparing one integer. A full
-change feed would be better but is much more work; the counter alone would make
-shared editing safe for every caching client.
-
----
-
-## 8. Package exports and API surface stability
-
-`ida_codemode/__init__.py` exports nothing, so a library consumer must import
-from submodules:
-
-```python
-from ida_codemode.client import DatabaseHandle, ClientError, RemoteError, InstanceDisconnectedError
-from ida_codemode.registry import REGISTRY_DIR, FileLock, RegistryEntry, canonical_path, idb_key, scan_instances
-from ida_codemode.resolver import IdbBusy, expected_idb_path
-```
-
-Some of those are clearly internals (`FileLock`, `REGISTRY_DIR`) that we only
-touch because no public equivalent exists (see §5).
-
-**Suggested fix:** export `DatabaseHandle` and the public exception types from the
-package root, and mark the intended-public registry helpers explicitly. It also
-makes "what is API and what is internal" answerable, which right now it is not.
-
----
-
-## 9. A testing note: `DatabaseHandle.open()`'s 30 keyword-only options
-
-The port we started from called `open(..., loading_address=...)`. The real
-parameter is `image_base`. Every `connect()` would have raised `TypeError` on the
-first call, and its contract tests passed anyway, because a hand-written fake
-handle accepts `**kwargs`.
-
-Not a library bug — but with 30 keyword-only options it is a very easy mistake,
-and it is invisible to exactly the offline tests people write.
-
-**Suggested fix:** ship `py.typed` and/or a `Protocol` for the handle, so a fake
-can be checked against the real signature and a typo is caught statically. (We
-added a test asserting our kwargs are a subset of
-`inspect.signature(DatabaseHandle.open).parameters`, which is a poor substitute.)
-
----
-
-## Priority, from a client author's view
-
-| # | item | impact | fixable by you? |
-|---|---|---|---|
-| 1 | `timeout_trace` line tracing | 52x on IDA calls, 10x on real operations | yes, one line |
-| 2 | `to_jsonable` on large results | 114x on serialisation | yes |
-| 7 | no change/revision counter | correctness for shared editing | yes, cheap |
-| 4 | loader switches fatal on reopen | crashes, hard to diagnose | yes |
-| 5 | replaced/deleted IDB under lease | silent hang | yes |
-| 6 | no close-without-save | a feature we had to drop | design question |
-| 8 | package exports | forces internal imports | yes, trivial |
-| 3 | 2 ms `execute_sync` floor | shapes client design | document; maybe batch |
-| 9 | typed handle for fakes | catches a whole bug class | yes |
-
-Items 1 and 2 together were the difference between "the port is 35x slower than
-the private worker it replaced" and "the port is within 2x, and faster on several
-operations". Both are in the runtime, not in client code — which is why they are
-worth fixing centrally rather than leaving each client to rediscover.
-
-Happy to supply the benchmark harness (it is backend-agnostic and runs against
-both our old worker and Code Mode), or to test a patch.
diff --git a/docs/GRAPH_VIEW.md b/docs/GRAPH_VIEW.md
index 29b280e..0b24aa5 100644
--- a/docs/GRAPH_VIEW.md
+++ b/docs/GRAPH_VIEW.md
@@ -32,6 +32,7 @@ extra work.
| `0` | jump to the entry block |
| `z` | zoom: full → compact → collapsed |
| `m` | show / hide the minimap |
+| `e` | layout engine: auto → native → triskel |
| `f` | centre on the current block |
| `Enter` | follow — stays in the graph when the target is a block of this function |
| `x` `n` `y` `;` | xrefs / rename / retype / comment, exactly as in the listing |
@@ -53,10 +54,64 @@ Growing a second disassembly renderer for graph mode would have been the real
cost.
The backend adds exactly one operation, `flowchart(addr)` in
-`idatui/codemode_client.py`, which returns block ranges and typed edges — **not**
+`idatui/nexus_client.py`, which returns block ranges and typed edges — **not**
text.
-## Layout (`idatui/graph.py`)
+## Two layout engines
+
+`graph.layout(blocks, sizer, engine=...)` takes `auto` (the default, also
+`$IDATUI_GRAPH_ENGINE`), `native` or `triskel`, and `e` cycles them in the view.
+`auto` prefers **triskel** where it is installed and the function is at most 180
+blocks, and falls back to **native** otherwise — including if triskel raises,
+which is never fatal, and the status line then says why.
+
+The 180 is an interactivity budget: layout runs on every open and every zoom
+keypress, and triskel's cost knees hard just past it (174 blocks: 66 ms;
+233 blocks: 489 ms; 329: 555 ms; 424: 1.5 s, against native's 25/72/93/144).
+
+| | native | triskel |
+|---|---|---|
+| algorithm | layered Sugiyama, below | SESE decomposition ([paper](https://hal.science/hal-04996939)) |
+| ships with | always, pure python | needs `pytriskel` (patched fork, unpublished) |
+| shape | wide and short | narrow and tall |
+| crossings | more | far fewer |
+| 87-block `main` | 15 ms, 1202×444 | 37 ms, 845×789 |
+| 424-block `sub_3720` | 145 ms | 1.5 s (so `auto` won't) |
+
+On the 128-function corpus with realistic box sizes, triskel draws fewer
+crossings on 12 functions, the same on 9, more on 3 — and the wins are where it
+matters: `sub_5CA0` 41 → 6, `sub_2C90` 32 → 7, `sub_2C00` 12 → 0. It also routes
+loop edges around the side of the graph the way IDA does, instead of straight
+back up the middle. It is not a clean sweep: on `sub_69C0` (109 blocks) its
+narrower canvas packs edges tighter and it ends up with *more* cells shared
+between edges than native (1280 vs 935).
+
+### The triskel path (`idatui/graph_triskel.py`)
+
+The whole impedance mismatch lives in that one module. Three things keep it
+small: triskel's routes are already orthogonal (0 diagonal segments in 2471), its
+ports already land spread along the box border, and — because our fork made the
+spacing settable — **we hand it cell counts rather than pixels**, so nothing is
+ever rounded and two edge lanes can never land on the same row.
+
+What it does not do is trust the library with degenerate input. Triskel's graph
+root is **whichever node was created first**, and every one of its analyses walks
+out from there, so anything the root cannot reach is undefined behaviour — it
+throws `EMPTY BL` from its SESE bracket lists, or, when the entry block has no
+successors at all, segfaults. That is not survivable: a crash in a C extension
+takes the TUI with it, with no chance to fall back. So the entry is created
+first, orphan blocks are attached to it with **phantom edges** that steer the
+layout but are never drawn, and reachability is *asserted in python* before
+crossing into C++.
+
+The rest is handled before the call too: self-loops (drawn as `↺`; they make
+triskel throw), and edges routed through a block, which are detoured and
+re-verified. Whatever is left over falls back to native rather than reach the
+screen wrong — currently 8 layouts in 1200 (`ls`, three zoom levels each), all
+of them triskel leaving two boxes a few columns into each other, which in a
+terminal means one block's disassembly overwriting another's.
+
+## Layout (`idatui/graph.py`, the native engine)
Pure python: no IDA, no Textual, no I/O, so it is unit-tested offline in
milliseconds (`tests/test_graph.py`, which needs no worker). Textbook Sugiyama,
@@ -130,9 +185,16 @@ listing. A CFG that size is not a picture anyone can read — IDA's own is a
hairball there too (1853 crossings on the worst function in `targets/echo`).
This is a feature, not a shortcoming.
-Known cosmetic gap: a back edge leaves its tail's *top* border (`┴`) and arrows
-up into the head's *bottom* (`▲`). Correct and readable, but IDA runs loop edges
-around the side of the graph.
+Known cosmetic gap **of the native engine**: a back edge leaves its tail's *top*
+border (`┴`) and arrows up into the head's *bottom* (`▲`). Correct and readable,
+but IDA runs loop edges around the side of the graph — which is exactly what the
+triskel engine does, so `e` is the workaround.
+
+That difference is why an edge's arrowhead is decided by `Route.flipped` and not
+by geometry. The native engine reverses back edges to get a DAG, so its polyline
+runs *against* control flow and the arrow belongs at the start; triskel keeps the
+real direction. Reading the direction off the drawing would silently reverse
+every loop edge on one of the two engines.
## Driving it
@@ -151,8 +213,36 @@ drive raw graph action=zoom
- `experiments/cfg_dump.py` — freeze real CFGs from a binary to JSON.
- `experiments/graph_spike.py` — lay out and render a corpus function to stdout,
- or `--stats` the whole corpus. Uses `idatui.graph`, so it exercises the
- shipping engine with no worker in the loop.
+ or `--stats` the whole corpus; `--engine` picks the backend. Uses
+ `idatui.graph`, so it exercises the shipping engine with no worker in the loop.
- `experiments/graph_smoke.py` — end-to-end: tool → domain → layout.
- `experiments/graph_shot.py` — render the real view headless at a chosen size
- (the pane you are in is usually too narrow to judge it).
+ (the pane you are in is usually too narrow to judge it); takes an engine as
+ its fifth argument.
+
+## Installing the triskel engine
+
+It is optional; without it everything works and `auto` means `native`.
+
+It needs `pytriskel`, and specifically a **patched build that is not published
+anywhere yet**. Upstream's wheels stop at cp313 with no sdist (so there is
+nothing to install on 3.14), and on any version their `get_waypoints()` raises,
+which means no edge routes at all. Until that fork is released you will get the
+native engine — which is the default, ships with the repo, and is fully
+supported. The rest of this section only applies if you already have a patched
+build tree.
+
+**Install it into the interpreter the launcher actually runs**, which is
+`$IDATUI_PYTHON` and defaults to `~/ida-venv/bin/python` — *not* the repo's
+`.venv`, which is only what the tests use. Getting this wrong is the one way to
+see `no pytriskel in ...` in the status bar while `tests/test_graph.py` happily
+exercises both engines; the message names the interpreter for that reason.
+
+```bash
+"$IDATUI_PYTHON" -m pip install /path/to/triskel/bindings/python
+.venv/bin/python -m pip install /path/to/triskel/bindings/python # for the tests
+```
+
+Needs cmake, ninja and a C++23 compiler at install time; the wheel is built from
+source for whichever interpreter runs pip. `$IDATUI_TRISKEL_PATH` can point at a
+build tree instead of installing.
diff --git a/docs/NEXUS_UPSTREAM.md b/docs/NEXUS_UPSTREAM.md
new file mode 100644
index 0000000..f4dcf91
--- /dev/null
+++ b/docs/NEXUS_UPSTREAM.md
@@ -0,0 +1,385 @@
+# Findings from porting a real client to IDA Nexus
+
+Notes for the `ida-nexus` maintainers, gathered while porting **ida-tui** (a
+Textual TUI frontend for IDA) from a private idalib worker to
+`ida_nexus.DatabaseHandle`.
+
+Everything below is measured, not inferred. Where we worked around something, the
+workaround is named so you can judge whether the library should make it
+unnecessary.
+
+**Environment:** ida-nexus 0.3.1, IDA 9.4 (idalib), Linux, single managed
+worker backend, quiet box. Target for timings: `targets/echo` unless stated.
+
+> **Status against the protocol-6 event-stream development tree, based on 0.6.1
+> (upstream `439289f`) — every item re-checked.**
+>
+> | item | verdict |
+> |---|---|
+> | 1 `timeout_trace` line tracing | ✅ **fixed in 0.3.2** — no `settrace` in the runtime at all |
+> | 2 `to_jsonable` on large results | ✅ **fixed in 0.3.2** — `dumps_json` C fast path |
+> | 3 2 ms `execute_sync` floor | ✅ **fixed in 0.3.2, 7.0x** — 2.055 ms → 0.294 ms |
+> | 4 loader switches fatal on reopen | **partial** — normal reopen fixed in 0.5.x; direct `.i64` paths remain [issue #36](https://github.com/HexRaysSA/ida-nexus/issues/36) |
+> | 5 IDB replaced under a live lease | **stale** — out-of-band replacement is outside the supported lifecycle, as it is for the IDA GUI |
+> | 6 close without save | **fixed in protocol 6** — the final managed-worker lease can choose `shutdown_database(save=False)` |
+> | 7 no change notification | ✅ **fixed in protocol 6** — `DatabaseHandle.subscribe_idb_events()` streams revisioned, operation-attributed IDB changes |
+> | 8 package exports | ✅ **fixed in 0.5.x** — a real `__all__` on the package root |
+> | 9 no `py.typed` / handle Protocol | ✅ **fixed in 0.5.x** — `ida_nexus/py.typed` ships |
+>
+> **0.5.x restructured the package**, which is why the old "these files are
+> byte-identical" re-check recipe no longer works: `client.py` → `handle.py`,
+> `registry.py` → `_registry.py` + `instances.py`, `resolver.py` → `_resolver.py`,
+> and the loader options moved into a frozen `DatabaseOpenOptions` dataclass.
+> Everything private is now underscore-prefixed, so the cheap re-check after an
+> upstream pull is simply: does anything we import still appear in
+> `ida_nexus.__all__`?
+>
+> 0.5.3 → 0.6.1 changed **nothing** we depend on: `__init__.py`, `handle.py`,
+> `instances.py`, `options.py`, `errors.py` and `models.py` are byte-identical
+> between those two releases. 0.6.1 only collapses the six console scripts into a
+> single `ida-nexus` command.
+>
+> Both client-side workarounds re-measured at **0.99x and 0.97x** on 0.3.2 —
+> i.e. nothing — and are deleted. Remote code is now ordinary typed Python,
+> installed as content-addressed modules by ida-nexus. Harness:
+> `experiments/bench_pack_trace.py`.
+
+**What the client does**, for scale: it renders a continuous disassembly listing,
+pseudocode, a CFG graph view and a hex view, paging over the database as the user
+scrolls. It is latency-sensitive in a way an agent-driven MCP client is not — a
+keypress must repaint. It issues ~1–8 operations per user action.
+
+---
+
+## 1. `timeout_trace` enables line tracing in every frame — 52x on IDA calls
+
+**Highest-impact item by a wide margin.** — ✅ **FIXED in 0.3.2.** The runtime no
+longer installs a trace hook at all; cancellation is a C-level thread interrupt.
+Our `sys.settrace(None)` workaround is deleted as of `a5137fe`.
+
+`runtime.py` wraps every `execute_python` in `sys.settrace(timeout_trace)` to
+enforce the deadline. `timeout_trace` ends with `return timeout_trace`, and
+returning a trace function from a `'call'` event asks CPython to trace **every
+line of that frame**. So every line of every function the snippet touches pays a
+Python-level callback, and the specialising interpreter is disabled throughout.
+
+Measured inside the worker, same process, same database:
+
+| | traced (stock) | untraced | native idalib |
+|---|---|---|---|
+| `ida_bytes.get_flags(ea)` | 5.49 µs | 0.106 µs | 0.119 µs |
+| our 200-row listing page | 20.2 ms | 2.0 ms | — |
+
+Untraced matches a plain idalib process, so the trace hook accounts for
+essentially all of it. For us this was the single largest cost in the port —
+larger than HTTP, serialisation and IDA itself combined.
+
+Reproduce inside any `execute_python`:
+
+```python
+import sys, time, ida_bytes
+def bench():
+ t = time.perf_counter()
+ for _ in range(20000): ida_bytes.get_flags(0x1000)
+ return (time.perf_counter() - t) / 20000 * 1e6
+traced = bench()
+old = sys.gettrace(); sys.settrace(None)
+try: untraced = bench()
+finally: sys.settrace(old)
+result = {"traced_us": traced, "untraced_us": untraced}
+```
+
+**Suggested fixes, cheapest first**
+
+1. `return None` from `timeout_trace` instead of itself. You keep `'call'`-event
+ deadline checks — which is enough to interrupt anything that calls a function
+ — and drop per-line tracing entirely.
+2. On 3.12+, use `sys.monitoring` with only the events you need; it is designed
+ for exactly this and is far cheaper than `settrace`.
+3. Or drop the trace and rely on the `threading.Timer` →
+ `ida_kernwin.set_cancelled()` path you already have, accepting that a
+ pure-Python loop with no calls in it cannot be interrupted.
+
+**Our workaround** (we would rather not ship it): the snippet detaches the trace
+and restores it in a `finally`. That gives up deadline enforcement for
+pure-Python loops inside our own code; your native cancel timer is unaffected and
+still fires. Every client that does real work per call will eventually find this
+and do the same, which is an argument for fixing it in the runtime.
+
+---
+
+## 2. `to_jsonable` dominates any large result
+
+**FIXED in 0.3.2**, via the first suggested fix below:
+`serialization.dumps_json` calls `json.dumps(value, default=to_jsonable)`, so a
+JSON-safe result never enters the Python walker. Our packing workaround measured
+0.97x and has been deleted.
+
+`execute_python` runs `to_jsonable()` over whatever the snippet returns. Our
+answers are already JSON-safe and they are big — a 200-row listing page is
+roughly 10k small objects.
+
+| | cost |
+|---|---|
+| `to_jsonable(page)` | 66.2 ms |
+| `json.dumps(page, separators=(",",":"))` — same data | 0.58 ms |
+| serialised size | 34.9 KB |
+
+That is 114x, and it was 72% of the page's total cost before we changed it.
+
+**Suggested fixes**
+
+- Fast-path values that are already JSON-safe (a cheap recursive type check that
+ bails to the original object beats rebuilding it), or
+- let a snippet opt out by returning an already-serialised payload — a documented
+ envelope such as `{"__json__": "<...>"}`, or simply passing `str`/`bytes`
+ through untouched.
+
+**Retired workaround:** snippets used to `json.dumps` inside the database process
+and return one string, which the client parsed. The typed remote API now owns
+strict argument/result encoding, and ida-tui contains no generated script
+strings or packing envelope.
+
+---
+
+## 3. The per-operation floor is `execute_sync`, not HTTP
+
+✅ **FIXED in 0.3.2 — 7.0x.** Re-measured as a same-box A/B by checking the
+installed editable checkout back to `4195f21` and forward again, 200 iterations
+each, `targets/echo`:
+
+| | 0.3.1 | 0.3.2 | |
+|---|---|---|---|
+| `GET /health` | 0.497 ms | 0.318 ms | 1.6x |
+| `execute_python("result = 1")` | **2.055 ms** | **0.294 ms** | **7.0x** |
+
+The 0.3.1 column reproduces the original 2.025 ms measurement below almost
+exactly, which is what makes the 0.3.2 column believable. `execute_python` now
+costs about the same as a bare HTTP GET, so the `execute_sync` marshalling that
+was ~93% of the floor is essentially gone. The design advice below — "a client
+that makes one call per row will be 20–100x slower than an in-process one" — is
+correspondingly much weaker now.
+
+Original 0.3.1 measurement, same worker, same connection, 200 iterations:
+
+| | cost |
+|---|---|
+| `GET /health` (no `execute_sync`) | **0.165 ms** |
+| `execute_python("result = 1")` | **2.025 ms** |
+
+HTTP framing is ~7% of the floor; marshalling the operation onto IDA's main
+thread is the other ~93%. The worker runs IDA's own `kernwin.serve()`, so this is
+plausibly IDA's dispatch latency rather than anything you control — but it is
+worth **documenting**, because it sets a hard 2 ms per-operation budget that
+shapes how a client must be designed.
+
+It did not hurt us (our call volume is 1–8 per user action; 4 calls to build a
+1060-block graph), but a client that makes one call per row or per symbol will be
+20–100x slower than an in-process one and the authors will not know why.
+
+**Suggested fixes:** document the floor; and consider a batch endpoint — accept
+`[{op, args}, ...]` and dispatch them within a single `execute_sync` — which
+would let chatty clients amortise it without redesigning around it.
+
+---
+
+## 4. Loader switches on an existing database are a FATAL, not an error — one edge remains
+
+Opening a target that already has an `.i64`, while passing spawn-only options,
+kills the worker:
+
+```
+FATAL ERROR: @0:636[]
+Switch '-b400' can be used only when loading a new file
+```
+
+The client sees only:
+
+```
+IDAConnectionError: idalib worker launcher <pid> exited with status 1
+```
+
+This is easy to hit and hard to diagnose: it is the natural second run of
+anything that opens a raw blob (`processor=`/`image_base=`/`file_type=` are
+recorded in the database the first run produced). Our test suite hit it as a
+crash five minutes into a run.
+
+**Suggested fixes**
+
+- In `DatabaseHandle.open()`, when the resolved IDB already exists and
+ `new_database` is not set, either ignore the spawn-only options or raise a
+ typed error naming them — before handing them to IDA.
+- Propagate the worker's fatal text into the client exception. The message
+ already exists on the worker's stderr; losing it turns a one-line fix into a
+ bisect.
+
+**Our workaround:** the client checked whether the expected IDB exists and
+dropped `processor`/`image_base`/`file_type` when it did.
+
+**FIXED in 0.5.x**, with exactly this fix, in `_resolver._build_worker_command`:
+
+```python
+if input_path == expected_idb and input_path != source:
+ # Loader/import switches are baked into an existing IDB...
+ options = WorkerLaunchOptions()
+```
+
+Our workaround is therefore deleted. **One narrow case remains**: the strip needs
+`input_path != source`, so passing an `.i64` path *directly* together with load
+options (`ida-tui foo.i64 --processor arm`) still forwards the switches and still
+fatals. Our old guard keyed on "the target IDB exists" and so covered it. It is a
+nonsense invocation and no ida-tui code path generates it — the project layer
+always passes `output_database`, and `_needs_load_options` bails when an `.i64`
+exists — but the library boundary should still reject or normalize it rather
+than launch a known-fatal IDA command. Tracked upstream as
+[issue #36](https://github.com/HexRaysSA/ida-nexus/issues/36).
+
+---
+
+## 5. Deleting or replacing an IDB under a live lease — STALE
+
+The original suite deleted an `.i64` while a private worker still had it open,
+then immediately reopened the same path. That ownership model no longer applies:
+IDA Nexus databases are shared resources, and the IDA GUI itself does not survive
+out-of-band replacement of its open database. Detecting arbitrary filesystem
+replacement is therefore not part of the supported lifecycle.
+
+The actionable lifecycle gaps that originally forced private-registry access are
+fixed. `find_database_owner()` and `wait_database_released()` are public exports;
+`DatabaseHandle.close(wait_for_database=True)` can wait for a final managed close;
+a draining owner remains registered until the IDB is actually closed; and
+`new_database=True` refuses to replace a live owner.
+
+ida-tui now uses the public owner/release API while recreating a database and no
+longer reaches into registry locks. Owner loss is attach-only: ida-tui will
+rediscover a replacement GUI or worker, but will never turn a
+user-closing-the-GUI action into an implicit headless reopen. There is no
+remaining upstream request in this section.
+
+---
+
+## 6. Close without save — FIXED in protocol 6
+
+`DatabaseHandle.shutdown_database(save=False)` can discard a managed idalib
+worker when the requesting handle is its only active lease and no other operation
+is running. The server rejects GUI databases and shared workers.
+
+The coherent ownership model is the **final lease**, not necessarily the lease
+that spawned the worker. Releasing a non-final lease makes no whole-database save
+decision; responsibility transfers to the leases that remain. The final client
+can save or discard the shared session. A client that needs its work to survive
+regardless of that later decision must call `save_database()` before releasing
+its lease.
+
+This does not claim to provide per-client rollback. Discard applies to all
+changes since the last database save, and attempting it while another lease is
+active is correctly rejected. That is the same ref-counted lifetime model used
+by other shared resources and requires no separate starter capability.
+
+The upstream gap is therefore closed. ida-tui now routes its discard action
+through `shutdown_database(save=False)`: a final managed-worker lease discards,
+while GUI-backed and still-shared sessions transfer finalization to their owner
+or remaining leases.
+
+---
+
+## 7. No change notification for shared databases — FIXED in protocol 6
+
+`DatabaseHandle.subscribe_idb_events()` now returns a closeable iterator over
+structured IDB changes. Each event carries a monotonic revision plus
+`operation_id`/`operation_label` attribution and an opaque `origin_id`.
+`DatabaseHandle.owns_event()` compares that origin with the handle's lease, so a
+caching client does not need to generate, retain, or race operation IDs itself.
+
+ida-tui keeps one subscription for its active database, asks the handle to drop
+its own events, and batches peer events behind a 200 ms quiet period. One batch
+invalidates the function, listing, decompiler, graph, strings, linkage, segment
+and byte caches, then reloads the visible view in place. Closing or switching
+databases closes the subscription, so the blocking event reader does not leak.
+
+---
+
+## 8. Package exports and API surface stability — FIXED in 0.5.x
+
+`ida_nexus/__init__.py` used to export nothing, so a library consumer had to
+import from submodules, including things that were clearly internals (`FileLock`,
+`REGISTRY_DIR`, `canonical_path`, `idb_key`, `scan_instances`) that we only
+touched because no public equivalent existed.
+
+**Suggested fix was:** export `DatabaseHandle` and the public exception types from
+the package root, and mark the intended-public registry helpers explicitly.
+
+**That is what 0.5.x did.** Everything we need is now on the package root, and
+the internals moved behind an underscore:
+
+```python
+from ida_nexus import DatabaseHandle, DatabaseOpenOptions, DatabaseInstance
+from ida_nexus import RemoteError, DatabaseBusyError, DatabaseDisconnectedError
+from ida_nexus import discover_databases, find_database_owner, wait_database_released
+```
+
+The two lock-poking helpers we had reimplemented client-side
+(`_wait_for_entry_release`) are now `wait_database_released()`, and our
+registry-scanning ownership check is now `find_database_owner()`. Both are
+deleted from our tree. Note `find_database_owner()` *raises*
+`AmbiguousDatabaseError` where our scan silently took the first match — a
+behaviour improvement, but callers need a handler.
+
+---
+
+## 9. A testing note: `DatabaseHandle.open()`'s 30 keyword-only options — FIXED in 0.5.x
+
+The port we started from called `open(..., loading_address=...)`. The real
+parameter is `image_base`. Every `connect()` would have raised `TypeError` on the
+first call, and its contract tests passed anyway, because a hand-written fake
+handle accepts `**kwargs`.
+
+Not a library bug — but with 30 keyword-only options it is a very easy mistake,
+and it is invisible to exactly the offline tests people write.
+
+**Suggested fix:** ship `py.typed` and/or a `Protocol` for the handle, so a fake
+can be checked against the real signature and a typo is caught statically. (We
+added a test asserting our kwargs are a subset of
+`inspect.signature(DatabaseHandle.open).parameters`, which is a poor substitute.)
+
+**0.5.x ships `ida_nexus/py.typed`**, and the 30 keyword-only options became a
+frozen `DatabaseOpenOptions` dataclass — which is strictly better, because an
+invented option name is now a `TypeError` at construction rather than something a
+`**kwargs` fake swallows. Our subset test survives in two halves
+(`_open_kwargs_are_real` for `open()`, `_option_fields_are_real` for the
+dataclass fields), because the offline contract suite must keep running with no
+`ida_nexus` installed at all and therefore still fakes both.
+
+---
+
+## Priority, from a client author's view
+
+| # | item | impact | fixable by you? |
+|---|---|---|---|
+| ~~1~~ | ~~`timeout_trace` line tracing~~ | ~~52x on IDA calls~~ | ✅ fixed in 0.3.2 |
+| ~~2~~ | ~~`to_jsonable` on large results~~ | ~~114x on serialisation~~ | ✅ fixed in 0.3.2 |
+| ~~3~~ | ~~2 ms `execute_sync` floor~~ | ~~shapes client design~~ | ✅ fixed in 0.3.2, 7.0x |
+| ~~7~~ | ~~no change/revision counter~~ | ~~correctness for shared editing~~ | ✅ fixed in protocol 6 |
+| 4 | direct `.i64` forwards loader-only options | fatal worker startup | [issue #36](https://github.com/HexRaysSA/ida-nexus/issues/36) |
+| ~~5~~ | ~~replaced/deleted IDB under lease~~ | ~~out-of-contract filesystem mutation~~ | **stale** |
+| ~~6~~ | ~~no close without save~~ | ~~could not discard a managed session~~ | **fixed in protocol 6: final lease decides** |
+| ~~8~~ | ~~package exports~~ | ~~forces internal imports~~ | ✅ fixed in 0.5.x |
+| ~~9~~ | ~~typed handle for fakes~~ | ~~catches a whole bug class~~ | ✅ fixed in 0.5.x (`py.typed` + options dataclass) |
+
+Items 1 and 2 together were the difference between "the port is 35x slower than
+the private worker it replaced" and "the port is within 2x, and faster on several
+operations". Both are in the runtime, not in client code — which is why they are
+worth fixing centrally rather than leaving each client to rediscover.
+
+**Both landed in 0.3.2**, along with item 3 — all three performance items are now
+fixed upstream, and both client-side workarounds could be measured at parity and
+retired. That is the outcome this document was written for.
+
+**What is left is entirely non-performance.** Items 6 through 9 are fixed, and
+item 5 is stale because out-of-band replacement is not a supported lifecycle for
+either IDA Nexus or the IDA GUI. One narrow piece remains: **4**, normalize or
+reject loader-only options when the source is itself an existing `.i64`
+([issue #36](https://github.com/HexRaysSA/ida-nexus/issues/36)).
+
+Happy to supply the benchmark harness (it is backend-agnostic and runs against
+both our old worker and IDA Nexus), or to test a patch.
diff --git a/docs/PAGING_FINDINGS.md b/docs/PAGING_FINDINGS.md
index bd6c38f..f343973 100644
--- a/docs/PAGING_FINDINGS.md
+++ b/docs/PAGING_FINDINGS.md
@@ -3,9 +3,9 @@
Measured against a real target: `libcrypto.so.3` (5.7 MB, **10,092 functions**,
biggest function **52,120 instructions**). These constraints drive the domain /
paging layer. The measurements below came from the former ida-pro-mcp tool
-backend. The Code Mode port preserves the adapter response shapes and conservative
+backend. The IDA Nexus port preserves the adapter response shapes and conservative
page sizes, but executes enumeration through ida-domain; old server caps and RTT
-numbers are historical rather than Code Mode constraints.
+numbers are historical rather than IDA Nexus constraints.
## Response shape (list_* / *_query tools)
@@ -93,17 +93,17 @@ disasm totals are **top-level** fields, not under `asm`:
(correct). The pseudocode view must handle "decompilation failed" gracefully —
fall back to the disassembly view or show an error panel.
-Code Mode returns the complete execution result directly; ida-tui no longer
+IDA Nexus returns the complete execution result directly; ida-tui no longer
needs MCP structured-content/download-URL recovery for large pseudocode bodies.
-## Code Mode lifecycle
+## IDA Nexus lifecycle
-`CodeModeClient` owns an authenticated SSE lease on a registered database:
+`NexusClient` owns an authenticated SSE lease on a registered database:
* A matching GUI is preferred and remains open when the TUI exits.
-* Otherwise Code Mode reuses or starts a shared managed idalib worker.
+* Otherwise IDA Nexus reuses or starts a shared managed idalib worker.
* Releasing one lease never terminates another client's session. A managed
- worker saves and exits after its final lease under Code Mode's grace policy.
+ worker saves and exits after its final lease under IDA Nexus's grace policy.
* Lease loss surfaces as `IDAConnectionError`; reconnect performs discovery
again and may bind a newly-created instance. It does not silently swap the
handle underneath an operation.
diff --git a/docs/PROJECTS.md b/docs/PROJECTS.md
index 0efee31..52dbf83 100644
--- a/docs/PROJECTS.md
+++ b/docs/PROJECTS.md
@@ -7,7 +7,7 @@ search across all of them, and (later) follow calls from one into another.
## The constraint that shapes everything
-IDA still exposes one active database per GUI/idalib process. Code Mode makes
+IDA still exposes one active database per GUI/idalib process. IDA Nexus makes
those instances discoverable and shareable: each project entry retains one
`DatabaseHandle` lease, which may target a registered GUI or a managed idalib
worker. N resident project databases can therefore mean up to N processes, but
@@ -32,13 +32,13 @@ crypto library.
Two capabilities that feel like one, but aren't:
-1. **Switching** to a binary needs a *live Code Mode lease*.
+1. **Switching** to a binary needs a *live IDA Nexus lease*.
2. **Searching across** binaries does *not* — if a per-binary index (functions,
strings, imports/exports) is cached on disk.
That split is the unlock: project-wide search stays instant across every binary,
including ones never opened this session, and only *jumping* to a hit costs a
-Code Mode attach/open.
+IDA Nexus attach/open.
## Layout
@@ -85,11 +85,11 @@ basename and must be unique (it names the staged file).
## Runtime
-- **`DatabasePool`** — one `CodeModeClient` lease per resident binary, attached
+- **`DatabasePool`** — one `NexusClient` lease per resident binary, attached
lazily on first switch and LRU-released when the advisory memory budget is
exceeded. Eviction explicitly saves managed IDBs but never implicitly saves a
GUI. Closing a lease never kills a GUI or another client's managed worker;
- Code Mode owns final worker shutdown.
+ IDA Nexus owns final worker shutdown.
- **`BinaryState`** — per binary: `client, program, nav, cur, func_index,
pref/active/split, filter`. Switching snapshots the current state and restores
the target's. `_after_reconnect` provides the client/program swap seam.
diff --git a/docs/SPLIT_VIEW.md b/docs/SPLIT_VIEW.md
index c421656..6e9b938 100644
--- a/docs/SPLIT_VIEW.md
+++ b/docs/SPLIT_VIEW.md
@@ -32,7 +32,7 @@ known technique:
The old ida-pro-mcp backend derived the per-line marker via
`cfunc.get_line_item(line, col=0, …).get_ea()`. To get the **full set**, sweep
every column of the line (`get_line_item(line, x, …).get_ea()` for `x` in
-`0..len`) and collect distinct non-`BADADDR` EAs. The Code Mode adapter's
+`0..len`) and collect distinct non-`BADADDR` EAs. The IDA Nexus adapter's
`decomp_map(ea)` operation returns
`[{line, primary_ea, eas:[…]}, …]`; invert for `ea → line`.
@@ -78,14 +78,14 @@ 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. DONE.** The Code Mode `decomp_map` operation
-(`idatui/codemode_client.py`) sweeps `cfunc.get_line_item` across every column of
+**Phase 3 — rich highlight. DONE.** The IDA Nexus `decomp_map` operation
+(`idatui/nexus_client.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 a real Code Mode database
+back to the single marker until the map lands. Verified on a real IDA Nexus database
(alignment + multi-instruction region band).
**Phase 4 — polish. DONE.**
diff --git a/docs/TEXTUAL_NOTES.md b/docs/TEXTUAL_NOTES.md
index 10df669..aba84ae 100644
--- a/docs/TEXTUAL_NOTES.md
+++ b/docs/TEXTUAL_NOTES.md
@@ -53,6 +53,15 @@ Hard-won Textual behaviour and the patterns this app relies on. Pairs with
`build_byte_to_codepoint_dict`, so character offsets smear on non-ASCII), and
a `TextAreaTheme` that sets `base_style` overrides the widget's CSS colours —
ours sets only `syntax_styles` so the editor keeps the app's background.
+- **Fixed-size splash art disappears instead of shrinking.** The kitty image is
+ scaled by the terminal into whatever cell box you place it in (`c=`/`r=`), so
+ sizing it to the artwork's natural height and then asking "is there room?" is
+ all-or-nothing — a 31-row zellij pane was ONE row short of the 41 the splash
+ wanted, and the logo silently vanished. Size the art to the room instead
+ (`logo_cells(max_rows)`), and keep the chrome constant honest:
+ `LOGO_CHROME_ROWS = 10` is border 2 + padding 2 + the art's margin 1 + title 1
+ + note 1+1 + help 1+1, which the old `rows + 9` under-counted by one, so at
+ exactly the threshold the help line was clipped off the bottom.
- **Centre modals with a rule, not a list.** `ModalScreen { align: center middle; }`
matches subclasses, so every dialog inherits it and the next one is centred
for free. Naming the screens instead (`SymbolPalette, StringsPalette, …`) is
diff --git a/experiments/bench_ops.py b/experiments/bench_ops.py
new file mode 100644
index 0000000..1c0ad69
--- /dev/null
+++ b/experiments/bench_ops.py
@@ -0,0 +1,116 @@
+"""Time a realistic idatui operation mix against whatever ida-nexus is installed.
+
+The companion to `bench_pack_trace.py`: that one isolates a single workaround,
+this one answers "how much faster is the whole client, on real operations".
+
+**It deliberately does not import anything version-specific**, so the SAME file
+can measure an OLD idatui checkout (with its `sys.settrace` strip and packing
+workarounds) and the current one. To compare across versions, copy it somewhere
+outside the repo first -- `git checkout` of an older commit would otherwise
+replace or delete it::
+
+ cp experiments/bench_ops.py /tmp/
+ # C: current client, current library
+ PYTHONPATH=. ~/ida-venv/bin/python /tmp/bench_ops.py
+
+ # B: current client against the OLD library (shows what the workarounds were for)
+ git -C ~/dev/ida-nexus checkout 4195f21
+ PYTHONPATH=. ~/ida-venv/bin/python /tmp/bench_ops.py
+
+ # A: the client as it SHIPPED on the old library, workarounds and all
+ git checkout 8550474 # the commit before the workaround removal
+ PYTHONPATH=. ~/ida-venv/bin/python /tmp/bench_ops.py
+
+ git checkout main && git -C ~/dev/ida-nexus checkout main # ALWAYS restore
+
+ida-nexus is installed **editable** into both venvs, so checking that repo out
+swaps the backend under the TUI with no reinstall -- which is what makes this A/B
+cheap.
+"""
+
+from __future__ import annotations
+
+import argparse
+import os
+import statistics
+import time
+
+from idatui import remote_ops
+from idatui.nexus_client import NexusClient
+
+
+def bench(fn, reps: int) -> tuple[float, float]:
+ """Best-of and median wall time in ms; best-of resists co-tenant noise."""
+ samples = []
+ for _ in range(reps):
+ started = time.perf_counter()
+ fn()
+ samples.append((time.perf_counter() - started) * 1000.0)
+ return min(samples), statistics.median(samples)
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser()
+ ap.add_argument("target", nargs="?", default="targets/bash")
+ ap.add_argument("--reps", type=int, default=20)
+ args = ap.parse_args()
+
+ client = NexusClient(os.path.abspath(args.target))
+ client.connect()
+ handle = client._handle
+
+ # Work on the biggest function we can find, so the payload-heavy operations
+ # are actually payload-heavy.
+ index = client.call(remote_ops.list_funcs, queries=[{"offset": 0, "count": 60}])
+ funcs = (index.get("result") or [{}])[0].get("data") or []
+ if not funcs:
+ print("VERDICT: FAIL - no functions")
+ return 1
+ big = max(funcs, key=lambda f: f.get("size") or 0)
+ ea = big["addr"] if isinstance(big["addr"], str) else hex(big["addr"])
+
+ ops = [
+ # Synthetic: isolates the per-operation floor (execute_sync marshalling).
+ ("empty round trip", lambda: handle.execute_python("result = 1")),
+ # Payload-dominated: what _PACK_EPILOGUE was written for.
+ (
+ "list_funcs 500",
+ lambda: client.call(
+ remote_ops.list_funcs, queries=[{"offset": 0, "count": 500}]
+ ),
+ ),
+ (
+ "heads 200 (listing page)",
+ lambda: client.call(remote_ops.heads, addr=ea, count=200, annotate=True),
+ ),
+ # IDA-work-dominated: Hex-Rays, nothing upstream can move.
+ ("decompile (warm)", lambda: client.call(remote_ops.decompile, addr=ea)),
+ ("flowchart (graph)", lambda: client.call(remote_ops.flowchart, addr=ea)),
+ # Round-trip-dominated: small payload, so only the floor matters.
+ (
+ "xrefs_to",
+ lambda: client.call(remote_ops.xref_query, direction="to", addr=ea),
+ ),
+ ]
+
+ print(
+ f"# target={os.path.basename(args.target)} func={ea} reps={args.reps} "
+ f"backend={client.backend}"
+ )
+ results = {}
+ for name, fn in ops:
+ try:
+ for _ in range(3): # warm caches; the first sample is always an outlier
+ fn()
+ best, med = bench(fn, args.reps)
+ results[name] = med
+ print(f"{name:28} best {best:8.3f}ms median {med:8.3f}ms")
+ except Exception as exc: # one broken op must not lose the other five
+ print(f"{name:28} FAILED: {type(exc).__name__}: {str(exc)[:60]}")
+ client.close()
+ print("RESULT " + ";".join(f"{k}={v:.3f}" for k, v in results.items()))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/experiments/bench_pack_trace.py b/experiments/bench_pack_trace.py
new file mode 100644
index 0000000..2b53afd
--- /dev/null
+++ b/experiments/bench_pack_trace.py
@@ -0,0 +1,79 @@
+"""Measure cold installation versus warm calls for typed remote modules.
+
+Historical note: this file used to benchmark ``_PACK_EPILOGUE``. Application
+scripts are no longer strings and packing is gone; the relevant design cost is
+now the one-time content-addressed module installation versus steady-state calls.
+
+Usage::
+
+ PYTHONPATH=. python experiments/bench_pack_trace.py [FILE]
+"""
+
+from __future__ import annotations
+
+import argparse
+import os
+import statistics
+import time
+
+from idatui import remote_ops
+from idatui.nexus_client import NexusClient
+
+
+def timed(function, reps: int = 1) -> tuple[object, float]:
+ samples = []
+ result = None
+ for _ in range(reps):
+ started = time.perf_counter()
+ result = function()
+ samples.append((time.perf_counter() - started) * 1000.0)
+ return result, statistics.median(samples)
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("target", nargs="?", default="targets/bash")
+ parser.add_argument("--reps", type=int, default=25)
+ parser.add_argument("--rows", type=int, default=200)
+ args = parser.parse_args()
+
+ client = NexusClient(os.path.abspath(args.target)).connect()
+
+ index, operations_cold = timed(
+ lambda: client.call(remote_ops.list_funcs, queries=[{"offset": 0, "count": 40}])
+ )
+ funcs = (index.get("result") or [{}])[0].get("data") or []
+ biggest = max(funcs, key=lambda function: function.get("size") or 0, default=None)
+ if not biggest:
+ print("VERDICT: FAIL - no functions")
+ return 1
+ addr = biggest["addr"]
+ if isinstance(addr, int):
+ addr = hex(addr)
+
+ _, operations_warm = timed(
+ lambda: client.call(
+ remote_ops.list_funcs, queries=[{"offset": 0, "count": 40}]
+ ),
+ args.reps,
+ )
+ page = lambda: client.call( # noqa: E731
+ remote_ops.heads, addr=addr, count=args.rows, annotate=True
+ )
+ payload, tools_cold = timed(page)
+ _, tools_warm = timed(page, args.reps)
+
+ print(f"target {os.path.basename(args.target)} backend={client.backend}")
+ print(f"function {addr}")
+ print(f"listing rows {len(payload.get('heads', []))}")
+ print(
+ f"operations.py cold {operations_cold:8.3f}ms warm {operations_warm:8.3f}ms"
+ )
+ print(f"remote_tools.py cold {tools_cold:8.3f}ms warm {tools_warm:8.3f}ms")
+ print(f"tools install overhead {tools_cold / max(tools_warm, 0.001):.2f}x one time")
+ client.close()
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/experiments/call_census.py b/experiments/call_census.py
new file mode 100644
index 0000000..4c0f9d3
--- /dev/null
+++ b/experiments/call_census.py
@@ -0,0 +1,174 @@
+"""Count backend round-trips per user action.
+
+Answers "are we batching, or paying a round-trip per item?" with numbers rather
+than intent. Wraps ``NexusClient.invoke`` on the live app, drives a headless
+Pilot through realistic actions, and reports calls + wall time + which
+operations were used for each.
+
+ PYTHONPATH=. ~/ida-venv/bin/python experiments/call_census.py [BINARY]
+
+Read it as: an action costing 1-8 calls is amortised (the snippet looped inside
+the database); an action whose call count scales with the number of rows or
+symbols on screen is a round-trip-per-item bug worth fixing.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import collections
+import os
+import sys
+import time
+
+sys.path.insert(
+ 0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "tests")
+)
+from _fixtures import fast_keys, staged # noqa: E402
+
+fast_keys()
+
+from idatui.app import IdaTui, ListingView # noqa: E402
+from idatui.nexus_client import NexusClient # noqa: E402
+
+
+class Census:
+ """Patch invoke() once; measure named spans against it."""
+
+ def __init__(self) -> None:
+ self.ops: collections.Counter = collections.Counter()
+ self.n = 0
+ original = NexusClient.invoke
+
+ def counting(client, operation, *a, **kw):
+ self.n += 1
+ self.ops[operation] += 1
+ return original(client, operation, *a, **kw)
+
+ NexusClient.invoke = counting
+ self._original = original
+
+ def restore(self) -> None:
+ NexusClient.invoke = self._original
+
+ def span(self, label: str):
+ return _Span(self, label)
+
+
+class _Span:
+ def __init__(self, census: Census, label: str) -> None:
+ self.c, self.label = census, label
+
+ def __enter__(self):
+ self.n0 = self.c.n
+ self.ops0 = self.c.ops.copy()
+ self.t0 = time.perf_counter()
+ return self
+
+ def __exit__(self, *exc):
+ ms = (time.perf_counter() - self.t0) * 1000
+ used = self.c.ops - self.ops0
+ detail = " ".join(
+ f"{k}x{v}" if v > 1 else k
+ for k, v in sorted(used.items(), key=lambda kv: -kv[1])
+ )
+ print(
+ f" {self.label:<34} {self.c.n - self.n0:>3} calls {ms:7.1f}ms {detail}"
+ )
+ return False
+
+
+async def main() -> int:
+ binary = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else "targets/bash")
+ async with staged(
+ binary, lambda p: IdaTui(open_path=p, keepalive=False), prefix="idatui-census-"
+ ) as target:
+ app = IdaTui(open_path=target, keepalive=False)
+ census = Census()
+ try:
+ async with app.run_test(size=(140, 44)) as pilot:
+ for _ in range(200):
+ if getattr(app, "_cur", None) is not None:
+ break
+ await pilot.pause(0.05)
+ print(f"\n# {os.path.basename(binary)} — backend calls per action\n")
+
+ # THE BACKGROUND GROWER MUST FINISH FIRST.
+ #
+ # ListingView._grow streams the WHOLE segment in 500-head pages
+ # on a worker thread, so it lands calls continuously no matter
+ # what the user is doing. Measuring an action while it runs
+ # attributes its traffic to that action -- every span comes out
+ # at a near-identical "~1 call per 10ms of pause", which says
+ # nothing about the action. Drain it, report it as its own line,
+ # then measure against a quiet backend.
+ def listing_done() -> bool:
+ try:
+ m = app.query_one(ListingView).model
+ except Exception:
+ return False
+ return m is not None and m.complete
+
+ with census.span("boot: stream the whole segment"):
+ for _ in range(2000):
+ if listing_done():
+ break
+ await pilot.pause(0.05)
+ await pilot.pause(0.4)
+ drained = listing_done()
+ print(
+ f" {'(grower finished: ' + str(drained) + ')':<34}\n"
+ f" -- everything below is on a QUIET backend --\n"
+ )
+
+ with census.span("scroll one page (pagedown)"):
+ await pilot.press("pagedown")
+ await pilot.pause(0.2)
+
+ with census.span("scroll 20 pages"):
+ for _ in range(20):
+ await pilot.press("pagedown")
+ await pilot.pause(0.5)
+
+ with census.span("switch to pseudocode (tab)"):
+ await pilot.press("tab")
+ await pilot.pause(0.6)
+
+ with census.span("cursor down x30 in pseudocode"):
+ for _ in range(30):
+ await pilot.press("down")
+ await pilot.pause(0.3)
+
+ with census.span("open graph (space)"):
+ await pilot.press("space")
+ await pilot.pause(0.8)
+
+ with census.span("open symbol palette (ctrl+n)"):
+ await pilot.press("ctrl+n")
+ await pilot.pause(0.4)
+
+ with census.span("type 5 chars into the palette"):
+ for ch in "write":
+ await pilot.press(ch)
+ await pilot.pause(0.4)
+ await pilot.press("escape")
+ await pilot.pause(0.2)
+
+ with census.span("hex view (backslash)"):
+ await pilot.press("backslash")
+ await pilot.pause(0.5)
+
+ with census.span("scroll hex 10 pages"):
+ for _ in range(10):
+ await pilot.press("pagedown")
+ await pilot.pause(0.4)
+
+ print(f"\n {'TOTAL':<34} {census.n:>3} calls")
+ top = ", ".join(f"{k}x{v}" for k, v in census.ops.most_common(6))
+ print(f" most-used ops: {top}\n")
+ finally:
+ census.restore()
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(asyncio.run(main()))
diff --git a/experiments/cfg_dump.py b/experiments/cfg_dump.py
index 30b6ba9..bef24e7 100644
--- a/experiments/cfg_dump.py
+++ b/experiments/cfg_dump.py
@@ -15,6 +15,7 @@ taken branch) or "switch" (n-way). That is exactly the input a graph view needs;
everything after this point (layering, ordering, routing, rendering) is pure
python and needs no IDA at all.
"""
+
from __future__ import annotations
import argparse
@@ -34,12 +35,12 @@ def dump(path: str, want: list[str], max_blocks: int) -> list[dict]:
if idapro.open_database(local, run_auto_analysis=True) != 0:
raise SystemExit(f"failed to open {local}")
+ import ida_bytes
import ida_funcs
import ida_gdl
import ida_lines
- import ida_bytes
- import idautils
import idaapi
+ import idautils
out = []
try:
@@ -60,7 +61,8 @@ def dump(path: str, want: list[str], max_blocks: int) -> list[dict]:
ea = bb.start_ea
while ea < bb.end_ea and ea != idaapi.BADADDR:
txt = ida_lines.tag_remove(
- ida_lines.generate_disasm_line(ea, 0) or "")
+ ida_lines.generate_disasm_line(ea, 0) or ""
+ )
lines.append(txt.rstrip())
nxt = ida_bytes.next_head(ea, bb.end_ea)
if nxt <= ea:
@@ -78,13 +80,15 @@ def dump(path: str, want: list[str], max_blocks: int) -> list[dict]:
else:
kind = "jump"
succs.append([index[s.start_ea], kind])
- blocks.append({
- "id": index[bb.start_ea],
- "start": bb.start_ea,
- "end": bb.end_ea,
- "lines": lines,
- "succs": succs,
- })
+ blocks.append(
+ {
+ "id": index[bb.start_ea],
+ "start": bb.start_ea,
+ "end": bb.end_ea,
+ "lines": lines,
+ "succs": succs,
+ }
+ )
if max_blocks and len(blocks) > max_blocks:
continue
out.append({"name": name, "ea": fea, "blocks": blocks})
@@ -98,10 +102,19 @@ def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("binary")
ap.add_argument("-o", "--out", default="/tmp/cfg.json")
- ap.add_argument("-f", "--func", action="append", default=[],
- help="only functions whose name contains this (repeatable)")
- ap.add_argument("--max-blocks", type=int, default=0,
- help="skip functions with more blocks than this")
+ ap.add_argument(
+ "-f",
+ "--func",
+ action="append",
+ default=[],
+ help="only functions whose name contains this (repeatable)",
+ )
+ ap.add_argument(
+ "--max-blocks",
+ type=int,
+ default=0,
+ help="skip functions with more blocks than this",
+ )
args = ap.parse_args()
recs = dump(os.path.abspath(args.binary), args.func, args.max_blocks)
diff --git a/experiments/graph_shot.py b/experiments/graph_shot.py
index 5f0d45a..e3eb209 100644
--- a/experiments/graph_shot.py
+++ b/experiments/graph_shot.py
@@ -1,11 +1,12 @@
"""Render the graph view headless at a chosen size and print it.
- ~/ida-venv/bin/python experiments/graph_shot.py [func] [cols] [rows] [zoom]
+ ~/ida-venv/bin/python experiments/graph_shot.py [func] [cols] [rows] [zoom] [engine]
The pane a person runs this in is usually too small to judge the layout, and the
pilot lays out synchronously at whatever size you ask for -- so this is the way
to actually look at the thing.
"""
+
import asyncio
import os
import shutil
@@ -19,6 +20,7 @@ func = sys.argv[1] if len(sys.argv) > 1 else "sub_2297"
cols = int(sys.argv[2]) if len(sys.argv) > 2 else 170
rows = int(sys.argv[3]) if len(sys.argv) > 3 else 55
zoom = int(sys.argv[4]) if len(sys.argv) > 4 else 0
+engine = sys.argv[5] if len(sys.argv) > 5 else None
src, tmp = f"{REPO}/targets/echo", "/tmp/echo_shot"
shutil.copy(src, tmp)
@@ -28,26 +30,33 @@ for e in ".i64 .id0 .id1 .id2 .nam .til".split():
except OSError:
pass
-from idatui.app import IdaTui, GraphView # noqa: E402
-from idatui._sync import wait_for # noqa: E402
-from idatui.rpc import screen_text # noqa: E402
+from idatui._sync import wait_for # noqa: E402
+from idatui.app import GraphView, IdaTui # noqa: E402
+from idatui.rpc import screen_text # noqa: E402
async def main() -> None:
app = IdaTui(tmp, keepalive=False)
async with app.run_test(size=(cols, rows)) as pilot:
- await wait_for(lambda: app.program is not None and app._cur is not None,
- pilot.pause, 120)
+ await wait_for(
+ lambda: app.program is not None and app._cur is not None, pilot.pause, 120
+ )
ea = app.program.resolve(func)
fn = app.program.function_of(ea)
app._open_function(fn.addr, fn.name)
- await wait_for(lambda: app._cur is not None and app._cur.ea == fn.addr,
- pilot.pause, 60)
+ await wait_for(
+ lambda: app._cur is not None and app._cur.ea == fn.addr, pilot.pause, 60
+ )
await pilot.pause(0.2)
await pilot.press("space")
gv = app.query_one(GraphView)
- ok = await wait_for(lambda: app._active == "graph" and gv.lay is not None,
- pilot.pause, 90)
+ if engine:
+ gv._engine = engine
+ gv._relayout()
+ app._graph_status()
+ ok = await wait_for(
+ lambda: app._active == "graph" and gv.lay is not None, pilot.pause, 90
+ )
if not ok:
print("graph never opened:", app.query_one("#status").render())
return
@@ -58,8 +67,14 @@ async def main() -> None:
print(screen_text(app)["text"])
print()
print("status:", app.query_one("#status").render())
- print("stats :", gv.lay.stats, "canvas",
- f"{gv.lay.width}x{gv.lay.height}", "zoom", gv.ZOOMS[gv._zoom])
+ print(
+ "stats :",
+ gv.lay.stats,
+ "canvas",
+ f"{gv.lay.width}x{gv.lay.height}",
+ "zoom",
+ gv.ZOOMS[gv._zoom],
+ )
app._save_on_exit = False
diff --git a/experiments/graph_smoke.py b/experiments/graph_smoke.py
index 1b6e21e..92fe1f5 100644
--- a/experiments/graph_smoke.py
+++ b/experiments/graph_smoke.py
@@ -5,6 +5,7 @@ idatui.graph layout, through a real idalib worker.
Wants: VERDICT: OK
"""
+
import os
import shutil
import sys
@@ -22,16 +23,17 @@ for e in ".i64 .id0 .id1 .id2 .nam .til".split():
except OSError:
pass
-from idatui.worker_client import WorkerClient # noqa: E402
-from idatui.domain import Program # noqa: E402
-from idatui import graph as G # noqa: E402
+from idatui.worker_client import WorkerClient # noqa: E402
+
+from idatui import graph as G # noqa: E402
+from idatui.domain import Program # noqa: E402
want = sys.argv[1] if len(sys.argv) > 1 else "main"
print("spawning worker + opening echo\u2026", flush=True)
t = time.time()
cl = WorkerClient(tmp)
cl.connect(progress=lambda m: None)
-print(f" worker ready in {time.time()-t:.2f}s", flush=True)
+print(f" worker ready in {time.time() - t:.2f}s", flush=True)
prog = Program(cl)
ok = True
@@ -40,7 +42,7 @@ print(f"resolve({want!r}) = {ea:#x}", flush=True)
t = time.time()
fc = prog.flowchart(ea)
-print(f"flowchart() -> {time.time()-t:.2f}s", flush=True)
+print(f"flowchart() -> {time.time() - t:.2f}s", flush=True)
if fc is None:
print("VERDICT: FAIL (no flowchart)")
raise SystemExit(1)
@@ -55,8 +57,10 @@ if not nrows:
empty = [b for b in fc.blocks if not b.rows]
if empty:
ok = False
- print(f" !! {len(empty)} blocks have NO rows, e.g. "
- f"{[hex(b.start) for b in empty[:4]]}")
+ print(
+ f" !! {len(empty)} blocks have NO rows, e.g. "
+ f"{[hex(b.start) for b in empty[:4]]}"
+ )
b0 = fc.blocks[fc.entry]
print(f" entry block {b0.start:#x}-{b0.end:#x}:")
@@ -74,20 +78,20 @@ for b in fc.blocks:
ok = False
print(f" !! block {b.start:#x} has rows outside its range")
-blocks = [G.Block(id=b.id, start=b.start, end=b.end, succs=list(b.succs))
- for b in fc.blocks]
+blocks = [
+ G.Block(id=b.id, start=b.start, end=b.end, succs=list(b.succs)) for b in fc.blocks
+]
def sizer(b):
src_b = fc.blocks[b.id]
- w = max([len(f"loc_{b.start:X}")]
- + [len(h.text) + 12 for h in src_b.rows]) + 4
+ w = max([len(f"loc_{b.start:X}")] + [len(h.text) + 12 for h in src_b.rows]) + 4
return (w, len(src_b.rows) + 3)
t = time.time()
lay = G.layout(blocks, sizer, entry=fc.entry)
-print(f"layout() -> {(time.time()-t)*1000:.0f} ms {lay.stats}", flush=True)
+print(f"layout() -> {(time.time() - t) * 1000:.0f} ms {lay.stats}", flush=True)
print(f" canvas {lay.width}x{lay.height}")
ok &= len(lay.nodes) == len(blocks)
@@ -97,9 +101,10 @@ ok &= covered == {b.id for b in blocks}
if covered != {b.id for b in blocks}:
print(f" !! row index misses {sorted({b.id for b in blocks} - covered)[:5]}")
-hits = sum(1 for r in range(min(lay.height, 400))
- if lay.painting.cells_at_row(r, 0, lay.width))
-print(f" {hits} of the first {min(lay.height,400)} rows carry edge cells")
+hits = sum(
+ 1 for r in range(min(lay.height, 400)) if lay.painting.cells_at_row(r, 0, lay.width)
+)
+print(f" {hits} of the first {min(lay.height, 400)} rows carry edge cells")
ok &= hits > 0
cl.close()
diff --git a/experiments/graph_spike.py b/experiments/graph_spike.py
index 547c829..b3c5dc2 100644
--- a/experiments/graph_spike.py
+++ b/experiments/graph_spike.py
@@ -11,6 +11,7 @@ milliseconds when you're changing layout heuristics.
python3 experiments/graph_spike.py /tmp/cfg-echo.json --func sub_61D0
python3 experiments/graph_spike.py /tmp/cfg-echo.json --stats
"""
+
from __future__ import annotations
import argparse
@@ -23,8 +24,10 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from idatui import graph as G # noqa: E402
COLOR = {
- G.E_UNCOND: "\033[38;5;39m", G.E_TRUE: "\033[38;5;40m",
- G.E_FALSE: "\033[38;5;203m", G.E_SWITCH: "\033[38;5;178m",
+ G.E_UNCOND: "\033[38;5;39m",
+ G.E_TRUE: "\033[38;5;40m",
+ G.E_FALSE: "\033[38;5;203m",
+ G.E_SWITCH: "\033[38;5;178m",
G.E_BACK: "\033[38;5;135m",
}
DIM, RESET = "\033[38;5;244m", "\033[0m"
@@ -37,11 +40,19 @@ def build(rec: dict, max_lines: int):
for b in rec["blocks"]:
lines = list(b["lines"])
if max_lines and len(lines) > max_lines:
- lines = lines[:max_lines - 1] + [f"... {len(b['lines']) - max_lines + 1} more"]
+ lines = lines[: max_lines - 1] + [
+ f"... {len(b['lines']) - max_lines + 1} more"
+ ]
texts[b["id"]] = lines
- blocks = [G.Block(id=b["id"], start=b["start"], end=b["end"],
- succs=[(d, k) for d, k in b["succs"]])
- for b in rec["blocks"]]
+ blocks = [
+ G.Block(
+ id=b["id"],
+ start=b["start"],
+ end=b["end"],
+ succs=[(d, k) for d, k in b["succs"]],
+ )
+ for b in rec["blocks"]
+ ]
def sizer(b: G.Block) -> tuple[int, int]:
lines = texts[b.id]
@@ -57,7 +68,8 @@ def render(lay: G.Layout, texts: dict[int, list[str]], color: bool) -> str:
for row in range(lay.height):
cells: dict[int, tuple[str, str]] = {}
for col, (ch, kind, _eid) in lay.painting.cells_at_row(
- row, 0, lay.width).items():
+ row, 0, lay.width
+ ).items():
cells[col] = (ch, COLOR.get(kind, ""))
for n in lay.nodes_at_row(row):
label = f"loc_{n.block.start:X}" if n.block else ""
@@ -107,41 +119,59 @@ def main() -> int:
ap.add_argument("corpus")
ap.add_argument("--func", help="function name (default: the smallest)")
ap.add_argument("--stats", action="store_true", help="lay out the whole corpus")
- ap.add_argument("--max-lines", type=int, default=8,
- help="collapse blocks longer than this (0 = never)")
+ ap.add_argument(
+ "--max-lines",
+ type=int,
+ default=8,
+ help="collapse blocks longer than this (0 = never)",
+ )
ap.add_argument("--no-color", action="store_true")
+ ap.add_argument(
+ "--engine",
+ choices=G.ENGINES,
+ default=None,
+ help="layout backend (default: $IDATUI_GRAPH_ENGINE or auto)",
+ )
args = ap.parse_args()
recs = json.load(open(args.corpus))
if args.stats:
- print(f"{'blocks':>7} {'nodes':>6} {'dummy':>6} {'layer':>6} "
- f"{'canvas':>12} {'ms':>8} name")
+ print(
+ f"{'blocks':>7} {'nodes':>6} {'dummy':>6} {'layer':>6} "
+ f"{'canvas':>12} {'ms':>8} name"
+ )
tot = 0.0
for r in sorted(recs, key=lambda r: len(r["blocks"])):
blocks, sizer, _ = build(r, args.max_lines)
- lay = G.layout(blocks, sizer)
+ lay = G.layout(blocks, sizer, engine=args.engine)
s = lay.stats
tot += s["ms"]
- print(f"{s['blocks']:>7} {s['nodes']:>6} {s['dummies']:>6} "
- f"{s['layers']:>6} {lay.width:>5}x{lay.height:<6} "
- f"{s['ms']:>8.1f} {r['name']}")
+ print(
+ f"{s['blocks']:>7} {s['nodes']:>6} {s['dummies']:>6} "
+ f"{s['layers']:>6} {lay.width:>5}x{lay.height:<6} "
+ f"{s['ms']:>8.1f} {r['name']}"
+ )
print(f"total {tot:.0f} ms over {len(recs)} functions")
return 0
if args.func:
rec = next((r for r in recs if r["name"] == args.func), None)
if rec is None:
- print("no such function; have: "
- f"{', '.join(r['name'] for r in recs[:20])}", file=sys.stderr)
+ print(
+ f"no such function; have: {', '.join(r['name'] for r in recs[:20])}",
+ file=sys.stderr,
+ )
return 1
else:
rec = min(recs, key=lambda r: len(r["blocks"]))
blocks, sizer, texts = build(rec, args.max_lines)
- lay = G.layout(blocks, sizer)
+ lay = G.layout(blocks, sizer, engine=args.engine)
print(render(lay, texts, color=not args.no_color))
- print(f"\n{rec['name']}: {lay.stats} canvas {lay.width}x{lay.height}",
- file=sys.stderr)
+ print(
+ f"\n{rec['name']}: {lay.stats} canvas {lay.width}x{lay.height}",
+ file=sys.stderr,
+ )
return 0
diff --git a/experiments/inproc_spike.py b/experiments/inproc_spike.py
index 469aa21..1b12204 100644
--- a/experiments/inproc_spike.py
+++ b/experiments/inproc_spike.py
@@ -25,6 +25,7 @@ Key facts this spike encodes (all verified):
another process, so the TUI's event loop never blocks. In-process, a slow
call (decompile ~150ms, analysis seconds) blocks the UI for its duration.
"""
+
from __future__ import annotations
import argparse
@@ -60,13 +61,21 @@ class DirectBackend:
def __init__(self, path: str) -> None:
import idapro
+
idapro.enable_console_messages(False)
t = time.time()
rc = idapro.open_database(path, run_auto_analysis=True)
self.open_secs = time.time() - t
if rc:
raise RuntimeError(f"open_database({path!r}) failed rc={rc}")
- import ida_bytes, ida_funcs, ida_hexrays, ida_name, idaapi, idautils, idc
+ import ida_bytes
+ import ida_funcs
+ import ida_hexrays
+ import ida_name
+ import idaapi
+ import idautils
+ import idc
+
self._idapro = idapro
self.idaapi, self.idautils, self.idc = idaapi, idautils, idc
self.ida_bytes, self.ida_hexrays = ida_bytes, ida_hexrays
@@ -108,6 +117,7 @@ class McpBackend:
if REPO not in sys.path: # idalib's init can reset sys.path out from under us
sys.path.insert(0, REPO)
from idatui.client import IDAClient
+
self.IDAClient = IDAClient
self.c = IDAClient(url, db=db)
self.c.connect()
@@ -150,8 +160,12 @@ class McpBackend:
return r.get("code", "") if isinstance(r, dict) else str(r)
def xrefs_to(self, ea):
- r = self.c.call("xref_query", queries=[{"addr": hex(ea), "direction": "to",
- "include_fn": True, "count": 2000}])
+ r = self.c.call(
+ "xref_query",
+ queries=[
+ {"addr": hex(ea), "direction": "to", "include_fn": True, "count": 2000}
+ ],
+ )
res = r.get("result", []) if isinstance(r, dict) else []
refs = res[0].get("refs", []) if res and isinstance(res[0], dict) else []
return [int(x["frm"], 16) for x in refs if x.get("frm")]
@@ -200,14 +214,21 @@ def _recv(sock):
return _unpack(_recvn(sock, n))
-_WORKER_OPS = ("functions", "resolve", "read_bytes", "disasm_line",
- "decompile", "xrefs_to")
+_WORKER_OPS = (
+ "functions",
+ "resolve",
+ "read_bytes",
+ "disasm_line",
+ "decompile",
+ "xrefs_to",
+)
def _worker_main(sockpath: str, dbpath: str) -> None:
"""Runs in a child process. Opens idalib on ITS main thread (constraint
satisfied), then serves one client serially over a unix socket."""
import socket as sk
+
direct = DirectBackend(dbpath)
ops = {name: getattr(direct, name) for name in _WORKER_OPS}
try:
@@ -237,10 +258,17 @@ class UnixWorkerBackend:
def __init__(self, dbpath: str) -> None:
import socket as sk
+
self.sockpath = f"/tmp/inproc_spike_{os.getpid()}.sock"
self.proc = __import__("subprocess").Popen(
- [sys.executable, os.path.abspath(__file__),
- "--worker", self.sockpath, dbpath])
+ [
+ sys.executable,
+ os.path.abspath(__file__),
+ "--worker",
+ self.sockpath,
+ dbpath,
+ ]
+ )
deadline = time.time() + 120
self.sock = None
while time.time() < deadline:
@@ -263,12 +291,23 @@ class UnixWorkerBackend:
raise RuntimeError(val)
return val
- def functions(self): return self._call("functions")
- def resolve(self, name): return self._call("resolve", name)
- def read_bytes(self, ea, n): return self._call("read_bytes", ea, n)
- def disasm_line(self, ea): return self._call("disasm_line", ea)
- def decompile(self, ea): return self._call("decompile", ea)
- def xrefs_to(self, ea): return self._call("xrefs_to", ea)
+ def functions(self):
+ return self._call("functions")
+
+ def resolve(self, name):
+ return self._call("resolve", name)
+
+ def read_bytes(self, ea, n):
+ return self._call("read_bytes", ea, n)
+
+ def disasm_line(self, ea):
+ return self._call("disasm_line", ea)
+
+ def decompile(self, ea):
+ return self._call("decompile", ea)
+
+ def xrefs_to(self, ea):
+ return self._call("xrefs_to", ea)
def close(self):
try:
@@ -321,6 +360,7 @@ def bench(target: str, n: int) -> None:
try:
import socket
+
socket.create_connection(("127.0.0.1", 8745), 0.3).close()
backends["mcp"] = McpBackend()
print("benching the running mcp server on :8745 too", flush=True)
@@ -338,15 +378,15 @@ def bench(target: str, n: int) -> None:
return sample[_nx[0]]
ops = {
- "resolve(main)": (lambda b: b.resolve("main"), n),
- "read_bytes(16)": (lambda b: b.read_bytes(main, 16), n),
- "read_bytes(4096)": (lambda b: b.read_bytes(main, 4096), n),
- "disasm_line": (lambda b: b.disasm_line(main), n),
- "xrefs_to": (lambda b: b.xrefs_to(_next_sample()), min(n, 200)),
- "decompile(cached)": (lambda b: b.decompile(main), min(n, 40)),
+ "resolve(main)": (lambda b: b.resolve("main"), n),
+ "read_bytes(16)": (lambda b: b.read_bytes(main, 16), n),
+ "read_bytes(4096)": (lambda b: b.read_bytes(main, 4096), n),
+ "disasm_line": (lambda b: b.disasm_line(main), n),
+ "xrefs_to": (lambda b: b.xrefs_to(_next_sample()), min(n, 200)),
+ "decompile(cached)": (lambda b: b.decompile(main), min(n, 40)),
}
names = list(backends)
- hdr = f"{'op':20}" + "".join(f"{nm+' us':>14}" for nm in names)
+ hdr = f"{'op':20}" + "".join(f"{nm + ' us':>14}" for nm in names)
print(hdr)
print("-" * len(hdr))
results: dict[str, dict[str, float]] = {nm: {} for nm in names}
@@ -371,7 +411,7 @@ def bench(target: str, n: int) -> None:
if nm == "mcp":
continue
v = results[nm].get(label, float("nan"))
- parts.append(f"{nm} {m/v:.0f}x" if v == v and v else f"{nm} -")
+ parts.append(f"{nm} {m / v:.0f}x" if v == v and v else f"{nm} -")
print(f" {label:20} {' '.join(parts)}")
for b in backends.values():
@@ -408,10 +448,12 @@ def _build_spike_app(backend: "DirectBackend"):
for ea, nm in backend.functions():
ol.add_option(Option(f"{ea:08x} {nm}", id=str(ea)))
yield ol
- yield Static("select a function, press F5 to decompile inline",
- id="code")
- yield Static("in-process idalib — every call runs on the UI thread",
- id="status")
+ yield Static(
+ "select a function, press F5 to decompile inline", id="code"
+ )
+ yield Static(
+ "in-process idalib — every call runs on the UI thread", id="status"
+ )
yield Footer()
def on_mount(self):
@@ -428,13 +470,13 @@ def _build_spike_app(backend: "DirectBackend"):
if ea is None:
return
t = time.perf_counter()
- code = backend.decompile(ea) # <-- BLOCKS the event loop
+ code = backend.decompile(ea) # <-- BLOCKS the event loop
dt = (time.perf_counter() - t) * 1e3
- self.query_one("#code", Static).update(
- "\n".join(code.splitlines()[:40]))
+ self.query_one("#code", Static).update("\n".join(code.splitlines()[:40]))
self.query_one("#status", Static).update(
f"decompiled {backend.idc.get_func_name(ea)} in {dt:.0f} ms "
- f"(UI was frozen for those {dt:.0f} ms)")
+ f"(UI was frozen for those {dt:.0f} ms)"
+ )
def action_decompile_all(self):
funcs = backend.functions()
@@ -442,14 +484,15 @@ def _build_spike_app(backend: "DirectBackend"):
n = 0
for ea, _ in funcs:
try:
- backend.decompile(ea) # <-- long, uninterruptible freeze
+ backend.decompile(ea) # <-- long, uninterruptible freeze
n += 1
except Exception: # noqa: BLE001
pass
dt = (time.perf_counter() - t) * 1e3
self.query_one("#status", Static).update(
f"decompiled {n} funcs in {dt:.0f} ms — the whole UI was frozen "
- f"the entire time (no spinner, no input)")
+ f"the entire time (no spinner, no input)"
+ )
def action_bytes(self):
ea = self._sel_ea()
@@ -459,7 +502,8 @@ def _build_spike_app(backend: "DirectBackend"):
b = backend.read_bytes(ea, 64)
dt = (time.perf_counter() - t) * 1e6
self.query_one("#status", Static).update(
- f"read 64 bytes in {dt:.1f} us: {b[:16].hex()}…")
+ f"read 64 bytes in {dt:.1f} us: {b[:16].hex()}…"
+ )
return Spike()
@@ -472,7 +516,9 @@ def _open_copy(target: str) -> "DirectBackend":
os.remove(tmp + e)
except OSError:
pass
- print("opening in-process (blocks the terminal until analysis is done)…", flush=True)
+ print(
+ "opening in-process (blocks the terminal until analysis is done)…", flush=True
+ )
return DirectBackend(tmp) # MAIN THREAD open, before the event loop starts
diff --git a/experiments/modal_shot.py b/experiments/modal_shot.py
index 2e6cd7a..82f1e2f 100644
--- a/experiments/modal_shot.py
+++ b/experiments/modal_shot.py
@@ -11,6 +11,7 @@ IDA and no .i64 -- it runs under any python with textual, in about a second.
The app it boots is a bare `App` carrying `IdaTui.CSS` and the app theme, which
is exactly what the modals resolve their styles against.
"""
+
import asyncio
import os
import sys
@@ -18,16 +19,23 @@ import sys
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, REPO)
-from textual.app import App, ComposeResult # noqa: E402
-from textual.widgets import Static # noqa: E402
+from textual.app import App, ComposeResult # noqa: E402
+from textual.widgets import Static # noqa: E402
-from idatui.app import ( # noqa: E402
- IDATUI_THEME, IdaTui, BusyScreen, ConfirmScreen, HelpScreen, LoadingScreen,
- QuitScreen, StructEditor, XrefsScreen,
+from idatui._sync import wait_for # noqa: E402
+from idatui.app import ( # noqa: E402
+ IDATUI_THEME,
+ BusyScreen,
+ ConfirmScreen,
+ HelpScreen,
+ IdaTui,
+ LoadingScreen,
+ QuitScreen,
+ StructEditor,
+ XrefsScreen,
)
-from idatui.domain import Struct # noqa: E402
-from idatui.rpc import screen_text # noqa: E402
-from idatui._sync import wait_for # noqa: E402
+from idatui.domain import Struct # noqa: E402
+from idatui.rpc import screen_text # noqa: E402
class _StubProgram:
@@ -36,14 +44,15 @@ class _StubProgram:
_STRUCTS = [
Struct(name="timespec", size=0x10, members=2, is_union=False, ordinal=1),
Struct(name="stat", size=0x90, members=15, is_union=False, ordinal=2),
- Struct(name="pthread_mutex_t", size=0x28, members=4, is_union=True,
- ordinal=3),
+ Struct(name="pthread_mutex_t", size=0x28, members=4, is_union=True, ordinal=3),
]
- _SRC = ("struct timespec\n"
- "{\n"
- " __time_t tv_sec; /* seconds */\n"
- " __syscall_slong_t tv_nsec;\n"
- "};\n")
+ _SRC = (
+ "struct timespec\n"
+ "{\n"
+ " __time_t tv_sec; /* seconds */\n"
+ " __syscall_slong_t tv_nsec;\n"
+ "};\n"
+ )
def list_structs(self):
return list(self._STRUCTS)
@@ -57,8 +66,7 @@ def _make(name: str):
if name == "structs":
return StructEditor(_StubProgram())
if name == "confirm":
- return ConfirmScreen("Delete struct 'timespec' ?",
- "This cannot be undone.")
+ return ConfirmScreen("Delete struct 'timespec' ?", "This cannot be undone.")
if name == "quit":
return QuitScreen(["echo", "libc.so.6"])
if name == "help":
@@ -68,10 +76,13 @@ def _make(name: str):
if name == "loading":
return LoadingScreen("echo", "opening database\u2026")
if name == "xrefs":
- return XrefsScreen(" xrefs to main", [
- (0x1234, " sub_2297+0x1c call main"),
- (0x5678, " _start+0x21 mov rdi, main"),
- ])
+ return XrefsScreen(
+ " xrefs to main",
+ [
+ (0x1234, " sub_2297+0x1c call main"),
+ (0x5678, " _start+0x21 mov rdi, main"),
+ ],
+ )
raise SystemExit(f"unknown modal {name!r}; --list to see them")
@@ -84,8 +95,9 @@ class _Shot(App):
def compose(self) -> ComposeResult:
# A little content underneath, so the modal's edge is visible against
# something rather than floating on an empty screen.
- yield Static("\n".join(" .... the view behind the dialog ...."
- for _ in range(60)))
+ yield Static(
+ "\n".join(" .... the view behind the dialog ...." for _ in range(60))
+ )
def on_mount(self) -> None:
self.register_theme(IDATUI_THEME)
@@ -108,12 +120,14 @@ async def main() -> None:
await pilot.pause()
if name == "structs":
await wait_for(
- lambda: bool(getattr(app.screen, "_structs", None)),
- pilot.pause, 5)
+ lambda: bool(getattr(app.screen, "_structs", None)), pilot.pause, 5
+ )
app.screen.on_option_list_option_selected(
- type("E", (), {"option_index": 0})())
- await wait_for(lambda: "{" in app.screen.query_one(
- "#se-edit").text, pilot.pause, 5)
+ type("E", (), {"option_index": 0})()
+ )
+ await wait_for(
+ lambda: "{" in app.screen.query_one("#se-edit").text, pilot.pause, 5
+ )
await pilot.pause()
print(f"\n=== {name} " + "=" * (cols - len(name) - 5))
print(screen_text(app)["text"])
diff --git a/experiments/opfmt_tools.py b/experiments/opfmt_tools.py
index 535688c..7654677 100644
--- a/experiments/opfmt_tools.py
+++ b/experiments/opfmt_tools.py
@@ -8,6 +8,7 @@ does to the database, what Hex-Rays will and won't render.
python3 experiments/opfmt_tools.py # 29 checks, ~40s
"""
+
import importlib.util
import os
import shutil
@@ -30,15 +31,21 @@ if os.path.exists(seed):
shutil.copy(seed, tmp + ".i64")
import idapro # noqa: E402
+
idapro.enable_console_messages(False)
assert idapro.open_database(tmp, run_auto_analysis=True) == 0
import ida_auto # noqa: E402
+
ida_auto.auto_wait()
-import ida_typeinf, idaapi, ida_bytes, ida_lines # noqa: E402,F401
+import ida_bytes
+import ida_lines
+import ida_typeinf # noqa: E402,F401
+import idaapi
spec = importlib.util.spec_from_file_location(
- "_patch", os.path.join(REPO, "server", "patch_server.py"))
+ "_patch", os.path.join(REPO, "server", "patch_server.py")
+)
patch = importlib.util.module_from_spec(spec)
spec.loader.exec_module(patch)
@@ -101,14 +108,25 @@ while e < ea + 0x400:
if target:
break
e = ida_bytes.next_head(e, ea + 0x800)
-print("target:", hex(target[0]), "n=", target[1], "value", hex(target[2]),
- "|", line(target[0]))
+print(
+ "target:",
+ hex(target[0]),
+ "n=",
+ target[1],
+ "value",
+ hex(target[2]),
+ "|",
+ line(target[0]),
+)
tea, tn, tv = target
r = op_format(addr=hex(tea), mode="show")
print(" show:", r)
-check("show reports the operand and its choices",
- r.get("n") == tn and "hex" in r.get("choices", []), str(r))
+check(
+ "show reports the operand and its choices",
+ r.get("n") == tn and "hex" in r.get("choices", []),
+ str(r),
+)
check("show doesn't change anything", r.get("applied") is False, str(r))
seen = []
@@ -116,15 +134,25 @@ for i in range(8):
r = op_format(addr=hex(tea), mode="cycle")
seen.append((r.get("format"), r.get("text")))
print(f" cycle -> {r.get('prev')} -> {r.get('format')}: {r.get('text')}")
-check("cycling returns to where it started",
- seen[0][0] == seen[len(r.get("choices", []))][0]
- if len(seen) > len(r.get("choices", [])) else True, str(seen))
-check("decimal renders differently from hex",
- any(s[1] != seen[0][1] for s in seen), str(seen))
+check(
+ "cycling returns to where it started",
+ seen[0][0] == seen[len(r.get("choices", []))][0]
+ if len(seen) > len(r.get("choices", []))
+ else True,
+ str(seen),
+)
+check(
+ "decimal renders differently from hex",
+ any(s[1] != seen[0][1] for s in seen),
+ str(seen),
+)
r = op_format(addr=hex(tea), mode="dec")
-check("explicit dec sticks", r.get("format") == "dec" and str(tv) in r.get("text", ""),
- str(r))
+check(
+ "explicit dec sticks",
+ r.get("format") == "dec" and str(tv) in r.get("text", ""),
+ str(r),
+)
r = op_format(addr=hex(tea), mode="back")
print(" back ->", r.get("format"), r.get("text"))
check("back steps the ring the other way", r.get("format") == "hex", str(r))
@@ -142,29 +170,55 @@ while e < ea + 0x800:
if off_target:
break
e = ida_bytes.next_head(e, ea + 0x800)
-print("target:", off_target and hex(off_target[0]), "|",
- off_target and line(off_target[0]))
+print(
+ "target:",
+ off_target and hex(off_target[0]),
+ "|",
+ off_target and line(off_target[0]),
+)
if off_target:
import ida_name # noqa: E402
+
oe, on = off_target
v, _w = NS["_idatui_op_value"](oe, on)
named = bool(ida_name.get_ea_name(v))
r = op_format(addr=hex(oe), n=on, mode="show")
- print(" ", hex(oe), "n=", on, "|", r.get("text"), r.get("choices"),
- "target named:", named)
- check("an unnamed target is not a cycle stop (it would invent a name)",
- ("offset" in r.get("choices", [])) == named, str(r))
+ print(
+ " ",
+ hex(oe),
+ "n=",
+ on,
+ "|",
+ r.get("text"),
+ r.get("choices"),
+ "target named:",
+ named,
+ )
+ check(
+ "an unnamed target is not a cycle stop (it would invent a name)",
+ ("offset" in r.get("choices", [])) == named,
+ str(r),
+ )
r = op_format(addr=hex(oe), n=on, mode="offset")
print(" offset ->", r.get("text"))
- check("but asking explicitly makes the reference",
- r.get("format") == "offset" and "offset" in r.get("text", ""), str(r))
+ check(
+ "but asking explicitly makes the reference",
+ r.get("format") == "offset" and "offset" in r.get("text", ""),
+ str(r),
+ )
r = op_format(addr=hex(oe), n=on, mode="show")
- check("and from then on the ring includes it",
- "offset" in r.get("choices", []), str(r))
+ check(
+ "and from then on the ring includes it",
+ "offset" in r.get("choices", []),
+ str(r),
+ )
r = op_format(addr=hex(oe), n=on, mode="hex")
print(" hex ->", r.get("text"))
- check("and back to a number", r.get("format") == "hex"
- and "offset" not in r.get("text", ""), str(r))
+ check(
+ "and back to a number",
+ r.get("format") == "hex" and "offset" not in r.get("text", ""),
+ str(r),
+ )
op_format(addr=hex(oe), n=on, mode="default")
else:
print(" (no literal-that-is-an-address in this function)")
@@ -175,22 +229,32 @@ spans = NS["_idatui_op_spans"](tea, txt)
print(" text:", repr(txt), "spans:", spans)
if len(spans) >= 2:
r = op_format(addr=hex(tea), col=spans[0][0], mode="show")
- check("a column inside operand 0 picks operand 0 (or the first literal)",
- r.get("n") in (spans[0][2], NS["_idatui_op_candidates"](tea)[0]), str(r))
+ check(
+ "a column inside operand 0 picks operand 0 (or the first literal)",
+ r.get("n") in (spans[0][2], NS["_idatui_op_candidates"](tea)[0]),
+ str(r),
+ )
r = op_format(addr=hex(tea), col=spans[-1][0], mode="show")
- check("a column inside the last operand picks it", r.get("n") == spans[-1][2],
- str(r))
+ check(
+ "a column inside the last operand picks it", r.get("n") == spans[-1][2], str(r)
+ )
print("\n=== listing: an unmapped value refuses to become an offset ===")
r = op_format(addr=hex(tea), n=tn, mode="offset")
print(" ", r.get("error") or r)
-check("offset on a non-address is refused, not invented",
- bool(r.get("error")) or ida_bytes.is_mapped(tv), str(r))
+check(
+ "offset on a non-address is refused, not invented",
+ bool(r.get("error")) or ida_bytes.is_mapped(tv),
+ str(r),
+)
print("\n=== listing: char is only offered when it renders as one ===")
r = op_format(addr=hex(tea), n=tn, mode="show")
-check("0x%x isn't offered as a char" % tv,
- ("char" in r["choices"]) == NS["_idatui_printable"](tv), str(r))
+check(
+ "0x%x isn't offered as a char" % tv,
+ ("char" in r["choices"]) == NS["_idatui_printable"](tv),
+ str(r),
+)
print("\n=== listing: a stack variable can be cycled AND put back ===")
stk = None
@@ -208,23 +272,32 @@ if stk:
orig = line(se)
r = op_format(addr=hex(se), n=sn, mode="cycle")
print(" cycle ->", r.get("format"), r.get("text"), "|", r.get("warn"))
- check("leaving a stack variable says so, and how to undo it",
- "stack" in (r.get("warn") or ""), str(r))
- ring = [op_format(addr=hex(se), n=sn, mode="cycle")
- for _ in range(len(r["choices"]))]
+ check(
+ "leaving a stack variable says so, and how to undo it",
+ "stack" in (r.get("warn") or ""),
+ str(r),
+ )
+ ring = [
+ op_format(addr=hex(se), n=sn, mode="cycle") for _ in range(len(r["choices"]))
+ ]
print(" ring:", [(x["format"], x["text"]) for x in ring])
- check("the ring is the same at every step (a lap comes home)",
- [x["format"] for x in ring] == r["choices"][1:] + r["choices"][:1],
- f"{[x['format'] for x in ring]} vs {r['choices']}")
+ check(
+ "the ring is the same at every step (a lap comes home)",
+ [x["format"] for x in ring] == r["choices"][1:] + r["choices"][:1],
+ f"{[x['format'] for x in ring]} vs {r['choices']}",
+ )
r = op_format(addr=hex(se), n=sn, mode="stack")
- check("'stack' puts the frame variable back",
- r.get("format") == "stack" and r.get("text") == orig,
- f"{r.get('text')!r} want {orig!r}")
+ check(
+ "'stack' puts the frame variable back",
+ r.get("format") == "stack" and r.get("text") == orig,
+ f"{r.get('text')!r} want {orig!r}",
+ )
else:
print(" (no stack-variable operand found)")
print("\n=== data item ===")
import ida_segment # noqa: E402
+
seg = ida_segment.get_segm_by_name(".data")
if seg:
de = seg.start_ea
@@ -257,8 +330,11 @@ while e < seg.end_ea and ue is None:
if ue is not None:
r = op_format(addr=hex(ue), mode="cycle")
print(" ", hex(ue), "->", r.get("error"))
- check("undefined bytes are refused with the fix, not a silent no-op",
- "define" in (r.get("error") or ""), str(r))
+ check(
+ "undefined bytes are refused with the fix, not a silent no-op",
+ "define" in (r.get("error") or ""),
+ str(r),
+ )
else:
print(" (no undefined bytes)")
@@ -268,6 +344,7 @@ print(" show without a line:", r.get("error"))
check("no line is an error, not a guess", bool(r.get("error")), str(r))
import ida_hexrays # noqa: E402
+
cf = ida_hexrays.decompile(parse_address("main"))
sv = cf.get_pseudocode()
pcline = None
@@ -276,8 +353,13 @@ for i in range(len(sv)):
if nums and nums[0]["value"] > 9:
pcline = (i, nums[0])
break
-print(" line", pcline[0], repr(ida_lines.tag_remove(sv[pcline[0]].line).strip()),
- "num:", pcline[1])
+print(
+ " line",
+ pcline[0],
+ repr(ida_lines.tag_remove(sv[pcline[0]].line).strip()),
+ "num:",
+ pcline[1],
+)
i, num = pcline
r = pc_num_format(addr="main", line=i, mode="show")
check("show finds the literal", r.get("ea") == hex(num["ea"]), str(r))
@@ -293,8 +375,11 @@ print(" bin ->", r.get("error"))
check("binary is refused with a reason", bool(r.get("error")), str(r))
r = pc_num_format(addr="main", line=i, mode="default")
print(" default ->", r.get("text"))
-check("default restores Hex-Rays' own choice",
- r.get("text") == ida_lines.tag_remove(sv[i].line).strip(), str(r))
+check(
+ "default restores Hex-Rays' own choice",
+ r.get("text") == ida_lines.tag_remove(sv[i].line).strip(),
+ str(r),
+)
r = pc_num_format(addr="main", line=i, mode="show")
check("and the format reads back as default", r.get("format") == "default", str(r))
print("\n=== pseudocode: the ring visits every stop ===")
@@ -303,10 +388,16 @@ for _ in range(len(r.get("choices", [])) * 2):
rr = pc_num_format(addr="main", line=i, mode="cycle")
ring.append((rr.get("format"), rr.get("text")))
print(" ", [x[0] for x in ring])
-check("every stop in the ring is reached",
- set(x[0] for x in ring) == set(r["choices"]), f"{ring} vs {r['choices']}")
-check("the ring's renderings are distinct",
- len({x[1] for x in ring}) >= len(r["choices"]) - 1, str(ring))
+check(
+ "every stop in the ring is reached",
+ set(x[0] for x in ring) == set(r["choices"]),
+ f"{ring} vs {r['choices']}",
+)
+check(
+ "the ring's renderings are distinct",
+ len({x[1] for x in ring}) >= len(r["choices"]) - 1,
+ str(ring),
+)
pc_num_format(addr="main", line=i, mode="default")
print("\n=== pseudocode: col picks the literal ===")
@@ -327,9 +418,12 @@ if multi:
col = len(NS["_idatui_compact"](plain[:x]).rstrip()) if x else 0
r = pc_num_format(addr="main", line=k, col=col, mode="show")
print(" col", col, "->", r.get("ea"), r.get("value"))
- check("a column selects the number under it",
- r.get("value") == hex(nums[1]["value"])
- or r.get("value") == hex(nums[0]["value"]), str(r))
+ check(
+ "a column selects the number under it",
+ r.get("value") == hex(nums[1]["value"])
+ or r.get("value") == hex(nums[0]["value"]),
+ str(r),
+ )
else:
print(" (no line with two literals)")
@@ -342,8 +436,11 @@ if row and row.get("ops"):
t = row["text"]
for lo, hi, n in row["ops"]:
print(f" op{n}: {t[lo:hi]!r}")
- check("the extents index the row's own text",
- all(t[lo:hi].strip() for lo, hi, n in row["ops"]), str(row["ops"]))
+ check(
+ "the extents index the row's own text",
+ all(t[lo:hi].strip() for lo, hi, n in row["ops"]),
+ str(row["ops"]),
+ )
# and they agree with what op_format picks for a column inside them
ok = True
for lo, hi, n in row["ops"]:
@@ -361,8 +458,11 @@ if regop:
lo, hi, n = regop
r = op_format(addr=hex(tea), col=(lo + hi) // 2, mode="cycle")
print(" ", repr(row["text"][lo:hi]), "->", r.get("error"))
- check("it names the operand and the one that CAN change",
- bool(r.get("error")) and "operand" in r["error"], str(r))
+ check(
+ "it names the operand and the one that CAN change",
+ bool(r.get("error")) and "operand" in r["error"],
+ str(r),
+ )
else:
print(" (this instruction has no register-only operand)")
@@ -376,15 +476,24 @@ for rec in pn["nums"]:
two = next((v for v in multi2.values() if len(v) >= 2), None)
if two:
import ida_hexrays as _hx
+
cf2 = _hx.decompile(parse_address("main"))
- disp = NS["_idatui_compact"](ida_lines.tag_remove(cf2.get_pseudocode()[two[0]["line"]].line))
+ disp = NS["_idatui_compact"](
+ ida_lines.tag_remove(cf2.get_pseudocode()[two[0]["line"]].line)
+ )
print(" line:", repr(disp.strip()))
for rec in two:
- print(f" x{rec['x0']}..{rec['x1']} = {disp[rec['x0']:rec['x1']]!r} value {rec['value']}")
- check("spans land on the literals in the DISPLAYED text",
- all(disp[r0["x0"]:r0["x1"]].strip() for r0 in two), str(two))
- check("distinct literals get distinct spans",
- two[0]["x0"] != two[1]["x0"], str(two))
+ print(
+ f" x{rec['x0']}..{rec['x1']} = {disp[rec['x0'] : rec['x1']]!r} value {rec['value']}"
+ )
+ check(
+ "spans land on the literals in the DISPLAYED text",
+ all(disp[r0["x0"] : r0["x1"]].strip() for r0 in two),
+ str(two),
+ )
+ check(
+ "distinct literals get distinct spans", two[0]["x0"] != two[1]["x0"], str(two)
+ )
print(f"\n{OK} passed, {FAIL} failed")
idapro.close_database(save=False)
diff --git a/experiments/profile_client.py b/experiments/profile_client.py
new file mode 100644
index 0000000..b03ed46
--- /dev/null
+++ b/experiments/profile_client.py
@@ -0,0 +1,76 @@
+"""Profile the CLIENT half of streaming a segment.
+
+`profile_remote.py` profiles inside the database process. This one profiles the
+other side: unpickling a page, building Heads and maintaining the model's
+indexes. Once the backend got cheap that half became the majority of boot, and
+nothing else here can see it.
+
+ PYTHONPATH=. ~/ida-venv/bin/python experiments/profile_client.py [BINARY] [--pages N]
+
+Time spent in `invoke` is the backend + transport; everything below it in the
+`tottime` list is ours and is what this file is for.
+"""
+
+from __future__ import annotations
+
+import argparse
+import cProfile
+import io
+import os
+import pstats
+import time
+
+from idatui import remote_ops
+from idatui.domain import Program
+from idatui.nexus_client import NexusClient
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser()
+ ap.add_argument("binary", nargs="?", default="targets/bash")
+ ap.add_argument("--pages", type=int, default=60)
+ ap.add_argument("--lines", type=int, default=16)
+ ap.add_argument(
+ "--text", action="store_true", help="load full pages instead of skeletons"
+ )
+ args = ap.parse_args()
+
+ client = NexusClient(os.path.abspath(args.binary))
+ client.connect()
+ program = Program(client)
+ regions = client.call(remote_ops.file_regions)
+ rows = regions.get("regions") or regions.get("result") or []
+ text_seg = next((r for r in rows if ".text" in str(r.get("name", ""))), rows[0])
+ model = program.listing(int(str(text_seg["start"]), 16))
+ assert model is not None
+ model.load_next_page() # prime one page, and install the remote lib
+
+ want_text = bool(args.text)
+ pr = cProfile.Profile()
+ started = time.perf_counter()
+ pr.enable()
+ loaded = 0
+ for _ in range(args.pages):
+ if model.complete:
+ break
+ n = model.load_next_page(text=want_text)
+ if n == 0:
+ break
+ loaded += 1
+ pr.disable()
+ wall = (time.perf_counter() - started) * 1000
+
+ print(
+ f"# {os.path.basename(args.binary)} pages={loaded} "
+ f"text={want_text} {wall:.0f}ms ({wall / max(loaded, 1):.2f}ms/page)"
+ )
+ buf = io.StringIO()
+ pstats.Stats(pr, stream=buf).sort_stats("tottime").print_stats(args.lines)
+ print(buf.getvalue())
+ program.close()
+ client.close()
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/experiments/profile_remote.py b/experiments/profile_remote.py
new file mode 100644
index 0000000..fbf17dc
--- /dev/null
+++ b/experiments/profile_remote.py
@@ -0,0 +1,73 @@
+"""Profile persistent ida-tui operations inside the IDA process.
+
+The profiler itself is a typed ``RemoteModule`` function in ``remote_tools.py``;
+this file contains no generated Python source or knowledge of remote module names.
+
+Usage::
+
+ PYTHONPATH=. python experiments/profile_remote.py [BINARY]
+ PYTHONPATH=. python experiments/profile_remote.py --op decompile
+"""
+
+from __future__ import annotations
+
+import argparse
+import os
+
+from idatui import remote_ops
+from idatui.nexus_client import NexusClient
+
+CALLS = {
+ "heads": ("heads", {"count": 500, "annotate": True}),
+ "heads_plain": ("heads", {"count": 500, "annotate": False}),
+ "heads_skeleton": (
+ "heads",
+ {"count": 500, "annotate": True, "text": False},
+ ),
+ "decompile": ("decompile", {}),
+}
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("binary", nargs="?", default="targets/bash")
+ parser.add_argument("--op", default="heads", choices=sorted(CALLS))
+ parser.add_argument("--reps", type=int, default=20)
+ parser.add_argument("--addr", default=None, help="default: the .text start")
+ args = parser.parse_args()
+
+ client = NexusClient(os.path.abspath(args.binary)).connect()
+ addr = args.addr
+ if addr is None:
+ regions = client.call(remote_ops.file_regions)
+ rows = regions.get("regions") or regions.get("result") or []
+ text = next(
+ (row for row in rows if ".text" in str(row.get("name", ""))),
+ None,
+ )
+ addr = (text or rows[0])["start"] if rows else "0x0"
+
+ operation, call_args = CALLS[args.op]
+ call_args = {"addr": addr, **call_args}
+ print(
+ f"# {os.path.basename(args.binary)} op={args.op} "
+ f"addr={addr} reps={args.reps}"
+ )
+
+ # Install the persistent tool module and warm its caches before profiling.
+ client.call(remote_ops.heads, addr=addr, count=500, annotate=True)
+ out = client.call(
+ remote_ops.profile_remote,
+ operation=operation,
+ args=call_args,
+ reps=args.reps,
+ )
+ per = out["total"] / out["reps"] * 1000
+ print(f"# {out['total'] * 1000:.0f}ms total, {per:.1f}ms per call\n")
+ print(out["stats"])
+ client.close()
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/experiments/splash_place_count.py b/experiments/splash_place_count.py
new file mode 100644
index 0000000..4490b9e
--- /dev/null
+++ b/experiments/splash_place_count.py
@@ -0,0 +1,102 @@
+#!/usr/bin/env python
+"""Count what the splash actually SENDS to the terminal.
+
+Pushes a real LoadingScreen onto a bare Textual app with kitty graphics forced
+on and kittygfx._write captured, then drives it the way the app does (a status
+write per progress tick) and tallies the escapes.
+
+ PYTHONPATH=. ~/ida-venv/bin/python experiments/splash_place_count.py
+"""
+
+from __future__ import annotations
+
+import asyncio
+import os
+import re
+import sys
+
+os.environ["IDATUI_KITTY"] = "1"
+
+from textual.app import App, ComposeResult # noqa: E402
+from textual.widgets import Static # noqa: E402
+
+from idatui import kittygfx # noqa: E402
+
+SENT: list[str] = []
+
+
+def _fake_write(data: str) -> bool:
+ SENT.append(data)
+ return True
+
+
+kittygfx._write = _fake_write # type: ignore[assignment]
+kittygfx._cell = (9, 22)
+
+from idatui.app import LoadingScreen # noqa: E402
+
+
+def tally() -> dict[str, int]:
+ blob = "".join(SENT)
+ return {
+ "uploads (a=t)": len(re.findall(r"\x1b_G[^;]*a=t", blob)),
+ "placements (a=p)": len(re.findall(r"\x1b_G[^;]*a=p", blob)),
+ "deletes (a=d)": len(re.findall(r"\x1b_G[^;]*a=d", blob)),
+ "bytes": len(blob),
+ }
+
+
+class Host(App):
+ CSS = "#loading-box { width: 70; height: auto; }"
+
+ def compose(self) -> ComposeResult:
+ yield Static("host")
+
+
+async def main() -> None:
+ ticks = int(sys.argv[1]) if len(sys.argv) > 1 else 40
+ app = Host()
+ async with app.run_test(size=(100, 45)) as pilot:
+ screen = LoadingScreen("target")
+ app.push_screen(screen)
+ await pilot.pause()
+ await pilot.pause()
+ print(f"image mode: {screen._image}")
+ after_mount = tally()
+ print("after mount:", after_mount)
+
+ # what the app does: _status() -> loading_screen.update_note(), once
+ # per progress write. Spread over ~4s of wall clock like a real load.
+ for i in range(ticks):
+ screen.update_note(f"analyzing… {i}")
+ await asyncio.sleep(0.1)
+ await pilot.pause()
+ print(f"after {ticks} progress notes:", tally())
+
+ screen.dismiss()
+ await pilot.pause()
+ print("after dismiss:", tally())
+
+ d = tally()
+ blob = "".join(SENT)
+ cmds = re.findall(r"\x1b_G([^;\x1b]*)", blob)
+ ids = {
+ dict(kv.split("=", 1) for kv in c.split(",") if "=" in kv).get("p")
+ for c in cmds
+ if "a=p" in c.split(",")
+ }
+ onscreen = "unbounded (anonymous)" if None in ids else len(ids)
+ print()
+ print(
+ f"=> {d['placements (a=p)']} place escapes sent, {d['deletes (a=d)']} deletes"
+ )
+ print(f" images actually on screen: {onscreen}")
+ print(" A placement is identified by (image id, placement id). An a=p with")
+ print(" no p= key is ANONYMOUS and stacks a fresh copy every time; with a")
+ print(" p= key the terminal replaces the previous one. logo.png is RGBA, so")
+ print(" stacking also composites its soft edges towards solid.")
+ m = re.search(r"\x1b_G(a=p[^;\x1b]*)", blob)
+ print(" placement escape:", m.group(1) if m else "(none)")
+
+
+asyncio.run(main())
diff --git a/experiments/worker_smoke.py b/experiments/worker_smoke.py
index 9b55138..08799a4 100644
--- a/experiments/worker_smoke.py
+++ b/experiments/worker_smoke.py
@@ -1,23 +1,26 @@
-"""Exercise the real domain.Program through an IDA Code Mode lease.
+"""Exercise the real domain.Program through an IDA Nexus lease.
-A matching registered GUI is reused; otherwise Code Mode starts a managed
+A matching registered GUI is reused; otherwise IDA Nexus starts a managed
idalib worker. Usage: ``uv run python experiments/worker_smoke.py FILE``.
"""
+
from __future__ import annotations
import os
import sys
import time
-from idatui.codemode_client import CodeModeClient
from idatui.domain import Program
+from idatui.nexus_client import NexusClient
def main() -> int:
- target = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else "experiments/fibonacci.elf")
- print(f"attaching Code Mode to {target}…", flush=True)
+ target = os.path.abspath(
+ sys.argv[1] if len(sys.argv) > 1 else "experiments/fibonacci.elf"
+ )
+ print(f"attaching IDA Nexus to {target}…", flush=True)
started = time.time()
- client = CodeModeClient(target)
+ client = NexusClient(target)
client.connect(progress=lambda message: print(f" {message}", flush=True))
print(
f" ready in {time.time() - started:.2f}s; backend={client.backend}; "
diff --git a/ida-tui b/ida-tui
index a488cac..de60a45 100755
--- a/ida-tui
+++ b/ida-tui
@@ -4,7 +4,7 @@
# ./ida-tui foo.elf # open a binary and drive it — that's it
#
# The launcher leases a registered IDA GUI or shared managed idalib worker
-# through ida_codemode. The selected Python must have ida-tui's dependencies;
+# through ida_nexus. The selected Python must have ida-tui's dependencies;
# override it with $IDATUI_PYTHON.
set -eu
diff --git a/idatui/__init__.py b/idatui/__init__.py
index 7da28b3..201fac6 100644
--- a/idatui/__init__.py
+++ b/idatui/__init__.py
@@ -1,31 +1,31 @@
-"""idatui — a keyboard-first TUI using shared IDA Code Mode databases."""
+"""idatui — a keyboard-first TUI using shared IDA Nexus databases."""
-from .errors import (
- IDAError,
- IDAConnectionError,
- IDATimeoutError,
- IDAProtocolError,
- IDARPCError,
- IDAToolError,
- IDASessionError,
- Session,
-)
-from .codemode_client import CodeModeClient
from .domain import (
- Program,
- FunctionIndex,
+ DISASM_BLOCK,
+ LIST_PAGE,
+ Decompilation,
DisasmModel,
Func,
+ FunctionIndex,
Line,
+ Program,
Ref,
Struct,
- Decompilation,
- LIST_PAGE,
- DISASM_BLOCK,
)
+from .errors import (
+ IDAConnectionError,
+ IDAError,
+ IDAProtocolError,
+ IDARPCError,
+ IDASessionError,
+ IDATimeoutError,
+ IDAToolError,
+ Session,
+)
+from .nexus_client import NexusClient
__all__ = [
- "CodeModeClient",
+ "NexusClient",
"Program",
"FunctionIndex",
"DisasmModel",
diff --git a/idatui/__main__.py b/idatui/__main__.py
index 0090ace..31c1da9 100644
--- a/idatui/__main__.py
+++ b/idatui/__main__.py
@@ -1,4 +1,5 @@
"""``python -m idatui`` -> the one-shot launcher (open a binary in the TUI)."""
+
import sys
from .launch import main
diff --git a/idatui/_sync.py b/idatui/_sync.py
index b7da338..0b2d9a2 100644
--- a/idatui/_sync.py
+++ b/idatui/_sync.py
@@ -8,6 +8,7 @@ Two yield strategies feed the same poll loop: under a Pilot (tests) we yield wit
``pilot.pause`` (which also drains the screen); live (RPC) we yield with
``asyncio.sleep`` and drain explicitly via a throwaway ``Pilot(app)``.
"""
+
from __future__ import annotations
import asyncio
diff --git a/idatui/app.py b/idatui/app.py
index bbf8e4e..75dc9d1 100644
--- a/idatui/app.py
+++ b/idatui/app.py
@@ -10,7 +10,7 @@ Design notes:
without ever materializing 52k lines in a widget.
* All network/domain work runs in Textual worker threads; the UI never blocks.
* An address-history stack backs Enter (follow) / Esc (back), IDA-style.
-* Database lifecycle is lease-based through ida_codemode: matching GUI sessions
+* Database lifecycle is lease-based through ida_nexus: matching GUI sessions
are reused, otherwise a shared managed idalib worker is opened on demand.
"""
@@ -20,6 +20,7 @@ import asyncio
import os
import re
import subprocess
+import sys
import time
from dataclasses import dataclass, field
from enum import StrEnum
@@ -36,27 +37,28 @@ from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.geometry import Region, Size
from textual.message import Message
from textual.reactive import reactive
-from textual.theme import Theme
from textual.screen import ModalScreen
from textual.scroll_view import ScrollView
from textual.strip import Strip
+from textual.theme import Theme
from textual.widgets import (
- DataTable, Input, OptionList, Static, TextArea,
+ DataTable,
+ Input,
+ OptionList,
+ Static,
+ TextArea,
)
from textual.widgets.option_list import Option
-from . import graph
-from . import kittygfx
+from . import diag, findings, graph, kittygfx, search
+from .domain import Func, Head, ListingModel, Program, Struct
from .edit_ctl import EditController
-from .prompt import PromptBar
-from .trace_ctl import TraceController
-from . import findings, search
+from .errors import IDAConnectionError
from .highlight import CTextArea, highlight_c
from .journal import Journal
-
-from .errors import IDAToolError, IDAConnectionError
-from .codemode_client import CodeModeClient, registered_database
-from .domain import Func, Head, ListingModel, Program, Struct
+from .nexus_client import NexusClient, registered_database
+from .prompt import PromptBar
+from .trace_ctl import TraceController
# Styles for the disassembly listing.
_S_ADDR = Style(color="#6b7684")
@@ -68,28 +70,28 @@ _S_INSN = Style(color="#c3cad3")
#: text), HUES only where they mean something (numbers, strings, symbols),
#: structure recedes so brackets and commas stop competing with operands.
_S_SPAN = {
- "insn": Style(color="#e8ecf2"), # 15.3:1 mnemonic / directive
- "reg": Style(color="#c3cad3"), # 11.0:1 registers = body weight
- "num": Style(color="#d8a657"), # 8.2:1 immediates, offsets
- "str": Style(color="#9ece6a"), # 9.9:1 string literals
- "name": Style(color="#7aa2f7"), # 7.2:1 symbols / xref targets
- "seg": Style(color="#93aee0"), # 8.1:1 segment names
- "cmt": Style(color="#7c8b9e", italic=True), # 5.2:1
- "punct": Style(color="#626c7a"), # 3.4:1 brackets, commas, +/-
- "err": Style(color="#c9762f"), # IDA's own error marker
- "text": Style(color="#c3cad3"), # 11.0:1 anything unclassified
+ "insn": Style(color="#e8ecf2"), # 15.3:1 mnemonic / directive
+ "reg": Style(color="#c3cad3"), # 11.0:1 registers = body weight
+ "num": Style(color="#d8a657"), # 8.2:1 immediates, offsets
+ "str": Style(color="#9ece6a"), # 9.9:1 string literals
+ "name": Style(color="#7aa2f7"), # 7.2:1 symbols / xref targets
+ "seg": Style(color="#93aee0"), # 8.1:1 segment names
+ "cmt": Style(color="#7c8b9e", italic=True), # 5.2:1
+ "punct": Style(color="#626c7a"), # 3.4:1 brackets, commas, +/-
+ "err": Style(color="#c9762f"), # IDA's own error marker
+ "text": Style(color="#c3cad3"), # 11.0:1 anything unclassified
}
_S_MNEM = Style(color="#e8ecf2")
_S_OPBYTES = Style(color="#5e6875") # raw opcode bytes column
_S_DATA = Style(color="#d8a657")
_S_UNK = Style(color="#7c8b9e", italic=True) # undefined bytes in the flat listing
_S_MEMBER = Style(color="#93aee0")
-_S_SEP = Style(color="#5e6875") # function boundary separators / banners
+_S_SEP = Style(color="#5e6875") # function boundary separators / banners
_S_FUNCHDR = Style(color="#7aa2f7", bold=True) # 'name proc'/'endp' headers
-_LST_INDENT = " " # one depth level: function names sit at level 0, code at 1
-_OP_LIMIT = 8 # opcode bytes shown in the 'limited' column mode
-_JUMP_CONTEXT = 4 # lines of context kept above a jump target (cursor stays on it)
+_LST_INDENT = " " # one depth level: function names sit at level 0, code at 1
+_OP_LIMIT = 8 # opcode bytes shown in the 'limited' column mode
+_JUMP_CONTEXT = 4 # lines of context kept above a jump target (cursor stays on it)
_SPLIT_MIN_WIDTH = 100 # need room for two usable code panes side by side
@@ -117,10 +119,10 @@ class ViewMode(StrEnum):
the next one has fewer places to reach.
"""
- LISTING = "listing" # the unified continuous listing (code + data)
- DECOMP = "decomp" # Hex-Rays pseudocode
- HEX = "hex" # the hex viewer
- GRAPH = "graph" # the CFG graph view
+ LISTING = "listing" # the unified continuous listing (code + data)
+ DECOMP = "decomp" # Hex-Rays pseudocode
+ HEX = "hex" # the hex viewer
+ GRAPH = "graph" # the CFG graph view
#: The two that show a code view over a NavEntry, i.e. where a follow, an
#: xref or a rename makes sense.
@@ -128,21 +130,47 @@ class ViewMode(StrEnum):
def code_modes(cls) -> frozenset["ViewMode"]:
return frozenset({cls.LISTING, cls.DECOMP, cls.GRAPH})
+
# Tokens that look like identifiers but aren't renamable symbols (so 'n' on them
# in the listing names the address instead of trying to rename the token).
-_ASM_KEYWORDS = frozenset({
- "db", "dw", "dd", "dq", "dt", "byte", "word", "dword", "qword", "tbyte",
- "offset", "short", "near", "far", "ptr", "dup", "cs", "ds", "es", "fs",
- "gs", "ss", "align", "public", "assume", "end",
-})
+_ASM_KEYWORDS = frozenset(
+ {
+ "db",
+ "dw",
+ "dd",
+ "dq",
+ "dt",
+ "byte",
+ "word",
+ "dword",
+ "qword",
+ "tbyte",
+ "offset",
+ "short",
+ "near",
+ "far",
+ "ptr",
+ "dup",
+ "cs",
+ "ds",
+ "es",
+ "fs",
+ "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_TRAIL_PAST = Style(bgcolor="#2b1c17") # warm: behind you
+_S_TRAIL_FUTURE = Style(bgcolor="#152230") # cool: ahead of you
#: Hex with a trace loaded: bytes the trace SAW at this timestamp vs bytes we're
#: still showing from the file. The distinction matters more than the values —
#: one is evidence, the other is an assumption.
@@ -157,12 +185,12 @@ _S_WORD = Style(bgcolor="#2a3f5f") # identifier under the cursor
#: _S_WORD (which marks every occurrence of an identifier): this marks ONE span,
#: the thing a keypress acts on, so it reads as a selection rather than a match.
_S_OPERAND = Style(bgcolor="#3a3560", underline=True)
-_S_CELL = Style(reverse=True) # the block cursor cell
-_S_LINENO = Style(color="#626c7a") # pseudocode line-number gutter
+_S_CELL = Style(reverse=True) # the block cursor cell
+_S_LINENO = Style(color="#626c7a") # pseudocode line-number gutter
_S_LINENO_CUR = Style(color="#c3cad3", bold=True) # gutter on the cursor line
-_S_DECOMP_SPIN = Style(color="#d0a215", bold=True) # 'decompiling' spinner glyph
+_S_DECOMP_SPIN = Style(color="#d0a215", bold=True) # 'decompiling' spinner glyph
_S_DECOMP_WAIT = Style(color="#7c8b9e", italic=True) # 'decompiling' label
-_S_DECOMP_DOTS = Style(color="#626c7a") # trailing ellipsis
+_S_DECOMP_DOTS = Style(color="#626c7a") # trailing ellipsis
_S_LINK = Style(bgcolor="#233044") # split view: rows linked to the other pane's cursor
# Hex-Rays appends a `/*0xEA*/` address marker to each pseudocode line (we fetch
@@ -206,8 +234,8 @@ class ViewAnchor:
"""
view: str = "listing"
- ea: int | None = None # cursor address
- top_ea: int | None = None # first visible address
+ ea: int | None = None # cursor address
+ top_ea: int | None = None # first visible address
cursor_x: int = 0
flash: str | None = None
#: The edit changed which functions exist, so the index must be rebuilt.
@@ -218,12 +246,12 @@ class ViewAnchor:
class NavEntry:
ea: int
name: str
- cursor: int = 0 # disasm line (instruction index)
- cursor_x: int = 0 # disasm column
- scroll_y: int = -1 # disasm viewport top (-1 = derive from cursor)
- dec_cursor: int = 0 # pseudocode line
+ cursor: int = 0 # disasm line (instruction index)
+ cursor_x: int = 0 # disasm column
+ scroll_y: int = -1 # disasm viewport top (-1 = derive from cursor)
+ dec_cursor: int = 0 # pseudocode line
dec_cursor_x: int = 0 # pseudocode column
- dec_scroll_y: int = -1 # pseudocode viewport top (-1 = derive)
+ 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)
view: str = "listing" # which code view to restore this entry in
@@ -417,7 +445,9 @@ def _overlay_over(strip: Strip, ranges: list[tuple[int, int]], style: Style) ->
if a > pos:
parts.append(strip.crop(pos, a))
mid = strip.crop(a, b)
- parts.append(Strip([Segment(s.text, (s.style or Style()) + style) for s in mid]))
+ parts.append(
+ Strip([Segment(s.text, (s.style or Style()) + style) for s in mid])
+ )
pos = b
if pos < total:
parts.append(strip.crop(pos, total))
@@ -638,11 +668,11 @@ class _MatchRanges:
__slots__ = ("_lines", "_needle", "_n", "_ci", "_text", "_cache")
def __init__(self, lines, needle: str, n: int, ci: bool, text) -> None:
- self._lines = lines # set[int]
- self._needle = needle # already case-folded when ci
- self._n = n # len(term); the needle may be folded
+ self._lines = lines # set[int]
+ self._needle = needle # already case-folded when ci
+ self._n = n # len(term); the needle may be folded
self._ci = ci
- self._text = text # callable: line index -> str | None
+ self._text = text # callable: line index -> str | None
self._cache: dict[int, list[tuple[int, int]]] = {}
def _find(self, i: int) -> list[tuple[int, int]]:
@@ -770,7 +800,7 @@ class SearchMixin:
starts.append(pos)
parts.append(s)
pos += len(s) + 1
- while len(starts) < count: # a short window: keep the indices lined up
+ while len(starts) < count: # a short window: keep the indices lined up
starts.append(pos)
parts.append("")
pos += 1
@@ -831,8 +861,11 @@ class SearchMixin:
def _after_incremental(self) -> None:
self._compute_matches()
self.refresh()
- self._jump_from(getattr(self, "_search_origin", 0),
- getattr(self, "_search_dir", 1), include_current=True)
+ self._jump_from(
+ getattr(self, "_search_origin", 0),
+ getattr(self, "_search_dir", 1),
+ include_current=True,
+ )
n = len(self._matches)
self._app_status(f"/{self._term} {n} match{'' if n == 1 else 'es'}")
@@ -840,13 +873,23 @@ class SearchMixin:
if not self._matches:
return
if direction >= 0:
- nxt = next((m for m in self._matches
- if (m >= origin if include_current else m > origin)),
- self._matches[0])
+ nxt = next(
+ (
+ m
+ for m in self._matches
+ if (m >= origin if include_current else m > origin)
+ ),
+ self._matches[0],
+ )
else:
- nxt = next((m for m in reversed(self._matches)
- if (m <= origin if include_current else m < origin)),
- self._matches[-1])
+ nxt = next(
+ (
+ m
+ for m in reversed(self._matches)
+ if (m <= origin if include_current else m < origin)
+ ),
+ self._matches[-1],
+ )
self._goto_line(nxt)
def search_commit(self) -> None:
@@ -860,7 +903,9 @@ class SearchMixin:
self._reset_search_cache()
self.cursor = getattr(self, "_search_origin", self.cursor)
self.cursor_x = getattr(self, "_search_origin_x", self.cursor_x)
- self.scroll_to(y=max(self.cursor - self._visible_height() // 2, 0), animate=False)
+ self.scroll_to(
+ y=max(self.cursor - self._visible_height() // 2, 0), animate=False
+ )
self.refresh()
def repeat_last(self, direction: int) -> None:
@@ -870,8 +915,13 @@ class SearchMixin:
return
self._term = term
self._ci = term.islower()
- self._search_ensure(lambda: (self._compute_matches(), self.refresh(),
- self.search_repeat(direction)))
+ self._search_ensure(
+ lambda: (
+ self._compute_matches(),
+ self.refresh(),
+ self.search_repeat(direction),
+ )
+ )
def _compute_matches(self) -> None:
term = self._term
@@ -910,8 +960,7 @@ class SearchMixin:
# finding every further occurrence in it.
nxt = starts[line + 1] if line + 1 < nlines else blen
j = body.find(needle, nxt)
- ranges = _MatchRanges(set(matches), needle, n, ci,
- self._search_line_text)
+ ranges = _MatchRanges(set(matches), needle, n, ci, self._search_line_text)
else:
# Typing forward can only ever REMOVE lines: a line holding "mov"
# holds "mo". So when the term just grew (and nothing else moved --
@@ -923,8 +972,14 @@ class SearchMixin:
# been looked at, and narrowing would silently never find them.
rows: object = range(count)
prev = self._matched_key
- if (prev is not None and prev[2] == count and prev[3] == src
- and prev[1] == ci and term.startswith(prev[0]) and prev[0]):
+ if (
+ prev is not None
+ and prev[2] == count
+ and prev[3] == src
+ and prev[1] == ci
+ and term.startswith(prev[0])
+ and prev[0]
+ ):
rows = self._matches
text_of = self._search_line_text
for i in rows:
@@ -948,10 +1003,18 @@ class SearchMixin:
return
cur = self.cursor
if direction >= 0:
- nxt = next((m for m in self._matches
- if (m >= cur if include_current else m > cur)), self._matches[0])
+ nxt = next(
+ (
+ m
+ for m in self._matches
+ if (m >= cur if include_current else m > cur)
+ ),
+ self._matches[0],
+ )
else:
- nxt = next((m for m in reversed(self._matches) if m < cur), self._matches[-1])
+ nxt = next(
+ (m for m in reversed(self._matches) if m < cur), self._matches[-1]
+ )
self._goto_line(nxt)
k = self._matches.index(nxt) + 1
self._app_status(f"/{self._term}/ {k}/{len(self._matches)} line {nxt}")
@@ -963,7 +1026,9 @@ class SearchMixin:
ranges = self._ranges.get(self.cursor)
if ranges:
self.cursor_x = ranges[0][0]
- self.scroll_to(y=max(self.cursor - self._visible_height() // 2, 0), animate=False)
+ self.scroll_to(
+ y=max(self.cursor - self._visible_height() // 2, 0), animate=False
+ )
self._hscroll() # bring the match column into horizontal view
self.refresh()
self._refresh_hl()
@@ -1054,8 +1119,8 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru
self._term = ""
self._matches: list[int] = []
self._ranges: dict[int, list[tuple[int, int]]] = {}
- self._op_mode = 1 # opcode column: 0=off, 1=limited, 2=full ('o' cycles)
- self._op_w = 0 # char width of the hex-bytes field (excl. gap)
+ self._op_mode = 1 # opcode column: 0=off, 1=limited, 2=full ('o' cycles)
+ self._op_w = 0 # char width of the hex-bytes field (excl. gap)
self._search_loading = False
self._search_pending: list = [] # done-callbacks awaiting the load
self._link_rows: set[int] = set() # split-view: linked instruction rows
@@ -1087,7 +1152,7 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru
def _span_segments(h: Head, fallback: Style):
"""Segments for a row's disassembly text.
- Uses IDA's own token classification when Code Mode supplies it; falls
+ Uses IDA's own token classification when IDA Nexus supplies it; falls
back to the mnemonic/rest split when spans are absent or disagree with
the plain text.
"""
@@ -1161,9 +1226,15 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru
self._refresh_hl()
# -- public API -------------------------------------------------------- #
- def load(self, model: ListingModel, name: str, cursor: int = 0,
- cursor_x: int = 0, scroll_y: int | None = None,
- focus: str | None = None) -> None:
+ def load(
+ self,
+ model: ListingModel,
+ name: str,
+ cursor: int = 0,
+ cursor_x: int = 0,
+ scroll_y: int | None = None,
+ focus: str | None = None,
+ ) -> None:
previous, self.model = self.model, model
self._name = name
self.total = 0
@@ -1184,11 +1255,21 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru
model = self.model
if model is None:
return
- # Load just enough to render the viewport around the cursor, so the
- # listing appears immediately even on a huge segment; the rest streams
- # in via _grow. (load_all here would blank the pane for seconds.)
+ # One call gets the WHOLE row index -- every row's address, kind and
+ # size, and the page boundaries -- so the scrollbar is right immediately
+ # and _grow has nothing left to stream. The rows arrive text-less and
+ # materialise a page at a time as they are read.
+ #
+ # It is an optimisation, not a contract: an older or unhappy backend
+ # returns nothing usable and we stream exactly as before.
height = max(self.size.height, 1)
- model.ensure(self.cursor + height + 2 * ListingModel.PAGE)
+ if model.build_from_index():
+ # Render the viewport HERE, on this worker thread. Reading a
+ # skeleton page fetches it, and doing that lazily from render_line
+ # would put an RPC on the UI loop for the first paint.
+ model.window(max(self.cursor - height, 0), height * 3)
+ else:
+ model.ensure(self.cursor + height + 2 * ListingModel.PAGE)
self.app.call_from_thread(self._on_primed, len(model), model.complete)
if not model.complete:
self._grow()
@@ -1255,19 +1336,31 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru
self._reset_search_cache(body=True)
self._clamp_x()
self.refresh()
- self._app_status("opcodes: " + {0: "off", 1: f"limited ({_OP_LIMIT} bytes)",
- 2: "full"}[self._op_mode])
+ self._app_status(
+ "opcodes: "
+ + {0: "off", 1: f"limited ({_OP_LIMIT} bytes)", 2: "full"}[self._op_mode]
+ )
@work(thread=True, exclusive=True, group="listing-grow")
def _grow(self) -> None:
"""Stream the rest of the segment's heads in the background, growing the
- virtual size as they land so the scrollbar/paging catch up."""
+ virtual size as they land so the scrollbar/paging catch up.
+
+ SKELETON pages: this loop only exists to find out how many rows the
+ segment has, and it used to render every one of them to do it -- 227k
+ rows for a 1.2MB bash, ~9s, essentially all never displayed. A skeleton
+ page has the same rows at the same addresses and no text, is 2.8x
+ cheaper and costs one round trip instead of two. The first read of one
+ materialises it through the same path a rename uses, so only what is
+ actually shown ever gets rendered. _prime (the viewport) still loads
+ real pages, so what you are looking at is never a skeleton.
+ """
model = self.model
if model is None:
return
since = 0
while not model.complete:
- if model.load_next_page() == 0:
+ if model.load_next_page(text=False) == 0:
break
if self.model is not model: # a new load() replaced us
return
@@ -1335,20 +1428,28 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru
if h is None:
strip = Strip([Segment(f" {idx:>8} …", _S_DIM)])
elif h.kind == "sep":
- strip = Strip([Segment(f"{h.ea:08X} ", _S_ADDR),
- Segment(_LST_INDENT + h.text, _S_SEP)])
+ strip = Strip(
+ [
+ Segment(f"{h.ea:08X} ", _S_ADDR),
+ Segment(_LST_INDENT + h.text, _S_SEP),
+ ]
+ )
elif h.kind == "funchdr":
# depth-0: address + 'name proc'/'endp' (no indent)
- strip = Strip([Segment(f"{h.ea:08X} ", _S_ADDR),
- Segment(h.text, _S_FUNCHDR)])
+ strip = Strip(
+ [Segment(f"{h.ea:08X} ", _S_ADDR), Segment(h.text, _S_FUNCHDR)]
+ )
elif h.kind == "label":
# depth-0: address + 'loc_XXX:' on its own line
- strip = Strip([Segment(f"{h.ea:08X} ", _S_ADDR),
- Segment(h.text, _S_LABEL)])
+ strip = Strip(
+ [Segment(f"{h.ea:08X} ", _S_ADDR), Segment(h.text, _S_LABEL)]
+ )
else:
# depth-1: address, one indent, then opcode+text
- segs: list[Segment] = [Segment(f"{h.ea:08X} ", _S_ADDR),
- Segment(_LST_INDENT, _S_INSN)]
+ segs: list[Segment] = [
+ Segment(f"{h.ea:08X} ", _S_ADDR),
+ Segment(_LST_INDENT, _S_INSN),
+ ]
op = self._op_field(h)
if op:
segs.append(Segment(op, _S_OPBYTES))
@@ -1369,8 +1470,12 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru
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)
+ _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))
@@ -1666,8 +1771,15 @@ class DecompView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True
elif self.cursor_x >= sx + width:
self.scroll_to(x=self.cursor_x - width + 1, animate=False)
- def show(self, ea: int, text: str, cursor: int = 0, cursor_x: int = 0,
- scroll_y: int = -1, scroll_x: int = 0) -> None:
+ def show(
+ self,
+ ea: int,
+ text: str,
+ cursor: int = 0,
+ cursor_x: int = 0,
+ scroll_y: int = -1,
+ scroll_x: int = 0,
+ ) -> None:
# Pull each line's `/*0xEA*/` marker into _line_eas, then strip it from
# the displayed text (clutter) before highlighting. Stripping only edits
# within lines, so line indices still align with the domain's raw code.
@@ -1705,8 +1817,9 @@ class DecompView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True
self._hscroll()
self.refresh()
- def goto(self, cursor: int, cursor_x: int = 0, scroll_y: int = -1,
- scroll_x: int = 0) -> None:
+ def goto(
+ self, cursor: int, cursor_x: int = 0, scroll_y: int = -1, scroll_x: int = 0
+ ) -> None:
"""Move the cursor/scroll on the already-loaded text (no re-highlight).
Used to jump to a target inside the function already displayed, e.g. an
xref/goto that resolves to this same function.
@@ -1758,7 +1871,9 @@ class DecompView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True
return self._line_eas[idx] if 0 <= idx < len(self._line_eas) else None
def _after_cursor_move(self) -> None:
- self.post_message(DecompView.CursorMoved(self.cursor, self._line_ea(self.cursor)))
+ self.post_message(
+ DecompView.CursorMoved(self.cursor, self._line_ea(self.cursor))
+ )
@property
def total(self) -> int:
@@ -1788,8 +1903,12 @@ class DecompView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True
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)
+ _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:
@@ -1798,12 +1917,13 @@ class DecompView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True
base = _overlay_ranges(base, occ, _S_WORD)
if idx == self.cursor:
base = _cursor_decorate(base, self._texts[idx], self.cursor_x)
- span = self._cursor_literal(idx) # the literal `o` would reformat
- if span is not None: # (last: see ListingView)
+ span = self._cursor_literal(idx) # the literal `o` would reformat
+ if span is not None: # (last: see ListingView)
base = _overlay_over(base, [span], _S_OPERAND)
code_w = max(width - gw, 0)
code = base.crop(x, x + code_w).adjust_cell_length(
- code_w, _S_LINK if linked else None)
+ code_w, _S_LINK if linked else None
+ )
if gw <= 0:
return code
style = _S_LINENO_CUR if idx == self.cursor else _S_LINENO
@@ -2084,16 +2204,16 @@ class HexView(ScrollView, can_focus=True):
panes. Matches ``render_line``'s layout: addr(9) + file-offset(10) + 16
hex cells of 3 cols (with a 1-col gap before byte 8), then ' |' + ASCII."""
HEX, ASCII = 19, 70
- if x < HEX: # clicked the address/offset gutter -> row start
+ if x < HEX: # clicked the address/offset gutter -> row start
return 0
- if x < HEX + 49: # hex byte region
+ if x < HEX + 49: # hex byte region
rel = x - HEX
- if rel >= 24: # collapse the 1-col gap between the two halves
+ if rel >= 24: # collapse the 1-col gap between the two halves
rel -= 1
return min(rel // 3, 15)
- if x < ASCII: # the ' |' separator -> last byte of the row
+ if x < ASCII: # the ' |' separator -> last byte of the row
return 15
- return min(x - ASCII, 15) # ASCII pane (and anything past it)
+ return min(x - ASCII, 15) # ASCII pane (and anything past it)
def on_click(self, event) -> None: # type: ignore[no-untyped-def]
if self.model is None or self.model.size == 0:
@@ -2220,12 +2340,12 @@ class HexView(ScrollView, can_focus=True):
# --------------------------------------------------------------------------- #
# Graph view
# --------------------------------------------------------------------------- #
-_S_GBORDER = Style(color="#4b5565") # box border, idle
-_S_GBORDER_CUR = Style(color="#7aa2f7", bold=True) # box border, cursor block
-_S_GLABEL = Style(color="#7aa2f7", bold=True) # loc_XXXX in the border
+_S_GBORDER = Style(color="#4b5565") # box border, idle
+_S_GBORDER_CUR = Style(color="#7aa2f7", bold=True) # box border, cursor block
+_S_GLABEL = Style(color="#7aa2f7", bold=True) # loc_XXXX in the border
_S_GLABEL_CUR = Style(color="#c0caf5", bold=True)
_S_GDIM = Style(color="#5e6875")
-_S_GENTRY = Style(color="#9ece6a", bold=True) # the entry block's label
+_S_GENTRY = Style(color="#9ece6a", bold=True) # the entry block's label
#: Edge colours follow IDA's convention: green = branch taken, red = falls
#: through, blue = the block's only successor, purple = loops back.
_S_EDGE = {
@@ -2249,7 +2369,7 @@ _S_MINI_CUR = Style(bgcolor="#161b22", color="#9ece6a", bold=True)
_S_MINI_VIEW = Style(bgcolor="#233044", color="#c0caf5")
_S_MINI_EDGE = Style(bgcolor="#161b22", color="#2f3945")
-_GPAD = 1 # columns of padding inside a box
+_GPAD = 1 # columns of padding inside a box
_MINI_W, _MINI_H = 30, 14
@@ -2297,7 +2417,7 @@ class _CellRow:
b = self.width
if b <= a:
return
- self.ch[a:b] = s[a - i:b - i]
+ self.ch[a:b] = s[a - i : b - i]
self.st[a:b] = [style] * (b - a)
def restyle(self, a: int, b: int, style: Style) -> None:
@@ -2356,6 +2476,7 @@ class GraphView(NavMixin, ScrollView, can_focus=True):
Binding("0", "goto_entry", "Entry", show=False),
Binding("z", "zoom", "Zoom"),
Binding("m", "minimap", "Minimap", show=False),
+ Binding("e", "engine", "Engine", show=False),
Binding("f", "center", "Centre", show=False),
Binding("ctrl+d", "pan(12)", "½↓", show=False),
Binding("ctrl+u", "pan(-12)", "½↑", show=False),
@@ -2382,15 +2503,18 @@ class GraphView(NavMixin, ScrollView, can_focus=True):
def __init__(self) -> None:
super().__init__()
- self.fc = None # domain.Flowchart
+ self.fc = None # domain.Flowchart
self.lay: graph.Layout | None = None
self.loaded_ea: int | None = None
self._blocks: dict[int, object] = {}
self._zoom = 0
self._show_minimap = True
+ #: layout backend; "auto" prefers triskel where it is installed and the
+ #: function is small enough for it. Cycled with `e`.
+ self._engine = "auto"
self._mini_cache: tuple | None = None
self._drag: tuple[int, int, float, float] | None = None
- self._drag_map = False # the drag started on the minimap
+ self._drag_map = False # the drag started on the minimap
self._hl_word = ""
self.trail: dict[int, str] | None = None
@@ -2421,9 +2545,13 @@ class GraphView(NavMixin, ScrollView, can_focus=True):
self.lay = None
self.virtual_size = Size(0, 0)
return
- blocks = [graph.Block(id=b.id, start=b.start, end=b.end,
- succs=list(b.succs)) for b in self.fc.blocks]
- self.lay = graph.layout(blocks, self._sizer, entry=self.fc.entry)
+ blocks = [
+ graph.Block(id=b.id, start=b.start, end=b.end, succs=list(b.succs))
+ for b in self.fc.blocks
+ ]
+ self.lay = graph.layout(
+ blocks, self._sizer, entry=self.fc.entry, engine=self._engine
+ )
self.virtual_size = Size(self.lay.width + 2, self.lay.height + 1)
def _rows(self, nid: int):
@@ -2432,7 +2560,7 @@ class GraphView(NavMixin, ScrollView, can_focus=True):
if b is None:
return []
if self._zoom == 2:
- return [None] # one synthetic summary row
+ return [None] # one synthetic summary row
return b.rows
def _row_plain(self, nid: int, i: int) -> str:
@@ -2452,15 +2580,16 @@ class GraphView(NavMixin, ScrollView, can_focus=True):
@staticmethod
def _head_text(h) -> str:
- return (f"{h.name} {h.text}" if h.name else h.text)
+ return f"{h.name} {h.text}" if h.name else h.text
def _sizer(self, b: graph.Block) -> tuple[int, int]:
nid = b.id
rows = self._rows(nid)
n = max(len(rows), 1)
label = f"loc_{b.start:X}"
- widest = max([len(label) + 4]
- + [len(self._row_plain(nid, i)) for i in range(n)])
+ widest = max(
+ [len(label) + 4] + [len(self._row_plain(nid, i)) for i in range(n)]
+ )
return (widest + 2 * _GPAD + 2, n + 2)
# -- geometry --------------------------------------------------------- #
@@ -2622,8 +2751,10 @@ class GraphView(NavMixin, ScrollView, can_focus=True):
y0 = int(self.scroll_offset.y)
x0 = int(self.scroll_offset.x)
y1, x1 = y0 + self.size.height, x0 + self.size.width
- return any(n.y <= y1 and y0 <= n.bottom and n.x <= x1 and x0 <= n.right
- for n in self.lay.nodes)
+ return any(
+ n.y <= y1 and y0 <= n.bottom and n.x <= x1 and x0 <= n.right
+ for n in self.lay.nodes
+ )
def _snap_into_view(self) -> None:
"""After a pan, if the viewport holds no block at all, ease to the
@@ -2650,13 +2781,43 @@ class GraphView(NavMixin, ScrollView, can_focus=True):
self._clamp_cursor()
self._center_cursor()
self.refresh(layout=True)
- self.app._graph_status() # keeps the function name; names the zoom
+ self.app._graph_status() # keeps the function name; names the zoom
def action_minimap(self) -> None:
self._show_minimap = not self._show_minimap
self.refresh()
self.app._status(f"graph: minimap {'on' if self._show_minimap else 'off'}")
+ def action_engine(self) -> None:
+ """Cycle the layout engine and redraw the same function with it.
+
+ The two engines disagree about shape more than about correctness --
+ native draws wide and short, triskel narrow and tall with far fewer
+ crossings -- and which one reads better genuinely depends on the
+ function. Cheaper to look than to argue.
+ """
+ from . import graph_triskel
+
+ choices = ["auto", "native"] + (
+ ["triskel"] if graph_triskel.available() else []
+ )
+ self._engine = choices[(choices.index(self._engine) + 1) % len(choices)]
+ self._relayout()
+ self._clamp_cursor()
+ self._center_cursor()
+ self.refresh(layout=True)
+ got = self.lay.stats["engine"] if self.lay else "?"
+ # Name the interpreter. The launcher runs $IDATUI_PYTHON (default
+ # ~/ida-venv), which is NOT the repo .venv the tests use, so "not
+ # installed" on its own sends people to check the wrong python.
+ note = (
+ "" if graph_triskel.available() else f" (no pytriskel in {sys.executable})"
+ )
+ # A fallback with no reason is a bug report nobody can file.
+ if self.lay and self.lay.stats.get("engine_error"):
+ note = f" \u2014 {self.lay.stats['engine_error']}"
+ self.app._status(f"graph: engine {self._engine} \u2192 {got}{note}")
+
def action_center(self) -> None:
self._center_cursor()
self.refresh()
@@ -2729,10 +2890,12 @@ class GraphView(NavMixin, ScrollView, can_focus=True):
self.scroll_to(y=y, x=x, animate=False)
if not defer:
return
+
# Setting virtual_size then scrolling immediately clamps to 0 (max_scroll
# isn't recomputed until layout), so apply it again after the refresh.
def _again() -> None:
self.scroll_to(y=y, x=x, animate=False)
+
self.call_after_refresh(_again)
def _center_cursor(self) -> None:
@@ -2766,11 +2929,17 @@ class GraphView(NavMixin, ScrollView, can_focus=True):
return None
best, best_d = None, None
for n in self.lay.nodes:
- dx = 0.0 if n.x <= col <= n.right else min(abs(col - n.x),
- abs(col - n.right))
- dy = 0.0 if n.y <= row <= n.bottom else min(abs(row - n.y),
- abs(row - n.bottom))
- d = (dx * 0.5) ** 2 + dy ** 2
+ dx = (
+ 0.0
+ if n.x <= col <= n.right
+ else min(abs(col - n.x), abs(col - n.right))
+ )
+ dy = (
+ 0.0
+ if n.y <= row <= n.bottom
+ else min(abs(row - n.y), abs(row - n.bottom))
+ )
+ d = (dx * 0.5) ** 2 + dy**2
if best_d is None or d < best_d:
best, best_d = n, d
return best
@@ -2793,21 +2962,23 @@ class GraphView(NavMixin, ScrollView, can_focus=True):
return False
left, top, _w, _h = rect
gw, gh = _MINI_W - 2, _MINI_H - 2
- c, r = x - left - 1, y - top - 1 # inside the border
+ c, r = x - left - 1, y - top - 1 # inside the border
if not (0 <= c < gw and 0 <= r < gh):
return False
lay = self.lay
sx = max(lay.width / gw, 1e-9)
sy = max(lay.height / gh, 1e-9)
- cx, cy = (c + 0.5) * sx, (r + 0.5) * sy # centre of that mini-cell
+ cx, cy = (c + 0.5) * sx, (r + 0.5) * sy # centre of that mini-cell
n = self._nearest_node(cy, cx)
if n is None:
- self.scroll_to(x=max(0, int(cx - self.size.width / 2)),
- y=max(0, int(cy - self.size.height / 2)),
- animate=False)
+ self.scroll_to(
+ x=max(0, int(cx - self.size.width / 2)),
+ y=max(0, int(cy - self.size.height / 2)),
+ animate=False,
+ )
return True
if n.id == self.cursor_node:
- return True # already there; don't churn while dragging
+ return True # already there; don't churn while dragging
self.cursor_node = n.id
self.cursor_row = 0
self.cursor_x = 0
@@ -2824,7 +2995,7 @@ class GraphView(NavMixin, ScrollView, can_focus=True):
return
if self._minimap_seek(off.x, off.y):
self._drag = None
- self._drag_map = True # keep scrubbing while the button is held
+ self._drag_map = True # keep scrubbing while the button is held
return
self._drag_map = False
self._drag = (off.x, off.y, self.scroll_offset.x, self.scroll_offset.y)
@@ -2834,7 +3005,7 @@ class GraphView(NavMixin, ScrollView, can_focus=True):
self._drag = None
self._drag_map = False
if was_pan:
- self._snap_into_view() # don't leave them adrift in the padding
+ self._snap_into_view() # don't leave them adrift in the padding
def on_mouse_move(self, event) -> None: # type: ignore[no-untyped-def]
if not event.button:
@@ -2849,8 +3020,9 @@ class GraphView(NavMixin, ScrollView, can_focus=True):
if self._drag is None:
return
x0, y0, sx, sy = self._drag
- self.scroll_to(x=max(0, sx + (x0 - off.x)), y=max(0, sy + (y0 - off.y)),
- animate=False)
+ self.scroll_to(
+ x=max(0, sx + (x0 - off.x)), y=max(0, sy + (y0 - off.y)), animate=False
+ )
def on_click(self, event) -> None: # type: ignore[no-untyped-def]
if self.lay is None:
@@ -2871,8 +3043,7 @@ class GraphView(NavMixin, ScrollView, can_focus=True):
return
self.focus()
self.cursor_node = n.id
- self.cursor_row = max(0, min(row - n.y - 1,
- max(len(self._rows(n.id)) - 1, 0)))
+ self.cursor_row = max(0, min(row - n.y - 1, max(len(self._rows(n.id)) - 1, 0)))
self.cursor_x = max(0, col - n.x - 1 - _GPAD)
self._clamp_cursor()
self.refresh()
@@ -2896,7 +3067,8 @@ class GraphView(NavMixin, ScrollView, can_focus=True):
# 1. edge cells (an index query, never a painted canvas)
for col, (ch, kind, eid) in self.lay.painting.cells_at_row(
- row, col0, col0 + width).items():
+ row, col0, col0 + width
+ ).items():
st = (_S_EDGE_HOT if eid in hot else base).get(kind, _S_GDIM)
out.put(col - col0, ch, st)
@@ -2909,8 +3081,7 @@ class GraphView(NavMixin, ScrollView, can_focus=True):
self._draw_minimap_row(out, y, width)
return out.strip().adjust_cell_length(width, _S_INSN)
- def _draw_node_row(self, out: _CellRow, n: graph.Node, row: int,
- col0: int) -> None:
+ def _draw_node_row(self, out: _CellRow, n: graph.Node, row: int, col0: int) -> None:
cur = n.id == self.cursor_node
bs = _S_GBORDER_CUR if cur else _S_GBORDER
left = n.x - col0
@@ -2921,19 +3092,24 @@ class GraphView(NavMixin, ScrollView, can_focus=True):
label = f"loc_{n.block.start:X}" if n.block else ""
if b is not None and b.rows and b.rows[0].name:
label = b.rows[0].name
- out.text(left, graph.BOX["tl"] + graph.BOX["h"] * (w - 2)
- + graph.BOX["tr"], bs)
+ out.text(
+ left, graph.BOX["tl"] + graph.BOX["h"] * (w - 2) + graph.BOX["tr"], bs
+ )
tag = f" {label} "
if len(tag) <= w - 4:
- st = _S_GENTRY if (self.fc and n.id == self.fc.entry) else (
- _S_GLABEL_CUR if cur else _S_GLABEL)
+ st = (
+ _S_GENTRY
+ if (self.fc and n.id == self.fc.entry)
+ else (_S_GLABEL_CUR if cur else _S_GLABEL)
+ )
out.text(left + 2, tag, st)
if n.block is not None and n.block.selfloop:
out.put(left + w - 2, "↺", _S_EDGE[graph.E_BACK])
return
if row == n.y + n.h - 1:
- out.text(left, graph.BOX["bl"] + graph.BOX["h"] * (w - 2)
- + graph.BOX["br"], bs)
+ out.text(
+ left, graph.BOX["bl"] + graph.BOX["h"] * (w - 2) + graph.BOX["br"], bs
+ )
return
out.put(left, graph.BOX["v"], bs)
out.put(left + w - 1, graph.BOX["v"], bs)
@@ -2945,7 +3121,7 @@ class GraphView(NavMixin, ScrollView, can_focus=True):
text_col = left + 1 + _GPAD
h = rows[i]
plain = self._row_plain(n.id, i)
- if h is None: # collapsed summary
+ if h is None: # collapsed summary
out.text(text_col, plain, _S_GDIM)
else:
c = text_col
@@ -2967,9 +3143,15 @@ class GraphView(NavMixin, ScrollView, can_focus=True):
if self.trail is not None and h is not None:
k = self.trail.get(h.ea)
if k is not None:
- out.restyle(inner_a, inner_b,
- _S_TRAIL_NOW if k == "now" else
- _S_TRAIL_PAST if k == "past" else _S_TRAIL_FUTURE)
+ out.restyle(
+ inner_a,
+ inner_b,
+ _S_TRAIL_NOW
+ if k == "now"
+ else _S_TRAIL_PAST
+ if k == "past"
+ else _S_TRAIL_FUTURE,
+ )
if self._hl_word and plain:
for a, bb in _word_occurrences(plain, self._hl_word):
out.restyle(text_col + a, text_col + bb, _S_WORD)
@@ -2997,8 +3179,9 @@ class GraphView(NavMixin, ScrollView, can_focus=True):
sy = max(lay.height / gh, 1e-9)
for lo, hi, col, _kind, _eid in lay.painting.vruns:
c = min(int(col / sx), gw - 1)
- for r in range(min(int(lo / sy), gh - 1),
- min(int(hi / sy), gh - 1) + 1):
+ for r in range(
+ min(int(lo / sy), gh - 1), min(int(hi / sy), gh - 1) + 1
+ ):
if not grid[r][c]:
grid[r][c] = 1
for n in lay.nodes:
@@ -3017,7 +3200,7 @@ class GraphView(NavMixin, ScrollView, can_focus=True):
# column, which otherwise eats the minimap's right border.
if self._minimap_rect() is None or not (0 <= y < _MINI_H):
return
- left = self._minimap_rect()[0] # one source of truth with the hit-test
+ left = self._minimap_rect()[0] # one source of truth with the hit-test
grid = self._minimap()
gw, gh = _MINI_W - 2, _MINI_H - 2
lay = self.lay
@@ -3066,7 +3249,9 @@ class GraphView(NavMixin, ScrollView, can_focus=True):
# --------------------------------------------------------------------------- #
class FunctionsPanel(Vertical):
def compose(self) -> ComposeResult:
- self._filter = Input(placeholder="filter (glob, e.g. sub_*) — Enter to apply", id="func-filter")
+ self._filter = Input(
+ placeholder="filter (glob, e.g. sub_*) — Enter to apply", id="func-filter"
+ )
self._filter.display = False
yield self._filter
table = DataTable(id="func-table", cursor_type="row", zebra_stripes=True)
@@ -3077,6 +3262,58 @@ class FunctionsPanel(Vertical):
# --------------------------------------------------------------------------- #
+# Shared list navigation for the overlays
+# --------------------------------------------------------------------------- #
+class OptionListNav:
+ """Cursor + paging for an overlay whose filter ``Input`` keeps the focus.
+
+ These screens focus a filter box, not the list, so the ``OptionList``'s own
+ bindings never fire -- the Input sees every key first. These actions forward
+ to the list on its behalf.
+
+ Paging delegates to the widget's OWN ``action_page_up``/``action_page_down``
+ rather than reimplementing it: those know the live viewport height, skip
+ disabled options and clamp at both ends. A hand-rolled "move by N" here
+ would have to guess the height and would drift from the list that DOES have
+ focus (``XrefsScreen``, or the struct list), which pages natively.
+
+ Textual only merges ``BINDINGS`` from ``DOMNode`` subclasses, so a plain
+ mixin's are silently dropped -- every screen must splat ``*NAV_BINDINGS``
+ (or list its own keys) explicitly. Same trap as ``SearchMixin``.
+ """
+
+ #: The common key set. ``StructEditor`` deliberately does NOT use this: it
+ #: binds ctrl+n to "new type", so it lists bare up/down itself.
+ NAV_BINDINGS = [
+ Binding("down,ctrl+n", "cursor_down", show=False),
+ Binding("up,ctrl+p", "cursor_up", show=False),
+ Binding("pagedown", "page_down", show=False),
+ Binding("pageup", "page_up", show=False),
+ ]
+
+ def _nav_list(self):
+ """The list to drive, or None when there is nothing to move through."""
+ ol = self.query_one(OptionList)
+ return ol if ol.option_count else None
+
+ def action_cursor_down(self) -> None:
+ if (ol := self._nav_list()) is not None:
+ ol.highlighted = min((ol.highlighted or 0) + 1, ol.option_count - 1)
+
+ def action_cursor_up(self) -> None:
+ if (ol := self._nav_list()) is not None:
+ ol.highlighted = max((ol.highlighted or 0) - 1, 0)
+
+ def action_page_down(self) -> None:
+ if (ol := self._nav_list()) is not None:
+ ol.action_page_down()
+
+ def action_page_up(self) -> None:
+ if (ol := self._nav_list()) is not None:
+ ol.action_page_up()
+
+
+# --------------------------------------------------------------------------- #
# Xrefs popup
# --------------------------------------------------------------------------- #
class XrefsScreen(ModalScreen):
@@ -3084,8 +3321,9 @@ class XrefsScreen(ModalScreen):
BINDINGS = [Binding("escape", "close", "Close")]
- def __init__(self, label: str, items: list[tuple[object, str]],
- preselect: int = 0) -> None:
+ def __init__(
+ self, label: str, items: list[tuple[object, str]], preselect: int = 0
+ ) -> None:
# payload is an int address, or (binary, address) for a caller in another
# project binary; dismiss() hands it back untouched.
super().__init__()
@@ -3148,13 +3386,12 @@ def _fuzzy(name: str, q: str):
return (score, tuple(pos))
-class SymbolPalette(ModalScreen):
+class SymbolPalette(OptionListNav, ModalScreen):
"""A command-palette overlay: type to fuzzy-find a symbol, Enter opens it."""
BINDINGS = [
Binding("escape", "close", "Close"),
- Binding("down,ctrl+n", "cursor_down", show=False),
- Binding("up,ctrl+p", "cursor_up", show=False),
+ *OptionListNav.NAV_BINDINGS,
# F2, not ctrl+a: the focused Input binds "home,ctrl+a" so it would never
# reach us. Function keys are untouched by Input.
Binding("f2", "scope", "This binary / whole project", show=False),
@@ -3168,8 +3405,8 @@ class SymbolPalette(ModalScreen):
def __init__(self, funcs: list[Func], index=None, binary=None) -> None:
super().__init__()
self._funcs = funcs
- self._index = index # ProjectIndex, when this is a project
- self._binary = binary # label of the binary we're currently in
+ self._index = index # ProjectIndex, when this is a project
+ self._binary = binary # label of the binary we're currently in
self._project_scope = False
#: (binary|None, addr, name) — binary is None for a local hit
self._results: list[tuple] = []
@@ -3177,8 +3414,10 @@ class SymbolPalette(ModalScreen):
def compose(self) -> ComposeResult:
with Vertical(id="pal-box") as box:
box.border_title = Text("symbols")
- yield Input(placeholder="fuzzy find symbol… ↑↓ select · Enter open · Esc close",
- id="pal-input")
+ yield Input(
+ placeholder="fuzzy find symbol… ↑↓ select · Enter open · Esc close",
+ id="pal-input",
+ )
yield OptionList(id="pal-list")
def on_mount(self) -> None:
@@ -3203,13 +3442,17 @@ class SymbolPalette(ModalScreen):
# rows: (binary|None, addr, name, match positions)
if self._project_scope and self._index is not None:
from .index import KIND_FUNC
+
# The trigram index already guarantees every hit CONTAINS the query,
# so ranking only has to order them — an exact-substring rank (match
# position, then name length) costs a find() per row instead of a
# full fuzzy pass, and fetching 3x the display limit rather than 10x
# keeps the per-keystroke work down on a big project.
- hits = self._index.search(query, kind=KIND_FUNC,
- limit=self.PROJECT_LIMIT * 3) if query else []
+ hits = (
+ self._index.search(query, kind=KIND_FUNC, limit=self.PROJECT_LIMIT * 3)
+ if query
+ else []
+ )
q = query.lower()
scored = []
for h in hits:
@@ -3219,9 +3462,15 @@ class SymbolPalette(ModalScreen):
# on (position, length, text), and a bare sort() would then fall
# through to comparing Hit objects, which aren't orderable.
scored.sort(key=lambda t: (t[0], t[1], t[2], t[3].binary, t[3].addr))
- rows = [(h.binary, h.addr, h.text,
- tuple(range(at, at + len(q))) if at < (1 << 30) else ())
- for at, _, _, h in scored[:self.PROJECT_LIMIT]]
+ rows = [
+ (
+ h.binary,
+ h.addr,
+ h.text,
+ tuple(range(at, at + len(q))) if at < (1 << 30) else (),
+ )
+ for at, _, _, h in scored[: self.PROJECT_LIMIT]
+ ]
elif query:
scored = []
for f in self._funcs:
@@ -3229,9 +3478,9 @@ class SymbolPalette(ModalScreen):
if m is not None:
scored.append((m[0], m[1], f))
scored.sort(key=lambda t: (-t[0], t[2].name))
- rows = [(None, f.addr, f.name, pos) for _, pos, f in scored[:self.LIMIT]]
+ rows = [(None, f.addr, f.name, pos) for _, pos, f in scored[: self.LIMIT]]
else:
- rows = [(None, f.addr, f.name, ()) for f in self._funcs[:self.LIMIT]]
+ rows = [(None, f.addr, f.name, ()) for f in self._funcs[: self.LIMIT]]
self._results = [(b, a, n) for b, a, n, _ in rows]
ol = self.query_one(OptionList)
ol.clear_options()
@@ -3253,20 +3502,14 @@ class SymbolPalette(ModalScreen):
scope = "project" if self._project_scope else "this binary"
cap = self.PROJECT_LIMIT if self._project_scope else self.LIMIT
more = "+" if len(self._results) == cap else ""
- hint = " (F2: this binary)" if self._project_scope else (
- " (F2: whole project)" if self._index is not None else "")
+ hint = (
+ " (F2: this binary)"
+ if self._project_scope
+ else (" (F2: whole project)" if self._index is not None else "")
+ )
self.query_one("#pal-box").border_title = Text(
- f"symbols [{scope}]: {len(self._results)}{more}{hint}")
-
- def action_cursor_down(self) -> None:
- ol = self.query_one(OptionList)
- if ol.option_count:
- ol.highlighted = min((ol.highlighted or 0) + 1, ol.option_count - 1)
-
- def action_cursor_up(self) -> None:
- ol = self.query_one(OptionList)
- if ol.option_count:
- ol.highlighted = max((ol.highlighted or 0) - 1, 0)
+ f"symbols [{scope}]: {len(self._results)}{more}{hint}"
+ )
def action_choose(self) -> None:
ol = self.query_one(OptionList)
@@ -3287,13 +3530,17 @@ class SymbolPalette(ModalScreen):
def _str_display(text: str, limit: int = 200) -> str:
"""One-line, printable rendering of a string literal for the browser: escape
the common control chars, drop the rest, and clip long bodies."""
- out = (text.replace("\\", "\\\\").replace("\n", "\\n")
- .replace("\r", "\\r").replace("\t", "\\t"))
+ out = (
+ text.replace("\\", "\\\\")
+ .replace("\n", "\\n")
+ .replace("\r", "\\r")
+ .replace("\t", "\\t")
+ )
out = "".join(ch if ch.isprintable() else "." for ch in out)
return out[:limit] + ("\u2026" if len(out) > limit else "")
-class SearchPalette(ModalScreen):
+class SearchPalette(OptionListNav, ModalScreen):
"""Ctrl+F: search the whole database, by text or by bytes.
Unlike every other palette here this does NOT filter as you type: each
@@ -3310,8 +3557,7 @@ class SearchPalette(ModalScreen):
BINDINGS = [
Binding("escape", "close", "Close"),
- Binding("down,ctrl+n", "cursor_down", show=False),
- Binding("up,ctrl+p", "cursor_up", show=False),
+ *OptionListNav.NAV_BINDINGS,
Binding("f2", "mode", "Text / bytes", show=False),
]
LIMIT = 500
@@ -3320,7 +3566,7 @@ class SearchPalette(ModalScreen):
super().__init__()
self._program = program
self._initial = initial
- self._forced: str | None = None # F2: pin the mode
+ self._forced: str | None = None # F2: pin the mode
self._hits: list = []
self._searched: tuple[str, str] | None = None # (mode, query) on screen
self._busy = False
@@ -3328,9 +3574,11 @@ class SearchPalette(ModalScreen):
def compose(self) -> ComposeResult:
with Vertical(id="pal-box") as box:
box.border_title = Text("search")
- yield Input(placeholder="text, or bytes like 48 8b ?? c3 \u00b7 "
- "Enter search \u00b7 F2 mode \u00b7 Esc close",
- id="pal-input")
+ yield Input(
+ placeholder="text, or bytes like 48 8b ?? c3 \u00b7 "
+ "Enter search \u00b7 F2 mode \u00b7 Esc close",
+ id="pal-input",
+ )
yield OptionList(id="pal-list")
def on_mount(self) -> None:
@@ -3361,16 +3609,17 @@ class SearchPalette(ModalScreen):
elif q:
state = "Enter searches"
self.query_one("#pal-box").border_title = Text(
- f"search [{mode}{pinned}]" + (f": {state}" if state else ""))
+ f"search [{mode}{pinned}]" + (f": {state}" if state else "")
+ )
def action_mode(self) -> None:
mode, _ = self._mode_query()
self._forced = search.TEXT if mode == search.BYTES else search.BYTES
- self._searched = None # the results on screen are for the old mode
+ self._searched = None # the results on screen are for the old mode
self._retitle()
def on_input_changed(self, event: Input.Changed) -> None:
- event.stop() # modal inputs bubble to the app's own #search handler
+ event.stop() # modal inputs bubble to the app's own #search handler
self._retitle()
def on_input_submitted(self, event: Input.Submitted) -> None:
@@ -3403,14 +3652,14 @@ class SearchPalette(ModalScreen):
@work(thread=True, exclusive=True, group="dbsearch")
def _search(self, mode: str, query: str) -> None:
try:
- hits, err, truncated = self._program.search(query, mode,
- limit=self.LIMIT)
+ hits, err, truncated = self._program.search(query, mode, limit=self.LIMIT)
except Exception as e: # noqa: BLE001 -- a search must not kill the app
hits, err, truncated = [], str(e), False
self.app.call_from_thread(self._present, mode, query, hits, err, truncated)
- def _present(self, mode: str, query: str, hits: list, err: str | None,
- truncated: bool) -> None:
+ def _present(
+ self, mode: str, query: str, hits: list, err: str | None, truncated: bool
+ ) -> None:
self._busy = False
self._hits = hits
# Remember what these results ARE, not what the box says now: the user
@@ -3441,20 +3690,12 @@ class SearchPalette(ModalScreen):
self._retitle("no match")
else:
n = len(hits)
- self._retitle(f"{n}{'+' if truncated else ''} "
- f"hit{'' if n == 1 else 's'} \u2014 Enter opens")
+ self._retitle(
+ f"{n}{'+' if truncated else ''} "
+ f"hit{'' if n == 1 else 's'} \u2014 Enter opens"
+ )
# -- moving / choosing --------------------------------------------------- #
- def action_cursor_down(self) -> None:
- ol = self.query_one(OptionList)
- if ol.option_count:
- ol.highlighted = min((ol.highlighted or 0) + 1, ol.option_count - 1)
-
- def action_cursor_up(self) -> None:
- ol = self.query_one(OptionList)
- if ol.option_count:
- ol.highlighted = max((ol.highlighted or 0) - 1, 0)
-
def action_choose(self) -> None:
ol = self.query_one(OptionList)
i = ol.highlighted
@@ -3469,14 +3710,13 @@ class SearchPalette(ModalScreen):
self.dismiss(None)
-class StringsPalette(ModalScreen):
+class StringsPalette(OptionListNav, ModalScreen):
"""Every string in the binary (IDA's Shift+F12), filterable; Enter jumps to
it in the unified listing."""
BINDINGS = [
Binding("escape", "close", "Close"),
- Binding("down,ctrl+n", "cursor_down", show=False),
- Binding("up,ctrl+p", "cursor_up", show=False),
+ *OptionListNav.NAV_BINDINGS,
Binding("f2", "scope", "This binary / whole project", show=False),
]
LIMIT = 500
@@ -3488,9 +3728,10 @@ class StringsPalette(ModalScreen):
super().__init__()
# Pre-render + pre-lower once: filtering runs on every keystroke and a
# big binary has tens of thousands of strings.
- self._rows = [(s, d, d.lower())
- for s in strings for d in (_str_display(s.text),)]
- self._index = index # ProjectIndex, when this is a project
+ self._rows = [
+ (s, d, d.lower()) for s in strings for d in (_str_display(s.text),)
+ ]
+ self._index = index # ProjectIndex, when this is a project
self._binary = binary
self._project_scope = False
#: (binary|None, addr, display text) — binary is None for a local hit
@@ -3499,8 +3740,11 @@ class StringsPalette(ModalScreen):
def compose(self) -> ComposeResult:
with Vertical(id="pal-box") as box:
box.border_title = Text("strings")
- yield Input(placeholder="filter strings\u2026 \u2191\u2193 select \u00b7 "
- "Enter jump \u00b7 Esc close", id="pal-input")
+ yield Input(
+ placeholder="filter strings\u2026 \u2191\u2193 select \u00b7 "
+ "Enter jump \u00b7 Esc close",
+ id="pal-input",
+ )
yield OptionList(id="pal-list")
def on_mount(self) -> None:
@@ -3526,19 +3770,23 @@ class StringsPalette(ModalScreen):
# rows: (binary|None, addr, length, display text, match offset)
if self._project_scope and self._index is not None:
from .index import KIND_STRING
- hits = self._index.search(query, kind=KIND_STRING,
- limit=self.PROJECT_LIMIT * 3) if query else []
+
+ hits = (
+ self._index.search(
+ query, kind=KIND_STRING, limit=self.PROJECT_LIMIT * 3
+ )
+ if query
+ else []
+ )
rows = []
for h in hits:
disp = _str_display(h.text)
- rows.append((h.binary, h.addr, len(h.text), disp,
- disp.lower().find(q)))
+ rows.append((h.binary, h.addr, len(h.text), disp, disp.lower().find(q)))
# the index already guarantees a match, so ranking only orders them:
# earliest match, then shortest, with a stable (binary, addr) tiebreak
# (a literal shared by two binaries would otherwise be unordered).
- rows.sort(key=lambda r: (r[4] if r[4] >= 0 else 1 << 30,
- r[2], r[0], r[1]))
- rows = rows[:self.PROJECT_LIMIT]
+ rows.sort(key=lambda r: (r[4] if r[4] >= 0 else 1 << 30, r[2], r[0], r[1]))
+ rows = rows[: self.PROJECT_LIMIT]
else:
rows = []
for s, disp, low in self._rows:
@@ -3569,20 +3817,14 @@ class StringsPalette(ModalScreen):
scope = "project" if self._project_scope else "this binary"
cap = self.PROJECT_LIMIT if self._project_scope else self.LIMIT
more = "+" if len(rows) == cap else ""
- hint = " (F2: this binary)" if self._project_scope else (
- " (F2: whole project)" if self._index is not None else "")
+ hint = (
+ " (F2: this binary)"
+ if self._project_scope
+ else (" (F2: whole project)" if self._index is not None else "")
+ )
self.query_one("#pal-box").border_title = Text(
- f"strings [{scope}]: {len(self._results)}{more} of {len(self._rows)}{hint}")
-
- def action_cursor_down(self) -> None:
- ol = self.query_one(OptionList)
- if ol.option_count:
- ol.highlighted = min((ol.highlighted or 0) + 1, ol.option_count - 1)
-
- def action_cursor_up(self) -> None:
- ol = self.query_one(OptionList)
- if ol.option_count:
- ol.highlighted = max((ol.highlighted or 0) - 1, 0)
+ f"strings [{scope}]: {len(self._results)}{more} of {len(self._rows)}{hint}"
+ )
def action_choose(self) -> None:
ol = self.query_one(OptionList)
@@ -3603,86 +3845,106 @@ class StringsPalette(ModalScreen):
#: The keyboard cheatsheet (F1). Grouped by task rather than by widget, which is
#: what makes it readable; keep it in step with the BINDINGS above it.
_HELP = (
- ("Navigate", (
- ("Enter", "follow the symbol under the cursor"),
- ("Esc", "back (navigation history)"),
- ("g", "goto address or symbol"),
- ("Ctrl+N", "find symbol (fuzzy)"),
- ("\"", "strings browser"),
- ("x", "cross-references to the symbol"),
- ("L", "continuous listing at the cursor"),
- ("Ctrl+O", "switch binary (projects)"),
- )),
- ("Views", (
- ("Tab / F5", "disassembly \u21c4 pseudocode"),
- ("Space", "control-flow graph \u21c4 text"),
- ("s", "split view: listing + pseudocode"),
- ("Tab", "in split: switch the driving pane"),
- ("\\", "hex view"),
- ("B", "cycle the opcode-bytes column"),
- ("Ctrl+B", "show/hide the names pane"),
- ("Ctrl+T", "structs / types editor"),
- ("Ctrl+F", "search the database: text or bytes"),
- ("Ctrl+E", "export findings as markdown"),
- ("Ctrl+P", "command palette"),
- )),
- ("Move", (
- ("j / k", "down / up"),
- ("Ctrl+D / Ctrl+U", "half page down / up"),
- ("PgDn / PgUp", "page down / up"),
- ("Ctrl+Home / Ctrl+End", "top / bottom (G also)"),
- ("Home / End", "start / end of line"),
- ("Shift+Home", "start of the instruction / code"),
- ("h / l", "column left / right"),
- ("w / b", "word forward / back"),
- )),
- ("Edit", (
- ("n", "rename"),
- ("y", "set type (prototype, local or global)"),
- (";", "comment"),
- ("c", "make code"),
- ("p", "make function"),
- ("d", "make data"),
- ("a", "make string"),
- ("u", "undefine"),
- ("o / O", "literal format: hex/dec/bin/char/offset"),
- ("Ctrl+S", "save the database"),
- )),
- ("Search", (
- ("/", "search forward (repeat to continue)"),
- ("?", "search backward"),
- ("N", "previous match"),
- ("Ctrl+Y", "copy the current line"),
- ("F1 / H", "this cheatsheet"),
- ("q", "quit"),
- )),
- ("Graph (Space)", (
- ("j / k", "line up/down, crossing blocks"),
- ("h / l", "column left / right"),
- ("J / K", "follow an edge to a successor / predecessor"),
- ("w / b", "next / previous block in layout order"),
- ("0", "jump to the entry block"),
- ("z", "zoom: full \u2192 compact \u2192 collapsed"),
- ("m", "show/hide the minimap"),
- ("f", "centre on the current block"),
- ("Enter", "follow (stays in the graph if it lands here)"),
- ("drag / click", "pan / put the cursor in a block"),
- ("click minimap", "jump the view there (drag to scrub)"),
- )),
+ (
+ "Navigate",
+ (
+ ("Enter", "follow the symbol under the cursor"),
+ ("Esc", "back (navigation history)"),
+ ("g", "goto address or symbol"),
+ ("Ctrl+N", "find symbol (fuzzy)"),
+ ('"', "strings browser"),
+ ("x", "cross-references to the symbol"),
+ ("L", "continuous listing at the cursor"),
+ ("Ctrl+O", "switch binary (projects)"),
+ ),
+ ),
+ (
+ "Views",
+ (
+ ("Tab / F5", "disassembly \u21c4 pseudocode"),
+ ("Space", "control-flow graph \u21c4 text"),
+ ("s", "split view: listing + pseudocode"),
+ ("Tab", "in split: switch the driving pane"),
+ ("\\", "hex view"),
+ ("B", "cycle the opcode-bytes column"),
+ ("Ctrl+B", "show/hide the names pane"),
+ ("Ctrl+T", "structs / types editor"),
+ ("Ctrl+F", "search the database: text or bytes"),
+ ("Ctrl+R", "refresh the current view in place"),
+ ("Ctrl+E", "export findings as markdown"),
+ ("Ctrl+P", "command palette"),
+ ),
+ ),
+ (
+ "Move",
+ (
+ ("j / k", "down / up"),
+ ("Ctrl+D / Ctrl+U", "half page down / up"),
+ ("PgDn / PgUp", "page down / up"),
+ ("Ctrl+Home / Ctrl+End", "top / bottom (G also)"),
+ ("Home / End", "start / end of line"),
+ ("Shift+Home", "start of the instruction / code"),
+ ("h / l", "column left / right"),
+ ("w / b", "word forward / back"),
+ ),
+ ),
+ (
+ "Edit",
+ (
+ ("n", "rename"),
+ ("y", "set type (prototype, local or global)"),
+ (";", "comment"),
+ ("c", "make code"),
+ ("p", "make function"),
+ ("d", "make data"),
+ ("a", "make string"),
+ ("u", "undefine"),
+ ("o / O", "literal format: hex/dec/bin/char/offset"),
+ ("Ctrl+S", "save the database"),
+ ),
+ ),
+ (
+ "Search",
+ (
+ ("/", "search forward (repeat to continue)"),
+ ("?", "search backward"),
+ ("N", "previous match"),
+ ("Ctrl+Y", "copy the current line"),
+ ("F1 / H", "this cheatsheet"),
+ ("q", "quit"),
+ ),
+ ),
+ (
+ "Graph (Space)",
+ (
+ ("j / k", "line up/down, crossing blocks"),
+ ("h / l", "column left / right"),
+ ("J / K", "follow an edge to a successor / predecessor"),
+ ("w / b", "next / previous block in layout order"),
+ ("0", "jump to the entry block"),
+ ("z", "zoom: full \u2192 compact \u2192 collapsed"),
+ ("m", "show/hide the minimap"),
+ ("e", "layout engine: auto \u2192 native \u2192 triskel"),
+ ("f", "centre on the current block"),
+ ("Enter", "follow (stays in the graph if it lands here)"),
+ ("drag / click", "pan / put the cursor in a block"),
+ ("click minimap", "jump the view there (drag to scrub)"),
+ ),
+ ),
)
class QuitScreen(ModalScreen):
"""Asked before exiting with unsaved database changes.
- Code Mode clients cannot roll a shared database back. The ``d`` choice means
- "do not explicitly save": a GUI keeps the changes dirty, while a managed
- idalib worker may persist them when its final lease closes.
+ A final managed-worker lease can discard the whole session. Shared workers
+ and GUI databases keep their state: releasing this lease transfers the final
+ save/discard decision to the remaining client or GUI owner.
"""
BINDINGS = [
Binding("s", "save", "Save & quit"),
- Binding("d", "discard", "Leave & quit"),
+ Binding("d", "discard", "Discard / leave"),
Binding("escape,c", "cancel", "Cancel"),
]
@@ -3691,16 +3953,24 @@ class QuitScreen(ModalScreen):
self._labels = labels
def compose(self) -> ComposeResult:
- what = (f"{len(self._labels)} databases have unsaved changes"
- if len(self._labels) > 1 else "unsaved changes")
+ what = (
+ f"{len(self._labels)} databases have unsaved changes"
+ if len(self._labels) > 1
+ else "unsaved changes"
+ )
with Vertical(id="quit-box") as box:
box.border_title = Text(f"\u26a0 {what}")
body = Text()
for label in self._labels:
body.append(f" \u2022 {label}\n", _S_LABEL)
+ body.append(
+ "\nFinal managed leases discard; shared/GUI sessions stay open.", _S_DIM
+ )
yield Static(body, id="quit-list")
- yield Static("s save & quit d leave as-is & quit Esc cancel",
- id="quit-help")
+ yield Static(
+ "s save & quit d discard / leave & quit Esc cancel",
+ id="quit-help",
+ )
def action_save(self) -> None:
self.dismiss("save")
@@ -3734,13 +4004,14 @@ class HelpScreen(ModalScreen):
with VerticalScroll(id="help-body"):
with Horizontal(id="help-cols"):
for c in range(cols):
- chunk = _HELP[c * per:(c + 1) * per]
+ chunk = _HELP[c * per : (c + 1) * per]
if not chunk:
continue
with Vertical(classes="help-col"):
for title, rows in chunk:
- card = Static(self._card(rows),
- classes="help-card", markup=False)
+ card = Static(
+ self._card(rows), classes="help-card", markup=False
+ )
card.border_title = title
yield card
yield Static("Esc · F1 · H to close", id="help-foot")
@@ -3767,7 +4038,7 @@ class HelpScreen(ModalScreen):
n = len(ws)
for cols in range(min(n, 4), 1, -1):
per = -(-n // cols)
- chunks = [ws[c * per:(c + 1) * per] for c in range(cols)]
+ chunks = [ws[c * per : (c + 1) * per] for c in range(cols)]
total = sum(max(c) for c in chunks if c) + (cols - 1)
if total <= avail:
return cols
@@ -3790,7 +4061,7 @@ class HelpScreen(ModalScreen):
self.dismiss(None)
-class RegWriteScreen(ModalScreen):
+class RegWriteScreen(OptionListNav, ModalScreen):
"""Registers, and the instruction that set each one.
"Which instruction set this register to its current value?" is the question
@@ -3801,21 +4072,22 @@ class RegWriteScreen(ModalScreen):
BINDINGS = [
Binding("escape", "close", "Close"),
- Binding("down,ctrl+n", "cursor_down", show=False),
- Binding("up,ctrl+p", "cursor_up", show=False),
+ *OptionListNav.NAV_BINDINGS,
Binding("enter", "choose", show=False, priority=True),
Binding("f", "choose_forward", show=False),
]
def __init__(self, rows, idx: int) -> None:
super().__init__()
- self._rows = rows # (name, value, last_write, next_write)
+ self._rows = rows # (name, value, last_write, next_write)
self._idx = idx
def compose(self) -> ComposeResult:
with Vertical(id="pal-box") as box:
box.border_title = Text(f"registers at t={self._idx:,}")
- box.border_subtitle = Text("Enter seeks to the write \u00b7 f seeks forward")
+ box.border_subtitle = Text(
+ "Enter seeks to the write \u00b7 f seeks forward"
+ )
yield OptionList(id="pal-list")
def on_mount(self) -> None:
@@ -3824,8 +4096,9 @@ class RegWriteScreen(ModalScreen):
for name, val, last, nxt in self._rows:
label = Text()
label.append(f" {name:>4} ", _S_MNEM)
- label.append(f"{val:#018x} " if val > 0xFFFFFFFF else f"{val:#010x} ",
- _S_INSN)
+ label.append(
+ f"{val:#018x} " if val > 0xFFFFFFFF else f"{val:#010x} ", _S_INSN
+ )
if last is None:
label.append("never written in this trace", _S_DIM)
elif last == self._idx:
@@ -3840,16 +4113,6 @@ class RegWriteScreen(ModalScreen):
ol.highlighted = 0
ol.focus()
- def action_cursor_down(self) -> None:
- ol = self.query_one(OptionList)
- if ol.option_count:
- ol.highlighted = min((ol.highlighted or 0) + 1, ol.option_count - 1)
-
- def action_cursor_up(self) -> None:
- ol = self.query_one(OptionList)
- if ol.option_count:
- ol.highlighted = max((ol.highlighted or 0) - 1, 0)
-
def _pick(self, forward: bool) -> None:
i = self.query_one(OptionList).highlighted
if i is None or not (0 <= i < len(self._rows)):
@@ -3927,15 +4190,16 @@ class TraceDock(Vertical):
continue
hot = name in changed
body.append(f" {name:>4} ", _S_MNEM if hot else _S_DIM)
- body.append(f"{v:#018x}\n" if v > 0xFFFFFFFF else f"{v:#010x}\n",
- _S_DATA if hot else (_S_LABEL if name == pc else _S_INSN))
+ body.append(
+ f"{v:#018x}\n" if v > 0xFFFFFFFF else f"{v:#010x}\n",
+ _S_DATA if hot else (_S_LABEL if name == pc else _S_INSN),
+ )
self.query_one("#trace-regs", Static).update(body)
self._render_stack(t)
tl = self.query_one(TraceTimeline)
tl.idx = self.idx
tl.refresh()
-
STACK_WORDS = 8
def _render_stack(self, t) -> None: # type: ignore[no-untyped-def]
@@ -3966,8 +4230,13 @@ class TraceDock(Vertical):
v = int.from_bytes(data, "little")
out.append(f"{v:0{width * 2}x}\n", _S_DATA if k == 0 else _S_INSN)
elif any(known):
- out.append("".join(f"{b:02x}" if known[i] else "??"
- for i, b in enumerate(data)) + "\n", _S_INSN)
+ out.append(
+ "".join(
+ f"{b:02x}" if known[i] else "??" for i, b in enumerate(data)
+ )
+ + "\n",
+ _S_INSN,
+ )
else:
out.append("?" * (width * 2) + "\n", _S_SEP)
self.query_one("#trace-stack", Static).update(out)
@@ -4005,7 +4274,7 @@ class TraceTimeline(Static):
return out
-class LoadOptionsScreen(ModalScreen):
+class LoadOptionsScreen(OptionListNav, ModalScreen):
"""Ask how to load a file no loader recognised.
IDA's own answer to an unidentified file is a dialog; ours is this. Without
@@ -4020,8 +4289,7 @@ class LoadOptionsScreen(ModalScreen):
BINDINGS = [
Binding("escape", "close", "Close"),
- Binding("down,ctrl+n", "cursor_down", show=False),
- Binding("up,ctrl+p", "cursor_up", show=False),
+ *OptionListNav.NAV_BINDINGS,
Binding("enter", "choose", show=False, priority=True),
]
@@ -4036,18 +4304,27 @@ class LoadOptionsScreen(ModalScreen):
def compose(self) -> ComposeResult:
from .formats import PROCESSORS
+
self._all = list(PROCESSORS)
with Vertical(id="pal-box") as box:
box.border_title = Text("unrecognised file \u2014 how should IDA load it?")
- yield Static(f" {os.path.basename(self._path)} ({self._nbytes:,} bytes) "
- f"\u2014 no loader matched; without a processor IDA "
- f"assumes x86 at 0", id="load-note", markup=False)
+ yield Static(
+ f" {os.path.basename(self._path)} ({self._nbytes:,} bytes) "
+ f"\u2014 no loader matched; without a processor IDA "
+ f"assumes x86 at 0",
+ id="load-note",
+ markup=False,
+ )
yield Input(placeholder="filter processors\u2026", id="pal-input")
yield OptionList(id="pal-list")
- yield Input(placeholder="load address, e.g. 0x8000000 (blank = 0)",
- id="load-base")
- yield Static(" Enter accept \u00b7 Tab base address \u00b7 "
- "Esc load as IDA would", id="load-help", markup=False)
+ yield Input(
+ placeholder="load address, e.g. 0x8000000 (blank = 0)", id="load-base"
+ )
+ yield Static(
+ " Enter accept \u00b7 Tab base address \u00b7 Esc load as IDA would",
+ id="load-help",
+ markup=False,
+ )
def on_mount(self) -> None:
self._apply("")
@@ -4082,8 +4359,11 @@ class LoadOptionsScreen(ModalScreen):
def _apply(self, query: str) -> None:
q = query.lower()
- rows = [(name, desc) for name, desc in self._all
- if not q or q in name.lower() or q in desc.lower()]
+ rows = [
+ (name, desc)
+ for name, desc in self._all
+ if not q or q in name.lower() or q in desc.lower()
+ ]
# An unlisted processor is still valid: IDA has 73 modules and this
# offers 20, so a typed name that matches nothing is taken literally
# rather than refused.
@@ -4102,17 +4382,8 @@ class LoadOptionsScreen(ModalScreen):
if rows:
ol.highlighted = 0
self.query_one("#pal-box").border_title = Text(
- f"unrecognised file \u2014 processor? ({len(rows)})")
-
- def action_cursor_down(self) -> None:
- ol = self.query_one(OptionList)
- if ol.option_count:
- ol.highlighted = min((ol.highlighted or 0) + 1, ol.option_count - 1)
-
- def action_cursor_up(self) -> None:
- ol = self.query_one(OptionList)
- if ol.option_count:
- ol.highlighted = max((ol.highlighted or 0) - 1, 0)
+ f"unrecognised file \u2014 processor? ({len(rows)})"
+ )
def action_choose(self) -> None:
ol = self.query_one(OptionList)
@@ -4127,14 +4398,16 @@ class LoadOptionsScreen(ModalScreen):
base = int(raw, 0)
except ValueError:
self.query_one("#load-help", Static).update(
- f" {raw!r} is not an address \u2014 try 0x8000000")
+ f" {raw!r} is not an address \u2014 try 0x8000000"
+ )
self.query_one("#load-base", Input).focus()
return
if base % 16:
# IDA's -b is in paragraphs, so an unaligned base can't be
# expressed and would quietly load somewhere else.
self.query_one("#load-help", Static).update(
- f" {base:#x} must be 16-byte aligned")
+ f" {base:#x} must be 16-byte aligned"
+ )
self.query_one("#load-base", Input).focus()
return
self.dismiss({"processor": self._results[i][0], "base": base})
@@ -4143,14 +4416,13 @@ class LoadOptionsScreen(ModalScreen):
self.dismiss({})
-class ProjectPalette(ModalScreen):
+class ProjectPalette(OptionListNav, ModalScreen):
"""The project's binaries; Enter switches to one. Shows which are resident
(a live worker, so switching is instant) vs cold (needs an open)."""
BINDINGS = [
Binding("escape", "close", "Close"),
- Binding("down,ctrl+n", "cursor_down", show=False),
- Binding("up,ctrl+p", "cursor_up", show=False),
+ *OptionListNav.NAV_BINDINGS,
]
def __init__(self, entries: list[dict]) -> None:
@@ -4161,8 +4433,11 @@ class ProjectPalette(ModalScreen):
def compose(self) -> ComposeResult:
with Vertical(id="pal-box") as box:
box.border_title = Text("binaries")
- yield Input(placeholder="filter binaries\u2026 \u2191\u2193 select \u00b7 "
- "Enter switch \u00b7 Esc close", id="pal-input")
+ yield Input(
+ placeholder="filter binaries\u2026 \u2191\u2193 select \u00b7 "
+ "Enter switch \u00b7 Esc close",
+ id="pal-input",
+ )
yield OptionList(id="pal-list")
def on_mount(self) -> None:
@@ -4179,16 +4454,20 @@ class ProjectPalette(ModalScreen):
def _apply(self, query: str) -> None:
q = query.lower()
- rows = [e for e in self._entries
- if not q or q in e["label"].lower() or q in e["source"].lower()]
+ rows = [
+ e
+ for e in self._entries
+ if not q or q in e["label"].lower() or q in e["source"].lower()
+ ]
self._results = rows
ol = self.query_one(OptionList)
ol.clear_options()
opts = []
for e in rows:
label = Text()
- label.append("\u25b8 " if e["active"] else " ",
- _S_MNEM if e["active"] else _S_DIM)
+ label.append(
+ "\u25b8 " if e["active"] else " ", _S_MNEM if e["active"] else _S_DIM
+ )
label.append(f"{e['label']:<22}", _S_LABEL)
if e["resident"]:
mb = e.get("memory_mb") or 0
@@ -4208,17 +4487,8 @@ class ProjectPalette(ModalScreen):
active = next((i for i, e in enumerate(rows) if e["active"]), 0)
ol.highlighted = active
self.query_one("#pal-box").border_title = Text(
- f"binaries: {len(rows)} of {len(self._entries)}")
-
- def action_cursor_down(self) -> None:
- ol = self.query_one(OptionList)
- if ol.option_count:
- ol.highlighted = min((ol.highlighted or 0) + 1, ol.option_count - 1)
-
- def action_cursor_up(self) -> None:
- ol = self.query_one(OptionList)
- if ol.option_count:
- ol.highlighted = max((ol.highlighted or 0) - 1, 0)
+ f"binaries: {len(rows)} of {len(self._entries)}"
+ )
def action_choose(self) -> None:
i = self.query_one(OptionList).highlighted
@@ -4269,23 +4539,43 @@ _LOGO_PATH = os.path.join(_REPO_ROOT, "logo.ans")
#: The same artwork as a real image, for terminals that can draw one. logo.ans
#: is half-blocks (two pixels per cell); this is a transparent PNG at 768px.
LOGO_PNG = os.path.join(_REPO_ROOT, "logo.png")
-_LOGO_BOX = (60, 33) # the most room the splash will give the art
+_LOGO_BOX = (60, 33) # the most room the splash will give the art
+#: Rows the loading box spends on everything that is not the artwork: border 2,
+#: padding 2, the art's margin 1, title 1, note 1 + margin 1, help 1 + margin 1.
+LOGO_CHROME_ROWS = 10
+#: Below this the image is a postage stamp; show the text splash instead.
+LOGO_MIN_ROWS = 8
_logo_cells: tuple[int, int] | None = None
-def logo_cells() -> tuple[int, int]:
+def logo_cells(max_rows: int | None = None) -> tuple[int, int]:
"""Cell footprint for the image, derived from the artwork and the terminal's
real cell size rather than hardcoded.
Cells are nowhere near square (9x22 px here, 1:2.44), so a fixed box picked
for one aspect ratio stretches any other. Recomputing means the art can be
replaced without anyone remembering to edit a constant.
+
+ ``max_rows`` shrinks it to the room actually available. The terminal scales
+ the image into whatever cell box we place it in, so there is no reason for
+ the splash to be all-or-nothing -- and it WAS all-or-nothing: a 31-row pane
+ is one row short of the natural size, so the logo silently disappeared
+ rather than being drawn a little smaller.
"""
global _logo_cells
if _logo_cells is None:
px = kittygfx.png_size(LOGO_PNG)
_logo_cells = kittygfx.fit(px, *_LOGO_BOX) if px else _LOGO_BOX
- return _logo_cells
+ if max_rows is None or max_rows >= _logo_cells[1]:
+ return _logo_cells
+ px = kittygfx.png_size(LOGO_PNG)
+ return (
+ kittygfx.fit(px, _LOGO_BOX[0], max(max_rows, 1))
+ if px
+ else (_LOGO_BOX[0], max(max_rows, 1))
+ )
+
+
_logo_cache: object = False # False == not yet loaded (None == absent/unreadable)
@@ -4316,13 +4606,17 @@ class LoadingScreen(ModalScreen):
super().__init__()
self._title = title
self._note = note
- self._image = False # drawing the real image, not the block art
- self._last_place = 0.0 # throttles re-anchoring after a repaint
+ self._image = False # drawing the real image, not the block art
+ self._cells: tuple[int, int] | None = None # image size, in cells
+ self._last_place = 0.0 # throttles re-anchoring after a repaint
+
+ def _room(self) -> int:
+ """Rows left for artwork once the box's own furniture is paid for."""
+ return self.app.size.height - LOGO_CHROME_ROWS
def _fits(self, rows: int) -> bool:
- """Room for the art plus the title/note/help lines and box chrome."""
- sz = self.app.size
- return sz.height >= rows + 9 and sz.width >= 64
+ """Room for art of exactly ``rows`` (the block art cannot be resized)."""
+ return self._room() >= rows and self.app.size.width >= 64
def compose(self) -> ComposeResult:
with Vertical(id="loading-box"):
@@ -4331,11 +4625,21 @@ class LoadingScreen(ModalScreen):
# The image is anchored to screen cells rather than composited by
# Textual (no unicode-placeholder support here), so the widget is
# only reserved blank space -- see _place_logo.
- cols, rows = logo_cells()
- kittygfx.log(f"compose: supported={kittygfx.supported()} "
- f"app.size={self.app.size} cells={cols}x{rows} "
- f"fits={self._fits(rows)}")
- if kittygfx.supported() and self._fits(rows):
+ # Scale the image to the room there is, rather than demanding its
+ # natural size and vanishing when one row is missing.
+ room = self._room()
+ cols, rows = logo_cells(room)
+ self._cells = (cols, rows)
+ kittygfx.log(
+ f"compose: supported={kittygfx.supported()} "
+ f"app.size={self.app.size} room={room} "
+ f"cells={cols}x{rows} natural={logo_cells()}"
+ )
+ if (
+ kittygfx.supported()
+ and self.app.size.width >= 64
+ and room >= LOGO_MIN_ROWS
+ ):
self._image = True
blank = Static("\n" * (rows - 1), id="loading-image")
blank.styles.height = rows
@@ -4352,8 +4656,10 @@ class LoadingScreen(ModalScreen):
yield Static(Align.center(logo), id="loading-logo")
yield Static(f"\u23f3 loading {self._title}", id="loading-title")
yield Static(self._note, id="loading-note")
- yield Static("first open of a big binary can take a while \u00b7 "
- "Esc to hide", id="loading-help")
+ yield Static(
+ "first open of a big binary can take a while \u00b7 Esc to hide",
+ id="loading-help",
+ )
def update_note(self, text: str) -> None:
try:
@@ -4361,8 +4667,11 @@ class LoadingScreen(ModalScreen):
except Exception: # noqa: BLE001 -- not mounted yet / already gone
pass
# Textual doesn't know the image is there, so a repaint can drop it.
- # Re-anchoring is one short escape with no image data; throttled so a
- # chatty progress callback can't turn it into a flicker.
+ # Re-anchoring is one short escape with no image data, and it REPLACES
+ # the placement rather than adding one (kittygfx.LOGO_PLACEMENT), so a
+ # long load ends with one image on screen instead of a stack of them.
+ # Still throttled: a chatty progress callback shouldn't drive the
+ # terminal's image compositor at status-write rate.
if self._image:
now = time.monotonic()
if now - self._last_place > 0.2:
@@ -4375,6 +4684,8 @@ class LoadingScreen(ModalScreen):
Deferred to after a refresh because a widget has no screen region until
it has been laid out, and re-run on resize because the region moves.
+ Idempotent: the placement carries an id, so calling this a hundred times
+ during a slow load leaves exactly one image on the screen.
"""
if not self._image:
return
@@ -4382,12 +4693,19 @@ class LoadingScreen(ModalScreen):
region = self.query_one("#loading-image", Static).region
except Exception as e: # noqa: BLE001 -- gone already
kittygfx.log(f"place_logo: no widget ({e})")
+ kittygfx.clear()
return
kittygfx.log(f"place_logo: region={region}")
if not region.width or not region.height:
+ # No room left to draw into -- drop the placement rather than leave
+ # the old, bigger one anchored over whatever now occupies the cells.
+ kittygfx.clear()
return
- cols, rows = logo_cells()
- col = region.x + max((region.width - cols) // 2, 0) # centre it
+ # The reserved region is the truth about how much room there is; the
+ # image is scaled into exactly it, so a resize needs no relayout.
+ cols, rows = self._cells or logo_cells()
+ rows = min(rows, region.height)
+ col = region.x + max((region.width - cols) // 2, 0) # centre it
kittygfx.place(region.y, col, min(cols, region.width), rows)
def on_mount(self) -> None:
@@ -4403,8 +4721,9 @@ class LoadingScreen(ModalScreen):
self.call_after_refresh(self._place_logo)
def on_resize(self) -> None:
+ # No clear() first: re-placing with the same placement id replaces the
+ # old one atomically, where delete-then-draw shows a hole for a frame.
if self._image:
- kittygfx.clear()
self.call_after_refresh(self._place_logo)
def on_unmount(self) -> None:
@@ -4461,10 +4780,13 @@ class StructEditor(ModalScreen):
Binding("delete,d", "delete", "Delete", show=False),
Binding("escape", "close", "Close"),
# Only ever reached while the FILTER has focus: a focused OptionList
- # consumes up/down itself, so these move its highlight from the prompt
+ # consumes these itself, so they move its highlight from the prompt
# (type to narrow, arrow to pick, exactly like the symbol palette).
+ # NOT OptionListNav.NAV_BINDINGS: ctrl+n is "new type" here.
Binding("up", "cursor_up", "Up", show=False),
Binding("down", "cursor_down", "Down", show=False),
+ Binding("pageup", "page_up", show=False),
+ Binding("pagedown", "page_down", show=False),
]
NEW_TEMPLATE = "struct NewStruct\n{\n int field;\n};\n"
@@ -4474,7 +4796,7 @@ class StructEditor(ModalScreen):
def __init__(self, program: Program) -> None:
super().__init__()
self._program = program
- self._all: list[Struct] = [] # every struct the database has
+ self._all: list[Struct] = [] # every struct the database has
self._structs: list[Struct] = [] # the VISIBLE rows (== _all when unfiltered)
self._filter = ""
self._loaded: str | None = None # name currently in the editor
@@ -4505,9 +4827,11 @@ class StructEditor(ModalScreen):
with Horizontal(id="se-panes"):
with Vertical(id="se-left"):
yield Static("structs", id="se-title")
- yield Input(placeholder="fuzzy filter\u2026 \u2191\u2193 pick \u00b7 "
- "Enter edit \u00b7 Esc clear",
- id="se-filter")
+ yield Input(
+ placeholder="fuzzy filter\u2026 \u2191\u2193 pick \u00b7 "
+ "Enter edit \u00b7 Esc clear",
+ id="se-filter",
+ )
yield OptionList(id="se-list")
with Vertical(id="se-right"):
yield Static("C definition", id="se-hint")
@@ -4516,7 +4840,8 @@ class StructEditor(ModalScreen):
yield Static(
"Enter edit · / filter · Ctrl+S save · Ctrl+Y copy · Ctrl+N new · "
"d/Del delete",
- id="se-status")
+ id="se-status",
+ )
def on_mount(self) -> None:
self._refresh()
@@ -4574,7 +4899,7 @@ class StructEditor(ModalScreen):
opts = []
for s, pos in rows:
kw = "union" if s.is_union else "struct"
- name = s.name if len(s.name) <= width else s.name[:width - 1] + "\u2026"
+ name = s.name if len(s.name) <= width else s.name[: width - 1] + "\u2026"
label = Text()
nm = Text(f"{name:<{width}}", style=_S_LABEL)
for p in pos:
@@ -4587,8 +4912,9 @@ class StructEditor(ModalScreen):
if rows:
idx = 0
if select is not None:
- idx = next((i for i, s in enumerate(self._structs)
- if s.name == select), 0)
+ idx = next(
+ (i for i, s in enumerate(self._structs) if s.name == select), 0
+ )
ol.highlighted = idx
cap = "structs"
if q:
@@ -4652,14 +4978,34 @@ class StructEditor(ModalScreen):
def action_cursor_down(self) -> None:
self._move_highlight(1)
- def _move_highlight(self, delta: int) -> None:
+ def action_page_up(self) -> None:
+ self._page(-1)
+
+ def action_page_down(self) -> None:
+ self._page(1)
+
+ def _list_from_filter(self):
+ """The type list, but only while the FILTER holds focus.
+
+ When the list itself has focus it handles these keys natively, and
+ forwarding as well would move the highlight twice.
+ """
if not self._filter_focused():
- return # the list has focus and moves itself
+ return None
ol = self.query_one("#se-list", OptionList)
- if not ol.option_count:
- return
- cur = ol.highlighted or 0
- ol.highlighted = max(0, min(cur + delta, ol.option_count - 1))
+ return ol if ol.option_count else None
+
+ def _move_highlight(self, delta: int) -> None:
+ ol = self._list_from_filter()
+ if ol is not None:
+ ol.highlighted = max(
+ 0, min((ol.highlighted or 0) + delta, ol.option_count - 1)
+ )
+
+ def _page(self, direction: int) -> None:
+ ol = self._list_from_filter()
+ if ol is not None:
+ (ol.action_page_down if direction > 0 else ol.action_page_up)()
@work(thread=True, exclusive=True, group="se-load")
def _load(self, name: str) -> None:
@@ -4706,16 +5052,20 @@ class StructEditor(ModalScreen):
formatted = None
self.app.call_from_thread(self._after_save, name, err, text, formatted)
- def _after_save(self, name: str | None, err: str | None, text: str,
- formatted: str | None) -> None:
+ def _after_save(
+ self, name: str | None, err: str | None, text: str, formatted: str | None
+ ) -> None:
if err:
# IDA's parse error is usually empty/cryptic; name the likely cause.
msg = err.strip()
if not msg or "parse" in msg.lower() or "fail" in msg.lower():
bad = self._reserved_field(text)
- msg = (f"'{bad}' is a reserved name in IDA's C parser — rename "
- f"that field to save" if bad else
- "IDA couldn't parse it (unknown type or reserved field name?)")
+ msg = (
+ f"'{bad}' is a reserved name in IDA's C parser — rename "
+ f"that field to save"
+ if bad
+ else "IDA couldn't parse it (unknown type or reserved field name?)"
+ )
self._set_status(f"save failed — {msg}", error=True)
return
self._loaded = name
@@ -4756,7 +5106,8 @@ class StructEditor(ModalScreen):
kind = "union" if s.is_union else "struct"
self.app.push_screen(
ConfirmScreen(f"Delete {kind} '{s.name}' ?"),
- lambda ok, name=s.name: self._delete(name) if ok else None)
+ lambda ok, name=s.name: self._delete(name) if ok else None,
+ )
@work(thread=True, exclusive=True, group="se-del")
def _delete(self, name: str) -> None:
@@ -4797,8 +5148,9 @@ class StructEditor(ModalScreen):
if self._filter_focused() or self._filter:
self._clear_filter()
return
- self._confirm_discard(lambda: self.dismiss(None),
- "Discard unsaved changes and close?")
+ self._confirm_discard(
+ lambda: self.dismiss(None), "Discard unsaved changes and close?"
+ )
def _set_status(self, text, error: bool = False) -> None: # type: ignore[no-untyped-def]
st = self.query_one("#se-status", Static)
@@ -4816,16 +5168,16 @@ class StructEditor(ModalScreen):
IDATUI_THEME = Theme(
name="idatui",
dark=True,
- background="#12161c", # deep blue-black, softer than pure black
- surface="#181d25", # views
- panel="#212832", # dialogs, status bar, gutters
+ background="#12161c", # deep blue-black, softer than pure black
+ surface="#181d25", # views
+ panel="#212832", # dialogs, status bar, gutters
foreground="#d6d9de",
- primary="#5aa0d6", # focus / links: the one cool accent
+ primary="#5aa0d6", # focus / links: the one cool accent
secondary="#2f5d82",
- accent="#d0a215", # the same amber as a search match — one meaning
- warning="#c9762f", # burnt orange — distinct from accent, reads as care
- error="#ff5f5f", # already used for failure text
- success="#6a9955", # already used for comments
+ accent="#d0a215", # the same amber as a search match — one meaning
+ warning="#c9762f", # burnt orange — distinct from accent, reads as care
+ error="#ff5f5f", # already used for failure text
+ success="#6a9955", # already used for comments
)
@@ -4840,73 +5192,146 @@ class IdaCommands(Provider):
app = self.app
va = app._palette_action # dispatch to the focused code view
return (
- ("Goto address / symbol…", "jump to an address or name (g)",
- app.action_goto),
+ (
+ "Goto address / symbol…",
+ "jump to an address or name (g)",
+ app.action_goto,
+ ),
("Find symbol…", "fuzzy function finder (Ctrl+N)", app.action_symbols),
- ("Strings…", "browse every string in the binary (\")",
- app.action_strings),
- ("Switch binary…", "another binary in the project (Ctrl+O)",
- app.action_switch_binary),
- ("Search database…", "disassembly text or a byte pattern with "
- "wildcards (Ctrl+F)", app.action_find),
- ("Export findings…", "your comments, names and types as markdown "
- "(Ctrl+E)", app.action_export),
- ("Keyboard shortcuts", "the key cheatsheet (F1 or H)",
- app.action_help),
- ("Follow symbol under cursor", "jump to the referenced symbol (Enter)",
- lambda: va("follow")),
- ("Show xrefs to symbol", "cross-references to the cursor symbol (x)",
- lambda: va("xrefs")),
+ ("Strings…", 'browse every string in the binary (")', app.action_strings),
+ (
+ "Switch binary…",
+ "another binary in the project (Ctrl+O)",
+ app.action_switch_binary,
+ ),
+ (
+ "Search database…",
+ "disassembly text or a byte pattern with wildcards (Ctrl+F)",
+ app.action_find,
+ ),
+ (
+ "Export findings…",
+ "your comments, names and types as markdown (Ctrl+E)",
+ app.action_export,
+ ),
+ ("Keyboard shortcuts", "the key cheatsheet (F1 or H)", app.action_help),
+ (
+ "Follow symbol under cursor",
+ "jump to the referenced symbol (Enter)",
+ lambda: va("follow"),
+ ),
+ (
+ "Show xrefs to symbol",
+ "cross-references to the cursor symbol (x)",
+ lambda: va("xrefs"),
+ ),
("Back", "navigation history (Esc)", app.action_back),
- ("Toggle disassembly / pseudocode", "decompile / listing (F5, Tab)",
- app.action_toggle_view),
- ("Continuous listing here", "flat segment listing (L)",
- app.action_continuous_here),
+ (
+ "Toggle disassembly / pseudocode",
+ "decompile / listing (F5, Tab)",
+ app.action_toggle_view,
+ ),
+ (
+ "Continuous listing here",
+ "flat segment listing (L)",
+ app.action_continuous_here,
+ ),
("Hex view", "raw bytes at the cursor (\\)", app.action_hex),
- ("Split view (listing ⇄ pseudocode)",
- "side-by-side synced views (s)", app.action_toggle_split),
- ("Graph view (control flow)",
- "the function's basic blocks as a graph (Space)",
- app.action_toggle_graph),
- ("Graph: cycle zoom",
- "full → compact → collapsed (z, in the graph)",
- lambda: va("zoom")),
- ("Graph: toggle minimap",
- "the overview box (m, in the graph)", lambda: va("minimap")),
- ("Rename symbol…", "rename the symbol under the cursor (n)",
- lambda: va("rename")),
- ("Set type / prototype…", "retype the symbol under the cursor (y)",
- lambda: va("retype")),
+ (
+ "Split view (listing ⇄ pseudocode)",
+ "side-by-side synced views (s)",
+ app.action_toggle_split,
+ ),
+ (
+ "Graph view (control flow)",
+ "the function's basic blocks as a graph (Space)",
+ app.action_toggle_graph,
+ ),
+ (
+ "Graph: cycle zoom",
+ "full → compact → collapsed (z, in the graph)",
+ lambda: va("zoom"),
+ ),
+ (
+ "Graph: toggle minimap",
+ "the overview box (m, in the graph)",
+ lambda: va("minimap"),
+ ),
+ (
+ "Rename symbol…",
+ "rename the symbol under the cursor (n)",
+ lambda: va("rename"),
+ ),
+ (
+ "Set type / prototype…",
+ "retype the symbol under the cursor (y)",
+ lambda: va("retype"),
+ ),
("Add comment…", "comment at the cursor (;)", lambda: va("comment")),
("Define code", "make code at the cursor (c)", lambda: va("define_code")),
- ("Create function", "define a function at the cursor (p)",
- lambda: va("define_func")),
- ("Make data", "define a data item at the cursor (d)",
- lambda: va("make_data")),
- ("Make string", "define a string at the cursor (a)",
- lambda: va("make_string")),
- ("Undefine", "undefine the item at the cursor (u)",
- lambda: va("undefine")),
- ("Literal format: next", "cycle the literal under the cursor (o)",
- lambda: va("op_format", "cycle")),
- ("Literal format: previous", "the other way round (O)",
- lambda: va("op_format", "back")),
- *((f"Literal format: {label}", f"show the literal as {label} ({fmt})",
- (lambda f=fmt: va("op_format", f)))
- for fmt, label in (("hex", "hexadecimal"), ("dec", "decimal"),
- ("oct", "octal"), ("bin", "binary"),
- ("char", "a character"),
- ("offset", "an offset (reference)"),
- ("stack", "a stack variable"),
- ("default", "IDA's own choice"))),
- ("Toggle opcode bytes", "cycle the opcode-bytes column (B)",
- lambda: va("toggle_opcodes")),
- ("Structs / types editor", "view + edit local types (Ctrl+T)",
- app.action_structs),
- ("Filter functions…", "glob-filter the function list (/)",
- app.action_filter),
- ("Toggle names pane", "function-list sidebar (Ctrl+B)",
- app.action_toggle_functions),
+ (
+ "Create function",
+ "define a function at the cursor (p)",
+ lambda: va("define_func"),
+ ),
+ (
+ "Make data",
+ "define a data item at the cursor (d)",
+ lambda: va("make_data"),
+ ),
+ (
+ "Make string",
+ "define a string at the cursor (a)",
+ lambda: va("make_string"),
+ ),
+ ("Undefine", "undefine the item at the cursor (u)", lambda: va("undefine")),
+ (
+ "Literal format: next",
+ "cycle the literal under the cursor (o)",
+ lambda: va("op_format", "cycle"),
+ ),
+ (
+ "Literal format: previous",
+ "the other way round (O)",
+ lambda: va("op_format", "back"),
+ ),
+ *(
+ (
+ f"Literal format: {label}",
+ f"show the literal as {label} ({fmt})",
+ (lambda f=fmt: va("op_format", f)),
+ )
+ for fmt, label in (
+ ("hex", "hexadecimal"),
+ ("dec", "decimal"),
+ ("oct", "octal"),
+ ("bin", "binary"),
+ ("char", "a character"),
+ ("offset", "an offset (reference)"),
+ ("stack", "a stack variable"),
+ ("default", "IDA's own choice"),
+ )
+ ),
+ (
+ "Toggle opcode bytes",
+ "cycle the opcode-bytes column (B)",
+ lambda: va("toggle_opcodes"),
+ ),
+ (
+ "Structs / types editor",
+ "view + edit local types (Ctrl+T)",
+ app.action_structs,
+ ),
+ (
+ "Filter functions…",
+ "glob-filter the function list (/)",
+ app.action_filter,
+ ),
+ (
+ "Toggle names pane",
+ "function-list sidebar (Ctrl+B)",
+ app.action_toggle_functions,
+ ),
("Save database (.i64)", "persist changes (Ctrl+S)", app.action_save),
("Quit", "exit ida-tui (q)", app.action_quit),
)
@@ -5075,6 +5500,7 @@ class IdaTui(App):
Binding("ctrl+n", "symbols", "Symbols"),
Binding("ctrl+t", "structs", "Structs"),
Binding("ctrl+f", "find", "Find"),
+ Binding("ctrl+r", "refresh_view", "Refresh", show=False),
Binding("ctrl+e", "export", "Export", show=False),
Binding("backslash", "hex", "Hex"),
Binding("s", "toggle_split", "Split", show=False),
@@ -5108,45 +5534,52 @@ class IdaTui(App):
Binding("escape", "back", "Back"),
]
- def __init__(self, open_path: str | None = None, keepalive: bool = True,
- rpc_path: str | None = None, ttl: int = 1800,
- project=None, load_args: str = "", trace_path: str = "") -> None:
+ def __init__(
+ self,
+ open_path: str | None = None,
+ keepalive: bool = True,
+ rpc_path: str | None = None,
+ ttl: int = 1800,
+ project=None,
+ load_args: str = "",
+ trace_path: str = "",
+ ) -> None:
super().__init__()
# Project mode is additive: with no project this is the plain
# single-binary app, unchanged.
self._project = project
self._pool = None
- self._binary: str | None = None # active project binary (label)
+ self._binary: str | None = None # active project binary (label)
self._states: dict[str, BinaryState] = {}
- self._pending_restore = None # entry to reopen after a switch
- self._goto_after_switch = None # cross-binary search hit to land on
- self._hops: list[str] = [] # binaries a navigation crossed FROM
- self._load_for_label = None # project binary the dialog is for
- self._no_functions = False # analysis produced nothing at all
- self._flash: str | None = None # message a pending reload must keep
- self._flash_until = 0.0 # ...until this monotonic time
- self._pending_switch = None # switch waiting on that answer
- self._nav_seq = 0 # bumped per navigation; drops stale ones
+ self._pending_restore = None # entry to reopen after a switch
+ self._goto_after_switch = None # cross-binary search hit to land on
+ self._hops: list[str] = [] # binaries a navigation crossed FROM
+ self._load_for_label = None # project binary the dialog is for
+ self._no_functions = False # analysis produced nothing at all
+ self._flash: str | None = None # message a pending reload must keep
+ self._flash_until = 0.0 # ...until this monotonic time
+ self._pending_switch = None # switch waiting on that answer
+ self._nav_seq = 0 # bumped per navigation; drops stale ones
#: Literal positions for the decompilation being loaded (worker thread
#: -> the view, handed over when the pseudocode is applied).
self._pending_nums: dict = {}
# None = teardown wasn't an explicit quit (crash/kill): save defensively.
# False = the user chose discard, or we already saved on the way out.
self._save_on_exit: bool | None = None
- self._index = None # project-wide symbol/string index
+ self._index = None # project-wide symbol/string index
if project is not None:
from .index import ProjectIndex
from .pool import DatabasePool
+
self._pool = DatabasePool(project, ttl=ttl)
- self._index = ProjectIndex(
- os.path.join(project.index_dir, "project.db"))
+ self._index = ProjectIndex(os.path.join(project.index_dir, "project.db"))
self._binary = project.refs[0].label
open_path = project.refs[0].staged
self._open_path = open_path
self._ttl = ttl
- self._load_args = load_args or "" # first-open options for a headerless blob
- self._new_database = False # Ctrl+L asks Code Mode for a fresh IDB
- self._title = (os.path.basename(open_path) if open_path else "")
+ self._load_args = load_args or "" # first-open options for a headerless blob
+ self._new_database = False # Ctrl+L asks IDA Nexus for a fresh IDB
+ self._title = os.path.basename(open_path) if open_path else ""
#: Where we are in the execution trace, and everything that moves us.
#: Owns the trace state; the _trace/_t/_trail_* properties below
#: forward to it.
@@ -5154,7 +5587,7 @@ class IdaTui(App):
self._do_keepalive = keepalive
self._rpc_path = rpc_path
self._rpc = None
- self.client: CodeModeClient | None = None
+ self.client: NexusClient | None = None
self.program: Program | None = None
self._loading_screen: LoadingScreen | None = None
self._ka = None
@@ -5164,13 +5597,15 @@ class IdaTui(App):
self._filter_term = ""
self._pending_filter = ""
self._filter_timer = None
- self._sort_col = 0 # 0=addr, 1=name, 2=size
+ self._sort_col = 0 # 0=addr, 1=name, 2=size
self._sort_reverse = False
# ONE notion of "which pane you're in": _active, kept in step with focus
# (on_descendant_focus does that while split). There used to be a second,
# _pref, but it was only ever assigned "listing" — see _code_mode().
- self._active = ViewMode.LISTING # currently shown view (in split: the focused pane)
- self._split = False # side-by-side listing + pseudocode
+ self._active = (
+ ViewMode.LISTING
+ ) # currently shown view (in split: the focused pane)
+ self._split = False # side-by-side listing + pseudocode
self._graph_sticky = False # stay in graph mode across navigations
self._split_eamap: list[list[int]] = [] # split: decomp line -> instr EAs
self._split_ea2line: dict[int, int] = {} # split: instr EA -> decomp line
@@ -5186,8 +5621,9 @@ class IdaTui(App):
self._search_ctx: tuple[object | None, int] = (None, 1)
#: The one-line prompts above the footer. Each holds its own context
#: for exactly as long as it is on screen; see idatui/prompt.py.
- self.prompts = PromptBar(self, "search", "rename", "comment",
- "retype", "makedata", "goto", "export")
+ self.prompts = PromptBar(
+ self, "search", "rename", "comment", "retype", "makedata", "goto", "export"
+ )
#: Everything that writes to the database (idatui/edit_ctl.py).
self.edits = EditController(self)
#: What those writes were, so the findings export can say which
@@ -5195,6 +5631,11 @@ class IdaTui(App):
self.journal = Journal()
self._xref_focus_name: str | None = None
self._dirty = False
+ # One subscription for the active database. NexusClient debounces
+ # bursts off the Textual worker pool; the callback re-enters here on the
+ # UI thread to invalidate and reload the visible models.
+ self._idb_event_watch = None
+ self._idb_refresh_seq = 0
# -- layout ------------------------------------------------------------ #
def compose(self) -> ComposeResult:
@@ -5264,7 +5705,7 @@ class IdaTui(App):
if self._rpc_path:
self._start_rpc()
# A file no loader recognises has to be described before it can be
- # opened, so ask BEFORE Code Mode creates it — once IDA has made a database
+ # opened, so ask BEFORE IDA Nexus creates it — once IDA has made a database
# the answer is baked in and changing it requires a fresh-IDB reopen.
if self._project is not None:
ref = self._pending_load_ref()
@@ -5293,8 +5734,10 @@ class IdaTui(App):
if self._load_args:
return False
from .formats import needs_load_options
+
if os.path.exists(self._open_path + ".i64") or os.path.exists(
- os.path.splitext(self._open_path)[0] + ".i64"):
+ os.path.splitext(self._open_path)[0] + ".i64"
+ ):
return False
try:
if registered_database(self._open_path):
@@ -5316,18 +5759,23 @@ class IdaTui(App):
if not self._can_reload():
if self.client is not None and self.client.backend == "gui":
self._status(
- "reload unavailable for a GUI-owned database — reopen it in IDA")
+ "reload unavailable for a GUI-owned database — reopen it in IDA"
+ )
else:
self._status("nothing to reload")
return
n = len(self._func_index) if self._func_index else 0
- note = ("this image has no functions, so nothing is lost"
- if n == 0 else
- f"discards the database for this binary \u2014 {n} "
- f"function{'s' if n != 1 else ''}, plus any names and comments "
- f"you've added")
- self.push_screen(ConfirmScreen("Reload with different options?", note),
- self._on_reload_confirmed)
+ note = (
+ "this image has no functions, so nothing is lost"
+ if n == 0
+ else f"discards the database for this binary \u2014 {n} "
+ f"function{'s' if n != 1 else ''}, plus any names and comments "
+ f"you've added"
+ )
+ self.push_screen(
+ ConfirmScreen("Reload with different options?", note),
+ self._on_reload_confirmed,
+ )
def _on_reload_confirmed(self, yes) -> None: # type: ignore[no-untyped-def]
if not yes:
@@ -5337,7 +5785,7 @@ class IdaTui(App):
ref = self._project.by_label(self._binary)
if ref is not None:
path, label = ref.source, ref.label
- # Release our lease first. Code Mode waits for a managed worker's final
+ # Release our lease first. IDA Nexus waits for a managed worker's final
# lease grace, then creates the replacement IDB atomically. A GUI-backed
# database is rejected by _can_reload(): the TUI must never close it.
self._release_database()
@@ -5356,6 +5804,7 @@ class IdaTui(App):
self._ask_load_options(path, label=label)
def _release_database(self) -> None:
+ self._stop_idb_event_watch()
if self.program is not None:
self.program.close()
if self._pool is not None and self._binary is not None:
@@ -5392,7 +5841,8 @@ class IdaTui(App):
# binary as already described and never ask again.
self._project.set_load(label, processor="", base=0)
self._project._entries[self._project._refs.index(ref)].pop(
- "processor", None)
+ "processor", None
+ )
self._project.save()
self._load_args = ""
if path:
@@ -5412,14 +5862,16 @@ class IdaTui(App):
if ref is None or ref.load_args:
return None
if os.path.exists(ref.db) or os.path.exists(
- os.path.splitext(ref.staged)[0] + ".i64"):
- return None # already analysed: the .i64 records how
+ os.path.splitext(ref.staged)[0] + ".i64"
+ ):
+ return None # already analysed: the .i64 records how
try:
if registered_database(ref.staged, output_database=ref.db):
return None
except Exception:
pass
from .formats import needs_load_options
+
return ref if needs_load_options(ref.source) else None
def _ask_load_options(self, path: str, label: str | None = None) -> None:
@@ -5432,6 +5884,7 @@ class IdaTui(App):
def _on_load_options(self, choice) -> None: # type: ignore[no-untyped-def]
from .formats import load_args
+
choice = choice or {}
label, self._load_for_label = self._load_for_label, None
proc, base = choice.get("processor", ""), int(choice.get("base", 0) or 0)
@@ -5464,6 +5917,7 @@ class IdaTui(App):
def _start_rpc(self) -> None:
from .rpc import RpcServer
+
self._rpc = RpcServer(self, self._rpc_path)
async def _serve() -> None:
@@ -5485,6 +5939,7 @@ class IdaTui(App):
when the user has read it and moved on.
"""
import time as _time
+
if priority:
self._flash = text
self._flash_until = _time.monotonic() + 8.0
@@ -5502,7 +5957,11 @@ class IdaTui(App):
# It stops being true the moment a function exists, though: latching it
# meant the warning survived defining one with `p` and kept telling you
# the load was wrong when it no longer was.
- if self._no_functions and self._func_index is not None and len(self._func_index):
+ if (
+ self._no_functions
+ and self._func_index is not None
+ and len(self._func_index)
+ ):
self._no_functions = False
if self._no_functions:
text += " \u2014 no functions: wrong processor/base? Ctrl+L to reload"
@@ -5529,18 +5988,183 @@ class IdaTui(App):
subprocess.run(
["tmux", "load-buffer", "-w", "-"],
input=text.encode("utf-8", "replace"),
- stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=2.0)
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ timeout=2.0,
+ )
except Exception: # noqa: BLE001
pass
return len(text)
+ # -- live refresh from shared IDB changes ----------------------------- #
+ def _start_idb_event_watch(self, client: NexusClient) -> None:
+ self._stop_idb_event_watch()
+ watch = getattr(client, "watch_idb_events", None)
+ if watch is None: # IDA-free test doubles and pre-event adapters
+ return
+
+ def changed(events) -> None: # listener thread
+ try:
+ self.call_from_thread(self._refresh_idb_events, client, events)
+ except Exception: # noqa: BLE001 -- app teardown can win this race
+ pass
+
+ def failed(error: BaseException) -> None: # listener thread
+ try:
+ self.call_from_thread(self._idb_event_watch_failed, client, error)
+ except Exception: # noqa: BLE001 -- app teardown can win this race
+ pass
+
+ self._idb_event_watch = watch(changed, on_error=failed, debounce=0.2)
+
+ def _stop_idb_event_watch(self) -> None:
+ watcher, self._idb_event_watch = self._idb_event_watch, None
+ if watcher is not None:
+ watcher.close()
+
+ def _idb_event_watch_failed(
+ self, client: NexusClient, error: BaseException
+ ) -> None:
+ if client is not self.client:
+ return
+ if isinstance(error, IDAConnectionError):
+ self._on_connection_lost()
+ else:
+ self._status(f"live database refresh stopped: {error}")
+
+ def _listing_event_anchor(self) -> ViewAnchor:
+ """Capture the listing position even when the split's decompiler has focus."""
+ anchor = ViewAnchor(view=self._active)
+ listing = self.query_one(ListingView)
+ model = listing.model
+ if model is None:
+ return anchor
+ anchor.cursor_x = listing.cursor_x
+ anchor.ea = listing._cursor_ea()
+ top = round(listing.scroll_offset.y)
+ head = model.cached_line(top) or model.get(top)
+ anchor.top_ea = getattr(head, "ea", None)
+ return anchor
+
+ def _refresh_idb_events(
+ self, client: NexusClient, events: tuple[dict, ...]
+ ) -> None:
+ """Invalidate once per external edit burst and reload the active surface."""
+ program = self.program
+ if (
+ not events
+ or client is not self.client
+ or program is None
+ or program.client is not client
+ ):
+ return
+ self._idb_refresh_seq += 1
+ seq = self._idb_refresh_seq
+ entry = self._cur
+ anchor = self._listing_event_anchor()
+ hex_ea = self.query_one(HexView).cursor_va() if self.is_hex else None
+ graph_ea = self.query_one(GraphView)._cursor_ea() if self.is_graph else None
+ decomp = self.query_one(DecompView)
+ if entry is not None and decomp.loaded_ea == entry.ea:
+ entry.dec_cursor = decomp.cursor
+ entry.dec_cursor_x = decomp.cursor_x
+ entry.dec_scroll_y = round(decomp.scroll_offset.y)
+ entry.dec_scroll_x = round(decomp.scroll_offset.x)
+
+ program.invalidate_external()
+ self._status(
+ f"{len(events)} external database "
+ f"change{'s' if len(events) != 1 else ''} — refreshing…"
+ )
+ self._reindex_functions()
+
+ if self.is_hex:
+ self.query_one(HexView).model = None
+ self._load_hex_model(hex_ea)
+ return
+ if self.is_graph and entry is not None:
+ self._load_graph(entry.ea, graph_ea or entry.ea)
+ return
+ if entry is None:
+ return
+
+ # A split needs both halves rebuilt; a decompiler-only view still keeps
+ # the hidden listing fresh so Tab does not reveal pre-event rows.
+ decomp.loaded_ea = None
+ self._reload_idb_listing(program, seq, entry, anchor)
+ if self.is_decomp or self._split:
+ self._show_active()
+
+ @work(thread=True, exclusive=True, group="idb-refresh")
+ def _reload_idb_listing(
+ self, program: Program, seq: int, entry: NavEntry, anchor: ViewAnchor
+ ) -> None:
+ target = anchor.ea if anchor.ea is not None else entry.ea
+ model = program.listing(target)
+ cursor = top = -1
+ if model is not None:
+ model.ensure_ea(target)
+ cursor, top = self._anchor_rows(anchor, model, target)
+ fn = program.function_of(entry.ea)
+ name = fn.name if fn is not None else program.region_label(entry.ea)
+ self.app.call_from_thread(
+ self._apply_idb_listing,
+ program,
+ seq,
+ entry,
+ model,
+ cursor,
+ top,
+ anchor.cursor_x,
+ name,
+ fn is None,
+ )
+
+ def _apply_idb_listing(
+ self,
+ program: Program,
+ seq: int,
+ entry: NavEntry,
+ model,
+ cursor: int,
+ top: int,
+ cursor_x: int,
+ name: str,
+ is_region: bool,
+ ) -> None:
+ if (
+ program is not self.program
+ or seq != self._idb_refresh_seq
+ or entry is not self._cur
+ ):
+ return
+ if model is None:
+ self._status(f"{entry.ea:#x} is no longer in a loaded segment")
+ return
+ entry.name = name
+ entry.is_region = is_region
+ entry.cursor = max(cursor, 0)
+ entry.cursor_x = cursor_x
+ if top >= 0:
+ entry.scroll_y = top
+ self.query_one(ListingView).load(
+ model,
+ name,
+ cursor=entry.cursor,
+ cursor_x=entry.cursor_x,
+ scroll_y=top if top >= 0 else None,
+ )
+ if self.is_listing:
+ self._show_active()
+
# -- connection loss / recovery --------------------------------------- #
def _handle_exception(self, error: BaseException) -> None:
- """Intercept a lost Code Mode lease so the app can rediscover the DB.
+ """Intercept a lost IDA Nexus lease so the app can rediscover the DB.
Everything unrelated to database connectivity crashes as usual.
"""
from textual.worker import WorkerFailed
+
orig = error.error if isinstance(error, WorkerFailed) else error
if isinstance(orig, IDAConnectionError):
self._on_connection_lost()
@@ -5552,7 +6176,8 @@ class IdaTui(App):
return
self._reconnecting = True
self._conn_screen = LoadingScreen(
- "the analysis server", note="connection lost \u2014 reconnecting\u2026")
+ "the analysis server", note="connection lost \u2014 reconnecting\u2026"
+ )
self.push_screen(self._conn_screen)
self._reconnect()
@@ -5571,37 +6196,65 @@ class IdaTui(App):
@work(thread=True, exclusive=True, group="reconnect")
def _reconnect(self) -> None:
- # The registered instance disappeared. Rediscover it; Code Mode may find
- # a GUI/replacement worker, then we rebuild caches against the new handle.
+ # Rediscovery is attach-only. If a GUI owner closes its database, a TUI
+ # must not silently reopen it by spawning a headless worker.
try:
if self._open_path is None:
- self.app.call_from_thread(self._reconnect_failed,
- "no binary to reopen")
+ self.app.call_from_thread(self._reconnect_failed, "no binary to reopen")
return
- client = CodeModeClient(self._open_path, ttl=self._ttl,
- load_args=self._load_args)
- client.connect(progress=lambda m: self.app.call_from_thread(
- self._conn_note, m))
+ if self._project is not None and self._binary is not None:
+ ref = self._project.by_label(self._binary)
+ client = NexusClient(
+ ref.staged,
+ ttl=self._ttl,
+ load_args=ref.load_args,
+ output_database=ref.db,
+ spawn=False,
+ )
+ else:
+ client = NexusClient(
+ self._open_path,
+ ttl=self._ttl,
+ load_args=self._load_args,
+ spawn=False,
+ )
+ client.connect(
+ progress=lambda m: self.app.call_from_thread(self._conn_note, m)
+ )
except Exception as e: # noqa: BLE001
self.app.call_from_thread(self._reconnect_failed, str(e))
return
self.app.call_from_thread(self._after_reconnect, client, Program(client))
- def _after_reconnect(self, client: "CodeModeClient", program: "Program") -> None:
+ def _after_reconnect(self, client: "NexusClient", program: "Program") -> None:
+ old_client, old_program = self.client, self.program
+ self._stop_idb_event_watch()
+ if old_program is not None:
+ old_program.close()
+ if self._pool is not None and self._binary is not None:
+ self._pool.replace_client(self._binary, old_client, client)
+ if old_client is not None and old_client is not client:
+ old_client.close()
self.client = client
self.program = program
+ self._start_idb_event_watch(client)
self._reconnecting = False
+ self._dirty = False
self._dismiss_conn()
- self._status("reconnected \u2014 reloading\u2026")
- self._load_functions() # rebuild the function index against the new client
+ self._status("reattached — reloading persisted state…")
+ self._load_functions()
cur = self._cur
- if cur is not None: # refresh the current view with the new program
+ if cur is not None:
self._open_entry(cur, push=False)
def _reconnect_failed(self, why: str) -> None:
self._reconnecting = False
- self._conn_note(f"reconnect failed: {why} \u2014 retry on next action, or 'q'")
- self._status(f"reconnect failed: {why}")
+ note = (
+ f"database owner closed: {why} — reopen it in IDA, then "
+ "Esc and retry an action; or q to quit"
+ )
+ self._conn_note(note)
+ self._status(note)
# -- connection + initial load ---------------------------------------- #
@work(thread=True, exclusive=True, group="connect")
@@ -5628,17 +6281,20 @@ class IdaTui(App):
return
self.client = client
self.program = program
+ self._start_idb_event_watch(client)
self._new_database = False
self.app.call_from_thread(
- self._status, f"{module} [{client.backend}] — loading functions…")
+ self._status, f"{module} [{client.backend}] — loading functions…"
+ )
self._load_functions()
def _open_database_client(self): # type: ignore[no-untyped-def]
- """Attach through Code Mode, reusing a GUI or managed idalib database."""
+ """Attach through IDA Nexus, reusing a GUI or managed idalib database."""
if self._pool is not None: # project mode: the pool owns the leases
label = self._binary or self._project.refs[0].label
- client = self._pool.get(label, progress=lambda m:
- self.app.call_from_thread(self._status, m))
+ client = self._pool.get(
+ label, progress=lambda m: self.app.call_from_thread(self._status, m)
+ )
self._binary = label
self._pool.set_active(label)
self._open_path = self._project.by_label(label).staged
@@ -5646,17 +6302,21 @@ class IdaTui(App):
return client
if not self._open_path:
self.app.call_from_thread(
- self._status, "Code Mode needs a database or executable path")
+ self._status, "IDA Nexus needs a database or executable path"
+ )
self.app.call_from_thread(self._dismiss_loading)
return None
base = os.path.basename(self._open_path)
self.app.call_from_thread(
- self._status, f"discovering Code Mode database for {base}…")
- client = CodeModeClient(self._open_path, ttl=self._ttl,
- load_args=self._load_args,
- new_database=self._new_database)
- client.connect(progress=lambda m: self.app.call_from_thread(
- self._status, m))
+ self._status, f"discovering IDA Nexus database for {base}…"
+ )
+ client = NexusClient(
+ self._open_path,
+ ttl=self._ttl,
+ load_args=self._load_args,
+ new_database=self._new_database,
+ )
+ client.connect(progress=lambda m: self.app.call_from_thread(self._status, m))
return client
@work(thread=True, exclusive=True, group="load-funcs")
@@ -5664,7 +6324,9 @@ class IdaTui(App):
assert self.program is not None
idx = self.program.functions()
self._func_index = idx
- self.app.call_from_thread(lambda: self.query_one("#func-table", DataTable).clear())
+ self.app.call_from_thread(
+ lambda: self.query_one("#func-table", DataTable).clear()
+ )
last = 0
while not idx.complete:
idx.load_next_page()
@@ -5672,21 +6334,20 @@ class IdaTui(App):
last = len(idx)
if rows:
self.app.call_from_thread(self._append_rows, rows)
- self.app.call_from_thread(
- self._status, f"{last} functions…"
- )
+ self.app.call_from_thread(self._status, f"{last} functions…")
# If a filter is active (typed during load), re-apply it over the full set.
if self._filter_term:
self.app.call_from_thread(self._apply_filter, self._filter_term)
else:
self.app.call_from_thread(
- self._status, f"{len(idx)} functions (Ctrl+N: find symbol)")
+ self._status, f"{len(idx)} functions (Ctrl+N: find symbol)"
+ )
# Land somewhere useful instead of an empty pane: main() if present,
# otherwise pop the fuzzy symbol picker.
self.app.call_from_thread(self._auto_land)
self._index_binary() # project mode: keep the cross-binary index fresh
if self.trace_ctl.armed:
- self._load_trace() # needs the index above: rebasing reads it
+ self._load_trace() # needs the index above: rebasing reads it
@work(thread=True, exclusive=True, group="prewarm")
def _prewarm_provider(self) -> None:
@@ -5707,6 +6368,7 @@ class IdaTui(App):
if not imps:
return
from collections import Counter
+
votes: Counter = Counter()
for name in {i.name for i in imps}:
for h in self._index.providers(name, exclude=self._binary):
@@ -5719,25 +6381,28 @@ class IdaTui(App):
try:
if self._pool.prewarm(cand):
self.app.call_from_thread(
- self._status, f"pre-warmed {cand} (provides {n} imports)")
+ self._status, f"pre-warmed {cand} (provides {n} imports)"
+ )
except Exception: # noqa: BLE001 -- speculative work must never surface
pass
@work(thread=True, exclusive=True, group="index")
def _index_binary(self) -> None:
"""Fold this binary's symbols + strings into the project index, so it can
- be searched later even when its Code Mode lease is gone."""
+ be searched later even when its IDA Nexus lease is gone."""
if self._index is None or self._project is None or self._binary is None:
return
ref = self._project.by_label(self._binary)
if ref is None or not self._index.is_stale(self._binary, ref.source):
return
from .index import KIND_EXPORT, KIND_FUNC, KIND_IMPORT, KIND_STRING
+
idx = self._func_index
- entries = [(KIND_FUNC, f.addr, f.name) for f in (idx.all_loaded() if idx else [])]
+ entries = [
+ (KIND_FUNC, f.addr, f.name) for f in (idx.all_loaded() if idx else [])
+ ]
try:
- entries += [(KIND_STRING, s.addr, s.text)
- for s in self.program.strings()]
+ entries += [(KIND_STRING, s.addr, s.text) for s in self.program.strings()]
except Exception: # noqa: BLE001 -- symbols alone are still worth indexing
pass
try:
@@ -5752,7 +6417,8 @@ class IdaTui(App):
self.app.call_from_thread(self._status, f"indexing failed: {e}")
return
self.app.call_from_thread(
- self._status, f"indexed {self._binary}: {n} symbols, strings + linkage")
+ self._status, f"indexed {self._binary}: {n} symbols, strings + linkage"
+ )
self._prewarm_provider()
# -- initial landing --------------------------------------------------- #
@@ -5795,8 +6461,9 @@ class IdaTui(App):
first = self._func_index.get(0)
if first is not None:
self._open_function(first.addr, first.name)
- self._status(f"no entry function — opened {first.name} "
- "(Ctrl+N: find symbol)")
+ self._status(
+ f"no entry function — opened {first.name} (Ctrl+N: find symbol)"
+ )
else:
self.action_symbols()
else:
@@ -5825,11 +6492,16 @@ class IdaTui(App):
if start is None:
self._status("no functions and no segments \u2014 nothing to show")
return
- self._open_at(start, self.program.section_of(start) or "image",
- cursor=0, push=True, is_region=True)
+ self._open_at(
+ start,
+ self.program.section_of(start) or "image",
+ cursor=0,
+ push=True,
+ is_region=True,
+ )
def _can_reload(self) -> bool:
- """Whether Code Mode can replace this IDB with different options.
+ """Whether IDA Nexus can replace this IDB with different options.
A GUI database is owned by the user and has no remote close/rollback
route. Managed idalib databases can be released and reopened fresh.
@@ -5883,7 +6555,9 @@ class IdaTui(App):
ci = term.islower()
is_glob = ("*" in term) or ("?" in term)
needle = term.lower() if ci else term
- pat = needle if (is_glob and ("*" in needle or "?" in needle)) else f"*{needle}*"
+ pat = (
+ needle if (is_glob and ("*" in needle or "?" in needle)) else f"*{needle}*"
+ )
matched: list[tuple[Func, tuple[int, int] | None]] = []
for f in funcs:
if not term:
@@ -5968,9 +6642,10 @@ class IdaTui(App):
if not funcs:
self._status("functions still loading…")
return
- self.push_screen(SymbolPalette(funcs, index=self._index,
- binary=self._binary),
- self._on_symbol_chosen)
+ self.push_screen(
+ SymbolPalette(funcs, index=self._index, binary=self._binary),
+ self._on_symbol_chosen,
+ )
def _on_symbol_chosen(self, choice) -> None: # type: ignore[no-untyped-def]
if choice is None:
@@ -6005,11 +6680,15 @@ class IdaTui(App):
active one and other still-resident binaries can be dirty.
"""
if self._pool is None:
- return [os.path.basename(self._open_path or "database")] if self._dirty else []
+ return (
+ [os.path.basename(self._open_path or "database")] if self._dirty else []
+ )
out = [self._binary] if (self._dirty and self._binary) else []
- out += [label for label, st in self._states.items()
- if st.dirty and label != self._binary
- and self._pool.is_resident(label)]
+ out += [
+ label
+ for label, st in self._states.items()
+ if st.dirty and label != self._binary and self._pool.is_resident(label)
+ ]
return out
async def action_quit(self) -> None:
@@ -6024,20 +6703,49 @@ class IdaTui(App):
def _on_quit_choice(self, choice: str | None) -> None:
if choice == "discard":
- # Code Mode has no rollback/close-without-save operation. For GUI
- # sessions this leaves changes dirty in IDA; a managed worker owns
- # its final save policy and may persist them on final lease release.
- self._save_on_exit = False
- self.exit()
+ # Whole-session discard is legal only for the final managed lease.
+ # GUI/shared sessions retain state and inherit finalization.
+ dirty = self._dirty_labels()
+ self._loading_screen = LoadingScreen(
+ "discarding", note="finalizing database leases…"
+ )
+ self.push_screen(self._loading_screen)
+ self._discard_then_exit(dirty)
elif choice == "save":
# Save with the overlay up: writing a big .i64 takes seconds, and
# doing it during teardown would look like a hang with no UI left.
- self._loading_screen = LoadingScreen("saving", note="writing databases\u2026")
+ self._loading_screen = LoadingScreen(
+ "saving", note="writing databases\u2026"
+ )
self.push_screen(self._loading_screen)
self._save_then_exit()
# None: cancel, stay put
@work(thread=True, exclusive=True, group="save-exit")
+ def _discard_then_exit(self, dirty: list[str]) -> None:
+ try:
+ if self._pool is not None:
+ transferred = self._pool.discard_changes(dirty)
+ elif self.client is not None:
+ transferred = [] if self.client.discard_database() else dirty
+ else:
+ transferred = dirty
+ except Exception as exc: # noqa: BLE001 -- keep the app open on failure
+ self.app.call_from_thread(self._discard_failed, str(exc))
+ return
+ self.app.call_from_thread(self._finish_discard, transferred)
+
+ def _discard_failed(self, why: str) -> None:
+ self._dismiss_loading()
+ self._status(f"discard failed: {why}", priority=True)
+
+ def _finish_discard(self, transferred: list[str]) -> None:
+ if transferred and self._loading_screen is not None:
+ labels = ", ".join(transferred)
+ self._loading_screen.update_note(f"finalization transferred: {labels}")
+ self._finish_exit()
+
+ @work(thread=True, exclusive=True, group="save-exit")
def _save_then_exit(self) -> None:
try:
if self._pool is not None:
@@ -6049,7 +6757,9 @@ class IdaTui(App):
self.app.call_from_thread(self._finish_exit)
def _finish_exit(self) -> None:
- self._save_on_exit = False # already written above
+ # Teardown must not save again: save, discard, or ownership transfer was
+ # already decided by the quit path.
+ self._save_on_exit = False
self._dirty = False
self.exit()
@@ -6067,8 +6777,7 @@ class IdaTui(App):
return
if self._prompt_active():
return
- self.push_screen(ProjectPalette(self._pool.status()),
- self._on_binary_chosen)
+ self.push_screen(ProjectPalette(self._pool.status()), self._on_binary_chosen)
def _on_binary_chosen(self, label: str | None) -> None:
if label and label != self._binary:
@@ -6086,10 +6795,16 @@ class IdaTui(App):
# the pool hand us a lease (attaching + evicting as the budget dictates).
if self._binary is not None:
self._states[self._binary] = BinaryState(
- label=self._binary, program=self.program,
- func_index=self._func_index, nav=list(self._nav), cur=self._cur,
- active=self._active, split=self._split,
- filter_term=self._filter_term, dirty=self._dirty)
+ label=self._binary,
+ program=self.program,
+ func_index=self._func_index,
+ nav=list(self._nav),
+ cur=self._cur,
+ active=self._active,
+ split=self._split,
+ filter_term=self._filter_term,
+ dirty=self._dirty,
+ )
self._loading_screen = LoadingScreen(label, note="switching\u2026")
self.push_screen(self._loading_screen)
self._do_switch(label)
@@ -6098,8 +6813,9 @@ class IdaTui(App):
def _do_switch(self, label: str) -> None:
assert self._pool is not None
try:
- client = self._pool.get(label, progress=lambda m:
- self.app.call_from_thread(self._status, m))
+ client = self._pool.get(
+ label, progress=lambda m: self.app.call_from_thread(self._status, m)
+ )
except Exception as e: # noqa: BLE001
self.app.call_from_thread(self._switch_failed, label, str(e))
return
@@ -6107,15 +6823,18 @@ class IdaTui(App):
# The Program (and its caches) only survive while that lease does; an
# evicted binary reattaches. Either way the nav
# history is just addresses, so it always survives.
- reuse = (st is not None and st.program is not None
- and getattr(st.program, "client", None) is client)
+ reuse = (
+ st is not None
+ and st.program is not None
+ and getattr(st.program, "client", None) is client
+ )
program = st.program if reuse else Program(client)
- self.app.call_from_thread(self._after_switch, label, client, program,
- st, reuse)
+ self.app.call_from_thread(self._after_switch, label, client, program, st, reuse)
def _after_switch(self, label, client, program, st, reuse) -> None: # type: ignore[no-untyped-def]
self.client = client
self.program = program
+ self._start_idb_event_watch(client)
self._binary = label
self._pool.set_active(label)
self._open_path = self._project.by_label(label).staged
@@ -6186,9 +6905,10 @@ class IdaTui(App):
self._status("no strings found (needs the list_strings tool)")
return
self._status(f"strings: {len(items)}")
- self.push_screen(StringsPalette(items, index=self._index,
- binary=self._binary),
- self._on_string_chosen)
+ self.push_screen(
+ StringsPalette(items, index=self._index, binary=self._binary),
+ self._on_string_chosen,
+ )
def action_find(self) -> None:
"""Ctrl+F: search the whole database — disassembly text, or bytes."""
@@ -6205,7 +6925,7 @@ class IdaTui(App):
try:
seed = view.word_under_cursor() or ""
except Exception: # noqa: BLE001 -- a seed is a nicety, never a
- seed = "" # reason not to open the search
+ seed = "" # reason not to open the search
self.push_screen(SearchPalette(self.program, seed), self._on_hit_chosen)
def _on_hit_chosen(self, hit) -> None: # type: ignore[no-untyped-def]
@@ -6216,8 +6936,9 @@ class IdaTui(App):
# there. The status names the exact address so it isn't lost.
self._goto_ea(hit.head, push=True)
if hit.addr != hit.head:
- self._status(f"match at {hit.addr:#x} (inside {hit.head:#x})",
- priority=True)
+ self._status(
+ f"match at {hit.addr:#x} (inside {hit.head:#x})", priority=True
+ )
def _on_string_chosen(self, choice) -> None: # type: ignore[no-untyped-def]
if choice is None:
@@ -6228,6 +6949,155 @@ class IdaTui(App):
return
self._goto_ea(addr, push=True) # land on the literal in the listing
+ def action_refresh_view(self) -> None:
+ """Ctrl+R: discard cached data and reload the visible view in place."""
+ if self.program is None or self._cur is None:
+ self._status("nothing to refresh")
+ return
+ if self._prompt_active() or self.screen is not self.screen_stack[0]:
+ return
+
+ if self.is_hex:
+ hx = self.query_one(HexView)
+ if hx.model is None:
+ self._status("hex: nothing to refresh")
+ return
+ self._status("hex — refreshing…")
+ hx.model.invalidate()
+ # Re-read the visible blocks off the UI thread. ``center=False``
+ # preserves both the byte cursor and the viewport.
+ hx._prime(center=False)
+ return
+
+ cur = self._cur
+ mode = self._active
+ split = self._split
+ listing_anchor = None
+ if self.is_listing or split:
+ # _anchor() follows the active pane. In split mode pseudocode may be
+ # active, but the listing must still round-trip through addresses:
+ # row indices do not survive an external structure change.
+ lst = self.query_one(ListingView)
+ listing_anchor = ViewAnchor(view=ViewMode.LISTING, cursor_x=lst.cursor_x)
+ model = lst.model
+ if model is not None:
+ listing_anchor.ea = lst._cursor_ea()
+ top = round(lst.scroll_offset.y)
+ h = model.cached_line(top) or model.get(top)
+ listing_anchor.top_ea = getattr(h, "ea", None)
+
+ refresh_decomp = self.is_decomp or split
+ if refresh_decomp:
+ # Keep the live pseudocode position; NavEntry is only updated when
+ # navigating away and may lag behind the widget.
+ dec = self.query_one(DecompView)
+ if dec.loaded_ea == cur.ea:
+ cur.dec_cursor = dec.cursor
+ cur.dec_cursor_x = dec.cursor_x
+ cur.dec_scroll_y = round(dec.scroll_offset.y)
+ cur.dec_scroll_x = round(dec.scroll_offset.x)
+ dec.loading = True
+
+ want_ea = self.query_one(GraphView)._cursor_ea() if self.is_graph else None
+ if self.is_graph:
+ self._graph_sticky = True
+ self._status(f"{cur.name} — refreshing graph…")
+ else:
+ self._status(f"{cur.name} — refreshing…")
+ self._refresh_view(
+ cur, mode, split, listing_anchor, refresh_decomp, want_ea, self.program
+ )
+
+ @work(thread=True, exclusive=True, group="refresh-view")
+ def _refresh_view(
+ self,
+ cur: NavEntry,
+ mode: ViewMode,
+ split: bool,
+ anchor: ViewAnchor | None,
+ refresh_decomp: bool,
+ want_ea: int | None,
+ program,
+ ) -> None: # type: ignore[no-untyped-def]
+ """Invalidate and rebuild without blocking Textual's event loop."""
+ try:
+ program.bump_items()
+ if refresh_decomp:
+ program.force_recompile(cur.ea)
+
+ model = None
+ cursor = top = -1
+ if anchor is not None:
+ target = anchor.ea if anchor.ea is not None else cur.ea
+ model = program.listing(target)
+ if model is not None:
+ model.ensure_ea(target)
+ cursor, top = self._anchor_rows(anchor, model, target)
+ except Exception as exc: # noqa: BLE001 -- a refresh is recoverable
+ diag.note("refresh_view", exc)
+ self.app.call_from_thread(self._view_refresh_failed, cur, program, str(exc))
+ return
+ self.app.call_from_thread(
+ self._apply_view_refresh,
+ cur,
+ mode,
+ split,
+ anchor,
+ refresh_decomp,
+ want_ea,
+ program,
+ model,
+ cursor,
+ top,
+ )
+
+ def _view_refresh_failed(self, cur: NavEntry, program, error: str) -> None: # type: ignore[no-untyped-def]
+ if self.program is program and self._cur is cur:
+ self.query_one(DecompView).loading = False
+ self._status(f"refresh failed: {error}", priority=True)
+
+ def _apply_view_refresh(
+ self,
+ cur: NavEntry,
+ mode: ViewMode,
+ split: bool,
+ anchor: ViewAnchor | None,
+ refresh_decomp: bool,
+ want_ea: int | None,
+ program,
+ model,
+ cursor: int,
+ top: int,
+ ) -> None: # type: ignore[no-untyped-def]
+ # A binary switch or navigation completed while the refresh was in
+ # flight. Its newer view wins; never drag the user back.
+ if self.program is not program or self._cur is not cur:
+ return
+ self._active = mode
+ self._split = split
+
+ if self.is_graph:
+ self._load_graph(cur.ea, want_ea)
+ return
+
+ if anchor is not None and model is not None:
+ lst = self.query_one(ListingView)
+ cur.cursor = max(cursor, 0)
+ cur.cursor_x = anchor.cursor_x
+ cur.scroll_y = top
+ lst.load(
+ model,
+ cur.name,
+ cursor=cur.cursor,
+ cursor_x=cur.cursor_x,
+ scroll_y=top if top >= 0 else None,
+ )
+ if refresh_decomp:
+ self.query_one(DecompView).loaded_ea = None
+ self._show_active()
+ if not refresh_decomp:
+ self._status(f"{cur.name} — refreshed", priority=True)
+
def action_toggle_view(self) -> None:
"""Tab: switch the code pane between disassembly and pseudocode (or leave
the hex view back to the preferred code view)."""
@@ -6246,8 +7116,11 @@ class IdaTui(App):
if self._split:
# In split mode Tab/F5 just moves focus between the two panes.
self._active = ViewMode.DECOMP if self.is_listing else ViewMode.LISTING
- (self.query_one(DecompView) if self.is_decomp
- else self.query_one(ListingView)).focus()
+ (
+ self.query_one(DecompView)
+ if self.is_decomp
+ else self.query_one(ListingView)
+ ).focus()
self._sync_split(self._active) # re-link from the new driver
self._status_for_cur("split")
return
@@ -6339,7 +7212,8 @@ class IdaTui(App):
if fn is None:
self.app.call_from_thread(
self._decomp_from_listing_failed,
- "F5 — cursor is not inside a defined function ('p' to make one)")
+ "F5 — cursor is not inside a defined function ('p' to make one)",
+ )
return
dec_idx = self._decomp_line_for(fn.addr, ea)
self.app.call_from_thread(self._enter_decomp, fn.addr, fn.name, dec_idx)
@@ -6361,8 +7235,9 @@ class IdaTui(App):
ret.cursor_x = lst.cursor_x
ret.scroll_y = round(lst.scroll_offset.y)
self._decomp_return = ret
- entry = NavEntry(ea=fn_addr, name=fn_name, is_region=False,
- dec_cursor=max(dec_idx, 0))
+ entry = NavEntry(
+ ea=fn_addr, name=fn_name, is_region=False, dec_cursor=max(dec_idx, 0)
+ )
self._cur = entry
self._active = ViewMode.DECOMP
self._show_active()
@@ -6375,8 +7250,10 @@ class IdaTui(App):
self._status("open a function first")
return
if not self._split and self.size.width < _SPLIT_MIN_WIDTH:
- self._status(f"terminal too narrow for split — need ≈{_SPLIT_MIN_WIDTH} "
- f"cols (have {self.size.width})")
+ self._status(
+ f"terminal too narrow for split — need ≈{_SPLIT_MIN_WIDTH} "
+ f"cols (have {self.size.width})"
+ )
return
self._split = not self._split
if self._active not in ("listing", "decomp"):
@@ -6406,8 +7283,11 @@ class IdaTui(App):
self._graph_sticky = True
gv = self.query_one(GraphView)
ea = self._graph_target_ea()
- if gv.loaded_ea is not None and gv.fc is not None \
- and gv.fc.func_ea == self._cur.ea:
+ if (
+ gv.loaded_ea is not None
+ and gv.fc is not None
+ and gv.fc.func_ea == self._cur.ea
+ ):
self._active = ViewMode.GRAPH
self._split = False
self._show_active()
@@ -6444,8 +7324,13 @@ class IdaTui(App):
err = f"{type(e).__name__}: {e}"
self.app.call_from_thread(self._apply_graph, func_ea, want_ea, fc, err)
- def _apply_graph(self, func_ea: int, want_ea: int | None, fc, # type: ignore[no-untyped-def]
- err: str) -> None:
+ def _apply_graph(
+ self,
+ func_ea: int,
+ want_ea: int | None,
+ fc, # type: ignore[no-untyped-def]
+ err: str,
+ ) -> None:
if self._cur is None or self._cur.ea != func_ea:
return # a newer navigation won
if not self._graph_sticky and not self.is_graph:
@@ -6454,13 +7339,17 @@ class IdaTui(App):
# here drags them back into a graph they already dismissed.
return
if fc is None:
- self._status(err or "no control-flow graph for this function "
- "(is it a thunk or an import?)")
+ self._status(
+ err
+ or "no control-flow graph for this function "
+ "(is it a thunk or an import?)"
+ )
return
if len(fc.blocks) > self.GRAPH_MAX_BLOCKS:
self._status(
f"{fc.name}: {len(fc.blocks)} blocks — too many to graph "
- f"(limit {self.GRAPH_MAX_BLOCKS}); staying in the listing")
+ f"(limit {self.GRAPH_MAX_BLOCKS}); staying in the listing"
+ )
return
gv = self.query_one(GraphView)
gv.set_graph(fc, want_ea)
@@ -6474,11 +7363,15 @@ class IdaTui(App):
if gv.lay is None or gv.fc is None:
return
s = gv.lay.stats
- loops = f", {s['back']} loop{'s' if s['back'] != 1 else ''}" if s["back"] else ""
+ loops = (
+ f", {s['back']} loop{'s' if s['back'] != 1 else ''}" if s["back"] else ""
+ )
+ eng = "" if s.get("engine") == "native" else f", {s.get('engine')}"
self._status(
f"{gv.fc.name} @ {gv.fc.func_ea:#x} [graph: {s['blocks']} blocks, "
- f"{s['edges']} edges{loops}] "
- f"z=zoom({gv.ZOOMS[gv._zoom]}) m=map J/K=edge space=text")
+ f"{s['edges']} edges{loops}{eng}] "
+ f"z=zoom({gv.ZOOMS[gv._zoom]}) m=map J/K=edge space=text"
+ )
def on_graph_view_cursor_moved(self, msg: "GraphView.CursorMoved") -> None:
gv = self.query_one(GraphView)
@@ -6504,7 +7397,7 @@ class IdaTui(App):
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) # crude link now
- self._load_split_map(ea) # region map (async) if decomp is loaded
+ 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
@@ -6567,8 +7460,9 @@ class IdaTui(App):
try:
self.journal.load(self.program)
self.journal.flush(self.program)
- out, f = findings.export(self.program, self._open_path or "", path,
- journal=self.journal)
+ out, f = findings.export(
+ self.program, self._open_path or "", path, journal=self.journal
+ )
except Exception as e: # noqa: BLE001 -- a bad path is a message, not a crash
self.call_from_thread(self._status, f"export failed: {e}", True)
return
@@ -6576,12 +7470,17 @@ class IdaTui(App):
self.call_from_thread(
self._status,
f"exported {len(f.comments)} comments, {n_named} names, "
- f"{len(f.types)} types → {out}", True)
+ f"{len(f.types)} types → {out}",
+ True,
+ )
def action_goto(self) -> None:
inp = self.query_one("#goto", Input)
- inp.placeholder = ("hex goto: 0xADDR or name — Enter" if self.is_hex
- else "goto: name or 0xADDR — Enter")
+ inp.placeholder = (
+ "hex goto: 0xADDR or name — Enter"
+ if self.is_hex
+ else "goto: name or 0xADDR — Enter"
+ )
inp.can_focus = True
inp.display = True
inp.value = ""
@@ -6612,7 +7511,7 @@ class IdaTui(App):
# Local history is spent, but we got here from another binary.
label = self._hops.pop()
self._status(f"\u25c2 back to {label}\u2026")
- self._switch_binary(label) # _states restores its nav and position
+ self._switch_binary(label) # _states restores its nav and position
elif self.query_one("#left", FunctionsPanel).display:
table.focus()
else:
@@ -6655,11 +7554,11 @@ class IdaTui(App):
# 'code' xref, and land on a call/jump's real target instead.
self._follow_disasm(ea, word, view._next_ea())
elif isinstance(view, DecompView) and view._texts:
- self._follow_decomp(view._texts[view.cursor], word,
- view._line_ea(view.cursor))
+ self._follow_decomp(
+ view._texts[view.cursor], word, view._line_ea(view.cursor)
+ )
- def _graph_local_target(self, view: "GraphView", ea: int,
- word: str) -> int | None:
+ def _graph_local_target(self, view: "GraphView", ea: int, word: str) -> int | None:
"""If the cursor's instruction branches somewhere inside this same
graph, return that address."""
if view.fc is None or self.program is None:
@@ -6738,14 +7637,16 @@ class IdaTui(App):
return not all(c in "0123456789abcdefABCDEF" for c in word)
@work(thread=True, group="nav")
- def _follow_disasm(self, ea: int, word: str | None,
- next_ea: int | None = None) -> None:
+ def _follow_disasm(
+ self, ea: int, word: str | None, next_ea: int | None = None
+ ) -> None:
assert self.program is not None
# Prefer the symbol under the cursor (handles multiple refs on a line).
if self._looks_like_symbol(word):
try:
- self._do_navigate(self.program.resolve(word), push=True,
- focus_name=word)
+ self._do_navigate(
+ self.program.resolve(word), push=True, focus_name=word
+ )
return
except Exception: # noqa: BLE001 -- not a resolvable name; fall back
pass
@@ -6804,13 +7705,15 @@ class IdaTui(App):
return False
label, addr = found
self.app.call_from_thread(
- self._status, f"{name} \u2192 {label} (import resolved)")
+ self._status, f"{name} \u2192 {label} (import resolved)"
+ )
self.app.call_from_thread(self._switch_then_goto, label, addr)
return True
@work(thread=True, group="nav")
- def _follow_decomp(self, line: str, word: str | None,
- line_ea: int | None = None) -> None:
+ def _follow_decomp(
+ self, line: str, word: str | None, line_ea: int | None = None
+ ) -> None:
if self._cur is None:
return
dec = self.program.decompile(self._cur.ea)
@@ -6853,8 +7756,13 @@ class IdaTui(App):
return None
@work(thread=True, group="xrefs")
- def _xrefs_disasm(self, ea: int, word: str | None,
- here_ea: int | None = None, here_end: int | None = None) -> None:
+ def _xrefs_disasm(
+ self,
+ ea: int,
+ word: str | None,
+ here_ea: int | None = None,
+ here_end: int | None = None,
+ ) -> None:
assert self.program is not None
subj: int | None = None
if self._looks_like_symbol(word):
@@ -6871,8 +7779,13 @@ class IdaTui(App):
self._xrefs_present(subj, word, here_ea, here_end)
@work(thread=True, group="xrefs")
- def _xrefs_decomp(self, line: str, word: str | None,
- here_ea: int | None = None, here_end: int | None = None) -> None:
+ def _xrefs_decomp(
+ self,
+ line: str,
+ word: str | None,
+ here_ea: int | None = None,
+ here_end: int | None = None,
+ ) -> None:
subj: int | None = None
if self._cur is not None and word:
dec = self.program.decompile(self._cur.ea)
@@ -6903,9 +7816,13 @@ class IdaTui(App):
span = i
return span if span is not None else 0
- def _xrefs_present(self, subj: int, subj_name: str | None = None,
- here_ea: int | None = None,
- here_end: int | None = None) -> None: # worker
+ def _xrefs_present(
+ self,
+ subj: int,
+ subj_name: str | None = None,
+ here_ea: int | None = None,
+ here_end: int | None = None,
+ ) -> None: # worker
assert self.program is not None
try:
return self._xrefs_present_inner(subj, subj_name, here_ea, here_end)
@@ -6966,22 +7883,30 @@ class IdaTui(App):
if not name:
return []
from .domain import link_name
+
name = link_name(name)
try:
_, exports = self.program.linkage()
except Exception: # noqa: BLE001
return []
if not any(e.name == name for e in exports):
- return [] # we don't export it; nobody imports it FROM US
+ return [] # we don't export it; nobody imports it FROM US
try:
hits = self._index.importers(name, exclude=self._binary)
except Exception: # noqa: BLE001
return []
- return [(h.binary, h.addr, f"{h.addr:08X} import [{h.binary}] {name}")
- for h in hits]
+ return [
+ (h.binary, h.addr, f"{h.addr:08X} import [{h.binary}] {name}")
+ for h in hits
+ ]
- def _present_xrefs(self, label: str, items: list[tuple[object, str]],
- focus_name: str | None = None, preselect: int = 0) -> None:
+ def _present_xrefs(
+ self,
+ label: str,
+ items: list[tuple[object, str]],
+ focus_name: str | None = None,
+ preselect: int = 0,
+ ) -> None:
if not self._xref_active:
return # cancelled (Esc) while we were still gathering
self._xref_active = False
@@ -6996,14 +7921,18 @@ class IdaTui(App):
def _on_xref_chosen(self, addr) -> None: # type: ignore[no-untyped-def]
if addr is None:
return
- if isinstance(addr, tuple): # a caller in another project binary
+ if isinstance(addr, tuple): # a caller in another project binary
binary, ea = addr
- self._switch_then_goto(binary, ea) # records a hop, so Esc returns
+ self._switch_then_goto(binary, ea) # records a hop, so Esc returns
return
# If xrefs was invoked from the decompiler, land the jump back in the
# decompiler (when the target is decompilable) rather than the listing.
- self._goto_ea(addr, push=True, focus_name=self._xref_focus_name,
- prefer_decomp=(self.is_decomp))
+ self._goto_ea(
+ addr,
+ push=True,
+ focus_name=self._xref_focus_name,
+ prefer_decomp=(self.is_decomp),
+ )
# -- database edits ---------------------------------------------------- #
# The bodies live in EditController (idatui/edit_ctl.py). What stays here is
@@ -7052,18 +7981,21 @@ class IdaTui(App):
self.edits.do_retype(kind, subject, word, new)
@work(thread=True, exclusive=True, group="makedata")
- def _do_make_data(self, ea: int, type_decl: str,
- anchor: ViewAnchor | None = None) -> None:
+ def _do_make_data(
+ self, ea: int, type_decl: str, anchor: ViewAnchor | None = None
+ ) -> None:
self.edits.do_make_data(ea, type_decl, anchor)
@work(thread=True, exclusive=True, group="opformat")
- def _do_op_format(self, mode: str, where: str, ea: int, col: int,
- line: int = -1) -> None:
+ def _do_op_format(
+ self, mode: str, where: str, ea: int, col: int, line: int = -1
+ ) -> None:
self.edits.do_op_format(mode, where, ea, col, line)
@work(thread=True, exclusive=True, group="edititem")
- def _do_edit_item(self, kind: str, ea: int,
- anchor: ViewAnchor | None = None) -> None:
+ def _do_edit_item(
+ self, kind: str, ea: int, anchor: ViewAnchor | None = None
+ ) -> None:
self.edits.do_edit_item(kind, ea, anchor)
def _reload_active_code(self) -> None:
@@ -7160,13 +8092,13 @@ class IdaTui(App):
return
if len(idx):
self._no_functions = False
- self._apply_filter(self._filter_term) # repopulate the names pane
+ self._apply_filter(self._filter_term) # repopulate the names pane
@work(thread=True, exclusive=True, group="save")
def _save(self) -> None:
assert self.program is not None
try:
- self.journal.flush(self.program) # ride along into the .i64
+ self.journal.flush(self.program) # ride along into the .i64
self.program.client.save_database()
except Exception as e: # noqa: BLE001
self.app.call_from_thread(self._status, f"save failed: {e}")
@@ -7182,12 +8114,22 @@ class IdaTui(App):
# -- navigation to an arbitrary address ------------------------------- #
@work(thread=True, group="nav")
- def _goto_ea(self, ea: int, push: bool = True,
- focus_name: str | None = None, prefer_decomp: bool = False) -> None:
+ def _goto_ea(
+ self,
+ ea: int,
+ push: bool = True,
+ focus_name: str | None = None,
+ prefer_decomp: bool = False,
+ ) -> None:
self._do_navigate(ea, push, focus_name, prefer_decomp)
- def _do_navigate(self, ea: int, push: bool, focus_name: str | None = None,
- prefer_decomp: bool = False) -> None: # worker context
+ def _do_navigate(
+ self,
+ ea: int,
+ push: bool,
+ focus_name: str | None = None,
+ prefer_decomp: bool = False,
+ ) -> None: # worker context
assert self.program is not None
# Which navigation this is. Decompiling below can take a while, and if
# you press Esc (or jump again) in the meantime this result is stale —
@@ -7209,11 +8151,12 @@ class IdaTui(App):
# anchor on the address but snap to the nearest line that
# actually holds the referenced symbol (the marker line and
# the symbol's line can differ), landing on the token.
- dec_idx, col = self._decomp_locate(fn.addr, ea,
- focus_name or fn.name)
+ dec_idx, col = self._decomp_locate(
+ fn.addr, ea, focus_name or fn.name
+ )
self.app.call_from_thread(
- self._open_decomp_entry, fn.addr, fn.name, dec_idx, col,
- push, seq)
+ self._open_decomp_entry, fn.addr, fn.name, dec_idx, col, push, seq
+ )
return
# Otherwise: everything opens the one continuous listing at ``ea``. A
# function name is used for the status label; a region gets a segment
@@ -7222,12 +8165,18 @@ class IdaTui(App):
idx = max(lm.ensure_ea(ea), 0) if lm is not None else 0
name = fn.name if fn is not None else self.program.region_label(ea)
self.app.call_from_thread(
- self._open_at_if_current, seq, ea, name, idx, push, fn is None,
- focus_name)
+ self._open_at_if_current, seq, ea, name, idx, push, fn is None, focus_name
+ )
- def _open_decomp_entry(self, fn_addr: int, fn_name: str, dec_idx: int,
- dec_cursor_x: int, push: bool,
- seq: int | None = None) -> None:
+ def _open_decomp_entry(
+ self,
+ fn_addr: int,
+ fn_name: str,
+ dec_idx: int,
+ dec_cursor_x: int,
+ push: bool,
+ seq: int | None = None,
+ ) -> None:
"""Open ``fn_addr`` in the decompiler as a real navigation (nav history
aware), landing on pseudocode line ``dec_idx`` column ``dec_cursor_x``.
@@ -7249,12 +8198,17 @@ class IdaTui(App):
src.dec_cursor_x = dv.cursor_x
src.dec_scroll_y = round(dv.scroll_offset.y)
if not self._nav or self._nav[-1] is not src:
- self._push_nav(src) # never stack a second copy of a spot
+ self._push_nav(src) # never stack a second copy of a spot
else:
self._save_current_pos()
self._decomp_return = None # a real navigation abandons the F5 return
- entry = NavEntry(ea=fn_addr, name=fn_name, view="decomp",
- dec_cursor=dec_idx, dec_cursor_x=dec_cursor_x)
+ entry = NavEntry(
+ ea=fn_addr,
+ name=fn_name,
+ view="decomp",
+ dec_cursor=dec_idx,
+ dec_cursor_x=dec_cursor_x,
+ )
if push:
self._push_nav(entry)
self._open_entry(entry, push=False)
@@ -7275,8 +8229,9 @@ class IdaTui(App):
m = re.search(rf"\b{re.escape(name)}\b", clean)
return m.start() if m else 0
- def _decomp_locate(self, fn_addr: int, ea: int,
- token: str | None) -> tuple[int, int]:
+ def _decomp_locate(
+ self, fn_addr: int, ea: int, token: str | None
+ ) -> tuple[int, int]:
"""Best (line, column) for address ``ea`` in ``fn_addr``'s pseudocode.
Anchors on the /*0xEA*/ marker line for ``ea``, but Hex-Rays can attribute
@@ -7334,8 +8289,9 @@ class IdaTui(App):
move, so it has no business being a step in the history."""
if a.ea != b.ea or a.view != b.view:
return False
- return (a.dec_cursor == b.dec_cursor if a.view == "decomp"
- else a.cursor == b.cursor)
+ return (
+ a.dec_cursor == b.dec_cursor if a.view == "decomp" else a.cursor == b.cursor
+ )
def _push_nav(self, entry: NavEntry) -> None:
"""Append to the nav stack unless that would duplicate where we already are.
@@ -7396,9 +8352,16 @@ class IdaTui(App):
cur = row_of(fallback_ea)
return (cur, row_of(a.top_ea))
- def _open_at_if_current(self, seq: int, ea: int, name: str, cursor: int,
- push: bool, is_region: bool,
- focus_name: str | None) -> None:
+ def _open_at_if_current(
+ self,
+ seq: int,
+ ea: int,
+ name: str,
+ cursor: int,
+ push: bool,
+ is_region: bool,
+ focus_name: str | None,
+ ) -> None:
"""Apply a navigation result only if it's still the one being awaited.
The decompiler path has had this since 756589a; the listing path hadn't,
@@ -7408,10 +8371,18 @@ class IdaTui(App):
return
self._open_at(ea, name, cursor, push, -1, 0, is_region, focus_name)
- def _open_at(self, ea: int, name: str, cursor: int, push: bool,
- dec_cursor: int = -1, dec_cursor_x: int = 0,
- is_region: bool = False, focus_name: str | None = None,
- scroll_y: int = -1) -> None:
+ def _open_at(
+ self,
+ ea: int,
+ name: str,
+ cursor: int,
+ push: bool,
+ dec_cursor: int = -1,
+ dec_cursor_x: int = 0,
+ is_region: bool = False,
+ focus_name: str | None = None,
+ scroll_y: int = -1,
+ ) -> None:
if push:
self._save_current_pos()
self._decomp_return = None # a real navigation abandons the F5 return
@@ -7468,8 +8439,11 @@ class IdaTui(App):
self._end_search(cancel=True)
elif prompt.id in ("goto", "export"):
self._end_goto() if prompt.id == "goto" else self._end_export()
- (self.query_one(HexView) if self.is_hex
- else (self._code_view() or self.query_one(ListingView))).focus()
+ (
+ self.query_one(HexView)
+ if self.is_hex
+ else (self._code_view() or self.query_one(ListingView))
+ ).focus()
else:
prompt.close()
return
@@ -7499,10 +8473,12 @@ class IdaTui(App):
# The edit prompts all submit the same way: take the context the prompt
# was holding (close() hands it over, so it can't be read twice or go
# stale) and let the controller decide what to do with it.
- submit = {"rename": self.edits.submit_rename,
- "comment": self.edits.submit_comment,
- "retype": self.edits.submit_retype,
- "makedata": self.edits.submit_make_data}.get(inp.id or "")
+ submit = {
+ "rename": self.edits.submit_rename,
+ "comment": self.edits.submit_comment,
+ "retype": self.edits.submit_retype,
+ "makedata": self.edits.submit_make_data,
+ }.get(inp.id or "")
if submit is not None:
ctx = self.prompts[inp.id].close()
if ctx is not None:
@@ -7510,15 +8486,21 @@ class IdaTui(App):
return
if inp.id == "goto":
self._end_goto()
- (self.query_one(HexView) if self.is_hex
- else (self._code_view() or self.query_one(ListingView))).focus()
+ (
+ self.query_one(HexView)
+ if self.is_hex
+ else (self._code_view() or self.query_one(ListingView))
+ ).focus()
if value:
self._goto(value)
return
if inp.id == "export":
self._end_export()
- (self.query_one(HexView) if self.is_hex
- else (self._code_view() or self.query_one(ListingView))).focus()
+ (
+ self.query_one(HexView)
+ if self.is_hex
+ else (self._code_view() or self.query_one(ListingView))
+ ).focus()
if value:
self.export_findings(value)
return
@@ -7683,13 +8665,13 @@ class IdaTui(App):
e.scroll_y = round(lst.scroll_offset.y)
@work(thread=True, group="nav")
- def _open_function(self, ea: int, name: str | None = None,
- push: bool = True) -> None:
+ def _open_function(
+ self, ea: int, name: str | None = None, push: bool = True
+ ) -> None:
# Unified: opening a function is just navigating the one linear listing
# to its entry address.
self._do_navigate(ea, push)
-
def _code_mode(self) -> ViewMode:
"""The code view to return to from hex — always the unified listing."""
return ViewMode.LISTING
@@ -7707,9 +8689,12 @@ class IdaTui(App):
dec = self.query_one(DecompView)
if dec.loaded_ea == entry.ea:
# already decompiled: reposition without a recompile
- dec.goto(entry.dec_cursor, entry.dec_cursor_x,
- entry.dec_scroll_y if entry.dec_scroll_y >= 0 else -1,
- entry.dec_scroll_x)
+ dec.goto(
+ entry.dec_cursor,
+ entry.dec_cursor_x,
+ entry.dec_scroll_y if entry.dec_scroll_y >= 0 else -1,
+ entry.dec_scroll_x,
+ )
self._show_active() # loads the pseudocode if loaded_ea != entry.ea
return
# Unified model: the code view is always the continuous listing,
@@ -7725,13 +8710,21 @@ class IdaTui(App):
# leave the viewport alone and just move the cursor; otherwise
# scroll so the target sits a few lines below the top for context.
top = round(lst.scroll_offset.y)
- if lst.model is lm and top <= entry.cursor < top + lst._visible_height():
+ if (
+ lst.model is lm
+ and top <= entry.cursor < top + lst._visible_height()
+ ):
sy = top
else:
sy = max(entry.cursor - _JUMP_CONTEXT, 0)
lst.load(
- lm, entry.name, cursor=entry.cursor,
- cursor_x=entry.cursor_x, scroll_y=sy, focus=focus)
+ lm,
+ entry.name,
+ cursor=entry.cursor,
+ cursor_x=entry.cursor_x,
+ scroll_y=sy,
+ focus=focus,
+ )
self._active = ViewMode.LISTING
self._show_active()
# Graph mode is sticky: following a call from the graph should land in
@@ -7743,8 +8736,15 @@ class IdaTui(App):
# Prompt overlays that own the keyboard while visible; a background
# navigation must not yank focus out from under them (else typed keys leak
# into a code view as destructive verbs — e.g. 'u' = undefine).
- _PROMPT_IDS = ("search", "rename", "comment", "retype", "goto", "export",
- "func-filter")
+ _PROMPT_IDS = (
+ "search",
+ "rename",
+ "comment",
+ "retype",
+ "goto",
+ "export",
+ "func-filter",
+ )
def _prompt_active(self) -> bool:
for iid in self._PROMPT_IDS:
@@ -7851,8 +8851,10 @@ class IdaTui(App):
sec = self.program.section_of(va) if self.program else None
fo = self.program.file_offset(va) if self.program else None
foff = f"file+{fo:#x}" if fo is not None else "file:--"
- self._status(f"hex va={va:#x} {foff} [{sec or '?'}] "
- "(g goto · Enter→code · Tab/Esc/\\→back)")
+ self._status(
+ f"hex va={va:#x} {foff} [{sec or '?'}] "
+ "(g goto · Enter→code · Tab/Esc/\\→back)"
+ )
def on_hex_view_moved(self, msg: HexView.Moved) -> None:
self._hex_status(msg.va)
@@ -7889,8 +8891,13 @@ class IdaTui(App):
why = self.program.decomp_error(ea)
self.app.call_from_thread(self._apply_decomp, ea, name, dec, why)
- def _apply_decomp(self, ea: int, name: str, dec, # type: ignore[no-untyped-def]
- why: str = "") -> None:
+ def _apply_decomp(
+ self,
+ ea: int,
+ name: str,
+ dec, # type: ignore[no-untyped-def]
+ why: str = "",
+ ) -> None:
view = self.query_one(DecompView)
view.loading = False
if dec.failed:
@@ -7934,11 +8941,12 @@ class IdaTui(App):
view.set_nums(self._pending_nums)
if self._split:
self._sync_split(self._active) # crude link now
- self._load_split_map(ea) # then upgrade to the region map
+ self._load_split_map(ea) # then upgrade to the region map
self._split_status()
else:
self._status(
- f"{name} @ {ea:#x} [pseudocode {len(dec.code or '')} chars]{note}")
+ f"{name} @ {ea:#x} [pseudocode {len(dec.code or '')} chars]{note}"
+ )
def _sync_split(self, source: str, resync: bool = True) -> None:
"""Split view: highlight (+ scroll into view) the companion pane's
@@ -7963,8 +8971,7 @@ class IdaTui(App):
if source == "decomp":
dec.set_link(None) # the driver shows its own cursor, no band
line, screen = self._split_anchor(dec)
- eas = (self._split_eamap[line]
- if 0 <= line < len(self._split_eamap) else [])
+ eas = self._split_eamap[line] if 0 <= line < len(self._split_eamap) else []
if not eas: # fallback: the single /*ea*/ marker for the line
one = dec._line_ea(line)
eas = [one] if one is not None else []
@@ -8053,18 +9060,25 @@ class IdaTui(App):
if self.is_decomp:
dec = self.query_one(DecompView)
ea = dec._line_ea(dec.cursor)
- n = (len(self._split_eamap[dec.cursor])
- if 0 <= dec.cursor < len(self._split_eamap) else 0)
+ n = (
+ len(self._split_eamap[dec.cursor])
+ if 0 <= dec.cursor < len(self._split_eamap)
+ else 0
+ )
at = f" @ {ea:#x}" if ea is not None else ""
rel = f" \u2194 {n} insn" if n else ""
- self._status(f"{self._cur.name}{at} "
- f"[split \u00b7 pseudocode line {dec.cursor + 1}{rel}]"
- f" (Tab/click: drive listing)")
+ self._status(
+ f"{self._cur.name}{at} "
+ f"[split \u00b7 pseudocode line {dec.cursor + 1}{rel}]"
+ f" (Tab/click: drive listing)"
+ )
else:
ea = self.query_one(ListingView)._cursor_ea()
at = f" @ {ea:#x}" if ea is not None else ""
- self._status(f"{self._cur.name}{at} [split \u00b7 listing]"
- f" (Tab/click: drive pseudocode)")
+ self._status(
+ f"{self._cur.name}{at} [split \u00b7 listing]"
+ f" (Tab/click: drive pseudocode)"
+ )
def on_descendant_focus(self, event) -> None: # type: ignore[no-untyped-def]
"""In split, focusing a pane (Tab or a mouse click) makes it the leading/
@@ -8072,8 +9086,13 @@ class IdaTui(App):
if not self._split:
return
w = event.control
- new = ("decomp" if isinstance(w, DecompView)
- else "listing" if isinstance(w, ListingView) else None)
+ new = (
+ "decomp"
+ if isinstance(w, DecompView)
+ else "listing"
+ if isinstance(w, ListingView)
+ else None
+ )
if new is not None and new != self._active:
self._active = new
self._sync_split(new)
@@ -8156,11 +9175,14 @@ class IdaTui(App):
ea = msg.ea
if ea is not None:
sec = self.program.section_of(ea) if self.program else None
- self._status(f"{sec or '?'} @ {ea:#x} [listing] "
- "(c code · p func · u undefine · Enter follow)")
+ self._status(
+ f"{sec or '?'} @ {ea:#x} [listing] "
+ "(c code · p func · u undefine · Enter follow)"
+ )
# -- teardown ---------------------------------------------------------- #
async def on_unmount(self) -> None:
+ self._stop_idb_event_watch()
if self._rpc is not None:
await self._rpc.stop()
if self._ka is not None:
diff --git a/idatui/codemode_client.py b/idatui/codemode_client.py
deleted file mode 100644
index 1ac995e..0000000
--- a/idatui/codemode_client.py
+++ /dev/null
@@ -1,1550 +0,0 @@
-"""Client adapter from ida-tui's domain operations to IDA Code Mode.
-
-``DatabaseHandle`` is the lifecycle boundary: it discovers an already-registered
-GUI database, reuses a shared managed idalib worker, or starts one when needed.
-The TUI never owns or terminates an IDA process. Closing this client releases
-only its lease.
-
-The Code Mode transport intentionally exposes one broad operation,
-``execute_python``. ``CodeModeClient.invoke`` turns the small, address-centric
-operations needed by the paging layer into self-contained snippets. The
-snippets prefer the public ``ida-domain`` ``db`` object. A handful of features
-that ida-domain does not currently expose (IDA-coloured listing rows, creating
-instructions, ARM T-state, and detailed Hex-Rays line maps/failures) use the
-IDAPython modules that Code Mode deliberately makes importable.
-"""
-from __future__ import annotations
-
-import hashlib
-import json
-import os
-import shlex
-import threading
-import time
-from pathlib import Path
-from textwrap import dedent, indent
-from typing import Any
-
-from .errors import IDAConnectionError, IDATimeoutError, IDAToolError, Session
-
-# ida_codemode is imported EAGERLY-IF-PRESENT but never at hard import cost.
-#
-# The paging/graph/trace layers and their offline test suites must keep importing
-# `idatui` on a machine with no IDA and no Code Mode installed -- that is the
-# house rule the stdlib-only worker client used to satisfy for free, and
-# `tests/run.py --fast` (257 checks, any python3) depends on it. A hard top-level
-# import here makes the whole package unimportable, so the failure is deferred to
-# the first operation that genuinely needs the library.
-_CODEMODE_ERROR: Exception | None = None
-try:
- from ida_codemode.client import (
- ClientError,
- DatabaseHandle,
- InstanceDisconnectedError,
- RemoteError,
- )
- from ida_codemode.registry import (
- REGISTRY_DIR,
- FileLock,
- RegistryEntry,
- canonical_path,
- idb_key,
- scan_instances,
- )
- from ida_codemode.resolver import IdbBusy, expected_idb_path
-except ImportError as _exc: # library absent: usable only for offline layers
- _CODEMODE_ERROR = _exc
- # Bound to None rather than left undefined so the names stay patchable: the
- # offline contract tests inject a fake DatabaseHandle here.
- ClientError = InstanceDisconnectedError = RemoteError = None # type: ignore[assignment,misc]
- DatabaseHandle = RegistryEntry = FileLock = None # type: ignore[assignment,misc]
- REGISTRY_DIR = canonical_path = idb_key = scan_instances = None # type: ignore[assignment]
- IdbBusy = expected_idb_path = None # type: ignore[assignment]
-
-
-def _require_codemode() -> None:
- """Raise an actionable error when the Code Mode library is missing.
-
- Gated on the binding, not on the original import result, so a test that
- injects a fake ``DatabaseHandle`` exercises the real adapter logic.
- """
- if DatabaseHandle is None:
- raise IDAConnectionError(
- "ida-codemode-mcp is not installed in this environment "
- f"({_CODEMODE_ERROR}). Install it (e.g. `uv sync`, or "
- "`pip install -e ../ida-codemode-mcp`) so ida-tui can lease a "
- "database.") from _CODEMODE_ERROR
-
-
-def database_owner(idb_path: str, staged_path: str | None = None):
- """The registry entry that owns ``idb_path``/``staged_path``, else None.
-
- Returns None when the Code Mode library is absent: with no library there is
- no client in this environment that could be holding the database, and the
- IDA-free layers (project staging) must keep working. Registry errors that
- happen WITH the library installed still propagate -- those mean "we could
- not determine ownership", which is not the same as "nobody owns it".
- """
- if DatabaseHandle is None:
- return None
- expected_key = idb_key(idb_path)
- staged = canonical_path(staged_path) if staged_path else None
- for item in scan_instances(timeout=0.5):
- entry = item.entry
- if entry.idb_key == expected_key:
- return entry
- if staged and entry.exe_path and canonical_path(entry.exe_path) == staged:
- return entry
- return None
-
-
-def registered_database(path: str, output_database: str | None = None) -> bool:
- """Whether a live/lock-held Code Mode instance owns this target."""
- _require_codemode()
- source = canonical_path(path)
- expected = canonical_path(output_database) if output_database else expected_idb_path(source)
- expected_key = idb_key(expected)
- for instance in scan_instances(timeout=0.5):
- entry = instance.entry
- if entry.idb_key == expected_key:
- return True
- if not output_database and entry.backend == "gui" and entry.exe_path:
- if canonical_path(entry.exe_path) == source:
- return True
- return False
-
-
-class _NoopKeepAlive:
- """Compatibility shim: the DatabaseHandle's SSE lease is the heartbeat."""
-
- def __init__(self) -> None:
- self.beats = self.failures = 0
-
- def start(self) -> "_NoopKeepAlive":
- return self
-
- def stop(self) -> None:
- pass
-
-
-def _parse_load_args(value: str) -> tuple[str | None, int | None, str | None]:
- """Translate ida-tui's legacy first-open switches to Code Mode options.
-
- Code Mode has typed options for processor, natural loading address and file
- type. It deliberately has no arbitrary command-line escape hatch; reject
- switches we cannot represent instead of silently loading a blob wrongly.
- """
- processor: str | None = None
- loading_address: int | None = None
- file_type: str | None = None
- unsupported: list[str] = []
- try:
- words = shlex.split(value or "", posix=os.name != "nt")
- except ValueError as exc:
- raise ValueError(f"invalid IDA load options: {exc}") from exc
- for word in words:
- if word.startswith("-p") and len(word) > 2:
- processor = word[2:]
- elif word.startswith("-b") and len(word) > 2:
- try:
- # IDA's -b is in 16-byte paragraphs. DatabaseHandle expects the
- # natural address, which is the safer public API.
- loading_address = int(word[2:], 16) << 4
- except ValueError as exc:
- raise ValueError(f"invalid IDA loading address: {word!r}") from exc
- elif word.startswith("-T") and len(word) > 2:
- file_type = word[2:]
- else:
- unsupported.append(word)
- if unsupported:
- joined = " ".join(unsupported)
- raise ValueError(
- "ida-codemode cannot represent arbitrary IDA load options: "
- f"{joined!r}; use processor/base/file type options instead"
- )
- return processor, loading_address, file_type
-
-
-#: Key of the pre-serialised payload envelope. See _script().
-_PACKED = "__idatui_json__"
-
-#: Serialise the answer INSIDE the database process and hand back one string.
-#:
-#: Code Mode runs to_jsonable() over whatever a snippet returns, walking the
-#: whole structure to make it JSON-safe. Our answers are already JSON-safe, and
-#: they are big: a 200-row listing page is ~10k small objects, which costs 66ms
-#: to walk -- 72% of the page's total cost, and 114x what json.dumps of the very
-#: same data costs (0.58ms). Returning a STRING makes that walk O(1); the client
-#: parses it, which it was going to do at the transport layer anyway.
-_PACK_EPILOGUE = (
- '\n{"' + _PACKED + '": json.dumps(result, separators=(",", ":"), default=str)}\n'
-)
-
-
-#: Keep Code Mode's per-line trace hook installed while our snippet runs.
-#: Set IDATUI_CODEMODE_TRACE=1 to restore the stock behaviour.
-_KEEP_TRACE = os.environ.get("IDATUI_CODEMODE_TRACE", "") not in ("", "0")
-
-
-def _script(args: dict[str, Any], body: str) -> str:
- """Bind JSON arguments without interpolating user text into Python code.
-
- Also runs the body with Code Mode's trace hook detached, which is worth an
- order of magnitude. The runtime wraps every execute_python in
- sys.settrace(timeout_trace), and that trace function RETURNS ITSELF, which
- turns on line tracing in every frame it sees -- so every line of every
- function we call pays a Python-level callback. Measured on this box:
- ida_bytes.get_flags is 0.106us untraced (0.119us in a plain idalib process)
- and 5.49us traced, 52x; a 200-row listing page is 2.0ms untraced and 20.2ms
- traced. That single hook was the whole residual gap against the old worker.
-
- What this gives up: the deadline is no longer enforced for a pure-Python
- loop inside our snippet. The runtime's OTHER cancellation path -- a
- threading.Timer that calls ida_kernwin.set_cancelled() -- is independent of
- the trace and still fires, so a long IDA operation is still interruptible;
- and every operation here is bounded by its own count/limit argument. The
- trace is restored in a finally, so a raising snippet cannot leak the change.
- """
- encoded = json.dumps(args, ensure_ascii=False, separators=(",", ":"))
- head = f"import json\na = json.loads({encoded!r})\n"
- if _KEEP_TRACE:
- return f"{head}{dedent(body).strip()}\n{_PACK_EPILOGUE}"
- return (
- f"{head}"
- "import sys\n"
- "_idatui_trace = sys.gettrace()\n"
- "sys.settrace(None)\n"
- "try:\n"
- f"{indent(dedent(body).strip(), ' ')}\n"
- ' _idatui_packed = {"' + _PACKED + '": json.dumps('
- 'result, separators=(",", ":"), default=str)}\n'
- "finally:\n"
- " sys.settrace(_idatui_trace)\n"
- "_idatui_packed\n"
- )
-
-
-_OPERATIONS: dict[str, str] = {
- "list_funcs": r'''
-import fnmatch
-queries = a.get("queries") or [{}]
-q = queries[0]
-offset, count = max(0, int(q.get("offset", 0))), max(1, int(q.get("count", 500)))
-pattern = str(q.get("filter") or "").lower()
-if pattern and not any(ch in pattern for ch in "*?["): pattern = "*" + pattern + "*"
-rows = []
-for fn in db.functions.get_all():
- name = db.functions.get_name(fn) or f"sub_{int(fn.start_ea):X}"
- if pattern and not fnmatch.fnmatchcase(name.lower(), pattern): continue
- rows.append({"addr": hex(int(fn.start_ea)), "name": name,
- "size": int(fn.end_ea) - int(fn.start_ea)})
-page = rows[offset:offset + count]
-result = {"result": [{"data": page, "next_offset": offset + len(page), "total": len(rows)}]}
-result
-''',
- "disasm": r'''
-ea = int(str(a["addr"]), 16)
-fn = db.functions.get_at(ea)
-if fn is None:
- result = {"instructions": [], "total_instructions": 0, "instruction_count": 0}
-else:
- instructions = list(db.functions.get_instructions(fn))
- limit = max(1, int(a.get("max_instructions", len(instructions) or 1)))
- rows = [{"addr": hex(int(insn.ea)), "instruction": db.instructions.get_disassembly(insn)}
- for insn in instructions[:limit]]
- result = {"instructions": rows, "total_instructions": len(instructions),
- "instruction_count": len(instructions)}
-result
-''',
- "file_regions": r'''
-import idaapi
-rows = []
-for seg in db.segments.get_all():
- try: file_off = int(idaapi.get_fileregion_offset(seg.start_ea))
- except Exception: file_off = -1
- if file_off < 0 or file_off >= (1 << 48): file_off = -1
- rows.append({"start": hex(int(seg.start_ea)), "end": hex(int(seg.end_ea)),
- "file_off": file_off, "name": db.segments.get_name(seg) or ""})
-result = {"regions": rows}
-result
-''',
- "read_raw": r'''
-import ida_bytes
-ea, size = int(str(a["addr"]), 16), max(0, int(a["size"]))
-raw = ida_bytes.get_bytes(ea, size) or b""
-raw = raw[:size] + b"\xff" * max(0, size - len(raw))
-data = bytearray(raw)
-for index, value in enumerate(data):
- if value == 0xFF and not ida_bytes.is_loaded(ea + index): data[index] = 0
-result = {"addr": a["addr"], "hex": bytes(data).hex(), "n": len(data)}
-result
-''',
- "get_bytes": r'''
-rows = []
-for region in a.get("regions", []):
- ea, size = int(str(region["addr"]), 16), int(region["size"])
- raw = db.bytes.get_bytes_at(ea, size) or b""
- rows.append({"addr": region["addr"], "data": " ".join(f"{b:02x}" for b in raw)})
-result = {"result": rows}
-result
-''',
- "search_structs": r'''
-needle = str(a.get("filter") or "").lower()
-rows = []
-for tif in db.types.get_all():
- name = tif.get_type_name() or ""
- if not name or needle not in name.lower() or not tif.is_udt(): continue
- members = list(db.types.get_udt_members(tif))
- rows.append({"name": name, "size": int(tif.get_size()), "is_union": bool(tif.is_union()),
- "cardinality": len(members), "ordinal": int(tif.get_ordinal())})
-result = {"result": rows}
-result
-''',
- "type_inspect": r'''
-rows = []
-for query in a.get("queries", []):
- name = str(query.get("name") or "")
- tif = db.types.get_by_name(name)
- if tif is None:
- rows.append({"name": name, "error": "type not found"}); continue
- members = [{"name": m.name, "type": m.type.dstr() or str(m.type),
- "offset": int(m.offset), "size": int(m.size)}
- for m in db.types.get_udt_members(tif)] if tif.is_udt() else []
- rows.append({"name": name, "size": int(tif.get_size()), "is_union": bool(tif.is_union()),
- "members": members})
-result = {"result": rows}
-result
-''',
- "declare_type": r'''
-import ida_typeinf
-decls = a.get("decls", "")
-if isinstance(decls, str): decls = [decls]
-rows = []
-for declaration in decls:
- try:
- errors = int(db.types.parse_declarations(ida_typeinf.get_idati(), declaration))
- rows.append({"ok": errors == 0, **({} if errors == 0 else {"error": f"{errors} parse error(s)"})})
- except Exception as exc:
- rows.append({"ok": False, "error": str(exc)})
-result = {"result": rows}
-result
-''',
- "del_type": r'''
-import ida_typeinf
-name = str(a["name"])
-ok = bool(ida_typeinf.del_named_type(ida_typeinf.get_idati(), name, ida_typeinf.NTF_TYPE))
-result = {"name": name, "deleted": ok, **({} if ok else {"error": f"Type {name!r} not found or could not be deleted"})}
-result
-''',
- "func_types": r'''
-import ida_typeinf
-ea = int(str(a["addr"]), 16)
-fn = db.functions.get_at(ea)
-if fn is None:
- result = {"addr": a["addr"], "error": "no function at address"}
-else:
- pseudo = db.pseudocode.decompile(fn)
- name = db.functions.get_name(fn) or ""
- tif = pseudo.get_func_type()
- try: prototype = ida_typeinf.print_tinfo("", 0, 0, ida_typeinf.PRTYPE_1LINE, tif, name, "") if tif else ""
- except Exception: prototype = tif.dstr() if tif else ""
- lvars = [{"name": var.name, "type": var.type_info.dstr() if var.type_info else "",
- "is_arg": bool(var.is_arg)} for var in pseudo.local_variables]
- result = {"addr": hex(int(fn.start_ea)), "name": name,
- "prototype": (prototype or "").strip(), "lvars": lvars}
-result
-''',
- "set_lvar_type": r'''
-import ida_typeinf
-ea, variable, declaration = int(str(a["addr"]), 16), str(a["variable"]), str(a["type"])
-fn = db.functions.get_at(ea)
-if fn is None:
- result = {"error": "no function at address"}
-else:
- pseudo = db.pseudocode.decompile(fn)
- var = pseudo.find_local_variable(variable)
- if var is None:
- result = {"error": f"local variable {variable!r} not found"}
- else:
- try:
- tif = db.types.parse_one_declaration(ida_typeinf.get_idati(), declaration)
- accepted = bool(var.set_type(tif))
- saved = bool(pseudo.save_local_variable_info(var, save_type=True)) if accepted else False
- result = {"addr": hex(int(fn.start_ea)), "variable": variable,
- "type": declaration, "ok": accepted and saved}
- except Exception as exc:
- result = {"error": f"bad type {declaration!r}: {exc}"}
-result
-''',
- "set_type": r'''
-from ida_domain.types import TypeApplyFlags
-rows = []
-for edit in a.get("edits", []):
- ea = int(str(edit["addr"]), 16)
- declaration = str(edit.get("signature") or edit.get("type") or "")
- try:
- ok = bool(db.types.apply_declaration_at(ea, declaration, TypeApplyFlags.DEFINITE))
- rows.append({"addr": hex(ea), "ok": ok, **({} if ok else {"error": "IDA rejected the type"})})
- except Exception as exc:
- rows.append({"addr": hex(ea), "ok": False, "error": str(exc)})
-result = {"result": rows}
-result
-''',
- "data_type": r'''
-ea = int(str(a["addr"]), 16)
-try:
- tif = db.types.get_at(ea)
- fn = db.functions.get_at(ea)
- result = {"addr": hex(ea), "name": db.names.get_at(ea) or "",
- "type": tif.dstr() if tif else "", "size": int(db.heads.size(ea)) if db.heads.is_head(ea) else 0,
- "is_func": bool(fn)}
-except Exception as exc:
- result = {"addr": hex(ea), "error": str(exc)}
-result
-''',
- "force_recompile": r'''
-import ida_hexrays
-rows = []
-for item in a.get("items", []):
- ea = int(str(item["addr"]), 16)
- ida_hexrays.mark_cfunc_dirty(ea, False)
- rows.append({"addr": hex(ea), "ok": True})
-result = {"result": rows}
-result
-''',
- "undefine": r'''
-import ida_bytes
-rows = []
-for item in a.get("items", []):
- ea = int(str(item["addr"]), 16)
- size = max(1, int(item.get("size") or ida_bytes.get_item_size(ea) or 1))
- ok = bool(ida_bytes.del_items(ea, ida_bytes.DELIT_SIMPLE, size))
- rows.append({"addr": hex(ea), "ok": ok, **({} if ok else {"error": "delete items failed"})})
-result = {"result": rows}
-result
-''',
- "define_code": r'''
-import ida_ua
-rows = []
-for item in a.get("items", []):
- ea = int(str(item["addr"]), 16); size = int(ida_ua.create_insn(ea))
- rows.append({"addr": hex(ea), "ok": size > 0, "size": size,
- **({} if size > 0 else {"error": "instruction did not decode"})})
-result = {"result": rows}
-result
-''',
- "define_func": r'''
-rows = []
-for item in a.get("items", []):
- ea = int(str(item["addr"]), 16); ok = bool(db.functions.create(ea))
- rows.append({"addr": hex(ea), "ok": ok, **({} if ok else {"error": "IDA refused the function"})})
-result = {"result": rows}
-result
-''',
- "make_data": r'''
-import ida_bytes, ida_idaapi, ida_typeinf
-from ida_domain.types import TypeApplyFlags
-rows = []
-for item in a.get("items", []):
- ea, declaration = int(str(item["addr"]), 16), str(item["type"])
- try:
- tif = db.types.parse_one_declaration(ida_typeinf.get_idati(), declaration)
- size = max(1, int(tif.get_size()))
- saved_names = [(addr, name) for addr, name in db.names.get_all()
- if ea <= int(addr) < ea + size]
- ida_bytes.del_items(ea, ida_bytes.DELIT_EXPAND | ida_bytes.DELIT_DELNAMES,
- max(size, int(ida_bytes.get_item_size(ea) or 1)))
- created = bool(ida_bytes.create_data(ea, ida_bytes.FF_BYTE, size, ida_idaapi.BADADDR))
- ok = created and bool(db.types.apply_at(tif, ea, TypeApplyFlags.DEFINITE))
- for address, name in saved_names:
- db.names.set_name(int(address), name)
- if ok and item.get("name"): ok = bool(db.names.set_name(ea, str(item["name"])))
- rows.append({"addr": hex(ea), "ok": ok, "size": size,
- **({} if ok else {"error": "IDA rejected the data type"})})
- except Exception as exc:
- rows.append({"addr": hex(ea), "ok": False, "error": str(exc)})
-result = {"result": rows}
-result
-''',
- "make_string": r'''
-from ida_domain.strings import StringType
-ea, length = int(str(a["addr"]), 16), max(0, int(a.get("length", 0)))
-kind = {"c": StringType.C, "c16": StringType.C_16, "c32": StringType.C_32,
- "pascal": StringType.PASCAL}.get(str(a.get("kind", "c")).lower(), StringType.C)
-import ida_bytes
-try:
- ida_bytes.del_items(ea, ida_bytes.DELIT_SIMPLE, length if length > 0 else 1)
-except Exception:
- pass
-try:
- ok = bool(db.bytes.create_string_at(ea, length or None, kind))
- text = db.bytes.get_string_at(ea) or "" if ok else ""
- result = {"addr": hex(ea), "ok": ok, "size": int(db.heads.size(ea)) if ok else 0, "text": text}
-except Exception as exc:
- result = {"addr": hex(ea), "ok": False, "error": str(exc)}
-result
-''',
- "list_strings": r'''
-from ida_domain.strings import StringListConfig
-offset, count, min_len = max(0, int(a.get("offset", 0))), max(1, int(a.get("count", 2000))), max(1, int(a.get("min_len", 4)))
-if offset == 0 or a.get("refresh"):
- from ida_domain.strings import StringType
- db.strings.rebuild(StringListConfig(string_types=list(StringType), min_len=min_len,
- only_ascii_7bit=False))
-items = list(db.strings.get_all())
-page = items[offset:offset + count]
-rows = []
-for item in page:
- try: text = str(item)
- except Exception: text = item.contents.decode("utf-8", "replace") if item.contents else ""
- rows.append({"addr": hex(int(item.address)), "text": text, "len": int(item.length), "type": item.type.name})
-result = {"strings": rows, "total": len(items), "next_offset": offset + len(rows)}
-result
-''',
- # Everything a person ADDED to the database: comments, non-dummy names, and
- # the prototypes they set.
- #
- # Names come from IDA's name list, which is already an index -- no scan at
- # all. Comments have no index, so they need a walk, and the walk is over
- # HEADS: `next_that`'s predicate is a *Python* callback (SWIG calls it with
- # one argument, so `f_has_cmt` does not even fit), which would be one call
- # per BYTE -- 400 million of them on a big image. `max_scan` bounds it and
- # reports `truncated` rather than sitting there.
- "list_annotations": r'''
-import ida_bytes, ida_funcs, ida_lines, ida_nalt, ida_name
-import ida_segment, ida_typeinf, idautils
-limit = max(1, int(a.get("limit", 4000)))
-max_scan = max(1000, int(a.get("max_scan", 2000000)))
-comments, names = [], []
-scanned = 0
-
-def _line(ea):
- try:
- txt = ida_lines.generate_disasm_line(ea, ida_lines.GENDSM_REMOVE_TAGS)
- except Exception:
- txt = ""
- return " ".join((txt or "").split())
-
-for ea, nm in idautils.Names():
- if len(names) >= limit:
- break
- if not nm or not ida_bytes.has_user_name(ida_bytes.get_flags(ea)):
- continue
- fn = ida_funcs.get_func(ea)
- is_fn = fn is not None and int(fn.start_ea) == int(ea)
- proto = None
- if is_fn:
- try:
- ti = ida_typeinf.tinfo_t()
- if ida_nalt.get_tinfo(ti, ea):
- proto = str(ti)
- except Exception:
- proto = None
- seg = ida_segment.getseg(ea)
- names.append({"addr": hex(int(ea)), "name": nm, "func": is_fn,
- "size": (int(fn.end_ea - fn.start_ea) if is_fn else 0),
- "proto": proto,
- "seg": (ida_segment.get_segm_name(seg) if seg else "")})
-
-for i in range(ida_segment.get_segm_qty()):
- seg = ida_segment.getnseg(i)
- if seg is None or len(comments) >= limit or scanned >= max_scan:
- continue
- for ea in idautils.Heads(seg.start_ea, seg.end_ea):
- scanned += 1
- if len(comments) >= limit or scanned >= max_scan:
- break
- if not ida_bytes.has_cmt(ida_bytes.get_flags(ea)):
- continue
- for rep in (False, True):
- text = ida_bytes.get_cmt(ea, rep)
- if text:
- fn = ida_funcs.get_func(ea)
- comments.append({
- "addr": hex(int(ea)), "text": text, "repeatable": rep,
- "line": _line(ea), "seg": ida_segment.get_segm_name(seg),
- "func": (ida_funcs.get_func_name(fn.start_ea) if fn else None),
- "func_addr": (hex(int(fn.start_ea)) if fn else None)})
-
-# Whole-function comments are not on the byte flags, so the scan cannot see them.
-for fn_ea in idautils.Functions():
- fn = ida_funcs.get_func(fn_ea)
- if fn is None or len(comments) >= limit:
- continue
- for rep in (False, True):
- text = ida_funcs.get_func_cmt(fn, rep)
- if text:
- seg = ida_segment.getseg(fn_ea)
- comments.append({"addr": hex(int(fn_ea)), "text": text,
- "repeatable": rep, "line": "", "whole_func": True,
- "seg": (ida_segment.get_segm_name(seg) if seg else ""),
- "func": ida_funcs.get_func_name(fn_ea),
- "func_addr": hex(int(fn_ea))})
-result = {"comments": comments, "names": names, "scanned": scanned,
- "truncated": (len(comments) >= limit or len(names) >= limit
- or scanned >= max_scan)}
-result
-''',
- # The findings journal (idatui/journal.py). A netnode blob rides along in
- # the .i64, so "what did I work out here" survives closing the database.
- "journal_get": r'''
-import ida_netnode
-n = ida_netnode.netnode(a.get("node", "$ idatui.journal"))
-blob = n.getblob(0, "I") if ida_netnode.exist(n) else None
-result = {"data": blob.decode("utf-8", "replace") if blob else ""}
-result
-''',
- "journal_put": r'''
-import ida_netnode
-n = ida_netnode.netnode(a.get("node", "$ idatui.journal"), 0, True)
-payload = (a.get("data") or "").encode("utf-8")
-n.setblob(payload, 0, "I")
-result = {"ok": True, "bytes": len(payload)}
-result
-''',
- # Database-wide search (Ctrl+F), two kinds.
- #
- # BYTES uses IDA's own `find_bytes`, which already understands the pattern
- # language people expect -- "B8 ? ? ? ? 90", nibble wildcards ("48 8? ??")
- # and quoted literals -- so we neither parse nor match anything ourselves.
- # Iterating is match+1, per its documented contract.
- "search_bytes": r'''
-import ida_bytes, ida_funcs, ida_idaapi, ida_lines, ida_segment
-pat = str(a.get("pattern", "")).strip()
-limit = max(1, int(a.get("limit", 500)))
-lo = int(a.get("start", 0))
-hi = int(a.get("end", 0)) or ida_idaapi.BADADDR
-flags = ida_bytes.BIN_SEARCH_FORWARD | ida_bytes.BIN_SEARCH_NOSHOW
-if a.get("case"):
- flags |= ida_bytes.BIN_SEARCH_CASE
-rows, err, ea = [], None, lo
-while len(rows) < limit:
- try:
- hit = ida_bytes.find_bytes(pat, range_start=ea, range_end=hi, flags=flags)
- except Exception as exc:
- err = str(exc) or exc.__class__.__name__
- break
- if hit is None or hit == ida_idaapi.BADADDR:
- break
- head = ida_bytes.get_item_head(hit)
- fn = ida_funcs.get_func(hit)
- seg = ida_segment.getseg(hit)
- try:
- line = ida_lines.generate_disasm_line(head, ida_lines.GENDSM_REMOVE_TAGS) or ""
- except Exception:
- line = ""
- rows.append({"addr": hex(int(hit)), "head": hex(int(head)),
- "line": " ".join(line.split()),
- "func": (ida_funcs.get_func_name(fn.start_ea) if fn else None),
- "func_addr": (hex(int(fn.start_ea)) if fn else None),
- "seg": (ida_segment.get_segm_name(seg) if seg else "")})
- ea = int(hit) + 1
-result = {"hits": rows, "error": err, "truncated": len(rows) >= limit}
-result
-''',
- # TEXT walks the listing the way a person reads it: every head's rendered
- # disassembly line, which is why it finds "call cs:__isoc99_scanf" and
- # "0deadbeefh" alike. Bounded by max_scan, so a 400MB image reports partial
- # results instead of stalling.
- "search_text": r'''
-import ida_lines, ida_funcs, ida_segment, idautils
-import re as _re
-q = str(a.get("query", ""))
-limit = max(1, int(a.get("limit", 500)))
-max_scan = max(1000, int(a.get("max_scan", 3000000)))
-ci = (not a.get("case")) and q.islower() # smartcase, like the in-view search
-rx, err = None, None
-if a.get("regex"):
- try:
- rx = _re.compile(q, _re.I if ci else 0)
- except Exception as exc:
- err = "bad regex: " + str(exc)
-needle = q.lower() if ci else q
-rows, scanned = [], 0
-if err is None and q:
- for i in range(ida_segment.get_segm_qty()):
- seg = ida_segment.getnseg(i)
- if seg is None or len(rows) >= limit or scanned >= max_scan:
- continue
- for ea in idautils.Heads(seg.start_ea, seg.end_ea):
- scanned += 1
- if len(rows) >= limit or scanned >= max_scan:
- break
- try:
- line = ida_lines.generate_disasm_line(ea, ida_lines.GENDSM_REMOVE_TAGS) or ""
- except Exception:
- continue
- # Match what the user SEES, not IDA's column padding: nobody types
- # "call" + four spaces + "cs:getenv_ptr".
- line = " ".join(line.split())
- hay = line.lower() if ci else line
- if (rx.search(line) if rx is not None else (needle in hay)):
- fn = ida_funcs.get_func(ea)
- rows.append({"addr": hex(int(ea)), "head": hex(int(ea)),
- "line": line,
- "func": (ida_funcs.get_func_name(fn.start_ea) if fn else None),
- "func_addr": (hex(int(fn.start_ea)) if fn else None),
- "seg": ida_segment.get_segm_name(seg)})
-result = {"hits": rows, "error": err, "scanned": scanned,
- "truncated": len(rows) >= limit or scanned >= max_scan}
-result
-''',
- "list_linkage": r'''
-imports = [{"addr": hex(int(item.address)), "name": item.name, "module": item.module_name}
- for item in db.imports.get_all_imports() if item.name]
-exports = [{"addr": hex(int(item.address)), "name": item.name, "ordinal": int(item.ordinal)}
- for item in db.entries.get_all() if item.name]
-result = {"imports": imports, "exports": exports,
- "n_imports": len(imports), "n_exports": len(exports)}
-result
-''',
- "lookup_funcs": r'''
-rows = []
-for query in a.get("queries", []):
- raw = str(query)
- try: ea = int(raw, 16)
- except ValueError:
- fn = db.functions.get_by_name(raw); ea = int(fn.start_ea) if fn else None
- else: fn = db.functions.get_at(ea)
- if fn is None:
- rows.append({"query": raw, "fn": None})
- else:
- rows.append({"query": raw, "fn": {"addr": hex(int(fn.start_ea)),
- "name": db.functions.get_name(fn) or f"sub_{int(fn.start_ea):X}",
- "size": int(fn.end_ea) - int(fn.start_ea)}})
-result = {"result": rows}
-result
-''',
- "resolve_names": r'''
-import ida_idaapi, ida_name
-rows = []
-for query in a.get("queries", []):
- name = str(query).strip(); ea = ida_name.get_name_ea(ida_idaapi.BADADDR, name)
- rows.append({"query": name, "ea": hex(int(ea)) if ea != ida_idaapi.BADADDR else None})
-result = {"result": rows}
-result
-''',
- # Ours: the coarse code/data type plus a fine `kind` (call/jump/flow,
- # read/write/offset/text/info) that the xref dialog draws its badges from.
- # Deliberately NOT sorted -- the dialog lists xrefs in IDA's own order.
- "xref_types": r'''
-import idaapi, idautils, ida_bytes, ida_funcs, ida_xref
-code_kind = {ida_xref.fl_CF: "call", ida_xref.fl_CN: "call", ida_xref.fl_JF: "jump",
- ida_xref.fl_JN: "jump", ida_xref.fl_F: "flow"}
-data_kind = {ida_xref.dr_O: "offset", ida_xref.dr_W: "write", ida_xref.dr_R: "read",
- ida_xref.dr_T: "text", ida_xref.dr_I: "info"}
-def _kind(xr):
- return (code_kind if xr.iscode else data_kind).get(xr.type, "code" if xr.iscode else "data")
-def _fn(ea):
- f = ida_funcs.get_func(ea)
- return {"addr": hex(int(f.start_ea)), "name": ida_funcs.get_func_name(f.start_ea) or ""} if f else None
-queries = a.get("queries") or []
-all_results = []
-for query in queries:
- query = query if isinstance(query, dict) else {"addr": query}
- raw = str(query.get("addr", "")).strip()
- direction = str(query.get("direction", "to") or "to").lower()
- include_fn = bool(query.get("include_fn", True))
- dedup = bool(query.get("dedup", True))
- try: count = int(query.get("count", 2000) or 2000)
- except (TypeError, ValueError): count = 2000
- try: target = int(raw, 16)
- except ValueError: target = idaapi.get_name_ea(idaapi.BADADDR, raw)
- rows = []
- if target is not None and target != idaapi.BADADDR and ida_bytes.is_mapped(target):
- if direction in ("to", "both"):
- for xr in idautils.XrefsTo(target, 0):
- row = {"direction": "to", "addr": hex(int(xr.frm)), "from": hex(int(xr.frm)),
- "to": hex(int(target)), "type": "code" if xr.iscode else "data", "kind": _kind(xr)}
- if include_fn: row["fn"] = _fn(xr.frm)
- rows.append(row)
- if direction in ("from", "both"):
- for xr in idautils.XrefsFrom(target, 0):
- row = {"direction": "from", "addr": hex(int(xr.to)), "from": hex(int(target)),
- "to": hex(int(xr.to)), "type": "code" if xr.iscode else "data", "kind": _kind(xr)}
- if include_fn: row["fn"] = _fn(xr.to)
- rows.append(row)
- if dedup:
- seen, deduped = set(), []
- for r in rows:
- k = (r["direction"], r["from"], r["to"], r["kind"])
- if k in seen: continue
- seen.add(k); deduped.append(r)
- rows = deduped
- rows = rows[:count]
- all_results.append({"query": raw, "data": rows, "next_offset": None})
-result = {"result": all_results}
-result
-''',
- # Mirrors the tool ida-tui was written against, ORDER INCLUDED. The rows are
- # sorted by the far-end address and deduped by default, and the pseudocode
- # follow's address fallback silently depends on it: at a call site the raw
- # IDA order yields the ordinary-flow xref (the next instruction) first, so an
- # unsorted result makes "follow the call" land on the following line instead.
- "xref_query": r'''
-import idaapi, idautils, ida_bytes, ida_funcs
-def _fn(ea):
- f = ida_funcs.get_func(ea)
- return {"addr": hex(int(f.start_ea)), "name": ida_funcs.get_func_name(f.start_ea) or ""} if f else None
-queries = a.get("queries") or []
-all_results = []
-for query in queries:
- raw = str(query.get("addr", "")).strip()
- direction = str(query.get("direction", "both") or "both").lower()
- if direction not in ("to", "from", "both"): direction = "both"
- xref_type = str(query.get("xref_type", "any") or "any").lower()
- if xref_type not in ("any", "code", "data"): xref_type = "any"
- include_fn = bool(query.get("include_fn", True))
- dedup = bool(query.get("dedup", True))
- sort_by = str(query.get("sort_by", "addr") or "addr")
- descending = bool(query.get("descending", False))
- try: offset = max(0, int(query.get("offset", 0) or 0))
- except (TypeError, ValueError): offset = 0
- try: count = max(0, min(int(query.get("count", 200) or 200), 5000))
- except (TypeError, ValueError): count = 200
- try:
- try: target = int(raw, 16)
- except ValueError:
- target = idaapi.get_name_ea(idaapi.BADADDR, raw)
- if target == idaapi.BADADDR: raise ValueError(f"Failed to resolve address/name: {raw}")
- if not ida_bytes.is_mapped(target): raise ValueError(f"Address not mapped: {raw}")
- rows = []
- if direction in ("to", "both"):
- for xr in idautils.XrefsTo(target, 0):
- kind = "code" if xr.iscode else "data"
- if xref_type != "any" and kind != xref_type: continue
- row = {"direction": "to", "addr": hex(int(xr.frm)), "from": hex(int(xr.frm)),
- "to": hex(int(target)), "type": kind}
- if include_fn: row["fn"] = _fn(xr.frm)
- rows.append(row)
- if direction in ("from", "both"):
- for xr in idautils.XrefsFrom(target, 0):
- kind = "code" if xr.iscode else "data"
- if xref_type != "any" and kind != xref_type: continue
- row = {"direction": "from", "addr": hex(int(xr.to)), "from": hex(int(target)),
- "to": hex(int(xr.to)), "type": kind}
- if include_fn: row["fn"] = _fn(xr.to)
- rows.append(row)
- if dedup:
- seen, deduped = set(), []
- for row in rows:
- key = (row["direction"], row["from"], row["to"], row["type"])
- if key in seen: continue
- seen.add(key); deduped.append(row)
- rows = deduped
- if sort_by == "type":
- rows.sort(key=lambda r: (str(r.get("type", "")), int(str(r["addr"]), 16)), reverse=descending)
- else:
- rows.sort(key=lambda r: int(str(r["addr"]), 16), reverse=descending)
- page = rows[offset:offset + count] if count else rows[offset:]
- nxt = offset + len(page)
- all_results.append({"target": raw, "resolved_addr": hex(int(target)), "direction": direction,
- "xref_type": xref_type, "data": page,
- "next_offset": nxt if nxt < len(rows) else None,
- "total": len(rows), "error": None})
- except Exception as exc:
- all_results.append({"target": raw, "resolved_addr": None, "direction": direction,
- "xref_type": xref_type, "data": [], "next_offset": None,
- "total": 0, "error": str(exc)})
-result = {"result": all_results}
-result
-''',
- # A comment must land in BOTH views, and the pseudocode half is not a
- # simple set: db.comments.set_at() alone leaves the pseudocode unchanged.
- # Hex-Rays comments are anchored to a ctree location (treeloc_t), and an
- # anchor the ctree does not actually own is dropped as an "orphan" -- so the
- # itp slot has to be searched until one sticks, exactly as IDA's own UI does.
- # Without it a comment silently never appears in the decompilation.
- "set_comments": r'''
-import idaapi, idc, ida_hexrays
-rows = []
-for item in a.get("items", []):
- addr_s = str(item.get("addr", ""))
- text = str(item.get("comment") or "")
- try:
- ea = int(addr_s, 16)
- if not idaapi.set_cmt(ea, text, False):
- rows.append({"addr": addr_s,
- "error": f"Failed to set disassembly comment at {hex(ea)}"})
- continue
- if not ida_hexrays.init_hexrays_plugin():
- rows.append({"addr": addr_s}); continue
- try:
- cfunc = ida_hexrays.decompile(ea)
- except Exception:
- cfunc = None
- if cfunc is None:
- rows.append({"addr": addr_s}); continue
- if ea == cfunc.entry_ea:
- # The signature line carries no ctree item: it is a function comment.
- idc.set_func_cmt(ea, text, True)
- cfunc.refresh_func_ctext()
- rows.append({"addr": addr_s}); continue
- eamap = cfunc.get_eamap()
- if ea not in eamap:
- rows.append({"addr": addr_s,
- "error": f"Failed to set decompiler comment at {hex(ea)}"})
- continue
- nearest_ea = eamap[ea][0].ea
- if cfunc.has_orphan_cmts():
- cfunc.del_orphan_cmts(); cfunc.save_user_cmts()
- tl = idaapi.treeloc_t(); tl.ea = nearest_ea
- placed = False
- for itp in range(idaapi.ITP_SEMI, idaapi.ITP_COLON):
- tl.itp = itp
- cfunc.set_user_cmt(tl, text)
- cfunc.save_user_cmts()
- cfunc.refresh_func_ctext()
- if not cfunc.has_orphan_cmts():
- placed = True; break
- cfunc.del_orphan_cmts(); cfunc.save_user_cmts()
- rows.append({"addr": addr_s} if placed else
- {"addr": addr_s,
- "error": f"Failed to set decompiler comment at {hex(ea)}"})
- except Exception as exc:
- rows.append({"addr": addr_s, "error": str(exc)})
-result = {"result": rows}
-result
-''',
- # Every category takes EITHER one edit or a LIST of them, and the answer is
- # one row per edit. The port accepted only a single dict, so any batch path
- # (rpc rename_many applying a whole symbol file, which is the entire point of
- # that verb) died with "list indices must be integers or slices, not str" and
- # reported the failure against addr=null. Mirrors the real tool: conflict
- # detection before the write, dry_run/allow_overwrite/stop_on_error, per-row
- # addr/old/name, and a summary counting EDITS rather than categories.
- "rename": r'''
-import idaapi, ida_hexrays, ida_name
-batch = a.get("batch") or {}
-dry_run = bool(batch.get("dry_run", False))
-allow_overwrite = bool(batch.get("allow_overwrite", False))
-stop_on_error = bool(batch.get("stop_on_error", False))
-
-def _items(value):
- if value is None: return []
- if isinstance(value, dict): return [value]
- if isinstance(value, list): return [i for i in value if isinstance(i, dict)]
- return []
-
-def _set_name_checked(ea, new):
- conflict = idaapi.get_name_ea(idaapi.BADADDR, new)
- if conflict != idaapi.BADADDR and conflict != ea and not allow_overwrite:
- return False, f"can't rename at {hex(ea)} as {new!r}: name already used at {hex(conflict)}"
- if dry_run:
- return True, None
- flags = idaapi.SN_CHECK
- if allow_overwrite: flags |= int(getattr(idaapi, "SN_FORCE", 0))
- if not idaapi.set_name(ea, new, flags):
- return False, (f"Rename failed at {hex(ea)}: IDA rejected name {new!r} "
- "(invalid identifier or internal conflict)")
- return True, None
-
-def _refresh_ctext(fn_addr):
- # A renamed function must invalidate Hex-Rays' cache, which is per function
- # and persisted in the .i64: without this the pseudocode keeps calling the
- # old name forever while every other readback reports the new one.
- if not ida_hexrays.init_hexrays_plugin(): return
- failure = ida_hexrays.hexrays_failure_t()
- cfunc = ida_hexrays.decompile_func(fn_addr, failure, ida_hexrays.DECOMP_WARNINGS)
- if cfunc: cfunc.refresh_func_ctext()
-
-out = {}; ok_count = failed = 0; halted = False
-for category in ("func", "data", "local", "stack"):
- if category not in batch: continue
- rows = []
- for edit in _items(batch.get(category)):
- try:
- if category == "func":
- addr_text = edit.get("addr") or edit.get("func_addr") or edit.get("func")
- new = edit.get("name") or edit.get("new") or edit.get("new_name")
- if not addr_text or not new:
- row = {"addr": addr_text, "name": new,
- "error": "Function rename requires addr + name"}
- else:
- ea = int(str(addr_text), 16)
- fn = idaapi.get_func(ea)
- if fn is None:
- row = {"addr": addr_text, "name": new, "error": "Function not found"}
- else:
- old = idaapi.get_name(fn.start_ea) or None
- ok, err = _set_name_checked(fn.start_ea, str(new))
- row = {"addr": addr_text, "old": old, "name": str(new)}
- if err: row["error"] = err
- if dry_run: row["dry_run"] = True
- if ok and not dry_run: _refresh_ctext(fn.start_ea)
- elif category == "data":
- addr_text = edit.get("addr")
- old = edit.get("old") or edit.get("old_name")
- new = edit.get("new") or edit.get("new_name") or edit.get("name")
- if not new and new != "":
- row = {"old": old, "new": None,
- "error": "Global rename requires target and new name"}
- else:
- if addr_text is not None:
- ea = int(str(addr_text), 16)
- old = old or (idaapi.get_name(ea) or None)
- else:
- ea = idaapi.get_name_ea(idaapi.BADADDR, str(old or ""))
- if ea == idaapi.BADADDR:
- row = {"old": old, "new": str(new), "error": f"Global {old!r} not found"}
- else:
- # An empty new name CLEARS the label; that is a real
- # request (tests revert with it), not a missing argument.
- if str(new) == "":
- ok = bool(ida_name.set_name(ea, "", idaapi.SN_CHECK))
- err = None if ok else f"Failed to clear the name at {hex(ea)}"
- else:
- ok, err = _set_name_checked(ea, str(new))
- row = {"addr": hex(ea), "old": old, "new": str(new)}
- if err: row["error"] = err
- if dry_run: row["dry_run"] = True
- else:
- fa, old, new = edit.get("func_addr"), edit.get("old"), edit.get("new")
- if not fa or not old or not new:
- row = {"old": old, "new": new,
- "error": f"{category} rename requires func_addr + old + new"}
- else:
- ea = int(str(fa), 16)
- pseudo = db.pseudocode.decompile(ea)
- var = pseudo.find_local_variable(str(old))
- if var is None:
- row = {"func_addr": fa, "old": old, "new": new,
- "error": f"no local {old!r} in that function"}
- elif dry_run:
- row = {"func_addr": fa, "old": old, "new": new, "dry_run": True}
- else:
- var.set_user_name(str(new))
- ok = bool(pseudo.save_local_variable_info(var, save_name=True))
- row = {"func_addr": fa, "old": old, "new": new}
- if not ok: row["error"] = "IDA rejected the local variable name"
- except Exception as exc:
- row = {"addr": edit.get("addr"), "error": str(exc)}
- rows.append(row)
- if row.get("error"): failed += 1
- else: ok_count += 1
- if row.get("error") and stop_on_error:
- halted = True; break
- out[category] = rows
- if halted: break
-out["summary"] = {"ok": ok_count, "failed": failed}
-if dry_run: out["summary"]["dry_run"] = True
-if halted: out["summary"]["halted"] = True
-result = out
-result
-''',
-}
-
-
-_OPERATIONS["define_code_run"] = r'''
-import ida_bytes, ida_idp, ida_segment, ida_ua, idaapi
-ea, limit = int(str(a["addr"]), 16), max(1, min(int(a.get("limit", 20000)), 200000))
-seg = ida_segment.getseg(ea)
-if seg is None:
- result = {"addr": a["addr"], "error": "no segment", "count": 0}
-else:
- start, count, stopped, hi = ea, 0, "limit", int(seg.end_ea)
- while count < limit:
- if ea >= hi: stopped = "segment"; break
- flags = ida_bytes.get_flags(ea)
- if ida_bytes.is_code(flags) or ida_bytes.is_data(flags): stopped = "defined"; break
- size = int(ida_ua.create_insn(ea))
- if size <= 0: stopped = "undecodable"; break
- count += 1
- insn = ida_ua.insn_t()
- if ida_ua.decode_insn(insn, ea) > 0:
- try: is_ret = bool(ida_idp.is_ret_insn(insn))
- except Exception: is_ret = False
- if is_ret or (insn.get_canon_feature() & idaapi.CF_STOP):
- ea += size; stopped = "flow"; break
- ea += size
- result = {"start": hex(start), "end": hex(ea), "count": count, "stopped": stopped}
-result
-'''
-
-
-_OPERATIONS["define_func_run"] = r'''
-import ida_bytes, ida_funcs, ida_segment
-ea = int(str(a["addr"]), 16)
-fn = db.functions.get_at(ea)
-if fn is not None and int(fn.start_ea) == ea:
- result = {"addr": hex(ea), "ok": True, "start": hex(ea), "end": hex(int(fn.end_ea)), "how": "existed"}
-else:
- automatic = bool(db.functions.create(ea))
- if not automatic:
- seg = db.segments.get_at(ea); end = ea; hi = int(seg.end_ea) if seg else ea
- while end < hi and ida_bytes.is_code(ida_bytes.get_flags(end)):
- nxt = int(ida_bytes.get_item_end(end))
- if nxt <= end: break
- end = nxt
- ok = bool(end > ea and ida_funcs.add_func(ea, end))
- else: ok = True
- fn = db.functions.get_at(ea)
- result = ({"addr": hex(ea), "ok": True, "start": hex(int(fn.start_ea)),
- "end": hex(int(fn.end_ea)), "how": "auto" if automatic else "explicit-end"}
- if ok and fn is not None else
- {"addr": hex(ea), "ok": False, "error": f"IDA refused a function at {ea:#x}"})
-result
-'''
-
-
-_OPERATIONS["set_thumb"] = r'''
-import ida_bytes, ida_ida, ida_idp, ida_segment, ida_segregs
-ea = int(str(a["addr"]), 16); treg = ida_idp.str2reg("T")
-seg = ida_segment.getseg(ea)
-if treg is None or treg < 0:
- result = {"addr": hex(ea), "error": "no T register (not an ARM database)"}
-elif seg is None:
- result = {"addr": hex(ea), "error": "no segment"}
-else:
- current = ida_segregs.get_sreg(ea, treg)
- current = 0 if current in (None, 0xFFFFFFFF, -1) else int(current)
- want = {"on": 1, "off": 0}.get(str(a.get("mode", "toggle")).lower(), 0 if current else 1)
- changed = False
- if want and seg.bitness != 1:
- ida_segment.set_segm_addressing(seg, 1); changed = True
- size = max(int(ida_bytes.get_item_size(ea)), 2)
- ida_bytes.del_items(ea, 0, size)
- ok = bool(ida_segregs.split_sreg_range(ea, treg, want, ida_segregs.SR_user))
- now = ida_segregs.get_sreg(ea, treg)
- result = {"addr": hex(ea), "thumb": bool(now), "was": bool(current), "ok": ok,
- "bitness": ida_segment.getseg(ea).bitness, "forced_32bit": changed,
- "db_64bit": bool(ida_ida.inf_get_app_bitness() == 64 and want)}
-result
-'''
-
-
-_OPERATIONS["thumb_scan"] = r'''
-import ida_bytes, ida_funcs, ida_idp, ida_segment, ida_segregs, ida_ua
-lo, hi = int(str(a["start"]), 16), int(str(a["end"]), 16)
-apply, limit = bool(a.get("apply", True)), int(a.get("limit", 512))
-treg = ida_idp.str2reg("T"); found = []; applied = 0; cursor = lo
-while cursor + 4 <= hi and len(found) < limit:
- at = cursor; value = int(ida_bytes.get_dword(cursor)); cursor += 4
- if not value & 1: continue
- target = value & ~1; seg = ida_segment.getseg(target)
- if seg is None or not (seg.perm & ida_segment.SEGPERM_EXEC or seg.perm == 0): continue
- flags = ida_bytes.get_flags(target)
- if ida_bytes.is_data(flags): continue
- item = {"at": hex(at), "value": hex(value), "target": hex(target),
- "was_code": bool(ida_bytes.is_code(flags))}; found.append(item)
- if not apply: continue
- if treg is not None and treg >= 0: ida_segregs.split_sreg_range(target, treg, 1, ida_segregs.SR_user)
- if not ida_bytes.is_code(ida_bytes.get_flags(target)):
- ida_bytes.del_items(target, 0, 2)
- if ida_ua.create_insn(target) <= 0: item["decoded"] = False; continue
- item["decoded"] = True; item["function"] = bool(db.functions.get_at(target) or db.functions.create(target)); applied += 1
-result = {"start": hex(lo), "end": hex(hi), "found": found, "applied": applied, "n": len(found)}
-result
-'''
-
-
-_OPERATIONS["decomp_error"] = r'''
-import ida_hexrays, ida_ida
-ea = int(str(a["addr"]), 16); fn = db.functions.get_at(ea)
-result = {"addr": hex(ea), "bitness": ida_ida.inf_get_app_bitness()}
-if fn is None:
- result["reason"] = "no function here"
-else:
- try:
- failure = ida_hexrays.hexrays_failure_t(); cfunc = ida_hexrays.decompile_func(fn, failure)
- if cfunc is not None: result["reason"] = ""
- else:
- result.update({"reason": failure.desc() or f"error {failure.code}",
- "code": int(failure.code), "errea": hex(int(failure.errea))})
- except Exception as exc: result["reason"] = f"{type(exc).__name__}: {exc}"
-result
-'''
-
-# `heads` and the operand-format tools are the port's IDAPython island: the
-# continuous listing's presentation model (undefined runs, colour spans, operand
-# extents, banners, struct members, the digest protocol) and IDA/Hex-Rays number
-# formats have no ida-domain surface. Rather than paraphrase ~1100 lines of
-# performance-tuned, behaviour-sensitive code into string literals, they stay
-# real, diffable source in idatui/remote_tools.py and are shipped to the database
-# process as text. Read once at import; the file ships beside this module.
-_REMOTE_LIB = (Path(__file__).with_name("remote_tools.py")).read_text(encoding="utf-8")
-
-#: Versioned by content, so editing remote_tools.py re-installs it instead of
-#: silently running the copy a long-lived worker already has.
-_REMOTE_MODULE = "_idatui_remote_" + hashlib.sha1(
- _REMOTE_LIB.encode("utf-8")).hexdigest()[:12]
-
-#: Sent back when the database process has not got the library yet; the client
-#: installs it and retries once. Amortised, a worker receives it exactly once.
-_NEED_LIB = "__idatui_needs_remote_lib__"
-
-#: Installs the library as a real module in the database process. Persisting it
-#: in sys.modules is what makes the module-level caches (the tag maps, and the
-#: line-render lru_cache the listing's throughput depends on) survive between
-#: calls -- execute_python builds a fresh namespace every time, so a library
-#: exec'd inline is rebuilt, and its caches thrown away, on every single call.
-_INSTALL_LIB = f'''
-import sys, types
-_m = types.ModuleType({_REMOTE_MODULE!r})
-exec(compile(a["source"], {_REMOTE_MODULE!r}, "exec"), _m.__dict__)
-sys.modules[{_REMOTE_MODULE!r}] = _m
-result = True
-result
-'''
-
-
-def _remote_op(call: str) -> str:
- """A snippet that calls one of the carried-over tools by its real signature.
-
- Costs one short request: the library is imported from the database process's
- own sys.modules, not shipped again.
- """
- return (f"import sys\n"
- f"_m = sys.modules.get({_REMOTE_MODULE!r})\n"
- f"result = {{{_NEED_LIB!r}: True}} if _m is None else _m.{call}\n"
- f"result\n")
-
-
-_OPERATIONS["op_format"] = _remote_op(
- 'op_format(addr=a["addr"], mode=a.get("mode", "cycle"),'
- ' col=int(a.get("col", -1)), n=int(a.get("n", -1)))')
-_OPERATIONS["pc_nums"] = _remote_op('pc_nums(addr=a["addr"])')
-_OPERATIONS["decompile"] = _remote_op(
- 'decompile(addr=a["addr"],'
- ' include_addresses=bool(a.get("include_addresses", True)))')
-_OPERATIONS["decomp_map"] = _remote_op('decomp_map(addr=a["addr"])')
-_OPERATIONS["pc_num_format"] = _remote_op(
- 'pc_num_format(addr=a["addr"], mode=a.get("mode", "cycle"),'
- ' line=int(a.get("line", -1)), col=int(a.get("col", -1)),'
- ' ea=a.get("ea", ""), opnum=int(a.get("opnum", -1)))')
-
-# The listing walker itself. Replaces the port's re-implementation, which
-# rendered no per-operand extents (so no keypress could say which literal it
-# would reformat) and had no digest/expect support (so every page was re-sent
-# after any edit), and whose span walk was the per-character loop our own
-# version had already been rewritten to avoid.
-_HEADS = _remote_op(
- 'heads(addr=a["addr"], count=int(a.get("count", 200)),'
- ' offset=int(a.get("offset", 0)), end=a.get("end", ""),'
- ' back=bool(a.get("back", False)), annotate=bool(a.get("annotate", False)),'
- ' expect=a.get("expect", ""))')
-
-
-# The graph view's only backend call. Blocks are address RANGES, never text:
-# the client re-renders them with `heads`, so boxes reuse the exact listing rows
-# (colours, operand marks, trail painting) instead of growing a second renderer.
-#
-# ida-domain exposes no basic-block/edge-kind surface, so this stays on ida_gdl.
-_OPERATIONS["flowchart"] = r'''
-import ida_funcs, ida_gdl
-ea = int(str(a["addr"]), 16)
-fn = ida_funcs.get_func(ea)
-if fn is None:
- result = {"addr": hex(ea), "error": "no function at that address", "blocks": []}
-else:
- fc = ida_gdl.FlowChart(fn, flags=ida_gdl.FC_PREDS)
- index, order = {}, []
- for bb in fc:
- index[bb.start_ea] = len(order)
- order.append(bb)
- blocks = []
- for bb in order:
- sl = [s for s in bb.succs() if s.start_ea in index]
- succs = []
- for s in sl:
- # Edge kind is what the graph view colours by: an n-way dispatch is
- # "switch", a successor that is literally the next address falls
- # through, anything else is a taken branch.
- if len(sl) > 2: kind = "switch"
- elif s.start_ea == bb.end_ea: kind = "fall"
- else: kind = "jump"
- succs.append([index[s.start_ea], kind])
- blocks.append({"id": index[bb.start_ea], "start": hex(int(bb.start_ea)),
- "end": hex(int(bb.end_ea)), "succs": succs})
- result = {"addr": hex(ea),
- "func": {"addr": hex(int(fn.start_ea)), "end": hex(int(fn.end_ea)),
- "name": ida_funcs.get_func_name(fn.start_ea) or ""},
- "entry": index.get(fn.start_ea, 0), "blocks": blocks}
-result
-'''
-
-# Only ever reached as domain.py's fallback when file_regions yields nothing.
-_OPERATIONS["survey_binary"] = r'''
-segments = []
-for seg in db.segments.get_all():
- segments.append({"start": hex(int(seg.start_ea)), "end": hex(int(seg.end_ea)),
- "name": db.segments.get_name(seg) or ""})
-result = {"segments": segments}
-result
-'''
-
-
-class CodeModeClient:
- """A leased GUI/idalib database accessed through ``ida_codemode``."""
-
- def __init__(
- self,
- binary_path: str,
- *,
- ttl: int = 0,
- load_args: str = "",
- processor: str | None = None,
- loading_address: int | None = None,
- file_type: str | None = None,
- output_database: str | None = None,
- spawn: bool = True,
- new_database: bool = False,
- ) -> None:
- del ttl # managed-worker lifetime is lease-based, not idle-TTL based
- self._path = os.path.abspath(os.path.expanduser(binary_path))
- parsed_processor, parsed_address, parsed_file_type = _parse_load_args(load_args)
- self._processor = processor or parsed_processor
- self._loading_address = loading_address if loading_address is not None else parsed_address
- self._file_type = file_type or parsed_file_type
- self._output_database = output_database
- self._spawn = spawn
- self._new_database = new_database
- self._handle: DatabaseHandle | None = None
- self._last_entry: RegistryEntry | None = None
- self._connect_lock = threading.Lock()
-
- def _database_exists(self) -> bool:
- """Whether the IDB this open would target is already on disk.
-
- Its loader switches are baked in, so they must not be sent again.
- """
- try:
- target = self._output_database or expected_idb_path(self._path)
- except Exception: # noqa: BLE001 -- resolver unavailable: assume fresh
- return False
- return bool(target) and os.path.exists(target)
-
- def connect(self, timeout: float = 1800.0, progress=None) -> "CodeModeClient":
- _require_codemode()
- with self._connect_lock:
- if self._handle is not None and self._handle.connected:
- return self
- if progress:
- progress(f"discovering Code Mode database for {os.path.basename(self._path)}…")
- try:
- # A Ctrl+L reload releases its current managed-worker lease, but
- # that worker remains registered during Code Mode's final-lease
- # grace period. Retry only that known handoff window. A GUI or
- # another long-lived client remains busy and yields a clear
- # failure rather than being modified underneath its owner.
- deadline = time.monotonic() + min(timeout, 60.0)
- while True:
- try:
- # Loader switches describe how to IMPORT a raw file and
- # are recorded in the database it produces. Sending them
- # again for a database that already exists is a FATAL
- # error in IDA itself ("Switch '-b400' can be used only
- # when loading a new file"), which kills the worker
- # before it can report anything useful. So: describe the
- # import only when there is an import to describe.
- fresh = self._new_database or not self._database_exists()
- handle = DatabaseHandle.open(
- self._path,
- spawn=self._spawn,
- timeout=max(0.1, timeout),
- output_database=self._output_database,
- processor=self._processor if fresh else None,
- # DatabaseHandle calls this image_base and wants the
- # natural (16-byte aligned) address; it does the
- # conversion to IDA's paragraph-based -b itself.
- image_base=self._loading_address if fresh else None,
- file_type=self._file_type if fresh else None,
- new_database=self._new_database,
- )
- break
- except IdbBusy:
- if not self._new_database or time.monotonic() >= deadline:
- raise
- if progress:
- progress("waiting for the previous Code Mode lease to close…")
- # Remember the record before managed shutdown withdraws
- # its JSON. The lifetime lock remains held until IDA has
- # actually closed the IDB; waiting on it avoids racing a
- # replacement worker into the old process's file lock.
- expected = canonical_path(
- self._output_database or expected_idb_path(self._path)
- )
- owners = [item.entry for item in scan_instances(timeout=0.5)
- if item.entry.idb_key == idb_key(expected)]
- if owners:
- self._wait_for_entry_release(
- owners[0], max(0.0, deadline - time.monotonic())
- )
- else:
- time.sleep(0.2)
- if progress:
- backend = handle.entry.backend
- progress(f"attached to {backend} database; waiting for auto-analysis…")
- handle.wait_autoanalysis(timeout=timeout)
- except Exception as exc: # normalize the dependency's transport errors
- raise self._connection_error(exc) from exc
- self._handle = handle
- self._last_entry = handle.entry
- return self
-
- @staticmethod
- def _connection_error(exc: BaseException) -> IDAConnectionError:
- return IDAConnectionError(str(exc) or type(exc).__name__)
-
- @property
- def connected(self) -> bool:
- return self._handle is not None and self._handle.connected
-
- @property
- def pid(self) -> int | None:
- return self._handle.entry.pid if self._handle is not None else None
-
- @property
- def backend(self) -> str | None:
- return self._handle.entry.backend if self._handle is not None else None
-
- def execute_python(self, code: str, *, timeout: float | None = None) -> Any:
- if not self.connected:
- self.connect()
- handle = self._handle
- if handle is None:
- raise IDAConnectionError("Code Mode database is not connected")
- try:
- response = handle.execute_python(code, timeout=timeout)
- except RemoteError as exc:
- details = exc.details or {}
- message = str(exc)
- if details.get("traceback"):
- message += f"\n{details['traceback']}"
- if exc.code == "operation_timeout":
- raise IDATimeoutError(message) from exc
- raise IDAToolError("execute_python", message) from exc
- except (InstanceDisconnectedError, ClientError) as exc:
- raise self._connection_error(exc) from exc
- if not isinstance(response, dict) or "result" not in response:
- raise IDAToolError("execute_python", "Code Mode returned an invalid execution result")
- return response["result"]
-
- @staticmethod
- def _unpack(answer: Any) -> Any:
- """Undo _PACK_EPILOGUE. Anything else passes through untouched."""
- if isinstance(answer, dict) and _PACKED in answer:
- return json.loads(answer[_PACKED])
- return answer
-
- def invoke(self, operation: str, *, timeout: float | None = None, **args) -> Any:
- """Execute one TUI domain operation through Code Mode."""
- if operation in ("idb_save", "save"):
- return self.save_database()
- if operation in ("server_health", "ping", "health", "state"):
- return self.health()
- body = _HEADS if operation == "heads" else _OPERATIONS.get(operation)
- if body is None:
- raise IDAToolError(operation, f"unknown ida-tui Code Mode operation: {operation}")
- try:
- answer = self._unpack(self.execute_python(_script(args, body), timeout=timeout))
- if isinstance(answer, dict) and answer.get(_NEED_LIB):
- # First call against this database process (or a restarted one).
- self.execute_python(_script({"source": _REMOTE_LIB}, _INSTALL_LIB),
- timeout=timeout)
- answer = self._unpack(
- self.execute_python(_script(args, body), timeout=timeout))
- return answer
- except IDAToolError as exc:
- if exc.tool == "execute_python":
- raise IDAToolError(operation, exc.message) from exc
- raise
-
- # Temporary source compatibility for external drivers/tests that used the
- # old WorkerClient. Application code uses the accurately named invoke().
- call = invoke
-
- def save_database(self) -> dict[str, Any]:
- if not self.connected:
- self.connect()
- handle = self._handle
- if handle is None:
- raise IDAConnectionError("Code Mode database is not connected")
- try:
- return handle.save_database()
- except RemoteError as exc:
- raise IDAToolError("save_database", str(exc)) from exc
- except (InstanceDisconnectedError, ClientError) as exc:
- raise self._connection_error(exc) from exc
-
- def health(self) -> dict[str, Any]:
- if not self.connected:
- self.connect()
- assert self._handle is not None
- entry = self._handle.entry
- module = os.path.basename(entry.exe_path or entry.idb_path or self._path)
- return {
- "ok": self._handle.connected,
- "module": module,
- "backend": entry.backend,
- "record_id": entry.record_id,
- "input_path": entry.exe_path,
- "idb_path": entry.idb_path,
- }
-
- def keepalive(self, interval: float = 120.0) -> _NoopKeepAlive:
- del interval
- return _NoopKeepAlive()
-
- def resolve_db(self) -> str:
- if not self.connected:
- self.connect()
- assert self._handle is not None
- return self._handle.entry.record_id
-
- def set_db(self, db: str | None) -> None:
- del db # one handle is permanently bound to one registered database
-
- def list_sessions(self) -> list[Session]:
- if not self.connected:
- self.connect()
- assert self._handle is not None
- entry = self._handle.entry
- path = entry.exe_path or entry.idb_path or self._path
- return [Session(session_id=entry.record_id, filename=os.path.basename(path),
- input_path=path, is_active=True)]
-
- def close(self, grace: float = 0.0) -> None:
- del grace
- with self._connect_lock:
- handle, self._handle = self._handle, None
- if handle is not None:
- self._last_entry = handle.entry
- handle.close() # release our lease; never close a GUI/other client's DB
-
- @staticmethod
- def _wait_for_entry_release(entry: "RegistryEntry", timeout: float) -> bool:
- _require_codemode()
- path = REGISTRY_DIR / f"{entry.record_id}.lock"
- deadline = time.monotonic() + max(0.0, timeout)
- while True:
- lock = FileLock(path)
- try:
- if lock.try_acquire():
- return True
- except OSError:
- pass
- finally:
- lock.close()
- if time.monotonic() >= deadline:
- return False
- time.sleep(min(0.1, deadline - time.monotonic()))
-
- def wait_released(self, timeout: float = 45.0) -> bool:
- """Wait until a managed instance releases its lifetime lock.
-
- Normal application shutdown must not wait: another client may retain the
- worker. This is an explicit test/maintenance helper for deleting a
- temporary IDB safely after this client closes. GUI instances return
- ``False`` immediately because clients never own their lifetime.
- """
- entry = self._last_entry
- if entry is None or entry.backend != "idalib":
- return False
- return self._wait_for_entry_release(entry, timeout)
-
- def __enter__(self) -> "CodeModeClient":
- return self.connect()
-
- def __exit__(self, *exc) -> None:
- self.close()
diff --git a/idatui/diag.py b/idatui/diag.py
index b45a72d..7feb6e9 100644
--- a/idatui/diag.py
+++ b/idatui/diag.py
@@ -26,6 +26,7 @@ screen is normal and happens constantly; wrapping that would bury the real
entries in noise. The test for whether it belongs here is "would I want to see
this after the fact?".
"""
+
from __future__ import annotations
import contextlib
@@ -60,7 +61,7 @@ def log(msg: str) -> None:
with open(path, "a", encoding="utf-8") as fh:
fh.write(f"{time.strftime('%H:%M:%S')} {msg}\n")
except OSError:
- pass # a broken log path must never break the app
+ pass # a broken log path must never break the app
def note(what: str, exc: BaseException) -> None:
@@ -76,8 +77,11 @@ def note(what: str, exc: BaseException) -> None:
_ring.append(entry)
log(f"[swallowed] {what}: {entry['error']} ({entry['where']})")
if _logfile():
- log("".join(traceback.format_exception(
- type(exc), exc, exc.__traceback__)).rstrip())
+ log(
+ "".join(
+ traceback.format_exception(type(exc), exc, exc.__traceback__)
+ ).rstrip()
+ )
def _origin(exc: BaseException) -> str:
diff --git a/idatui/domain.py b/idatui/domain.py
index d65c473..1e5a863 100644
--- a/idatui/domain.py
+++ b/idatui/domain.py
@@ -1,4 +1,4 @@
-"""Domain / paging layer: address-centric models over IDA Code Mode.
+"""Domain / paging layer: address-centric models over IDA Nexus.
This is where the "millions of lines" problem is solved, so the TUI widgets only
ever see a viewport-sized slice. Every hard-won constraint from
@@ -7,7 +7,7 @@ ever see a viewport-sized slice. Every hard-won constraint from
* Page sizes remain bounded so remote execution returns viewport-scale JSON.
* Pagination advances by the number of rows actually returned.
* Deep head walks are block-cached (revisits are free) and neighboring blocks
- prefetch through the thread-safe Code Mode client.
+ prefetch through the thread-safe IDA Nexus client.
* Expensive function totals are fetched once and cached.
* Decompilation failures are surfaced as data, not application crashes.
@@ -17,26 +17,26 @@ Textual worker threads; the internal prefetch pool is separate and small.
from __future__ import annotations
+import array
import bisect
import re
import threading
+from base64 import b64decode
+from collections.abc import Callable, Sequence
from concurrent.futures import ThreadPoolExecutor
-from collections.abc import Sequence
from dataclasses import dataclass, field, replace
-from typing import NamedTuple
-from typing import Callable, TYPE_CHECKING
+from typing import TYPE_CHECKING, NamedTuple
-from . import diag
+from . import remote_ops
from .errors import IDAToolError
if TYPE_CHECKING: # type hint only
- from .codemode_client import CodeModeClient
+ from .nexus_client import NexusClient
# Clamps derived from measured caps (list ~700, disasm ~500). Margin included.
LIST_PAGE = 500
DISASM_BLOCK = 256 # instructions per cached/fetched block (<= disasm cap)
-HEX_BLOCK = 16384 # bytes per cached/fetched hex block (compact read_raw -> cheap)
-DECOMPILE_TIMEOUT = 15.0 # s; cap per decompile so a failing one can't hang the CLI
+HEX_BLOCK = 16384 # bytes per cached/fetched hex block (compact read_raw -> cheap)
_TRUNC_RE = re.compile(r"\[(\d+) chars total\]\s*$")
@@ -45,9 +45,12 @@ _TRUNC_RE = re.compile(r"\[(\d+) chars total\]\s*$")
# Value models
# --------------------------------------------------------------------------- #
def _as_int(v) -> int:
+ # Both arms of the ternary this used to end with were `int(v, 16)`, so the
+ # isinstance+startswith test in front of them decided nothing and ran on
+ # every address the client parses -- 55k times per 60 listing pages.
if isinstance(v, int):
return v
- return int(v, 16) if isinstance(v, str) and v.startswith("0x") else int(v, 16)
+ return int(v, 16)
@dataclass(frozen=True)
@@ -87,7 +90,7 @@ class Line:
class Head(NamedTuple):
- """One flat-listing item (from the Code Mode ``heads`` operation): a code
+ """One flat-listing item (from the IDA Nexus ``heads`` operation): a code
instruction, a data item, or an undefined byte run.
A ``NamedTuple`` rather than a dataclass because this is by far the
@@ -102,13 +105,13 @@ class Head(NamedTuple):
"""
ea: int
- kind: str # 'code' | 'data' | 'unknown' | 'member'
+ kind: str # 'code' | 'data' | 'unknown' | 'member'
size: int
text: str
name: str | None = None
raw: bytes | None = None # opcode/item bytes (filled in for code by the model)
#: [(kind, text)] from IDA's own colour tags — mnem/reg/num/name/str/punct/…
- #: None when Code Mode didn't provide them (or the spans
+ #: None when IDA Nexus didn't provide them (or the spans
#: disagreed with the plain text, in which case the text wins).
#:
#: Held exactly as it came off the wire, and **read-only**. The worker
@@ -140,15 +143,22 @@ class Head(NamedTuple):
# tool emits [str, str] and [int, int, int], so re-coercing them was
# re-proving that once per listing row -- and copying them into tuples
# destroyed the sharing the worker's line cache had just created.
+ #
+ # Built POSITIONALLY, and with the address converted inline. This is the
+ # most-constructed object in the codebase (227k of them to stream one
+ # bash) and the two together are worth ~40%: keyword construction has to
+ # match names against the tuple's fields, and _as_int was a call per row
+ # to do one isinstance and an int().
+ v = d["ea"]
return cls(
- ea=_as_int(d["ea"]),
- kind=d.get("kind", "unknown"),
- size=int(d.get("size", 0) or 0),
- text=d.get("text", ""),
- name=d.get("name"),
- raw=raw,
- spans=d.get("spans") or None,
- ops=d.get("ops") or None,
+ v if isinstance(v, int) else int(v, 16),
+ d.get("kind", "unknown"),
+ int(d.get("size", 0) or 0),
+ d.get("text", ""),
+ d.get("name"),
+ raw,
+ d.get("spans") or None,
+ d.get("ops") or None,
)
@@ -187,10 +197,10 @@ class Ref:
@dataclass
class Xref:
- frm: int # the referencing address
- to: int | None # the referenced address
- type: str # coarse: "code" | "data"
- fn_name: str | None # function containing `frm`
+ frm: int # the referencing address
+ to: int | None # the referenced address
+ type: str # coarse: "code" | "data"
+ fn_name: str | None # function containing `frm`
fn_addr: int | None
kind: str | None = None # fine: call/jump/flow/read/write/offset/text/info
@@ -206,7 +216,7 @@ class LVar:
class FuncTypes:
addr: int
name: str
- prototype: str # e.g. 'int __fastcall foo(int a, char *b)'
+ prototype: str # e.g. 'int __fastcall foo(int a, char *b)'
lvars: list[LVar]
@@ -215,7 +225,7 @@ class Struct:
name: str
size: int
is_union: bool
- members: int # field count
+ members: int # field count
ordinal: int
@classmethod
@@ -232,6 +242,7 @@ class Struct:
@dataclass(frozen=True)
class StrLit:
"""A string literal IDA found in the binary (the Shift+F12 list)."""
+
addr: int
text: str
length: int
@@ -259,6 +270,7 @@ class SearchHit:
an instruction, so ``head`` is the item to navigate to and ``line`` is what
that item renders as.
"""
+
addr: int
head: int
line: str = ""
@@ -275,6 +287,7 @@ class Comment:
report can show what was being commented ON without a second round trip.
``whole_func`` marks a function comment rather than an instruction one.
"""
+
addr: int
text: str
repeatable: bool = False
@@ -290,6 +303,7 @@ class NamedItem:
"""An address carrying a real name -- one you typed, or one the file's own
symbols supplied. IDA records both as "user" names and does not remember
which was which, so a report must say so rather than claim authorship."""
+
addr: int
name: str
is_func: bool = False
@@ -307,6 +321,7 @@ class Linkage:
``name`` is the joinable name; ``raw`` keeps the spelling IDA reported, which
is what the user sees in the listing.
"""
+
addr: int
name: str
module: str = ""
@@ -362,7 +377,9 @@ class FunctionIndex:
query: dict = {"offset": offset, "count": LIST_PAGE}
if self.filter:
query["filter"] = self.filter
- data = _query_data(self._prog.client.invoke("list_funcs", queries=[query]))
+ data = _query_data(
+ self._prog.client.call(remote_ops.list_funcs, queries=[query])
+ )
added = 0
with self._lock:
for d in data:
@@ -456,7 +473,7 @@ class DisasmModel:
self._blocks: dict[int, list[Line]] = {}
self._total: int | None = None
self._ea_list: list[int] | None = None
- self._max_raw = 0 # widest opcode length seen (bytes)
+ self._max_raw = 0 # widest opcode length seen (bytes)
self._func_end: int | None = None
self._func_end_done = False
self._lock = threading.Lock()
@@ -472,8 +489,8 @@ class DisasmModel:
code function this equals the heads row count that backs the lines."""
if self._total is not None:
return self._total
- payload = self._prog.client.invoke(
- "disasm", addr=hex(self.ea), max_instructions=1, include_total=True
+ payload = self._prog.client.call(
+ remote_ops.disasm, addr=hex(self.ea), max_instructions=1, include_total=True
)
total = payload.get("total_instructions")
if total is None:
@@ -515,7 +532,7 @@ class DisasmModel:
nxt = lines[i + 1].ea if i + 1 < len(lines) else last_end
length = max(nxt - ln.ea, 0)
off = ln.ea - start
- b = bytes(data[off:off + length])
+ b = bytes(data[off : off + length])
biggest = max(biggest, len(b))
out.append(replace(ln, raw=b))
with self._lock:
@@ -526,20 +543,22 @@ class DisasmModel:
@staticmethod
def _line_from_head(r: dict) -> Line:
"""Adapt a ``heads`` row to a disasm Line (label = the head's name)."""
- return Line(ea=_as_int(r["ea"]), text=r.get("text", ""),
- label=r.get("name"))
+ return Line(ea=_as_int(r["ea"]), text=r.get("text", ""), label=r.get("name"))
def _fetch_block(self, b: int) -> list[Line]:
# The function disasm view is a listing filtered to the function: fetch a
# block of heads (one per instruction for code). Over-fetch one row so
# the block knows where its last instruction ends (opcode-byte sizing).
- payload = self._prog.client.invoke(
- "heads", addr=hex(self.ea), offset=b * self.BLOCK,
- count=self.BLOCK + 1, **self._end_kw(),
+ payload = self._prog.client.call(
+ remote_ops.heads,
+ addr=hex(self.ea),
+ offset=b * self.BLOCK,
+ count=self.BLOCK + 1,
+ **self._end_kw(),
)
rows = payload.get("heads", []) if isinstance(payload, dict) else []
fetched = [self._line_from_head(r) for r in rows]
- lines = fetched[:self.BLOCK]
+ lines = fetched[: self.BLOCK]
if len(fetched) > self.BLOCK:
end_ea: int | None = fetched[self.BLOCK].ea
else: # this block ends the function
@@ -595,7 +614,7 @@ class DisasmModel:
block = self._get_block(b)
lo = start - b * self.BLOCK if b == b0 else 0
hi = end - b * self.BLOCK if b == b1 else self.BLOCK
- out.extend(block[max(lo, 0):hi])
+ out.extend(block[max(lo, 0) : hi])
if prefetch:
self._prefetch_block(b1 + 1) # forward scroll
self._prefetch_block(b0 - 1) # backward scroll
@@ -674,7 +693,7 @@ class ListingModel:
"""A flat, IDA-style disassembly *listing* over one segment: code, data and
undefined heads interleaved, unlike ``DisasmModel`` (one function, code only).
- Backed by the Code Mode adapter's ``heads`` operation, which walks item heads
+ Backed by the IDA Nexus adapter's ``heads`` operation, which walks item heads
and renders each via ``generate_disasm_line``. The segment is walked lazily in
forward pages (``FunctionIndex`` style); line index == position in the walked
head list. Random access to an address is O(distance-from-seg-start) the
@@ -682,10 +701,14 @@ class ListingModel:
on demand as the viewport scrolls. Synchronous + thread-safe.
"""
- PAGE = 500 # viewport-scale heads per Code Mode execution
+ PAGE = 500 # viewport-scale heads per IDA Nexus execution
+ #: Generation marker for a skeleton (text-less) page. Never equals a real
+ #: _text_gen, which counts up from 0, so such a page always reads as stale.
+ _SKELETON_GEN = -1
- def __init__(self, program: "Program", seg_start: int, seg_end: int,
- name: str | None = None):
+ def __init__(
+ self, program: "Program", seg_start: int, seg_end: int, name: str | None = None
+ ):
self._prog = program
self.seg_start = seg_start
self.seg_end = seg_end
@@ -699,7 +722,7 @@ class ListingModel:
# N bytes PRESENTS as N rows and the text for each is synthesised on
# demand. _row_at[i] is the logical row where physical head i starts.
self._row_at: list[int] = []
- self._head_eas: list[int] = [] # parallel to _heads, for bisect
+ self._head_eas: list[int] = [] # parallel to _heads, for bisect
#: Which name generation each head's TEXT was rendered at, parallel to
#: _heads. A rename bumps :attr:`_text_gen`; the rows themselves stay
#: (their addresses and row numbers are unchanged) and are re-rendered a
@@ -709,6 +732,10 @@ class ListingModel:
#: Whether a rename has ever staled this model. Until one has, every
#: read takes exactly the path it always did.
self._renamed = False
+ #: Whether any page was loaded as a text-less skeleton. Same effect as
+ #: _renamed -- reads have to check the per-head generation -- so the two
+ #: are ORed at every gate rather than duplicating the machinery.
+ self._skeleton = False
#: One entry per loaded PAGE: where its heads start, the address it was
#: fetched from, the digest it came back with, and how many rows it
#: held. A stale-text refresh re-asks for exactly that page, so it can
@@ -721,7 +748,7 @@ class ListingModel:
#: means something DID move the walk. Program.listing() throws the model
#: away when it sees this, so the next read rebuilds from scratch.
self.stale_structure = False
- self._rows = 0 # total logical rows loaded
+ self._rows = 0 # total logical rows loaded
self._ubytes: dict[int, bytes] = {} # lazily-read bytes for those rows
self._next: int | None = seg_start # next address to fetch from
self._done = False
@@ -735,7 +762,7 @@ class ListingModel:
# containing a huge coalesced undefined run doesn't pull megabytes.
_OP_SPAN_CAP = 1 << 16
- def _build_page(self, rows: list) -> list[Head]:
+ def _build_page(self, rows: list, raw: bool = True) -> list[Head]:
"""Turn the tool's raw rows into ``Head``s with their opcode bytes
already attached, via one bulk read over the code extent.
@@ -753,7 +780,10 @@ class ListingModel:
lo = ea
hi = ea + int(r["size"])
data = None
- if 0 <= lo < hi and hi - lo <= self._OP_SPAN_CAP:
+ # A skeleton page shows no text, so it needs no opcode bytes -- and
+ # skipping them drops the SECOND round trip a page costs (heads is
+ # always followed by a bulk read_raw over the code extent).
+ if raw and 0 <= lo < hi and hi - lo <= self._OP_SPAN_CAP:
try:
data = self._prog.read_bytes(lo, hi - lo)
except Exception: # noqa: BLE001 -- opcode bytes are decoration
@@ -766,7 +796,7 @@ class ListingModel:
size = int(r.get("size") or 0)
if size > 0:
off = _as_int(r["ea"]) - lo
- raw = bytes(data[off:off + size])
+ raw = bytes(data[off : off + size])
if len(raw) > biggest:
biggest = len(raw)
try:
@@ -782,30 +812,139 @@ class ListingModel:
with self._lock:
return self._max_raw
- def load_next_page(self) -> int:
- """Load one more page of heads; returns how many were added."""
- return self._load_next_page()
+ def build_from_index(self) -> bool:
+ """Populate the whole row index from ONE call instead of streaming it.
- def _load_next_page(self) -> int:
+ ``segment_index(detail=True)`` walks the segment and returns every row's
+ address, kind and size as packed arrays, plus the page boundaries a
+ refetch would use. That is everything this model needs to know how many
+ rows there are and where each one lives -- all that is missing is the
+ rendered text, which is exactly what a skeleton page is missing too.
+
+ So the rows land marked ``_SKELETON_GEN`` and the FIRST read of any page
+ materialises it through the existing ``_ensure_text``/``_ensure_page``
+ path, the same one a rename uses. Measured on bash: 594ms and one call,
+ against 1827ms and 458 for streaming the same thing.
+
+ Returns False if the backend cannot supply it, in which case the caller
+ should stream as before -- this is an optimisation, not a new contract.
+
+ IDEMPOTENT, and that is load-bearing: the view re-primes on every switch
+ back to the listing, so rebuilding here unconditionally put a ~900ms
+ segment_index in front of every Tab out of the decompiler.
+ """
+ with self._lock:
+ if self._done and self._heads:
+ return True # already indexed; re-priming is a no-op
+ try:
+ idx = self._prog.client.call(
+ remote_ops.segment_index,
+ addr=hex(self.seg_start),
+ end=hex(self.seg_end),
+ page_rows=self.PAGE,
+ detail=True,
+ )
+ except Exception: # noqa: BLE001 -- fall back to streaming
+ return False
+ if not isinstance(idx, dict) or idx.get("error") or "eas" not in idx:
+ return False
+ try:
+ eas = array.array("Q")
+ eas.frombytes(b64decode(idx["eas"]))
+ kinds = array.array("B")
+ kinds.frombytes(b64decode(idx["kinds"]))
+ sizes = array.array("I")
+ sizes.frombytes(b64decode(idx["sizes"]))
+ except Exception: # noqa: BLE001
+ return False
+ names = idx.get("kind_names") or []
+ anchors = idx.get("anchors") or []
+ n = len(eas)
+ if not (n == len(kinds) == len(sizes)) or not anchors:
+ return False
+
+ heads: list[Head] = []
+ row_at: list[int] = []
+ by_ea: dict[int, int] = {}
+ rows = 0
+ ap = heads.append
+ rap = row_at.append
+ for i in range(n):
+ ea = eas[i]
+ kind = names[kinds[i]] if kinds[i] < len(names) else "unknown"
+ size = sizes[i]
+ ap(Head(ea, kind, size, ""))
+ rap(rows)
+ # Banner/label rows are display-only; navigation must land on the
+ # real head at that address. Same rule as the streaming loader.
+ if kind not in ("sep", "funchdr", "label"):
+ by_ea.setdefault(ea, rows)
+ rows += size if (kind == "unknown" and size > 1) else 1
+
+ with self._lock:
+ self._heads = heads
+ self._head_eas = list(eas)
+ self._head_gen = [self._SKELETON_GEN] * n
+ self._row_at = row_at
+ self._by_ea = by_ea
+ self._rows = rows
+ # Anchors are [logical_row, ea, head_index] at the exact boundaries
+ # heads(count=PAGE) pages on, so _ensure_page can refetch one page
+ # and have it line up head for head.
+ self._page_head = [a[2] for a in anchors]
+ self._page_addr = [_as_int(a[1]) for a in anchors]
+ self._page_digest = [None] * len(anchors)
+ self._page_rows = [
+ (anchors[k + 1][2] if k + 1 < len(anchors) else n) - anchors[k][2]
+ for k in range(len(anchors))
+ ]
+ self._skeleton = True
+ self._done = True
+ self._next = None
+ return True
+
+ def load_next_page(self, text: bool = True) -> int:
+ """Load one more page of heads; returns how many were added.
+
+ ``text=False`` loads a SKELETON page: the same rows at the same
+ addresses with the same sizes and kinds, but no rendered disassembly
+ and no opcode bytes -- 2.8x cheaper, and one round trip instead of two.
+
+ That is all the background grower needs. It exists to discover how many
+ rows the segment has so the scrollbar and paging are right, and it
+ renders 227k rows of a 1.2MB bash to do it, essentially all of which are
+ never looked at. A skeleton page is marked text-stale, so the FIRST read
+ of one goes through exactly the same ``_ensure_text`` path a rename uses
+ and materialises it, one page per round trip, only for what is shown.
+ """
+ return self._load_next_page(text)
+
+ def _load_next_page(self, text: bool = True) -> int:
with self._load_lock:
- return self._load_next_page_locked()
+ return self._load_next_page_locked(text)
- def _load_next_page_locked(self) -> int:
+ def _load_next_page_locked(self, text: bool = True) -> int:
with self._lock:
if self._done or self._next is None:
return 0
frm = self._next
- payload = self._prog.client.invoke(
- "heads", addr=hex(frm), count=self.PAGE, annotate=True)
+ payload = self._prog.client.call(
+ remote_ops.heads, addr=hex(frm), count=self.PAGE, annotate=True, text=text
+ )
rows = payload.get("heads", []) if isinstance(payload, dict) else []
cur = payload.get("cursor", {}) if isinstance(payload, dict) else {}
- page = self._build_page(rows)
+ page = self._build_page(rows, raw=text)
with self._lock:
- gen = self._text_gen
+ # A sentinel generation no _text_gen can ever equal, so the page
+ # reads as stale until something asks for it and refreshes it.
+ gen = self._text_gen if text else self._SKELETON_GEN
+ if not text:
+ self._skeleton = True
self._page_head.append(len(self._heads))
self._page_addr.append(frm)
- self._page_digest.append(payload.get("digest")
- if isinstance(payload, dict) else None)
+ self._page_digest.append(
+ payload.get("digest") if isinstance(payload, dict) else None
+ )
self._page_rows.append(len(rows))
for h in page:
# Banner/label rows (function headers, separators, code labels)
@@ -862,7 +1001,7 @@ class ListingModel:
self._ubytes[b0] = blk
off = a - b0
take = min(BLK - off, n - len(out))
- chunk = blk[off:off + take] if blk else b""
+ chunk = blk[off : off + take] if blk else b""
if not chunk:
break
out += chunk
@@ -883,8 +1022,9 @@ class ListingModel:
ea = h.ea + off
b = self._unknown_bytes(ea, 1)
text = f"db {b[0]:02X}h" if b else "db ?"
- return Head(ea=ea, kind="unknown", size=1, text=text,
- name=h.name if off == 0 else None)
+ return Head(
+ ea=ea, kind="unknown", size=1, text=text, name=h.name if off == 0 else None
+ )
def ensure(self, n: int) -> None:
"""Ensure at least ``n`` logical rows are loaded (or all, if fewer)."""
@@ -901,8 +1041,11 @@ class ListingModel:
return idx
with self._lock:
have = self._rows
- last_ea = (self._heads[-1].ea + max(self._heads[-1].size, 1) - 1
- if self._heads else -1)
+ last_ea = (
+ self._heads[-1].ea + max(self._heads[-1].size, 1) - 1
+ if self._heads
+ else -1
+ )
done = self._done
if done or (have and last_ea >= ea):
# Loaded past ea without an exact head hit: return the first head
@@ -965,13 +1108,13 @@ class ListingModel:
"""
with self._lock:
if not (self.seg_start <= ea < self.seg_end):
- return True # another segment; nothing moved here
+ return True # another segment; nothing moved here
if len(self._page_head) < 3:
- return False # barely walked; a rebuild is cheaper
+ return False # barely walked; a rebuild is cheaper
p = bisect.bisect_right(self._page_addr, ea) - 1
p = max(p - 1, 0)
if p <= 0:
- return False # the edit is in the first pages
+ return False # the edit is in the first pages
keep = self._page_head[p]
if keep <= 0:
return False
@@ -989,7 +1132,7 @@ class ListingModel:
last = self._heads[-1]
self._rows = self._row_at[-1] + self._span(last)
self._done = False
- self._ubytes.clear() # undefined-run bytes behind the drop point
+ self._ubytes.clear() # undefined-run bytes behind the drop point
return True
def invalidate_text(self) -> None:
@@ -1035,8 +1178,9 @@ class ListingModel:
def _page_bounds(self, p: int) -> tuple[int, int]:
"""[first, last) head index of page ``p`` (caller holds the lock)."""
lo = self._page_head[p]
- hi = (self._page_head[p + 1] if p + 1 < len(self._page_head)
- else len(self._heads))
+ hi = (
+ self._page_head[p + 1] if p + 1 < len(self._page_head) else len(self._heads)
+ )
return lo, hi
def _ensure_page(self, p: int) -> int:
@@ -1060,13 +1204,20 @@ class ListingModel:
# the expectation rather than asking first means a page that HAS changed
# still costs one round trip.
try:
- payload = self._prog.client.invoke(
- "heads", addr=hex(addr), count=self.PAGE, annotate=True,
- expect="" if want_digest is None else str(want_digest))
+ payload = self._prog.client.call(
+ remote_ops.heads,
+ addr=hex(addr),
+ count=self.PAGE,
+ annotate=True,
+ expect="" if want_digest is None else str(want_digest),
+ )
except Exception: # noqa: BLE001 -- keep the old text rather than blank
return p + 1
- if (isinstance(payload, dict) and "heads" not in payload
- and payload.get("count") == want_rows):
+ if (
+ isinstance(payload, dict)
+ and "heads" not in payload
+ and payload.get("count") == want_rows
+ ):
with self._lock:
if self._text_gen == gen and len(self._heads) >= hi:
for k in range(lo, hi):
@@ -1091,8 +1242,9 @@ class ListingModel:
# what it once loaded. Leaving it stale is how a literal cycling
# hex -> dec -> hex ends up declared "unchanged" while the row still
# shows the decimal it was refetched with in between.
- self._page_digest[p] = (payload.get("digest")
- if isinstance(payload, dict) else None)
+ self._page_digest[p] = (
+ payload.get("digest") if isinstance(payload, dict) else None
+ )
for k in range(lo, hi):
self._head_gen[k] = gen
return p + 1
@@ -1104,7 +1256,9 @@ class ListingModel:
j, off = self._phys(i)
if j < 0:
return None
- stale = self._renamed and self._head_gen[j] != self._text_gen
+ stale = (self._renamed or self._skeleton) and self._head_gen[
+ j
+ ] != self._text_gen
if not stale:
span = self._span(self._heads[j])
h = self._heads[j]
@@ -1132,7 +1286,7 @@ class ListingModel:
# _renamed stays set once a rename has happened; _ensure_text then
# does the precise, range-limited staleness check. Before the first
# rename this is one boolean and the read is exactly as it was.
- dirty = self._renamed
+ dirty = self._renamed or self._skeleton
if dirty:
j0 = max(self._phys(max(start, 0))[0], 0)
j1 = self._phys(max(min(self._rows, start + count) - 1, 0))[0] + 1
@@ -1143,8 +1297,9 @@ class ListingModel:
spans = [self._phys(i) for i in range(max(start, 0), max(rows, 0))]
heads = self._heads
plain = [(j, off, heads[j]) for j, off in spans if j >= 0]
- return [self._row_head(j, off) if self._span(h) > 1 else h
- for j, off, h in plain]
+ return [
+ self._row_head(j, off) if self._span(h) > 1 else h for j, off, h in plain
+ ]
def index_of_ea(self, ea: int) -> int:
with self._lock:
@@ -1239,7 +1394,7 @@ class HexModel:
if block is None:
return (va, None)
bo = off - b * self.BLOCK
- return (va, block[bo:bo + 16])
+ return (va, block[bo : bo + 16])
def ensure(self, r0: int, count: int) -> None:
"""Blocking: fetch the blocks covering rows [r0, r0+count) if missing."""
@@ -1267,6 +1422,11 @@ class HexModel:
for b in range(b0 - 1, b1 + 2):
self._prefetch(b)
+ def invalidate(self) -> None:
+ """Drop cached bytes so the next viewport read reaches the database."""
+ with self._lock:
+ self._blocks.clear()
+
# --------------------------------------------------------------------------- #
# Program: top-level handle, model registry, prefetch pool
@@ -1274,7 +1434,7 @@ class HexModel:
class Program:
"""The bound analysis session: models, caches, and a small prefetch pool."""
- def __init__(self, client: "CodeModeClient", prefetch_workers: int = 2):
+ def __init__(self, client: "NexusClient", prefetch_workers: int = 2):
self.client = client
self._pool = ThreadPoolExecutor(
max_workers=prefetch_workers, thread_name_prefix="idatui-prefetch"
@@ -1325,27 +1485,39 @@ class Program:
"""Sorted raw segment map [(start, end, file_off, name)] — the single
source for sections()/file_regions()/image_range. Cached.
- Uses the Code Mode adapter's ``file_regions`` operation (a plain segment
+ Uses the IDA Nexus adapter's ``file_regions`` operation (a plain segment
walk, ~ms), avoiding broad binary surveys on the hex-pane open path.
"""
if self._segments_cache is not None:
return self._segments_cache
segs: list[tuple[int, int, int, str]] = []
try:
- r = self.client.invoke("file_regions")
- for d in (r.get("regions", []) if isinstance(r, dict) else []):
+ r = self.client.call(remote_ops.file_regions)
+ for d in r.get("regions", []) if isinstance(r, dict) else []:
if isinstance(d, dict) and "start" in d:
- segs.append((_as_int(d["start"]), _as_int(d["end"]),
- int(d.get("file_off", -1)), d.get("name", "") or ""))
+ segs.append(
+ (
+ _as_int(d["start"]),
+ _as_int(d["end"]),
+ int(d.get("file_off", -1)),
+ d.get("name", "") or "",
+ )
+ )
except IDAToolError:
segs = []
if not segs: # older server without file_regions -> survey_binary (slow)
try:
- sb = self.client.invoke("survey_binary")
- for s in (sb.get("segments", []) if isinstance(sb, dict) else []):
+ sb = self.client.call(remote_ops.survey_binary)
+ for s in sb.get("segments", []) if isinstance(sb, dict) else []:
try:
- segs.append((_as_int(s["start"]), _as_int(s["end"]), -1,
- s.get("name", "") or ""))
+ segs.append(
+ (
+ _as_int(s["start"]),
+ _as_int(s["end"]),
+ -1,
+ s.get("name", "") or "",
+ )
+ )
except (KeyError, ValueError, TypeError):
continue
except Exception: # noqa: BLE001 -- best-effort; callers handle empty
@@ -1401,28 +1573,34 @@ class Program:
def read_bytes(self, ea: int, n: int) -> bytes:
"""Raw bytes [ea, ea+n) from IDA (gaps read as zero).
- The Code Mode adapter returns one contiguous hex string (C-speed in IDA).
+ The IDA Nexus adapter returns one contiguous hex string (C-speed in IDA).
A legacy ``get_bytes`` decoding fallback remains for alternate clients.
"""
if n <= 0:
return b""
if not self._no_read_raw:
try:
- r = self.client.invoke("read_raw", addr=hex(ea), size=int(n))
+ r = self.client.call(remote_ops.read_raw, addr=hex(ea), size=int(n))
h = r.get("hex") if isinstance(r, dict) else None
if isinstance(h, str):
out = bytes.fromhex(h)
return out[:n] if len(out) >= n else out + b"\x00" * (n - len(out))
except IDAToolError as e:
# Tool missing on this server: stop trying it, use get_bytes.
- if "read_raw" in str(e) or "Unknown tool" in str(e) or "not found" in str(e):
+ if (
+ "read_raw" in str(e)
+ or "Unknown tool" in str(e)
+ or "not found" in str(e)
+ ):
self._no_read_raw = True
else:
return b"\x00" * n
except (ValueError, KeyError):
pass # malformed hex -> fall through to the legacy decoder
try:
- r = self.client.invoke("get_bytes", regions=[{"addr": hex(ea), "size": int(n)}])
+ r = self.client.call(
+ remote_ops.get_bytes, regions=[{"addr": hex(ea), "size": int(n)}]
+ )
except IDAToolError:
return b"\x00" * n
res = r.get("result", []) if isinstance(r, dict) else []
@@ -1463,7 +1641,7 @@ class Program:
with self._lock:
m = self._listings.get(start)
if m is not None and m.stale_structure:
- m = None # a refresh found the walk had moved; start over
+ m = None # a refresh found the walk had moved; start over
if m is None:
m = ListingModel(self, start, end, name)
self._listings[start] = m
@@ -1473,11 +1651,15 @@ class Program:
def list_structs(self, filter: str = "") -> list[Struct]:
"""All local structs/unions (optionally name-substring filtered), sorted
by name."""
- payload = self.client.invoke("search_structs", filter=filter)
+ payload = self.client.call(remote_ops.search_structs, filter=filter)
res = payload.get("result", []) if isinstance(payload, dict) else []
- out = [Struct.from_raw(d) for d in res
- if isinstance(d, dict) and d.get("name")
- and not str(d["name"]).startswith("$")] # skip anonymous UDTs
+ out = [
+ Struct.from_raw(d)
+ for d in res
+ if isinstance(d, dict)
+ and d.get("name")
+ and not str(d["name"]).startswith("$")
+ ] # skip anonymous UDTs
out.sort(key=lambda s: s.name.lower())
return out
@@ -1485,8 +1667,9 @@ class Program:
"""A C definition for ``name`` reconstructed from its member layout
(the remote operation exposes members, not printable source). Faithful to IDA's
field names/types; array dims are moved after the field name."""
- payload = self.client.invoke(
- "type_inspect", queries=[{"name": name, "include_members": True}])
+ payload = self.client.call(
+ remote_ops.type_inspect, queries=[{"name": name, "include_members": True}]
+ )
res = payload.get("result", []) if isinstance(payload, dict) else []
info = res[0] if res and isinstance(res[0], dict) else {}
kw = "union" if info.get("is_union") else "struct"
@@ -1508,7 +1691,7 @@ class Program:
def declare_type(self, decl: str) -> str | None:
"""Create or update a C type. Returns None on success, else the parse
error. (Re-declaring a name updates it in place.)"""
- payload = self.client.invoke("declare_type", decls=decl)
+ payload = self.client.call(remote_ops.declare_type, decls=decl)
res = payload.get("result", []) if isinstance(payload, dict) else []
if res and isinstance(res[0], dict):
return res[0].get("error")
@@ -1519,20 +1702,32 @@ class Program:
"""Structured decompiler types for the function at ``ea`` (prototype +
local variables). None if ``ea`` isn't a decompilable function."""
try:
- r = self.client.invoke("func_types", addr=hex(ea))
+ r = self.client.call(remote_ops.func_types, addr=hex(ea))
except IDAToolError:
return None
if not isinstance(r, dict) or r.get("error"):
return None
- lvars = [LVar(name=lv.get("name", ""), type=lv.get("type", ""),
- is_arg=bool(lv.get("is_arg")))
- for lv in r.get("lvars", []) if isinstance(lv, dict)]
- return FuncTypes(addr=_as_int(r.get("addr", hex(ea))), name=r.get("name", ""),
- prototype=r.get("prototype", ""), lvars=lvars)
+ lvars = [
+ LVar(
+ name=lv.get("name", ""),
+ type=lv.get("type", ""),
+ is_arg=bool(lv.get("is_arg")),
+ )
+ for lv in r.get("lvars", [])
+ if isinstance(lv, dict)
+ ]
+ return FuncTypes(
+ addr=_as_int(r.get("addr", hex(ea))),
+ name=r.get("name", ""),
+ prototype=r.get("prototype", ""),
+ lvars=lvars,
+ )
def set_function_type(self, ea: int, signature: str) -> str | None:
"""Set a function's prototype. None on success, else an error string."""
- r = self.client.invoke("set_type", edits=[{"addr": hex(ea), "signature": signature}])
+ r = self.client.call(
+ remote_ops.set_type, edits=[{"addr": hex(ea), "signature": signature}]
+ )
res = r.get("result", []) if isinstance(r, dict) else []
row = res[0] if res and isinstance(res[0], dict) else {}
if row.get("ok"):
@@ -1543,7 +1738,7 @@ class Program:
"""Current type info for a data item/global: {addr,name,type,size,is_func}.
None if the operation fails or the address isn't mapped."""
try:
- r = self.client.invoke("data_type", addr=hex(ea))
+ r = self.client.call(remote_ops.data_type, addr=hex(ea))
except IDAToolError:
return None
if not isinstance(r, dict) or r.get("error"):
@@ -1552,8 +1747,10 @@ class Program:
def set_data_type(self, ea: int, decl: str) -> str | None:
"""Set a global/data item's type. None on success, else an error string."""
- r = self.client.invoke(
- "set_type", edits=[{"kind": "global", "addr": hex(ea), "type": decl}])
+ r = self.client.call(
+ remote_ops.set_type,
+ edits=[{"kind": "global", "addr": hex(ea), "type": decl}],
+ )
res = r.get("result", []) if isinstance(r, dict) else []
row = res[0] if res and isinstance(res[0], dict) else {}
if row.get("ok"):
@@ -1563,7 +1760,9 @@ class Program:
def set_lvar_type(self, fn_ea: int, var: str, ty: str) -> str | None:
"""Set a decompiler local variable's type through ida-domain pseudocode.
None on success, else an error string."""
- r = self.client.invoke("set_lvar_type", addr=hex(fn_ea), variable=var, type=ty)
+ r = self.client.call(
+ remote_ops.set_lvar_type, addr=hex(fn_ea), variable=var, type=ty
+ )
if isinstance(r, dict) and r.get("error"):
return r["error"]
if isinstance(r, dict) and not r.get("ok"):
@@ -1574,12 +1773,12 @@ class Program:
"""Delete a named type. Returns None on success, else an error string.
Returns a clear error instead of raising when the runtime cannot do it."""
try:
- self.client.invoke("del_type", name=name)
+ self.client.call(remote_ops.del_type, name=name)
return None
except IDAToolError as e:
msg = e.message
if "not found" in msg.lower() and "del_type" in msg:
- return "the connected Code Mode runtime cannot delete local types"
+ return "the connected IDA Nexus runtime cannot delete local types"
return msg
# -- disassembly ------------------------------------------------------- #
@@ -1592,8 +1791,25 @@ class Program:
return m
# -- decompilation ----------------------------------------------------- #
+ def force_recompile(self, ea: int) -> None:
+ """Drop local and Hex-Rays caches before an explicit view refresh.
+
+ Normal edit paths use generation-based invalidation. Ctrl+R is also for
+ changes made by another IDA Nexus/IDA client, for which this Program has
+ seen no generation bump, so it must explicitly ask Hex-Rays to discard
+ its cached cfunc.
+ """
+ with self._lock:
+ self._decomp.pop(ea, None)
+ self._pc_nums.pop(ea, None)
+ self._decomp_maps.pop(ea, None)
+ try:
+ self.client.call(remote_ops.force_recompile, items=[{"addr": hex(ea)}])
+ except Exception: # noqa: BLE001 -- refresh still refetches best-effort
+ pass
+
def decompile(self, ea: int, refresh: bool = False) -> Decompilation:
- """Full pseudocode for a function, returned directly by Code Mode."""
+ """Full pseudocode for a function, returned directly by IDA Nexus."""
if not refresh:
with self._lock:
hit = self._decomp.get(ea)
@@ -1605,22 +1821,15 @@ class Program:
# Cached before a rename: names may be stale. Drop Hex-Rays'
# cache so the refetch reflects the new names.
try:
- self.client.invoke("force_recompile", items=[{"addr": hex(ea)}])
+ self.client.call(
+ remote_ops.force_recompile, items=[{"addr": hex(ea)}]
+ )
except Exception: # noqa: BLE001
pass
- # Bound the decompile: a function Hex-Rays can't handle tends to stall
- # near the client's default 30s timeout, and the transport retries a
- # dropped connection up to max_retries+1 times, re-running the failing
- # decompile each time. Cap it so the worst case stays well under the
- # rpcclient socket timeout, and cache the failure below so a re-request
- # returns instantly instead of re-grinding.
+ # The typed remote declaration carries a 15-second transport timeout,
+ # so a function Hex-Rays cannot handle does not stall the UI.
try:
- # Code Mode returns the complete JSON result directly; unlike the
- # old MCP tool transport there is no structured-content envelope or
- # out-of-band download URL to unwrap.
- payload = self.client.invoke(
- "decompile", addr=hex(ea), timeout=DECOMPILE_TIMEOUT
- )
+ payload = self.client.call(remote_ops.decompile, addr=hex(ea))
except Exception as e: # noqa: BLE001 -- surface as a failed decompile
dec = Decompilation(ea, None, True, f"decompile error: {e}", False, None)
with self._lock:
@@ -1646,7 +1855,7 @@ class Program:
self._name_gen += 1
models = list(self._disasm.values())
listings = list(self._listings.values())
- self._pc_nums.clear() # a reformat moves every literal on its line
+ self._pc_nums.clear() # a reformat moves every literal on its line
for m in models:
m.invalidate()
for lm in listings:
@@ -1688,6 +1897,34 @@ class Program:
if self._listings.get(start) is lm:
del self._listings[start]
+ def invalidate_external(self) -> None:
+ """Drop every cached view of an IDB changed by another client.
+
+ An event may describe a rename, a byte patch, a new function, or a
+ segment move. Treating an unknown event as text-only risks displaying a
+ structurally impossible mix of old rows and new metadata, so the
+ external boundary deliberately invalidates all derived state. The app
+ debounces event bursts before reaching this method.
+ """
+ with self._lock:
+ self._name_gen += 1
+ models = list(self._disasm.values())
+ self._indices.clear()
+ self._disasm.clear()
+ self._listings.clear()
+ self._decomp.clear()
+ self._pc_nums.clear()
+ self._decomp_maps.clear()
+ self._flowcharts.clear()
+ self._strings = None
+ self._linkage = None
+ self._segments_cache = None
+ self._sections = None
+ self._fileregions = None
+ self._hexmodel = None
+ for model in models:
+ model.invalidate()
+
# -- item / function structure edits (IDA c/d/u/p) --------------------- #
@staticmethod
def _first_result(payload) -> dict:
@@ -1703,18 +1940,19 @@ class Program:
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.invoke("undefine", items=[{"addr": hex(ea)}])
+ self.client.call(remote_ops.undefine, items=[{"addr": hex(ea)}])
except IDAToolError:
pass # nothing defined here yet -> just try to create the insn
res = self._first_result(
- self.client.invoke("define_code", items=[{"addr": hex(ea)}]))
+ self.client.call(remote_ops.define_code, items=[{"addr": hex(ea)}])
+ )
if res.get("error"):
raise IDAToolError("define_code", f"@ {ea:#x}: {res['error']}")
def decomp_error(self, ea: int) -> str:
"""Hex-Rays' own reason for refusing ``ea``, or "" if it won't say."""
try:
- r = self.client.invoke("decomp_error", addr=hex(ea))
+ r = self.client.call(remote_ops.decomp_error, addr=hex(ea))
except IDAToolError:
return ""
if not isinstance(r, dict):
@@ -1733,19 +1971,22 @@ class Program:
def thumb_scan(self, start: int, end: int, apply: bool = True) -> dict:
"""Find Thumb entry points from odd pointers in ``[start, end)``."""
- r = self.client.invoke("thumb_scan", start=hex(start), end=hex(end),
- apply=bool(apply))
+ r = self.client.call(
+ remote_ops.thumb_scan, start=hex(start), end=hex(end), apply=bool(apply)
+ )
if not isinstance(r, dict) or r.get("error"):
- raise IDAToolError("thumb_scan",
- f"@ {start:#x}: {(r or {}).get('error', 'failed')}")
+ raise IDAToolError(
+ "thumb_scan", f"@ {start:#x}: {(r or {}).get('error', 'failed')}"
+ )
return r
def set_thumb(self, ea: int, mode: str = "toggle") -> dict:
"""Switch ARM/Thumb decoding at ``ea``. Returns the resulting state."""
- r = self.client.invoke("set_thumb", addr=hex(ea), mode=mode)
+ r = self.client.call(remote_ops.set_thumb, addr=hex(ea), mode=mode)
if not isinstance(r, dict) or r.get("error"):
- raise IDAToolError("set_thumb",
- f"@ {ea:#x}: {(r or {}).get('error', 'failed')}")
+ raise IDAToolError(
+ "set_thumb", f"@ {ea:#x}: {(r or {}).get('error', 'failed')}"
+ )
return r
def define_code_run(self, ea: int, limit: int = 20000) -> dict:
@@ -1755,32 +1996,37 @@ class Program:
provide the run operation.
"""
try:
- r = self.client.invoke("define_code_run", addr=hex(ea), limit=int(limit))
+ r = self.client.call(
+ remote_ops.define_code_run, addr=hex(ea), limit=int(limit)
+ )
except IDAToolError:
self.define_code(ea)
return {"count": 1, "stopped": "single", "end": hex(ea)}
if not isinstance(r, dict) or r.get("error"):
- raise IDAToolError("define_code_run",
- f"@ {ea:#x}: {(r or {}).get('error', 'failed')}")
+ raise IDAToolError(
+ "define_code_run", f"@ {ea:#x}: {(r or {}).get('error', 'failed')}"
+ )
return r
def define_func(self, ea: int) -> dict:
"""Create a function starting at ``ea`` (IDA's 'p').
- Prefers the Code Mode operation, which works out the end when IDA can't;
+ Prefers the IDA Nexus operation, which works out the end when IDA can't;
falls back to a plain create for alternate clients.
"""
try:
- r = self.client.invoke("define_func_run", addr=hex(ea))
+ r = self.client.call(remote_ops.define_func_run, addr=hex(ea))
except IDAToolError:
res = self._first_result(
- self.client.invoke("define_func", items=[{"addr": hex(ea)}]))
+ self.client.call(remote_ops.define_func, items=[{"addr": hex(ea)}])
+ )
if res.get("error"):
raise IDAToolError("define_func", f"@ {ea:#x}: {res['error']}")
return {"ok": True, "how": "legacy"}
if not isinstance(r, dict) or not r.get("ok"):
- raise IDAToolError("define_func",
- f"@ {ea:#x}: {(r or {}).get('error', 'failed')}")
+ raise IDAToolError(
+ "define_func", f"@ {ea:#x}: {(r or {}).get('error', 'failed')}"
+ )
return r
def undefine(self, ea: int, size: int | None = None) -> None:
@@ -1788,7 +2034,7 @@ class Program:
item: dict = {"addr": hex(ea)}
if size:
item["size"] = int(size)
- res = self._first_result(self.client.invoke("undefine", items=[item]))
+ res = self._first_result(self.client.call(remote_ops.undefine, items=[item]))
if res.get("error"):
raise IDAToolError("undefine", f"@ {ea:#x}: {res['error']}")
@@ -1798,24 +2044,29 @@ class Program:
item: dict = {"addr": hex(ea), "type": type_decl}
if name:
item["name"] = name
- res = self._first_result(self.client.invoke("make_data", items=[item]))
+ res = self._first_result(self.client.call(remote_ops.make_data, items=[item]))
if res.get("ok") is False or res.get("error"):
raise IDAToolError(
- "make_data", f"@ {ea:#x}: {res.get('error') or 'rejected'}")
+ "make_data", f"@ {ea:#x}: {res.get('error') or 'rejected'}"
+ )
def make_string(self, ea: int, length: int = 0, kind: str = "c") -> str:
"""Create a string literal at ``ea`` (IDA's 'A'); auto-length when 0.
Returns the decoded contents."""
- r = self.client.invoke("make_string", addr=hex(ea), length=int(length), kind=kind)
+ r = self.client.call(
+ remote_ops.make_string, addr=hex(ea), length=int(length), kind=kind
+ )
res = r if isinstance(r, dict) else {}
if not res.get("ok"):
raise IDAToolError(
- "make_string", f"@ {ea:#x}: {res.get('error') or 'rejected'}")
+ "make_string", f"@ {ea:#x}: {res.get('error') or 'rejected'}"
+ )
return res.get("text", "")
# -- literal display formats (IDA's 'o': hex / dec / char / offset) ---- #
- def op_format(self, ea: int, mode: str = "cycle", col: int = -1,
- n: int = -1) -> dict:
+ def op_format(
+ self, ea: int, mode: str = "cycle", col: int = -1, n: int = -1
+ ) -> dict:
"""Change how the literal at ``ea`` is DISPLAYED in the listing.
``col`` is a column inside the rendered line, which is how the cursor
@@ -1823,8 +2074,9 @@ class Program:
``cycle``/``back`` (step the stops that make sense for this value) or a
format by name. ``show`` reports without changing anything.
"""
- r = self.client.invoke("op_format", addr=hex(ea), mode=str(mode),
- col=int(col), n=int(n))
+ r = self.client.call(
+ remote_ops.op_format, addr=hex(ea), mode=str(mode), col=int(col), n=int(n)
+ )
res = r if isinstance(r, dict) else {}
if res.get("error"):
raise IDAToolError("op_format", f"@ {ea:#x}: {res['error']}")
@@ -1847,31 +2099,43 @@ class Program:
if hit is not None and hit[1] == gen:
return hit[0]
try:
- r = self.client.invoke("pc_nums", addr=hex(fn_ea))
+ r = self.client.call(remote_ops.pc_nums, addr=hex(fn_ea))
except Exception: # noqa: BLE001 -- an older worker hasn't got the tool
r = {}
out: dict[int, list[tuple[int, int, str, int, int]]] = {}
for rec in (r or {}).get("nums", []):
try:
out.setdefault(int(rec["line"]), []).append(
- (int(rec["x0"]), int(rec["x1"]), str(rec.get("value", "")),
- _as_int(rec["ea"]), int(rec.get("opnum", 0))))
+ (
+ int(rec["x0"]),
+ int(rec["x1"]),
+ str(rec.get("value", "")),
+ _as_int(rec["ea"]),
+ int(rec.get("opnum", 0)),
+ )
+ )
except Exception: # noqa: BLE001 -- skip a malformed row
continue
with self._lock:
self._pc_nums[fn_ea] = (out, gen)
return out
- def pc_num_format(self, fn_ea: int, mode: str = "cycle", line: int = -1,
- col: int = -1) -> dict:
+ def pc_num_format(
+ self, fn_ea: int, mode: str = "cycle", line: int = -1, col: int = -1
+ ) -> dict:
"""The same, for a number in the DECOMPILATION of ``fn_ea``.
Hex-Rays keeps number formats of its own, per (address, operand) — the
listing's format doesn't reach the pseudocode and vice versa, so this is
a separate call rather than a flag on ``op_format``.
"""
- r = self.client.invoke("pc_num_format", addr=hex(fn_ea), mode=str(mode),
- line=int(line), col=int(col))
+ r = self.client.call(
+ remote_ops.pc_num_format,
+ addr=hex(fn_ea),
+ mode=str(mode),
+ line=int(line),
+ col=int(col),
+ )
res = r if isinstance(r, dict) else {}
if res.get("error"):
raise IDAToolError("pc_num_format", f"@ {fn_ea:#x}: {res['error']}")
@@ -1899,22 +2163,30 @@ class Program:
offset, page = 0, 2000
while True:
try:
- payload = self.client.invoke(
- "list_strings", offset=offset, count=page, min_len=min_len,
- refresh=(refresh and offset == 0))
+ payload = self.client.call(
+ remote_ops.list_strings,
+ offset=offset,
+ count=page,
+ min_len=min_len,
+ refresh=(refresh and offset == 0),
+ )
except IDAToolError:
return []
rows = payload.get("strings", []) if isinstance(payload, dict) else []
for r in rows:
if not isinstance(r, dict):
continue
- out.append(StrLit(
- addr=_as_int(r.get("addr", 0)),
- text=r.get("text", ""),
- length=int(r.get("len", 0) or 0),
- type=r.get("type", "") or "",
- ))
- total = int(payload.get("total", 0) or 0) if isinstance(payload, dict) else 0
+ out.append(
+ StrLit(
+ addr=_as_int(r.get("addr", 0)),
+ text=r.get("text", ""),
+ length=int(r.get("len", 0) or 0),
+ type=r.get("type", "") or "",
+ )
+ )
+ total = (
+ int(payload.get("total", 0) or 0) if isinstance(payload, dict) else 0
+ )
if len(rows) < page or len(out) >= total:
break
offset += len(rows)
@@ -1930,27 +2202,39 @@ class Program:
if hit is not None:
return hit
try:
- payload = self.client.invoke("list_linkage", kind="both")
+ payload = self.client.call(remote_ops.list_linkage, kind="both")
except IDAToolError:
return ([], [])
if not isinstance(payload, dict):
return ([], [])
- imps = [Linkage(addr=_as_int(r.get("addr", 0)),
- name=link_name(r.get("name", "")),
- module=r.get("module", "") or "",
- raw=r.get("name", "") or "")
- for r in payload.get("imports", []) if isinstance(r, dict)]
- exps = [Linkage(addr=_as_int(r.get("addr", 0)),
- name=link_name(r.get("name", "")),
- ordinal=int(r.get("ordinal", 0) or 0),
- raw=r.get("name", "") or "")
- for r in payload.get("exports", []) if isinstance(r, dict)]
+ imps = [
+ Linkage(
+ addr=_as_int(r.get("addr", 0)),
+ name=link_name(r.get("name", "")),
+ module=r.get("module", "") or "",
+ raw=r.get("name", "") or "",
+ )
+ for r in payload.get("imports", [])
+ if isinstance(r, dict)
+ ]
+ exps = [
+ Linkage(
+ addr=_as_int(r.get("addr", 0)),
+ name=link_name(r.get("name", "")),
+ ordinal=int(r.get("ordinal", 0) or 0),
+ raw=r.get("name", "") or "",
+ )
+ for r in payload.get("exports", [])
+ if isinstance(r, dict)
+ ]
out = ([i for i in imps if i.name], [e for e in exps if e.name])
with self._lock:
self._linkage = out
return out
- def annotations(self, limit: int = 4000) -> tuple[list["Comment"], list["NamedItem"]]:
+ def annotations(
+ self, limit: int = 4000
+ ) -> tuple[list["Comment"], list["NamedItem"]]:
"""``(comments, names)`` -- everything a person added to this database.
Not cached: it is the *current* state of your work, and the one caller
@@ -1958,44 +2242,64 @@ class Program:
no such operation, so an alternate client degrades instead of breaking.
"""
try:
- payload = self.client.invoke("list_annotations", limit=int(limit))
+ payload = self.client.call(remote_ops.list_annotations, limit=int(limit))
except IDAToolError:
return ([], [])
if not isinstance(payload, dict):
return ([], [])
comments = [
- Comment(addr=_as_int(r.get("addr", 0)), text=str(r.get("text", "")),
- repeatable=bool(r.get("repeatable")),
- whole_func=bool(r.get("whole_func")),
- line=str(r.get("line", "") or ""),
- seg=str(r.get("seg", "") or ""),
- func=(r.get("func") or None),
- func_addr=(_as_int(r["func_addr"]) if r.get("func_addr") else None))
- for r in payload.get("comments", []) if isinstance(r, dict) and r.get("text")]
+ Comment(
+ addr=_as_int(r.get("addr", 0)),
+ text=str(r.get("text", "")),
+ repeatable=bool(r.get("repeatable")),
+ whole_func=bool(r.get("whole_func")),
+ line=str(r.get("line", "") or ""),
+ seg=str(r.get("seg", "") or ""),
+ func=(r.get("func") or None),
+ func_addr=(_as_int(r["func_addr"]) if r.get("func_addr") else None),
+ )
+ for r in payload.get("comments", [])
+ if isinstance(r, dict) and r.get("text")
+ ]
names = [
- NamedItem(addr=_as_int(r.get("addr", 0)), name=str(r.get("name", "")),
- is_func=bool(r.get("func")), size=int(r.get("size", 0) or 0),
- proto=(r.get("proto") or None), seg=str(r.get("seg", "") or ""))
- for r in payload.get("names", []) if isinstance(r, dict) and r.get("name")]
+ NamedItem(
+ addr=_as_int(r.get("addr", 0)),
+ name=str(r.get("name", "")),
+ is_func=bool(r.get("func")),
+ size=int(r.get("size", 0) or 0),
+ proto=(r.get("proto") or None),
+ seg=str(r.get("seg", "") or ""),
+ )
+ for r in payload.get("names", [])
+ if isinstance(r, dict) and r.get("name")
+ ]
return (comments, names)
-
- def search(self, query: str, mode: str = "text", *, limit: int = 500,
- regex: bool = False, case: bool = False,
- ) -> tuple[list["SearchHit"], str | None, bool]:
+ def search(
+ self,
+ query: str,
+ mode: str = "text",
+ *,
+ limit: int = 500,
+ regex: bool = False,
+ case: bool = False,
+ ) -> tuple[list["SearchHit"], str | None, bool]:
"""Search the whole database. Returns ``(hits, error, truncated)``.
A failed search is DATA (a message to show), not an exception: a bad
regex or an unparsable byte pattern is something the user typed, and
the palette wants to say so without unwinding.
"""
- op = "search_bytes" if mode == "bytes" else "search_text"
+ operation = (
+ remote_ops.search_bytes if mode == "bytes" else remote_ops.search_text
+ )
args: dict = {"limit": int(limit), "case": bool(case)}
if mode == "bytes":
# Validate HERE, not just in the UI: IDA's find_bytes answers a
# malformed pattern with zero hits and no error, which reads as
# "not present" -- the most misleading answer a search can give.
from .search import normalise_pattern, pattern_problem
+
problem = pattern_problem(query)
if problem:
return ([], problem, False)
@@ -2004,29 +2308,32 @@ class Program:
args["query"] = query
args["regex"] = bool(regex)
try:
- payload = self.client.invoke(op, **args)
+ payload = self.client.call(operation, **args)
except IDAToolError as e:
return ([], str(e), False)
if not isinstance(payload, dict):
return ([], "the backend returned nothing searchable", False)
hits = [
- SearchHit(addr=_as_int(r.get("addr", 0)),
- head=_as_int(r.get("head", r.get("addr", 0))),
- line=str(r.get("line", "") or ""),
- func=(r.get("func") or None),
- func_addr=(_as_int(r["func_addr"]) if r.get("func_addr")
- else None),
- seg=str(r.get("seg", "") or ""))
- for r in payload.get("hits", []) if isinstance(r, dict)]
+ SearchHit(
+ addr=_as_int(r.get("addr", 0)),
+ head=_as_int(r.get("head", r.get("addr", 0))),
+ line=str(r.get("line", "") or ""),
+ func=(r.get("func") or None),
+ func_addr=(_as_int(r["func_addr"]) if r.get("func_addr") else None),
+ seg=str(r.get("seg", "") or ""),
+ )
+ for r in payload.get("hits", [])
+ if isinstance(r, dict)
+ ]
return (hits, payload.get("error") or None, bool(payload.get("truncated")))
def journal_get(self) -> str:
"""The findings journal blob stored in this database ('' if none)."""
- payload = self.client.invoke("journal_get")
+ payload = self.client.call(remote_ops.journal_get)
return str(payload.get("data", "")) if isinstance(payload, dict) else ""
def journal_put(self, data: str) -> None:
- self.client.invoke("journal_put", data=str(data))
+ self.client.call(remote_ops.journal_put, data=str(data))
def decomp_map(self, ea: int) -> list[list[int]]:
"""Per-pseudocode-line instruction coverage for the split-view region
@@ -2039,12 +2346,15 @@ class Program:
if hit is not None and hit[1] == gen:
return hit[0]
try:
- payload = self.client.invoke("decomp_map", addr=hex(ea))
+ payload = self.client.call(remote_ops.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)]
+ 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
@@ -2069,7 +2379,7 @@ class Program:
if hit is not None and hit[1] == gen:
return hit[0]
try:
- payload = self.client.invoke("flowchart", addr=hex(ea))
+ payload = self.client.call(remote_ops.flowchart, addr=hex(ea))
except IDAToolError:
return None
if not isinstance(payload, dict) or payload.get("error"):
@@ -2080,10 +2390,14 @@ class Program:
blocks = []
for b in raw:
try:
- blocks.append(BasicBlock(
- id=int(b["id"]), start=_as_int(b["start"]),
- end=_as_int(b["end"]),
- succs=[(int(d), str(k)) for d, k in (b.get("succs") or [])]))
+ blocks.append(
+ BasicBlock(
+ id=int(b["id"]),
+ start=_as_int(b["start"]),
+ end=_as_int(b["end"]),
+ succs=[(int(d), str(k)) for d, k in (b.get("succs") or [])],
+ )
+ )
except (KeyError, ValueError, TypeError):
continue
if not blocks:
@@ -2095,8 +2409,9 @@ class Program:
for b in blocks:
# bisect, not a scan per block: a 400-block function against a few
# thousand rows is a million comparisons done for nothing.
- b.rows = rows[bisect.bisect_left(eas, b.start):
- bisect.bisect_left(eas, b.end)]
+ b.rows = rows[
+ bisect.bisect_left(eas, b.start) : bisect.bisect_left(eas, b.end)
+ ]
fcv = Flowchart(
func_ea=_as_int(f.get("addr", lo)),
name=str(f.get("name") or f"sub_{lo:X}"),
@@ -2139,11 +2454,12 @@ class Program:
operand marks for free."""
out: list[Head] = []
addr = lo
- for _ in range(64): # bounded: ~128k heads
+ for _ in range(64): # bounded: ~128k heads
if addr >= hi:
break
- payload = self.client.invoke("heads", addr=hex(addr), end=hex(hi),
- count=2000)
+ payload = self.client.call(
+ remote_ops.heads, addr=hex(addr), end=hex(hi), count=2000
+ )
rows = payload.get("heads", []) if isinstance(payload, dict) else []
if not rows:
break
@@ -2171,27 +2487,34 @@ class Program:
# -- cross-references & containing function --------------------------- #
def function_of(self, ea: int) -> Func | None:
"""Return the function containing ``ea`` (resolves mid-function addrs)."""
- payload = self.client.invoke("lookup_funcs", queries=[hex(ea)])
+ payload = self.client.call(remote_ops.lookup_funcs, queries=[hex(ea)])
res = payload.get("result", []) if isinstance(payload, dict) else []
fn = res[0].get("fn") if res and isinstance(res[0], dict) else None
return Func.from_raw(fn) if fn else None
def xrefs_from(self, ea: int) -> list[Xref]:
- payload = self.client.invoke(
- "xref_query",
+ payload = self.client.call(
+ remote_ops.xref_query,
queries=[{"addr": hex(ea), "direction": "from", "include_fn": True}],
)
return _parse_xrefs(payload)
def xrefs_to(self, ea: int, limit: int = 2000) -> list[Xref]:
- q = [{"addr": hex(ea), "direction": "to", "include_fn": True,
- "dedup": True, "count": limit}]
+ q = [
+ {
+ "addr": hex(ea),
+ "direction": "to",
+ "include_fn": True,
+ "dedup": True,
+ "count": limit,
+ }
+ ]
try:
# xref_types adds a fine-grained `kind` (call/read/write/...) for the
# xref dialog; fall back to xref_query (code/data only) if absent.
- payload = self.client.invoke("xref_types", queries=q)
+ payload = self.client.call(remote_ops.xref_types, queries=q)
except IDAToolError:
- payload = self.client.invoke("xref_query", queries=q)
+ payload = self.client.call(remote_ops.xref_query, queries=q)
return _parse_xrefs(payload)
# -- address resolution ------------------------------------------------ #
@@ -2209,7 +2532,7 @@ class Program:
# (loc_/locret_): lookup_funcs would map a label to its *containing*
# function's entry, so double-clicking a label jumped to the wrong place.
try:
- payload = self.client.invoke("resolve_names", queries=[s])
+ payload = self.client.call(remote_ops.resolve_names, queries=[s])
res = payload.get("result", []) if isinstance(payload, dict) else []
ea = res[0].get("ea") if res and isinstance(res[0], dict) else None
if ea:
@@ -2219,7 +2542,7 @@ class Program:
# Fall back to function-name resolution (also drives the 'did you mean'
# suggestion when the name is unknown).
try:
- payload = self.client.invoke("lookup_funcs", queries=[s])
+ payload = self.client.call(remote_ops.lookup_funcs, queries=[s])
except IDAToolError as e:
raise KeyError(f"cannot resolve {target!r}: {e}") from e
res = payload.get("result", []) if isinstance(payload, dict) else []
@@ -2245,8 +2568,10 @@ class Program:
except Exception: # noqa: BLE001 -- suggestions are strictly optional
return ""
if not cands:
- return (" (no function name contains it; it may be a data symbol or "
- "not a function — pass an address like 0x1234)")
+ return (
+ " (no function name contains it; it may be a data symbol or "
+ "not a function — pass an address like 0x1234)"
+ )
shown = cands[:5]
names = ", ".join(f"{c.name} @ {c.addr:#x}" for c in shown)
more = " …" if len(cands) > len(shown) else ""
@@ -2257,7 +2582,9 @@ class Program:
"""Set (empty text clears) the comment at ``ea``; affects both the disasm
and decompiler views. Returns the raw payload so the caller can surface a
soft per-item error. The caller must invalidate/recompile to see it."""
- return self.client.invoke("set_comments", items=[{"addr": hex(ea), "comment": text}])
+ return self.client.call(
+ remote_ops.set_comments, items=[{"addr": hex(ea), "comment": text}]
+ )
# -- invalidation (after edits) --------------------------------------- #
def invalidate(self, ea: int) -> None:
@@ -2286,14 +2613,16 @@ def _parse_xrefs(payload) -> list[Xref]:
fn = d.get("fn") or {}
frm = d.get("from", d.get("addr"))
to = d.get("to")
- out.append(Xref(
- frm=_as_int(frm) if frm is not None else 0,
- to=_as_int(to) if to is not None else None,
- type=d.get("type", "?"),
- fn_name=fn.get("name"),
- fn_addr=_as_int(fn["addr"]) if fn.get("addr") else None,
- kind=d.get("kind"),
- ))
+ out.append(
+ Xref(
+ frm=_as_int(frm) if frm is not None else 0,
+ to=_as_int(to) if to is not None else None,
+ type=d.get("type", "?"),
+ fn_name=fn.get("name"),
+ fn_addr=_as_int(fn["addr"]) if fn.get("addr") else None,
+ kind=d.get("kind"),
+ )
+ )
return out
@@ -2303,13 +2632,15 @@ def _parse_decompilation(ea: int, payload) -> Decompilation:
code = payload.get("code")
error = payload.get("error")
if not code:
- return Decompilation(ea, None, True, error or "decompilation failed",
- False, None)
+ return Decompilation(
+ ea, None, True, error or "decompilation failed", False, None
+ )
m = _TRUNC_RE.search(code)
truncated = m is not None
total_chars = int(m.group(1)) if m else len(code)
refs = [
Ref(addr=_as_int(r["addr"]), name=r.get("name", ""), string=r.get("string"))
- for r in payload.get("refs", []) if isinstance(r, dict) and "addr" in r
+ for r in payload.get("refs", [])
+ if isinstance(r, dict) and "addr" in r
]
return Decompilation(ea, code, False, error, truncated, total_chars, refs)
diff --git a/idatui/drive.py b/idatui/drive.py
index 6e5c21a..a1da513 100644
--- a/idatui/drive.py
+++ b/idatui/drive.py
@@ -17,6 +17,7 @@ Socket: --sock, else IDATUI_RPC_SOCK, else the one live pane (from `pane list`).
'.' or omitted as a target means the current function. `raw <method> k=v` is a
passthrough to rpcclient (pretty JSON).
"""
+
from __future__ import annotations
import json
@@ -33,16 +34,24 @@ def _resolve_sock(explicit: str | None) -> str:
env = os.environ.get("IDATUI_RPC_SOCK")
if env:
return env
- live = [r for r in _load_registry()
- if _pane_alive(r.get("pane", "")) and r.get("sock")
- and os.path.exists(r["sock"])]
+ live = [
+ r
+ for r in _load_registry()
+ if _pane_alive(r.get("pane", ""))
+ and r.get("sock")
+ and os.path.exists(r["sock"])
+ ]
if len(live) == 1:
return live[0]["sock"]
if not live:
- raise SystemExit("no live idatui pane — pass --sock, set IDATUI_RPC_SOCK, "
- "or `python -m idatui.pane spawn ...`")
- raise SystemExit("multiple live panes — pass --sock <one of>:\n"
- + "\n".join(" " + r["sock"] for r in live))
+ raise SystemExit(
+ "no live idatui pane — pass --sock, set IDATUI_RPC_SOCK, "
+ "or `python -m idatui.pane spawn ...`"
+ )
+ raise SystemExit(
+ "multiple live panes — pass --sock <one of>:\n"
+ + "\n".join(" " + r["sock"] for r in live)
+ )
def _tgt(a: str | None) -> str | None:
@@ -121,8 +130,9 @@ def cmd_pc(c, args):
lines = d["code"].splitlines()
if needle:
nlow = needle.lower()
- lines = [f"{i:4} {line}" for i, line in enumerate(lines)
- if nlow in line.lower()]
+ lines = [
+ f"{i:4} {line}" for i, line in enumerate(lines) if nlow in line.lower()
+ ]
return "\n".join(lines) or f"(no line matches {needle!r})"
return d["code"]
@@ -153,8 +163,10 @@ def cmd_callers(c, args):
if not args:
raise SystemExit("usage: callers <fn>")
xs = c.call("xrefs_to", target=args[0])
- return "\n".join(f" {x['frm']:#x} in {x.get('fn_name')}" for x in xs) \
+ return (
+ "\n".join(f" {x['frm']:#x} in {x.get('fn_name')}" for x in xs)
or "(no callers)"
+ )
def cmd_names(c, args):
@@ -162,8 +174,10 @@ def cmd_names(c, args):
raise SystemExit("usage: names <substr> [limit]")
lim = int(args[1]) if len(args) > 1 else 40
fs = c.call("functions", filter=args[0], limit=lim)
- return "\n".join(f" {f['ea']:#x} {f['name']} ({f['size']})" for f in fs) \
+ return (
+ "\n".join(f" {f['ea']:#x} {f['name']} ({f['size']})" for f in fs)
or "(no match)"
+ )
def cmd_binaries(c, args):
@@ -246,8 +260,9 @@ def cmd_define(c, args):
a symbol file), so take them all and report per-target.
"""
if not args:
- raise SystemExit("usage: define <code|func|undef|thumb|thumbscan|data|"
- "string> [target ...]")
+ raise SystemExit(
+ "usage: define <code|func|undef|thumb|thumbscan|data|string> [target ...]"
+ )
kind, targets = args[0], (args[1:] or [None])
out = []
for t in targets:
@@ -284,8 +299,10 @@ def cmd_syms(c, args):
raise SystemExit("usage: syms <symbols.json>")
r = c.call("rename_many", file=os.path.abspath(os.path.expanduser(args[0])))
m = r.get("rename_many", {})
- out = [f" {m.get('ok', 0)}/{m.get('requested', 0)} renamed"
- f" (skipped {m.get('skipped', 0)}, failed {m.get('failed', 0)})"]
+ out = [
+ f" {m.get('ok', 0)}/{m.get('requested', 0)} renamed"
+ f" (skipped {m.get('skipped', 0)}, failed {m.get('failed', 0)})"
+ ]
for e in m.get("errors", []):
out.append(f" {e.get('addr')}: {e.get('error')}")
return "\n".join(out)
@@ -304,8 +321,10 @@ def cmd_find(c, args):
hits = r.get("hits", [])
out = [f" [{r.get('mode')}] {len(hits)}{'+' if r.get('truncated') else ''} hits"]
for h in hits[:40]:
- out.append(f" {h['addr']} {(h.get('func') or h.get('seg') or ''):<20.20} "
- f"{h.get('line', '')}")
+ out.append(
+ f" {h['addr']} {(h.get('func') or h.get('seg') or ''):<20.20} "
+ f"{h.get('line', '')}"
+ )
if len(hits) > 40:
out.append(f" … {len(hits) - 40} more")
return "\n".join(out)
@@ -314,9 +333,11 @@ def cmd_find(c, args):
def cmd_export(c, args):
"""export [path] -- write the session's findings as markdown."""
r = c.call("export", **({"path": args[0]} if args else {}))
- return (f" {r.get('path')} ({r.get('bytes', 0)} bytes: "
- f"{r.get('comments', 0)} comments, {r.get('names', 0)} names, "
- f"{r.get('types', 0)} types)")
+ return (
+ f" {r.get('path')} ({r.get('bytes', 0)} bytes: "
+ f"{r.get('comments', 0)} comments, {r.get('names', 0)} names, "
+ f"{r.get('types', 0)} types)"
+ )
def cmd_screen(c, args):
@@ -334,13 +355,27 @@ def cmd_raw(c, args):
COMMANDS = {
- "where": cmd_where, "go": cmd_go, "pc": cmd_pc, "dis": cmd_dis,
- "callees": cmd_callees, "callers": cmd_callers, "names": cmd_names,
- "rename": cmd_rename, "mv": cmd_mv, "note": cmd_note, "retype": cmd_retype,
- "save": cmd_save, "screen": cmd_screen, "raw": cmd_raw, "define": cmd_define,
- "syms": cmd_syms, "fmt": cmd_fmt, "export": cmd_export,
+ "where": cmd_where,
+ "go": cmd_go,
+ "pc": cmd_pc,
+ "dis": cmd_dis,
+ "callees": cmd_callees,
+ "callers": cmd_callers,
+ "names": cmd_names,
+ "rename": cmd_rename,
+ "mv": cmd_mv,
+ "note": cmd_note,
+ "retype": cmd_retype,
+ "save": cmd_save,
+ "screen": cmd_screen,
+ "raw": cmd_raw,
+ "define": cmd_define,
+ "syms": cmd_syms,
+ "fmt": cmd_fmt,
+ "export": cmd_export,
"find": cmd_find,
- "binaries": cmd_binaries, "switch": cmd_switch,
+ "binaries": cmd_binaries,
+ "switch": cmd_switch,
}
diff --git a/idatui/edit_ctl.py b/idatui/edit_ctl.py
index 52566ca..89dfc69 100644
--- a/idatui/edit_ctl.py
+++ b/idatui/edit_ctl.py
@@ -19,6 +19,7 @@ The message handlers and the ``@work`` entry points stay on ``IdaTui``: Textual
dispatches ``on_<message>`` by name on the DOMNode, and its worker machinery
wants a DOMNode host. They are one-line delegates into here.
"""
+
from __future__ import annotations
import re
@@ -26,10 +27,10 @@ from typing import TYPE_CHECKING
from textual.widgets import DataTable
-from . import diag
+from . import diag, remote_ops
from .errors import IDAToolError
-if TYPE_CHECKING: # pragma: no cover
+if TYPE_CHECKING: # pragma: no cover
from .app import IdaTui
_app_mod = None
@@ -40,13 +41,18 @@ def _M():
global _app_mod
if _app_mod is None:
from . import app as _m
+
_app_mod = _m
return _app_mod
#: A C type wide enough for N bytes, for prefilling a retype/define prompt.
-_BY_SIZE = {1: "unsigned __int8", 2: "unsigned __int16",
- 4: "unsigned __int32", 8: "unsigned __int64"}
+_BY_SIZE = {
+ 1: "unsigned __int8",
+ 2: "unsigned __int16",
+ 4: "unsigned __int32",
+ 8: "unsigned __int64",
+}
class EditController:
@@ -169,16 +175,24 @@ class EditController:
# If the cursor is on a symbol token (a call/branch target, a data
# reference, or this head's own label) rename THAT symbol; otherwise
# create/rename a label at the head's address (bare/undefined bytes).
- if (word and app._looks_like_symbol(word) and word != mnem
- and word.lower() not in M._ASM_KEYWORDS):
+ if (
+ word
+ and app._looks_like_symbol(word)
+ and word != mnem
+ and word.lower() not in M._ASM_KEYWORDS
+ ):
app.prompts.rename.show(
f"rename '{word}' — Enter=apply Esc=cancel",
- word, ctx=(msg.view, word, None))
+ word,
+ ctx=(msg.view, word, None),
+ )
else:
cur = head.name if (head is not None and head.name) else ""
app.prompts.rename.show(
f"name @ {ea:#x} — Enter=apply Esc=cancel",
- cur, ctx=(msg.view, cur, ea))
+ cur,
+ ctx=(msg.view, cur, ea),
+ )
return
if not msg.name:
app._status("nothing to rename under the cursor")
@@ -186,11 +200,14 @@ class EditController:
if self.is_pseudocode_label(msg.view, msg.name):
app._status(
f"can't rename pseudocode label '{msg.name}' "
- "(Hex-Rays goto labels aren't renamable via the API)")
+ "(Hex-Rays goto labels aren't renamable via the API)"
+ )
return
app.prompts.rename.show(
f"rename '{msg.name}' — Enter=apply Esc=cancel",
- msg.name, ctx=(msg.view, msg.name, None))
+ msg.name,
+ ctx=(msg.view, msg.name, None),
+ )
def submit_rename(self, ctx, value: str) -> None: # type: ignore[no-untyped-def]
view, old, addr = ctx
@@ -242,7 +259,7 @@ class EditController:
kind = "stack"
batch = {"stack": {"func_addr": hex(cur.ea), "old": old, "new": new}}
try:
- res = prog.client.invoke("rename", batch=batch)
+ res = prog.client.call(remote_ops.rename, batch=batch)
except IDAToolError as e:
app.call_from_thread(app._status, f"rename failed: {e.message}")
return
@@ -275,8 +292,9 @@ class EditController:
app = self.app
assert app.program is not None
try:
- res = app.program.client.invoke(
- "rename", batch={"data": {"addr": hex(addr), "new": name}})
+ res = app.program.client.call(
+ remote_ops.rename, batch={"data": {"addr": hex(addr), "new": name}}
+ )
except IDAToolError as e:
app.call_from_thread(app._status, f"name failed: {e.message}")
return
@@ -307,11 +325,11 @@ class EditController:
lm = app.program.listing(addr)
label = name if is_func_start else app.program.region_label(addr)
idx = max(lm.ensure_ea(addr), 0) if lm is not None else 0
- app.call_from_thread(self.open_at_named, label, addr, idx, name,
- is_func_start)
+ app.call_from_thread(self.open_at_named, label, addr, idx, name, is_func_start)
- def open_at_named(self, label: str, addr: int, idx: int, name: str,
- is_func_start: bool = False) -> None:
+ def open_at_named(
+ self, label: str, addr: int, idx: int, name: str, is_func_start: bool = False
+ ) -> None:
app = self.app
if is_func_start:
app.program.bump_names()
@@ -328,10 +346,11 @@ class EditController:
pseudocode a comment is `// text` before the trailing /*0xEA*/ markers;
C has no `//` operator, so the last `//` is unambiguously the comment."""
if isinstance(view, _M().DecompView) and 0 <= view.cursor < len(view._texts):
- s = re.sub(r"(?:/\*\s*0x[0-9A-Fa-f]+\s*\*/\s*)+$", "",
- view._texts[view.cursor])
+ s = re.sub(
+ r"(?:/\*\s*0x[0-9A-Fa-f]+\s*\*/\s*)+$", "", view._texts[view.cursor]
+ )
i = s.rfind("//")
- return s[i + 2:].strip() if i >= 0 else ""
+ return s[i + 2 :].strip() if i >= 0 else ""
return ""
def request_comment(self, msg) -> None: # type: ignore[no-untyped-def]
@@ -349,7 +368,9 @@ class EditController:
what = "function comment" if func_level else "comment"
app.prompts.comment.show(
f"{what} @ {ea:#x} — Enter=apply (empty=clear) Esc=cancel",
- existing, ctx=(msg.view, ea, existing))
+ existing,
+ ctx=(msg.view, ea, existing),
+ )
def submit_comment(self, ctx, value: str) -> None: # type: ignore[no-untyped-def]
view, ea, existing = ctx
@@ -369,10 +390,13 @@ class EditController:
app.call_from_thread(app._status, f"comment failed: {e.message}")
return
data = res.get("result") if isinstance(res, dict) else None
- if (isinstance(data, list) and data and isinstance(data[0], dict)
- and data[0].get("error")):
- app.call_from_thread(app._status,
- f"comment failed: {data[0]['error']}")
+ if (
+ isinstance(data, list)
+ and data
+ and isinstance(data[0], dict)
+ and data[0].get("error")
+ ):
+ app.call_from_thread(app._status, f"comment failed: {data[0]['error']}")
return
app.call_from_thread(self.after_comment, ea, text)
@@ -429,30 +453,34 @@ class EditController:
if dt is not None and not dt.get("is_func"):
kind, subject = "data", tgt
prefill = dt.get("type") or self.guess_data_type(
- dt.get("size") or 0)
+ dt.get("size") or 0
+ )
# 3) fall back to the current function itself
if kind is None and ft is not None:
kind, subject, prefill = "func", app._cur.ea, ft.prototype
if kind is None:
- app.call_from_thread(app._status,
- "nothing to retype under the cursor")
+ app.call_from_thread(app._status, "nothing to retype under the cursor")
return
- app.call_from_thread(self.open_retype, view, kind, subject,
- word or "", prefill)
+ app.call_from_thread(self.open_retype, view, kind, subject, word or "", prefill)
- def open_retype(self, view, kind: str, subject: int, word: str,
- prefill: str) -> None: # type: ignore[no-untyped-def]
+ def open_retype(
+ self, view, kind: str, subject: int, word: str, prefill: str
+ ) -> None: # type: ignore[no-untyped-def]
label = "prototype" if kind == "func" else f"type for '{word}'"
- self.app.prompts.retype.show(f"{label} — Enter=apply Esc=cancel",
- prefill, ctx=(view, kind, subject, word))
+ self.app.prompts.retype.show(
+ f"{label} — Enter=apply Esc=cancel",
+ prefill,
+ ctx=(view, kind, subject, word),
+ )
def submit_retype(self, ctx, value: str) -> None: # type: ignore[no-untyped-def]
view, kind, subject, word = ctx
if view is not None and value:
self.app._do_retype(kind, subject, word, value)
- def do_retype(self, kind: str, subject: int, word: str,
- new: str) -> None: # worker context
+ def do_retype(
+ self, kind: str, subject: int, word: str, new: str
+ ) -> None: # worker context
app = self.app
assert app.program is not None
if kind == "func":
@@ -473,8 +501,9 @@ class EditController:
app.program.bump_names()
self.reload_active_code()
app._dirty = True
- app.journal.record("retype", getattr(app._cur, "ea", None), word,
- {"kind": kind})
+ app.journal.record(
+ "retype", getattr(app._cur, "ea", None), word, {"kind": kind}
+ )
what = "prototype" if kind == "func" else f"'{word}'"
app._status(f"retyped {what} (Ctrl+S to save)")
@@ -498,15 +527,17 @@ class EditController:
f"data type @ {ea:#x} (e.g. int, char[16], my_struct)"
" — Enter=apply Esc=cancel",
self.default_data_type(head) if head is not None else "int",
- ctx=(view, ea))
+ ctx=(view, ea),
+ )
def submit_make_data(self, ctx, value: str) -> None: # type: ignore[no-untyped-def]
view, ea = ctx
if view is not None and value:
self.app._do_make_data(ea, value, self.app._anchor())
- def do_make_data(self, ea: int, type_decl: str,
- anchor=None) -> None: # worker context
+ def do_make_data(
+ self, ea: int, type_decl: str, anchor=None
+ ) -> None: # worker context
app = self.app
assert app.program is not None
try:
@@ -522,8 +553,7 @@ class EditController:
lm = app.program.listing(ea)
idx = max(lm.ensure_ea(ea), 0) if lm is not None else 0
_cur, top = app._anchor_rows(anchor, lm, ea)
- app.call_from_thread(
- app._open_at, ea, name, idx, False, -1, 0, True, None, top)
+ app.call_from_thread(app._open_at, ea, name, idx, False, -1, 0, True, None, top)
app.call_from_thread(self.edit_done, anchor)
# -- literal display formats (IDA 'o') --------------------------------- #
@@ -554,8 +584,9 @@ class EditController:
fn = view.loaded_ea if view.loaded_ea is not None else app._cur.ea
app._do_op_format(msg.mode, "decomp", fn, view.cursor_x, view.cursor)
- def do_op_format(self, mode: str, where: str, ea: int, col: int,
- line: int = -1) -> None: # worker context
+ def do_op_format(
+ self, mode: str, where: str, ea: int, col: int, line: int = -1
+ ) -> None: # worker context
app = self.app
assert app.program is not None
try:
@@ -584,7 +615,9 @@ class EditController:
app.call_from_thread(
app._status,
f"{what}{fmt} {r.get('value') or ''}"
- f" [{', '.join(r.get('choices', []))}]", True)
+ f" [{', '.join(r.get('choices', []))}]",
+ True,
+ )
return
step = f"{prev} \u2192 {fmt}" if prev and prev != fmt else fmt
desc = f"{what}{step}: {text[:96]}"
@@ -629,13 +662,17 @@ class EditController:
return
app._do_edit_item(msg.kind, ea, app._anchor())
- def do_edit_item(self, kind: str, ea: int,
- anchor=None) -> None: # worker context
+ def do_edit_item(self, kind: str, ea: int, anchor=None) -> None: # worker context
app = self.app
assert app.program is not None
- verb = {"code": "defined code", "func": "created function",
- "undef": "undefined", "string": "made string",
- "thumb": "switched decoding", "thumbscan": "scanned"}[kind]
+ verb = {
+ "code": "defined code",
+ "func": "created function",
+ "undef": "undefined",
+ "string": "made string",
+ "thumb": "switched decoding",
+ "thumbscan": "scanned",
+ }[kind]
try:
if kind == "code":
# Keep going until something stops it: one instruction is rarely
@@ -646,20 +683,24 @@ class EditController:
if n == 0 and why == "defined":
# Already code/data here — a no-op, not a failure. Saying
# "failed to create instruction" for it would be a lie.
- app.call_from_thread(
- app._status, f"already defined @ {ea:#x}")
+ app.call_from_thread(app._status, f"already defined @ {ea:#x}")
return
if n == 0:
- raise IDAToolError("define_code",
- f"@ {ea:#x}: Failed to create instruction")
+ raise IDAToolError(
+ "define_code", f"@ {ea:#x}: Failed to create instruction"
+ )
end = int(str(r.get("end", hex(ea))), 0)
- reason = {"undecodable": "hit bytes that don't decode",
- "flow": "control flow ends here",
- "defined": "ran into existing code/data",
- "segment": "end of segment",
- "limit": "instruction limit"}.get(why, why)
- verb = (f"defined {n} instruction{'s' if n != 1 else ''} "
- f"({ea:#x}\u2013{end:#x}) \u2014 {reason}")
+ reason = {
+ "undecodable": "hit bytes that don't decode",
+ "flow": "control flow ends here",
+ "defined": "ran into existing code/data",
+ "segment": "end of segment",
+ "limit": "instruction limit",
+ }.get(why, why)
+ verb = (
+ f"defined {n} instruction{'s' if n != 1 else ''} "
+ f"({ea:#x}\u2013{end:#x}) \u2014 {reason}"
+ )
elif kind == "thumbscan":
# A vector table is a list of Thumb entry points that IDA won't
# follow on a headerless image, because nothing tells it those
@@ -668,11 +709,15 @@ class EditController:
r = app.program.thumb_scan(ea, ea + 0x400)
n, applied = int(r.get("n", 0)), int(r.get("applied", 0))
if not n:
- verb = (f"no Thumb entry pointers in {ea:#x}\u2013{ea+0x400:#x}"
- " (odd words pointing into the image)")
+ verb = (
+ f"no Thumb entry pointers in {ea:#x}\u2013{ea + 0x400:#x}"
+ " (odd words pointing into the image)"
+ )
else:
- verb = (f"{n} Thumb entr{'y' if n == 1 else 'ies'} found, "
- f"{applied} disassembled")
+ verb = (
+ f"{n} Thumb entr{'y' if n == 1 else 'ies'} found, "
+ f"{applied} disassembled"
+ )
elif kind == "thumb":
# Switch the mode, then disassemble in it: flipping T and
# leaving the bytes undefined shows nothing, and the reason you
@@ -686,11 +731,16 @@ class EditController:
verb += " (segment set to 32-bit; Thumb needs ARM32)"
if r.get("db_64bit"):
# Disassembly will look right and F5 will never work.
- verb += (" \u26a0 this database is 64-bit, so Hex-Rays "
- "won't decompile it \u2014 Ctrl+L and pick "
- "arm:ARMv7-A")
- verb += (f" \u2014 {n} instruction{'s' if n != 1 else ''}"
- if n else " \u2014 still doesn't decode")
+ verb += (
+ " \u26a0 this database is 64-bit, so Hex-Rays "
+ "won't decompile it \u2014 Ctrl+L and pick "
+ "arm:ARMv7-A"
+ )
+ verb += (
+ f" \u2014 {n} instruction{'s' if n != 1 else ''}"
+ if n
+ else " \u2014 still doesn't decode"
+ )
# falls through to the shared reload: same cache bump, same
# anchor restore, same flash. That is the whole point of having
# one path.
@@ -698,9 +748,11 @@ class EditController:
anchor.refresh_functions = True
r = app.program.define_func(ea)
if r.get("start") and r.get("end"):
- verb = (f"created function {r['start']}\u2013{r['end']}"
- + (" (end worked out from the code)"
- if r.get("how") == "explicit-end" else ""))
+ verb = f"created function {r['start']}\u2013{r['end']}" + (
+ " (end worked out from the code)"
+ if r.get("how") == "explicit-end"
+ else ""
+ )
elif kind == "string":
s = app.program.make_string(ea)
verb = f"made string ({s[:24]!r})" if s else verb
@@ -726,13 +778,14 @@ class EditController:
idx = 0 if ea == fn.addr else model.index_of_ea(ea)
_cur, top = app._anchor_rows(anchor, model, ea)
app.call_from_thread(
- app._open_at, fn.addr, fn.name, idx, False, -1, 0, False,
- None, top)
+ app._open_at, fn.addr, fn.name, idx, False, -1, 0, False, None, top
+ )
else:
name = app.program.region_label(ea)
lm = app.program.listing(ea)
idx = max(lm.ensure_ea(ea), 0) if lm is not None else 0
_cur, top = app._anchor_rows(anchor, lm, ea)
app.call_from_thread(
- app._open_at, ea, name, idx, False, -1, 0, True, None, top)
+ app._open_at, ea, name, idx, False, -1, 0, True, None, top
+ )
app.call_from_thread(self.edit_done, anchor)
diff --git a/idatui/errors.py b/idatui/errors.py
index aaf2dc5..7aceef2 100644
--- a/idatui/errors.py
+++ b/idatui/errors.py
@@ -1,9 +1,10 @@
"""TUI-facing error hierarchy and lightweight database session model.
-The Code Mode adapter normalizes ``ida_codemode.client`` transport and execution
+The IDA Nexus adapter normalizes ``ida_nexus`` transport and execution
errors into these types so the domain and Textual layers do not depend on HTTP or
registry implementation details.
"""
+
from __future__ import annotations
from dataclasses import dataclass
diff --git a/idatui/findings.py b/idatui/findings.py
index 58a69f6..e5ce91f 100644
--- a/idatui/findings.py
+++ b/idatui/findings.py
@@ -85,7 +85,9 @@ def from_loader(seg: str, name: str = "") -> bool:
#: its stereotyped shapes, which no one types by accident.
_ANALYZER = re.compile(
r"^(?:switch \d+ cases?|switch jump|jumptable [0-9A-Fa-f]+\b.*|"
- r"indirect table for switch.*|jump table for switch.*)$", re.I)
+ r"indirect table for switch.*|jump table for switch.*)$",
+ re.I,
+)
#: The other family is argument hints (`s1`, `locale`, `domainname`), which IDA
#: copies from the callee's prototype onto each argument-setup instruction. They
@@ -101,11 +103,14 @@ def analyzer_texts(comments) -> set[str]:
counts: dict[str, int] = {}
for c in comments:
text = (c.text or "").strip()
- if text and not text.split()[1:]: # a single whitespace-free token
+ if text and not text.split()[1:]: # a single whitespace-free token
counts[text] = counts.get(text, 0) + 1
out = {t for t, n in counts.items() if n >= _HINT_REPEATS}
- out |= {(c.text or "").strip() for c in comments
- if _ANALYZER.match((c.text or "").strip())}
+ out |= {
+ (c.text or "").strip()
+ for c in comments
+ if _ANALYZER.match((c.text or "").strip())
+ }
return out
@@ -115,7 +120,8 @@ _DUMMY = re.compile(
r"^(?:(?:sub|loc|locret|off|seg|asc|byte|word|dword|qword|xmmword|ymmword|"
r"flt|dbl|tbyte|stru|algn|unk|nullsub|def|jpt|jsub)_[0-9A-Fa-f]+"
# j_strlen: a thunk name IDA derives from its target, not from a person.
- r"|j_\w+)$")
+ r"|j_\w+)$"
+)
def is_dummy(name: str) -> bool:
@@ -123,8 +129,9 @@ def is_dummy(name: str) -> bool:
return bool(_DUMMY.match(name or ""))
-def gather(program, path: str = "", *, limit: int = 4000,
- types: bool = True, journal=None) -> Findings:
+def gather(
+ program, path: str = "", *, limit: int = 4000, types: bool = True, journal=None
+) -> Findings:
"""Collect a :class:`Findings` from a live :class:`Program`.
``path`` is the binary the app opened -- ``Program`` speaks to a database
@@ -139,8 +146,11 @@ def gather(program, path: str = "", *, limit: int = 4000,
if journal is not None:
try:
out.recorded = journal.addresses()
- out.recorded_types = {e.get("d", "") for e in journal.entries
- if e.get("k") == "type" and e.get("d")}
+ out.recorded_types = {
+ e.get("d", "")
+ for e in journal.entries
+ if e.get("k") == "type" and e.get("d")
+ }
out.n_recorded = len(journal)
except Exception: # noqa: BLE001
out.recorded, out.recorded_types, out.n_recorded = set(), set(), 0
@@ -188,8 +198,9 @@ def _esc(text: str) -> str:
def _fence(text: str) -> str:
"""Fence body text so a comment containing backticks cannot break out."""
- ticks = "`" * max(3, max((len(m) for m in re.findall(r"`+", text or "")),
- default=0) + 1)
+ ticks = "`" * max(
+ 3, max((len(m) for m in re.findall(r"`+", text or "")), default=0) + 1
+ )
return f"{ticks}\n{(text or '').rstrip()}\n{ticks}"
@@ -199,9 +210,13 @@ def _user_names(f: Findings) -> list:
With a journal, that is exactly the addresses we recorded renaming. Without
one, it is a judgement: a real name, not the linker's, not the loader's.
"""
- names = [n for n in f.names
- if not is_dummy(n.name) and n.name not in f.linked
- and not from_loader(n.seg, n.name)]
+ names = [
+ n
+ for n in f.names
+ if not is_dummy(n.name)
+ and n.name not in f.linked
+ and not from_loader(n.seg, n.name)
+ ]
if f.recorded:
return [n for n in names if n.addr in f.recorded]
return names
@@ -212,8 +227,7 @@ def _user_types(f: Findings) -> list:
IDA loaded, so with a journal we show only the ones declared here; without
one, all of them, newest ordinal first (yours are the newest)."""
if f.recorded or f.recorded_types:
- return [t for t in f.types
- if getattr(t[0], "name", "") in f.recorded_types]
+ return [t for t in f.types if getattr(t[0], "name", "") in f.recorded_types]
return list(f.types)
@@ -244,8 +258,7 @@ def render(f: Findings) -> str:
names = sorted(_user_names(f), key=lambda n: n.addr)
funcs = [n for n in names if n.is_func]
data = [n for n in names if not n.is_func]
- comments = sorted(_user_comments(f), key=lambda c: (c.func_addr or c.addr,
- c.addr))
+ comments = sorted(_user_comments(f), key=lambda c: (c.func_addr or c.addr, c.addr))
dropped = (len(f.comments) - len(comments)) + (len(f.names) - len(names))
types = _user_types(f)
@@ -253,9 +266,11 @@ def render(f: Findings) -> str:
title = f.binary or "database"
L.append(f"# Findings — {title}")
L.append("")
- L.append(f"*{len(funcs)} named functions · {len(data)} named data · "
- f"{len(comments)} comments · {len(types)} local types — "
- f"exported {when} by idatui*")
+ L.append(
+ f"*{len(funcs)} named functions · {len(data)} named data · "
+ f"{len(comments)} comments · {len(types)} local types — "
+ f"exported {when} by idatui*"
+ )
L.append("")
if f.path:
L.append(f"- **binary**: `{f.path}`")
@@ -267,24 +282,34 @@ def render(f: Findings) -> str:
L.append(f"- **segments**: {segs}{more}")
if f.recorded or f.recorded_types:
n_at = len(f.recorded)
- L.append(f"- **source**: idatui's edit journal — {f.n_recorded} recorded "
- f"edits across {n_at} address{'' if n_at == 1 else 'es'}. "
- "Everything below is work done here, not the analyzer's.")
+ L.append(
+ f"- **source**: idatui's edit journal — {f.n_recorded} recorded "
+ f"edits across {n_at} address{'' if n_at == 1 else 'es'}. "
+ "Everything below is work done here, not the analyzer's."
+ )
else:
- L.append("- **source**: a scan of the database. Nothing in a `.i64` "
- "records *who* wrote a comment or a name — IDA's own analyzer "
- "uses the same calls — so this is filtered by shape and may "
- "include its work as well as yours.")
+ L.append(
+ "- **source**: a scan of the database. Nothing in a `.i64` "
+ "records *who* wrote a comment or a name — IDA's own analyzer "
+ "uses the same calls — so this is filtered by shape and may "
+ "include its work as well as yours."
+ )
if not f.stripped:
- L.append("- **note**: this binary has its own symbols, so the names "
- "below include ones it shipped with.")
+ L.append(
+ "- **note**: this binary has its own symbols, so the names "
+ "below include ones it shipped with."
+ )
if dropped and (f.recorded or f.recorded_types):
- L.append(f"- **note**: {dropped} other annotations in this database "
- "were not made here (the analyzer's, the loader's, the "
- "linker's) and are left out.")
+ L.append(
+ f"- **note**: {dropped} other annotations in this database "
+ "were not made here (the analyzer's, the loader's, the "
+ "linker's) and are left out."
+ )
elif dropped:
- L.append(f"- **note**: {dropped} annotations left out as the loader's "
- "own (file headers, dummy names, imports).")
+ L.append(
+ f"- **note**: {dropped} annotations left out as the loader's "
+ "own (file headers, dummy names, imports)."
+ )
if f.truncated:
L.append("- **note**: the scan hit its limit; this report is partial.")
L.append("")
@@ -293,8 +318,10 @@ def render(f: Findings) -> str:
L.append("## Comments")
L.append("")
if not comments:
- L.append("*None. (Comments are the part of a database nobody else can "
- "reconstruct — they are worth writing.)*")
+ L.append(
+ "*None. (Comments are the part of a database nobody else can "
+ "reconstruct — they are worth writing.)*"
+ )
L.append("")
else:
by_func: dict[str, list] = {}
@@ -309,11 +336,9 @@ def render(f: Findings) -> str:
L.append("")
for c in rows:
if c.whole_func:
- L.append(f"- **{c.addr:#x}** — *whole function*: "
- f"{_esc(c.text)}")
+ L.append(f"- **{c.addr:#x}** — *whole function*: {_esc(c.text)}")
elif c.line:
- L.append(f"- **{c.addr:#x}** `{_esc(c.line)}` \n"
- f" {_esc(c.text)}")
+ L.append(f"- **{c.addr:#x}** `{_esc(c.line)}` \n {_esc(c.text)}")
else:
L.append(f"- **{c.addr:#x}** — {_esc(c.text)}")
L.append("")
@@ -329,8 +354,7 @@ def render(f: Findings) -> str:
L.append("|---|---|---|---|")
for n in funcs:
proto = f"`{_esc(n.proto)}`" if n.proto else ""
- L.append(f"| `{n.addr:#x}` | `{_esc(n.name)}` | "
- f"{n.size:#x} | {proto} |")
+ L.append(f"| `{n.addr:#x}` | `{_esc(n.name)}` | {n.size:#x} | {proto} |")
L.append("")
if data:
L.append("## Named data")
@@ -346,17 +370,21 @@ def render(f: Findings) -> str:
L.append("## Local types")
L.append("")
if not (f.recorded or f.recorded_types):
- L.append("*Newest first. A database is seeded with types from the "
- "libraries IDA loaded, so the ones you defined are the "
- "ones with the highest ordinals — at the top of this "
- "list.*")
+ L.append(
+ "*Newest first. A database is seeded with types from the "
+ "libraries IDA loaded, so the ones you defined are the "
+ "ones with the highest ordinals — at the top of this "
+ "list.*"
+ )
L.append("")
ordered = sorted(types, key=lambda t: -getattr(t[0], "ordinal", 0))
for st, src in ordered:
kw = "union" if getattr(st, "is_union", False) else "struct"
- L.append(f"### `{kw} {st.name}` "
- f"({getattr(st, 'size', 0):#x} bytes, "
- f"{getattr(st, 'members', 0)} fields)")
+ L.append(
+ f"### `{kw} {st.name}` "
+ f"({getattr(st, 'size', 0):#x} bytes, "
+ f"{getattr(st, 'members', 0)} fields)"
+ )
L.append("")
if src:
L.append("```c")
@@ -372,9 +400,15 @@ def default_path(program_path: str) -> str:
return f"{base}.findings.md"
-def export(program, binary_path: str = "", out_path: str | None = None, *,
- limit: int = 4000, types: bool = True,
- journal=None) -> tuple[str, Findings]:
+def export(
+ program,
+ binary_path: str = "",
+ out_path: str | None = None,
+ *,
+ limit: int = 4000,
+ types: bool = True,
+ journal=None,
+) -> tuple[str, Findings]:
"""Gather, render and WRITE the report. Returns ``(path, findings)``."""
f = gather(program, binary_path, limit=limit, types=types, journal=journal)
out = out_path or default_path(f.path)
diff --git a/idatui/formats.py b/idatui/formats.py
index f09bb99..d3d2abd 100644
--- a/idatui/formats.py
+++ b/idatui/formats.py
@@ -60,7 +60,7 @@ def sniff(path: str) -> str | None:
if not head:
return None
for magic, off, name in _MAGIC:
- if head[off:off + len(magic)] == magic:
+ if head[off : off + len(magic)] == magic:
return name
# Only treat a text prefix as a format if the whole head is printable —
# a raw blob starting with 0x3a (':') is far more likely than Intel HEX.
diff --git a/idatui/graph.py b/idatui/graph.py
index baee597..f8df2ec 100644
--- a/idatui/graph.py
+++ b/idatui/graph.py
@@ -26,15 +26,20 @@ cells, so ``Painting`` is an *index* — per-row horizontal runs, a bucketed
interval index of vertical runs, and point marks — and the view asks it for one
row at a time (``cells_at_row``), exactly like the listing's ``render_line``.
"""
+
from __future__ import annotations
+import logging
+import os
import time
from dataclasses import dataclass, field
+_LOG = logging.getLogger(__name__)
+
# Terminal cells are about twice as tall as they are wide, so horizontal gaps
# need roughly 2x the cell count of vertical gaps to look square.
-HGAP = 3 # min columns between two boxes in a layer
-VGAP = 1 # min rows between a layer band and the channel below it
+HGAP = 3 # min columns between two boxes in a layer
+VGAP = 1 # min rows between a layer band and the channel below it
# Edge classes, used as style keys by the renderer.
E_UNCOND = "uncond"
@@ -64,8 +69,8 @@ class Node:
label: str = ""
rank: int = 0
order: int = 0
- x: int = 0 # left column
- y: int = 0 # top row
+ x: int = 0 # left column
+ y: int = 0 # top row
w: int = 1
h: int = 1
@@ -100,6 +105,12 @@ class Edge:
kind: str = E_UNCOND
back: bool = False
chain: list[int] = field(default_factory=list)
+ #: ``src``/``dst`` are swapped relative to control flow. The native engine
+ #: reverses back edges so layering sees a DAG; the triskel engine handles
+ #: cycles itself and leaves them alone. Everything downstream that has to
+ #: recover the real direction (succ/pred, arrowheads) reads THIS, not
+ #: ``back`` -- which is now purely a style bit.
+ flipped: bool = False
@property
def style(self) -> str:
@@ -124,6 +135,7 @@ class _Graph:
# ------------------------------------------------------------ 1. cycles
+
def _break_cycles(g: _Graph, root: int) -> None:
"""Reverse back edges (DFS gray-set) so layering sees a DAG."""
color: dict[int, int] = {}
@@ -151,10 +163,12 @@ def _break_cycles(g: _Graph, root: int) -> None:
for e in g.edges:
if e.back:
e.src, e.dst = e.dst, e.src
+ e.flipped = True
# --------------------------------------------------------- 2. layering
+
def _assign_ranks(g: _Graph, root: int) -> None:
"""Longest-path layering: rank(v) = 1 + max(rank(preds)).
@@ -194,11 +208,12 @@ def _assign_ranks(g: _Graph, root: int) -> None:
# ---------------------------------------------------------- 3. dummies
+
def _add_dummies(g: _Graph) -> None:
for e in list(g.edges):
span = g.nodes[e.dst].rank - g.nodes[e.src].rank
if span <= 0:
- e.back = True # residual cycle: colour it, route it flat
+ e.back = True # residual cycle: colour it, route it flat
chain = [e.src]
if span > 1:
for r in range(g.nodes[e.src].rank + 1, g.nodes[e.dst].rank):
@@ -227,6 +242,7 @@ def _segments(g: _Graph) -> list[tuple[int, int, Edge]]:
# ---------------------------------------------------------- 4. ordering
+
def _neighbors(g: _Graph) -> tuple[dict[int, list[int]], dict[int, list[int]]]:
down: dict[int, list[int]] = {i: [] for i in g.nodes}
up: dict[int, list[int]] = {i: [] for i in g.nodes}
@@ -236,8 +252,9 @@ def _neighbors(g: _Graph) -> tuple[dict[int, list[int]], dict[int, list[int]]]:
return down, up
-def _cross_below(layer: list[int], down: dict[int, list[int]],
- pos: dict[int, int]) -> int:
+def _cross_below(
+ layer: list[int], down: dict[int, list[int]], pos: dict[int, int]
+) -> int:
"""Crossings between this layer and the one below, counted as inversions
with a Fenwick tree: O(E log E). The naive O(E^2) version is the entire
runtime on a 400-block function (20s vs 150ms), so it is not an option."""
@@ -266,8 +283,7 @@ def _cross_below(layer: list[int], down: dict[int, list[int]],
return total
-def _pair_cross(a: int, b: int, side: dict[int, list[int]],
- pos: dict[int, int]) -> int:
+def _pair_cross(a: int, b: int, side: dict[int, list[int]], pos: dict[int, int]) -> int:
"""Crossings from a's and b's edges to one neighbouring layer given a sits
immediately LEFT of b. Local — O(deg(a)*deg(b)) — so the transposition pass
never has to recount the whole graph per candidate swap."""
@@ -280,8 +296,13 @@ def _pair_cross(a: int, b: int, side: dict[int, list[int]],
return n
-def _swap_delta(a: int, b: int, down: dict[int, list[int]],
- up: dict[int, list[int]], pos: dict[int, int]) -> tuple[int, int]:
+def _swap_delta(
+ a: int,
+ b: int,
+ down: dict[int, list[int]],
+ up: dict[int, list[int]],
+ pos: dict[int, int],
+) -> tuple[int, int]:
"""``(keep, swap)`` for the adjacent pair (a, b), both sides, in one pass.
The same as calling :func:`_pair_cross` four times, which is what the
@@ -307,8 +328,9 @@ def _swap_delta(a: int, b: int, down: dict[int, list[int]],
return keep, swap
-def crossings(layers: list[list[int]], down: dict[int, list[int]],
- pos: dict[int, int]) -> int:
+def crossings(
+ layers: list[list[int]], down: dict[int, list[int]], pos: dict[int, int]
+) -> int:
return sum(_cross_below(l, down, pos) for l in layers)
@@ -329,7 +351,7 @@ def _order_layers(g: _Graph, root: int, sweeps: int = 6) -> list[list[int]]:
seen.add(j)
stack.append(j)
for layer in layers:
- layer.sort(key=lambda i: seed.get(i, 10 ** 9))
+ layer.sort(key=lambda i: seed.get(i, 10**9))
pos = {i: k for layer in layers for k, i in enumerate(layer)}
def median(i: int, side: dict[int, list[int]]) -> float:
@@ -383,6 +405,7 @@ def _order_layers(g: _Graph, root: int, sweeps: int = 6) -> list[list[int]]:
# --------------------------------------------------------- 5. x coords
+
def _assign_x(g: _Graph, layers: list[list[int]], sweeps: int = 8) -> None:
down, up = _neighbors(g)
for layer in layers:
@@ -407,8 +430,9 @@ def _assign_x(g: _Graph, layers: list[list[int]], sweeps: int = 8) -> None:
for r in rng:
layer = layers[r]
# dummies first: keeping long edges straight matters most
- order = sorted(layer, key=lambda i: (not g.nodes[i].dummy,
- g.nodes[i].order))
+ order = sorted(
+ layer, key=lambda i: (not g.nodes[i].dummy, g.nodes[i].order)
+ )
for i in order:
nb = side[i]
if not nb:
@@ -426,6 +450,7 @@ def _assign_x(g: _Graph, layers: list[list[int]], sweeps: int = 8) -> None:
# ------------------------------------------------------------ 6. route
+
def _ports(g: _Graph) -> tuple[dict, dict]:
"""Spread a node's out-edges along its bottom border and its in-edges along
its top, each ordered by the other end's x so they don't cross at the node."""
@@ -459,8 +484,11 @@ def _ports(g: _Graph) -> tuple[dict, dict]:
class Route:
edge: Edge
pts: list[tuple[int, int]]
- head: bool = True # arrowhead (target is a real block)
- tail: bool = True # port tee (source is a real block)
+ head: bool = True # arrowhead (target is a real block)
+ tail: bool = True # port tee (source is a real block)
+ #: the polyline is drawn against control flow (a reversed back edge), so the
+ #: arrowhead belongs at ``pts[0]`` and the port tee at ``pts[-1]``.
+ flipped: bool = False
def _route(g: _Graph, layers: list[list[int]]) -> list[Route]:
@@ -476,7 +504,7 @@ def _route(g: _Graph, layers: list[list[int]]) -> list[Route]:
runs = []
for a, b, e in lst:
x0, x1 = out_port[(a, b, id(e))], in_port[(a, b, id(e))]
- if x0 != x1: # a straight drop needs no lane
+ if x0 != x1: # a straight drop needs no lane
runs.append((min(x0, x1), max(x0, x1), (a, b, id(e))))
runs.sort(key=lambda t: (t[1] - t[0], t[0]))
occupied: list[list[tuple[int, int]]] = []
@@ -502,7 +530,7 @@ def _route(g: _Graph, layers: list[list[int]]) -> list[Route]:
n = g.nodes[i]
n.y = y
if n.dummy:
- n.h = h # the band is its pass-through
+ n.h = h # the band is its pass-through
chan_y.append(y + h - 1 + VGAP)
y += h - 1 + VGAP + channels[r] + VGAP + 1
@@ -521,34 +549,58 @@ def _route(g: _Graph, layers: list[list[int]]) -> list[Route]:
else:
ych = chan_y[na.rank] + lanes.get((a, b, id(e)), 0)
pts = [(y0, x0), (ych, x0), (ych, x1), (y1, x1)]
- routes.append(Route(edge=e, pts=pts,
- head=not nb.dummy, tail=not na.dummy))
+ routes.append(
+ Route(
+ edge=e, pts=pts, head=not nb.dummy, tail=not na.dummy, flipped=e.flipped
+ )
+ )
return routes
# ------------------------------------------------------------ painting
-BOX = {"tl": "\u250c", "tr": "\u2510", "bl": "\u2514", "br": "\u2518",
- "h": "\u2500", "v": "\u2502"}
-LINE_CHARS = set("\u2502\u2500\u250c\u2510\u2514\u2518\u251c\u2524\u252c\u2534"
- "\u253c\u256d\u256e\u2570\u256f")
+BOX = {
+ "tl": "\u250c",
+ "tr": "\u2510",
+ "bl": "\u2514",
+ "br": "\u2518",
+ "h": "\u2500",
+ "v": "\u2502",
+}
+LINE_CHARS = set(
+ "\u2502\u2500\u250c\u2510\u2514\u2518\u251c\u2524\u252c\u2534"
+ "\u253c\u256d\u256e\u2570\u256f"
+)
MERGE = {
frozenset("\u2502\u2500"): "\u253c",
- frozenset("\u2502\u250c"): "\u251c", frozenset("\u2502\u2510"): "\u2524",
- frozenset("\u2502\u2514"): "\u251c", frozenset("\u2502\u2518"): "\u2524",
- frozenset("\u2500\u250c"): "\u252c", frozenset("\u2500\u2510"): "\u252c",
- frozenset("\u2500\u2514"): "\u2534", frozenset("\u2500\u2518"): "\u2534",
- frozenset("\u2502\u256d"): "\u251c", frozenset("\u2502\u256e"): "\u2524",
- frozenset("\u2502\u2570"): "\u251c", frozenset("\u2502\u256f"): "\u2524",
- frozenset("\u2500\u256d"): "\u252c", frozenset("\u2500\u256e"): "\u252c",
- frozenset("\u2500\u2570"): "\u2534", frozenset("\u2500\u256f"): "\u2534",
+ frozenset("\u2502\u250c"): "\u251c",
+ frozenset("\u2502\u2510"): "\u2524",
+ frozenset("\u2502\u2514"): "\u251c",
+ frozenset("\u2502\u2518"): "\u2524",
+ frozenset("\u2500\u250c"): "\u252c",
+ frozenset("\u2500\u2510"): "\u252c",
+ frozenset("\u2500\u2514"): "\u2534",
+ frozenset("\u2500\u2518"): "\u2534",
+ frozenset("\u2502\u256d"): "\u251c",
+ frozenset("\u2502\u256e"): "\u2524",
+ frozenset("\u2502\u2570"): "\u251c",
+ frozenset("\u2502\u256f"): "\u2524",
+ frozenset("\u2500\u256d"): "\u252c",
+ frozenset("\u2500\u256e"): "\u252c",
+ frozenset("\u2500\u2570"): "\u2534",
+ frozenset("\u2500\u256f"): "\u2534",
}
CORNER = {
- ("D", "R"): "\u2570", ("D", "L"): "\u256f", ("R", "D"): "\u256e",
- ("L", "D"): "\u256d", ("R", "U"): "\u256f", ("L", "U"): "\u2570",
- ("U", "R"): "\u256d", ("U", "L"): "\u256e",
+ ("D", "R"): "\u2570",
+ ("D", "L"): "\u256f",
+ ("R", "D"): "\u256e",
+ ("L", "D"): "\u256d",
+ ("R", "U"): "\u256f",
+ ("L", "U"): "\u2570",
+ ("U", "R"): "\u256d",
+ ("U", "L"): "\u256e",
}
-BUCKET = 32 # rows per vertical-run index bucket
+BUCKET = 32 # rows per vertical-run index bucket
def _dir(p: tuple[int, int], q: tuple[int, int]) -> str:
@@ -581,8 +633,9 @@ class Painting:
def add_mark(self, row: int, col: int, ch: str, style: str, eid: int) -> None:
self.marks.setdefault(row, []).append((col, ch, style, eid))
- def cells_at_row(self, row: int, c0: int, c1: int
- ) -> dict[int, tuple[str, str, int]]:
+ def cells_at_row(
+ self, row: int, c0: int, c1: int
+ ) -> dict[int, tuple[str, str, int]]:
"""{col: (char, style, edge_id)} for ``row`` within [c0, c1)."""
out: dict[int, tuple[str, str, int]] = {}
@@ -590,8 +643,13 @@ class Painting:
if col < c0 or col >= c1:
return
old = out.get(col)
- if old and not force and old[0] != ch \
- and old[0] in LINE_CHARS and ch in LINE_CHARS:
+ if (
+ old
+ and not force
+ and old[0] != ch
+ and old[0] in LINE_CHARS
+ and ch in LINE_CHARS
+ ):
ch = MERGE.get(frozenset((old[0], ch)), ch)
out[col] = (ch, style, eid)
@@ -612,16 +670,16 @@ class Layout:
"""The finished drawing: boxes, an edge index, and enough structure for the
view to hit-test, navigate and highlight."""
- nodes: list[Node] # real blocks only, layout order
+ nodes: list[Node] # real blocks only, layout order
by_id: dict[int, Node]
edges: list[Edge]
painting: Painting
width: int
height: int
entry: int
- rows: dict[int, list[int]] # row -> real node ids covering it
- incident: dict[int, set[int]] # node id -> edge ids touching it
- succ: dict[int, list[tuple[int, str]]] # node id -> [(node id, style)]
+ rows: dict[int, list[int]] # row -> real node ids covering it
+ incident: dict[int, set[int]] # node id -> edge ids touching it
+ succ: dict[int, list[tuple[int, str]]] # node id -> [(node id, style)]
pred: dict[int, list[tuple[int, str]]]
stats: dict
@@ -646,20 +704,32 @@ class Layout:
return None
-def layout(blocks: list[Block], sizer, entry: int | None = None) -> Layout:
- """Lay out ``blocks``. ``sizer(block) -> (width, height)`` in cells."""
- t0 = time.perf_counter()
+def _build(blocks: list[Block], sizer, entry: int | None) -> tuple[_Graph, int]:
+ """The block list as a layout graph, plus the entry node id.
+
+ Shared by both engines, and re-run from scratch if one of them has to fall
+ back, because an engine positions nodes in place.
+ """
g = _Graph()
for b in blocks:
w, h = sizer(b)
- g.add(Node(id=b.id, block=b, label=f"loc_{b.start:X}",
- w=max(int(w), 4), h=max(int(h), 3)))
+ b.selfloop = False
+ g.add(
+ Node(
+ id=b.id,
+ block=b,
+ label=f"loc_{b.start:X}",
+ w=max(int(w), 4),
+ h=max(int(h), 3),
+ )
+ )
for b in blocks:
outs = [(d, k) for d, k in b.succs if d in g.nodes]
for dst, kind in outs:
if dst == b.id:
- # A self-loop constrains nothing and would deadlock the Kahn
- # ranking (its own in-degree never drains). Drawn as a marker.
+ # A self-loop constrains nothing, deadlocks the Kahn ranking
+ # (its own in-degree never drains) and makes triskel throw
+ # "EMPTY BL" from its bracket lists. Drawn as a marker instead.
b.selfloop = True
continue
if len(outs) == 1:
@@ -667,15 +737,83 @@ def layout(blocks: list[Block], sizer, entry: int | None = None) -> Layout:
g.edges.append(Edge(src=b.id, dst=dst, kind=kind))
root = entry if entry in g.nodes else (min(g.nodes) if g.nodes else 0)
- if g.nodes:
- _break_cycles(g, root)
- _assign_ranks(g, root)
- _add_dummies(g)
- layers = _order_layers(g, root)
- _assign_x(g, layers)
- routes = _route(g, layers)
+ return g, root
+
+
+def _native_engine(g: _Graph, root: int) -> tuple[list[Route], int]:
+ """Layered Sugiyama in cells: the pipeline documented at the top."""
+ _break_cycles(g, root)
+ _assign_ranks(g, root)
+ _add_dummies(g)
+ layers = _order_layers(g, root)
+ _assign_x(g, layers)
+ return _route(g, layers), len(layers)
+
+
+#: Engine names accepted by ``layout(engine=...)`` and ``IDATUI_GRAPH_ENGINE``.
+ENGINES = ("auto", "native", "triskel")
+
+#: Above this many blocks ``auto`` stays native. Layout runs on every open and
+#: every zoom keypress, so this is an interactivity budget, not a correctness
+#: one. Triskel's cost knees hard (measured on `ls`, 400 functions):
+#:
+#: blocks 174 233 256 329 424 495
+#: native 25 72 23 93 144 203 ms
+#: triskel 66 489 266 555 1501 1968 ms
+#:
+#: 180 keeps the worst auto-triskel layout in the tens of milliseconds. Raising
+#: it buys prettier pictures of graphs nobody can read anyway -- the view
+#: refuses to draw past 400 blocks at all.
+AUTO_TRISKEL_MAX_BLOCKS = 180
+
+
+def _pick_engine(engine: str | None, nblocks: int) -> str:
+ want = (engine or os.environ.get("IDATUI_GRAPH_ENGINE") or "auto").lower()
+ if want not in ENGINES:
+ want = "auto"
+ if want == "auto":
+ from . import graph_triskel
+
+ if nblocks <= AUTO_TRISKEL_MAX_BLOCKS and graph_triskel.available():
+ return "triskel"
+ return "native"
+ return want
+
+
+def layout(
+ blocks: list[Block], sizer, entry: int | None = None, engine: str | None = None
+) -> Layout:
+ """Lay out ``blocks``. ``sizer(block) -> (width, height)`` in cells.
+
+ ``engine`` picks the layout backend: ``native`` (pure python, always
+ available), ``triskel`` (SESE decomposition via the C++ library, far fewer
+ crossings) or ``auto``. Defaults to ``$IDATUI_GRAPH_ENGINE`` or ``auto``.
+ A triskel failure is never fatal: it falls back to native.
+ """
+ t0 = time.perf_counter()
+ name = _pick_engine(engine, len(blocks))
+ g, root = _build(blocks, sizer, entry)
+
+ layers = 0
+ err = None
+ if not g.nodes:
+ routes = []
+ elif name == "triskel":
+ from . import graph_triskel
+
+ try:
+ routes, layers = graph_triskel.run(g, root)
+ except Exception as exc: # noqa: BLE001
+ # Native code with a history of throwing on degenerate CFGs. The
+ # graph view is a convenience; losing it beats losing the session.
+ # Keep the REASON: a fallback the user can see but not explain is
+ # only marginally better than a crash.
+ _LOG.warning("triskel layout failed (%s), falling back", exc)
+ name, err = "native (triskel failed)", f"{type(exc).__name__}: {exc}"
+ g, root = _build(blocks, sizer, entry)
+ routes, layers = _native_engine(g, root)
else:
- layers, routes = [], []
+ routes, layers = _native_engine(g, root)
# ---- paint into the index -----------------------------------------
p = Painting()
@@ -708,43 +846,78 @@ def layout(blocks: list[Block], sizer, entry: int | None = None) -> Layout:
ch = CORNER.get((_dir(a, b), _dir(b, c)))
if ch and not blocked(*b):
p.add_mark(b[0], b[1], ch, style, eid)
- # A back edge was reversed for layering, so its polyline runs from the
- # loop HEAD down to the tail: the arrow belongs at the start, pointing
- # up into the block control returns to.
+ # Where the arrowhead goes is a question about CONTROL FLOW, not about
+ # geometry. The native engine reverses back edges for layering, so their
+ # polyline runs from the loop HEAD down to the tail and the arrow
+ # belongs at the start, pointing up into the block control returns to.
+ # The triskel engine keeps the real direction and routes the loop around
+ # the side of the graph, so the arrow is at the end like any other edge.
+ # ``rt.flipped`` is the only thing that distinguishes the two.
first, last = rt.pts[0], rt.pts[-1]
- if e.back:
+ down_first = rt.pts[1][0] > first[0] if len(rt.pts) > 1 else True
+ down_last = last[0] > rt.pts[-2][0] if len(rt.pts) > 1 else True
+ if rt.flipped:
if rt.tail:
- p.add_mark(first[0], first[1], "\u25b2", style, eid)
+ p.add_mark(
+ first[0], first[1], "\u25b2" if down_first else "\u25bc", style, eid
+ )
if rt.head:
- p.add_mark(last[0], last[1], "\u2534", style, eid)
+ p.add_mark(
+ last[0], last[1], "\u2534" if down_last else "\u252c", style, eid
+ )
else:
if rt.tail:
- p.add_mark(first[0], first[1], "\u252c", style, eid)
+ p.add_mark(
+ first[0], first[1], "\u252c" if down_first else "\u2534", style, eid
+ )
if rt.head:
- p.add_mark(last[0], last[1], "\u25bc", style, eid)
+ p.add_mark(
+ last[0], last[1], "\u25bc" if down_last else "\u25b2", style, eid
+ )
succ: dict[int, list[tuple[int, str]]] = {n.id: [] for n in real}
pred: dict[int, list[tuple[int, str]]] = {n.id: [] for n in real}
for e in g.edges:
- a, b = (e.dst, e.src) if e.back else (e.src, e.dst) # undo reversal
+ a, b = (e.dst, e.src) if e.flipped else (e.src, e.dst) # undo reversal
if a in succ:
succ[a].append((b, e.style))
if b in pred:
pred[b].append((a, e.style))
+ # The canvas has to cover the EDGES too, not just the boxes. Under the
+ # native engine that is the same thing -- dummy nodes reserve space, so no
+ # edge is ever outside the boxes' bounding box. Triskel routes a loop around
+ # the side of the graph, past every node, and sizing on boxes alone clipped
+ # exactly the edges that make its layouts worth having.
width = max((n.right + 1 for n in real), default=1)
height = max((n.y + n.h for n in real), default=1)
+ for rt in routes:
+ for r, c in rt.pts:
+ width = max(width, c + 1)
+ height = max(height, r + 1)
order = sorted(real, key=lambda n: (n.rank, n.order))
stats = {
"blocks": len(blocks),
"nodes": len(g.nodes),
"dummies": len(g.nodes) - len(real),
- "layers": len(layers),
+ "layers": layers,
"edges": len(g.edges),
"back": sum(1 for e in g.edges if e.back),
+ "engine": name,
+ "engine_error": err,
"ms": (time.perf_counter() - t0) * 1000,
}
- return Layout(nodes=order, by_id={n.id: n for n in g.nodes.values()},
- edges=g.edges, painting=p, width=width, height=height,
- entry=root, rows=rows, incident=incident,
- succ=succ, pred=pred, stats=stats)
+ return Layout(
+ nodes=order,
+ by_id={n.id: n for n in g.nodes.values()},
+ edges=g.edges,
+ painting=p,
+ width=width,
+ height=height,
+ entry=root,
+ rows=rows,
+ incident=incident,
+ succ=succ,
+ pred=pred,
+ stats=stats,
+ )
diff --git a/idatui/graph_triskel.py b/idatui/graph_triskel.py
new file mode 100644
index 0000000..5e8b3f0
--- /dev/null
+++ b/idatui/graph_triskel.py
@@ -0,0 +1,516 @@
+"""Triskel-backed layout: SESE decomposition, in character cells.
+
+`triskel <https://github.com/triskellib/triskel>`_ lays a CFG out by splitting it
+into Single-Entry Single-Exit regions first, laying each region out on its own,
+and pasting the results back as super-nodes. On our corpus that takes functions
+that our own layered engine draws with up to 41 edge crossings down to 0 or 1,
+and it routes loop edges around the side of the graph the way IDA does instead
+of straight back up the middle.
+
+This module owns the whole impedance mismatch between a float/pixel layout
+engine and a grid of character cells. Three things make that mismatch small:
+
+1. **We work in cells, not pixels.** Our fork exposes ``set_spacing()``, so the
+ gutters and the edge-lane pitch are set in cells (3 / 1 / 1) and node sizes
+ are handed over in cells. Upstream's constants are pixels (50 / 40 / 30);
+ feeding those a 32px-tall cell rounds two adjacent edge lanes onto the same
+ row, which in a terminal means two differently-coloured edges fighting over
+ one cell. In cell units the output is integral and lanes never collide.
+2. **Triskel's routes are already orthogonal.** Zero diagonal segments out of
+ 2471 on the corpus, so every segment is a run of ``─`` or ``│``.
+3. **Ports already land on the box border**, spread along it by degree, which is
+ exactly what our own ``_ports`` does.
+
+What it does NOT do is trust the library with degenerate input. Self-loops and
+disconnected graphs make it throw, an empty graph used to segfault, and a
+segfault takes the TUI down with it. Both are handled here, before the call.
+"""
+
+from __future__ import annotations
+
+import os
+
+from . import graph as G
+
+# Spacing, in cells. X_GUTTER is the gap between boxes in a layer, Y_GUTTER the
+# gap between a box and the first edge lane, EDGE_HEIGHT the pitch between
+# stacked horizontal edge runs -- so EDGE_HEIGHT >= 1 is what guarantees two
+# lanes never share a row.
+HGAP = 3
+VGAP = 1
+LANE = 1
+
+_mod: object | None = None
+_tried = False
+
+
+def module():
+ """The ``pytriskel`` extension, or None. Imported lazily and cached.
+
+ ``$IDATUI_TRISKEL_PATH`` points at a build tree (our fork's
+ ``build/bindings/python``) for development installs.
+ """
+ global _mod, _tried
+ if _tried:
+ return _mod
+ _tried = True
+ path = os.environ.get("IDATUI_TRISKEL_PATH")
+ if path:
+ import sys
+
+ if path not in sys.path:
+ sys.path.insert(0, path)
+ try:
+ import pytriskel # noqa: PLC0415
+ except ImportError:
+ return None
+ # Upstream ships wheels whose get_waypoints() always throws (a missing
+ # <pybind11/stl.h>), and without waypoints there are no edges to draw. Fail
+ # the availability check rather than dying mid-layout.
+ if not hasattr(pytriskel, "set_spacing"):
+ return None
+ _mod = pytriskel
+ return _mod
+
+
+def available() -> bool:
+ return module() is not None
+
+
+def _reachable(succ: dict[int, list[int]], root: int) -> set[int]:
+ seen = {root}
+ stack = [root]
+ while stack:
+ for j in succ.get(stack.pop(), ()):
+ if j not in seen:
+ seen.add(j)
+ stack.append(j)
+ return seen
+
+
+def _phantom_edges(g: G._Graph, root: int) -> list[tuple[int, int]]:
+ """Extra root->node edges that make every node reachable from ``root``.
+
+ **This is a hard precondition, not a nicety.** Triskel's root is whichever
+ node was created first, and every analysis walks out from it; hand it a node
+ the root cannot reach and it either throws ``EMPTY BL`` from the SESE
+ bracket lists or -- with an entry block that has no successors at all --
+ dereferences its way straight off the end and SEGFAULTS. A segfault cannot
+ be caught and fallen back from; it takes the TUI with it.
+
+ Real CFGs hit this in two ways, both routine: IDA flowcharts contain blocks
+ unreachable from the entry (dead code, a jump table entry it could not
+ resolve), and a function whose entry is a bare `jmp` thunk can leave the
+ rest of the chunk weakly connected but not reachable.
+
+ The edges are handed to the layout but never drawn. They cost a little
+ reserved space and, in exchange, triskel positions the orphans sensibly
+ (under the entry) instead of us stacking them beside the graph and hoping.
+ Attachment points are chosen at the natural entry of each orphan subgraph --
+ a node no other orphan reaches -- so one phantom edge usually covers many
+ blocks.
+ """
+ succ: dict[int, list[int]] = {i: [] for i in g.nodes}
+ preds: dict[int, list[int]] = {i: [] for i in g.nodes}
+ for e in g.edges:
+ succ[e.src].append(e.dst)
+ preds[e.dst].append(e.src)
+
+ reach = _reachable(succ, root)
+ phantom: list[tuple[int, int]] = []
+ while len(reach) < len(g.nodes):
+ rest = [i for i in g.nodes if i not in reach]
+ rest_set = set(rest)
+ head = next(
+ (i for i in rest if not any(p in rest_set for p in preds[i])), rest[0]
+ )
+ phantom.append((root, head))
+ reach |= _reachable(succ, head)
+ return phantom
+
+
+def _edge_type(pt, kind: str):
+ if kind == G.E_TRUE:
+ return pt.EdgeType.T
+ if kind == G.E_FALSE:
+ return pt.EdgeType.F
+ return pt.EdgeType.Default
+
+
+def _clean(pts: list[tuple[int, int]]) -> list[tuple[int, int]]:
+ """Drop duplicate and collinear waypoints.
+
+ Triskel emits doubled points where it stitches region layouts together (a
+ back edge came back with 20 waypoints, 6 of them duplicates). A doubled
+ point is a zero-length segment, which would make the corner-glyph pass read
+ a direction of "nowhere".
+ """
+ out: list[tuple[int, int]] = []
+ for p in pts:
+ if out and out[-1] == p:
+ continue
+ out.append(p)
+ i = 1
+ while i < len(out) - 1:
+ a, b, c = out[i - 1], out[i], out[i + 1]
+ if (a[0] == b[0] == c[0]) or (a[1] == b[1] == c[1]):
+ del out[i]
+ else:
+ i += 1
+ return out
+
+
+def run(g: G._Graph, root: int) -> tuple[list[G.Route], int]:
+ """Position every node in ``g`` and return (routes, layer count).
+
+ Mirrors the contract of ``graph._native_engine``: nodes come back with
+ ``x``/``y``/``rank``/``order`` set, edges keep their real direction (triskel
+ handles cycles internally, so nothing is flipped), and routes are cell
+ polylines.
+ """
+ pt = module()
+ if pt is None:
+ raise RuntimeError("pytriskel is not available")
+ pt.set_spacing(x_gutter=float(HGAP), y_gutter=float(VGAP), edge_height=float(LANE))
+
+ routes: list[G.Route] = []
+ if g.nodes:
+ phantom = _phantom_edges(g, root)
+ _layout_graph(pt, g, root, g.edges, phantom, routes)
+
+ # The native engine learns which edges are back edges from its DFS, because
+ # it has to reverse them to get a DAG. Triskel handles cycles internally and
+ # tells us nothing, so recover it from the drawing: an edge that does not
+ # descend is one control flow comes back along. This is style only (purple,
+ # and the `loop:` reading in the RPC surface) -- the direction is untouched,
+ # which is why ``flipped`` stays False on every triskel route.
+ for e in g.edges:
+ e.back = g.nodes[e.dst].y <= g.nodes[e.src].y
+
+ # Ranks are a layout concept the rest of the app navigates by (`w`/`b`, the
+ # RPC surface). Triskel doesn't expose them -- it has regions, not layers --
+ # so recover bands from the y coordinates the boxes actually landed on.
+ real = [n for n in g.nodes.values() if not n.dummy]
+ bands = sorted({n.y for n in real})
+ rank_of = {y: r for r, y in enumerate(bands)}
+ for n in real:
+ n.rank = rank_of[n.y]
+ for y in bands:
+ row = sorted((n for n in real if n.y == y), key=lambda n: n.x)
+ for k, n in enumerate(row):
+ n.order = k
+
+ _repair_boxes(g, routes)
+ _verify(g, routes)
+ return routes, len(bands)
+
+
+def _layout_graph(
+ pt,
+ g: G._Graph,
+ root: int,
+ edges: list[G.Edge],
+ phantom: list[tuple[int, int]],
+ routes: list[G.Route],
+) -> None:
+ """Lay the whole graph out and append its routes."""
+ order = [root] + [i for i in g.nodes if i != root]
+ succ: dict[int, list[int]] = {i: [] for i in g.nodes}
+ for e in edges:
+ succ[e.src].append(e.dst)
+ for a, b in phantom:
+ succ[a].append(b)
+ unreachable = set(g.nodes) - _reachable(succ, root)
+ if unreachable:
+ # Belt and braces: _phantom_edges is supposed to have made this
+ # impossible, and the consequence of being wrong is a SIGSEGV rather
+ # than an exception, so check before crossing into C++ rather than
+ # after. RuntimeError here means a fallback to native; a segfault means
+ # the user loses the session.
+ raise RuntimeError(
+ f"{len(unreachable)} blocks unreachable from the "
+ f"layout root {root}: {sorted(unreachable)[:8]}"
+ )
+
+ builder = pt.make_layout_builder()
+ tid = {}
+ # The root MUST be created first: triskel takes its graph root to be
+ # whichever node was made first, and every one of its analyses walks out
+ # from there.
+ for nid in order:
+ n = g.nodes[nid]
+ # NOTE the argument order: make_node(height, width). Upstream's Python
+ # docstring says "width and height", which is the other way round; our
+ # fork makes them keyword arguments so it cannot be got wrong silently.
+ tid[nid] = builder.make_node(height=float(n.h), width=float(n.w))
+ teid = [
+ (builder.make_edge(tid[e.src], tid[e.dst], _edge_type(pt, e.kind)), e)
+ for e in edges
+ ]
+ for a, b in phantom:
+ builder.make_edge(tid[a], tid[b], _edge_type(pt, G.E_UNCOND))
+ lay = builder.build()
+
+ polys: list[tuple[G.Edge, list[tuple[float, float]]]] = []
+ for eid, e in teid:
+ polys.append((e, [(p.x, p.y) for p in lay.get_waypoints(eid)]))
+
+ # Triskel's origin is not its bounding box: a loop edge routed around the
+ # side runs to y = -1, above every node. Normalise on everything drawn, not
+ # just the boxes, or the canvas clips its own edges.
+ xs = [lay.get_coords(tid[i]).x for i in g.nodes]
+ ys = [lay.get_coords(tid[i]).y for i in g.nodes]
+ xs += [x for _, wps in polys for x, _ in wps]
+ ys += [y for _, wps in polys for _, y in wps]
+ min_x, min_y = min(xs, default=0.0), min(ys, default=0.0)
+
+ def cell(x: float, y: float) -> tuple[int, int]:
+ return int(round(y - min_y)), int(round(x - min_x))
+
+ for nid in g.nodes:
+ n = g.nodes[nid]
+ p = lay.get_coords(tid[nid])
+ n.y, n.x = cell(p.x, p.y)
+
+ for e, wps in polys:
+ pts = _clean([cell(x, y) for x, y in wps])
+ if len(pts) < 2:
+ continue
+ _snap_ports(g, e, pts)
+ routes.append(
+ G.Route(edge=e, pts=_clean(pts), head=True, tail=True, flipped=False)
+ )
+
+
+def _box_index(g: G._Graph) -> tuple[dict[int, list[G.Node]], dict[int, list[G.Node]]]:
+ """(boxes strictly covering each column, boxes strictly covering each row).
+
+ "Strictly" because a cell ON the border is where ports, arrowheads and tees
+ legitimately live; only the interior is off limits.
+ """
+ by_col: dict[int, list[G.Node]] = {}
+ by_row: dict[int, list[G.Node]] = {}
+ for n in g.nodes.values():
+ if n.dummy:
+ continue
+ for c in range(n.x + 1, n.right):
+ by_col.setdefault(c, []).append(n)
+ for r in range(n.y + 1, n.bottom):
+ by_row.setdefault(r, []).append(n)
+ return by_col, by_row
+
+
+def _hits(by_col, by_row, p: tuple[int, int], q: tuple[int, int]) -> list[G.Node]:
+ """Boxes whose interior a straight segment from ``p`` to ``q`` runs into."""
+ (r0, c0), (r1, c1) = p, q
+ if c0 == c1:
+ lo, hi = (r0, r1) if r0 <= r1 else (r1, r0)
+ return [n for n in by_col.get(c0, ()) if n.y < hi and lo < n.bottom]
+ lo, hi = (c0, c1) if c0 <= c1 else (c1, c0)
+ return [n for n in by_row.get(r0, ()) if n.x < hi and lo < n.right]
+
+
+def _free_line(
+ blocked: list[tuple[int, int]], want: int, allow: tuple[int, int] | None = None
+) -> int | None:
+ """The coordinate nearest ``want`` that is in none of ``blocked``.
+
+ ``blocked`` is a list of inclusive intervals. Jumping to the near side of
+ the *first* box in the way is not enough in a dense layout -- that column is
+ very often inside the next box along -- so consider every box the run
+ passes and step out of each interval in turn.
+
+ ``allow`` constrains the result to an inclusive range, which is how a port
+ stays on its own box's border: everything outside becomes blocked.
+ """
+ if allow is not None:
+ lo, hi = allow
+ if lo > hi:
+ return None
+ blocked = list(blocked) + [(hi + 1, hi + 1 + 10**6)]
+ if lo > 0:
+ blocked.append((0, lo - 1))
+ if not blocked:
+ return want
+ merged: list[list[int]] = []
+ for lo, hi in sorted(blocked):
+ if merged and lo <= merged[-1][1] + 1:
+ merged[-1][1] = max(merged[-1][1], hi)
+ else:
+ merged.append([lo, hi])
+
+ def inside(v: int) -> list[int] | None:
+ for iv in merged:
+ if iv[0] <= v <= iv[1]:
+ return iv
+ return None
+
+ if inside(want) is None:
+ return want
+ low = want
+ while (iv := inside(low)) is not None:
+ low = iv[0] - 1
+ if low < 0:
+ low = None
+ break
+ high = want
+ while (iv := inside(high)) is not None:
+ high = iv[1] + 1
+ if low is None:
+ return high
+ return low if want - low <= high - want else high
+
+
+def _repair_boxes(g: G._Graph, routes: list[G.Route]) -> int:
+ """Detour any segment that runs through a box. Returns the number moved.
+
+ Triskel does not actually guarantee this. On the corpus one edge in 128
+ functions comes back drawn through a block (``sub_69C0``: a vertical at
+ x=235 crossing a box spanning x=222.5..236.5, in float space -- so it is the
+ library's own layout, not our rounding). Two cells is nothing in a PNG,
+ where the box is opaque and painted last. In a terminal the box is mostly
+ holes: the edge appears *inside* the disassembly text, and ``edge_at``
+ happily reports an edge under a cell the user reads as code.
+
+ ``docs/GRAPH_VIEW.md`` states that no edge ever crosses a box and
+ ``tests/test_graph.py`` counts it across the corpus, so rather than weaken
+ the claim we push the offending run out to the nearest side of the box it
+ hits. Only interior segments are moved -- the first and last carry the port
+ and the arrowhead, and those belong on the border.
+ """
+ by_col, by_row = _box_index(g)
+ real = [n for n in g.nodes.values() if not n.dummy]
+ moved = 0
+ for rt in routes:
+ # Moving one segment stretches the two beside it, which can push THEM
+ # into a box, so sweep until the route stops changing. Three passes is
+ # plenty in practice and bounds the work on a pathological route.
+ for _ in range(3):
+ dirty = False
+ last = len(rt.pts) - 2
+ for i in range(0, len(rt.pts) - 1):
+ p, q = rt.pts[i], rt.pts[i + 1]
+ if not _hits(by_col, by_row, p, q):
+ continue
+ if p[1] == q[1]: # vertical: shift column
+ lo, hi = sorted((p[0], q[0]))
+ blocked = [
+ (n.x + 1, n.right - 1)
+ for n in real
+ if n.y < hi and lo < n.bottom
+ ]
+ # The first and last segments carry the port and the
+ # arrowhead, so they may only move ALONG their own box's
+ # border -- but move they must: triskel is happy to park a
+ # block directly above its successor and drive the final
+ # approach straight through it. Another port on the same
+ # border is almost always free.
+ allow = None
+ if i == 0 or i == last:
+ ends = []
+ if i == 0:
+ ends.append(g.nodes[rt.edge.src])
+ if i == last:
+ ends.append(g.nodes[rt.edge.dst])
+ allow = (
+ max(n.x + 1 for n in ends),
+ min(n.right - 1 for n in ends),
+ )
+ col = _free_line(blocked, p[1], allow)
+ if col is None:
+ continue
+ rt.pts[i], rt.pts[i + 1] = (p[0], col), (q[0], col)
+ elif i not in (0, last): # horizontal: shift row
+ lo, hi = sorted((p[1], q[1]))
+ blocked = [
+ (n.y + 1, n.bottom - 1)
+ for n in real
+ if n.x < hi and lo < n.right
+ ]
+ row = _free_line(blocked, p[0])
+ if row is None:
+ continue
+ rt.pts[i], rt.pts[i + 1] = (row, p[1]), (row, q[1])
+ else:
+ continue
+ moved += 1
+ dirty = True
+ if not dirty:
+ break
+ return moved
+
+
+def _verify(g: G._Graph, routes: list[G.Route]) -> None:
+ """Raise if the drawing breaks an invariant, so ``layout()`` falls back.
+
+ The invariants are worth more than the engine: a layout with more crossings
+ beats one that draws edges through the code, or one block over another.
+
+ Overlapping boxes are triskel's, not ours -- it superimposes independently
+ laid out SESE regions, and on 2 of `ls`'s 400 functions two blocks end up a
+ couple of columns into each other in float space, before any rounding. In a
+ PNG that is a cosmetic nick on a border. Here the boxes are made of text, so
+ one block's disassembly overwrites another's.
+ """
+ rows: dict[int, list[G.Node]] = {}
+ for n in g.nodes.values():
+ if n.dummy:
+ continue
+ for r in range(n.y, n.y + n.h):
+ rows.setdefault(r, []).append(n)
+ for r, boxes in rows.items():
+ boxes.sort(key=lambda n: n.x)
+ for a, b in zip(boxes, boxes[1:]):
+ if b.x <= a.right:
+ raise RuntimeError(
+ f"blocks {a.id} and {b.id} overlap on row {r} "
+ f"(x[{a.x},{a.right}] vs x[{b.x},{b.right}])"
+ )
+
+ by_col, by_row = _box_index(g)
+ for rt in routes:
+ for p, q in zip(rt.pts, rt.pts[1:]):
+ hit = _hits(by_col, by_row, p, q)
+ if hit:
+ raise RuntimeError(
+ f"edge {rt.edge.src}->{rt.edge.dst} crosses block "
+ f"{hit[0].id} at {p}-{q} and could not be detoured"
+ )
+
+
+def _snap_ports(g: G._Graph, e: G.Edge, pts: list[tuple[int, int]]) -> None:
+ """Pull the polyline's ends onto the box borders, in place.
+
+ Triskel leaves a node at ``y + height`` -- the first row *below* the box,
+ because it thinks in half-open pixel rectangles while our boxes own rows
+ ``y .. y+h-1`` inclusive and draw a border on the last one. Landing the end
+ points on the border row is what lets the arrowhead and the port tee replace
+ a border character instead of floating one cell off it.
+ """
+ src, dst = g.nodes[e.src], g.nodes[e.dst]
+
+ def clamp(n: G.Node, col: int) -> int:
+ return max(n.x + 1, min(col, n.x + n.w - 2))
+
+ if len(pts) == 2:
+ # A straight drop between two boxes: one column has to satisfy both, or
+ # the "line" acquires a kink with no corner glyph to explain it.
+ col = clamp(dst, clamp(src, pts[0][1]))
+ down = pts[1][0] >= pts[0][0]
+ pts[0] = (src.bottom if down else src.y, col)
+ pts[1] = (dst.y if down else dst.bottom, col)
+ return
+
+ # tail: src's bottom border if the edge leaves downward, its top if not
+ old_r, old_c = pts[0]
+ col = clamp(src, old_c)
+ pts[0] = (src.bottom if pts[1][0] >= old_r else src.y, col)
+ if pts[1][1] == old_c: # the first segment was vertical: keep it
+ pts[1] = (pts[1][0], col)
+
+ # head: dst's top border if the edge arrives downward, its bottom if not
+ old_r, old_c = pts[-1]
+ col = clamp(dst, old_c)
+ pts[-1] = (dst.y if pts[-2][0] <= old_r else dst.bottom, col)
+ if pts[-2][1] == old_c:
+ pts[-2] = (pts[-2][0], col)
diff --git a/idatui/highlight.py b/idatui/highlight.py
index 0bd76af..3f22d30 100644
--- a/idatui/highlight.py
+++ b/idatui/highlight.py
@@ -15,10 +15,10 @@ The same tokenizer feeds two consumers, so one palette covers both:
from __future__ import annotations
-from rich.segment import Segment
-from rich.style import Style
from pygments.lexers import CLexer
from pygments.token import Token
+from rich.segment import Segment
+from rich.style import Style
from textual.widgets import TextArea
from textual.widgets.text_area import TextAreaTheme
@@ -36,18 +36,26 @@ from textual.widgets.text_area import TextAreaTheme
# mnemonic column: they're the skeleton you scan for, and a hue there would
# claim a meaning the rest of the palette already assigns.
_PALETTE: list[tuple[str, object, Style]] = [
- ("comment", Token.Comment, Style(color="#7c8b9e", italic=True)), # 5.2:1 commentary
- ("type", Token.Keyword.Type, Style(color="#93aee0")), # 8.1:1 type info
- ("keyword", Token.Keyword, Style(color="#e8ecf2", bold=True)), # 15.3:1 control flow
- ("builtin", Token.Name.Builtin, Style(color="#93aee0")), # 8.1:1 type info
- ("string", Token.Literal.String, Style(color="#9ece6a")), # 9.9:1 strings
- ("number", Token.Literal.Number, Style(color="#d8a657")), # 8.2:1 data/number
- ("operator", Token.Operator, Style(color="#c3cad3")), # 11.0:1 body
- ("punctuation", Token.Punctuation, Style(color="#626c7a")), # 3.4:1 structure
- ("name", Token.Name, Style(color="#7aa2f7")), # 7.2:1 symbol names
+ (
+ "comment",
+ Token.Comment,
+ Style(color="#7c8b9e", italic=True),
+ ), # 5.2:1 commentary
+ ("type", Token.Keyword.Type, Style(color="#93aee0")), # 8.1:1 type info
+ (
+ "keyword",
+ Token.Keyword,
+ Style(color="#e8ecf2", bold=True),
+ ), # 15.3:1 control flow
+ ("builtin", Token.Name.Builtin, Style(color="#93aee0")), # 8.1:1 type info
+ ("string", Token.Literal.String, Style(color="#9ece6a")), # 9.9:1 strings
+ ("number", Token.Literal.Number, Style(color="#d8a657")), # 8.2:1 data/number
+ ("operator", Token.Operator, Style(color="#c3cad3")), # 11.0:1 body
+ ("punctuation", Token.Punctuation, Style(color="#626c7a")), # 3.4:1 structure
+ ("name", Token.Name, Style(color="#7aa2f7")), # 7.2:1 symbol names
]
_STYLES: list[tuple[object, Style]] = [(t, s) for _, t, s in _PALETTE]
-_DEFAULT = Style(color="#c3cad3") # 11.0:1 body
+_DEFAULT = Style(color="#c3cad3") # 11.0:1 body
_DEFAULT_NAME = "text"
#: highlight name -> style, for TextArea themes (see ``CTextArea``).
@@ -97,7 +105,7 @@ def highlight_c(code: str) -> list[list[Segment]]:
if not value:
continue
style = _style_for(token)
- if "\n" not in value: # the common case: a token inside one line
+ if "\n" not in value: # the common case: a token inside one line
lines[-1].append(Segment(value, style))
continue
parts = value.split("\n")
@@ -136,7 +144,7 @@ def highlight_c_spans(code: str) -> dict[int, list[tuple[int, int, str]]]:
if not part:
continue
width = len(part) if part.isascii() else len(part.encode("utf-8"))
- if part.strip(): # whitespace carries no visible style
+ if part.strip(): # whitespace carries no visible style
spans.setdefault(row, []).append((col, col + width, name))
col += width
return spans
diff --git a/idatui/index.py b/idatui/index.py
index 751ad1f..3925ee8 100644
--- a/idatui/index.py
+++ b/idatui/index.py
@@ -21,6 +21,7 @@ three characters — it silently returns nothing rather than erroring — so sho
queries fall back to LIKE. Without that, typing "e" then "er" would show "no
matches" until the third keystroke.
"""
+
from __future__ import annotations
import os
@@ -81,7 +82,8 @@ class ProjectIndex:
def stamp(self, label: str) -> tuple[int, int, int] | None:
"""(size, mtime, entry count) recorded when ``label`` was last indexed."""
row = self._db.execute(
- "SELECT size, mtime, n FROM stamps WHERE binary = ?", (label,)).fetchone()
+ "SELECT size, mtime, n FROM stamps WHERE binary = ?", (label,)
+ ).fetchone()
return tuple(row) if row else None # type: ignore[return-value]
def is_stale(self, label: str, source: str) -> bool:
@@ -99,11 +101,11 @@ class ProjectIndex:
def reindex(self, label: str, entries, source: str | None = None) -> int:
"""Replace ``label``'s entries with ``entries`` — (kind, addr, text)
triples. Per-binary, so re-indexing one never touches the others."""
- rows = [(text, label, kind, int(addr))
- for kind, addr, text in entries if text]
+ rows = [(text, label, kind, int(addr)) for kind, addr, text in entries if text]
self._db.execute("DELETE FROM entries WHERE binary = ?", (label,))
self._db.executemany(
- "INSERT INTO entries(text, binary, kind, addr) VALUES(?,?,?,?)", rows)
+ "INSERT INTO entries(text, binary, kind, addr) VALUES(?,?,?,?)", rows
+ )
size = mtime = 0
if source:
try:
@@ -114,7 +116,8 @@ class ProjectIndex:
self._db.execute(
"INSERT INTO stamps(binary, size, mtime, n) VALUES(?,?,?,?) "
"ON CONFLICT(binary) DO UPDATE SET size=?, mtime=?, n=?",
- (label, size, mtime, len(rows), size, mtime, len(rows)))
+ (label, size, mtime, len(rows), size, mtime, len(rows)),
+ )
self._db.commit()
return len(rows)
@@ -125,8 +128,9 @@ class ProjectIndex:
self._db.commit()
# -- query -------------------------------------------------------------- #
- def search(self, query: str, kind: str | None = None,
- limit: int = 500) -> list[Hit]:
+ def search(
+ self, query: str, kind: str | None = None, limit: int = 500
+ ) -> list[Hit]:
"""Substring search across every indexed binary, newest-agnostic.
Uses the trigram index at >= 3 characters and falls back to a LIKE scan
@@ -189,12 +193,12 @@ class ProjectIndex:
# -- introspection ------------------------------------------------------ #
def counts(self) -> dict[str, int]:
"""Indexed entry count per binary."""
- return {b: n for b, n in
- self._db.execute("SELECT binary, n FROM stamps").fetchall()}
+ return {
+ b: n for b, n in self._db.execute("SELECT binary, n FROM stamps").fetchall()
+ }
def total(self) -> int:
- return int(self._db.execute(
- "SELECT count(*) FROM entries").fetchone()[0])
+ return int(self._db.execute("SELECT count(*) FROM entries").fetchone()[0])
def close(self) -> None:
try:
diff --git a/idatui/journal.py b/idatui/journal.py
index a5a07de..352a324 100644
--- a/idatui/journal.py
+++ b/idatui/journal.py
@@ -46,8 +46,13 @@ class Journal:
self._lock = threading.Lock()
# -- recording ---------------------------------------------------------- #
- def record(self, kind: str, ea: int | None = None, detail: str = "",
- extra: dict | None = None) -> None:
+ def record(
+ self,
+ kind: str,
+ ea: int | None = None,
+ detail: str = "",
+ extra: dict | None = None,
+ ) -> None:
"""Note one edit: ``kind`` is 'rename' / 'comment' / 'retype' / …"""
entry = {"k": str(kind), "t": int(time.time())}
if ea is not None:
@@ -59,14 +64,17 @@ class Journal:
with self._lock:
self.entries.append(entry)
if len(self.entries) > MAX_ENTRIES:
- del self.entries[:len(self.entries) - MAX_ENTRIES]
+ del self.entries[: len(self.entries) - MAX_ENTRIES]
self._dirty = True
def addresses(self, kinds: tuple[str, ...] | None = None) -> set[int]:
"""Every address touched (optionally only by certain kinds of edit)."""
with self._lock:
- return {e["ea"] for e in self.entries
- if "ea" in e and (kinds is None or e.get("k") in kinds)}
+ return {
+ e["ea"]
+ for e in self.entries
+ if "ea" in e and (kinds is None or e.get("k") in kinds)
+ }
def __len__(self) -> int:
return len(self.entries)
diff --git a/idatui/kittygfx.py b/idatui/kittygfx.py
index e53fba3..9f83656 100644
--- a/idatui/kittygfx.py
+++ b/idatui/kittygfx.py
@@ -32,6 +32,7 @@ screen cannot be placed from the alternate one -- placement reports no error, it
simply draws nothing. That combination is why the splash calls ``supported()``
from the launcher and ``upload()`` from its own ``on_mount``.
"""
+
from __future__ import annotations
import base64
@@ -45,9 +46,17 @@ import time
#: One id for the splash. Ids are a terminal-wide namespace shared with whatever
#: else the user is running, so this is deliberately not 1.
LOGO_ID = 0x1DA7
+#: Placement id for the splash. A placement is identified by the PAIR (image id,
+#: placement id): re-placing with the same pair REPLACES the placement, while a
+#: placement with no ``p`` key is anonymous and every one of those stacks a new
+#: copy on the screen. The splash re-anchors itself on every progress note, so
+#: without this the terminal ends a long load holding hundreds of placements of
+#: the same image at the same cell -- alpha-compositing the (RGBA) logo over
+#: itself until its soft edges go solid, and re-rendering all of them per frame.
+LOGO_PLACEMENT = 1
_supported: bool | None = None
-_uploaded: dict[int, tuple[int, int]] = {} # image id -> (pixel w, pixel h)
+_uploaded: dict[int, tuple[int, int]] = {} # image id -> (pixel w, pixel h)
#: Terminal cell size in pixels, asked for in the same round trip as the
#: graphics query. Cells are nothing like a fixed 1:2 -- this box reports 9x22,
#: i.e. 1:2.44 -- and getting it wrong stretches the image.
@@ -71,8 +80,16 @@ def log(msg: str) -> None:
# Detection
# --------------------------------------------------------------------------- #
def _query_tty(timeout: float = 2.0) -> bool:
- import termios
- import tty as ttymod
+ # ``termios`` and ``/dev/tty`` are POSIX-only. Native Windows terminals
+ # generally don't expose the synchronous reply channel this probe needs;
+ # use the ANSI-art splash there instead of making graphics fatal to the
+ # whole application. IDATUI_KITTY=1 still permits an explicit override.
+ try:
+ import termios
+ import tty as ttymod
+ except ImportError:
+ log("supported: tty queries are unavailable on this platform")
+ return False
try:
fd = os.open("/dev/tty", os.O_RDWR | os.O_NOCTTY)
@@ -98,10 +115,10 @@ def _query_tty(timeout: float = 2.0) -> bool:
if not chunk:
break
buf += chunk
- if re.search(rb"\033\[\?[0-9;]*c", buf): # DA1: the answers are in
+ if re.search(rb"\033\[\?[0-9;]*c", buf): # DA1: the answers are in
break
global _cell
- m = re.search(rb"\033\[6;(\d+);(\d+)t", buf) # CSI 6 ; height ; width t
+ m = re.search(rb"\033\[6;(\d+);(\d+)t", buf) # CSI 6 ; height ; width t
if m:
ch, cw = int(m.group(1)), int(m.group(2))
if 0 < cw < 100 and 0 < ch < 200:
@@ -133,10 +150,14 @@ def supported() -> bool:
elif env in ("0", "no", "false", "off"):
_supported = False
elif not (sys.__stdout__ and sys.__stdout__.isatty()):
- _supported = False # pilot tests, pipes, redirected output
+ _supported = False # pilot tests, pipes, redirected output
log("supported: stdout is not a tty")
else:
- _supported = _query_tty()
+ try:
+ _supported = _query_tty()
+ except Exception as exc: # graphics are optional on every platform
+ log(f"supported: terminal query failed ({type(exc).__name__}: {exc})")
+ _supported = False
log(f"supported() -> {_supported}")
return _supported
@@ -188,14 +209,13 @@ def upload(path: str, image_id: int = LOGO_ID) -> bool:
payload = base64.standard_b64encode(f.read())
except OSError:
return False
- parts = [payload[i:i + 4096] for i in range(0, len(payload), 4096)]
+ parts = [payload[i : i + 4096] for i in range(0, len(payload), 4096)]
if not parts:
return False
buf = []
for i, part in enumerate(parts):
more = 1 if i < len(parts) - 1 else 0
- ctrl = (f"a=t,f=100,t=d,i={image_id},q=2,m={more}" if i == 0
- else f"m={more}")
+ ctrl = f"a=t,f=100,t=d,i={image_id},q=2,m={more}" if i == 0 else f"m={more}"
buf.append("\033_G" + ctrl + ";" + part.decode("ascii") + "\033\\")
if not _write("".join(buf)):
log("upload: write failed")
@@ -209,23 +229,35 @@ def is_uploaded(image_id: int = LOGO_ID) -> bool:
return image_id in _uploaded
-def place(row: int, col: int, cols: int, rows: int,
- image_id: int = LOGO_ID) -> bool:
+def place(
+ row: int,
+ col: int,
+ cols: int,
+ rows: int,
+ image_id: int = LOGO_ID,
+ placement_id: int = LOGO_PLACEMENT,
+) -> bool:
"""Draw the uploaded image at (``row``, ``col``), 0-based, sized in cells.
Saves and restores the cursor, and asks the terminal not to move it
(``C=1``), so Textual's idea of where the cursor is stays true.
+
+ Always carries a placement id (``p``), so calling this again REPLACES the
+ previous placement instead of adding another one underneath it -- see
+ ``LOGO_PLACEMENT``. Callers re-anchor freely; the screen holds exactly one.
"""
size = _uploaded.get(image_id)
if size is None or cols <= 0 or rows <= 0:
log(f"place: refused size={size} cols={cols} rows={rows}")
return False
w, h = size
- log(f"place row={row} col={col} c={cols} r={rows}")
+ log(f"place row={row} col={col} c={cols} r={rows} p={placement_id}")
return _write(
f"\033[s\033[{row + 1};{col + 1}H"
- f"\033_Ga=p,i={image_id},s={w},v={h},c={cols},r={rows},C=1,q=2\033\\"
- f"\033[u")
+ f"\033_Ga=p,i={image_id},p={placement_id},"
+ f"s={w},v={h},c={cols},r={rows},C=1,q=2\033\\"
+ f"\033[u"
+ )
def clear(image_id: int = LOGO_ID) -> None:
@@ -249,8 +281,12 @@ def cell_size() -> tuple[int, int]:
return _cell or (10, 20)
-def fit(px: tuple[int, int], max_cols: int, max_rows: int,
- cell: tuple[int, int] | None = None) -> tuple[int, int]:
+def fit(
+ px: tuple[int, int],
+ max_cols: int,
+ max_rows: int,
+ cell: tuple[int, int] | None = None,
+) -> tuple[int, int]:
"""Cell size that fits ``max_cols`` x ``max_rows`` keeping the aspect ratio.
Cells are far from square -- this box reports 9x22 px -- so a naive
diff --git a/idatui/launch.py b/idatui/launch.py
index 0c53987..6e3b432 100644
--- a/idatui/launch.py
+++ b/idatui/launch.py
@@ -1,6 +1,6 @@
-"""One-shot launcher for the IDA Code Mode-backed TUI.
+"""One-shot launcher for the IDA Nexus-backed TUI.
-A path first resolves to a registered GUI database; when none matches, Code Mode
+A path first resolves to a registered GUI database; when none matches, IDA Nexus
reuses or starts a managed idalib worker. With no path, a single registered
database is selected automatically.
@@ -9,12 +9,14 @@ Usage::
ida-tui /path/to/binary
ida-tui # attach when exactly one database is registered
"""
+
from __future__ import annotations
import argparse
import os
import sys
+
def _load_args(load: dict) -> str:
"""``load`` as IDA switches, for the single-binary path (no project ref).
@@ -25,8 +27,12 @@ def _load_args(load: dict) -> str:
"""
from .formats import load_args
from .project import _as_addr
- return load_args(load.get("processor", ""), _as_addr(load.get("base", 0)),
- str(load.get("ida_args", "") or ""))
+
+ return load_args(
+ load.get("processor", ""),
+ _as_addr(load.get("base", 0)),
+ str(load.get("ida_args", "") or ""),
+ )
def _log(msg: str) -> None:
@@ -34,10 +40,27 @@ def _log(msg: str) -> None:
def _registered_databases() -> tuple[list[dict], list[dict]]:
- """Ready and blocked Code Mode registrations, with normalized errors."""
+ """Ready and blocked IDA Nexus registrations, with normalized errors."""
try:
- from ida_codemode.registry import discover_instances
- return discover_instances()
+ from ida_nexus import InstanceState, discover_databases
+
+ ready: list[dict] = []
+ blocked: list[dict] = []
+ for discovered in discover_databases():
+ instance = discovered.instance
+ item = {
+ "record_id": instance.record_id,
+ "backend": instance.backend,
+ "pid": instance.pid,
+ "exe_path": instance.exe_path,
+ "idb_path": instance.idb_path,
+ }
+ if discovered.state is InstanceState.READY:
+ ready.append(item)
+ else:
+ item["error"] = discovered.detail or "instance is unavailable"
+ blocked.append(item)
+ return ready, blocked
except Exception as exc: # discovery diagnostics belong at the CLI boundary
return [], [{"error": str(exc)}]
@@ -45,32 +68,63 @@ def _registered_databases() -> tuple[list[dict], list[dict]]:
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(
prog="ida-tui",
- description="Open a registered GUI or managed idalib database in the IDA TUI.")
- p.add_argument("binary", nargs="*",
- help="binary to open and analyze (several with --project "
- "creates/extends that project)")
- p.add_argument("--project", metavar="FILE",
- help="open a multi-binary project (created from the given "
- "binaries if FILE doesn't exist)")
- p.add_argument("--ttl", type=int, default=1800,
- help="deprecated compatibility option (Code Mode uses leases)")
- p.add_argument("--no-keepalive", action="store_true",
- help="deprecated compatibility option (the lease is the heartbeat)")
- p.add_argument("--rpc", metavar="PATH",
- help="listen for RPC on this unix socket (puppeteer the TUI)")
- p.add_argument("--trace", metavar="FILE",
- help="Tenet execution trace to explore alongside the binary")
+ description="Open a registered GUI or managed idalib database in the IDA TUI.",
+ )
+ p.add_argument(
+ "binary",
+ nargs="*",
+ help="binary to open and analyze (several with --project "
+ "creates/extends that project)",
+ )
+ p.add_argument(
+ "--project",
+ metavar="FILE",
+ help="open a multi-binary project (created from the given "
+ "binaries if FILE doesn't exist)",
+ )
+ p.add_argument(
+ "--ttl",
+ type=int,
+ default=1800,
+ help="deprecated compatibility option (IDA Nexus uses leases)",
+ )
+ p.add_argument(
+ "--no-keepalive",
+ action="store_true",
+ help="deprecated compatibility option (the lease is the heartbeat)",
+ )
+ p.add_argument(
+ "--rpc",
+ metavar="PATH",
+ help="listen for RPC on this unix socket (puppeteer the TUI)",
+ )
+ p.add_argument(
+ "--trace",
+ metavar="FILE",
+ help="Tenet execution trace to explore alongside the binary",
+ )
g = p.add_argument_group(
"loading a headerless blob",
"An ELF/PE/Mach-O says what it is. A raw firmware dump doesn't, and IDA "
"falls back to x86 at address 0 — which analyses to nothing. These say "
- "how to read it, and are recorded per binary in a project.")
- g.add_argument("--processor", metavar="NAME",
- help="IDA processor: arm, armb (big-endian), mipsb, metapc, …")
- g.add_argument("--base", metavar="ADDR",
- help="load address, e.g. 0x8000000 (any base; NOT paragraphs)")
- g.add_argument("--ida-args", metavar="STR", dest="ida_args",
- help="legacy switches; only Code Mode-representable -p/-b/-T are accepted")
+ "how to read it, and are recorded per binary in a project.",
+ )
+ g.add_argument(
+ "--processor",
+ metavar="NAME",
+ help="IDA processor: arm, armb (big-endian), mipsb, metapc, …",
+ )
+ g.add_argument(
+ "--base",
+ metavar="ADDR",
+ help="load address, e.g. 0x8000000 (any base; NOT paragraphs)",
+ )
+ g.add_argument(
+ "--ida-args",
+ metavar="STR",
+ dest="ida_args",
+ help="legacy switches; only IDA Nexus-representable -p/-b/-T are accepted",
+ )
args = p.parse_args(argv)
load: dict = {}
@@ -95,6 +149,7 @@ def main(argv: list[str] | None = None) -> int:
binary = None
if args.project:
from .project import Project, ProjectError
+
ppath = os.path.abspath(os.path.expanduser(args.project))
try:
if os.path.isfile(ppath):
@@ -109,8 +164,10 @@ def main(argv: list[str] | None = None) -> int:
project.save()
_log(f"added {added} binary(ies) to {ppath}")
if dupes:
- _log(f"{dupes} already in the project (matched by path) "
- f"— left alone")
+ _log(
+ f"{dupes} already in the project (matched by path) "
+ f"— left alone"
+ )
elif args.binary:
project = Project.create(ppath, args.binary, load=load or None)
_log(f"created project {ppath} with {len(project.refs)} binaries")
@@ -137,7 +194,8 @@ def main(argv: list[str] | None = None) -> int:
key = os.path.normcase(os.path.realpath(binary))
registered = any(
key == os.path.normcase(os.path.realpath(str(item.get(field) or "")))
- for item in ready for field in ("exe_path", "idb_path")
+ for item in ready
+ for field in ("exe_path", "idb_path")
if item.get(field)
)
if not os.path.isfile(binary) and not registered:
@@ -149,13 +207,15 @@ def main(argv: list[str] | None = None) -> int:
_log(f"attaching to registered {item.get('backend')} database: {binary}")
elif not ready:
detail = f" ({blocked[0].get('error')})" if blocked else ""
- _log(f"no registered Code Mode database; pass a binary path{detail}")
+ _log(f"no registered IDA Nexus database; pass a binary path{detail}")
return 2
else:
- _log("several Code Mode databases are registered; pass one of these paths:")
+ _log("several IDA Nexus databases are registered; pass one of these paths:")
for item in ready:
- _log(f" {item.get('exe_path') or item.get('idb_path')} "
- f"[{item.get('backend')}, {item.get('record_id')}]")
+ _log(
+ f" {item.get('exe_path') or item.get('idb_path')} "
+ f"[{item.get('backend')}, {item.get('record_id')}]"
+ )
return 2
# Hand off to the TUI (imported late so --help works without Textual). Code
@@ -173,16 +233,23 @@ def main(argv: list[str] | None = None) -> int:
# round trip, and only when attached to a tty.
try:
from . import kittygfx
+
kittygfx.supported()
except Exception: # noqa: BLE001 -- graphics are decoration, never fatal
pass
rpc_path = os.path.abspath(os.path.expanduser(args.rpc)) if args.rpc else None
- IdaTui(open_path=binary, keepalive=not args.no_keepalive,
- rpc_path=rpc_path, ttl=args.ttl, project=project,
- load_args=_load_args(load),
- trace_path=(os.path.abspath(os.path.expanduser(args.trace))
- if args.trace else "")).run()
+ IdaTui(
+ open_path=binary,
+ keepalive=not args.no_keepalive,
+ rpc_path=rpc_path,
+ ttl=args.ttl,
+ project=project,
+ load_args=_load_args(load),
+ trace_path=(
+ os.path.abspath(os.path.expanduser(args.trace)) if args.trace else ""
+ ),
+ ).run()
return 0
diff --git a/idatui/nexus_client.py b/idatui/nexus_client.py
new file mode 100644
index 0000000..44eb1b1
--- /dev/null
+++ b/idatui/nexus_client.py
@@ -0,0 +1,551 @@
+"""Client adapter from ida-tui's domain operations to IDA Nexus.
+
+``DatabaseHandle`` is the lifecycle boundary: it discovers an already-registered
+GUI database, reuses a shared managed idalib worker, or starts one when needed.
+The TUI never owns or terminates an IDA process. Closing this client releases
+only its lease.
+
+Remote operations are ordinary typed Python functions declared in
+``idatui.remote_ops``. IDA Nexus installs their content-addressed modules once
+per IDA Python interpreter; subsequent calls send only encoded arguments. The
+optimized IDAPython listing/decompiler implementation remains real source in
+``idatui.remote_tools`` and is installed through the same module interface.
+"""
+
+from __future__ import annotations
+
+import os
+import shlex
+import threading
+import time
+from collections.abc import Callable
+from typing import Any
+
+from .errors import IDAConnectionError, IDATimeoutError, IDAToolError, Session
+
+# ida_nexus is imported EAGERLY-IF-PRESENT but never at hard import cost.
+#
+# The paging/graph/trace layers and their offline test suites must keep importing
+# `idatui` on a machine with no IDA and no IDA Nexus installed -- that is the
+# house rule the stdlib-only worker client used to satisfy for free, and
+# `tests/run.py --fast` (380 checks, any python3) depends on it. A hard top-level
+# import here makes the whole package unimportable, so the failure is deferred to
+# the first operation that genuinely needs the library.
+_NEXUS_ERROR: Exception | None = None
+try:
+ from ida_nexus import (
+ DatabaseBusyError,
+ DatabaseDisconnectedError,
+ DatabaseHandle,
+ DatabaseInstance,
+ DatabaseOpenOptions,
+ NexusConnectionError,
+ RemoteError,
+ find_database_owner,
+ wait_database_released,
+ )
+except ImportError as _exc: # library absent: usable only for offline layers
+ _NEXUS_ERROR = _exc
+ # Bound to None rather than left undefined so the names stay patchable: the
+ # offline contract tests inject a fake DatabaseHandle here.
+ NexusConnectionError = DatabaseDisconnectedError = RemoteError = None # type: ignore[assignment,misc]
+ DatabaseBusyError = DatabaseHandle = DatabaseInstance = None # type: ignore[assignment,misc]
+ DatabaseOpenOptions = find_database_owner = wait_database_released = None # type: ignore[assignment]
+
+
+def _require_nexus() -> None:
+ """Raise an actionable error when the IDA Nexus library is missing.
+
+ Gated on the binding, not on the original import result, so a test that
+ injects a fake ``DatabaseHandle`` exercises the real adapter logic.
+ """
+ if DatabaseHandle is None:
+ raise IDAConnectionError(
+ "ida-nexus is not installed in this environment "
+ f"({_NEXUS_ERROR}). Install it (e.g. `uv sync`, or "
+ "`pip install ida-nexus`) so ida-tui can lease a "
+ "database."
+ ) from _NEXUS_ERROR
+
+
+def database_owner(idb_path: str, staged_path: str | None = None):
+ """The IDA Nexus instance that owns ``idb_path``/``staged_path``, else None.
+
+ Returns None when the IDA Nexus library is absent: with no library there is
+ no client in this environment that could be holding the database, and the
+ IDA-free layers (project staging) must keep working. Discovery errors with
+ the library installed still propagate because unknown ownership is unsafe.
+ """
+ if DatabaseHandle is None:
+ return None
+ if staged_path:
+ owner = find_database_owner(
+ staged_path,
+ output_database=idb_path,
+ timeout=0.5,
+ )
+ return owner or find_database_owner(staged_path, timeout=0.5)
+ return find_database_owner(idb_path, timeout=0.5)
+
+
+def registered_database(path: str, output_database: str | None = None) -> bool:
+ """Whether a live/lock-held IDA Nexus instance owns this target."""
+ _require_nexus()
+ return (
+ find_database_owner(
+ path,
+ output_database=output_database,
+ timeout=0.5,
+ )
+ is not None
+ )
+
+
+class _NoopKeepAlive:
+ """Compatibility shim: the DatabaseHandle's SSE lease is the heartbeat."""
+
+ def __init__(self) -> None:
+ self.beats = self.failures = 0
+
+ def start(self) -> "_NoopKeepAlive":
+ return self
+
+ def stop(self) -> None:
+ pass
+
+
+def _parse_load_args(value: str) -> tuple[str | None, int | None, str | None]:
+ """Translate ida-tui's legacy first-open switches to IDA Nexus options.
+
+ IDA Nexus has typed options for processor, natural loading address and file
+ type. It deliberately has no arbitrary command-line escape hatch; reject
+ switches we cannot represent instead of silently loading a blob wrongly.
+ """
+ processor: str | None = None
+ loading_address: int | None = None
+ file_type: str | None = None
+ unsupported: list[str] = []
+ try:
+ words = shlex.split(value or "", posix=os.name != "nt")
+ except ValueError as exc:
+ raise ValueError(f"invalid IDA load options: {exc}") from exc
+ for word in words:
+ if word.startswith("-p") and len(word) > 2:
+ processor = word[2:]
+ elif word.startswith("-b") and len(word) > 2:
+ try:
+ # IDA's -b is in 16-byte paragraphs. DatabaseHandle expects the
+ # natural address, which is the safer public API.
+ loading_address = int(word[2:], 16) << 4
+ except ValueError as exc:
+ raise ValueError(f"invalid IDA loading address: {word!r}") from exc
+ elif word.startswith("-T") and len(word) > 2:
+ file_type = word[2:]
+ else:
+ unsupported.append(word)
+ if unsupported:
+ joined = " ".join(unsupported)
+ raise ValueError(
+ "ida-nexus cannot represent arbitrary IDA load options: "
+ f"{joined!r}; use processor/base/file type options instead"
+ )
+ return processor, loading_address, file_type
+
+
+class IDBEventListener:
+ """Debounced, closeable delivery of another client's IDB changes.
+
+ IDA Nexus's subscription is a blocking iterator, so one daemon thread reads
+ it and a second waits for a quiet period before handing a batch to the UI.
+ Keeping the debounce here avoids a permanent Textual worker (which would
+ make the app's worker-idle contract impossible) and bounds refresh work to
+ one pass per edit burst.
+ """
+
+ def __init__(
+ self,
+ client: "NexusClient",
+ callback: Callable[[tuple[dict[str, Any], ...]], None],
+ *,
+ on_error: Callable[[BaseException], None] | None = None,
+ debounce: float = 0.2,
+ ) -> None:
+ self._client = client
+ self._callback = callback
+ self._on_error = on_error
+ self._debounce = max(float(debounce), 0.0)
+ self._condition = threading.Condition()
+ self._closed = False
+ self._subscription = None
+ self._pending: list[dict[str, Any]] = []
+ self._deadline = 0.0
+ self._reader = threading.Thread(
+ target=self._read, name="idatui-idb-events", daemon=True
+ )
+ self._deliverer = threading.Thread(
+ target=self._deliver, name="idatui-idb-refresh", daemon=True
+ )
+ self._deliverer.start()
+ self._reader.start()
+
+ def _report(self, error: BaseException) -> None:
+ disconnected = DatabaseDisconnectedError
+ if isinstance(disconnected, type) and isinstance(error, disconnected):
+ error = self._client._connection_error(error)
+ with self._condition:
+ closed = self._closed
+ if not closed and self._on_error is not None:
+ self._on_error(error)
+
+ def _read(self) -> None:
+ try:
+ subscription = self._client.subscribe_idb_events()
+ except Exception as exc: # noqa: BLE001 -- surfaced through on_error
+ self._report(exc)
+ with self._condition:
+ self._closed = True
+ self._pending.clear()
+ self._condition.notify_all()
+ return
+ with self._condition:
+ if self._closed:
+ subscription.close()
+ return
+ self._subscription = subscription
+ try:
+ for event in subscription:
+ with self._condition:
+ if self._closed:
+ break
+ if self._client.owns_event(event):
+ continue
+ with self._condition:
+ if self._closed:
+ break
+ self._pending.append(event)
+ self._deadline = time.monotonic() + self._debounce
+ self._condition.notify_all()
+ except Exception as exc: # noqa: BLE001 -- stream failures are recoverable
+ self._report(exc)
+ finally:
+ subscription.close()
+ with self._condition:
+ if self._subscription is subscription:
+ self._subscription = None
+ self._closed = True
+ self._pending.clear()
+ self._condition.notify_all()
+
+ def _deliver(self) -> None:
+ while True:
+ with self._condition:
+ while not self._closed and not self._pending:
+ self._condition.wait()
+ if self._closed:
+ return
+ remaining = self._deadline - time.monotonic()
+ if remaining > 0:
+ self._condition.wait(remaining)
+ continue
+ batch = tuple(self._pending)
+ self._pending.clear()
+ try:
+ self._callback(batch)
+ except Exception as exc: # noqa: BLE001 -- keep the stream alive
+ self._report(exc)
+
+ def close(self) -> None:
+ """Stop delivery and unblock the subscription reader."""
+ with self._condition:
+ if self._closed:
+ return
+ self._closed = True
+ self._pending.clear()
+ subscription = self._subscription
+ self._condition.notify_all()
+ if subscription is not None:
+ subscription.close()
+
+
+class NexusClient:
+ """A leased GUI/idalib database accessed through ``ida_nexus``."""
+
+ def __init__(
+ self,
+ binary_path: str,
+ *,
+ ttl: int = 0,
+ load_args: str = "",
+ processor: str | None = None,
+ loading_address: int | None = None,
+ file_type: str | None = None,
+ output_database: str | None = None,
+ spawn: bool = True,
+ new_database: bool = False,
+ ) -> None:
+ del ttl # managed-worker lifetime is lease-based, not idle-TTL based
+ self._path = os.path.abspath(os.path.expanduser(binary_path))
+ parsed_processor, parsed_address, parsed_file_type = _parse_load_args(load_args)
+ self._processor = processor or parsed_processor
+ self._loading_address = (
+ loading_address if loading_address is not None else parsed_address
+ )
+ self._file_type = file_type or parsed_file_type
+ self._output_database = output_database
+ self._spawn = spawn
+ self._new_database = new_database
+ self._handle: DatabaseHandle | None = None
+ self._last_instance: DatabaseInstance | None = None
+ self._connect_lock = threading.Lock()
+
+ def connect(self, timeout: float = 1800.0, progress=None) -> "NexusClient":
+ _require_nexus()
+ with self._connect_lock:
+ handle = self._handle
+ if handle is not None:
+ if handle.connected:
+ return self
+ raise IDAConnectionError(
+ "IDA Nexus database disconnected; explicit rediscovery required"
+ )
+ if progress:
+ progress(
+ f"discovering IDA Nexus database for {os.path.basename(self._path)}…"
+ )
+ try:
+ # A Ctrl+L reload releases its current managed-worker lease, but
+ # that worker remains registered during IDA Nexus's final-lease
+ # grace period. Retry only that known handoff window. A GUI or
+ # another long-lived client remains busy and yields a clear
+ # failure rather than being modified underneath its owner.
+ deadline = time.monotonic() + min(timeout, 60.0)
+ while True:
+ try:
+ handle = DatabaseHandle.open(
+ self._path,
+ options=DatabaseOpenOptions(
+ spawn=self._spawn,
+ startup_timeout=max(0.1, timeout),
+ output_database=self._output_database,
+ processor=self._processor,
+ # The natural byte address is converted to IDA's
+ # paragraph-based -b value by IDA Nexus.
+ image_base=self._loading_address,
+ file_type=self._file_type,
+ new_database=self._new_database,
+ ),
+ )
+ break
+ except DatabaseBusyError:
+ if not self._new_database or time.monotonic() >= deadline:
+ raise
+ if progress:
+ progress(
+ "waiting for the previous IDA Nexus lease to close…"
+ )
+ owner = find_database_owner(
+ self._path,
+ output_database=self._output_database,
+ timeout=0.5,
+ )
+ if owner is not None:
+ wait_database_released(
+ owner,
+ max(0.0, deadline - time.monotonic()),
+ )
+ else:
+ time.sleep(0.2)
+ if progress:
+ backend = handle.instance.backend
+ progress(
+ f"attached to {backend} database; waiting for auto-analysis…"
+ )
+ handle.wait_autoanalysis(timeout=timeout)
+ except Exception as exc: # normalize the dependency's transport errors
+ raise self._connection_error(exc) from exc
+ self._handle = handle
+ self._last_instance = handle.instance
+ return self
+
+ @staticmethod
+ def _connection_error(exc: BaseException) -> IDAConnectionError:
+ return IDAConnectionError(str(exc) or type(exc).__name__)
+
+ @property
+ def connected(self) -> bool:
+ return self._handle is not None and self._handle.connected
+
+ @property
+ def pid(self) -> int | None:
+ return self._handle.instance.pid if self._handle is not None else None
+
+ @property
+ def backend(self) -> str | None:
+ return self._handle.instance.backend if self._handle is not None else None
+
+ def owns_event(self, event: dict[str, Any]) -> bool:
+ """Whether ``event`` was produced through this client's handle."""
+ handle = self._handle
+ return handle is not None and handle.owns_event(event)
+
+ def subscribe_idb_events(self):
+ """Open IDA Nexus's closeable IDB-change iterator."""
+ if not self.connected:
+ self.connect()
+ handle = self._handle
+ if handle is None:
+ raise IDAConnectionError("IDA Nexus database is not connected")
+ try:
+ return handle.subscribe_idb_events()
+ except (DatabaseDisconnectedError, NexusConnectionError) as exc:
+ raise self._connection_error(exc) from exc
+
+ def watch_idb_events(
+ self,
+ callback: Callable[[tuple[dict[str, Any], ...]], None],
+ *,
+ on_error: Callable[[BaseException], None] | None = None,
+ debounce: float = 0.2,
+ ) -> IDBEventListener:
+ """Deliver external IDB changes in debounced batches."""
+ return IDBEventListener(self, callback, on_error=on_error, debounce=debounce)
+
+ def call(self, operation: Callable[..., Any], /, **args) -> Any:
+ """Execute one source-backed remote declaration through this client."""
+ name = getattr(operation, "__name__", "remote operation")
+ try:
+ from .remote_ops import bind
+
+ remote = bind(operation)
+ except KeyError as exc:
+ raise IDAToolError(
+ name, f"remote operation {name!r} is not registered"
+ ) from exc
+ if not self.connected:
+ self.connect()
+ handle = self._handle
+ if handle is None:
+ raise IDAConnectionError("IDA Nexus database is not connected")
+ try:
+ return remote(handle, **args)
+ except RemoteError as exc:
+ message = str(exc)
+ if exc.details.get("traceback"):
+ message += f"\n{exc.details['traceback']}"
+ if exc.code == "operation_timeout":
+ raise IDATimeoutError(message) from exc
+ raise IDAToolError(name, message) from exc
+ except (DatabaseDisconnectedError, NexusConnectionError) as exc:
+ raise self._connection_error(exc) from exc
+
+ def save_database(self) -> dict[str, Any]:
+ if not self.connected:
+ self.connect()
+ handle = self._handle
+ if handle is None:
+ raise IDAConnectionError("IDA Nexus database is not connected")
+ try:
+ return handle.save_database()
+ except RemoteError as exc:
+ raise IDAToolError("save_database", str(exc)) from exc
+ except (DatabaseDisconnectedError, NexusConnectionError) as exc:
+ raise self._connection_error(exc) from exc
+
+ def discard_database(self, timeout: float = 5.0) -> bool:
+ """Discard a final managed-worker lease; otherwise transfer finalization.
+
+ ``False`` is an expected ownership result: a GUI owns its session, or
+ another lease still shares the managed worker. A busy final worker is
+ retried briefly so background reads finishing during quit do not turn a
+ real discard into an implicit save.
+ """
+ handle = self._handle
+ if handle is None or not handle.connected:
+ return False
+ entry = handle.instance
+ if entry.backend != "idalib" or not getattr(entry, "managed", False):
+ return False
+ deadline = time.monotonic() + max(float(timeout), 0.0)
+ while True:
+ try:
+ handle.shutdown_database(save=False)
+ return True
+ except RemoteError as exc:
+ if exc.code in ("instance_shared", "shutdown_not_supported"):
+ return False
+ if exc.code == "instance_busy" and time.monotonic() < deadline:
+ time.sleep(0.05)
+ continue
+ raise IDAToolError("shutdown_database", str(exc)) from exc
+ except (DatabaseDisconnectedError, NexusConnectionError) as exc:
+ raise self._connection_error(exc) from exc
+
+ def health(self) -> dict[str, Any]:
+ if not self.connected:
+ self.connect()
+ assert self._handle is not None
+ entry = self._handle.instance
+ module = os.path.basename(entry.exe_path or entry.idb_path or self._path)
+ return {
+ "ok": self._handle.connected,
+ "module": module,
+ "backend": entry.backend,
+ "record_id": entry.record_id,
+ "input_path": entry.exe_path,
+ "idb_path": entry.idb_path,
+ }
+
+ def keepalive(self, interval: float = 120.0) -> _NoopKeepAlive:
+ del interval
+ return _NoopKeepAlive()
+
+ def resolve_db(self) -> str:
+ if not self.connected:
+ self.connect()
+ assert self._handle is not None
+ return self._handle.instance.record_id
+
+ def set_db(self, db: str | None) -> None:
+ del db # one handle is permanently bound to one registered database
+
+ def list_sessions(self) -> list[Session]:
+ if not self.connected:
+ self.connect()
+ assert self._handle is not None
+ entry = self._handle.instance
+ path = entry.exe_path or entry.idb_path or self._path
+ return [
+ Session(
+ session_id=entry.record_id,
+ filename=os.path.basename(path),
+ input_path=path,
+ is_active=True,
+ )
+ ]
+
+ def close(self, grace: float = 0.0) -> None:
+ del grace
+ with self._connect_lock:
+ handle, self._handle = self._handle, None
+ if handle is not None:
+ self._last_instance = handle.instance
+ handle.close() # release our lease; never close a GUI/other client's DB
+
+ def wait_released(self, timeout: float = 45.0) -> bool:
+ """Wait until a managed instance releases its lifetime lock.
+
+ Normal application shutdown must not wait: another client may retain the
+ worker. This is an explicit test/maintenance helper for deleting a
+ temporary IDB safely after this client closes. GUI instances return
+ ``False`` immediately because clients never own their lifetime.
+ """
+ instance = self._last_instance
+ if instance is None or instance.backend != "idalib":
+ return False
+ return wait_database_released(instance, timeout)
+
+ def __enter__(self) -> "NexusClient":
+ return self.connect()
+
+ def __exit__(self, *exc) -> None:
+ self.close()
diff --git a/idatui/pane.py b/idatui/pane.py
index c31a93c..67b462b 100644
--- a/idatui/pane.py
+++ b/idatui/pane.py
@@ -24,9 +24,10 @@ per pane in the registry, so stop/list/capture/keys keep working across both
python -m idatui.pane keys --pane <pane> Escape
Requires: running inside tmux or zellij. Each pane leases a registered GUI or
-shared managed idalib database through Code Mode. Uses ~/ida-venv/bin/python for
+shared managed idalib database through IDA Nexus. Uses ~/ida-venv/bin/python for
the TUI (needs textual) unless --python / IDATUI_PYTHON says otherwise.
"""
+
from __future__ import annotations
import argparse
@@ -42,7 +43,8 @@ from .rpcclient import RpcClient, RpcError
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DEFAULT_PY = os.environ.get(
- "IDATUI_PYTHON", os.path.expanduser("~/ida-venv/bin/python"))
+ "IDATUI_PYTHON", os.path.expanduser("~/ida-venv/bin/python")
+)
def _sockdir() -> str:
@@ -106,26 +108,31 @@ def _mux_of_pane(pane: str) -> str:
def _zellij_argv() -> list[str]:
"""Base zellij argv, pinned to our session when we know it (so it still works
from a process that isn't itself attached)."""
- session = (os.environ.get("IDATUI_ZELLIJ_SESSION")
- or os.environ.get("ZELLIJ_SESSION_NAME"))
+ session = os.environ.get("IDATUI_ZELLIJ_SESSION") or os.environ.get(
+ "ZELLIJ_SESSION_NAME"
+ )
return ["zellij", "-s", session] if session else ["zellij"]
def _tmux(*args: str) -> str:
- return subprocess.run(["tmux", *args], capture_output=True, text=True,
- check=True).stdout.strip()
+ return subprocess.run(
+ ["tmux", *args], capture_output=True, text=True, check=True
+ ).stdout.strip()
def _zellij(*args: str) -> str:
- return subprocess.run([*_zellij_argv(), *args], capture_output=True,
- text=True, check=True).stdout.strip()
+ return subprocess.run(
+ [*_zellij_argv(), *args], capture_output=True, text=True, check=True
+ ).stdout.strip()
def _zellij_panes() -> list[dict[str, Any]]:
try:
- out = subprocess.run([*_zellij_argv(), "action", "list-panes",
- "--state", "--json"],
- capture_output=True, text=True)
+ out = subprocess.run(
+ [*_zellij_argv(), "action", "list-panes", "--state", "--json"],
+ capture_output=True,
+ text=True,
+ )
rows = json.loads(out.stdout or "[]")
except (OSError, ValueError):
return []
@@ -148,8 +155,9 @@ def _pane_alive(pane: str, mux: str | None = None) -> bool:
if str(row.get("id")) == want and bool(row.get("is_plugin")) is False:
return not row.get("exited", False)
return False
- out = subprocess.run(["tmux", "list-panes", "-a", "-F", "#{pane_id}"],
- capture_output=True, text=True)
+ out = subprocess.run(
+ ["tmux", "list-panes", "-a", "-F", "#{pane_id}"], capture_output=True, text=True
+ )
return pane in out.stdout.split()
@@ -159,8 +167,9 @@ def _pane_exists(pane: str, mux: str | None = None) -> bool:
return False
if (mux or _mux_of_pane(pane)) == "zellij":
want = pane.split("_", 1)[-1]
- return any(str(r.get("id")) == want and not r.get("is_plugin")
- for r in _zellij_panes())
+ return any(
+ str(r.get("id")) == want and not r.get("is_plugin") for r in _zellij_panes()
+ )
return _pane_alive(pane, "tmux")
@@ -169,24 +178,36 @@ def _pane_kill(pane: str, mux: str | None = None) -> None:
if not pane:
return
if (mux or _mux_of_pane(pane)) == "zellij":
- subprocess.run([*_zellij_argv(), "action", "close-pane",
- "--pane-id", pane], capture_output=True)
+ subprocess.run(
+ [*_zellij_argv(), "action", "close-pane", "--pane-id", pane],
+ capture_output=True,
+ )
else:
subprocess.run(["tmux", "kill-pane", "-t", pane], capture_output=True)
-def _pane_split(inner: list[str], *, mux: str, vertical: bool,
- size: str | None, detached: bool) -> str:
+def _pane_split(
+ inner: list[str], *, mux: str, vertical: bool, size: str | None, detached: bool
+) -> str:
"""Open a pane running ``inner`` (argv) in REPO, and return its pane id."""
if mux == "zellij":
# zellij runs the argv directly (no shell) and takes the cwd as a flag,
# so there's nothing to quote. --name labels the pane in the UI.
- argv = [*_zellij_argv(), "action", "new-pane",
- "--direction", "down" if vertical else "right",
- "--cwd", REPO, "--name", "idatui"]
+ argv = [
+ *_zellij_argv(),
+ "action",
+ "new-pane",
+ "--direction",
+ "down" if vertical else "right",
+ "--cwd",
+ REPO,
+ "--name",
+ "idatui",
+ ]
argv += ["--", *inner]
- pane = subprocess.run(argv, capture_output=True, text=True,
- check=True).stdout.strip()
+ pane = subprocess.run(
+ argv, capture_output=True, text=True, check=True
+ ).stdout.strip()
# zellij prints the new pane id ('terminal_3'); without it we could not
# target this pane later, so treat a missing id as a hard failure.
if not pane.startswith(("terminal_", "plugin_")):
@@ -196,13 +217,14 @@ def _pane_split(inner: list[str], *, mux: str, vertical: bool,
# to the pane we were called from.
origin = os.environ.get("ZELLIJ_PANE_ID")
if origin:
- subprocess.run([*_zellij_argv(), "action", "focus-pane-id",
- f"terminal_{origin}"], capture_output=True)
+ subprocess.run(
+ [*_zellij_argv(), "action", "focus-pane-id", f"terminal_{origin}"],
+ capture_output=True,
+ )
return pane
cmd = f"cd {REPO!r} && exec " + " ".join(_q(a) for a in inner)
- split = ["split-window", "-v" if vertical else "-h",
- "-P", "-F", "#{pane_id}"]
+ split = ["split-window", "-v" if vertical else "-h", "-P", "-F", "#{pane_id}"]
if size:
split += ["-l", str(size)]
if detached:
@@ -224,9 +246,15 @@ def _pane_capture(pane: str, mux: str | None = None) -> str:
# tmux key names -> zellij key names (zellij rejects e.g. "Escape", wants "Esc").
_ZELLIJ_KEYS = {
- "escape": "Esc", "bspace": "Backspace", "space": "Space",
- "pageup": "PageUp", "pagedown": "PageDown", "ppage": "PageUp",
- "npage": "PageDown", "ic": "Insert", "dc": "Delete",
+ "escape": "Esc",
+ "bspace": "Backspace",
+ "space": "Space",
+ "pageup": "PageUp",
+ "pagedown": "PageDown",
+ "ppage": "PageUp",
+ "npage": "PageDown",
+ "ic": "Insert",
+ "dc": "Delete",
}
@@ -244,24 +272,34 @@ def _pane_keys(pane: str, keys: list[str], mux: str | None = None) -> None:
"""Inject real terminal keystrokes into the pane (the input-layer cross-check)."""
mux = mux or _mux_of_pane(pane)
if mux == "zellij":
- subprocess.run([*_zellij_argv(), "action", "send-keys", "--pane-id", pane,
- *[_to_zellij_key(k) for k in keys]], check=True)
+ subprocess.run(
+ [
+ *_zellij_argv(),
+ "action",
+ "send-keys",
+ "--pane-id",
+ pane,
+ *[_to_zellij_key(k) for k in keys],
+ ],
+ check=True,
+ )
else:
subprocess.run(["tmux", "send-keys", "-t", pane, *keys], check=True)
-# Code Mode owns database process lifetime: a closed pane drops its lease at the
-# socket/kernel boundary and Code Mode decides whether a managed worker still
+# IDA Nexus owns database process lifetime: a closed pane drops its lease at the
+# socket/kernel boundary and IDA Nexus decides whether a managed worker still
# has clients. There is nothing for the pane layer to reap.
def _count_live_panes() -> int:
- return sum(1 for r in _load_registry()
- if _pane_alive(r.get("pane", ""), r.get("mux")))
+ return sum(
+ 1 for r in _load_registry() if _pane_alive(r.get("pane", ""), r.get("mux"))
+ )
def _reap_orphan_workers(force: bool = False) -> int:
- """Compatibility no-op: Code Mode workers are shared and lease-managed."""
+ """Compatibility no-op: IDA Nexus workers are shared and lease-managed."""
del force
return 0
@@ -272,20 +310,28 @@ def _reap_orphan_workers(force: bool = False) -> int:
def spawn(args) -> int:
mux = args.mux or _detect_mux()
if mux.startswith("?"):
- print(f"error: unknown multiplexer {mux[1:]!r} (want tmux or zellij)",
- file=sys.stderr)
+ print(
+ f"error: unknown multiplexer {mux[1:]!r} (want tmux or zellij)",
+ file=sys.stderr,
+ )
return 2
if not mux:
- print("error: not inside tmux or zellij (spawn creates a pane there). "
- "Set $IDATUI_MUX=tmux|zellij to force a backend.", file=sys.stderr)
+ print(
+ "error: not inside tmux or zellij (spawn creates a pane there). "
+ "Set $IDATUI_MUX=tmux|zellij to force a backend.",
+ file=sys.stderr,
+ )
return 2
if not args.open and not getattr(args, "project", None):
print("error: pass --open <binary> or --project <file>", file=sys.stderr)
return 2
sock = args.sock or os.path.join(_sockdir(), f"idatui-{secrets.token_hex(3)}.sock")
- project = (os.path.abspath(os.path.expanduser(args.project))
- if getattr(args, "project", None) else None)
+ project = (
+ os.path.abspath(os.path.expanduser(args.project))
+ if getattr(args, "project", None)
+ else None
+ )
target = os.path.abspath(os.path.expanduser(args.open)) if args.open else None
if target is not None and not os.path.exists(target):
print(f"error: no such binary: {target}", file=sys.stderr)
@@ -294,7 +340,7 @@ def spawn(args) -> int:
print(f"error: no such project: {project}", file=sys.stderr)
return 2
- # The pane owns only the TUI. Code Mode's lease cleanup handles crashes;
+ # The pane owns only the TUI. IDA Nexus's lease cleanup handles crashes;
# kill-pane must never reap a shared GUI/idalib database.
if project is not None:
# launch takes: --project FILE [binaries...]; extra binaries are added to
@@ -317,17 +363,30 @@ def spawn(args) -> int:
inner += ["--trace", os.path.abspath(os.path.expanduser(args.trace))]
if args.size and mux == "zellij":
- print("note: --size is tmux-only; zellij tiles the new pane evenly",
- file=sys.stderr)
+ print(
+ "note: --size is tmux-only; zellij tiles the new pane evenly",
+ file=sys.stderr,
+ )
try:
- pane = _pane_split(inner, mux=mux, vertical=args.vertical,
- size=args.size, detached=args.detached)
+ pane = _pane_split(
+ inner,
+ mux=mux,
+ vertical=args.vertical,
+ size=args.size,
+ detached=args.detached,
+ )
except (OSError, subprocess.CalledProcessError, RuntimeError) as e:
print(f"error: could not create a {mux} pane: {e}", file=sys.stderr)
return 2
- row = {"sock": sock, "pane": pane, "mux": mux, "target": project or target,
- "kind": "project" if project else "open", "started": time.time()}
+ row = {
+ "sock": sock,
+ "pane": pane,
+ "mux": mux,
+ "target": project or target,
+ "kind": "project" if project else "open",
+ "started": time.time(),
+ }
reg = [r for r in _load_registry() if r.get("sock") != sock]
reg.append(row)
_save_registry(reg)
@@ -340,14 +399,20 @@ def spawn(args) -> int:
def _q(s: str) -> str:
import shlex
+
return shlex.quote(s)
-def _wait_ready(sock: str, timeout: float, pane: str,
- stuck_after: float = 45.0, mux: str | None = None) -> dict[str, Any]:
+def _wait_ready(
+ sock: str,
+ timeout: float,
+ pane: str,
+ stuck_after: float = 45.0,
+ mux: str | None = None,
+) -> dict[str, Any]:
"""Poll the socket + ping until the TUI reports ready (or timeout).
- Emits a one-time hint if Code Mode discovery/opening is still not ready after
+ Emits a one-time hint if IDA Nexus discovery/opening is still not ready after
``stuck_after`` seconds.
"""
start = time.time()
@@ -367,10 +432,16 @@ def _wait_ready(sock: str, timeout: float, pane: str,
pass
if not warned and (time.time() - start) > stuck_after:
warned = True
- why = ("RPC socket not created yet" if not os.path.exists(sock)
- else "TUI up but analysis not ready")
- print(f"still waiting ({int(time.time() - start)}s): {why}. "
- f"Check Code Mode registrations and worker logs.", file=sys.stderr)
+ why = (
+ "RPC socket not created yet"
+ if not os.path.exists(sock)
+ else "TUI up but analysis not ready"
+ )
+ print(
+ f"still waiting ({int(time.time() - start)}s): {why}. "
+ f"Check IDA Nexus registrations and worker logs.",
+ file=sys.stderr,
+ )
time.sleep(0.4)
last = dict(last)
last["ready"] = False
@@ -383,9 +454,12 @@ def _wait_ready(sock: str, timeout: float, pane: str,
# --------------------------------------------------------------------------- #
def stop(args) -> int:
reg = _load_registry()
- rows = [r for r in reg
- if (args.sock and r.get("sock") == args.sock)
- or (args.pane and r.get("pane") == args.pane)]
+ rows = [
+ r
+ for r in reg
+ if (args.sock and r.get("sock") == args.sock)
+ or (args.pane and r.get("pane") == args.pane)
+ ]
if not rows and args.sock: # allow stopping an untracked socket
rows = [{"sock": args.sock, "pane": args.pane}]
if not rows:
@@ -432,8 +506,10 @@ def stop(args) -> int:
# Only ever reached on timeout: say so, because it means a save may have
# been cut short rather than "clean teardown".
out["force_killed"] = killed
- out["warning"] = (f"pane(s) did not exit within {args.timeout}s and were "
- "killed; unsaved database changes may be lost")
+ out["warning"] = (
+ f"pane(s) did not exit within {args.timeout}s and were "
+ "killed; unsaved database changes may be lost"
+ )
print(json.dumps(out))
return 0
@@ -467,9 +543,17 @@ def list_panes(args) -> int:
def reap(args) -> int:
- """Deprecated no-op; shared Code Mode workers are managed by leases."""
- print(json.dumps({"reaped_workers": 0, "live_panes": _count_live_panes(),
- "forced": args.force, "deprecated": True}))
+ """Deprecated no-op; shared IDA Nexus workers are managed by leases."""
+ print(
+ json.dumps(
+ {
+ "reaped_workers": 0,
+ "live_panes": _count_live_panes(),
+ "forced": args.force,
+ "deprecated": True,
+ }
+ )
+ )
return 0
@@ -520,59 +604,100 @@ def _resolve_pane(sock: str | None) -> str | None:
else:
print("error: several live panes, pass --pane or --sock:", file=sys.stderr)
for r in live:
- print(f" {r.get('pane')} {r.get('sock')} {r.get('target')}",
- file=sys.stderr)
+ print(
+ f" {r.get('pane')} {r.get('sock')} {r.get('target')}",
+ file=sys.stderr,
+ )
return None
def main(argv: list[str]) -> int:
p = argparse.ArgumentParser(
prog="idatui.pane",
- description="spawn/manage idatui TUI panes in tmux or zellij")
+ description="spawn/manage idatui TUI panes in tmux or zellij",
+ )
sub = p.add_subparsers(dest="cmd", required=True)
sp = sub.add_parser("spawn", help="open a TUI pane and wait until ready")
- sp.add_argument("--open", metavar="PATH",
- help="binary to open (its dir must be writable)")
- sp.add_argument("--trace", metavar="FILE",
- help="Tenet execution trace to load alongside the binary")
- sp.add_argument("--project", metavar="FILE",
- help="project file to open instead of a single binary; "
- "any --open paths are added to it (created if absent)")
- sp.add_argument("--processor", metavar="NAME",
- help="IDA processor for a headerless blob: arm, armb, "
- "mipsb, metapc, … (passed to idatui.launch)")
- sp.add_argument("--base", metavar="ADDR",
- help="load address for a headerless blob, e.g. 0x8000000 "
- "(16-byte aligned)")
- sp.add_argument("--ida-args", metavar="STR", dest="ida_args",
- help="extra IDA command-line switches, passed through")
- sp.add_argument("--sock", help="RPC socket path (default: auto in $XDG_RUNTIME_DIR)")
- sp.add_argument("--python", default=DEFAULT_PY, help=f"python for the TUI ({DEFAULT_PY})")
- sp.add_argument("--vertical", action="store_true", help="split vertically (stacked)")
- sp.add_argument("--size", help="new pane size (tmux -l value, e.g. 60%% or 120; "
- "ignored under zellij)")
+ sp.add_argument(
+ "--open", metavar="PATH", help="binary to open (its dir must be writable)"
+ )
+ sp.add_argument(
+ "--trace",
+ metavar="FILE",
+ help="Tenet execution trace to load alongside the binary",
+ )
+ sp.add_argument(
+ "--project",
+ metavar="FILE",
+ help="project file to open instead of a single binary; "
+ "any --open paths are added to it (created if absent)",
+ )
+ sp.add_argument(
+ "--processor",
+ metavar="NAME",
+ help="IDA processor for a headerless blob: arm, armb, "
+ "mipsb, metapc, … (passed to idatui.launch)",
+ )
+ sp.add_argument(
+ "--base",
+ metavar="ADDR",
+ help="load address for a headerless blob, e.g. 0x8000000 (16-byte aligned)",
+ )
+ sp.add_argument(
+ "--ida-args",
+ metavar="STR",
+ dest="ida_args",
+ help="extra IDA command-line switches, passed through",
+ )
+ sp.add_argument(
+ "--sock", help="RPC socket path (default: auto in $XDG_RUNTIME_DIR)"
+ )
+ sp.add_argument(
+ "--python", default=DEFAULT_PY, help=f"python for the TUI ({DEFAULT_PY})"
+ )
+ sp.add_argument(
+ "--vertical", action="store_true", help="split vertically (stacked)"
+ )
+ sp.add_argument(
+ "--size",
+ help="new pane size (tmux -l value, e.g. 60%% or 120; ignored under zellij)",
+ )
sp.add_argument("--detached", action="store_true", help="don't focus the new pane")
- sp.add_argument("--mux", choices=MUXES, default="",
- help="multiplexer to spawn in (default: autodetect from "
- "$ZELLIJ/$TMUX; $IDATUI_MUX overrides)")
- sp.add_argument("--timeout", type=float, default=300.0,
- help="seconds to wait for readiness (fresh --open analysis is slow)")
+ sp.add_argument(
+ "--mux",
+ choices=MUXES,
+ default="",
+ help="multiplexer to spawn in (default: autodetect from "
+ "$ZELLIJ/$TMUX; $IDATUI_MUX overrides)",
+ )
+ sp.add_argument(
+ "--timeout",
+ type=float,
+ default=300.0,
+ help="seconds to wait for readiness (fresh --open analysis is slow)",
+ )
sp.set_defaults(fn=spawn)
st = sub.add_parser("stop", help="graceful quit + kill the pane")
st.add_argument("--sock")
st.add_argument("--pane")
- st.add_argument("--timeout", type=float, default=600.0,
- help="seconds to wait for the pane to exit (it saves dirty "
- "databases on the way out) before force-killing it")
+ st.add_argument(
+ "--timeout",
+ type=float,
+ default=600.0,
+ help="seconds to wait for the pane to exit (it saves dirty "
+ "databases on the way out) before force-killing it",
+ )
st.set_defaults(fn=stop)
ls = sub.add_parser("list", help="list tracked panes")
- ls.add_argument("--prune", action="store_true", help="drop dead panes (and their sockets)")
+ ls.add_argument(
+ "--prune", action="store_true", help="drop dead panes (and their sockets)"
+ )
ls.set_defaults(fn=list_panes)
- rp = sub.add_parser("reap", help="deprecated no-op (Code Mode uses shared leases)")
+ rp = sub.add_parser("reap", help="deprecated no-op (IDA Nexus uses shared leases)")
rp.add_argument("--force", action="store_true", help=argparse.SUPPRESS)
rp.set_defaults(fn=reap)
@@ -582,8 +707,11 @@ def main(argv: list[str]) -> int:
cp.add_argument("--mux", choices=MUXES, default="")
cp.set_defaults(fn=capture)
- kp = sub.add_parser("keys", help="inject real keystrokes into a pane "
- "(tmux-style names, translated per mux)")
+ kp = sub.add_parser(
+ "keys",
+ help="inject real keystrokes into a pane "
+ "(tmux-style names, translated per mux)",
+ )
kp.add_argument("keys", nargs="+", help="e.g. Escape, Enter, C-a, g m a i n")
kp.add_argument("--pane")
kp.add_argument("--sock", help="resolve the pane from this socket")
diff --git a/idatui/pool.py b/idatui/pool.py
index 465dff2..084d01a 100644
--- a/idatui/pool.py
+++ b/idatui/pool.py
@@ -1,6 +1,6 @@
-"""DatabasePool — LRU leases on Code Mode databases for a project.
+"""DatabasePool — LRU leases on IDA Nexus databases for a project.
-Code Mode may bind a lease to an existing IDA GUI or to a shared managed idalib
+IDA Nexus may bind a lease to an existing IDA GUI or to a shared managed idalib
worker. The pool therefore owns *client interest*, never an IDA process. Releasing
an LRU entry persists managed IDBs but does not implicitly save a GUI, then closes
only this TUI's lease; other clients and GUI sessions remain alive. Managed workers exit themselves after their final lease.
@@ -9,6 +9,7 @@ The historical memory budget remains useful for managed idalib instances, while
GUI process memory is only advisory. The active and pinned databases are never
released to satisfy it.
"""
+
from __future__ import annotations
from .project import BinaryRef, Project
@@ -47,9 +48,12 @@ def _pss_mb(pid: int | None) -> int:
return 0
-def _default_spawn(ref: BinaryRef, ttl: int, *, new_database: bool = False): # pragma: no cover - needs IDA
- from .codemode_client import CodeModeClient
- return CodeModeClient(
+def _default_spawn(
+ ref: BinaryRef, ttl: int, *, new_database: bool = False
+): # pragma: no cover - needs IDA
+ from .nexus_client import NexusClient
+
+ return NexusClient(
ref.staged,
ttl=ttl,
load_args=ref.load_args,
@@ -59,24 +63,33 @@ def _default_spawn(ref: BinaryRef, ttl: int, *, new_database: bool = False): #
class DatabasePool:
- """Live Code Mode database leases, keyed by project label."""
+ """Live IDA Nexus database leases, keyed by project label."""
- def __init__(self, project: Project, *, budget_mb: int | None = None,
- ttl: int = 1800, spawn=None, mem_fn=None) -> None:
+ def __init__(
+ self,
+ project: Project,
+ *,
+ budget_mb: int | None = None,
+ ttl: int = 1800,
+ spawn=None,
+ mem_fn=None,
+ ) -> None:
self.project = project
self._ttl = ttl
self._spawn = spawn or _default_spawn
self._mem = mem_fn or (lambda c: _pss_mb(getattr(c, "pid", None)))
self._clients: dict[str, object] = {}
- self._lru: list[str] = [] # least-recently-used first
+ self._lru: list[str] = [] # least-recently-used first
self._pinned: set[str] = set()
self._recreate: set[str] = set() # Ctrl+L: next attachment creates a fresh IDB
self.active: str | None = None # never evicted
if budget_mb is None:
ram = _total_ram_mb()
- budget_mb = (ram * project.memory_pct // 100) if ram else _FALLBACK_BUDGET_MB
+ budget_mb = (
+ (ram * project.memory_pct // 100) if ram else _FALLBACK_BUDGET_MB
+ )
self.budget_mb = max(budget_mb, 256)
- self.evicted: list[str] = [] # labels evicted, most recent last
+ self.evicted: list[str] = [] # labels evicted, most recent last
# -- residency --------------------------------------------------------- #
def resident(self) -> list[str]:
@@ -101,7 +114,7 @@ class DatabasePool:
"""A live client for ``label``, attaching or spawning as needed.
Do not sweep IDA scratch files here: a registered GUI or another Code
- Mode client may own the database. Code Mode's registry locks and health
+ Mode client may own the database. IDA Nexus's registry locks and health
probes are the authority for safe discovery and stale-record cleanup.
"""
client = self._clients.get(label)
@@ -120,8 +133,11 @@ class DatabasePool:
self.project.stage(ref)
note(f"opening {ref.label}\u2026")
fresh = label in self._recreate
- client = (_default_spawn(ref, self._ttl, new_database=fresh)
- if self._spawn is _default_spawn else self._spawn(ref, self._ttl))
+ client = (
+ _default_spawn(ref, self._ttl, new_database=fresh)
+ if self._spawn is _default_spawn
+ else self._spawn(ref, self._ttl)
+ )
connect = getattr(client, "connect", None)
if connect is not None:
connect(progress=progress) if progress is not None else connect()
@@ -178,8 +194,7 @@ class DatabasePool:
self._touch(label)
# -- release ----------------------------------------------------------- #
- def evict(self, label: str, save: bool = True,
- save_gui: bool = False) -> bool:
+ def evict(self, label: str, save: bool = True, save_gui: bool = False) -> bool:
"""Release a resident lease, persisting a managed database first.
A budget-driven eviction must not save somebody's GUI implicitly. GUI
@@ -225,27 +240,49 @@ class DatabasePool:
self.evict(label, save=save, save_gui=save)
self.active = None
+ def discard_changes(self, labels: list[str]) -> list[str]:
+ """Discard final managed sessions; return labels whose owner remains.
+
+ A returned label is not an error: its client is attached to a GUI or a
+ still-shared worker, so releasing our lease transfers finalization to
+ that session's owner or remaining clients.
+ """
+ transferred: list[str] = []
+ for label in labels:
+ client = self._clients.get(label)
+ if client is not None and not client.discard_database():
+ transferred.append(label)
+ return transferred
+
+ def replace_client(self, label: str, old, new) -> bool:
+ """Replace one disconnected lease without changing residency policy."""
+ if self._clients.get(label) is not old:
+ return False
+ self._clients[label] = new
+ self._touch(label)
+ return True
+
# -- introspection ------------------------------------------------------ #
def status(self) -> list[dict]:
"""Per-binary residency for the switcher UI."""
out = []
for ref in self.project.refs:
client = self._clients.get(ref.label)
- out.append({
- "label": ref.label,
- "source": ref.source,
- "resident": client is not None,
- "pinned": ref.label in self._pinned,
- "active": ref.label == self.active,
- "analysed": self.project.has_db(ref),
- "memory_mb": self._mem(client) if client is not None else 0,
- })
+ out.append(
+ {
+ "label": ref.label,
+ "source": ref.source,
+ "resident": client is not None,
+ "pinned": ref.label in self._pinned,
+ "active": ref.label == self.active,
+ "analysed": self.project.has_db(ref),
+ "memory_mb": self._mem(client) if client is not None else 0,
+ }
+ )
return out
def __repr__(self) -> str: # pragma: no cover - debug aid
- return (f"<DatabasePool {len(self._clients)}/{len(self.project.refs)} resident "
- f"{self.memory_mb()}/{self.budget_mb}MB active={self.active}>")
-
-
-# Source compatibility for callers that imported the pre-Code-Mode name.
-WorkerPool = DatabasePool
+ return (
+ f"<DatabasePool {len(self._clients)}/{len(self.project.refs)} resident "
+ f"{self.memory_mb()}/{self.budget_mb}MB active={self.active}>"
+ )
diff --git a/idatui/project.py b/idatui/project.py
index 53f3b0a..eaf45b4 100644
--- a/idatui/project.py
+++ b/idatui/project.py
@@ -23,9 +23,10 @@ firmware image, a cleaned build tree).
A source whose size/mtime no longer matches the staged copy is re-staged, and its
now-stale database is dropped (the DB describes the old bytes).
-The model has no IDA imports. Staging consults ida_codemode's registry before
+The model has no IDA imports. Staging consults ida_nexus's registry before
replacing files so it never mutates a database owned by a GUI/shared worker.
"""
+
from __future__ import annotations
import json
@@ -50,14 +51,14 @@ class ProjectError(Exception):
class BinaryRef:
"""One binary in a project: where it came from, and where IDA works on it."""
- label: str # unique within the project; names the staged file
- source: str # absolute path to the original binary
- staged: str # absolute path IDA actually opens (inside the sidecar)
+ label: str # unique within the project; names the staged file
+ source: str # absolute path to the original binary
+ staged: str # absolute path IDA actually opens (inside the sidecar)
#: How to LOAD it. Only meaningful for a headerless blob: an ELF/PE says what
#: it is, a raw firmware image doesn't, and IDA defaults to metapc at 0.
- processor: str = "" # IDA processor name: arm, armb, mipsb, metapc, …
- base: int = 0 # load address (natural, e.g. 0x8000000)
- ida_args: str = "" # legacy -p/-b/-T switches accepted by Code Mode adapter
+ processor: str = "" # IDA processor name: arm, armb, mipsb, metapc, …
+ base: int = 0 # load address (natural, e.g. 0x8000000)
+ ida_args: str = "" # legacy -p/-b/-T switches accepted by IDA Nexus adapter
@property
def db(self) -> str:
@@ -73,6 +74,7 @@ class BinaryRef:
conversion lives in ``formats.load_args``.
"""
from .formats import load_args
+
return load_args(self.processor, self.base, self.ida_args)
@@ -112,12 +114,17 @@ def _unlink(path: str) -> bool:
class Project:
"""A set of binaries analysed together, with all IDA artifacts corralled."""
- def __init__(self, path: str, name: str, entries: list[dict],
- memory_pct: int = DEFAULT_MEMORY_PCT) -> None:
+ def __init__(
+ self,
+ path: str,
+ name: str,
+ entries: list[dict],
+ memory_pct: int = DEFAULT_MEMORY_PCT,
+ ) -> None:
self.path = os.path.abspath(os.path.expanduser(path))
self.name = name
self.memory_pct = memory_pct
- self._entries = entries # raw, as written to the file
+ self._entries = entries # raw, as written to the file
self._refs = self._build_refs()
# -- construction ------------------------------------------------------ #
@@ -145,9 +152,13 @@ class Project:
# Keep every recognised key: a whitelist of path/label silently
# dropped the load options on the first save, so a blob's processor
# and base vanished the moment the project was reopened.
- norm.append({k: e[k] for k in
- ("path", "label", "processor", "base", "ida_args")
- if e.get(k) not in (None, "")})
+ norm.append(
+ {
+ k: e[k]
+ for k in ("path", "label", "processor", "base", "ida_args")
+ if e.get(k) not in (None, "")
+ }
+ )
name = raw.get("name") or os.path.splitext(os.path.basename(path))[0]
try:
pct = int(raw.get("memory_pct", DEFAULT_MEMORY_PCT))
@@ -156,8 +167,14 @@ class Project:
return cls(path, str(name), norm, max(1, min(pct, 90)))
@classmethod
- def create(cls, path: str, binaries: list[str], name: str | None = None,
- memory_pct: int = DEFAULT_MEMORY_PCT, load: dict | None = None) -> "Project":
+ def create(
+ cls,
+ path: str,
+ binaries: list[str],
+ name: str | None = None,
+ memory_pct: int = DEFAULT_MEMORY_PCT,
+ load: dict | None = None,
+ ) -> "Project":
"""Write a new project file listing ``binaries`` (an ad-hoc project).
``load`` carries per-binary load options (processor/base/ida_args) that
@@ -177,14 +194,21 @@ class Project:
e.update({k: v for k, v in (load or {}).items() if v})
entries.append(e)
path = os.path.abspath(os.path.expanduser(path))
- proj = cls(path, name or os.path.splitext(os.path.basename(path))[0],
- entries, memory_pct)
+ proj = cls(
+ path,
+ name or os.path.splitext(os.path.basename(path))[0],
+ entries,
+ memory_pct,
+ )
proj.save()
return proj
def save(self) -> None:
- data = {"name": self.name, "memory_pct": self.memory_pct,
- "binaries": self._entries}
+ data = {
+ "name": self.name,
+ "memory_pct": self.memory_pct,
+ "binaries": self._entries,
+ }
tmp = self.path + ".tmp"
os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True)
with open(tmp, "w") as f:
@@ -228,20 +252,25 @@ class Project:
n += 1
label = f"{label}_{n}"
used.add(label)
- refs.append(BinaryRef(
- label=label, source=src,
- staged=os.path.join(self.bin_dir, label),
- processor=str(e.get("processor") or ""),
- base=_as_addr(e.get("base")),
- ida_args=str(e.get("ida_args") or "")))
+ refs.append(
+ BinaryRef(
+ label=label,
+ source=src,
+ staged=os.path.join(self.bin_dir, label),
+ processor=str(e.get("processor") or ""),
+ base=_as_addr(e.get("base")),
+ ida_args=str(e.get("ida_args") or ""),
+ )
+ )
return tuple(refs)
@property
def refs(self) -> tuple[BinaryRef, ...]:
return self._refs
- def set_load(self, label: str, processor: str = "", base: int = 0,
- ida_args: str = "") -> BinaryRef | None:
+ def set_load(
+ self, label: str, processor: str = "", base: int = 0, ida_args: str = ""
+ ) -> BinaryRef | None:
"""Record how ``label`` should be loaded, and persist it.
Answered once: the dialog that asks writes the answer here, so reopening
@@ -275,11 +304,11 @@ class Project:
``./a.elf``, ``/abs/a.elf`` and a symlink to it are all the same file.
"""
key = os.path.realpath(os.path.abspath(os.path.expanduser(binary)))
- return next((r for r in self._refs
- if os.path.realpath(r.source) == key), None)
+ return next((r for r in self._refs if os.path.realpath(r.source) == key), None)
- def add(self, binary: str, label: str | None = None,
- load: dict | None = None) -> BinaryRef:
+ def add(
+ self, binary: str, label: str | None = None, load: dict | None = None
+ ) -> BinaryRef:
"""Add a binary, or return the existing entry if it's already here."""
existing = self.by_source(binary)
if existing is not None:
@@ -311,7 +340,7 @@ class Project:
"""Ensure ``ref`` is staged in the sidecar; returns the staged path.
Re-staging a changed source drops its database: the DB describes the old
- bytes. Refuse while Code Mode reports a GUI/idalib owner; replacing a
+ bytes. Refuse while IDA Nexus reports a GUI/idalib owner; replacing a
staged executable or IDB underneath a shared live instance is corruption.
"""
if not os.path.isfile(ref.source):
@@ -319,15 +348,16 @@ class Project:
if not self.is_stale(ref):
return ref.staged
try:
- from .codemode_client import database_owner
+ from .nexus_client import database_owner
+
owner = database_owner(ref.db, ref.staged)
except Exception as exc:
raise ProjectError(
- f"cannot verify Code Mode ownership before staging {ref.label}: {exc}"
+ f"cannot verify IDA Nexus ownership before staging {ref.label}: {exc}"
) from exc
if owner is not None:
raise ProjectError(
- f"cannot restage {ref.label}: Code Mode instance {owner.record_id} "
+ f"cannot restage {ref.label}: IDA Nexus instance {owner.record_id} "
f"still owns {owner.idb_path}; close/release it first"
)
os.makedirs(self.bin_dir, exist_ok=True)
@@ -337,7 +367,7 @@ class Project:
# leaving the staged bytes immune to an in-place rewrite of the source.
shutil.copy2(ref.source, tmp)
os.replace(tmp, ref.staged)
- for suf in DB_SUFFIXES: # the old DB describes the old bytes
+ for suf in DB_SUFFIXES: # the old DB describes the old bytes
_unlink(ref.staged + suf)
return ref.staged
@@ -353,7 +383,7 @@ class Project:
def sweep_scratch(self, ref: BinaryRef) -> int:
"""Delete unpacked working files (never the ``.i64``) for maintenance.
- Runtime paths no longer call this: Code Mode instances are shared, so a
+ Runtime paths no longer call this: IDA Nexus instances are shared, so a
registry owner may still be using these files. Callers must independently
prove that no GUI/idalib instance owns the database.
"""
diff --git a/idatui/prompt.py b/idatui/prompt.py
index 74956f6..6a46c75 100644
--- a/idatui/prompt.py
+++ b/idatui/prompt.py
@@ -16,13 +16,14 @@ Note the `can_focus` toggling: a hidden `Input` that stays focusable still takes
part in Tab focus-nav, so tabbing around a closed prompt used to land the cursor
in an invisible widget and swallow every subsequent keystroke.
"""
+
from __future__ import annotations
from typing import TYPE_CHECKING
from textual.widgets import Input, Static
-if TYPE_CHECKING: # pragma: no cover
+if TYPE_CHECKING: # pragma: no cover
from textual.app import App
diff --git a/idatui/remote_ops.py b/idatui/remote_ops.py
new file mode 100644
index 0000000..8105f81
--- /dev/null
+++ b/idatui/remote_ops.py
@@ -0,0 +1,1742 @@
+"""Typed remote operations executed through ida-nexus."""
+
+from __future__ import annotations
+
+import threading
+from collections.abc import Callable
+from pathlib import Path
+from typing import TYPE_CHECKING, Any
+
+if TYPE_CHECKING:
+ from ida_domain import Database
+
+# Same contract as nexus_client: ida_nexus is imported eagerly-if-present but
+# never at hard import cost, and the name is bound to None rather than left
+# undefined so it stays PATCHABLE -- the offline contract tests inject a fake
+# RemoteModule here and run this module's binding logic under a stdlib-only
+# python3 (tests/run.py --fast).
+try:
+ from ida_nexus import RemoteModule
+except ImportError: # library absent: bindings fail actionably on first use
+ RemoteModule = None # type: ignore[assignment,misc]
+
+
+def operation_label() -> str:
+ """Display attribution for the current call; ready for per-user context."""
+ return "IDA TUI"
+
+
+def data_type(db: Database, **a: Any) -> Any:
+ ea = int(str(a["addr"]), 16)
+ try:
+ tif = db.types.get_at(ea)
+ fn = db.functions.get_at(ea)
+ result = {
+ "addr": hex(ea),
+ "name": db.names.get_at(ea) or "",
+ "type": tif.dstr() if tif else "",
+ "size": int(db.heads.size(ea)) if db.heads.is_head(ea) else 0,
+ "is_func": bool(fn),
+ }
+ except Exception as exc:
+ result = {"addr": hex(ea), "error": str(exc)}
+ return result
+
+
+def declare_type(db: Database, **a: Any) -> Any:
+ import ida_typeinf
+
+ decls = a.get("decls", "")
+ if isinstance(decls, str):
+ decls = [decls]
+ rows = []
+ for declaration in decls:
+ try:
+ errors = int(
+ db.types.parse_declarations(ida_typeinf.get_idati(), declaration)
+ )
+ rows.append(
+ {
+ "ok": errors == 0,
+ **({} if errors == 0 else {"error": f"{errors} parse error(s)"}),
+ }
+ )
+ except Exception as exc:
+ rows.append({"ok": False, "error": str(exc)})
+ result = {"result": rows}
+ return result
+
+
+def decomp_error(db: Database, **a: Any) -> Any:
+ import ida_hexrays
+ import ida_ida
+
+ ea = int(str(a["addr"]), 16)
+ fn = db.functions.get_at(ea)
+ result = {"addr": hex(ea), "bitness": ida_ida.inf_get_app_bitness()}
+ if fn is None:
+ result["reason"] = "no function here"
+ else:
+ try:
+ failure = ida_hexrays.hexrays_failure_t()
+ cfunc = ida_hexrays.decompile_func(fn, failure)
+ if cfunc is not None:
+ result["reason"] = ""
+ else:
+ result.update(
+ {
+ "reason": failure.desc() or f"error {failure.code}",
+ "code": int(failure.code),
+ "errea": hex(int(failure.errea)),
+ }
+ )
+ except Exception as exc:
+ result["reason"] = f"{type(exc).__name__}: {exc}"
+ return result
+
+
+def define_code(db: Database, **a: Any) -> Any:
+ import ida_ua
+
+ rows = []
+ for item in a.get("items", []):
+ ea = int(str(item["addr"]), 16)
+ size = int(ida_ua.create_insn(ea))
+ rows.append(
+ {
+ "addr": hex(ea),
+ "ok": size > 0,
+ "size": size,
+ **({} if size > 0 else {"error": "instruction did not decode"}),
+ }
+ )
+ result = {"result": rows}
+ return result
+
+
+def define_code_run(db: Database, **a: Any) -> Any:
+ import ida_bytes
+ import ida_idp
+ import ida_segment
+ import ida_ua
+ import idaapi
+
+ ea, limit = int(str(a["addr"]), 16), max(1, min(int(a.get("limit", 20000)), 200000))
+ seg = ida_segment.getseg(ea)
+ if seg is None:
+ result = {"addr": a["addr"], "error": "no segment", "count": 0}
+ else:
+ start, count, stopped, hi = ea, 0, "limit", int(seg.end_ea)
+ while count < limit:
+ if ea >= hi:
+ stopped = "segment"
+ break
+ flags = ida_bytes.get_flags(ea)
+ if ida_bytes.is_code(flags) or ida_bytes.is_data(flags):
+ stopped = "defined"
+ break
+ size = int(ida_ua.create_insn(ea))
+ if size <= 0:
+ stopped = "undecodable"
+ break
+ count += 1
+ insn = ida_ua.insn_t()
+ if ida_ua.decode_insn(insn, ea) > 0:
+ try:
+ is_ret = bool(ida_idp.is_ret_insn(insn))
+ except Exception:
+ is_ret = False
+ if is_ret or (insn.get_canon_feature() & idaapi.CF_STOP):
+ ea += size
+ stopped = "flow"
+ break
+ ea += size
+ result = {
+ "start": hex(start),
+ "end": hex(ea),
+ "count": count,
+ "stopped": stopped,
+ }
+ return result
+
+
+def define_func(db: Database, **a: Any) -> Any:
+ rows = []
+ for item in a.get("items", []):
+ ea = int(str(item["addr"]), 16)
+ ok = bool(db.functions.create(ea))
+ rows.append(
+ {
+ "addr": hex(ea),
+ "ok": ok,
+ **({} if ok else {"error": "IDA refused the function"}),
+ }
+ )
+ result = {"result": rows}
+ return result
+
+
+def define_func_run(db: Database, **a: Any) -> Any:
+ import ida_bytes
+ import ida_funcs
+ import ida_segment
+
+ ea = int(str(a["addr"]), 16)
+ fn = db.functions.get_at(ea)
+ if fn is not None and int(fn.start_ea) == ea:
+ result = {
+ "addr": hex(ea),
+ "ok": True,
+ "start": hex(ea),
+ "end": hex(int(fn.end_ea)),
+ "how": "existed",
+ }
+ else:
+ automatic = bool(db.functions.create(ea))
+ if not automatic:
+ seg = db.segments.get_at(ea)
+ end = ea
+ hi = int(seg.end_ea) if seg else ea
+ while end < hi and ida_bytes.is_code(ida_bytes.get_flags(end)):
+ nxt = int(ida_bytes.get_item_end(end))
+ if nxt <= end:
+ break
+ end = nxt
+ ok = bool(end > ea and ida_funcs.add_func(ea, end))
+ else:
+ ok = True
+ fn = db.functions.get_at(ea)
+ result = (
+ {
+ "addr": hex(ea),
+ "ok": True,
+ "start": hex(int(fn.start_ea)),
+ "end": hex(int(fn.end_ea)),
+ "how": "auto" if automatic else "explicit-end",
+ }
+ if ok and fn is not None
+ else {
+ "addr": hex(ea),
+ "ok": False,
+ "error": f"IDA refused a function at {ea:#x}",
+ }
+ )
+ return result
+
+
+def del_type(db: Database, **a: Any) -> Any:
+ import ida_typeinf
+
+ name = str(a["name"])
+ ok = bool(
+ ida_typeinf.del_named_type(ida_typeinf.get_idati(), name, ida_typeinf.NTF_TYPE)
+ )
+ result = {
+ "name": name,
+ "deleted": ok,
+ **({} if ok else {"error": f"Type {name!r} not found or could not be deleted"}),
+ }
+ return result
+
+
+def disasm(db: Database, **a: Any) -> Any:
+ ea = int(str(a["addr"]), 16)
+ fn = db.functions.get_at(ea)
+ if fn is None:
+ result = {"instructions": [], "total_instructions": 0, "instruction_count": 0}
+ else:
+ instructions = list(db.functions.get_instructions(fn))
+ limit = max(1, int(a.get("max_instructions", len(instructions) or 1)))
+ rows = [
+ {
+ "addr": hex(int(insn.ea)),
+ "instruction": db.instructions.get_disassembly(insn),
+ }
+ for insn in instructions[:limit]
+ ]
+ result = {
+ "instructions": rows,
+ "total_instructions": len(instructions),
+ "instruction_count": len(instructions),
+ }
+ return result
+
+
+def file_regions(db: Database, **a: Any) -> Any:
+ import idaapi
+
+ rows = []
+ for seg in db.segments.get_all():
+ try:
+ file_off = int(idaapi.get_fileregion_offset(seg.start_ea))
+ except Exception:
+ file_off = -1
+ if file_off < 0 or file_off >= (1 << 48):
+ file_off = -1
+ rows.append(
+ {
+ "start": hex(int(seg.start_ea)),
+ "end": hex(int(seg.end_ea)),
+ "file_off": file_off,
+ "name": db.segments.get_name(seg) or "",
+ }
+ )
+ result = {"regions": rows}
+ return result
+
+
+def flowchart(db: Database, **a: Any) -> Any:
+ import ida_funcs
+ import ida_gdl
+
+ ea = int(str(a["addr"]), 16)
+ fn = ida_funcs.get_func(ea)
+ if fn is None:
+ result = {"addr": hex(ea), "error": "no function at that address", "blocks": []}
+ else:
+ fc = ida_gdl.FlowChart(fn, flags=ida_gdl.FC_PREDS)
+ index, order = {}, []
+ for bb in fc:
+ index[bb.start_ea] = len(order)
+ order.append(bb)
+ blocks = []
+ for bb in order:
+ sl = [s for s in bb.succs() if s.start_ea in index]
+ succs = []
+ for s in sl:
+ # Edge kind is what the graph view colours by: an n-way dispatch is
+ # "switch", a successor that is literally the next address falls
+ # through, anything else is a taken branch.
+ if len(sl) > 2:
+ kind = "switch"
+ elif s.start_ea == bb.end_ea:
+ kind = "fall"
+ else:
+ kind = "jump"
+ succs.append([index[s.start_ea], kind])
+ blocks.append(
+ {
+ "id": index[bb.start_ea],
+ "start": hex(int(bb.start_ea)),
+ "end": hex(int(bb.end_ea)),
+ "succs": succs,
+ }
+ )
+ result = {
+ "addr": hex(ea),
+ "func": {
+ "addr": hex(int(fn.start_ea)),
+ "end": hex(int(fn.end_ea)),
+ "name": ida_funcs.get_func_name(fn.start_ea) or "",
+ },
+ "entry": index.get(fn.start_ea, 0),
+ "blocks": blocks,
+ }
+ return result
+
+
+def force_recompile(db: Database, **a: Any) -> Any:
+ import ida_hexrays
+
+ rows = []
+ for item in a.get("items", []):
+ ea = int(str(item["addr"]), 16)
+ ida_hexrays.mark_cfunc_dirty(ea, False)
+ rows.append({"addr": hex(ea), "ok": True})
+ result = {"result": rows}
+ return result
+
+
+def func_types(db: Database, **a: Any) -> Any:
+ import ida_typeinf
+
+ ea = int(str(a["addr"]), 16)
+ fn = db.functions.get_at(ea)
+ if fn is None:
+ result = {"addr": a["addr"], "error": "no function at address"}
+ else:
+ pseudo = db.pseudocode.decompile(fn)
+ name = db.functions.get_name(fn) or ""
+ tif = pseudo.get_func_type()
+ try:
+ prototype = (
+ ida_typeinf.print_tinfo(
+ "", 0, 0, ida_typeinf.PRTYPE_1LINE, tif, name, ""
+ )
+ if tif
+ else ""
+ )
+ except Exception:
+ prototype = tif.dstr() if tif else ""
+ lvars = [
+ {
+ "name": var.name,
+ "type": var.type_info.dstr() if var.type_info else "",
+ "is_arg": bool(var.is_arg),
+ }
+ for var in pseudo.local_variables
+ ]
+ result = {
+ "addr": hex(int(fn.start_ea)),
+ "name": name,
+ "prototype": (prototype or "").strip(),
+ "lvars": lvars,
+ }
+ return result
+
+
+def get_bytes(db: Database, **a: Any) -> Any:
+ rows = []
+ for region in a.get("regions", []):
+ ea, size = int(str(region["addr"]), 16), int(region["size"])
+ raw = db.bytes.get_bytes_at(ea, size) or b""
+ rows.append({"addr": region["addr"], "data": " ".join(f"{b:02x}" for b in raw)})
+ result = {"result": rows}
+ return result
+
+
+def journal_get(db: Database, **a: Any) -> Any:
+ import ida_netnode
+
+ n = ida_netnode.netnode(a.get("node", "$ idatui.journal"))
+ blob = n.getblob(0, "I") if ida_netnode.exist(n) else None
+ result = {"data": blob.decode("utf-8", "replace") if blob else ""}
+ return result
+
+
+def journal_put(db: Database, **a: Any) -> Any:
+ import ida_netnode
+
+ n = ida_netnode.netnode(a.get("node", "$ idatui.journal"), 0, True)
+ payload = (a.get("data") or "").encode("utf-8")
+ n.setblob(payload, 0, "I")
+ result = {"ok": True, "bytes": len(payload)}
+ return result
+
+
+def list_annotations(db: Database, **a: Any) -> Any:
+ import ida_bytes
+ import ida_funcs
+ import ida_lines
+ import ida_nalt
+ import ida_name
+ import ida_segment
+ import ida_typeinf
+ import idautils
+
+ limit = max(1, int(a.get("limit", 4000)))
+ max_scan = max(1000, int(a.get("max_scan", 2000000)))
+ comments, names = [], []
+ scanned = 0
+
+ def _line(ea):
+ try:
+ txt = ida_lines.generate_disasm_line(ea, ida_lines.GENDSM_REMOVE_TAGS)
+ except Exception:
+ txt = ""
+ return " ".join((txt or "").split())
+
+ for ea, nm in idautils.Names():
+ if len(names) >= limit:
+ break
+ if not nm or not ida_bytes.has_user_name(ida_bytes.get_flags(ea)):
+ continue
+ fn = ida_funcs.get_func(ea)
+ is_fn = fn is not None and int(fn.start_ea) == int(ea)
+ proto = None
+ if is_fn:
+ try:
+ ti = ida_typeinf.tinfo_t()
+ if ida_nalt.get_tinfo(ti, ea):
+ proto = str(ti)
+ except Exception:
+ proto = None
+ seg = ida_segment.getseg(ea)
+ names.append(
+ {
+ "addr": hex(int(ea)),
+ "name": nm,
+ "func": is_fn,
+ "size": (int(fn.end_ea - fn.start_ea) if is_fn else 0),
+ "proto": proto,
+ "seg": (ida_segment.get_segm_name(seg) if seg else ""),
+ }
+ )
+
+ for i in range(ida_segment.get_segm_qty()):
+ seg = ida_segment.getnseg(i)
+ if seg is None or len(comments) >= limit or scanned >= max_scan:
+ continue
+ for ea in idautils.Heads(seg.start_ea, seg.end_ea):
+ scanned += 1
+ if len(comments) >= limit or scanned >= max_scan:
+ break
+ if not ida_bytes.has_cmt(ida_bytes.get_flags(ea)):
+ continue
+ for rep in (False, True):
+ text = ida_bytes.get_cmt(ea, rep)
+ if text:
+ fn = ida_funcs.get_func(ea)
+ comments.append(
+ {
+ "addr": hex(int(ea)),
+ "text": text,
+ "repeatable": rep,
+ "line": _line(ea),
+ "seg": ida_segment.get_segm_name(seg),
+ "func": (
+ ida_funcs.get_func_name(fn.start_ea) if fn else None
+ ),
+ "func_addr": (hex(int(fn.start_ea)) if fn else None),
+ }
+ )
+
+ # Whole-function comments are not on the byte flags, so the scan cannot see them.
+ for fn_ea in idautils.Functions():
+ fn = ida_funcs.get_func(fn_ea)
+ if fn is None or len(comments) >= limit:
+ continue
+ for rep in (False, True):
+ text = ida_funcs.get_func_cmt(fn, rep)
+ if text:
+ seg = ida_segment.getseg(fn_ea)
+ comments.append(
+ {
+ "addr": hex(int(fn_ea)),
+ "text": text,
+ "repeatable": rep,
+ "line": "",
+ "whole_func": True,
+ "seg": (ida_segment.get_segm_name(seg) if seg else ""),
+ "func": ida_funcs.get_func_name(fn_ea),
+ "func_addr": hex(int(fn_ea)),
+ }
+ )
+ result = {
+ "comments": comments,
+ "names": names,
+ "scanned": scanned,
+ "truncated": (
+ len(comments) >= limit or len(names) >= limit or scanned >= max_scan
+ ),
+ }
+ return result
+
+
+def list_funcs(db: Database, **a: Any) -> Any:
+ import fnmatch
+
+ queries = a.get("queries") or [{}]
+ q = queries[0]
+ offset, count = max(0, int(q.get("offset", 0))), max(1, int(q.get("count", 500)))
+ pattern = str(q.get("filter") or "").lower()
+ if pattern and not any(ch in pattern for ch in "*?["):
+ pattern = "*" + pattern + "*"
+ rows = []
+ for fn in db.functions.get_all():
+ name = db.functions.get_name(fn) or f"sub_{int(fn.start_ea):X}"
+ if pattern and not fnmatch.fnmatchcase(name.lower(), pattern):
+ continue
+ rows.append(
+ {
+ "addr": hex(int(fn.start_ea)),
+ "name": name,
+ "size": int(fn.end_ea) - int(fn.start_ea),
+ }
+ )
+ page = rows[offset : offset + count]
+ result = {
+ "result": [
+ {"data": page, "next_offset": offset + len(page), "total": len(rows)}
+ ]
+ }
+ return result
+
+
+def list_linkage(db: Database, **a: Any) -> Any:
+ imports = [
+ {"addr": hex(int(item.address)), "name": item.name, "module": item.module_name}
+ for item in db.imports.get_all_imports()
+ if item.name
+ ]
+ exports = [
+ {
+ "addr": hex(int(item.address)),
+ "name": item.name,
+ "ordinal": int(item.ordinal),
+ }
+ for item in db.entries.get_all()
+ if item.name
+ ]
+ result = {
+ "imports": imports,
+ "exports": exports,
+ "n_imports": len(imports),
+ "n_exports": len(exports),
+ }
+ return result
+
+
+def list_strings(db: Database, **a: Any) -> Any:
+ from ida_domain.strings import StringListConfig
+
+ offset, count, min_len = (
+ max(0, int(a.get("offset", 0))),
+ max(1, int(a.get("count", 2000))),
+ max(1, int(a.get("min_len", 4))),
+ )
+ if offset == 0 or a.get("refresh"):
+ from ida_domain.strings import StringType
+
+ db.strings.rebuild(
+ StringListConfig(
+ string_types=list(StringType), min_len=min_len, only_ascii_7bit=False
+ )
+ )
+ items = list(db.strings.get_all())
+ page = items[offset : offset + count]
+ rows = []
+ for item in page:
+ try:
+ text = str(item)
+ except Exception:
+ text = item.contents.decode("utf-8", "replace") if item.contents else ""
+ rows.append(
+ {
+ "addr": hex(int(item.address)),
+ "text": text,
+ "len": int(item.length),
+ "type": item.type.name,
+ }
+ )
+ result = {"strings": rows, "total": len(items), "next_offset": offset + len(rows)}
+ return result
+
+
+def lookup_funcs(db: Database, **a: Any) -> Any:
+ rows = []
+ for query in a.get("queries", []):
+ raw = str(query)
+ try:
+ ea = int(raw, 16)
+ except ValueError:
+ fn = db.functions.get_by_name(raw)
+ ea = int(fn.start_ea) if fn else None
+ else:
+ fn = db.functions.get_at(ea)
+ if fn is None:
+ rows.append({"query": raw, "fn": None})
+ else:
+ rows.append(
+ {
+ "query": raw,
+ "fn": {
+ "addr": hex(int(fn.start_ea)),
+ "name": db.functions.get_name(fn)
+ or f"sub_{int(fn.start_ea):X}",
+ "size": int(fn.end_ea) - int(fn.start_ea),
+ },
+ }
+ )
+ result = {"result": rows}
+ return result
+
+
+def make_data(db: Database, **a: Any) -> Any:
+ import ida_bytes
+ import ida_idaapi
+ import ida_typeinf
+ from ida_domain.types import TypeApplyFlags
+
+ rows = []
+ for item in a.get("items", []):
+ ea, declaration = int(str(item["addr"]), 16), str(item["type"])
+ try:
+ tif = db.types.parse_one_declaration(ida_typeinf.get_idati(), declaration)
+ size = max(1, int(tif.get_size()))
+ saved_names = [
+ (addr, name)
+ for addr, name in db.names.get_all()
+ if ea <= int(addr) < ea + size
+ ]
+ ida_bytes.del_items(
+ ea,
+ ida_bytes.DELIT_EXPAND | ida_bytes.DELIT_DELNAMES,
+ max(size, int(ida_bytes.get_item_size(ea) or 1)),
+ )
+ created = bool(
+ ida_bytes.create_data(ea, ida_bytes.FF_BYTE, size, ida_idaapi.BADADDR)
+ )
+ ok = created and bool(db.types.apply_at(tif, ea, TypeApplyFlags.DEFINITE))
+ for address, name in saved_names:
+ db.names.set_name(int(address), name)
+ if ok and item.get("name"):
+ ok = bool(db.names.set_name(ea, str(item["name"])))
+ rows.append(
+ {
+ "addr": hex(ea),
+ "ok": ok,
+ "size": size,
+ **({} if ok else {"error": "IDA rejected the data type"}),
+ }
+ )
+ except Exception as exc:
+ rows.append({"addr": hex(ea), "ok": False, "error": str(exc)})
+ result = {"result": rows}
+ return result
+
+
+def make_string(db: Database, **a: Any) -> Any:
+ from ida_domain.strings import StringType
+
+ ea, length = int(str(a["addr"]), 16), max(0, int(a.get("length", 0)))
+ kind = {
+ "c": StringType.C,
+ "c16": StringType.C_16,
+ "c32": StringType.C_32,
+ "pascal": StringType.PASCAL,
+ }.get(str(a.get("kind", "c")).lower(), StringType.C)
+ import ida_bytes
+
+ try:
+ ida_bytes.del_items(ea, ida_bytes.DELIT_SIMPLE, length if length > 0 else 1)
+ except Exception:
+ pass
+ try:
+ ok = bool(db.bytes.create_string_at(ea, length or None, kind))
+ text = db.bytes.get_string_at(ea) or "" if ok else ""
+ result = {
+ "addr": hex(ea),
+ "ok": ok,
+ "size": int(db.heads.size(ea)) if ok else 0,
+ "text": text,
+ }
+ except Exception as exc:
+ result = {"addr": hex(ea), "ok": False, "error": str(exc)}
+ return result
+
+
+def read_raw(db: Database, **a: Any) -> Any:
+ import ida_bytes
+
+ ea, size = int(str(a["addr"]), 16), max(0, int(a["size"]))
+ raw = ida_bytes.get_bytes(ea, size) or b""
+ raw = raw[:size] + b"\xff" * max(0, size - len(raw))
+ data = bytearray(raw)
+ for index, value in enumerate(data):
+ if value == 0xFF and not ida_bytes.is_loaded(ea + index):
+ data[index] = 0
+ result = {"addr": a["addr"], "hex": bytes(data).hex(), "n": len(data)}
+ return result
+
+
+def rename(db: Database, **a: Any) -> Any:
+ import ida_hexrays
+ import ida_name
+ import idaapi
+
+ batch = a.get("batch") or {}
+ dry_run = bool(batch.get("dry_run", False))
+ allow_overwrite = bool(batch.get("allow_overwrite", False))
+ stop_on_error = bool(batch.get("stop_on_error", False))
+
+ def _items(value):
+ if value is None:
+ return []
+ if isinstance(value, dict):
+ return [value]
+ if isinstance(value, list):
+ return [i for i in value if isinstance(i, dict)]
+ return []
+
+ def _set_name_checked(ea, new):
+ conflict = idaapi.get_name_ea(idaapi.BADADDR, new)
+ if conflict != idaapi.BADADDR and conflict != ea and not allow_overwrite:
+ return (
+ False,
+ f"can't rename at {hex(ea)} as {new!r}: name already used at {hex(conflict)}",
+ )
+ if dry_run:
+ return True, None
+ flags = idaapi.SN_CHECK
+ if allow_overwrite:
+ flags |= int(getattr(idaapi, "SN_FORCE", 0))
+ if not idaapi.set_name(ea, new, flags):
+ return False, (
+ f"Rename failed at {hex(ea)}: IDA rejected name {new!r} "
+ "(invalid identifier or internal conflict)"
+ )
+ return True, None
+
+ def _refresh_ctext(fn_addr):
+ # A renamed function must invalidate Hex-Rays' cache, which is per function
+ # and persisted in the .i64: without this the pseudocode keeps calling the
+ # old name forever while every other readback reports the new one.
+ if not ida_hexrays.init_hexrays_plugin():
+ return
+ failure = ida_hexrays.hexrays_failure_t()
+ cfunc = ida_hexrays.decompile_func(
+ fn_addr, failure, ida_hexrays.DECOMP_WARNINGS
+ )
+ if cfunc:
+ cfunc.refresh_func_ctext()
+
+ out = {}
+ ok_count = failed = 0
+ halted = False
+ for category in ("func", "data", "local", "stack"):
+ if category not in batch:
+ continue
+ rows = []
+ for edit in _items(batch.get(category)):
+ try:
+ if category == "func":
+ addr_text = (
+ edit.get("addr") or edit.get("func_addr") or edit.get("func")
+ )
+ new = edit.get("name") or edit.get("new") or edit.get("new_name")
+ if not addr_text or not new:
+ row = {
+ "addr": addr_text,
+ "name": new,
+ "error": "Function rename requires addr + name",
+ }
+ else:
+ ea = int(str(addr_text), 16)
+ fn = idaapi.get_func(ea)
+ if fn is None:
+ row = {
+ "addr": addr_text,
+ "name": new,
+ "error": "Function not found",
+ }
+ else:
+ old = idaapi.get_name(fn.start_ea) or None
+ ok, err = _set_name_checked(fn.start_ea, str(new))
+ row = {"addr": addr_text, "old": old, "name": str(new)}
+ if err:
+ row["error"] = err
+ if dry_run:
+ row["dry_run"] = True
+ if ok and not dry_run:
+ _refresh_ctext(fn.start_ea)
+ elif category == "data":
+ addr_text = edit.get("addr")
+ old = edit.get("old") or edit.get("old_name")
+ new = edit.get("new") or edit.get("new_name") or edit.get("name")
+ if not new and new != "":
+ row = {
+ "old": old,
+ "new": None,
+ "error": "Global rename requires target and new name",
+ }
+ else:
+ if addr_text is not None:
+ ea = int(str(addr_text), 16)
+ old = old or (idaapi.get_name(ea) or None)
+ else:
+ ea = idaapi.get_name_ea(idaapi.BADADDR, str(old or ""))
+ if ea == idaapi.BADADDR:
+ row = {
+ "old": old,
+ "new": str(new),
+ "error": f"Global {old!r} not found",
+ }
+ else:
+ # An empty new name CLEARS the label; that is a real
+ # request (tests revert with it), not a missing argument.
+ if str(new) == "":
+ ok = bool(ida_name.set_name(ea, "", idaapi.SN_CHECK))
+ err = (
+ None
+ if ok
+ else f"Failed to clear the name at {hex(ea)}"
+ )
+ else:
+ ok, err = _set_name_checked(ea, str(new))
+ row = {"addr": hex(ea), "old": old, "new": str(new)}
+ if err:
+ row["error"] = err
+ if dry_run:
+ row["dry_run"] = True
+ else:
+ fa, old, new = (
+ edit.get("func_addr"),
+ edit.get("old"),
+ edit.get("new"),
+ )
+ if not fa or not old or not new:
+ row = {
+ "old": old,
+ "new": new,
+ "error": f"{category} rename requires func_addr + old + new",
+ }
+ else:
+ ea = int(str(fa), 16)
+ pseudo = db.pseudocode.decompile(ea)
+ var = pseudo.find_local_variable(str(old))
+ if var is None:
+ row = {
+ "func_addr": fa,
+ "old": old,
+ "new": new,
+ "error": f"no local {old!r} in that function",
+ }
+ elif dry_run:
+ row = {
+ "func_addr": fa,
+ "old": old,
+ "new": new,
+ "dry_run": True,
+ }
+ else:
+ var.set_user_name(str(new))
+ ok = bool(
+ pseudo.save_local_variable_info(var, save_name=True)
+ )
+ row = {"func_addr": fa, "old": old, "new": new}
+ if not ok:
+ row["error"] = "IDA rejected the local variable name"
+ except Exception as exc:
+ row = {"addr": edit.get("addr"), "error": str(exc)}
+ rows.append(row)
+ if row.get("error"):
+ failed += 1
+ else:
+ ok_count += 1
+ if row.get("error") and stop_on_error:
+ halted = True
+ break
+ out[category] = rows
+ if halted:
+ break
+ out["summary"] = {"ok": ok_count, "failed": failed}
+ if dry_run:
+ out["summary"]["dry_run"] = True
+ if halted:
+ out["summary"]["halted"] = True
+ result = out
+ return result
+
+
+def resolve_names(db: Database, **a: Any) -> Any:
+ import ida_idaapi
+ import ida_name
+
+ rows = []
+ for query in a.get("queries", []):
+ name = str(query).strip()
+ ea = ida_name.get_name_ea(ida_idaapi.BADADDR, name)
+ rows.append(
+ {"query": name, "ea": hex(int(ea)) if ea != ida_idaapi.BADADDR else None}
+ )
+ result = {"result": rows}
+ return result
+
+
+def search_bytes(db: Database, **a: Any) -> Any:
+ import ida_bytes
+ import ida_funcs
+ import ida_idaapi
+ import ida_lines
+ import ida_segment
+
+ pat = str(a.get("pattern", "")).strip()
+ limit = max(1, int(a.get("limit", 500)))
+ lo = int(a.get("start", 0))
+ hi = int(a.get("end", 0)) or ida_idaapi.BADADDR
+ flags = ida_bytes.BIN_SEARCH_FORWARD | ida_bytes.BIN_SEARCH_NOSHOW
+ if a.get("case"):
+ flags |= ida_bytes.BIN_SEARCH_CASE
+ rows, err, ea = [], None, lo
+ while len(rows) < limit:
+ try:
+ hit = ida_bytes.find_bytes(pat, range_start=ea, range_end=hi, flags=flags)
+ except Exception as exc:
+ err = str(exc) or exc.__class__.__name__
+ break
+ if hit is None or hit == ida_idaapi.BADADDR:
+ break
+ head = ida_bytes.get_item_head(hit)
+ fn = ida_funcs.get_func(hit)
+ seg = ida_segment.getseg(hit)
+ try:
+ line = (
+ ida_lines.generate_disasm_line(head, ida_lines.GENDSM_REMOVE_TAGS) or ""
+ )
+ except Exception:
+ line = ""
+ rows.append(
+ {
+ "addr": hex(int(hit)),
+ "head": hex(int(head)),
+ "line": " ".join(line.split()),
+ "func": (ida_funcs.get_func_name(fn.start_ea) if fn else None),
+ "func_addr": (hex(int(fn.start_ea)) if fn else None),
+ "seg": (ida_segment.get_segm_name(seg) if seg else ""),
+ }
+ )
+ ea = int(hit) + 1
+ result = {"hits": rows, "error": err, "truncated": len(rows) >= limit}
+ return result
+
+
+def search_structs(db: Database, **a: Any) -> Any:
+ needle = str(a.get("filter") or "").lower()
+ rows = []
+ for tif in db.types.get_all():
+ name = tif.get_type_name() or ""
+ if not name or needle not in name.lower() or not tif.is_udt():
+ continue
+ members = list(db.types.get_udt_members(tif))
+ rows.append(
+ {
+ "name": name,
+ "size": int(tif.get_size()),
+ "is_union": bool(tif.is_union()),
+ "cardinality": len(members),
+ "ordinal": int(tif.get_ordinal()),
+ }
+ )
+ result = {"result": rows}
+ return result
+
+
+def search_text(db: Database, **a: Any) -> Any:
+ import re as _re
+
+ import ida_funcs
+ import ida_lines
+ import ida_segment
+ import idautils
+
+ q = str(a.get("query", ""))
+ limit = max(1, int(a.get("limit", 500)))
+ max_scan = max(1000, int(a.get("max_scan", 3000000)))
+ ci = (not a.get("case")) and q.islower() # smartcase, like the in-view search
+ rx, err = None, None
+ if a.get("regex"):
+ try:
+ rx = _re.compile(q, _re.I if ci else 0)
+ except Exception as exc:
+ err = "bad regex: " + str(exc)
+ needle = q.lower() if ci else q
+ rows, scanned = [], 0
+ if err is None and q:
+ for i in range(ida_segment.get_segm_qty()):
+ seg = ida_segment.getnseg(i)
+ if seg is None or len(rows) >= limit or scanned >= max_scan:
+ continue
+ for ea in idautils.Heads(seg.start_ea, seg.end_ea):
+ scanned += 1
+ if len(rows) >= limit or scanned >= max_scan:
+ break
+ try:
+ line = (
+ ida_lines.generate_disasm_line(ea, ida_lines.GENDSM_REMOVE_TAGS)
+ or ""
+ )
+ except Exception:
+ continue
+ # Match what the user SEES, not IDA's column padding: nobody types
+ # "call" + four spaces + "cs:getenv_ptr".
+ line = " ".join(line.split())
+ hay = line.lower() if ci else line
+ if rx.search(line) if rx is not None else (needle in hay):
+ fn = ida_funcs.get_func(ea)
+ rows.append(
+ {
+ "addr": hex(int(ea)),
+ "head": hex(int(ea)),
+ "line": line,
+ "func": (
+ ida_funcs.get_func_name(fn.start_ea) if fn else None
+ ),
+ "func_addr": (hex(int(fn.start_ea)) if fn else None),
+ "seg": ida_segment.get_segm_name(seg),
+ }
+ )
+ result = {
+ "hits": rows,
+ "error": err,
+ "scanned": scanned,
+ "truncated": len(rows) >= limit or scanned >= max_scan,
+ }
+ return result
+
+
+def set_comments(db: Database, **a: Any) -> Any:
+ import ida_hexrays
+ import idaapi
+ import idc
+
+ rows = []
+ for item in a.get("items", []):
+ addr_s = str(item.get("addr", ""))
+ text = str(item.get("comment") or "")
+ try:
+ ea = int(addr_s, 16)
+ if not idaapi.set_cmt(ea, text, False):
+ rows.append(
+ {
+ "addr": addr_s,
+ "error": f"Failed to set disassembly comment at {hex(ea)}",
+ }
+ )
+ continue
+ if not ida_hexrays.init_hexrays_plugin():
+ rows.append({"addr": addr_s})
+ continue
+ try:
+ cfunc = ida_hexrays.decompile(ea)
+ except Exception:
+ cfunc = None
+ if cfunc is None:
+ rows.append({"addr": addr_s})
+ continue
+ if ea == cfunc.entry_ea:
+ # The signature line carries no ctree item: it is a function comment.
+ idc.set_func_cmt(ea, text, True)
+ cfunc.refresh_func_ctext()
+ rows.append({"addr": addr_s})
+ continue
+ eamap = cfunc.get_eamap()
+ if ea not in eamap:
+ rows.append(
+ {
+ "addr": addr_s,
+ "error": f"Failed to set decompiler comment at {hex(ea)}",
+ }
+ )
+ continue
+ nearest_ea = eamap[ea][0].ea
+ if cfunc.has_orphan_cmts():
+ cfunc.del_orphan_cmts()
+ cfunc.save_user_cmts()
+ tl = idaapi.treeloc_t()
+ tl.ea = nearest_ea
+ placed = False
+ for itp in range(idaapi.ITP_SEMI, idaapi.ITP_COLON):
+ tl.itp = itp
+ cfunc.set_user_cmt(tl, text)
+ cfunc.save_user_cmts()
+ cfunc.refresh_func_ctext()
+ if not cfunc.has_orphan_cmts():
+ placed = True
+ break
+ cfunc.del_orphan_cmts()
+ cfunc.save_user_cmts()
+ rows.append(
+ {"addr": addr_s}
+ if placed
+ else {
+ "addr": addr_s,
+ "error": f"Failed to set decompiler comment at {hex(ea)}",
+ }
+ )
+ except Exception as exc:
+ rows.append({"addr": addr_s, "error": str(exc)})
+ result = {"result": rows}
+ return result
+
+
+def set_lvar_type(db: Database, **a: Any) -> Any:
+ import ida_typeinf
+
+ ea, variable, declaration = (
+ int(str(a["addr"]), 16),
+ str(a["variable"]),
+ str(a["type"]),
+ )
+ fn = db.functions.get_at(ea)
+ if fn is None:
+ result = {"error": "no function at address"}
+ else:
+ pseudo = db.pseudocode.decompile(fn)
+ var = pseudo.find_local_variable(variable)
+ if var is None:
+ result = {"error": f"local variable {variable!r} not found"}
+ else:
+ try:
+ tif = db.types.parse_one_declaration(
+ ida_typeinf.get_idati(), declaration
+ )
+ accepted = bool(var.set_type(tif))
+ saved = (
+ bool(pseudo.save_local_variable_info(var, save_type=True))
+ if accepted
+ else False
+ )
+ result = {
+ "addr": hex(int(fn.start_ea)),
+ "variable": variable,
+ "type": declaration,
+ "ok": accepted and saved,
+ }
+ except Exception as exc:
+ result = {"error": f"bad type {declaration!r}: {exc}"}
+ return result
+
+
+def set_thumb(db: Database, **a: Any) -> Any:
+ import ida_bytes
+ import ida_ida
+ import ida_idp
+ import ida_segment
+ import ida_segregs
+
+ ea = int(str(a["addr"]), 16)
+ treg = ida_idp.str2reg("T")
+ seg = ida_segment.getseg(ea)
+ if treg is None or treg < 0:
+ result = {"addr": hex(ea), "error": "no T register (not an ARM database)"}
+ elif seg is None:
+ result = {"addr": hex(ea), "error": "no segment"}
+ else:
+ current = ida_segregs.get_sreg(ea, treg)
+ current = 0 if current in (None, 0xFFFFFFFF, -1) else int(current)
+ want = {"on": 1, "off": 0}.get(
+ str(a.get("mode", "toggle")).lower(), 0 if current else 1
+ )
+ changed = False
+ if want and seg.bitness != 1:
+ ida_segment.set_segm_addressing(seg, 1)
+ changed = True
+ size = max(int(ida_bytes.get_item_size(ea)), 2)
+ ida_bytes.del_items(ea, 0, size)
+ ok = bool(ida_segregs.split_sreg_range(ea, treg, want, ida_segregs.SR_user))
+ now = ida_segregs.get_sreg(ea, treg)
+ result = {
+ "addr": hex(ea),
+ "thumb": bool(now),
+ "was": bool(current),
+ "ok": ok,
+ "bitness": ida_segment.getseg(ea).bitness,
+ "forced_32bit": changed,
+ "db_64bit": bool(ida_ida.inf_get_app_bitness() == 64 and want),
+ }
+ return result
+
+
+def set_type(db: Database, **a: Any) -> Any:
+ from ida_domain.types import TypeApplyFlags
+
+ rows = []
+ for edit in a.get("edits", []):
+ ea = int(str(edit["addr"]), 16)
+ declaration = str(edit.get("signature") or edit.get("type") or "")
+ try:
+ ok = bool(
+ db.types.apply_declaration_at(ea, declaration, TypeApplyFlags.DEFINITE)
+ )
+ rows.append(
+ {
+ "addr": hex(ea),
+ "ok": ok,
+ **({} if ok else {"error": "IDA rejected the type"}),
+ }
+ )
+ except Exception as exc:
+ rows.append({"addr": hex(ea), "ok": False, "error": str(exc)})
+ result = {"result": rows}
+ return result
+
+
+def survey_binary(db: Database, **a: Any) -> Any:
+ segments = []
+ for seg in db.segments.get_all():
+ segments.append(
+ {
+ "start": hex(int(seg.start_ea)),
+ "end": hex(int(seg.end_ea)),
+ "name": db.segments.get_name(seg) or "",
+ }
+ )
+ result = {"segments": segments}
+ return result
+
+
+def thumb_scan(db: Database, **a: Any) -> Any:
+ import ida_bytes
+ import ida_funcs
+ import ida_idp
+ import ida_segment
+ import ida_segregs
+ import ida_ua
+
+ lo, hi = int(str(a["start"]), 16), int(str(a["end"]), 16)
+ apply, limit = bool(a.get("apply", True)), int(a.get("limit", 512))
+ treg = ida_idp.str2reg("T")
+ found = []
+ applied = 0
+ cursor = lo
+ while cursor + 4 <= hi and len(found) < limit:
+ at = cursor
+ value = int(ida_bytes.get_dword(cursor))
+ cursor += 4
+ if not value & 1:
+ continue
+ target = value & ~1
+ seg = ida_segment.getseg(target)
+ if seg is None or not (seg.perm & ida_segment.SEGPERM_EXEC or seg.perm == 0):
+ continue
+ flags = ida_bytes.get_flags(target)
+ if ida_bytes.is_data(flags):
+ continue
+ item = {
+ "at": hex(at),
+ "value": hex(value),
+ "target": hex(target),
+ "was_code": bool(ida_bytes.is_code(flags)),
+ }
+ found.append(item)
+ if not apply:
+ continue
+ if treg is not None and treg >= 0:
+ ida_segregs.split_sreg_range(target, treg, 1, ida_segregs.SR_user)
+ if not ida_bytes.is_code(ida_bytes.get_flags(target)):
+ ida_bytes.del_items(target, 0, 2)
+ if ida_ua.create_insn(target) <= 0:
+ item["decoded"] = False
+ continue
+ item["decoded"] = True
+ item["function"] = bool(
+ db.functions.get_at(target) or db.functions.create(target)
+ )
+ applied += 1
+ result = {
+ "start": hex(lo),
+ "end": hex(hi),
+ "found": found,
+ "applied": applied,
+ "n": len(found),
+ }
+ return result
+
+
+def type_inspect(db: Database, **a: Any) -> Any:
+ rows = []
+ for query in a.get("queries", []):
+ name = str(query.get("name") or "")
+ tif = db.types.get_by_name(name)
+ if tif is None:
+ rows.append({"name": name, "error": "type not found"})
+ continue
+ members = (
+ [
+ {
+ "name": m.name,
+ "type": m.type.dstr() or str(m.type),
+ "offset": int(m.offset),
+ "size": int(m.size),
+ }
+ for m in db.types.get_udt_members(tif)
+ ]
+ if tif.is_udt()
+ else []
+ )
+ rows.append(
+ {
+ "name": name,
+ "size": int(tif.get_size()),
+ "is_union": bool(tif.is_union()),
+ "members": members,
+ }
+ )
+ result = {"result": rows}
+ return result
+
+
+def undefine(db: Database, **a: Any) -> Any:
+ import ida_bytes
+
+ rows = []
+ for item in a.get("items", []):
+ ea = int(str(item["addr"]), 16)
+ size = max(1, int(item.get("size") or ida_bytes.get_item_size(ea) or 1))
+ ok = bool(ida_bytes.del_items(ea, ida_bytes.DELIT_SIMPLE, size))
+ rows.append(
+ {
+ "addr": hex(ea),
+ "ok": ok,
+ **({} if ok else {"error": "delete items failed"}),
+ }
+ )
+ result = {"result": rows}
+ return result
+
+
+def xref_query(db: Database, **a: Any) -> Any:
+ import ida_bytes
+ import ida_funcs
+ import idaapi
+ import idautils
+
+ def _fn(ea):
+ f = ida_funcs.get_func(ea)
+ return (
+ {
+ "addr": hex(int(f.start_ea)),
+ "name": ida_funcs.get_func_name(f.start_ea) or "",
+ }
+ if f
+ else None
+ )
+
+ queries = a.get("queries") or []
+ all_results = []
+ for query in queries:
+ raw = str(query.get("addr", "")).strip()
+ direction = str(query.get("direction", "both") or "both").lower()
+ if direction not in ("to", "from", "both"):
+ direction = "both"
+ xref_type = str(query.get("xref_type", "any") or "any").lower()
+ if xref_type not in ("any", "code", "data"):
+ xref_type = "any"
+ include_fn = bool(query.get("include_fn", True))
+ dedup = bool(query.get("dedup", True))
+ sort_by = str(query.get("sort_by", "addr") or "addr")
+ descending = bool(query.get("descending", False))
+ try:
+ offset = max(0, int(query.get("offset", 0) or 0))
+ except (TypeError, ValueError):
+ offset = 0
+ try:
+ count = max(0, min(int(query.get("count", 200) or 200), 5000))
+ except (TypeError, ValueError):
+ count = 200
+ try:
+ try:
+ target = int(raw, 16)
+ except ValueError:
+ target = idaapi.get_name_ea(idaapi.BADADDR, raw)
+ if target == idaapi.BADADDR:
+ raise ValueError(f"Failed to resolve address/name: {raw}")
+ if not ida_bytes.is_mapped(target):
+ raise ValueError(f"Address not mapped: {raw}")
+ rows = []
+ if direction in ("to", "both"):
+ for xr in idautils.XrefsTo(target, 0):
+ kind = "code" if xr.iscode else "data"
+ if xref_type != "any" and kind != xref_type:
+ continue
+ row = {
+ "direction": "to",
+ "addr": hex(int(xr.frm)),
+ "from": hex(int(xr.frm)),
+ "to": hex(int(target)),
+ "type": kind,
+ }
+ if include_fn:
+ row["fn"] = _fn(xr.frm)
+ rows.append(row)
+ if direction in ("from", "both"):
+ for xr in idautils.XrefsFrom(target, 0):
+ kind = "code" if xr.iscode else "data"
+ if xref_type != "any" and kind != xref_type:
+ continue
+ row = {
+ "direction": "from",
+ "addr": hex(int(xr.to)),
+ "from": hex(int(target)),
+ "to": hex(int(xr.to)),
+ "type": kind,
+ }
+ if include_fn:
+ row["fn"] = _fn(xr.to)
+ rows.append(row)
+ if dedup:
+ seen, deduped = set(), []
+ for row in rows:
+ key = (row["direction"], row["from"], row["to"], row["type"])
+ if key in seen:
+ continue
+ seen.add(key)
+ deduped.append(row)
+ rows = deduped
+ if sort_by == "type":
+ rows.sort(
+ key=lambda r: (str(r.get("type", "")), int(str(r["addr"]), 16)),
+ reverse=descending,
+ )
+ else:
+ rows.sort(key=lambda r: int(str(r["addr"]), 16), reverse=descending)
+ page = rows[offset : offset + count] if count else rows[offset:]
+ nxt = offset + len(page)
+ all_results.append(
+ {
+ "target": raw,
+ "resolved_addr": hex(int(target)),
+ "direction": direction,
+ "xref_type": xref_type,
+ "data": page,
+ "next_offset": nxt if nxt < len(rows) else None,
+ "total": len(rows),
+ "error": None,
+ }
+ )
+ except Exception as exc:
+ all_results.append(
+ {
+ "target": raw,
+ "resolved_addr": None,
+ "direction": direction,
+ "xref_type": xref_type,
+ "data": [],
+ "next_offset": None,
+ "total": 0,
+ "error": str(exc),
+ }
+ )
+ result = {"result": all_results}
+ return result
+
+
+def xref_types(db: Database, **a: Any) -> Any:
+ import ida_bytes
+ import ida_funcs
+ import ida_xref
+ import idaapi
+ import idautils
+
+ code_kind = {
+ ida_xref.fl_CF: "call",
+ ida_xref.fl_CN: "call",
+ ida_xref.fl_JF: "jump",
+ ida_xref.fl_JN: "jump",
+ ida_xref.fl_F: "flow",
+ }
+ data_kind = {
+ ida_xref.dr_O: "offset",
+ ida_xref.dr_W: "write",
+ ida_xref.dr_R: "read",
+ ida_xref.dr_T: "text",
+ ida_xref.dr_I: "info",
+ }
+
+ def _kind(xr):
+ return (code_kind if xr.iscode else data_kind).get(
+ xr.type, "code" if xr.iscode else "data"
+ )
+
+ def _fn(ea):
+ f = ida_funcs.get_func(ea)
+ return (
+ {
+ "addr": hex(int(f.start_ea)),
+ "name": ida_funcs.get_func_name(f.start_ea) or "",
+ }
+ if f
+ else None
+ )
+
+ queries = a.get("queries") or []
+ all_results = []
+ for query in queries:
+ query = query if isinstance(query, dict) else {"addr": query}
+ raw = str(query.get("addr", "")).strip()
+ direction = str(query.get("direction", "to") or "to").lower()
+ include_fn = bool(query.get("include_fn", True))
+ dedup = bool(query.get("dedup", True))
+ try:
+ count = int(query.get("count", 2000) or 2000)
+ except (TypeError, ValueError):
+ count = 2000
+ try:
+ target = int(raw, 16)
+ except ValueError:
+ target = idaapi.get_name_ea(idaapi.BADADDR, raw)
+ rows = []
+ if (
+ target is not None
+ and target != idaapi.BADADDR
+ and ida_bytes.is_mapped(target)
+ ):
+ if direction in ("to", "both"):
+ for xr in idautils.XrefsTo(target, 0):
+ row = {
+ "direction": "to",
+ "addr": hex(int(xr.frm)),
+ "from": hex(int(xr.frm)),
+ "to": hex(int(target)),
+ "type": "code" if xr.iscode else "data",
+ "kind": _kind(xr),
+ }
+ if include_fn:
+ row["fn"] = _fn(xr.frm)
+ rows.append(row)
+ if direction in ("from", "both"):
+ for xr in idautils.XrefsFrom(target, 0):
+ row = {
+ "direction": "from",
+ "addr": hex(int(xr.to)),
+ "from": hex(int(target)),
+ "to": hex(int(xr.to)),
+ "type": "code" if xr.iscode else "data",
+ "kind": _kind(xr),
+ }
+ if include_fn:
+ row["fn"] = _fn(xr.to)
+ rows.append(row)
+ if dedup:
+ seen, deduped = set(), []
+ for r in rows:
+ k = (r["direction"], r["from"], r["to"], r["kind"])
+ if k in seen:
+ continue
+ seen.add(k)
+ deduped.append(r)
+ rows = deduped
+ rows = rows[:count]
+ all_results.append({"query": raw, "data": rows, "next_offset": None})
+ result = {"result": all_results}
+ return result
+
+
+def op_format(addr: str, mode: str = "cycle", col: int = -1, n: int = -1) -> dict: ...
+
+
+def pc_nums(addr: str) -> dict: ...
+
+
+def decompile(addr, include_addresses=True) -> dict: ...
+
+
+def decomp_map(addr: str) -> dict: ...
+
+
+def pc_num_format(
+ addr: str,
+ mode: str = "cycle",
+ line: int = -1,
+ col: int = -1,
+ ea: str = "",
+ opnum: int = -1,
+) -> dict: ...
+
+
+def segment_index(
+ addr: str,
+ end: str = "",
+ page_rows: int = 500,
+ detail: bool = False,
+) -> dict: ...
+
+
+def heads(
+ addr: str,
+ count: int = 200,
+ offset: int = 0,
+ end: str = "",
+ back: bool = False,
+ annotate: bool = False,
+ expect: str = "",
+ text: bool = True,
+) -> dict: ...
+
+
+def profile_remote(operation: str, args: dict[str, Any], reps: int = 5) -> dict: ...
+
+
+OPERATIONS: dict[str, Callable[..., Any]] = {
+ "data_type": data_type,
+ "declare_type": declare_type,
+ "decomp_error": decomp_error,
+ "define_code": define_code,
+ "define_code_run": define_code_run,
+ "define_func": define_func,
+ "define_func_run": define_func_run,
+ "del_type": del_type,
+ "disasm": disasm,
+ "file_regions": file_regions,
+ "flowchart": flowchart,
+ "force_recompile": force_recompile,
+ "func_types": func_types,
+ "get_bytes": get_bytes,
+ "heads": heads,
+ "journal_get": journal_get,
+ "journal_put": journal_put,
+ "list_annotations": list_annotations,
+ "list_funcs": list_funcs,
+ "list_linkage": list_linkage,
+ "list_strings": list_strings,
+ "lookup_funcs": lookup_funcs,
+ "make_data": make_data,
+ "make_string": make_string,
+ "op_format": op_format,
+ "pc_nums": pc_nums,
+ "decompile": decompile,
+ "decomp_map": decomp_map,
+ "pc_num_format": pc_num_format,
+ "profile_remote": profile_remote,
+ "read_raw": read_raw,
+ "rename": rename,
+ "resolve_names": resolve_names,
+ "search_bytes": search_bytes,
+ "search_structs": search_structs,
+ "search_text": search_text,
+ "segment_index": segment_index,
+ "set_comments": set_comments,
+ "set_lvar_type": set_lvar_type,
+ "set_thumb": set_thumb,
+ "set_type": set_type,
+ "survey_binary": survey_binary,
+ "thumb_scan": thumb_scan,
+ "type_inspect": type_inspect,
+ "undefine": undefine,
+ "xref_query": xref_query,
+ "xref_types": xref_types,
+}
+
+_MODULE_DECLARATIONS = frozenset(
+ (
+ heads,
+ segment_index,
+ op_format,
+ pc_nums,
+ decompile,
+ decomp_map,
+ pc_num_format,
+ profile_remote,
+ )
+)
+_BOUND: dict[Callable[..., Any], Any] | None = None
+_BIND_LOCK = threading.Lock()
+
+
+def _bindings() -> dict[Callable[..., Any], Any]:
+ global _BOUND
+ with _BIND_LOCK:
+ if _BOUND is not None:
+ return _BOUND
+ # Gated on the binding, not a fresh import, so an injected fake is
+ # honoured (see the module docstring on the guarded import above).
+ if RemoteModule is None:
+ raise ImportError(
+ "The 'ida-nexus' package is required to execute remote "
+ "operations but is not installed in this interpreter."
+ )
+ operations_module = RemoteModule(
+ Path(__file__), operation_label=operation_label, codec="json"
+ )
+ tools_module = RemoteModule(
+ Path(__file__).with_name("remote_tools.py"),
+ operation_label=operation_label,
+ codec="json",
+ )
+ bound: dict[Callable[..., Any], Any] = {}
+ for declaration in OPERATIONS.values():
+ if declaration in _MODULE_DECLARATIONS:
+ bound[declaration] = tools_module.function(
+ declaration,
+ timeout=15.0 if declaration is decompile else None,
+ )
+ else:
+ bound[declaration] = operations_module.function(declaration)
+ _BOUND = bound
+ return bound
+
+
+def bind(function: Callable[..., Any]) -> Any:
+ """Return the lazily constructed remote callable for one declaration."""
+ return _bindings()[function]
diff --git a/idatui/remote_tools.py b/idatui/remote_tools.py
index 6fb6436..6681379 100644
--- a/idatui/remote_tools.py
+++ b/idatui/remote_tools.py
@@ -1,8 +1,8 @@
-"""The IDAPython ida-tui runs inside the Code Mode sandbox.
+"""The IDAPython ida-tui runs inside the IDA Nexus sandbox.
Two features have no ida-domain surface at all and are carried over VERBATIM
from the tools ida-tui was developed against (`server/patch_server.py`'s
-injected BODY, which the Code Mode port deletes):
+injected BODY, which the IDA Nexus port deletes):
* `heads` -- the continuous listing. ida-domain enumerates defined heads and
renders plain disassembly; the listing also needs coalesced undefined runs,
@@ -20,12 +20,54 @@ what you see). A re-implementation drifts from it silently.
This file is SOURCE SHIPPED AS TEXT to the database process; it is never
imported here, because the ida_* modules do not exist in the TUI's interpreter.
-`codemode_client` reads it and prepends it to the relevant snippets. Keep it
-self-contained: no relative imports, nothing beyond what Code Mode provides.
+`nexus_client` reads it and prepends it to the relevant snippets. Keep it
+self-contained: no relative imports, nothing beyond what IDA Nexus provides.
"""
+
# ruff: noqa
import re as _re
+# IDAPython, imported ONCE at module scope.
+#
+# This file is never imported by the client -- nexus_client reads it as
+# TEXT and installs it as a module inside the database process -- so the
+# no-IDA house rule that keeps idatui importable without IDA does not apply
+# here, and these need not be function-local.
+#
+# It is worth real time: a function-local `import` still costs a sys.modules
+# lookup per call (0.124us measured in the database process) and
+# _idatui_head_row did three per LISTING ROW -- 3.2ms on a 500-row page,
+# which the background grower pays 455 times to stream one bash.
+#
+# ida_hexrays is deliberately NOT here: it is licence-dependent, and a
+# module-level import would break this whole library for someone without the
+# decompiler instead of failing only when they decompile.
+import ida_bytes
+import ida_funcs
+import ida_lines
+import ida_nalt
+import ida_name
+import ida_offset
+import ida_segment
+import ida_typeinf
+import ida_ua
+import idaapi
+
+# Item-class bits, read once.
+#
+# ida_bytes.is_code/is_data are thin python wrappers around a C call, and the
+# listing walk asks ~6 times per head (once each for the row's kind, twice more
+# via _is_unknown_f from _advance and _rows_for). The mask compare is the same
+# question with no call at all.
+#
+# Equivalence is not assumed: it was checked against is_code/is_data over all
+# four classes x 16,020 synthetic flag values, and over every distinct flag
+# value in a real database (125 of them across 207,191 heads). Zero mismatches.
+# MS_CLS=0x600, FF_CODE=0x600, FF_DATA=0x400, FF_TAIL=0x200, FF_UNK=0x0.
+_MS_CLS = ida_bytes.MS_CLS
+_FF_CODE = ida_bytes.FF_CODE
+_FF_DATA = ida_bytes.FF_DATA
+
from typing import Annotated # the extracted tool signatures still carry these
@@ -40,7 +82,6 @@ def parse_address(addr):
try:
return int(addr, 0)
except ValueError:
- import idaapi
ea = idaapi.get_name_ea(idaapi.BADADDR, str(addr).strip())
if ea != idaapi.BADADDR:
return ea
@@ -73,105 +114,39 @@ def compact_whitespace(line: str) -> str:
return lead + _IDATUI_STRING_OR_SPACES_RE.sub(_repl, stripped)
-def _idatui_head_row(ea, flags=None):
+# NOTE: an identical, UNDECORATED copy of _idatui_head_row/_idatui_line_parts
+# used to sit here, shadowed by the real ones below. If you find one again:
+# keep the copy carrying @lru_cache. Deleting that one instead is a silent
+# ~2.7x regression on every listing row (10.4us -> 3.9us is the cache).
+def _idatui_head_row(ea, flags=None, text=True):
"""One flat-listing row for the head at ``ea``: kind (code/data/unknown),
byte size, rendered text, and any symbol name.
``flags`` lets a caller that already asked for them say so -- the walk in
``heads`` used to fetch them three times per head (here, in _is_unknown from
_advance, and again from _rows_for).
- """
- import ida_bytes
- import ida_lines
- import ida_name
-
- f = ida_bytes.get_flags(ea) if flags is None else flags
- if ida_bytes.is_code(f):
- kind = "code"
- elif ida_bytes.is_data(f):
- kind = "data"
- else:
- kind = "unknown"
- line = ida_lines.generate_disasm_line(ea, 0)
- text, spans, ops = _idatui_line_parts(line) if line else ("", None, None)
- row = {
- "ea": hex(ea),
- "kind": kind,
- "size": int(ida_bytes.get_item_size(ea)),
- "text": text,
- }
- if spans is not None:
- row["spans"] = spans
- # Where each operand sits in `text`. Comes out of the same tag walk
- # (free), and is what lets the client show WHICH literal a keypress
- # would reformat before you press it.
- if ops:
- row["ops"] = ops
- nm = ida_name.get_ea_name(ea)
- if nm:
- row["name"] = nm
- return row
-
-
-import functools as _idatui_functools
-
-
-import os as _idatui_os
-
-
-_IDATUI_LINE_CACHE = int(_idatui_os.environ.get("IDATUI_LINE_CACHE") or 65536)
-
-def _idatui_line_parts(line):
- """``(text, spans, ops)`` for one tagged disassembly line -- memoised.
-
- A function of the tagged line and nothing else, so the same line always
- gives the same answer: a rename changes the line, which changes the key.
- And listings repeat themselves hard -- 196k lines of bash are 53k distinct
- ones, so a 16k-entry cache serves ~70% of them and takes the per-line cost
- from 10.4us to 3.9us. This is the most expensive thing the backend does per
- listing row, and a jump to an address near the end of a big binary walks
- hundreds of thousands of them.
-
- ``spans`` is None when the tag walk and the plain text disagree about what
- the line says (then the text wins and the row renders unhighlighted).
-
- The returned lists are SHARED between every row that has the same line;
- treat them as read-only. Pickle notices the sharing too, so a page of
- repetitive disassembly also serialises smaller.
+ ``text=False`` builds a SKELETON row: address, kind, size and name, but no
+ rendered text and no colour spans. generate_disasm_line is 22x the cost of
+ the walk around it, and a caller that only needs to know how many rows a
+ segment has -- which is what sizing the scrollbar needs -- should not pay
+ it. The row COUNT and the addresses are identical either way, which is what
+ makes a skeleton page swappable for a real one later.
"""
- import ida_lines
- text = " ".join(ida_lines.tag_remove(line).split()) # collapse the padding
- spans, ops = _idatui_spans(line)
- # Built from the SAME line as `text`, then whitespace-collapsed identically,
- # so the two can never disagree about what the row says.
- joined = "".join([t for _k, t in spans])
- if " ".join(joined.split()) != text:
- return (text, None, None)
- return (text, spans, ops)
-
-
-def _idatui_head_row(ea, flags=None):
- """One flat-listing row for the head at ``ea``: kind (code/data/unknown),
- byte size, rendered text, and any symbol name.
-
- ``flags`` lets a caller that already asked for them say so -- the walk in
- ``heads`` used to fetch them three times per head (here, in _is_unknown from
- _advance, and again from _rows_for).
- """
- import ida_bytes
- import ida_lines
- import ida_name
f = ida_bytes.get_flags(ea) if flags is None else flags
- if ida_bytes.is_code(f):
+ cls = f & _MS_CLS # == is_code(f) / is_data(f), without the calls
+ if cls == _FF_CODE:
kind = "code"
- elif ida_bytes.is_data(f):
+ elif cls == _FF_DATA:
kind = "data"
else:
kind = "unknown"
- line = ida_lines.generate_disasm_line(ea, 0)
- text, spans, ops = _idatui_line_parts(line) if line else ("", None, None)
+ if text:
+ line = ida_lines.generate_disasm_line(ea, 0)
+ text, spans, ops = _idatui_line_parts(line) if line else ("", None, None)
+ else:
+ text, spans, ops = "", None, None
row = {
"ea": hex(ea),
"kind": kind,
@@ -219,7 +194,6 @@ def _idatui_line_parts(line):
treat them as read-only. Pickle notices the sharing too, so a page of
repetitive disassembly also serialises smaller.
"""
- import ida_lines
text = " ".join(ida_lines.tag_remove(line).split()) # collapse the padding
spans, ops = _idatui_spans(line)
# Built from the SAME line as `text`, then whitespace-collapsed identically,
@@ -238,10 +212,20 @@ _IDATUI_SPAN_KINDS = {
# NB the real constant names: DATNAME/CODNAME, not "DNAME". Guessing here
# fails silently — an unmapped tag renders as plain body text, so symbols
# just quietly aren't blue and nothing tells you why.
- "name": ("SCOLOR_DATNAME", "SCOLOR_CODNAME", "SCOLOR_LOCNAME",
- "SCOLOR_IMPNAME", "SCOLOR_DEMNAME", "SCOLOR_LIBNAME",
- "SCOLOR_CNAME", "SCOLOR_DNAME",
- "SCOLOR_CREF", "SCOLOR_DREF", "SCOLOR_CREFTAIL", "SCOLOR_DREFTAIL"),
+ "name": (
+ "SCOLOR_DATNAME",
+ "SCOLOR_CODNAME",
+ "SCOLOR_LOCNAME",
+ "SCOLOR_IMPNAME",
+ "SCOLOR_DEMNAME",
+ "SCOLOR_LIBNAME",
+ "SCOLOR_CNAME",
+ "SCOLOR_DNAME",
+ "SCOLOR_CREF",
+ "SCOLOR_DREF",
+ "SCOLOR_CREFTAIL",
+ "SCOLOR_DREFTAIL",
+ ),
"seg": ("SCOLOR_SEGNAME",),
"cmt": ("SCOLOR_AUTOCMT", "SCOLOR_REGCMT", "SCOLOR_RPTCMT", "SCOLOR_VOIDOP"),
"punct": ("SCOLOR_SYMBOL", "SCOLOR_ALTOP", "SCOLOR_HIDNAME"),
@@ -251,7 +235,6 @@ _IDATUI_SPAN_KINDS = {
def _idatui_tag_map():
"""{tag character: kind}, built once from whatever this IDA actually has."""
- import ida_lines
out = {}
for kind, names in _IDATUI_SPAN_KINDS.items():
for n in names:
@@ -269,7 +252,7 @@ _IDATUI_TAGS = None
_IDATUI_OPND_TAGS = None
-_IDATUI_CTL = None # re: a tag = one of three control chars plus its argument
+_IDATUI_CTL = None # re: a tag = one of three control chars plus its argument
_IDATUI_TAGINFO = None
@@ -280,7 +263,6 @@ def _idatui_opnd_tag_map():
line in COLOR_OPND1..8, so the line already says where operand N starts and
ends -- no need to re-render operands with print_operand to find out (and
the two agree exactly; checked over thousands of instructions)."""
- import ida_lines
out = {}
for i in range(1, 9):
v = getattr(ida_lines, "COLOR_OPND%d" % i, None)
@@ -303,13 +285,13 @@ def _idatui_spans(line):
emit a colour we don't classify, and losing the characters would corrupt the
line."""
global _IDATUI_TAGS, _IDATUI_OPND_TAGS, _IDATUI_CTL, _IDATUI_TAGINFO
- import ida_lines
if _IDATUI_TAGS is None:
_IDATUI_TAGS = _idatui_tag_map()
if _IDATUI_OPND_TAGS is None:
_IDATUI_OPND_TAGS = _idatui_opnd_tag_map()
if _IDATUI_CTL is None:
import re as _re
+
# One capturing split gives [text, tag, text, tag, ..., text] in a
# single C pass. A per-character python loop over the line used to be
# the most expensive thing the `heads` tool did, and a line is ~54
@@ -319,17 +301,18 @@ def _idatui_spans(line):
if _IDATUI_TAGINFO is None:
_IDATUI_TAGINFO = {
tag: (_IDATUI_TAGS.get(tag, "text"), _IDATUI_OPND_TAGS.get(tag))
- for tag in set(_IDATUI_TAGS) | set(_IDATUI_OPND_TAGS)}
+ for tag in set(_IDATUI_TAGS) | set(_IDATUI_OPND_TAGS)
+ }
taginfo = _IDATUI_TAGINFO
plain_tag = ("text", None)
on, off, esc = "\x01", "\x02", "\x03"
addr_tag = chr(getattr(ida_lines, "COLOR_ADDR", 0x28))
addr_len = int(getattr(ida_lines, "COLOR_ADDR_SIZE", 16))
parts = _IDATUI_CTL.split(line)
- spans, stack = [], [] # stack entries: (kind, operand index|None)
- kind, opnd = "text", None # state the current run of text belongs to
+ spans, stack = [], [] # stack entries: (kind, operand index|None)
+ kind, opnd = "text", None # state the current run of text belongs to
pend = ""
- skip = 0 # characters of an address payload still due
+ skip = 0 # characters of an address payload still due
i, n = 0, len(parts)
while i < n:
txt = parts[i]
@@ -347,11 +330,11 @@ def _idatui_spans(line):
break
pair = parts[i]
i += 1
- if skip: # a tag INSIDE an address payload: 2 chars
+ if skip: # a tag INSIDE an address payload: 2 chars
skip = skip - 2 if skip > 2 else 0
continue
ch = pair[0]
- if ch == esc: # escaped literal: keep the char it guards
+ if ch == esc: # escaped literal: keep the char it guards
pend += pair[1]
continue
tag = pair[1]
@@ -367,7 +350,7 @@ def _idatui_spans(line):
stack.append((kind, opnd))
kind, o = taginfo.get(tag, plain_tag)
if o is not None:
- opnd = o # operands nest: an inner colour keeps the operand
+ opnd = o # operands nest: an inner colour keeps the operand
elif stack:
kind, opnd = stack.pop()
else:
@@ -389,7 +372,7 @@ def _idatui_spans(line):
prev_space = False
out.append([kind, txt, opnd])
continue
- if not core: # the span is nothing but padding
+ if not core: # the span is nothing but padding
if not prev_space:
prev_space = True
out.append([kind, " ", opnd])
@@ -426,7 +409,7 @@ def _idatui_spans(line):
ops.append([start, pos, cur])
text = "".join(t for _k, t, _o in out)
trimmed = []
- for lo, hi, k in ops: # don't let a range own trailing space
+ for lo, hi, k in ops: # don't let a range own trailing space
while hi > lo and text[hi - 1].isspace():
hi -= 1
while lo < hi and text[lo].isspace():
@@ -465,8 +448,17 @@ def _idatui_rows_digest(rows):
sh = seen.get(key)
if sh is None:
sh = seen[key] = hash(tuple(map(tuple, sp)))
- acc = hash((acc, r.get("ea"), r.get("kind"), r.get("size"),
- r.get("text"), r.get("name"), sh))
+ acc = hash(
+ (
+ acc,
+ r.get("ea"),
+ r.get("kind"),
+ r.get("size"),
+ r.get("text"),
+ r.get("name"),
+ sh,
+ )
+ )
return acc
@@ -475,12 +467,15 @@ def _idatui_unknown_row(ea, size):
``ea``. A single byte is rendered normally (shows its value); a longer run
collapses to ``db N dup(?)`` so a big .bss/gap doesn't explode into millions
of one-byte rows."""
- import ida_name
if size <= 1:
return _idatui_head_row(ea)
- row = {"ea": hex(ea), "kind": "unknown", "size": int(size),
- "text": f"db {size} dup(?)"}
+ row = {
+ "ea": hex(ea),
+ "kind": "unknown",
+ "size": int(size),
+ "text": f"db {size} dup(?)",
+ }
nm = ida_name.get_ea_name(ea)
if nm:
row["name"] = nm
@@ -490,9 +485,6 @@ def _idatui_unknown_row(ea, size):
def _idatui_struct_member_rows(ea):
"""Indented member rows for a struct-typed data item at ``ea`` (expansion),
or [] if it isn't a struct. Top-level fields only."""
- import ida_nalt
- import ida_typeinf
- import idaapi
tif = ida_typeinf.tinfo_t()
if not (ida_nalt.get_tinfo(tif, ea) and tif.is_udt()):
@@ -515,45 +507,301 @@ def _idatui_struct_member_rows(ea):
sz = 0
name = m.name or ""
text = f"+{off:X} {name}" + (f" {mtype}" if mtype else "")
- rows.append({"ea": hex(ea + off), "kind": "member", "size": sz,
- "text": text})
+ rows.append({"ea": hex(ea + off), "kind": "member", "size": sz, "text": text})
return rows
def _idatui_func_header_rows(ea):
"""IDA-style subroutine banner rows shown just before a function's entry."""
- import ida_funcs
name = ida_funcs.get_func_name(ea) or "sub_%X" % ea
bar = "=" * 15 + " S U B R O U T I N E " + "=" * 15
return [
{"ea": hex(ea), "kind": "sep", "size": 0, "text": ""},
{"ea": hex(ea), "kind": "sep", "size": 0, "text": "; " + bar},
- {"ea": hex(ea), "kind": "funchdr", "size": 0,
- "text": name + " proc", "name": name},
+ {
+ "ea": hex(ea),
+ "kind": "funchdr",
+ "size": 0,
+ "text": name + " proc",
+ "name": name,
+ },
]
def _idatui_func_footer_rows(ea, func):
"""End-of-function marker shown just after a function's last item."""
- import ida_funcs
name = ida_funcs.get_func_name(func.start_ea) or "sub_%X" % func.start_ea
return [
- {"ea": hex(ea), "kind": "funchdr", "size": 0,
- "text": name + " endp", "name": name},
+ {
+ "ea": hex(ea),
+ "kind": "funchdr",
+ "size": 0,
+ "text": name + " endp",
+ "name": name,
+ },
{"ea": hex(ea), "kind": "sep", "size": 0, "text": "; " + "-" * 60},
]
+#: Row kinds, as small ints, for the packed detail index. Order is frozen: the
+#: client decodes by position.
+_IDATUI_KINDS = ("code", "data", "unknown", "sep", "funchdr", "label", "member")
+_IDATUI_KIND_ID = {k: i for i, k in enumerate(_IDATUI_KINDS)}
+
+
+def _idatui_segment_detail(addr, end, page_rows):
+ """Every listing ROW of a segment as packed arrays, with no text.
+
+ ``{eas, kinds, sizes}`` are raw buffers -- uint64, uint8, uint32, one entry
+ per row in listing order -- so the client can build its whole row index
+ (addresses, spans, ea->row map) from ONE call instead of 458 pages.
+
+ This re-implements the row sequence that ``_rows_for`` emits rather than
+ calling it, because building the dicts is most of what a page costs and
+ skipping them is the entire point. That duplication is the risk, so it is
+ covered by a test that walks a whole segment and compares this against the
+ real ``heads()`` output row for row -- if the two ever drift, that fails.
+ """
+ import array
+ import ida_segment
+
+ start = parse_address(addr)
+ seg = ida_segment.getseg(start)
+ if not seg:
+ return {"addr": str(addr), "error": "no segment", "rows": 0, "anchors": []}
+ lo, hi = seg.start_ea, seg.end_ea
+ if end:
+ try:
+ hi = min(hi, parse_address(end))
+ except Exception:
+ pass
+
+ K_CODE = _IDATUI_KIND_ID["code"]
+ K_DATA = _IDATUI_KIND_ID["data"]
+ K_UNK = _IDATUI_KIND_ID["unknown"]
+ K_SEP = _IDATUI_KIND_ID["sep"]
+ K_FUNC = _IDATUI_KIND_ID["funchdr"]
+ K_LABEL = _IDATUI_KIND_ID["label"]
+ K_MEMBER = _IDATUI_KIND_ID["member"]
+
+ eas = array.array("Q")
+ kinds = array.array("B")
+ sizes = array.array("I")
+ ea_ap, kind_ap, size_ap = eas.append, kinds.append, sizes.append
+
+ get_flags = ida_bytes.get_flags
+ get_item_end = ida_bytes.get_item_end
+ get_item_size = ida_bytes.get_item_size
+ next_head = ida_bytes.next_head
+ get_ea_name = ida_name.get_ea_name
+ get_func = idaapi.get_func
+ BAD = idaapi.BADADDR
+
+ # Anchors mark where heads(addr=..., count=page_rows) would START each page,
+ # so a client can refetch exactly one page. heads() stops once it has
+ # emitted >= count PHYSICAL rows, checked before the next head -- so a
+ # boundary is the first head at which the running physical count reached the
+ # limit. Anchoring every N LOGICAL rows instead looks equivalent (the two
+ # are the same number until a segment contains an undefined run) and then
+ # silently yields pages that do not line up with a refetch.
+ anchors = []
+ page_phys = 0 # physical rows emitted into the page being filled
+ rows = 0 # logical rows so far (what the scrollbar counts)
+ fn = None
+ ea = ida_bytes.get_item_head(lo)
+ while ea != BAD and ea < hi:
+ if not anchors or page_phys >= page_rows:
+ anchors.append([rows, hex(ea), len(eas)])
+ page_phys = 0
+ before = len(eas)
+ f = get_flags(ea)
+ cls = f & _MS_CLS
+ if cls != _FF_CODE and cls != _FF_DATA:
+ nh = next_head(ea, hi)
+ stop = nh if (nh != BAD and ea < nh <= hi) else hi
+ run = stop - ea
+ ea_ap(ea)
+ kind_ap(K_UNK)
+ size_ap(run)
+ rows += run if run > 1 else 1
+ page_phys += len(eas) - before
+ ea = stop
+ continue
+ if fn is None or not (fn.start_ea <= ea < fn.end_ea):
+ fn = get_func(ea)
+ at_start = fn is not None and fn.start_ea == ea
+ if at_start:
+ for k in (K_SEP, K_SEP, K_FUNC): # blank, banner, `name proc`
+ ea_ap(ea)
+ kind_ap(k)
+ size_ap(0)
+ rows += 3
+ elif cls == _FF_CODE and get_ea_name(ea):
+ ea_ap(ea)
+ kind_ap(K_LABEL)
+ size_ap(0)
+ rows += 1
+ ea_ap(ea)
+ kind_ap(K_CODE if cls == _FF_CODE else K_DATA)
+ size_ap(int(get_item_size(ea)))
+ rows += 1
+ if cls == _FF_DATA:
+ for m in _idatui_struct_member_rows(ea):
+ ea_ap(int(m["ea"], 16) if isinstance(m["ea"], str) else m["ea"])
+ kind_ap(_IDATUI_KIND_ID.get(m.get("kind", "member"), K_MEMBER))
+ size_ap(int(m.get("size", 0) or 0))
+ rows += 1
+ item_end = get_item_end(ea)
+ if fn is not None and item_end >= fn.end_ea:
+ for k in (K_FUNC, K_SEP): # `name endp`, separator
+ ea_ap(ea)
+ kind_ap(k)
+ size_ap(0)
+ rows += 2
+ page_phys += len(eas) - before
+ ea = item_end if item_end > ea else ea + 1
+
+ # base64, not raw bytes: the client packs answers with json.dumps(default=str),
+ # which turns a bytes object into its repr -- 4 characters per byte and
+ # unparseable at the other end. Learned by watching 2.97MB arrive as 11.26MB.
+ import base64
+
+ b64 = base64.b64encode
+ return {
+ "addr": hex(lo),
+ "end": hex(hi),
+ "rows": rows,
+ "heads": len(eas),
+ "anchors": anchors,
+ "kind_names": list(_IDATUI_KINDS),
+ "eas": b64(eas.tobytes()).decode(),
+ "kinds": b64(kinds.tobytes()).decode(),
+ "sizes": b64(sizes.tobytes()).decode(),
+ }
+
+
+def segment_index(
+ addr: Annotated[str, "Any address in the segment to index"],
+ end: Annotated[str, "Optional exclusive end address; default = segment end"] = "",
+ page_rows: Annotated[int, "Rows between anchors (default 500)"] = 500,
+ detail: Annotated[
+ bool, "Also return every row's ea/kind/size as packed arrays"
+ ] = False,
+) -> dict:
+ """How many listing rows a segment has, and where to seek into it.
+
+ The listing needs a total row count to size its scrollbar, and the only way
+ to get one used to be to fetch every row: 455 round trips and 227k rows for
+ a 1.2MB bash, none of which is looked at. This walks the same items and
+ counts what ``heads`` WOULD emit, without building or rendering any of them.
+
+ Returns ``{rows, heads, anchors}`` where anchors is ``[[row, ea], ...]``
+ every ``page_rows`` logical rows -- enough to turn "show me row N" into a
+ ``heads(addr=anchor)`` call, so pages can be fetched on demand instead of
+ streamed in order.
+
+ **The count must match what heads() actually emits, exactly**, or the
+ scrollbar lies and a jump lands on the wrong row. It therefore mirrors
+ _rows_for's arithmetic rather than approximating it: 3 banner rows at a
+ function start, a label row for a named code head that is not one, the head
+ row itself, struct member rows for data, 2 footer rows at a function end,
+ and an undefined run counted as its byte length (the client presents one
+ collapsed row as that many logical rows). Verified equal to summing the real
+ pages, head for head, over a whole segment.
+ """
+
+ count = max(int(page_rows), 1)
+ try:
+ start = parse_address(addr)
+ except Exception as e:
+ return {"addr": str(addr), "error": str(e), "rows": 0, "anchors": []}
+ if detail:
+ return _idatui_segment_detail(addr, end, count)
+ import ida_segment
+
+ seg = ida_segment.getseg(start)
+ if not seg:
+ return {"addr": str(addr), "error": "no segment", "rows": 0, "anchors": []}
+ lo, hi = seg.start_ea, seg.end_ea
+ if end:
+ try:
+ hi = min(hi, parse_address(end))
+ except Exception:
+ pass
+
+ get_flags = ida_bytes.get_flags
+ get_item_end = ida_bytes.get_item_end
+ next_head = ida_bytes.next_head
+ get_ea_name = ida_name.get_ea_name
+ get_func = idaapi.get_func
+ BAD = idaapi.BADADDR
+
+ rows = 0
+ n_heads = 0
+ anchors = []
+ fn = None
+ ea = ida_bytes.get_item_head(lo)
+ while ea != BAD and ea < hi:
+ if rows // count >= len(anchors):
+ anchors.append([rows, hex(ea)])
+ f = get_flags(ea)
+ cls = f & _MS_CLS
+ if cls != _FF_CODE and cls != _FF_DATA:
+ # An undefined run is ONE emitted row that PRESENTS as one logical
+ # row per byte (see _idatui_unknown_row and the client's _span).
+ nh = next_head(ea, hi)
+ stop = nh if (nh != BAD and ea < nh <= hi) else hi
+ run = stop - ea
+ rows += run if run > 1 else 1
+ n_heads += 1
+ ea = stop
+ continue
+ if fn is None or not (fn.start_ea <= ea < fn.end_ea):
+ fn = get_func(ea)
+ n = 1
+ if fn is not None and fn.start_ea == ea:
+ n += 3 # blank, banner, `proc`
+ elif cls == _FF_CODE and get_ea_name(ea):
+ n += 1 # loc_XXX label on its own row
+ if cls == _FF_DATA:
+ n += len(_idatui_struct_member_rows(ea))
+ item_end = get_item_end(ea)
+ if fn is not None and item_end >= fn.end_ea:
+ n += 2 # `endp` + separator
+ rows += n
+ n_heads += 1
+ ea = item_end if item_end > ea else ea + 1
+ return {
+ "addr": hex(lo),
+ "end": hex(hi),
+ "rows": rows,
+ "heads": n_heads,
+ "anchors": anchors,
+ }
+
+
def heads(
addr: Annotated[str, "Start address or name to walk from"],
count: Annotated[int, "Max heads to return (default 200, max 2000)"] = 200,
offset: Annotated[int, "Skip first N heads from addr (default 0)"] = 0,
end: Annotated[str, "Optional exclusive end address; default = segment end"] = "",
- back: Annotated[bool, "Walk backwards: return the count heads ENDING just before addr, in forward order"] = False,
- annotate: Annotated[bool, "Emit IDA-style function boundary banner rows (kind sep/funchdr)"] = False,
- expect: Annotated[str, "Digest a caller already holds: the rows are omitted when they still hash to it"] = "",
+ back: Annotated[
+ bool,
+ "Walk backwards: return the count heads ENDING just before addr, in forward order",
+ ] = False,
+ annotate: Annotated[
+ bool, "Emit IDA-style function boundary banner rows (kind sep/funchdr)"
+ ] = False,
+ expect: Annotated[
+ str,
+ "Digest a caller already holds: the rows are omitted when they still hash to it",
+ ] = "",
+ text: Annotated[
+ bool,
+ "Render each row's disassembly text (default true). False = a skeleton page: same rows, same addresses, no text",
+ ] = True,
) -> dict:
"""Walk item heads from ``addr`` as a flat listing: every head is rendered
(code OR data OR undefined) via generate_disasm_line and stepped with
@@ -561,19 +809,26 @@ def heads(
byte) this shows db/dw/dd/... lines for data and undefined regions — IDA's
real disassembly view. Address-paged: page forward by re-calling with
``addr`` = the returned cursor.next; page up with ``back=true``."""
- import ida_bytes
- import ida_segment
- import idaapi
count = 2000 if count > 2000 else (1 if count < 1 else count)
offset = max(int(offset), 0)
try:
start = parse_address(addr)
except Exception as e:
- return {"addr": str(addr), "error": str(e), "heads": [], "cursor": {"done": True}}
+ return {
+ "addr": str(addr),
+ "error": str(e),
+ "heads": [],
+ "cursor": {"done": True},
+ }
seg = ida_segment.getseg(start)
if not seg:
- return {"addr": str(addr), "error": "no segment", "heads": [], "cursor": {"done": True}}
+ return {
+ "addr": str(addr),
+ "error": "no segment",
+ "heads": [],
+ "cursor": {"done": True},
+ }
lo, hi = seg.start_ea, seg.end_ea
if end:
try:
@@ -596,7 +851,9 @@ def heads(
rows = [_idatui_head_row(e) for e in chosen]
first = chosen[0] if chosen else start
pea = ida_bytes.prev_head(first, lo)
- cursor = {"done": True} if pea == idaapi.BADADDR or pea < lo else {"prev": hex(pea)}
+ cursor = (
+ {"done": True} if pea == idaapi.BADADDR or pea < lo else {"prev": hex(pea)}
+ )
return {"addr": str(addr), "heads": rows, "cursor": cursor}
# Walk by item END (not next_head): next_head SKIPS undefined bytes, but a
@@ -606,7 +863,9 @@ def heads(
# end found in O(1) via next_head, which skips undefined) so a large .bss or
# gap doesn't explode into millions of one-byte rows.
def _is_unknown_f(f):
- return not (ida_bytes.is_code(f) or ida_bytes.is_data(f))
+ # Hot: twice per head. See _MS_CLS -- same test, no call.
+ cls = f & _MS_CLS
+ return cls != _FF_CODE and cls != _FF_DATA
def _run_end(e):
"""End (exclusive) of the undefined run starting at ``e``."""
@@ -643,7 +902,7 @@ def heads(
out = []
if at_start:
out.extend(_idatui_func_header_rows(e))
- row = _idatui_head_row(e, f)
+ row = _idatui_head_row(e, f, text)
if at_start:
row = dict(row)
row["name"] = None # the name is shown on the proc header line
@@ -651,8 +910,9 @@ def heads(
# A code label (loc_XXX/jump target) gets its OWN line at depth 0,
# like IDA; strip it from the instruction row below.
nm = row["name"]
- out.append({"ea": hex(e), "kind": "label", "size": 0,
- "text": nm + ":", "name": nm})
+ out.append(
+ {"ea": hex(e), "kind": "label", "size": 0, "text": nm + ":", "name": nm}
+ )
row = dict(row)
row["name"] = None
out.append(row)
@@ -673,11 +933,13 @@ def heads(
if len(rows) >= count:
more = True
break
- f = get_flags(ea) # once per head, not once per consumer
+ f = get_flags(ea) # once per head, not once per consumer
rows.extend(_rows_for(ea, f)) # a struct head expands into member rows
ea = _advance(ea, f)
cursor = {"next": hex(ea)} if more else {"done": True}
- dig = _idatui_rows_digest(rows)
+ # A skeleton page has no text to go stale, so there is nothing to digest --
+ # and the digest is only ever used to skip re-sending text.
+ dig = _idatui_rows_digest(rows) if text else None
out = {"addr": str(addr), "cursor": cursor, "digest": dig, "count": len(rows)}
# ``expect`` says "I already hold a page that hashed to this". The rows are
# built either way -- generate_disasm_line is the floor and there is no way
@@ -697,23 +959,39 @@ def heads(
_IDATUI_FMT_CYCLE = ("hex", "dec", "bin", "char", "offset", "default")
-_IDATUI_FMT_SETTABLE = ("hex", "dec", "oct", "bin", "char", "offset", "seg",
- "float", "stack", "default")
+_IDATUI_FMT_SETTABLE = (
+ "hex",
+ "dec",
+ "oct",
+ "bin",
+ "char",
+ "offset",
+ "seg",
+ "float",
+ "stack",
+ "default",
+)
def _idatui_fmt_nibbles():
"""{format name: IDA operand-type nibble}. Built on call, not at import:
this module is injected into a file that is imported before a database is
open."""
- import ida_bytes
return {
- "default": ida_bytes.FF_N_VOID, "hex": ida_bytes.FF_N_NUMH,
- "dec": ida_bytes.FF_N_NUMD, "char": ida_bytes.FF_N_CHAR,
- "seg": ida_bytes.FF_N_SEG, "offset": ida_bytes.FF_N_OFF,
- "bin": ida_bytes.FF_N_NUMB, "oct": ida_bytes.FF_N_NUMO,
- "enum": ida_bytes.FF_N_ENUM, "forced": ida_bytes.FF_N_FOP,
- "stroff": ida_bytes.FF_N_STRO, "stack": ida_bytes.FF_N_STK,
- "float": ida_bytes.FF_N_FLT, "custom": ida_bytes.FF_N_CUST,
+ "default": ida_bytes.FF_N_VOID,
+ "hex": ida_bytes.FF_N_NUMH,
+ "dec": ida_bytes.FF_N_NUMD,
+ "char": ida_bytes.FF_N_CHAR,
+ "seg": ida_bytes.FF_N_SEG,
+ "offset": ida_bytes.FF_N_OFF,
+ "bin": ida_bytes.FF_N_NUMB,
+ "oct": ida_bytes.FF_N_NUMO,
+ "enum": ida_bytes.FF_N_ENUM,
+ "forced": ida_bytes.FF_N_FOP,
+ "stroff": ida_bytes.FF_N_STRO,
+ "stack": ida_bytes.FF_N_STK,
+ "float": ida_bytes.FF_N_FLT,
+ "custom": ida_bytes.FF_N_CUST,
}
@@ -730,7 +1008,6 @@ def _idatui_op_fmt(ea, n):
Reads the nibble IDA keeps per operand rather than guessing from the text --
``1`` renders identically in hex and decimal, so the rendered line cannot
answer this."""
- import ida_bytes
F = ida_bytes.get_flags(ea)
nib = (F >> ida_bytes.get_operand_type_shift(int(n))) & 0xF
return _idatui_fmt_name(nib)
@@ -741,8 +1018,6 @@ def _idatui_op_value(ea, n):
The value is what decides which formats are OFFERED: a character constant
for 0x38A9 or an offset to an unmapped address are stops worth skipping."""
- import ida_bytes
- import ida_ua
F = ida_bytes.get_flags(ea)
if ida_bytes.is_code(F):
@@ -762,8 +1037,12 @@ def _idatui_op_value(ea, n):
size = 0
return int(v), size
size = int(ida_bytes.get_item_size(ea))
- read = {1: ida_bytes.get_byte, 2: ida_bytes.get_word,
- 4: ida_bytes.get_dword, 8: ida_bytes.get_qword}.get(size)
+ read = {
+ 1: ida_bytes.get_byte,
+ 2: ida_bytes.get_word,
+ 4: ida_bytes.get_dword,
+ 8: ida_bytes.get_qword,
+ }.get(size)
if read is None:
return None, size
try:
@@ -801,8 +1080,6 @@ def _idatui_offset_worth(v):
An explicit request still converts anything mapped: that's a decision, not a
keypress that happened to land here. After it, the target HAS a name, so the
ring includes the stop from then on."""
- import ida_bytes
- import ida_name
return bool(v and ida_bytes.is_mapped(v) and ida_name.get_ea_name(v))
@@ -820,14 +1097,12 @@ def _idatui_op_candidates(ea):
that appears to do nothing is worse than one that says it can't.
An explicit ``n`` still reaches them; this is what a bare cursor picks."""
- import ida_bytes
- import ida_ua
F = ida_bytes.get_flags(ea)
if ida_bytes.is_data(F):
- return [0] # a data item's value is operand 0
+ return [0] # a data item's value is operand 0
if not ida_bytes.is_code(F):
- return [] # undefined bytes: IDA refuses a format outright
+ return [] # undefined bytes: IDA refuses a format outright
insn = ida_ua.insn_t()
if ida_ua.decode_insn(insn, ea) <= 0:
return []
@@ -852,8 +1127,6 @@ def _idatui_op_spans(ea, text):
fallback for a processor module that emits no operand markers -- it agrees
with the tags where both exist, but it re-renders every operand to say so.
"""
- import ida_lines
- import ida_ua
line = ida_lines.generate_disasm_line(ea, 0)
if line:
@@ -873,7 +1146,7 @@ def _idatui_op_spans(ea, text):
if not op:
continue
i = text.find(op, pos)
- if i < 0: # duplicated operand text (mov eax, eax)
+ if i < 0: # duplicated operand text (mov eax, eax)
i = text.find(op)
if i < 0:
continue
@@ -883,7 +1156,6 @@ def _idatui_op_spans(ea, text):
def _idatui_line_text(ea):
- import ida_lines
line = ida_lines.generate_disasm_line(ea, 0)
return " ".join(ida_lines.tag_remove(line).split()) if line else ""
@@ -898,9 +1170,6 @@ def _idatui_op_text(ea, text, n):
def _idatui_apply_fmt(ea, n, fmt):
"""Set operand ``n``'s display format. Returns (ok, error)."""
- import ida_bytes
- import ida_offset
- import idaapi
n = int(n)
if fmt == "default":
@@ -910,21 +1179,34 @@ def _idatui_apply_fmt(ea, n, fmt):
if base in (idaapi.BADADDR, None) or base < 0:
base = 0
return bool(ida_offset.op_plain_offset(ea, n, base)), ""
- fn = {"hex": ida_bytes.op_hex, "dec": ida_bytes.op_dec,
- "oct": ida_bytes.op_oct, "bin": ida_bytes.op_bin,
- "char": ida_bytes.op_chr, "seg": ida_bytes.op_seg,
- "float": ida_bytes.op_flt, "stack": ida_bytes.op_stkvar}.get(fmt)
+ fn = {
+ "hex": ida_bytes.op_hex,
+ "dec": ida_bytes.op_dec,
+ "oct": ida_bytes.op_oct,
+ "bin": ida_bytes.op_bin,
+ "char": ida_bytes.op_chr,
+ "seg": ida_bytes.op_seg,
+ "float": ida_bytes.op_flt,
+ "stack": ida_bytes.op_stkvar,
+ }.get(fmt)
if fn is None:
- return False, (f"can't set {fmt!r} from a name alone"
- if fmt in _idatui_fmt_nibbles() else
- f"unknown format {fmt!r}")
+ return False, (
+ f"can't set {fmt!r} from a name alone"
+ if fmt in _idatui_fmt_nibbles()
+ else f"unknown format {fmt!r}"
+ )
return bool(fn(ea, n)), ""
def op_format(
addr: Annotated[str, "Address of the instruction or data item"],
- mode: Annotated[str, "cycle | back | show | hex | dec | oct | bin | char | offset | stack | default"] = "cycle",
- col: Annotated[int, "Cursor column inside the rendered line (-1: first literal)"] = -1,
+ mode: Annotated[
+ str,
+ "cycle | back | show | hex | dec | oct | bin | char | offset | stack | default",
+ ] = "cycle",
+ col: Annotated[
+ int, "Cursor column inside the rendered line (-1: first literal)"
+ ] = -1,
n: Annotated[int, "Operand index; -1 derives it from ``col``"] = -1,
) -> dict:
"""Change how a literal is DISPLAYED (IDA's 'o' family): hex, decimal,
@@ -943,7 +1225,6 @@ def op_format(
Which operand: ``n`` if given, else the one under ``col`` (a column in the
whitespace-collapsed line, as ``heads`` renders it), else the first literal
on the line."""
- import ida_bytes
try:
ea = ida_bytes.get_item_head(parse_address(addr))
@@ -967,19 +1248,27 @@ def op_format(
# a different operand would make that highlight a lie -- say
# which one can be changed instead.
where = before[lo:hi].strip()
- alt = (f"; the literal on this line is operand {cands[0]} "
- f"({_idatui_op_text(ea, before, cands[0])})"
- if cands else "")
- return {"addr": hex(ea), "n": i, "text": before,
- "error": f"operand {i} ({where}) has no format to "
- f"change{alt}"}
+ alt = (
+ f"; the literal on this line is operand {cands[0]} "
+ f"({_idatui_op_text(ea, before, cands[0])})"
+ if cands
+ else ""
+ )
+ return {
+ "addr": hex(ea),
+ "n": i,
+ "text": before,
+ "error": f"operand {i} ({where}) has no format to change{alt}",
+ }
if n < 0:
if not cands:
F = ida_bytes.get_flags(ea)
- why = ("no literal on this line to reformat"
- if ida_bytes.is_code(F) or ida_bytes.is_data(F) else
- "undefined bytes have no format to change -- define "
- "them first ('d' makes data, 'c' makes code)")
+ why = (
+ "no literal on this line to reformat"
+ if ida_bytes.is_code(F) or ida_bytes.is_data(F)
+ else "undefined bytes have no format to change -- define "
+ "them first ('d' makes data, 'c' makes code)"
+ )
return {"addr": hex(ea), "text": before, "error": why}
n = cands[0]
@@ -989,9 +1278,12 @@ def op_format(
# The ring is a property of the OPERAND, not of what you last pressed: every
# stop is one that changes what you see for this value, and it is the same
# ring at every step, so a lap always comes home.
- choices = [f for f in _IDATUI_FMT_CYCLE
- if (f != "char" or _idatui_printable(value))
- and (f != "offset" or _idatui_offset_worth(value))]
+ choices = [
+ f
+ for f in _IDATUI_FMT_CYCLE
+ if (f != "char" or _idatui_printable(value))
+ and (f != "offset" or _idatui_offset_worth(value))
+ ]
# A stack variable is deliberately NOT a stop: ``[rbp+var_40]`` is a frame
# member, not a way of writing a number, and IDA's own "is this a stack
# variable" test isn't exposed to Python here (calc_stkvar_struc_offset
@@ -1001,10 +1293,18 @@ def op_format(
mode = str(mode or "cycle").lower()
if mode == "show":
- return {"addr": hex(ea), "n": n, "format": cur, "prev": cur,
- "choices": choices, "text": before, "before": before,
- "value": None if value is None else hex(value),
- "width": width, "applied": False}
+ return {
+ "addr": hex(ea),
+ "n": n,
+ "format": cur,
+ "prev": cur,
+ "choices": choices,
+ "text": before,
+ "before": before,
+ "value": None if value is None else hex(value),
+ "width": width,
+ "applied": False,
+ }
if mode in ("cycle", "back"):
step = 1 if mode == "cycle" else -1
if cur in choices:
@@ -1017,31 +1317,51 @@ def op_format(
else:
want = mode
if want not in _idatui_fmt_nibbles():
- return {"addr": hex(ea), "n": n, "text": before,
- "error": f"unknown format {mode!r}; one of "
- + ", ".join(_IDATUI_FMT_SETTABLE)}
+ return {
+ "addr": hex(ea),
+ "n": n,
+ "text": before,
+ "error": f"unknown format {mode!r}; one of "
+ + ", ".join(_IDATUI_FMT_SETTABLE),
+ }
if want == "offset" and not mapped:
- return {"addr": hex(ea), "n": n, "text": before, "format": cur,
- "error": (f"{'0x%x' % value if value is not None else 'this operand'}"
- " isn't a mapped address -- an offset to it would"
- " invent a name for nothing")}
+ return {
+ "addr": hex(ea),
+ "n": n,
+ "text": before,
+ "format": cur,
+ "error": (
+ f"{'0x%x' % value if value is not None else 'this operand'}"
+ " isn't a mapped address -- an offset to it would"
+ " invent a name for nothing"
+ ),
+ }
ok, err = _idatui_apply_fmt(ea, n, want)
if err:
- return {"addr": hex(ea), "n": n, "text": before, "format": cur,
- "error": err}
+ return {"addr": hex(ea), "n": n, "text": before, "format": cur, "error": err}
got = _idatui_op_fmt(ea, n)
- out = {"addr": hex(ea), "n": n, "prev": cur, "format": got,
- "requested": want, "applied": bool(ok), "choices": choices,
- "before": before, "text": _idatui_line_text(ea),
- "value": None if value is None else hex(value), "width": width}
+ out = {
+ "addr": hex(ea),
+ "n": n,
+ "prev": cur,
+ "format": got,
+ "requested": want,
+ "applied": bool(ok),
+ "choices": choices,
+ "before": before,
+ "text": _idatui_line_text(ea),
+ "value": None if value is None else hex(value),
+ "width": width,
+ }
if not ok:
out["error"] = f"IDA refused {want} on operand {n}"
elif lossy:
- out["warn"] = (
- f"operand {n} was {cur} and the ring has no stop there -- "
- + (f"'{cur}' sets it again" if cur in _IDATUI_FMT_SETTABLE else
- f"{cur} names a type this can't put back, reassign it by hand"))
+ out["warn"] = f"operand {n} was {cur} and the ring has no stop there -- " + (
+ f"'{cur}' sets it again"
+ if cur in _IDATUI_FMT_SETTABLE
+ else f"{cur} names a type this can't put back, reassign it by hand"
+ )
return out
@@ -1108,13 +1428,18 @@ def _idatui_lit_extent(plain, x):
around the column, which cannot reach a ``)`` or a space."""
if x >= len(plain):
return None
- if plain[x] == "'": # a character constant: '-'
+ if plain[x] == "'": # a character constant: '-'
end = plain.find("'", x + 1)
return (x, end + 1) if end > x else None
lo = plain.rfind("'", 0, x)
- if lo >= 0 and plain.find("'", x) > x and "'" in plain[lo:x] and \
- plain[lo:x].count("'") == 1 and " " not in plain[lo:x]:
- return (lo, plain.find("'", x) + 1) # inside 'c'
+ if (
+ lo >= 0
+ and plain.find("'", x) > x
+ and "'" in plain[lo:x]
+ and plain[lo:x].count("'") == 1
+ and " " not in plain[lo:x]
+ ):
+ return (lo, plain.find("'", x) + 1) # inside 'c'
if plain[x] not in _IDATUI_LIT_CHARS:
return None
lo = x
@@ -1123,7 +1448,7 @@ def _idatui_lit_extent(plain, x):
hi = x
while hi < len(plain) and plain[hi] in _IDATUI_LIT_CHARS:
hi += 1
- if lo > 0 and plain[lo - 1] == "-": # a unary minus is part of it
+ if lo > 0 and plain[lo - 1] == "-": # a unary minus is part of it
lo -= 1
return (lo, hi)
@@ -1135,10 +1460,7 @@ def _idatui_pc_nums(cf, sl):
Asks Hex-Rays what each column belongs to rather than pattern-matching the
text: a regex over ``v6 = a1 - 1;`` has to guess which of those characters
are a literal, and ``v11`` looks like one."""
- import ida_bytes
import ida_hexrays
- import ida_lines
- import idaapi
plain = ida_lines.tag_remove(sl.line)
out = []
@@ -1165,26 +1487,36 @@ def _idatui_pc_nums(cf, sl):
continue
nf = e.n.nf
opnum = ord(nf.opnum) if isinstance(nf.opnum, str) else int(nf.opnum)
- nbytes = (ord(nf.org_nbytes) if isinstance(nf.org_nbytes, str)
- else int(nf.org_nbytes))
+ nbytes = (
+ ord(nf.org_nbytes) if isinstance(nf.org_nbytes, str) else int(nf.org_nbytes)
+ )
ea = int(e.ea)
if ea == idaapi.BADADDR:
x = extent[1]
- continue # synthesised: nothing to key on
+ continue # synthesised: nothing to key on
nib = (nf.flags >> ida_bytes.get_operand_type_shift(opnum)) & 0xF
# Whether this format is the USER's or Hex-Rays' own guess. The nibble
# can't say: an untouched number reads back as whatever it happens to
# be printed as, and cycling from there would skip that stop forever
# (default already looks like it) and never come back to it.
loc = ida_hexrays.operand_locator_t(ea, opnum)
- user = (ida_hexrays.user_numforms_find(cf.numforms, loc)
- != ida_hexrays.user_numforms_end(cf.numforms))
- out.append({"x0": extent[0], "x1": extent[1], "ea": ea,
- "opnum": opnum, "value": int(e.n._value),
- "nbytes": nbytes, "user": user,
- "fmt": _idatui_fmt_name(nib) if user else "default",
- "shown": _idatui_fmt_name(nib)})
- x = extent[1] # past this literal, not into it
+ user = ida_hexrays.user_numforms_find(
+ cf.numforms, loc
+ ) != ida_hexrays.user_numforms_end(cf.numforms)
+ out.append(
+ {
+ "x0": extent[0],
+ "x1": extent[1],
+ "ea": ea,
+ "opnum": opnum,
+ "value": int(e.n._value),
+ "nbytes": nbytes,
+ "user": user,
+ "fmt": _idatui_fmt_name(nib) if user else "default",
+ "shown": _idatui_fmt_name(nib),
+ }
+ )
+ x = extent[1] # past this literal, not into it
return out
@@ -1199,8 +1531,6 @@ def pc_nums(
Columns are in the same collapsed coordinates the decompile tool serves its
text in, i.e. what the client actually displays."""
import ida_hexrays
- import ida_lines
- import idaapi
if not ida_hexrays.init_hexrays_plugin():
return {"addr": str(addr), "error": "no decompiler", "nums": []}
@@ -1213,31 +1543,36 @@ def pc_nums(
try:
cf = ida_hexrays.decompile(f.start_ea)
except Exception as e:
- return {"addr": hex(f.start_ea), "error": f"decompile failed: {e}",
- "nums": []}
+ return {"addr": hex(f.start_ea), "error": f"decompile failed: {e}", "nums": []}
if cf is None:
- return {"addr": hex(f.start_ea), "error": "decompilation failed",
- "nums": []}
+ return {"addr": hex(f.start_ea), "error": "decompilation failed", "nums": []}
sv = cf.get_pseudocode()
out = []
for i in range(len(sv)):
plain = ida_lines.tag_remove(sv[i].line)
compact = _idatui_compact(plain)
for rec in _idatui_pc_nums(cf, sv[i]):
- out.append({
- "line": i,
- "x0": _idatui_compact_col(plain, compact, rec["x0"]),
- "x1": _idatui_compact_col(plain, compact, rec["x1"]),
- "ea": hex(rec["ea"]), "opnum": rec["opnum"],
- "value": hex(rec["value"]), "fmt": rec["fmt"],
- "shown": rec["shown"], "user": bool(rec["user"]),
- })
+ out.append(
+ {
+ "line": i,
+ "x0": _idatui_compact_col(plain, compact, rec["x0"]),
+ "x1": _idatui_compact_col(plain, compact, rec["x1"]),
+ "ea": hex(rec["ea"]),
+ "opnum": rec["opnum"],
+ "value": hex(rec["value"]),
+ "fmt": rec["fmt"],
+ "shown": rec["shown"],
+ "user": bool(rec["user"]),
+ }
+ )
return {"addr": hex(f.start_ea), "nums": out, "lines": len(sv)}
def pc_num_format(
addr: Annotated[str, "Function address (or any address inside it)"],
- mode: Annotated[str, "cycle | back | show | hex | dec | oct | char | default"] = "cycle",
+ mode: Annotated[
+ str, "cycle | back | show | hex | dec | oct | char | default"
+ ] = "cycle",
line: Annotated[int, "0-based pseudocode line index"] = -1,
col: Annotated[int, "Cursor column in the DISPLAYED line (-1: first literal)"] = -1,
ea: Annotated[str, "Address of the number instead of line/col"] = "",
@@ -1251,8 +1586,6 @@ def pc_num_format(
makes the function stop decompiling). Returns the re-rendered line, and
marks the function dirty so the next decompile is the new text."""
import ida_hexrays
- import ida_lines
- import idaapi
if not ida_hexrays.init_hexrays_plugin():
return {"addr": str(addr), "error": "no decompiler"}
@@ -1279,8 +1612,9 @@ def pc_num_format(
return {"addr": hex(f.start_ea), "error": str(e)}
for i in range(len(sv)):
for rec in _idatui_pc_nums(cf, sv[i]):
- if rec["ea"] == want_ea and (int(opnum) < 0
- or rec["opnum"] == int(opnum)):
+ if rec["ea"] == want_ea and (
+ int(opnum) < 0 or rec["opnum"] == int(opnum)
+ ):
target, line = rec, i
break
if target:
@@ -1294,26 +1628,42 @@ def pc_num_format(
target = next((r for r in nums if r["x0"] <= x < r["x1"]), None)
target = target or nums[0]
else:
- return {"addr": hex(f.start_ea),
- "error": f"line {line} is outside the {len(sv)}-line decompilation"}
+ return {
+ "addr": hex(f.start_ea),
+ "error": f"line {line} is outside the {len(sv)}-line decompilation",
+ }
if target is None:
- return {"addr": hex(f.start_ea), "line": line,
- "text": (ida_lines.tag_remove(sv[line].line).strip()
- if 0 <= line < len(sv) else ""),
- "error": "no number literal on this line"}
+ return {
+ "addr": hex(f.start_ea),
+ "line": line,
+ "text": (
+ ida_lines.tag_remove(sv[line].line).strip()
+ if 0 <= line < len(sv)
+ else ""
+ ),
+ "error": "no number literal on this line",
+ }
cur, value = target["fmt"], target["value"]
- choices = [c for c in _IDATUI_PC_FMT_CYCLE
- if c != "char" or _idatui_printable(value)]
+ choices = [
+ c for c in _IDATUI_PC_FMT_CYCLE if c != "char" or _idatui_printable(value)
+ ]
# Same rule as the listing: one ring per literal, every step. A format the
# ring can't hold (an enum set in the GUI) is reported on the way out
# instead of being kept for one lap and then lost.
lossy = cur not in choices and cur != "default"
- out = {"addr": hex(f.start_ea), "ea": hex(target["ea"]),
- "opnum": target["opnum"], "line": line, "prev": cur,
- "format": cur, "shown": target["shown"], "choices": choices,
- "value": hex(value),
- "before": ida_lines.tag_remove(sv[line].line).strip()}
+ out = {
+ "addr": hex(f.start_ea),
+ "ea": hex(target["ea"]),
+ "opnum": target["opnum"],
+ "line": line,
+ "prev": cur,
+ "format": cur,
+ "shown": target["shown"],
+ "choices": choices,
+ "value": hex(value),
+ "before": ida_lines.tag_remove(sv[line].line).strip(),
+ }
mode = str(mode or "cycle").lower()
if mode == "show":
@@ -1329,13 +1679,16 @@ def pc_num_format(
else:
want = mode
if want in ("bin", "offset", "stack", "seg", "float"):
- out["error"] = (f"Hex-Rays has no {want} format for a number "
- f"-- set it on the listing instead")
+ out["error"] = (
+ f"Hex-Rays has no {want} format for a number "
+ f"-- set it on the listing instead"
+ )
out["text"] = out["before"]
return out
if want not in ("hex", "dec", "oct", "char", "default"):
- out["error"] = (f"unknown format {mode!r}; one of hex, dec, oct, "
- f"char, default")
+ out["error"] = (
+ f"unknown format {mode!r}; one of hex, dec, oct, char, default"
+ )
out["text"] = out["before"]
return out
@@ -1346,10 +1699,10 @@ def pc_num_format(
# set here would silently win over the new one.
ida_hexrays.user_numforms_erase(cf.numforms, it)
if want != "default":
- import ida_bytes
nf = ida_hexrays.number_format_t(target["opnum"])
- nf.flags = ida_bytes.get_operand_flag(_idatui_fmt_nibbles()[want],
- target["opnum"])
+ nf.flags = ida_bytes.get_operand_flag(
+ _idatui_fmt_nibbles()[want], target["opnum"]
+ )
try:
nf.org_nbytes = target["nbytes"]
except Exception:
@@ -1364,14 +1717,18 @@ def pc_num_format(
out["format"] = want
out["applied"] = True
if lossy:
- out["warn"] = (f"this number was {cur}, which names a type a radix "
- f"can't put back -- reassign it in IDA")
+ out["warn"] = (
+ f"this number was {cur}, which names a type a radix "
+ f"can't put back -- reassign it in IDA"
+ )
try:
- cf2 = ida_hexrays.decompile(f.start_ea,
- flags=ida_hexrays.DECOMP_NO_CACHE)
+ cf2 = ida_hexrays.decompile(f.start_ea, flags=ida_hexrays.DECOMP_NO_CACHE)
sv2 = cf2.get_pseudocode() if cf2 is not None else None
- out["text"] = (ida_lines.tag_remove(sv2[line].line).strip()
- if sv2 is not None and line < len(sv2) else out["before"])
+ out["text"] = (
+ ida_lines.tag_remove(sv2[line].line).strip()
+ if sv2 is not None and line < len(sv2)
+ else out["before"]
+ )
except Exception as e:
out["text"] = out["before"]
out["warn"] = f"re-render failed: {e}"
@@ -1383,7 +1740,7 @@ def decompile(addr, include_addresses=True):
Faithful to the tool ida-tui was written against, and in particular to its
COST: the per-line address anchor comes from ONE ``get_line_item`` at column
- 0 per line. The Code Mode port asked for the full per-column line map (what
+ 0 per line. The IDA Nexus port asked for the full per-column line map (what
``decomp_map`` is for) purely to fill in that anchor, which is thousands of
``get_line_item``+``dstr()`` calls per function instead of one per line, and
made every pseudocode open cost the same as opening the split view.
@@ -1391,11 +1748,7 @@ def decompile(addr, include_addresses=True):
Text is whitespace-collapsed exactly as the client displays it, because
``pc_nums`` reports literal columns in those coordinates.
"""
- import ida_bytes
import ida_hexrays
- import ida_lines
- import ida_name
- import idaapi
try:
ea = parse_address(addr)
@@ -1410,11 +1763,17 @@ def decompile(addr, include_addresses=True):
try:
cfunc = ida_hexrays.decompile_func(fn, failure)
except Exception as e:
- return {"addr": hex(int(fn.start_ea)), "code": None,
- "error": f"Decompilation failed at {ea:#x}: {e}"}
+ return {
+ "addr": hex(int(fn.start_ea)),
+ "code": None,
+ "error": f"Decompilation failed at {ea:#x}: {e}",
+ }
if cfunc is None:
- return {"addr": hex(int(fn.start_ea)), "code": None,
- "error": failure.desc() or f"Decompilation failed at {ea:#x}"}
+ return {
+ "addr": hex(int(fn.start_ea)),
+ "code": None,
+ "error": failure.desc() or f"Decompilation failed at {ea:#x}",
+ }
lines = []
for sl in cfunc.get_pseudocode():
@@ -1422,7 +1781,9 @@ def decompile(addr, include_addresses=True):
item = ida_hexrays.ctree_item_t()
tail = ida_hexrays.ctree_item_t()
line_ea = None
- if include_addresses and cfunc.get_line_item(sl.line, 0, False, head, item, tail):
+ if include_addresses and cfunc.get_line_item(
+ sl.line, 0, False, head, item, tail
+ ):
parts = (item.dstr() or "").split(": ")
if len(parts) == 2:
try:
@@ -1448,9 +1809,13 @@ def decompile(addr, include_addresses=True):
text = raw.decode("utf-8", "replace") if raw else None
except Exception:
text = None
- refs.append({"addr": hex(target),
- "name": ida_name.get_name(target) or "",
- "string": text})
+ refs.append(
+ {
+ "addr": hex(target),
+ "name": ida_name.get_name(target) or "",
+ "string": text,
+ }
+ )
return 0
try:
@@ -1468,7 +1833,7 @@ def decomp_map(
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:
@@ -1482,7 +1847,6 @@ def decomp_map(
return {"error": f"decompile failed: {e}"}
if cfunc is None:
return {"error": "decompile failed"}
- import ida_lines
# Three things this loop must not do, each measured on real functions (the 25
# largest of bash went 68.3s -> 6.5s; echo's 60 largest 5.4s -> 0.6s, with
# byte-identical output):
@@ -1546,3 +1910,32 @@ def decomp_map(
eas.append(hex(e))
lines.append({"ea": eas[0] if eas else None, "eas": eas})
return {"addr": hex(func.start_ea), "lines": lines}
+
+
+def profile_remote(operation, args, reps=5):
+ """Profile one persistent remote tool entirely inside IDA."""
+ import cProfile
+ import io
+ import pstats
+
+ functions = {
+ "heads": heads,
+ "segment_index": segment_index,
+ "op_format": op_format,
+ "pc_nums": pc_nums,
+ "decompile": decompile,
+ "decomp_map": decomp_map,
+ "pc_num_format": pc_num_format,
+ }
+ function = functions.get(str(operation))
+ if function is None:
+ raise ValueError(f"unknown profile operation: {operation!r}")
+ profiler = cProfile.Profile()
+ for _ in range(max(1, int(reps))):
+ profiler.runcall(function, **dict(args))
+ stats = pstats.Stats(profiler)
+ output = io.StringIO()
+ stats.stream = output
+ stats.sort_stats("tottime")
+ stats.print_stats(80)
+ return {"stats": output.getvalue(), "total": stats.total_tt, "reps": reps}
diff --git a/idatui/rpc.py b/idatui/rpc.py
index 1262ebb..cfeb0ca 100644
--- a/idatui/rpc.py
+++ b/idatui/rpc.py
@@ -17,6 +17,7 @@ Method tiers:
introspect state, view, screen, functions
(semantic verbs — open/goto/rename/... — layer on top in a later pass.)
"""
+
from __future__ import annotations
import asyncio
@@ -27,8 +28,8 @@ from typing import Any
from rich.console import Console
-from ._sync import drain, settle
from . import diag
+from ._sync import drain, settle
from .app import DecompView, GraphView, HexView, ListingView, ViewMode
PROTO_VERSION = 1
@@ -36,10 +37,31 @@ TYPE_DELAY_MS = 35 # default per-char delay for high-level typed ops (aesthetic
# Verbs that dereference app.program — refused with a clear error before load.
_PROGRAM_METHODS = {
- "goto", "open", "rename", "comment", "retype", "follow", "xrefs", "symbols",
- "structs", "search", "select", "save", "hex", "toggle_view",
- "pseudocode", "disassembly", "xrefs_to", "xrefs_from", "resolve",
- "define", "rename_many", "opfmt", "graph", "export", "find",
+ "goto",
+ "open",
+ "rename",
+ "comment",
+ "retype",
+ "follow",
+ "xrefs",
+ "symbols",
+ "structs",
+ "search",
+ "select",
+ "save",
+ "hex",
+ "toggle_view",
+ "pseudocode",
+ "disassembly",
+ "xrefs_to",
+ "xrefs_from",
+ "resolve",
+ "define",
+ "rename_many",
+ "opfmt",
+ "graph",
+ "export",
+ "find",
}
# Self-documenting method table (returned by the 'methods' verb).
@@ -55,7 +77,7 @@ METHODS = {
"disassembly": "{target?,max?=2000} -> {total,lines:[{ea,text}]}",
"xrefs_to": "{target,limit?=200} -> [{frm,to,type,fn_addr,fn_name}]",
"xrefs_from": "{target,limit?=200} -> callees/refs; function-scoped for a "
- "function (decomp refs), address-scoped for a 0xADDR",
+ "function (decomp refs), address-scoped for a 0xADDR",
"resolve": "{name} -> {ea}",
"keys": "{keys:[str],settle?,timeout?} raw key injection (supports 'wait:<ms>')",
"text": "{text,delay_ms?,settle?} type a literal string into the focused input",
@@ -69,17 +91,17 @@ METHODS = {
"toggle_view": "disasm <-> pseudocode",
"hex": "hex view",
"graph": "{action?=show|open|close|toggle|zoom|block|entry|succ|pred,"
- "target?,blocks?} the control-flow graph: 'show' reports its "
- "structure (blocks, edges, cursor) without touching it; the others "
- "drive it. 'block' takes target=<id|0xADDR>",
+ "target?,blocks?} the control-flow graph: 'show' reports its "
+ "structure (blocks, edges, cursor) without touching it; the others "
+ "drive it. 'block' takes target=<id|0xADDR>",
"xrefs": "open the xref picker",
"symbols": "{query?} open the symbol palette",
"structs": "open the struct editor",
"export": "{path?,types?=true} write the session's comments/names/types as "
- "a markdown report -> {path,comments,names,types}",
+ "a markdown report -> {path,comments,names,types}",
"find": "{query,mode?=auto|text|bytes,limit?=500,regex?,case?} search the "
- "WHOLE database: disassembly text, or a byte pattern with "
- "wildcards (48 8b ?? c3) -> {mode,hits:[{addr,head,line,func}]}",
+ "WHOLE database: disassembly text, or a byte pattern with "
+ "wildcards (48 8b ?? c3) -> {mode,hits:[{addr,head,line,func}]}",
"search": "{term,direction?=1} incremental search in the code view",
"select": "{index?} choose the highlighted/nth item in the open modal",
"save": "persist the .i64 (Ctrl+S)",
@@ -90,42 +112,62 @@ METHODS = {
"move": "{dir,n?=1} fast movement (down/up/.../pagedown)",
"cursor": "{line?,col?} set the code-pane cursor directly",
"define": "{kind:code|func|undef|thumb|thumbscan|data|string,target?} "
- "(re)define bytes at target — the raw-image workflow",
+ "(re)define bytes at target — the raw-image workflow",
"rename_many": "{items:[{addr,name}] | file:JSON} bulk-apply a symbol file "
- "in ONE call (no typing, no navigation)",
+ "in ONE call (no typing, no navigation)",
"opfmt": "{mode?=cycle|back|show|hex|dec|oct|bin|char|offset|stack|"
- "default,target?,word?,line?,col?} how the literal under the cursor is "
- "DISPLAYED (IDA's 'o'); works on the listing and on pseudocode "
- "numbers. 'show' reports the format and the stops without editing",
+ "default,target?,word?,line?,col?} how the literal under the cursor is "
+ "DISPLAYED (IDA's 'o'); works on the listing and on pseudocode "
+ "numbers. 'show' reports the format and the stops without editing",
}
#: `opfmt` modes that have a real key on the code views. Driving the key keeps
#: the pane honest (a viewer sees the same thing a human would do); the named
#: formats have no key, so those go through the view's action directly.
_OPFMT_KEYS = {"cycle": "o", "back": "O"}
-_OPFMT_MODES = ("cycle", "back", "show", "hex", "dec", "oct", "bin", "char",
- "offset", "stack", "default")
+_OPFMT_MODES = (
+ "cycle",
+ "back",
+ "show",
+ "hex",
+ "dec",
+ "oct",
+ "bin",
+ "char",
+ "offset",
+ "stack",
+ "default",
+)
# `define` kinds -> the ListingView key that runs them. Driving the real key
# keeps the pane honest (a viewer sees the same thing a human would do) and
# reuses the app's own edit worker, which reports what actually happened.
_DEFINE_KEYS = {
- "code": "c", # make code (runs until flow/undecodable)
- "func": "p", # make function
+ "code": "c", # make code (runs until flow/undecodable)
+ "func": "p", # make function
"undef": "u",
- "thumb": "t", # flip ARM/Thumb at the cursor, then disassemble
- "thumbscan": "T", # find Thumb entry pointers in a vector table
+ "thumb": "t", # flip ARM/Thumb at the cursor, then disassemble
+ "thumbscan": "T", # find Thumb entry pointers in a vector table
"data": "d",
"string": "a",
}
# Movement keys — driven fast (no typed delay) so the pane still visibly moves.
_MOVE_KEYS = {
- "down": "j", "up": "k", "left": "h", "right": "l",
- "word": "w", "wordback": "b", "bol": "0", "eol": "dollar_sign",
- "top": "home", "bottom": "G",
- "halfdown": "ctrl+d", "halfup": "ctrl+u",
- "pagedown": "pagedown", "pageup": "pageup",
+ "down": "j",
+ "up": "k",
+ "left": "h",
+ "right": "l",
+ "word": "w",
+ "wordback": "b",
+ "bol": "0",
+ "eol": "dollar_sign",
+ "top": "home",
+ "bottom": "G",
+ "halfdown": "ctrl+d",
+ "halfup": "ctrl+u",
+ "pagedown": "pagedown",
+ "pageup": "pageup",
}
@@ -148,8 +190,11 @@ def graph_info(app, blocks: bool = True) -> dict[str, Any]:
rather than the box-drawing characters it is rendered as."""
gv = app.query_one(GraphView)
if gv.fc is None or gv.lay is None:
- return {"open": app.is_graph, "loaded": False,
- "note": "press space (or graph {action:'open'}) on a function"}
+ return {
+ "open": app.is_graph,
+ "loaded": False,
+ "note": "press space (or graph {action:'open'}) on a function",
+ }
lay, fc = gv.lay, gv.fc
out: dict[str, Any] = {
"open": app.is_graph,
@@ -158,24 +203,30 @@ def graph_info(app, blocks: bool = True) -> dict[str, Any]:
"zoom": gv.ZOOMS[gv._zoom],
"canvas": {"w": lay.width, "h": lay.height},
"stats": dict(lay.stats),
- "cursor": {"block": gv.cursor_node, "row": gv.cursor_row,
- "ea": gv._cursor_ea(), "word": gv.word_under_cursor()},
+ "cursor": {
+ "block": gv.cursor_node,
+ "row": gv.cursor_row,
+ "ea": gv._cursor_ea(),
+ "word": gv.word_under_cursor(),
+ },
}
if blocks:
rows = []
for n in lay.nodes:
b = gv._blocks.get(n.id)
- rows.append({
- "id": n.id,
- "start": b.start if b else None,
- "end": b.end if b else None,
- "insns": len(b.rows) if b else 0,
- "rank": n.rank,
- "box": {"x": n.x, "y": n.y, "w": n.w, "h": n.h},
- "succs": [{"id": i, "kind": k} for i, k in lay.succ.get(n.id, [])],
- "preds": [{"id": i, "kind": k} for i, k in lay.pred.get(n.id, [])],
- "selfloop": bool(b and any(d == n.id for d, _ in b.succs)),
- })
+ rows.append(
+ {
+ "id": n.id,
+ "start": b.start if b else None,
+ "end": b.end if b else None,
+ "insns": len(b.rows) if b else 0,
+ "rank": n.rank,
+ "box": {"x": n.x, "y": n.y, "w": n.w, "h": n.h},
+ "succs": [{"id": i, "kind": k} for i, k in lay.succ.get(n.id, [])],
+ "preds": [{"id": i, "kind": k} for i, k in lay.pred.get(n.id, [])],
+ "selfloop": bool(b and any(d == n.id for d, _ in b.succs)),
+ }
+ )
out["blocks"] = rows
return out
@@ -186,8 +237,20 @@ _MODALS = ("XrefsScreen", "SymbolPalette", "StructEditor", "ConfirmScreen")
#: Handlers that did ``int(...)`` coped; the ones that compared directly blew up
#: with e.g. "'<' not supported between instances of 'int' and 'str'". Coerce the
#: known-numeric names once, centrally, instead of at every call site.
-_INT_PARAMS = ("lines", "limit", "max", "n", "index", "line", "col",
- "occurrence", "delay_ms", "direction", "addr", "count")
+_INT_PARAMS = (
+ "lines",
+ "limit",
+ "max",
+ "n",
+ "index",
+ "line",
+ "col",
+ "occurrence",
+ "delay_ms",
+ "direction",
+ "addr",
+ "count",
+)
_FLOAT_PARAMS = ("timeout",)
@@ -221,13 +284,16 @@ def _modal_snapshot(app) -> dict[str, Any] | None:
if isinstance(items, list):
try:
from textual.widgets import OptionList
+
hl = scr.query_one(OptionList).highlighted
except Exception: # noqa: BLE001
hl = None
info["highlighted"] = hl
info["items"] = [
- {"ea": (it[0] if isinstance(it[0], int) else None),
- "label": str(it[1]) if len(it) > 1 else str(it)}
+ {
+ "ea": (it[0] if isinstance(it[0], int) else None),
+ "label": str(it[1]) if len(it) > 1 else str(it),
+ }
for it in items[:64]
]
return info
@@ -235,14 +301,23 @@ def _modal_snapshot(app) -> dict[str, Any] | None:
def _cursor_info(app, w) -> dict[str, Any]:
if isinstance(w, HexView):
- return {"kind": "hex", "va": (w.cursor_va() if w.model else None),
- "byte": w.cursor}
+ return {
+ "kind": "hex",
+ "va": (w.cursor_va() if w.model else None),
+ "byte": w.cursor,
+ }
if isinstance(w, GraphView):
# The graph cursor is (block, row), not a line index -- reporting it as
# one would make a driver's `cursor line=` land somewhere arbitrary.
- return {"kind": "graph", "ea": w._cursor_ea(), "block": w.cursor_node,
- "row": w.cursor_row, "col": w.cursor_x,
- "word": w.word_under_cursor(), "text": w._line_plain()}
+ return {
+ "kind": "graph",
+ "ea": w._cursor_ea(),
+ "block": w.cursor_node,
+ "row": w.cursor_row,
+ "col": w.cursor_x,
+ "word": w.word_under_cursor(),
+ "text": w._line_plain(),
+ }
# disasm / decomp share the ColumnCursor surface
word = None
try:
@@ -254,9 +329,15 @@ def _cursor_info(app, w) -> dict[str, Any]:
ea = app._line_ea_for(w)
except Exception: # noqa: BLE001
pass
- return {"kind": app._active, "line": w.cursor, "col": w.cursor_x,
- "word": word, "ea": ea, "total": getattr(w, "total", None),
- "scroll_y": round(w.scroll_offset.y)}
+ return {
+ "kind": app._active,
+ "line": w.cursor,
+ "col": w.cursor_x,
+ "word": word,
+ "ea": ea,
+ "total": getattr(w, "total", None),
+ "scroll_y": round(w.scroll_offset.y),
+ }
def _where(app) -> str:
@@ -290,12 +371,12 @@ def snapshot(app) -> dict[str, Any]:
pass
return {
"active": app._active,
- "pref": app._code_mode(), # kept for wire compat; a constant now
+ "pref": app._code_mode(), # kept for wire compat; a constant now
"function": ({"ea": cur.ea, "name": cur.name} if cur else None),
"cursor": _cursor_info(app, w),
"status": st,
"filter": app._filter_term,
- "binary": app._binary, # None outside project mode
+ "binary": app._binary, # None outside project mode
"nav_depth": len(app._nav),
"hops": list(getattr(app, "_hops", [])),
"dirty": bool(app._dirty),
@@ -309,13 +390,18 @@ def view_lines(app, lines: int | None = None) -> dict[str, Any]:
for hex use screen())."""
w = _active_widget(app)
if isinstance(w, HexView):
- return {"active": "hex", "note": "use screen() for the hex grid",
- "cursor": _cursor_info(app, w)}
+ return {
+ "active": "hex",
+ "note": "use screen() for the hex grid",
+ "cursor": _cursor_info(app, w),
+ }
if isinstance(w, GraphView):
- return {"active": "graph", "note": "use graph() for structure, "
- "screen() for the drawing",
- "cursor": _cursor_info(app, w),
- "graph": graph_info(app, blocks=False)}
+ return {
+ "active": "graph",
+ "note": "use graph() for structure, screen() for the drawing",
+ "cursor": _cursor_info(app, w),
+ "graph": graph_info(app, blocks=False),
+ }
top = round(w.scroll_offset.y)
height = w.size.height or 40
n = min(lines or height, max(w.total - top, 0))
@@ -323,21 +409,39 @@ def view_lines(app, lines: int | None = None) -> dict[str, Any]:
for r in range(n):
idx = top + r
plain = w._line_plain(idx)
- out.append({"i": idx, "cur": idx == w.cursor,
- "text": plain if plain is not None else ""})
- return {"active": app._active, "top": top, "total": w.total,
- "cursor": _cursor_info(app, w), "lines": out}
+ out.append(
+ {
+ "i": idx,
+ "cur": idx == w.cursor,
+ "text": plain if plain is not None else "",
+ }
+ )
+ return {
+ "active": app._active,
+ "top": top,
+ "total": w.total,
+ "cursor": _cursor_info(app, w),
+ "lines": out,
+ }
def screen_text(app, fmt: str = "text") -> dict[str, Any]:
"""Render the whole screen exactly as shown. ``fmt``: 'text' (plain, default),
'html' or 'svg' (colored — handy for an out-of-band web viewer)."""
width, height = app.size
- console = Console(width=width, height=height or 40, file=io.StringIO(),
- force_terminal=True, color_system="truecolor", record=True,
- legacy_windows=False, safe_box=False)
+ console = Console(
+ width=width,
+ height=height or 40,
+ file=io.StringIO(),
+ force_terminal=True,
+ color_system="truecolor",
+ record=True,
+ legacy_windows=False,
+ safe_box=False,
+ )
render = app.screen._compositor.render_update(
- full=True, screen_stack=app._background_screens, simplify=False)
+ full=True, screen_stack=app._background_screens, simplify=False
+ )
console.print(render)
out: dict[str, Any] = {"width": width, "height": height, "format": fmt}
if fmt == "html":
@@ -388,8 +492,10 @@ def cursor_on(app, word: str, line: int | None = None, occurrence: int = 1) -> b
if isinstance(w, HexView):
raise ValueError("cursor_on: not supported in the hex view")
if isinstance(w, GraphView):
- raise ValueError("cursor_on: not supported in the graph view — use "
- "graph {action:'block'} or goto")
+ raise ValueError(
+ "cursor_on: not supported in the graph view — use "
+ "graph {action:'block'} or goto"
+ )
if isinstance(w, DecompView):
texts = list(w._texts)
else:
@@ -412,7 +518,7 @@ def cursor_on(app, word: str, line: int | None = None, occurrence: int = 1) -> b
if w.word_under_cursor() == word:
hits += 1
if hits >= max(1, occurrence):
- place_cursor(w) # scrolls: an off-screen cursor edits blind
+ place_cursor(w) # scrolls: an off-screen cursor edits blind
return True
col = t.find(word, col + 1)
w.cursor, w.cursor_x = orig # not found: leave the cursor untouched
@@ -455,8 +561,14 @@ def pseudocode(app, target=None) -> dict[str, Any]:
if dea is None:
return {"ea": None, "error": "no target"}
d = app.program.decompile(dea)
- return {"ea": dea, "name": (fn.name if fn else None), "failed": d.failed,
- "error": d.error, "truncated": d.truncated, "code": d.code}
+ return {
+ "ea": dea,
+ "name": (fn.name if fn else None),
+ "failed": d.failed,
+ "error": d.error,
+ "truncated": d.truncated,
+ "code": d.code,
+ }
def disassembly(app, target=None, max_lines: int = 2000) -> dict[str, Any]:
@@ -469,13 +581,26 @@ def disassembly(app, target=None, max_lines: int = 2000) -> dict[str, Any]:
m = app.program.disasm(dea, fn.name if fn else None)
total = m.total()
lines = m.lines(0, min(total, max(1, max_lines)), prefetch=False)
- return {"ea": dea, "name": (fn.name if fn else None), "total": total,
- "lines": [{"ea": ln.ea, "text": ln.text} for ln in lines]}
+ return {
+ "ea": dea,
+ "name": (fn.name if fn else None),
+ "total": total,
+ "lines": [{"ea": ln.ea, "text": ln.text} for ln in lines],
+ }
def _xref_dicts(xs, limit: int) -> list[dict[str, Any]]:
- return [{"frm": x.frm, "to": x.to, "type": x.type, "kind": x.kind,
- "fn_addr": x.fn_addr, "fn_name": x.fn_name} for x in xs[:limit]]
+ return [
+ {
+ "frm": x.frm,
+ "to": x.to,
+ "type": x.type,
+ "kind": x.kind,
+ "fn_addr": x.fn_addr,
+ "fn_name": x.fn_name,
+ }
+ for x in xs[:limit]
+ ]
def xrefs_to(app, target, limit: int = 200) -> list[dict[str, Any]]:
@@ -497,9 +622,15 @@ def xrefs_from(app, target, limit: int = 200) -> list[dict[str, Any]]:
for r in app.program.decompile(ea).refs[:limit]:
tf = app.program.function_of(r.addr)
is_func = bool(tf and tf.addr == r.addr)
- out.append({"to": r.addr, "name": r.name or (tf.name if tf else None),
- "string": r.string, "is_func": is_func,
- "type": "code" if is_func else "data"})
+ out.append(
+ {
+ "to": r.addr,
+ "name": r.name or (tf.name if tf else None),
+ "string": r.string,
+ "is_func": is_func,
+ "type": "code" if is_func else "data",
+ }
+ )
return out
return _xref_dicts(app.program.xrefs_from(ea), limit)
@@ -560,15 +691,25 @@ class RpcServer:
except OSError:
pass
- async def _on_client(self, reader: asyncio.StreamReader,
- writer: asyncio.StreamWriter) -> None:
+ async def _on_client(
+ self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
+ ) -> None:
if self._busy:
# No multi-driver support yet: refuse a second concurrent client
# rather than let two drivers interleave mutations.
try:
- writer.write(json.dumps(
- {"id": None, "error": {"message": "busy: another client is "
- "connected (single-driver only)"}}).encode() + b"\n")
+ writer.write(
+ json.dumps(
+ {
+ "id": None,
+ "error": {
+ "message": "busy: another client is "
+ "connected (single-driver only)"
+ },
+ }
+ ).encode()
+ + b"\n"
+ )
await writer.drain()
writer.close()
except Exception: # noqa: BLE001
@@ -604,7 +745,11 @@ class RpcServer:
except Exception as e: # noqa: BLE001 — report, never kill the connection
# ``str(KeyError("msg"))`` returns ``repr("msg")`` (adds quotes), which
# mangles our friendly resolve messages; unwrap the single arg instead.
- if isinstance(e, KeyError) and len(e.args) == 1 and isinstance(e.args[0], str):
+ if (
+ isinstance(e, KeyError)
+ and len(e.args) == 1
+ and isinstance(e.args[0], str)
+ ):
msg = e.args[0]
else:
msg = str(e)
@@ -619,7 +764,8 @@ class RpcServer:
# would go on to edit whatever the *previous* location was.
raise TimeoutError(
f"{what or 'action'} did not complete within {timeout}s "
- f"(still at {_where(self.app)}); retry with a larger timeout=")
+ f"(still at {_where(self.app)}); retry with a larger timeout="
+ )
return snapshot(self.app)
async def _graph(self, params, timeout):
@@ -641,18 +787,21 @@ class RpcServer:
return {**snapshot(app), "graph": graph_info(app, blocks=want_blocks)}
if action == "close" and not app.is_graph:
return {**snapshot(app), "graph": graph_info(app, blocks=want_blocks)}
- want = "graph" if action in ("open", "toggle") and \
- not app.is_graph else None
+ want = (
+ "graph" if action in ("open", "toggle") and not app.is_graph else None
+ )
res = await self._press(
["space"],
- (lambda: app.is_graph) if want else
- (lambda: not app.is_graph),
- timeout, f"graph {action}")
+ (lambda: app.is_graph) if want else (lambda: not app.is_graph),
+ timeout,
+ f"graph {action}",
+ )
return {**res, "graph": graph_info(app, blocks=want_blocks)}
if not app.is_graph:
- raise ValueError(f"graph {action}: the graph is not open "
- f"(graph {{action:'open'}} first)")
+ raise ValueError(
+ f"graph {action}: the graph is not open (graph {{action:'open'}} first)"
+ )
if action == "zoom":
before = gv._zoom
await self._press(["z"], lambda: gv._zoom != before, timeout, "graph zoom")
@@ -660,9 +809,12 @@ class RpcServer:
await self._press(["0"], None, timeout, "graph entry")
elif action in ("succ", "pred"):
before = gv.cursor_node
- await self._press(["J" if action == "succ" else "K"],
- lambda: gv.cursor_node != before, timeout,
- f"graph {action}")
+ await self._press(
+ ["J" if action == "succ" else "K"],
+ lambda: gv.cursor_node != before,
+ timeout,
+ f"graph {action}",
+ )
elif action == "block":
target = params.get("target")
if target is None:
@@ -694,18 +846,23 @@ class RpcServer:
"""Open a prompt (a keystroke), optionally clear its prefill, type the
value with the typed-out delay, submit. Returns after the prompt closes."""
from textual.widgets import Input
+
app = self.app
await app._press_keys([open_key])
- await settle(app, lambda: app.query_one(f"#{input_id}", Input).display, timeout=10)
+ await settle(
+ app, lambda: app.query_one(f"#{input_id}", Input).display, timeout=10
+ )
inp = app.query_one(f"#{input_id}", Input)
if not inp.display:
# Say *why*. The old message always blamed the word under the cursor,
# which sent readers hunting for a cursor problem when the real cause
# was usually a modal eating the opening keystroke.
modal = type(app.screen).__name__
- why = (f"modal {modal!r} has focus and ate the {open_key!r} keystroke"
- if modal in _MODALS or modal != "Screen"
- else "no renameable token under the cursor")
+ why = (
+ f"modal {modal!r} has focus and ate the {open_key!r} keystroke"
+ if modal in _MODALS or modal != "Screen"
+ else "no renameable token under the cursor"
+ )
raise RuntimeError(f"{input_id!r} prompt did not open: {why}")
if clear:
inp.value = ""
@@ -727,14 +884,14 @@ class RpcServer:
app = self.app
items = params.get("items")
src = params.get("file")
- if isinstance(items, str): # `drive raw` hands params through as text
+ if isinstance(items, str): # `drive raw` hands params through as text
items = json.loads(items)
if items is None:
if not src:
raise ValueError("rename_many needs items=[{addr,name}] or file=<json>")
with open(os.path.expanduser(str(src))) as f:
items = json.load(f)
- if isinstance(items, dict): # {"0x4370": "name"} is a natural shape too
+ if isinstance(items, dict): # {"0x4370": "name"} is a natural shape too
items = [{"addr": k, "name": v} for k, v in items.items()]
if not isinstance(items, list) or not items:
raise ValueError("rename_many: items must be a non-empty list")
@@ -745,8 +902,14 @@ class RpcServer:
skipped += 1
continue
# Accept the field names symbol files actually use.
- addr = next((it[k] for k in ("addr", "start", "ea", "address")
- if it.get(k) is not None), None)
+ addr = next(
+ (
+ it[k]
+ for k in ("addr", "start", "ea", "address")
+ if it.get(k) is not None
+ ),
+ None,
+ )
name = it.get("name") or it.get("label")
if addr is None or not name:
skipped += 1
@@ -764,8 +927,15 @@ class RpcServer:
# or the TUI freezes for the length of the batch.
res = await asyncio.to_thread(app.program.client.invoke, "rename", batch=batch)
summary = res.get("summary", {}) if isinstance(res, dict) else {}
- failed = [r for r in (res.get("func") or []) if isinstance(r, dict)
- and r.get("error")] if isinstance(res, dict) else []
+ failed = (
+ [
+ r
+ for r in (res.get("func") or [])
+ if isinstance(r, dict) and r.get("error")
+ ]
+ if isinstance(res, dict)
+ else []
+ )
# Names live in the IDB, but every cache in front of it is now stale --
# including Hex-Rays', which is per-function and does NOT notice that a
@@ -779,18 +949,23 @@ class RpcServer:
app.program.bump_names()
app.program.invalidate_functions()
app._func_index = None
- app._load_functions() # re-streams the function table
+ app._load_functions() # re-streams the function table
await settle(app, timeout=timeout)
app._dirty = True
- app._status(f"renamed {summary.get('ok', 0)} symbols"
- + (f", {len(failed)} failed" if failed else "")
- + " (Ctrl+S to save)")
+ app._status(
+ f"renamed {summary.get('ok', 0)} symbols"
+ + (f", {len(failed)} failed" if failed else "")
+ + " (Ctrl+S to save)"
+ )
snap = snapshot(app)
snap["rename_many"] = {
- "requested": len(ops), "skipped": skipped,
- "ok": summary.get("ok", 0), "failed": summary.get("failed", 0),
- "errors": [{"addr": r.get("addr"), "error": r.get("error")}
- for r in failed[:10]],
+ "requested": len(ops),
+ "skipped": skipped,
+ "ok": summary.get("ok", 0),
+ "failed": summary.get("failed", 0),
+ "errors": [
+ {"addr": r.get("addr"), "error": r.get("error")} for r in failed[:10]
+ ],
}
return snap
@@ -810,13 +985,31 @@ class RpcServer:
#: Verbs that drive the *main* app by injecting keystrokes. If a modal is on
#: top it eats those keys, so they must refuse rather than silently no-op.
_NEEDS_NO_MODAL = {
- "goto", "open", "rename", "comment", "retype", "follow", "back",
- "toggle_view", "hex", "save", "search", "move", "cursor", "cursor_on",
- "define", "opfmt",
+ "goto",
+ "open",
+ "rename",
+ "comment",
+ "retype",
+ "follow",
+ "back",
+ "toggle_view",
+ "hex",
+ "save",
+ "search",
+ "move",
+ "cursor",
+ "cursor_on",
+ "define",
+ "opfmt",
}
#: Modals the driver is expected to interact with (they have their own verbs).
- _DRIVABLE_MODALS = {"XrefsScreen", "SymbolPalette", "StructEditor",
- "ProjectPalette", "QuitScreen"}
+ _DRIVABLE_MODALS = {
+ "XrefsScreen",
+ "SymbolPalette",
+ "StructEditor",
+ "ProjectPalette",
+ "QuitScreen",
+ }
def _modal_kind(self) -> str | None:
scr = self.app.screen
@@ -833,15 +1026,20 @@ class RpcServer:
f"modal {modal!r} is on top and will swallow this verb's "
f"keystrokes; dismiss it first (close) or use its own verb "
f"(select/symbols/xrefs). Note: a binary with no entry "
- f"function can land in the symbol palette on startup.")
+ f"function can land in the symbol palette on startup."
+ )
if method in (None, "ping"):
module = None
try:
module = app._module() if app.client else None
except Exception: # noqa: BLE001
pass
- return {"ok": True, "proto": PROTO_VERSION, "module": module,
- **_readiness(app)}
+ return {
+ "ok": True,
+ "proto": PROTO_VERSION,
+ "module": module,
+ **_readiness(app),
+ }
if method == "methods":
return METHODS
if method == "quit":
@@ -856,14 +1054,18 @@ class RpcServer:
def _go():
if dirty and save:
- app._on_quit_choice("save") # saves, then exits
+ app._on_quit_choice("save") # saves, then exits
else:
app._on_quit_choice("discard")
# answer first, then tear down (so this response still gets written)
asyncio.get_running_loop().call_later(0.2, _go)
- return {"ok": True, "quitting": True, "saving": bool(dirty and save),
- "dirty": dirty}
+ return {
+ "ok": True,
+ "quitting": True,
+ "saving": bool(dirty and save),
+ "dirty": dirty,
+ }
if method in _PROGRAM_METHODS and app.program is None:
raise ValueError("not ready: still connecting / loading functions")
@@ -902,8 +1104,10 @@ class RpcServer:
if params.get("clear"):
diag.clear()
return {"cleared": True}
- return {"recent": diag.recent(int(params.get("n", 10))),
- "log": os.environ.get("IDATUI_LOG") or None}
+ return {
+ "recent": diag.recent(int(params.get("n", 10))),
+ "log": os.environ.get("IDATUI_LOG") or None,
+ }
if method == "trace":
tc = app.trace_ctl
@@ -916,12 +1120,19 @@ class RpcServer:
if isinstance(v, str) and v.startswith("!"):
idx = int(float(v[1:]) * (t.length - 1) / 100.0)
else:
- idx = int(str(v).replace(",", ""), 0) if isinstance(v, str) else int(v)
+ idx = (
+ int(str(v).replace(",", ""), 0)
+ if isinstance(v, str)
+ else int(v)
+ )
tc.seek(idx)
- elif "goto" in params: # first execution of an address/name
+ elif "goto" in params: # first execution of an address/name
tgt = params["goto"]
- ea = (int(str(tgt), 0) if str(tgt).lower().startswith("0x")
- else app.program.resolve(str(tgt)))
+ ea = (
+ int(str(tgt), 0)
+ if str(tgt).lower().startswith("0x")
+ else app.program.resolve(str(tgt))
+ )
first = t.first_execution(ea)
if first is None:
raise ValueError(f"{tgt} never executed in this trace")
@@ -933,9 +1144,12 @@ class RpcServer:
(tc.step_over if over else tc.step)(1 if n > 0 else -1)
await settle(app, timeout=float(params.get("timeout", 20.0)))
snap = snapshot(app)
- snap["trace"] = {"idx": tc.t, "length": t.length,
- "pc": hex(t.ip(tc.t)),
- "changed": sorted(t.changed(tc.t))}
+ snap["trace"] = {
+ "idx": tc.t,
+ "length": t.length,
+ "pc": hex(t.ip(tc.t)),
+ "changed": sorted(t.changed(tc.t)),
+ }
return snap
if method == "binaries":
@@ -943,12 +1157,20 @@ class RpcServer:
raise ValueError("not a project session (launch with --project)")
counts = app._index.counts() if app._index is not None else {}
resident = set(app._pool.resident()) if app._pool is not None else set()
- return {"active": app._binary, "hops": list(app._hops),
- "binaries": [{"label": r.label, "source": r.source,
- "active": r.label == app._binary,
- "resident": r.label in resident,
- "indexed": int(counts.get(r.label, 0))}
- for r in app._project.refs]}
+ return {
+ "active": app._binary,
+ "hops": list(app._hops),
+ "binaries": [
+ {
+ "label": r.label,
+ "source": r.source,
+ "active": r.label == app._binary,
+ "resident": r.label in resident,
+ "indexed": int(counts.get(r.label, 0)),
+ }
+ for r in app._project.refs
+ ],
+ }
if method == "switch":
if app._project is None:
@@ -965,30 +1187,41 @@ class RpcServer:
else:
# Same path a project search hit takes, so it records a hop and
# Esc comes back here.
- app._switch_then_goto(label, int(str(addr), 0)
- if isinstance(addr, str) else int(addr))
- await settle(app, lambda: app._binary == label
- and app._func_index is not None
- and app._func_index.complete,
- timeout=float(params.get("timeout", 300.0)))
+ app._switch_then_goto(
+ label, int(str(addr), 0) if isinstance(addr, str) else int(addr)
+ )
+ await settle(
+ app,
+ lambda: (
+ app._binary == label
+ and app._func_index is not None
+ and app._func_index.complete
+ ),
+ timeout=float(params.get("timeout", 300.0)),
+ )
return snapshot(app)
# -- structured introspection (heavy: run off the UI loop) -------- #
loop = asyncio.get_running_loop()
if method == "pseudocode":
- return await loop.run_in_executor(None, pseudocode, app, params.get("target"))
+ return await loop.run_in_executor(
+ None, pseudocode, app, params.get("target")
+ )
if method == "disassembly":
mx = int(params.get("max", 2000))
return await loop.run_in_executor(
- None, disassembly, app, params.get("target"), mx)
+ None, disassembly, app, params.get("target"), mx
+ )
if method == "xrefs_to":
lim = int(params.get("limit", 200))
return await loop.run_in_executor(
- None, xrefs_to, app, params.get("target"), lim)
+ None, xrefs_to, app, params.get("target"), lim
+ )
if method == "xrefs_from":
lim = int(params.get("limit", 200))
return await loop.run_in_executor(
- None, xrefs_from, app, params.get("target"), lim)
+ None, xrefs_from, app, params.get("target"), lim
+ )
if method == "resolve":
return await loop.run_in_executor(None, resolve, app, params.get("name"))
@@ -998,15 +1231,24 @@ class RpcServer:
# optional ergonomic: place the cursor on a token before an edit/follow
if method in ("rename", "retype", "follow") and params.get("word"):
- if not cursor_on(app, str(params["word"]), params.get("line"),
- int(params.get("occurrence", 1))):
- raise ValueError(f"cursor_on: token {params['word']!r} not found "
- "in the current view")
+ if not cursor_on(
+ app,
+ str(params["word"]),
+ params.get("line"),
+ int(params.get("occurrence", 1)),
+ ):
+ raise ValueError(
+ f"cursor_on: token {params['word']!r} not found in the current view"
+ )
await drain(app)
if method == "cursor_on":
- found = cursor_on(app, str(params["word"]), params.get("line"),
- int(params.get("occurrence", 1)))
+ found = cursor_on(
+ app,
+ str(params["word"]),
+ params.get("line"),
+ int(params.get("occurrence", 1)),
+ )
await drain(app)
snap = snapshot(app)
snap["found"] = found
@@ -1024,7 +1266,8 @@ class RpcServer:
# on the function the caller *used* to be looking at.
raise TimeoutError(
f"goto {target!r} did not land within {timeout}s "
- f"(still at {_where(app)}); retry with a larger timeout=")
+ f"(still at {_where(app)}); retry with a larger timeout="
+ )
return snapshot(app)
if method == "define":
@@ -1032,80 +1275,95 @@ class RpcServer:
if kind not in _DEFINE_KEYS:
raise ValueError(
f"unknown define kind {kind!r}; one of "
- f"{', '.join(sorted(_DEFINE_KEYS))}")
+ f"{', '.join(sorted(_DEFINE_KEYS))}"
+ )
target = params.get("target")
if target not in (None, ""):
# Land on the address first. A raw image is mostly *undefined*,
# so the target usually has no name and no function — the goto
# predicate can't be address-based, only "we moved".
- await self._fill_prompt("g", "goto", str(target), delay,
- clear=False)
+ await self._fill_prompt("g", "goto", str(target), delay, clear=False)
await settle(app, timeout=timeout)
if app.is_hex:
# backslash leaves hex for the code view (which may be decomp).
- await self._press(["backslash"],
- lambda: not app.is_hex, timeout,
- "leave the hex view")
+ await self._press(
+ ["backslash"], lambda: not app.is_hex, timeout, "leave the hex view"
+ )
if app.is_decomp:
# These bindings live on the listing; in the decompiler the key
# would be swallowed or do something else entirely.
- await self._press(["tab"], lambda: app.is_listing,
- timeout, "switch to the listing")
+ await self._press(
+ ["tab"], lambda: app.is_listing, timeout, "switch to the listing"
+ )
if not app.is_listing:
raise RuntimeError(
f"define needs the listing view, but the active pane is "
- f"{app._active!r}")
- snap = await self._press([_DEFINE_KEYS[kind]], timeout=timeout,
- what=f"define {kind}")
+ f"{app._active!r}"
+ )
+ snap = await self._press(
+ [_DEFINE_KEYS[kind]], timeout=timeout, what=f"define {kind}"
+ )
snap["define"] = {"kind": kind, "status": snap.get("status", "")}
return snap
if method == "opfmt":
mode = str(params.get("mode", "cycle")).lower()
if mode not in _OPFMT_MODES:
- raise ValueError(f"unknown opfmt mode {mode!r}; one of "
- f"{', '.join(_OPFMT_MODES)}")
+ raise ValueError(
+ f"unknown opfmt mode {mode!r}; one of {', '.join(_OPFMT_MODES)}"
+ )
target = params.get("target")
if target not in (None, ""):
- await self._fill_prompt("g", "goto", str(target), delay,
- clear=False)
+ await self._fill_prompt("g", "goto", str(target), delay, clear=False)
await settle(app, timeout=timeout)
if app.is_hex:
- await self._press(["backslash"], lambda: not app.is_hex,
- timeout, "leave the hex view")
+ await self._press(
+ ["backslash"], lambda: not app.is_hex, timeout, "leave the hex view"
+ )
view = _active_widget(app)
if isinstance(view, HexView):
raise RuntimeError("opfmt needs a code view, not the hex view")
if params.get("word"):
# Land the column on the literal first: WHICH operand gets
# reformatted is decided by where the cursor is.
- if not cursor_on(app, str(params["word"]), params.get("line"),
- int(params.get("occurrence", 1) or 1)):
+ if not cursor_on(
+ app,
+ str(params["word"]),
+ params.get("line"),
+ int(params.get("occurrence", 1) or 1),
+ ):
raise RuntimeError(
f"{params['word']!r} is not on screen in this view, so "
- f"there is no literal to reformat")
+ f"there is no literal to reformat"
+ )
await drain(app)
elif params.get("line") is not None or params.get("col") is not None:
place_cursor(view, params.get("line"), params.get("col"))
await drain(app)
before = _where(app)
if mode in _OPFMT_KEYS:
- snap = await self._press([_OPFMT_KEYS[mode]], timeout=timeout,
- what=f"opfmt {mode}")
+ snap = await self._press(
+ [_OPFMT_KEYS[mode]], timeout=timeout, what=f"opfmt {mode}"
+ )
else:
view.focus()
view.action_op_format(mode)
await settle(app, timeout=timeout)
snap = snapshot(app)
- snap["opfmt"] = {"mode": mode, "at": before,
- "status": snap.get("status", "")}
+ snap["opfmt"] = {
+ "mode": mode,
+ "at": before,
+ "status": snap.get("status", ""),
+ }
return snap
if method == "rename_many":
return await self._rename_many(params, timeout)
if method == "rename":
- await self._fill_prompt("n", "rename", str(params["name"]), delay, clear=True)
+ await self._fill_prompt(
+ "n", "rename", str(params["name"]), delay, clear=True
+ )
await settle(app, timeout=timeout)
return snapshot(app)
if method == "comment":
@@ -1115,19 +1373,21 @@ class RpcServer:
# the Input widget. The app's _do_comment converts the two-char
# sequence '\n' into a real newline for IDA, so we escape here.
ctext = str(params["text"]).replace("\n", "\\n")
- await self._fill_prompt("semicolon", "comment", ctext, 0,
- clear=True)
+ await self._fill_prompt("semicolon", "comment", ctext, 0, clear=True)
await settle(app, timeout=timeout)
return snapshot(app)
if method == "retype":
- await self._fill_prompt("y", "retype", str(params["proto"]), delay, clear=True)
+ await self._fill_prompt(
+ "y", "retype", str(params["proto"]), delay, clear=True
+ )
await settle(app, timeout=timeout)
return snapshot(app)
if method == "follow":
depth = len(app._nav)
- return await self._press(["enter"], lambda: len(app._nav) > depth,
- timeout, "follow")
+ return await self._press(
+ ["enter"], lambda: len(app._nav) > depth, timeout, "follow"
+ )
if method == "back":
return await self._press(["escape"], timeout=timeout)
if method == "toggle_view":
@@ -1158,17 +1418,23 @@ class RpcServer:
# call that LEAVES hex could never be satisfied and always timed
# out -- a driver could open the hex view but never close it.
was_hex = app.is_hex
- return await self._press(["backslash"], lambda: app.is_hex != was_hex,
- timeout, "hex")
+ return await self._press(
+ ["backslash"], lambda: app.is_hex != was_hex, timeout, "hex"
+ )
if method == "graph":
return await self._graph(params, timeout)
if method == "xrefs":
return await self._press(
- ["x"], lambda: type(app.screen).__name__ == "XrefsScreen",
- timeout, "xrefs")
+ ["x"],
+ lambda: type(app.screen).__name__ == "XrefsScreen",
+ timeout,
+ "xrefs",
+ )
if method == "symbols":
await app._press_keys(["ctrl+n"])
- await settle(app, lambda: type(app.screen).__name__ == "SymbolPalette", timeout=10)
+ await settle(
+ app, lambda: type(app.screen).__name__ == "SymbolPalette", timeout=10
+ )
q = params.get("query")
if q:
await app._press_keys(_text_to_keys(str(q), delay))
@@ -1176,10 +1442,14 @@ class RpcServer:
return snapshot(app)
if method == "structs":
return await self._press(
- ["ctrl+t"], lambda: type(app.screen).__name__ == "StructEditor",
- timeout, "structs")
+ ["ctrl+t"],
+ lambda: type(app.screen).__name__ == "StructEditor",
+ timeout,
+ "structs",
+ )
if method == "find":
from . import search as _search
+
q = str(params.get("query", ""))
forced = params.get("mode")
forced = None if forced in (None, "auto") else str(forced)
@@ -1190,33 +1460,57 @@ class RpcServer:
raise ValueError(f"find: {problem}")
cleaned = _search.normalise_pattern(cleaned)
hits, err, truncated = await asyncio.to_thread(
- app.program.search, cleaned, mode,
+ app.program.search,
+ cleaned,
+ mode,
limit=int(params.get("limit", 500)),
- regex=bool(params.get("regex")), case=bool(params.get("case")))
+ regex=bool(params.get("regex")),
+ case=bool(params.get("case")),
+ )
if err:
raise ValueError(f"find: {err}")
- return {"mode": mode, "query": cleaned, "truncated": truncated,
- "hits": [{"addr": hex(h.addr), "head": hex(h.head),
- "line": h.line, "func": h.func,
- "seg": h.seg} for h in hits]}
+ return {
+ "mode": mode,
+ "query": cleaned,
+ "truncated": truncated,
+ "hits": [
+ {
+ "addr": hex(h.addr),
+ "head": hex(h.head),
+ "line": h.line,
+ "func": h.func,
+ "seg": h.seg,
+ }
+ for h in hits
+ ],
+ }
if method == "export":
# Deliberately NOT driven through the prompt: this is the one verb
# whose whole point is the file it leaves behind, and a driver needs
# the path back, not a screenshot of a prompt closing.
from . import findings
+
path = params.get("path")
app.journal.load(app.program)
app.journal.flush(app.program)
out, f = await asyncio.to_thread(
- findings.export, app.program, app._open_path or "",
+ findings.export,
+ app.program,
+ app._open_path or "",
str(path) if path else None,
- types=bool(params.get("types", True)), journal=app.journal)
+ types=bool(params.get("types", True)),
+ journal=app.journal,
+ )
app._status(f"exported findings → {out}", priority=True)
await drain(app)
- return {"path": out, "comments": len(f.comments),
- "names": len(findings._user_names(f)),
- "types": len(f.types), "functions": f.n_functions,
- "bytes": os.path.getsize(out) if os.path.exists(out) else 0}
+ return {
+ "path": out,
+ "comments": len(f.comments),
+ "names": len(findings._user_names(f)),
+ "types": len(f.types),
+ "functions": f.n_functions,
+ "bytes": os.path.getsize(out) if os.path.exists(out) else 0,
+ }
if method == "close":
return await self._press(["escape"], timeout=timeout)
if method == "save":
@@ -1224,13 +1518,16 @@ class RpcServer:
if method == "search":
term = str(params.get("term", ""))
- open_key = "slash" if int(params.get("direction", 1)) >= 0 else "question_mark"
+ open_key = (
+ "slash" if int(params.get("direction", 1)) >= 0 else "question_mark"
+ )
await self._fill_prompt(open_key, "search", term, delay, clear=True)
await settle(app, timeout=timeout)
return snapshot(app)
if method == "select":
from textual.widgets import OptionList
+
scr = app.screen
if type(scr).__name__ not in _MODALS:
raise ValueError("select: no modal list is open")
@@ -1249,8 +1546,10 @@ class RpcServer:
if method == "move":
key = _MOVE_KEYS.get(str(params.get("dir")))
if key is None:
- raise ValueError(f"unknown move dir: {params.get('dir')!r} "
- f"(one of {sorted(_MOVE_KEYS)})")
+ raise ValueError(
+ f"unknown move dir: {params.get('dir')!r} "
+ f"(one of {sorted(_MOVE_KEYS)})"
+ )
n = max(1, int(params.get("n", 1)))
await app._press_keys([key] * n)
if params.get("settle", True):
diff --git a/idatui/rpcclient.py b/idatui/rpcclient.py
index 4231d62..df0ba63 100644
--- a/idatui/rpcclient.py
+++ b/idatui/rpcclient.py
@@ -15,6 +15,7 @@ Also usable as a library:
No auth: whoever can r/w the socket drives the app.
"""
+
from __future__ import annotations
import json
@@ -114,8 +115,10 @@ def main(argv: list[str]) -> int:
sock = args[1]
args = args[2:]
if not sock:
- print("error: no socket (pass --sock PATH or set IDATUI_RPC_SOCK)",
- file=sys.stderr)
+ print(
+ "error: no socket (pass --sock PATH or set IDATUI_RPC_SOCK)",
+ file=sys.stderr,
+ )
return 2
if not args:
print("error: no method given", file=sys.stderr)
@@ -124,7 +127,7 @@ def main(argv: list[str]) -> int:
method, rest = args[0], args[1:]
params: dict[str, Any] = {}
if method == "keys":
- params["keys"] = rest # every positional is a key name
+ params["keys"] = rest # every positional is a key name
elif method == "text" and rest and "=" not in rest[0]:
# first positional is the literal text; the rest may be key=value
params["text"] = rest[0]
diff --git a/idatui/search.py b/idatui/search.py
index feb7e3d..ba8c692 100644
--- a/idatui/search.py
+++ b/idatui/search.py
@@ -69,7 +69,7 @@ def classify(query: str, forced: str | None = None) -> tuple[str, str]:
low = q.lower()
for prefix, mode in (("hex:", BYTES), ("bytes:", BYTES), ("text:", TEXT)):
if low.startswith(prefix):
- return (mode, q[len(prefix):].strip())
+ return (mode, q[len(prefix) :].strip())
if forced in (TEXT, BYTES):
return (forced, q)
if looks_like_bytes(q) or probably_meant_bytes(q):
@@ -89,7 +89,7 @@ def normalise_pattern(pattern: str) -> str:
q = q.replace(",", " ")
# "488B??C3" -- a bare hex run with no separators at all.
if " " not in q and len(q) > 2 and len(q) % 2 == 0:
- q = " ".join(q[i:i + 2] for i in range(0, len(q), 2))
+ q = " ".join(q[i : i + 2] for i in range(0, len(q), 2))
return " ".join(q.split())
@@ -107,6 +107,7 @@ def pattern_problem(pattern: str) -> str | None:
tokens = [t for t in q.split() if t]
bad = [t for t in tokens if not _TOKEN.match(t)]
if bad:
- return (f"{bad[0]!r} is not a byte: use hex pairs, ? wildcards "
- 'or a "quoted string"')
+ return (
+ f'{bad[0]!r} is not a byte: use hex pairs, ? wildcards or a "quoted string"'
+ )
return None
diff --git a/idatui/trace.py b/idatui/trace.py
index 931f918..3d144d3 100644
--- a/idatui/trace.py
+++ b/idatui/trace.py
@@ -59,19 +59,25 @@ class TraceInfo:
def load(cls, path: str) -> "TraceInfo | None":
try:
with open(path) as f:
- raw = dict(
- ln.strip().split("=", 1) for ln in f if "=" in ln)
+ raw = dict(ln.strip().split("=", 1) for ln in f if "=" in ln)
except OSError:
return None
+
def num(k):
try:
return int(raw.get(k, "0"), 0)
except ValueError:
return 0
- return cls(arch=raw.get("arch", ""), mode=raw.get("mode", ""),
- binary=raw.get("binary", ""), start_code=num("start_code"),
- end_code=num("end_code"), entry_code=num("entry_code"),
- traced=raw.get("traced", ""))
+
+ return cls(
+ arch=raw.get("arch", ""),
+ mode=raw.get("mode", ""),
+ binary=raw.get("binary", ""),
+ start_code=num("start_code"),
+ end_code=num("end_code"),
+ entry_code=num("entry_code"),
+ traced=raw.get("traced", ""),
+ )
@dataclass
@@ -233,8 +239,7 @@ class Trace:
return vals[i] if i >= 0 else None
def register_state(self, idx: int) -> dict[str, int]:
- return {n: v for n in self.reg_at
- if (v := self.register(n, idx)) is not None}
+ return {n: v for n in self.reg_at if (v := self.register(n, idx)) is not None}
def changed(self, idx: int) -> set[str]:
"""Registers written BY the instruction at ``idx`` (what the line said).
@@ -277,9 +282,13 @@ class Trace:
out = []
for k in range(lo, hi):
off, ln = self.mem_off[k], self.mem_len[k]
- out.append(MemOp(addr=self.mem_addr[k],
- data=bytes(self.mem_blob[off:off + ln]),
- write=bool(self.mem_write[k])))
+ out.append(
+ MemOp(
+ addr=self.mem_addr[k],
+ data=bytes(self.mem_blob[off : off + ln]),
+ write=bool(self.mem_write[k]),
+ )
+ )
return out
# -- memory state ------------------------------------------------------- #
@@ -298,8 +307,9 @@ class Trace:
self._mem_starts = [self.mem_addr[k] for k in order]
self._mem_maxlen = max(self.mem_len) if len(self.mem_len) else 0
- def memory_raw(self, addr: int, length: int,
- idx: int | None = None) -> tuple[bytes, bytes]:
+ def memory_raw(
+ self, addr: int, length: int, idx: int | None = None
+ ) -> tuple[bytes, bytes]:
"""Memory at a TRACE address (no slide).
The stack lives here. Measured on two real traces, 0% of memory accesses
@@ -309,8 +319,9 @@ class Trace:
"""
return self.memory(addr + self.slide, length, idx)
- def memory(self, addr: int, length: int,
- idx: int | None = None) -> tuple[bytes, bytes]:
+ def memory(
+ self, addr: int, length: int, idx: int | None = None
+ ) -> tuple[bytes, bytes]:
"""``(data, known)`` for ``length`` bytes at ``addr`` as of ``idx``.
``known`` is a byte-per-byte mask: a trace only says what it saw, so a
@@ -332,6 +343,7 @@ class Trace:
raw = addr - self.slide
best = [-1] * length
import bisect as _b
+
lo = _b.bisect_left(self._mem_starts, raw - self._mem_maxlen)
hi = _b.bisect_right(self._mem_starts, raw + length - 1)
for pos in range(lo, hi):
@@ -369,6 +381,7 @@ class Trace:
self._mem_index()
raw = addr - self.slide
import bisect as _b
+
lo = _b.bisect_left(self._mem_starts, raw - self._mem_maxlen)
hi = _b.bisect_right(self._mem_starts, raw + length - 1)
out = set()
@@ -439,7 +452,9 @@ class Trace:
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)
+ 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:
diff --git a/idatui/trace_ctl.py b/idatui/trace_ctl.py
index 8072801..5a91d37 100644
--- a/idatui/trace_ctl.py
+++ b/idatui/trace_ctl.py
@@ -15,6 +15,7 @@ The controller owns the trace state. ``IdaTui`` keeps forwarding properties
(``app._trace``, ``app._t``, ``app._trail_map``...) because the pilot suite and
the RPC layer read them by those names; see ``IdaTui._trace``.
"""
+
from __future__ import annotations
import bisect
@@ -23,7 +24,7 @@ from typing import TYPE_CHECKING
from . import diag
-if TYPE_CHECKING: # pragma: no cover
+if TYPE_CHECKING: # pragma: no cover
from .app import IdaTui
_app_mod = None
@@ -39,6 +40,7 @@ def _views():
global _app_mod
if _app_mod is None:
from . import app as _m
+
_app_mod = _m
return _app_mod
@@ -48,15 +50,15 @@ class TraceController:
def __init__(self, app: "IdaTui", path: str = "") -> None:
self.app = app
- self.path = path or "" # the Tenet trace to explore, if any
- self.trace = None # the loaded Trace, once analysed
- self.t = 0 # current timestamp in that trace
- self.trail_map = [] # decomp_map for trail_map_ea
+ self.path = path or "" # the Tenet trace to explore, if any
+ self.trace = None # the loaded Trace, once analysed
+ self.t = 0 # current timestamp in that trace
+ self.trail_map = [] # decomp_map for trail_map_ea
self.trail_map_ea = None
- self.trail_line_of: dict[int, int] = {} # ea -> pseudocode line
- self.trail_eas: list[int] = [] # sorted keys of trail_line_of
- self.trail_span = None # ea span of that function
- self.pending_line = None # step waiting on a re-decompile
+ self.trail_line_of: dict[int, int] = {} # ea -> pseudocode line
+ self.trail_eas: list[int] = [] # sorted keys of trail_line_of
+ self.trail_span = None # ea span of that function
+ self.pending_line = None # step waiting on a re-decompile
@property
def armed(self) -> bool:
@@ -82,26 +84,30 @@ class TraceController:
Called on a worker thread, so every touch of the UI hops back.
"""
from .trace import Trace
+
app = self.app
path = self.path
try:
+
def note(n):
- app.call_from_thread(
- app._status, f"trace: {n:,} instructions\u2026")
+ app.call_from_thread(app._status, f"trace: {n:,} instructions\u2026")
+
trace = Trace.load(path, progress=note)
except OSError as e:
app.call_from_thread(app._status, f"trace: {e}")
return
if not trace.length:
app.call_from_thread(
- app._status, f"trace: {os.path.basename(path)} is empty")
+ app._status, f"trace: {os.path.basename(path)} is empty"
+ )
return
idx = app._func_index
addrs = [f.addr for f in idx.all_loaded()] if idx is not None else []
slide = trace.rebase(addrs)
trace.apply_slide(slide)
- hit = sum(1 for f in (idx.all_loaded() if idx else [])
- if trace.executions(f.addr))
+ hit = sum(
+ 1 for f in (idx.all_loaded() if idx else []) if trace.executions(f.addr)
+ )
app.call_from_thread(self.ready, trace, slide, hit)
def ready(self, trace, slide: int, hit: int) -> None:
@@ -111,9 +117,11 @@ class TraceController:
dock = app.query_one(_views().TraceDock)
dock.display = True
dock.show(trace, 0)
- where = (f"rebased {slide:+#x}" if slide else "no rebase needed")
- app._status(f"trace: {trace.length:,} instructions, {hit} functions "
- f"touched ({where})", priority=True)
+ where = f"rebased {slide:+#x}" if slide else "no rebase needed"
+ app._status(
+ f"trace: {trace.length:,} instructions, {hit} functions touched ({where})",
+ priority=True,
+ )
self.seek(0, follow=True)
# -- trace navigation --------------------------------------------------- #
@@ -144,8 +152,7 @@ class TraceController:
# 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.
- app._goto_ea(pc, push=False,
- prefer_decomp=(app.is_decomp))
+ app._goto_ea(pc, push=False, prefer_decomp=(app.is_decomp))
def seek_split(self, pc: int) -> bool:
"""Put BOTH panes on ``pc``. True if handled.
@@ -166,7 +173,7 @@ class TraceController:
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
+ return False # not in this listing (other segment): full nav
lst.cursor = row
lst._scroll_cursor_into_view()
@@ -177,8 +184,9 @@ class TraceController:
# bounced main -> PLT stub -> main, each bounce costing a synchronous
# 769-line map fetch on the UI thread.
span = self.trail_span
- inside = (pc in self.trail_line_of
- or (span is not None and span[0] <= pc <= span[1]))
+ inside = pc in self.trail_line_of or (
+ span is not None and span[0] <= pc <= span[1]
+ )
if not inside:
self.pending_line = pc
app._resync_decomp_async(pc)
@@ -227,7 +235,7 @@ class TraceController:
t = self.trace
if t is None:
return
- hx = app._try_view(M.HexView) # None until it's mounted
+ hx = app._try_view(M.HexView) # None until it's mounted
if hx is not None:
hx.trace, hx.trace_idx = t, self.t
if hx.display:
@@ -325,7 +333,7 @@ class TraceController:
sp_name = "rsp" if "rsp" in t.reg_at else ("esp" if "esp" in t.reg_at else "sp")
sp0 = t.register(sp_name, self.t)
i = self.t + direction
- limit = 200000 # a runaway search must not hang the UI
+ limit = 200000 # a runaway search must not hang the UI
while 0 <= i < t.length and limit > 0:
sp = t.register(sp_name, i)
if sp0 is None or sp is None or sp >= sp0:
@@ -364,15 +372,17 @@ class TraceController:
# and often no question at all, since most lines have no marker.
line = view.cursor
eas = []
- if (self.trail_map_ea == view.loaded_ea
- and 0 <= line < len(self.trail_map or [])):
+ if self.trail_map_ea == view.loaded_ea and 0 <= line < len(
+ self.trail_map or []
+ ):
eas = list(self.trail_map[line])
if not eas:
one = view._line_ea(line)
eas = [one] if one is not None else []
if not eas:
- app._status("this line has no instructions to seek on",
- priority=True)
+ app._status(
+ "this line has no instructions to seek on", priority=True
+ )
return
stamps = sorted({x for e in eas for x in t.executions(e)})
what = f"execution of C line {line + 1}"
@@ -392,12 +402,15 @@ class TraceController:
i = bisect.bisect_left(stamps, self.t) - 1
if not (0 <= i < len(stamps)):
edge = "last" if direction > 0 else "first"
- app._status(f"already at the {edge} {what} "
- f"({len(stamps)} in the trace)", priority=True)
+ app._status(
+ f"already at the {edge} {what} ({len(stamps)} in the trace)",
+ priority=True,
+ )
return
self.seek(stamps[i])
- app._status(f"{what}: {i + 1} of {len(stamps)} @ t={stamps[i]:,}",
- priority=True)
+ app._status(
+ f"{what}: {i + 1} of {len(stamps)} @ t={stamps[i]:,}", priority=True
+ )
def seek_reg_write(self) -> None:
"""W: which instruction set each register to its current value."""
@@ -410,11 +423,13 @@ class TraceController:
v = t.register(name, self.t)
if v is None:
continue
- rows.append((name, v, t.last_write(name, self.t),
- t.next_write(name, self.t)))
+ rows.append(
+ (name, v, t.last_write(name, self.t), t.next_write(name, self.t))
+ )
if rows:
- app.push_screen(_views().RegWriteScreen(rows, self.t),
- self._on_reg_write_chosen)
+ app.push_screen(
+ _views().RegWriteScreen(rows, self.t), self._on_reg_write_chosen
+ )
def _on_reg_write_chosen(self, idx) -> None: # type: ignore[no-untyped-def]
if idx is not None:
diff --git a/plan/rpc.md b/plan/rpc.md
deleted file mode 100644
index 1f6b119..0000000
--- a/plan/rpc.md
+++ /dev/null
@@ -1,20 +0,0 @@
-## RPC functionality
-
-we need some way to drive an instance of our IDA tui programatically,
-with all UI interactions actually being shown as if a regular user was driving
-the software.
-
-the rationale is we'll be doing some machine-assisted reverse engineering
-and livestreaming the work/progress on our little terminal streaming platform,
-sl0p.foo !
-
-come up with a plan on how to architect this, remember the machine (you, the LLM)
-that will eventually be driving our TUI is running inside a tmux pane.
-I think it'd be most sensible if you assume we run the TUI in some kind of rpc/server
-mode in a second pane and talk to it over unixsocket/tcp.
-
-I think we want some "highlevel" RPC primitives for UI actions, but also a
-more raw RPC primitive that lets us send "keystrokes" to the TUI.
-
-carefully go through our current architecture and the requirements I vaguely
-sketched out above and propose an implementation.
diff --git a/pyproject.toml b/pyproject.toml
index 1171107..c16d406 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,18 +1,23 @@
[project]
name = "idatui"
version = "0.0.1"
-description = "A keyboard-first TUI frontend for shared IDA Code Mode databases."
+description = "A keyboard-first TUI frontend for shared IDA Nexus databases."
requires-python = ">=3.11"
-# ida-codemode supplies GUI discovery, shared idalib workers, leases, and the
+# ida-nexus supplies GUI discovery, shared idalib workers, leases, and the
# execute_python/ida-domain database surface.
dependencies = [
- "ida-codemode>=0.3.1",
+ "ida-nexus>=0.7.0",
"textual>=8",
"pygments>=2", # Used directly for pseudocode highlighting.
]
[project.optional-dependencies]
-dev = ["pytest>=8"]
+dev = ["pytest>=8", "ruff==0.16.3"] # ruff.toml pins the same version
+# The graph view's SESE layout engine (`e` in graph mode) is optional: without
+# it, layout falls back to the pure-python engine. It is deliberately NOT listed
+# as a dependency -- PyPI's pytriskel has no wheel for current Pythons, no sdist,
+# and a binding bug that makes edge routes unreachable. It needs a patched fork
+# that is not published yet; see docs/GRAPH_VIEW.md.
[project.scripts]
idatui = "idatui.launch:main"
@@ -24,3 +29,4 @@ build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["idatui"]
+
diff --git a/rehearsed-engineer.md b/rehearsed-engineer.md
deleted file mode 100644
index e7fd23a..0000000
--- a/rehearsed-engineer.md
+++ /dev/null
@@ -1,6 +0,0 @@
- alright, we're going try to solve a CTF challenge you can find in ~/re300_files
- you should be able to analyze it using our ida TUI in a fresh tmux pane.
- we're livestreaming this work, so Id prefer you interact with the TUI where
- possible to make this (somewhat) entertaining to watch. remember to populate the ida idb
- with good comments, names, types and other metadata that can help us understand
- and follow your reverse engineering progress.
diff --git a/ruff.toml b/ruff.toml
new file mode 100644
index 0000000..b7162a5
--- /dev/null
+++ b/ruff.toml
@@ -0,0 +1,40 @@
+# How the Python in this tree is formatted, and how its imports are ordered.
+# `.githooks/pre-commit` applies both to what you staged (install it with
+# `git config core.hooksPath .githooks`); `ruff format --check .` asks.
+#
+# Adopted when PR #1 arrived black-formatted: ruff's formatter IS black's
+# output, it is one static binary instead of a Python dependency chain, and it
+# is fast enough that a pre-commit hook is free. Configuration is deliberately
+# left at the defaults -- every knob turned here is an argument somebody has to
+# have again later.
+
+# The version this tree was formatted with, enforced by ruff itself: a
+# formatter whose output moves between releases turns "formatted" into
+# "formatted by whoever committed last", and the diff lands on the next
+# person. A mismatch is refused with both versions named rather than quietly
+# reformatting everything. Bumping it is one line here, one in the hook's
+# install hint, and a reformat commit (added to .git-blame-ignore-revs) --
+# the honest cost of a new version.
+required-version = "==0.16.3"
+
+target-version = "py311"
+line-length = 88
+
+[format]
+# 0.16 formats Python fenced blocks inside markdown by default. The snippets
+# in docs/ are deliberately terse repro recipes (a `try: x` one-liner reads as
+# one step); a formatter blowing them up to three lines makes them worse, and
+# nothing executes them. Real code lives in .py files, which are formatted.
+exclude = ["*.md"]
+
+[lint]
+# Import sorting, and nothing else. `ruff check` can enforce a great deal more
+# and one day it might, but this hook's job is formatting: one that also
+# refused a commit over an unused variable would be a different feature, and
+# not one anybody asked for.
+#
+# Sorting is safe here because ruff sorts import BLOCKS and never hoists across
+# a statement: every test file's `sys.path.insert(...)` sits between the
+# standard library and the `idatui` imports, and that line is exactly what
+# makes them importable.
+select = ["I"]
diff --git a/tests/_fixtures.py b/tests/_fixtures.py
index 16192fa..87f6184 100644
--- a/tests/_fixtures.py
+++ b/tests/_fixtures.py
@@ -20,6 +20,7 @@ writes back to -- turns that into a file copy.
The cache is rebuilt whenever it is older than the binary, so editing a target
doesn't silently test the previous one. `.pristine.i64` is gitignored.
"""
+
from __future__ import annotations
import asyncio
@@ -64,10 +65,12 @@ def fast_keys() -> None:
if not hasattr(textual.app, "wait_for_idle"): # pragma: no cover
raise RuntimeError(
"textual.app.wait_for_idle is gone -- tests/_fixtures.fast_keys "
- "needs updating for this Textual version")
+ "needs updating for this Textual version"
+ )
- async def _yield_instead_of_sleeping(min_sleep: float = 0.0,
- max_sleep: float = 1.0) -> None:
+ async def _yield_instead_of_sleeping(
+ min_sleep: float = 0.0, max_sleep: float = 1.0
+ ) -> None:
await asyncio.sleep(0)
async def _press(self, *keys: str) -> None:
@@ -110,7 +113,7 @@ def synthetic(name: str, build) -> str:
path = os.path.join(SYNTHETIC_DIR, name)
data = build()
if not os.path.exists(path) or open(path, "rb").read() != data:
- with open(path, "wb") as fh: # content changed -> cache is stale
+ with open(path, "wb") as fh: # content changed -> cache is stale
fh.write(data)
for stale in (cache_path(path), path + ".i64"):
if os.path.exists(stale):
@@ -133,7 +136,7 @@ async def build_pristine(binary: str, cache: str, app_factory) -> None:
break
app.program.client.save_database()
# Textual's headless run_test context does not reliably emit App.Unmount on
- # every platform/version; release the Code Mode lease explicitly.
+ # every platform/version; release the IDA Nexus lease explicitly.
if app.program is not None:
app.program.close()
if app.client is not None:
diff --git a/tests/run.py b/tests/run.py
index b38a707..2fb093f 100755
--- a/tests/run.py
+++ b/tests/run.py
@@ -38,6 +38,7 @@ Usage::
Exit code is 0 only if every file selected ran and passed.
"""
+
from __future__ import annotations
import argparse
@@ -51,8 +52,8 @@ import time
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TESTS = os.path.join(ROOT, "tests")
-#: The IDA-capable interpreter. The pilot tests need textual AND the Code Mode
-#: library in one python; the database process is Code Mode's to place.
+#: The IDA-capable interpreter. The pilot tests need textual AND the IDA Nexus
+#: library in one python; the database process is IDA Nexus's to place.
DEFAULT_PY = os.path.expanduser("~/ida-venv/bin/python")
#: Both shapes the suites print: "N passed, M failed" and "N checks, M failed".
@@ -80,14 +81,17 @@ def needs_ida(path: str) -> bool:
if isinstance(target, ast.Name) and target.id == "NEEDS_IDA":
value = ast.literal_eval(node.value)
if not isinstance(value, bool):
- raise Marker(f"{os.path.basename(path)}: "
- f"NEEDS_IDA must be a bool, got {value!r}")
+ raise Marker(
+ f"{os.path.basename(path)}: "
+ f"NEEDS_IDA must be a bool, got {value!r}"
+ )
return value
raise Marker(
f"{os.path.basename(path)}: no NEEDS_IDA marker.\n"
f" Add `NEEDS_IDA = True` (spawns a worker / drives the pilot) or\n"
f" `NEEDS_IDA = False` (pure: stdlib, no IDA, runs anywhere) at module\n"
- f" scope, so tests/run.py --fast knows whether it can run you.")
+ f" scope, so tests/run.py --fast knows whether it can run you."
+ )
def discover() -> list[tuple[str, bool]]:
@@ -124,41 +128,70 @@ def tally(output: str) -> tuple[int, int] | None:
def run_one(path: str, python: str, extra: list[str], echo: bool) -> dict:
"""Run one test file as a subprocess and summarise it."""
- name = os.path.basename(path)[len("test_"):-len(".py")]
+ name = os.path.basename(path)[len("test_") : -len(".py")]
started = time.time()
- proc = subprocess.run([python, path, *extra], cwd=ROOT,
- capture_output=not echo, text=True)
+ proc = subprocess.run(
+ [python, path, *extra], cwd=ROOT, capture_output=not echo, text=True
+ )
took = time.time() - started
out = "" if echo else (proc.stdout or "") + (proc.stderr or "")
counts = tally(out)
skipped = bool(_SKIP.search(out)) and (counts is None or counts == (0, 0))
return {
- "name": name, "path": path, "code": proc.returncode, "took": took,
+ "name": name,
+ "path": path,
+ "code": proc.returncode,
+ "took": took,
"passed": counts[0] if counts else 0,
"failed": counts[1] if counts else 0,
"counted": counts is not None,
- "skipped": skipped, "output": out,
+ "skipped": skipped,
+ "output": out,
}
def main(argv: list[str]) -> int:
ap = argparse.ArgumentParser(
- prog="tests/run.py", description=__doc__,
- formatter_class=argparse.RawDescriptionHelpFormatter)
- ap.add_argument("only", nargs="*", metavar="SUBSTR",
- help="only run test files whose name contains one of these")
- ap.add_argument("--fast", action="store_true",
- help="skip every file that needs IDA (seconds, runs anywhere)")
- ap.add_argument("--ida-only", action="store_true",
- help="only the files that need IDA")
- ap.add_argument("--list", action="store_true",
- help="show what would run, and whether it needs IDA")
- ap.add_argument("-x", "--exitfirst", action="store_true",
- help="stop after the first failing file")
- ap.add_argument("-v", "--verbose", action="store_true",
- help="stream each suite's output instead of capturing it")
- ap.add_argument("--python", default=os.environ.get("IDATUI_PYTHON", DEFAULT_PY),
- help=f"interpreter for the IDA suites (default {DEFAULT_PY})")
+ prog="tests/run.py",
+ description=__doc__,
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ )
+ ap.add_argument(
+ "only",
+ nargs="*",
+ metavar="SUBSTR",
+ help="only run test files whose name contains one of these",
+ )
+ ap.add_argument(
+ "--fast",
+ action="store_true",
+ help="skip every file that needs IDA (seconds, runs anywhere)",
+ )
+ ap.add_argument(
+ "--ida-only", action="store_true", help="only the files that need IDA"
+ )
+ ap.add_argument(
+ "--list",
+ action="store_true",
+ help="show what would run, and whether it needs IDA",
+ )
+ ap.add_argument(
+ "-x",
+ "--exitfirst",
+ action="store_true",
+ help="stop after the first failing file",
+ )
+ ap.add_argument(
+ "-v",
+ "--verbose",
+ action="store_true",
+ help="stream each suite's output instead of capturing it",
+ )
+ ap.add_argument(
+ "--python",
+ default=os.environ.get("IDATUI_PYTHON", DEFAULT_PY),
+ help=f"interpreter for the IDA suites (default {DEFAULT_PY})",
+ )
args, extra = ap.parse_known_args(argv)
try:
@@ -190,10 +223,12 @@ def main(argv: list[str]) -> int:
# an IDA file needs the interpreter that has textual + idapro.
pure_py = sys.executable
if any(ida for _, ida in selected) and not os.path.exists(args.python):
- print(f"error: {args.python} not found — the IDA suites need an "
- f"interpreter with textual + idapro.\n"
- f" Pass --python, set $IDATUI_PYTHON, or use --fast.",
- file=sys.stderr)
+ print(
+ f"error: {args.python} not found — the IDA suites need an "
+ f"interpreter with textual + idapro.\n"
+ f" Pass --python, set $IDATUI_PYTHON, or use --fast.",
+ file=sys.stderr,
+ )
return 2
results = []
@@ -226,7 +261,7 @@ def main(argv: list[str]) -> int:
elif r["code"] != 0 or r["failed"]:
state = "\033[31mFAIL\033[0m"
elif not r["counted"]:
- state = "\033[33m ? \033[0m" # exit 0 but printed no tally
+ state = "\033[33m ? \033[0m" # exit 0 but printed no tally
else:
state = "\033[32m ok \033[0m"
detail = f"{r['passed']:4d} passed"
diff --git a/tests/test_blob_ui.py b/tests/test_blob_ui.py
index d4740d0..7c3aee5 100644
--- a/tests/test_blob_ui.py
+++ b/tests/test_blob_ui.py
@@ -19,12 +19,12 @@ import tempfile
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+from _fixtures import fast_keys, staged, synthetic # noqa: E402
from textual.widgets import Input, Static # noqa: E402
from idatui.app import ConfirmScreen, IdaTui, ListingView # noqa: E402
-from _fixtures import fast_keys, staged, synthetic # noqa: E402
-fast_keys() # ~85ms -> ~2ms per keypress; see _fixtures.fast_keys
+fast_keys() # ~85ms -> ~2ms per keypress; see _fixtures.fast_keys
from idatui._sync import settle # noqa: E402
PASS = FAIL = 0
@@ -66,14 +66,19 @@ def _blob_bytes() -> bytes:
functions" is asserted below.
"""
import random
+
data = bytearray(random.Random(0xB10BCAFE).randbytes(64 * 1024))
- # -parm puts IDA in AArch64 mode, so these are A64 encodings; the ARM32
- # spelling of a nop (0xE1A00000) is NOT decodable there and made this
- # test fail for a reason that had nothing to do with what it checks.
- for k, insn in enumerate((0xD503201F, # nop
- 0xD503201F, # nop
- 0xD65F03C0)): # ret <- the run must stop here
- data[PLANTED + k * 4:PLANTED + k * 4 + 4] = insn.to_bytes(4, "little")
+ # -parm puts IDA in AArch64 mode, so these are A64 encodings; the ARM32
+ # spelling of a nop (0xE1A00000) is NOT decodable there and made this
+ # test fail for a reason that had nothing to do with what it checks.
+ for k, insn in enumerate(
+ (
+ 0xD503201F, # nop
+ 0xD503201F, # nop
+ 0xD65F03C0,
+ )
+ ): # ret <- the run must stop here
+ data[PLANTED + k * 4 : PLANTED + k * 4 + 4] = insn.to_bytes(4, "little")
return bytes(data)
@@ -109,27 +114,45 @@ async def run() -> int:
# happens AFTER a described blob turns out to contain nothing.
app = _blob_app(blob)
async with app.run_test(size=(140, 44)) as pilot:
- ok = await wait(lambda: app._func_index is not None
- and app._func_index.complete, pilot)
+ ok = await wait(
+ lambda: app._func_index is not None and app._func_index.complete, pilot
+ )
check("a random blob still finishes loading", ok)
- check("and really has no functions", len(app._func_index) == 0,
- f"n={len(app._func_index)}")
+ check(
+ "and really has no functions",
+ len(app._func_index) == 0,
+ f"n={len(app._func_index)}",
+ )
landed = await wait(lambda: app._cur is not None, pilot, 60)
- check("it lands somewhere instead of leaving empty panes", landed,
- f"cur={app._cur}")
+ check(
+ "it lands somewhere instead of leaving empty panes",
+ landed,
+ f"cur={app._cur}",
+ )
lst = app.query_one(ListingView)
- check("the listing actually has rows to show",
- lst.total > 0, f"total={lst.total}")
- check("landed at the image base", app._cur is not None
- and app._cur.ea == 0x4000, f"{app._cur.ea if app._cur else None:#x}")
+ check(
+ "the listing actually has rows to show",
+ lst.total > 0,
+ f"total={lst.total}",
+ )
+ check(
+ "landed at the image base",
+ app._cur is not None and app._cur.ea == 0x4000,
+ f"{app._cur.ea if app._cur else None:#x}",
+ )
status = str(app.query_one("#status", Static).render())
- check("the status says there are no functions (not 'still loading')",
- "no functions" in status and "still loading" not in status,
- status[:90])
- check("and points at the likely cause",
- "processor" in status and "Ctrl+L" in status, status[:90])
+ check(
+ "the status says there are no functions (not 'still loading')",
+ "no functions" in status and "still loading" not in status,
+ status[:90],
+ )
+ check(
+ "and points at the likely cause",
+ "processor" in status and "Ctrl+L" in status,
+ status[:90],
+ )
# The hint is a property of the database, so it must survive moving
# around — an earlier version wrote it once and the next status
@@ -139,8 +162,9 @@ async def run() -> int:
await pilot.press("down")
await settle(app)
status2 = str(app.query_one("#status", Static).render())
- check("the hint survives navigating", "no functions" in status2,
- status2[:90])
+ check(
+ "the hint survives navigating", "no functions" in status2, status2[:90]
+ )
# -- byte-granular carving ---------------------------------- #
# An undefined run arrives as ONE head ("db N dup(?)"). It has to
@@ -148,47 +172,64 @@ async def run() -> int:
# press `c` at, which is how IDA works and the only way to find an
# instruction stream that doesn't start at the run's first byte.
m = lst.model
- check("an undefined run presents one row per byte",
- m.loaded() >= 4096 and len(m._heads) < 64,
- f"rows={m.loaded()} physical heads={len(m._heads)}")
+ check(
+ "an undefined run presents one row per byte",
+ m.loaded() >= 4096 and len(m._heads) < 64,
+ f"rows={m.loaded()} physical heads={len(m._heads)}",
+ )
rows = m.window(0, 4)
- check("each row is a single addressable byte",
- [h.ea for h in rows] == [0x4000, 0x4001, 0x4002, 0x4003]
- and all(h.size == 1 for h in rows),
- f"{[(hex(h.ea), h.size) for h in rows]}")
- check("and shows its value, not a placeholder",
- all(h.text.startswith("db ") and "dup" not in h.text
- for h in rows), f"{[h.text for h in rows]}")
+ check(
+ "each row is a single addressable byte",
+ [h.ea for h in rows] == [0x4000, 0x4001, 0x4002, 0x4003]
+ and all(h.size == 1 for h in rows),
+ f"{[(hex(h.ea), h.size) for h in rows]}",
+ )
+ check(
+ "and shows its value, not a placeholder",
+ all(h.text.startswith("db ") and "dup" not in h.text for h in rows),
+ f"{[h.text for h in rows]}",
+ )
# The point of all this: land on an arbitrary byte and convert it.
i = m.index_of_ea(0x4021)
- check("an address inside the run resolves to its own row",
- i >= 0 and m.get(i).ea == 0x4021,
- f"row={i} ea={m.get(i).ea if i >= 0 else None}")
+ check(
+ "an address inside the run resolves to its own row",
+ i >= 0 and m.get(i).ea == 0x4021,
+ f"row={i} ea={m.get(i).ea if i >= 0 else None}",
+ )
- target = 0x4000 + PLANTED # a NOP we put there ourselves
+ target = 0x4000 + PLANTED # a NOP we put there ourselves
lst.cursor = m.index_of_ea(target)
lst._scroll_cursor_into_view()
await settle(app, lambda: lst._cursor_ea() == target)
- check("the cursor sits on the byte we aimed at",
- lst._cursor_ea() == target,
- f"{lst._cursor_ea():#x} want {target:#x}")
+ check(
+ "the cursor sits on the byte we aimed at",
+ lst._cursor_ea() == target,
+ f"{lst._cursor_ea():#x} want {target:#x}",
+ )
await pilot.press("c")
# settle(), not a fixed sleep AND not a bare predicate: an edit can
# look done for a moment and then be replaced when a queued listing
# rebuild lands, so the gate has to be "the row is code AND the app
# has stopped working". settle() is the same helper the app's own
# RPC layer uses, so tests and driver agree on what "done" means.
- await settle(app, lambda: (lambda h: h is not None and h.kind == "code")(
- head_at(lst, target)), timeout=30)
+ await settle(
+ app,
+ lambda: (lambda h: h is not None and h.kind == "code")(
+ head_at(lst, target)
+ ),
+ timeout=30,
+ )
# Re-read the model: defining an item rebuilds it, and holding the
# old object shows pre-edit rows -- which looks exactly like the
# edit silently failing.
m = lst.model
h = head_at(lst, target)
- check("`c` on a chosen byte carves an instruction there",
- h is not None and h.kind == "code",
- f"kind={h.kind if h else None} text={h.text if h else None!r}")
+ check(
+ "`c` on a chosen byte carves an instruction there",
+ h is not None and h.kind == "code",
+ f"kind={h.kind if h else None} text={h.text if h else None!r}",
+ )
if h is not None and h.kind == "code":
print(f" carved {target:#x}: {h.text}")
# `c` runs until something stops it, like IDA — one instruction
@@ -197,21 +238,32 @@ async def run() -> int:
# four and stop AT the ret, not run on into the random bytes
# after it.
run = [m.get(m.index_of_ea(target + k * 4)) for k in range(3)]
- check("`c` keeps going until control flow ends",
- all(x is not None and x.kind == "code" for x in run),
- f"{[(hex(x.ea), x.kind) for x in run if x]}")
- check("and stops at the ret instead of running into junk",
- m.get(m.index_of_ea(target + 12)).kind == "unknown",
- f"{m.get(m.index_of_ea(target + 12)).text!r}")
+ check(
+ "`c` keeps going until control flow ends",
+ all(x is not None and x.kind == "code" for x in run),
+ f"{[(hex(x.ea), x.kind) for x in run if x]}",
+ )
+ check(
+ "and stops at the ret instead of running into junk",
+ m.get(m.index_of_ea(target + 12)).kind == "unknown",
+ f"{m.get(m.index_of_ea(target + 12)).text!r}",
+ )
status = str(app.query_one("#status", Static).render())
- check("the status reports what the run did",
- "3 instructions" in status and "control flow" in status,
- status[:80])
- check("the carved row spans the instruction, not one byte",
- h.size == 4, f"size={h.size}")
- check("bytes before it stay individually addressable",
- m.get(m.index_of_ea(target - 1)).size == 1
- and m.get(m.index_of_ea(target - 1)).ea == target - 1)
+ check(
+ "the status reports what the run did",
+ "3 instructions" in status and "control flow" in status,
+ status[:80],
+ )
+ check(
+ "the carved row spans the instruction, not one byte",
+ h.size == 4,
+ f"size={h.size}",
+ )
+ check(
+ "bytes before it stay individually addressable",
+ m.get(m.index_of_ea(target - 1)).size == 1
+ and m.get(m.index_of_ea(target - 1)).ea == target - 1,
+ )
# -- an edit must not move the view -------------------------- #
# Every mutation rebuilds the model, and row indices don't survive
@@ -239,14 +291,17 @@ async def run() -> int:
# (The listing re-renders its text lazily, so the comment is not
# necessarily visible in model rows the moment the worker returns --
# which is why this waits for the app, not for the text.)
- await settle(app, lambda: not app.query_one("#comment", Input).display,
- timeout=30)
- check("commenting leaves the view where it was",
- lst.model.get(round(lst.scroll_offset.y)).ea == ctop
- and lst._cursor_ea() == ccur,
- f"top {ctop:#x} -> "
- f"{lst.model.get(round(lst.scroll_offset.y)).ea:#x}, "
- f"cursor {ccur:#x} -> {lst._cursor_ea():#x}")
+ await settle(
+ app, lambda: not app.query_one("#comment", Input).display, timeout=30
+ )
+ check(
+ "commenting leaves the view where it was",
+ lst.model.get(round(lst.scroll_offset.y)).ea == ctop
+ and lst._cursor_ea() == ccur,
+ f"top {ctop:#x} -> "
+ f"{lst.model.get(round(lst.scroll_offset.y)).ea:#x}, "
+ f"cursor {ccur:#x} -> {lst._cursor_ea():#x}",
+ )
# -- carving must not move the view -------------------------- #
# Defining code collapses rows (four byte rows become one
@@ -259,8 +314,11 @@ async def run() -> int:
await settle(app, lambda: lst._cursor_ea() == far)
top_before = lst.model.get(round(lst.scroll_offset.y)).ea
cur_before = lst._cursor_ea()
- check("scrolled somewhere with rows above us",
- round(lst.scroll_offset.y) > 0, f"top={lst.scroll_offset.y}")
+ check(
+ "scrolled somewhere with rows above us",
+ round(lst.scroll_offset.y) > 0,
+ f"top={lst.scroll_offset.y}",
+ )
await pilot.press("c")
# No predicate here on purpose: this spot is random data, so the
# carve may legitimately produce nothing and "the row became code"
@@ -270,54 +328,80 @@ async def run() -> int:
await settle(app, timeout=30)
m2 = lst.model
top_after = m2.get(round(lst.scroll_offset.y)).ea
- check("carving leaves the scroll position where it was",
- top_after == top_before,
- f"{top_before:#x} -> {top_after:#x}")
- check("and leaves the cursor on the same address",
- lst._cursor_ea() == cur_before,
- f"{cur_before:#x} -> {lst._cursor_ea():#x}")
+ check(
+ "carving leaves the scroll position where it was",
+ top_after == top_before,
+ f"{top_before:#x} -> {top_after:#x}",
+ )
+ check(
+ "and leaves the cursor on the same address",
+ lst._cursor_ea() == cur_before,
+ f"{cur_before:#x} -> {lst._cursor_ea():#x}",
+ )
# -- `p` after carving: the rest of the app must notice ------ #
# The "no functions" hint was latched at load and only cleared on a
# reload, so it kept telling you the processor/base were wrong long
# after you'd defined a function. The function index was never
# rebuilt either, which meant Ctrl+N couldn't find what `p` made.
- check("no functions yet, and the hint says so",
- len(app._func_index) == 0
- and "no functions" in str(app.query_one("#status", Static).render()),
- f"n={len(app._func_index)}")
+ check(
+ "no functions yet, and the hint says so",
+ len(app._func_index) == 0
+ and "no functions" in str(app.query_one("#status", Static).render()),
+ f"n={len(app._func_index)}",
+ )
lst.cursor = lst.model.index_of_ea(target)
lst._scroll_cursor_into_view()
await settle(app, lambda: lst._cursor_ea() == target)
mp = lst.model
await pilot.press("p")
- await wait(lambda: lst.model is not mp and lst.model is not None,
- pilot, 40)
- await wait(lambda: app._func_index is not None
- and len(app._func_index) > 0, pilot, 60)
- check("`p` creates a function the index can see",
- len(app._func_index) == 1, f"n={len(app._func_index)}")
+ await wait(lambda: lst.model is not mp and lst.model is not None, pilot, 40)
+ await wait(
+ lambda: app._func_index is not None and len(app._func_index) > 0,
+ pilot,
+ 60,
+ )
+ check(
+ "`p` creates a function the index can see",
+ len(app._func_index) == 1,
+ f"n={len(app._func_index)}",
+ )
status = str(app.query_one("#status", Static).render())
- check("and the stale 'no functions' hint is gone",
- "no functions" not in status, status[:90])
- check("the status names the function it made",
- "created function" in status, status[:90])
+ check(
+ "and the stale 'no functions' hint is gone",
+ "no functions" not in status,
+ status[:90],
+ )
+ check(
+ "the status names the function it made",
+ "created function" in status,
+ status[:90],
+ )
await pilot.press("ctrl+l")
- opened = await wait(lambda: isinstance(app.screen, ConfirmScreen), pilot, 20)
- check("Ctrl+L offers to reload with different options", opened,
- f"screen={type(app.screen).__name__}")
+ opened = await wait(
+ lambda: isinstance(app.screen, ConfirmScreen), pilot, 20
+ )
+ check(
+ "Ctrl+L offers to reload with different options",
+ opened,
+ f"screen={type(app.screen).__name__}",
+ )
if opened:
note = str(app.screen.query_one("#confirm-note", Static).render())
# We just made a function, so it must NOT claim nothing is lost —
# reloading throws the database away and that is now a real cost.
- check("the confirmation counts what would be lost",
- "1 function," in note and "nothing is lost" not in note,
- note[:80])
+ check(
+ "the confirmation counts what would be lost",
+ "1 function," in note and "nothing is lost" not in note,
+ note[:80],
+ )
await pilot.press("escape")
await settle(app, lambda: not isinstance(app.screen, ConfirmScreen))
- check("declining leaves the binary open",
- not isinstance(app.screen, ConfirmScreen) and app._cur is not None)
+ check(
+ "declining leaves the binary open",
+ not isinstance(app.screen, ConfirmScreen) and app._cur is not None,
+ )
print(f"\n{PASS} passed, {FAIL} failed")
return 1 if FAIL else 0
diff --git a/tests/test_codemode_client.py b/tests/test_codemode_client.py
deleted file mode 100644
index 2f70ba3..0000000
--- a/tests/test_codemode_client.py
+++ /dev/null
@@ -1,162 +0,0 @@
-"""IDA-free contract tests for the Code Mode client adapter."""
-from __future__ import annotations
-
-import os
-import sys
-import tempfile
-from dataclasses import dataclass
-
-sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
-
-import idatui.codemode_client as module # noqa: E402
-from idatui.codemode_client import CodeModeClient, _parse_load_args # noqa: E402
-from idatui.errors import IDAToolError # noqa: E402
-
-#: Pure: fakes the DatabaseHandle, never touches IDA or the Code Mode library.
-NEEDS_IDA = False
-
-PASS = FAIL = 0
-
-
-def check(name: str, condition: bool, detail="") -> None:
- global PASS, FAIL
- if condition:
- PASS += 1
- print(f" ok {name}")
- else:
- FAIL += 1
- print(f" FAIL {name} {detail}")
-
-
-@dataclass(frozen=True)
-class FakeEntry:
- pid: int = 123
- backend: str = "gui"
- record_id: str = "123-abcdef"
- exe_path: str = ""
- idb_path: str = ""
-
-
-class FakeHandle:
- def __init__(self, path: str) -> None:
- self.connected = True
- self.entry = FakeEntry(exe_path=path, idb_path=path + ".i64")
- self.waited = None
- self.saved = 0
- self.closed = False
- self.code = ""
- self.code_timeout = None
-
- def wait_autoanalysis(self, timeout=None):
- self.waited = timeout
- return {"complete": True, "status": "complete"}
-
- def execute_python(self, code, timeout=None):
- self.code = code
- self.code_timeout = timeout
- return {"result": {"sentinel": 7}, "stdout": "", "stderr": ""}
-
- def save_database(self):
- self.saved += 1
- return {"saved": True, "idb_path": self.entry.idb_path}
-
- def close(self):
- self.connected = False
- self.closed = True
-
-
-class FakeDatabaseHandle:
- opened = None
- kwargs = None
-
- @classmethod
- def open(cls, path, **kwargs):
- cls.opened = path
- cls.kwargs = kwargs
- return FakeHandle(path)
-
-
-def _open_kwargs_are_real(sent: dict):
- """(ok, detail) for the kwargs the adapter passes to DatabaseHandle.open.
-
- Skips (passes) when ida_codemode is not installed, so the file stays pure.
- """
- try:
- import inspect
- from ida_codemode.client import DatabaseHandle as Real
- except ImportError:
- return True, "ida_codemode not installed - signature not checked"
- accepted = set(inspect.signature(Real.open).parameters)
- unknown = sorted(set(sent) - accepted)
- return not unknown, f"open() rejects {unknown}"
-
-
-def main() -> int:
- proc, base, file_type = _parse_load_args("-parm:ARMv7-M -b800000 -TRaw")
- check("legacy switches map to typed Code Mode options",
- (proc, base, file_type) == ("arm:ARMv7-M", 0x8000000, "Raw"),
- (proc, base, file_type))
- try:
- _parse_load_args("-parm -zcustom")
- except ValueError as exc:
- check("arbitrary IDA switches fail loudly", "cannot represent" in str(exc), exc)
- else:
- check("arbitrary IDA switches fail loudly", False)
-
- original = module.DatabaseHandle
- module.DatabaseHandle = FakeDatabaseHandle
- try:
- with tempfile.TemporaryDirectory() as tmp:
- path = os.path.join(tmp, "sample.bin")
- with open(path, "wb") as file:
- file.write(b"sample")
- client = CodeModeClient(path, load_args="-parm:ARMv7-A -b100")
- notes = []
- client.connect(timeout=42, progress=notes.append)
- handle = client._handle
- check("connect delegates database discovery to DatabaseHandle.open",
- FakeDatabaseHandle.opened == path and handle is not None)
- check("typed loader options cross the dependency boundary",
- FakeDatabaseHandle.kwargs["processor"] == "arm:ARMv7-A"
- and FakeDatabaseHandle.kwargs["image_base"] == 0x1000,
- FakeDatabaseHandle.kwargs)
- # A fake that swallows **kwargs cannot catch a keyword the real
- # library does not have -- which is exactly how this port shipped
- # `loading_address` (the real name is `image_base`) and would have
- # raised TypeError on the very first connect. Check the names we
- # send against the real signature whenever it is importable.
- check("every open() keyword exists in the real library",
- *_open_kwargs_are_real(FakeDatabaseHandle.kwargs))
- check("connect waits for Code Mode autoanalysis",
- handle.waited == 42, getattr(handle, "waited", None))
- check("progress distinguishes discovery and backend attachment",
- len(notes) == 2 and "gui" in notes[-1], notes)
- result = client.invoke("list_funcs", queries=[{"offset": 0, "count": 2}])
- check("invoke returns execute_python's result", result == {"sentinel": 7}, result)
- check("operation scripts use the preloaded ida-domain database",
- "db.functions.get_all()" in handle.code, handle.code[:200])
- check("health exposes registry identity",
- client.health()["record_id"] == "123-abcdef")
- client.save_database()
- check("save uses the public Code Mode save route", handle.saved == 1)
- client.close()
- check("close releases only the handle lease", handle.closed)
- check("GUI lifetime is never claimed by the client",
- client.wait_released(0) is False)
- finally:
- module.DatabaseHandle = original
-
- client = CodeModeClient(__file__)
- try:
- client.invoke("not-an-operation")
- except IDAToolError as exc:
- check("unknown adapter operations are explicit", exc.tool == "not-an-operation")
- else:
- check("unknown adapter operations are explicit", False)
-
- print(f"\n{PASS} passed, {FAIL} failed")
- return 1 if FAIL else 0
-
-
-if __name__ == "__main__":
- raise SystemExit(main())
diff --git a/tests/test_diag.py b/tests/test_diag.py
index e3b966a..d1b480a 100644
--- a/tests/test_diag.py
+++ b/tests/test_diag.py
@@ -3,6 +3,7 @@
Pure: no IDA, no worker, no Textual.
"""
+
from __future__ import annotations
import os
@@ -41,10 +42,16 @@ def t_swallow_keeps_going():
r = diag.recent()
check("the error is recorded", len(r) == 1, str(r))
check("with what was being attempted", r[0]["what"] == "a thing", str(r[0]))
- check("and the exception type and message",
- r[0]["error"] == "ValueError: nope", r[0]["error"])
- check("and where it was actually raised",
- r[0]["where"].startswith("test_diag.py:"), r[0]["where"])
+ check(
+ "and the exception type and message",
+ r[0]["error"] == "ValueError: nope",
+ r[0]["error"],
+ )
+ check(
+ "and where it was actually raised",
+ r[0]["where"].startswith("test_diag.py:"),
+ r[0]["where"],
+ )
def t_reraise():
@@ -61,8 +68,11 @@ def t_reraise():
check("reraise lets the listed type through", False, "not raised")
except Wanted:
check("reraise lets the listed type through", True)
- check("and a reraised error is not recorded twice",
- diag.recent() == [], str(diag.recent()))
+ check(
+ "and a reraised error is not recorded twice",
+ diag.recent() == [],
+ str(diag.recent()),
+ )
with diag.swallow("still swallows others", reraise=(Wanted,)):
raise ValueError("other")
check("other types are still swallowed", len(diag.recent()) == 1)
@@ -74,12 +84,21 @@ def t_ring_is_bounded():
diag.note(f"item {i}", RuntimeError(str(i)))
r = diag.recent(1000)
check("the ring is bounded", len(r) == diag._MAX, f"{len(r)}")
- check("it keeps the NEWEST entries",
- r[-1]["what"] == f"item {diag._MAX + 24}", r[-1]["what"])
- check("recent(n) returns the last n, newest last",
- [e["what"] for e in diag.recent(3)]
- == [f"item {diag._MAX + 22}", f"item {diag._MAX + 23}",
- f"item {diag._MAX + 24}"], str(diag.recent(3)))
+ check(
+ "it keeps the NEWEST entries",
+ r[-1]["what"] == f"item {diag._MAX + 24}",
+ r[-1]["what"],
+ )
+ check(
+ "recent(n) returns the last n, newest last",
+ [e["what"] for e in diag.recent(3)]
+ == [
+ f"item {diag._MAX + 22}",
+ f"item {diag._MAX + 23}",
+ f"item {diag._MAX + 24}",
+ ],
+ str(diag.recent(3)),
+ )
def t_log_file():
@@ -95,8 +114,11 @@ def t_log_file():
body = open(path, encoding="utf-8").read()
check("the log records what was attempted", "logged thing" in body, body[:200])
check("and the error", "KeyError" in body, body[:200])
- check("and a traceback, which the ring doesn't carry",
- "Traceback" in body and "t_log_file" in body, body[:300])
+ check(
+ "and a traceback, which the ring doesn't carry",
+ "Traceback" in body and "t_log_file" in body,
+ body[:300],
+ )
def t_log_is_off_by_default():
@@ -104,8 +126,10 @@ def t_log_is_off_by_default():
os.environ.pop("IDATUI_LOG", None)
with diag.swallow("unlogged"):
raise ValueError("x")
- check("without $IDATUI_LOG nothing is written, but the ring still has it",
- len(diag.recent()) == 1)
+ check(
+ "without $IDATUI_LOG nothing is written, but the ring still has it",
+ len(diag.recent()) == 1,
+ )
def t_broken_log_path_is_harmless():
@@ -116,8 +140,7 @@ def t_broken_log_path_is_harmless():
with diag.swallow("still fine"):
raise ValueError("boom")
check("an unwritable log path doesn't raise", True)
- check("and the error is still recorded in the ring",
- len(diag.recent()) == 1)
+ check("and the error is still recorded in the ring", len(diag.recent()) == 1)
finally:
os.environ.pop("IDATUI_LOG", None)
@@ -128,41 +151,55 @@ def t_env_read_per_call():
diag.clear()
with tempfile.TemporaryDirectory() as d:
path = os.path.join(d, "late.log")
- os.environ["IDATUI_LOG"] = path # set AFTER import
+ os.environ["IDATUI_LOG"] = path # set AFTER import
try:
diag.log("hello")
finally:
os.environ.pop("IDATUI_LOG", None)
- check("a log path set after import is honoured",
- os.path.exists(path) and "hello" in open(path).read())
+ check(
+ "a log path set after import is honoured",
+ os.path.exists(path) and "hello" in open(path).read(),
+ )
def t_thread_safe():
diag.clear()
+
def go(n):
for i in range(40):
diag.note(f"t{n}-{i}", RuntimeError("x"))
+
ts = [threading.Thread(target=go, args=(n,)) for n in range(6)]
for t in ts:
t.start()
for t in ts:
t.join(10)
r = diag.recent(1000)
- check("concurrent notes don't corrupt the ring",
- len(r) == diag._MAX and all("what" in e for e in r), f"{len(r)}")
- check("the recording thread is captured",
- all(e["thread"] for e in r))
+ check(
+ "concurrent notes don't corrupt the ring",
+ len(r) == diag._MAX and all("what" in e for e in r),
+ f"{len(r)}",
+ )
+ check("the recording thread is captured", all(e["thread"] for e in r))
def main() -> int:
- for fn in (t_swallow_keeps_going, t_reraise, t_ring_is_bounded, t_log_file,
- t_log_is_off_by_default, t_broken_log_path_is_harmless,
- t_env_read_per_call, t_thread_safe):
+ for fn in (
+ t_swallow_keeps_going,
+ t_reraise,
+ t_ring_is_bounded,
+ t_log_file,
+ t_log_is_off_by_default,
+ t_broken_log_path_is_harmless,
+ t_env_read_per_call,
+ t_thread_safe,
+ ):
print(f"\n{fn.__name__}")
try:
fn()
except Exception as e: # noqa: BLE001
import traceback
+
check(f"{fn.__name__} did not crash", False, f"{type(e).__name__}: {e}")
traceback.print_exc()
diag.clear()
diff --git a/tests/test_findings.py b/tests/test_findings.py
index cf813c2..205b005 100644
--- a/tests/test_findings.py
+++ b/tests/test_findings.py
@@ -18,7 +18,12 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from idatui.domain import Comment, NamedItem, Struct # noqa: E402
from idatui.findings import ( # noqa: E402
- Findings, default_path, from_loader, gather, is_dummy, render,
+ Findings,
+ default_path,
+ from_loader,
+ gather,
+ is_dummy,
+ render,
)
PASS = FAIL = 0
@@ -36,34 +41,62 @@ def check(name, cond, detail=""):
def sample() -> Findings:
return Findings(
- binary="echo", path="/tmp/echo",
+ binary="echo",
+ path="/tmp/echo",
sections=[(0x1000, 0x2000, ".text"), (0x2000, 0x2100, ".data")],
n_functions=128,
comments=[
- Comment(addr=0x1100, text="length is attacker controlled",
- line="mov edi, [rbp+len]", func="parse", func_addr=0x1000),
- Comment(addr=0x1010, text="entry", line="push rbp",
- func="parse", func_addr=0x1000),
- Comment(addr=0x1000, text="parses the header", whole_func=True,
- func="parse", func_addr=0x1000),
+ Comment(
+ addr=0x1100,
+ text="length is attacker controlled",
+ line="mov edi, [rbp+len]",
+ func="parse",
+ func_addr=0x1000,
+ ),
+ Comment(
+ addr=0x1010,
+ text="entry",
+ line="push rbp",
+ func="parse",
+ func_addr=0x1000,
+ ),
+ Comment(
+ addr=0x1000,
+ text="parses the header",
+ whole_func=True,
+ func="parse",
+ func_addr=0x1000,
+ ),
Comment(addr=0x2004, text="magic", line="dd 0DEADBEEFh"),
# What the ELF loader writes into every database, through the very
# same set_cmt a person uses.
- Comment(addr=0x4, text="File class: 64-bit", line="db 2",
- seg="LOAD"),
+ Comment(addr=0x4, text="File class: 64-bit", line="db 2", seg="LOAD"),
],
names=[
- NamedItem(addr=0x1000, name="parse", is_func=True, size=0x120,
- proto="int __fastcall parse(char *)"),
+ NamedItem(
+ addr=0x1000,
+ name="parse",
+ is_func=True,
+ size=0x120,
+ proto="int __fastcall parse(char *)",
+ ),
NamedItem(addr=0x1200, name="sub_1200", is_func=True, size=0x30),
NamedItem(addr=0x2004, name="hdr_magic", seg=".data"),
NamedItem(addr=0x1400, name="memcpy", is_func=True, size=0x40),
NamedItem(addr=0x390, name="elf_gnu_hash_nbuckets", seg="LOAD"),
],
- types=[(Struct(name="hdr", size=0x10, is_union=False, members=3,
- ordinal=42), "struct hdr\n{\n int magic;\n};\n"),
- (Struct(name="Elf64_Dyn", size=0x10, is_union=False, members=2,
- ordinal=3), "struct Elf64_Dyn\n{\n int d_tag;\n};\n")],
+ types=[
+ (
+ Struct(name="hdr", size=0x10, is_union=False, members=3, ordinal=42),
+ "struct hdr\n{\n int magic;\n};\n",
+ ),
+ (
+ Struct(
+ name="Elf64_Dyn", size=0x10, is_union=False, members=2, ordinal=3
+ ),
+ "struct Elf64_Dyn\n{\n int d_tag;\n};\n",
+ ),
+ ],
linked={"memcpy"},
stripped=True,
)
@@ -74,9 +107,11 @@ def main() -> int:
check("the report names the binary", doc.startswith("# Findings — echo"), doc[:40])
# 1 function, not 3: sub_1200 is IDA's invention and memcpy is the linker's.
- check("the summary counts only what a person contributed",
- "1 named functions · 1 named data · 4 comments · 2 local types" in doc,
- doc.splitlines()[2] if len(doc.splitlines()) > 2 else "")
+ check(
+ "the summary counts only what a person contributed",
+ "1 named functions · 1 named data · 4 comments · 2 local types" in doc,
+ doc.splitlines()[2] if len(doc.splitlines()) > 2 else "",
+ )
# A name IDA invented is not a finding, and neither is one the linker gave.
check("dummy names are excluded", "sub_1200" not in doc)
@@ -86,75 +121,111 @@ def main() -> int:
# The loader annotates every database it makes; none of it is a finding.
check("the loader's own comments are left out", "File class" not in doc)
check("the loader's own names are left out", "elf_gnu_hash" not in doc)
- check("but the report says how many it dropped",
- "4 annotations left out as the loader's own" in doc, # 1 comment + 3 names
- [l for l in doc.splitlines() if "left out" in l])
- check("from_loader knows both shapes",
- from_loader("LOAD") and from_loader("", "elf_gnu_hash_x")
- and not from_loader(".text", "parse"))
- check("is_dummy knows the shapes IDA invents",
- all(is_dummy(n) for n in ("sub_1234", "loc_A0", "unk_4000", "j_free"))
- and not any(is_dummy(n) for n in ("parse", "sub_parse", "main", "")),
- "")
+ check(
+ "but the report says how many it dropped",
+ "4 annotations left out as the loader's own" in doc, # 1 comment + 3 names
+ [l for l in doc.splitlines() if "left out" in l],
+ )
+ check(
+ "from_loader knows both shapes",
+ from_loader("LOAD")
+ and from_loader("", "elf_gnu_hash_x")
+ and not from_loader(".text", "parse"),
+ )
+ check(
+ "is_dummy knows the shapes IDA invents",
+ all(is_dummy(n) for n in ("sub_1234", "loc_A0", "unk_4000", "j_free"))
+ and not any(is_dummy(n) for n in ("parse", "sub_parse", "main", "")),
+ "",
+ )
# Comments lead, grouped by function, address-ordered within a group.
- check("comments come before the name tables",
- doc.index("## Comments") < doc.index("## Named functions"))
- body = doc[doc.index("## Comments"):doc.index("## Named functions")]
+ check(
+ "comments come before the name tables",
+ doc.index("## Comments") < doc.index("## Named functions"),
+ )
+ body = doc[doc.index("## Comments") : doc.index("## Named functions")]
check("comments are grouped under their function", "### `parse`" in body)
- check("a commentless region is grouped separately",
- "### outside any function" in body)
- check("comments are ordered by address inside a group",
- body.index("0x1010") < body.index("0x1100"))
- check("a function comment says that is what it is",
- "*whole function*: parses the header" in body)
- check("an instruction comment carries the line it annotates",
- "`mov edi, [rbp+len]`" in body)
+ check(
+ "a commentless region is grouped separately", "### outside any function" in body
+ )
+ check(
+ "comments are ordered by address inside a group",
+ body.index("0x1010") < body.index("0x1100"),
+ )
+ check(
+ "a function comment says that is what it is",
+ "*whole function*: parses the header" in body,
+ )
+ check(
+ "an instruction comment carries the line it annotates",
+ "`mov edi, [rbp+len]`" in body,
+ )
# Types: newest ordinal first, because that is the one you just wrote.
- types = doc[doc.index("## Local types"):]
- check("your newest type is first",
- types.index("hdr") < types.index("Elf64_Dyn"))
+ types = doc[doc.index("## Local types") :]
+ check("your newest type is first", types.index("hdr") < types.index("Elf64_Dyn"))
check("type source is fenced as C", "```c\nstruct hdr" in types)
# Escaping.
- hostile = Findings(binary="x", comments=[
- Comment(addr=1, text="a | b", line="mov | rax"),
- ], names=[NamedItem(addr=2, name="a|b")])
+ hostile = Findings(
+ binary="x",
+ comments=[
+ Comment(addr=1, text="a | b", line="mov | rax"),
+ ],
+ names=[NamedItem(addr=2, name="a|b")],
+ )
hdoc = render(hostile)
check("a pipe cannot break a table row", "a\\|b" in hdoc, hdoc)
check("a pipe in a comment is escaped too", "a \\| b" in hdoc)
# The empty database must still produce a document that says something.
empty = render(Findings(binary="nothing"))
- check("an empty report is still a document",
- empty.startswith("# Findings — nothing") and "## Comments" in empty)
+ check(
+ "an empty report is still a document",
+ empty.startswith("# Findings — nothing") and "## Comments" in empty,
+ )
check("and it says why it is empty", "Comments are the part" in empty)
- check("an empty report has no dangling type section",
- "## Local types" not in empty)
+ check("an empty report has no dangling type section", "## Local types" not in empty)
# Provenance must be stated, not implied. Without a journal the report is a
# scan and says so; with one it is exactly what idatui recorded doing.
- scanned = render(Findings(binary="x", stripped=False,
- names=[NamedItem(addr=1, name="main", is_func=True)]))
- check("a scanned report admits it cannot know who wrote what",
- "**source**: a scan of the database" in scanned
- and "include its work as well as yours" in scanned)
- check("and warns when the binary brought its own symbols",
- "include ones it shipped with" in scanned)
+ scanned = render(
+ Findings(
+ binary="x",
+ stripped=False,
+ names=[NamedItem(addr=1, name="main", is_func=True)],
+ )
+ )
+ check(
+ "a scanned report admits it cannot know who wrote what",
+ "**source**: a scan of the database" in scanned
+ and "include its work as well as yours" in scanned,
+ )
+ check(
+ "and warns when the binary brought its own symbols",
+ "include ones it shipped with" in scanned,
+ )
j = sample()
j.recorded = {0x1100, 0x1000}
j.n_recorded = 7
jdoc = render(j)
- check("a journalled report says so", "idatui's edit journal" in jdoc
- and "7 recorded edits" in jdoc, "")
- jbody = jdoc[jdoc.index("## Comments"):jdoc.index("## Named functions")]
- check("and lists only the comments it recorded",
- "length is attacker controlled" in jbody and "0x2004" not in jbody,
- jbody)
- check("a journalled report drops names it did not record",
- "`parse`" in jdoc and "hdr_magic" not in jdoc)
+ check(
+ "a journalled report says so",
+ "idatui's edit journal" in jdoc and "7 recorded edits" in jdoc,
+ "",
+ )
+ jbody = jdoc[jdoc.index("## Comments") : jdoc.index("## Named functions")]
+ check(
+ "and lists only the comments it recorded",
+ "length is attacker controlled" in jbody and "0x2004" not in jbody,
+ jbody,
+ )
+ check(
+ "a journalled report drops names it did not record",
+ "`parse`" in jdoc and "hdr_magic" not in jdoc,
+ )
# -- gather ------------------------------------------------------------- #
class FakeProgram:
@@ -162,8 +233,10 @@ def main() -> int:
return [(0x1000, 0x2000, ".text")]
def annotations(self, limit=4000):
- return ([Comment(addr=1, text="hi")],
- [NamedItem(addr=1, name="parse", is_func=True)])
+ return (
+ [Comment(addr=1, text="hi")],
+ [NamedItem(addr=1, name="parse", is_func=True)],
+ )
def linkage(self):
return ([], [])
@@ -180,11 +253,15 @@ def main() -> int:
f = gather(FakeProgram(), "/tmp/echo")
check("gather reads the annotations", len(f.comments) == 1 and len(f.names) == 1)
check("gather takes the binary name from the path", f.binary == "echo", f.binary)
- check("a failing backend degrades the report instead of raising",
- f.n_functions == 0 and f.types == [] and "# Findings" in render(f))
+ check(
+ "a failing backend degrades the report instead of raising",
+ f.n_functions == 0 and f.types == [] and "# Findings" in render(f),
+ )
- check("the default path sits beside the binary",
- default_path("/tmp/echo") == "/tmp/echo.findings.md")
+ check(
+ "the default path sits beside the binary",
+ default_path("/tmp/echo") == "/tmp/echo.findings.md",
+ )
print(f"\n{PASS} passed, {FAIL} failed")
return 1 if FAIL else 0
diff --git a/tests/test_formats.py b/tests/test_formats.py
index c0ad542..05d98ba 100644
--- a/tests/test_formats.py
+++ b/tests/test_formats.py
@@ -10,8 +10,12 @@ import tempfile
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
-from idatui.formats import (PROCESSORS, load_args, # noqa: E402
- needs_load_options, sniff)
+from idatui.formats import ( # noqa: E402
+ PROCESSORS,
+ load_args,
+ needs_load_options,
+ sniff,
+)
PASS = FAIL = 0
@@ -28,6 +32,7 @@ def check(name, ok, detail=""):
def main() -> int:
with tempfile.TemporaryDirectory() as tmp:
+
def w(name, data):
p = os.path.join(tmp, name)
with open(p, "wb") as f:
@@ -35,67 +40,102 @@ def main() -> int:
return p
# -- formats IDA can load on its own: never interrupt the user -------- #
- for name, head in (("elf", b"\x7fELF\x02\x01\x01"), ("pe", b"MZ\x90\x00"),
- ("macho", b"\xcf\xfa\xed\xfe"), ("dex", b"dex\n035\x00"),
- ("wasm", b"\x00asm\x01\x00")):
+ for name, head in (
+ ("elf", b"\x7fELF\x02\x01\x01"),
+ ("pe", b"MZ\x90\x00"),
+ ("macho", b"\xcf\xfa\xed\xfe"),
+ ("dex", b"dex\n035\x00"),
+ ("wasm", b"\x00asm\x01\x00"),
+ ):
p = w(name, head + bytes(60))
- check(f"{name} is recognised (no dialog)",
- sniff(p) is not None and not needs_load_options(p), f"{sniff(p)}")
+ check(
+ f"{name} is recognised (no dialog)",
+ sniff(p) is not None and not needs_load_options(p),
+ f"{sniff(p)}",
+ )
# -- the case this exists for ----------------------------------------- #
blob = w("fw.bin", bytes(range(256)) * 4)
- check("a headerless blob is not recognised (ask)",
- sniff(blob) is None and needs_load_options(blob))
+ check(
+ "a headerless blob is not recognised (ask)",
+ sniff(blob) is None and needs_load_options(blob),
+ )
# Intel HEX / S-records are text containers IDA does load.
hexf = w("f.hex", b":10010000214601360121470136007EFE09D2190140\n")
check("Intel HEX is recognised", sniff(hexf) == "Intel HEX", f"{sniff(hexf)}")
- srec = w("f.s19", b"S00600004844521B\nS1130000285F245F2212226A000424290008237C\n")
- check("Motorola S-records are recognised",
- sniff(srec) == "Motorola S-record", f"{sniff(srec)}")
+ srec = w(
+ "f.s19", b"S00600004844521B\nS1130000285F245F2212226A000424290008237C\n"
+ )
+ check(
+ "Motorola S-records are recognised",
+ sniff(srec) == "Motorola S-record",
+ f"{sniff(srec)}",
+ )
# A binary that merely STARTS with ':' is not Intel HEX. Text formats are
# only accepted when the whole head is printable, or half the firmware in
# the world gets mis-detected on one byte.
colon = w("colon.bin", b":\x00\xff\xfe\x01\x02" + bytes(58))
- check("a blob starting with ':' is not mistaken for Intel HEX",
- sniff(colon) is None, f"{sniff(colon)}")
+ check(
+ "a blob starting with ':' is not mistaken for Intel HEX",
+ sniff(colon) is None,
+ f"{sniff(colon)}",
+ )
check("an empty file is not recognised", sniff(w("empty", b"")) is None)
- check("a missing file never asks (nothing to load)",
- not needs_load_options(os.path.join(tmp, "nope")))
+ check(
+ "a missing file never asks (nothing to load)",
+ not needs_load_options(os.path.join(tmp, "nope")),
+ )
check("a directory never asks", not needs_load_options(tmp))
# -- switch construction ------------------------------------------------- #
# -b is in PARAGRAPHS: 0x8000000 >> 4 == 0x800000. Getting this wrong loads
# the image 16x off and every address in the database is wrong.
- check("base is converted to paragraphs",
- load_args("arm", 0x8000000) == "-parm -b800000",
- load_args("arm", 0x8000000))
+ check(
+ "base is converted to paragraphs",
+ load_args("arm", 0x8000000) == "-parm -b800000",
+ load_args("arm", 0x8000000),
+ )
check("processor alone", load_args("mipsb") == "-pmipsb", load_args("mipsb"))
check("base alone", load_args("", 0x10000) == "-b1000", load_args("", 0x10000))
- check("base 0 emits no switch (it's the default)",
- load_args("arm", 0) == "-parm", load_args("arm", 0))
+ check(
+ "base 0 emits no switch (it's the default)",
+ load_args("arm", 0) == "-parm",
+ load_args("arm", 0),
+ )
check("nothing in, nothing out", load_args() == "")
- check("extra switches pass through",
- load_args("arm", 0, "-T binary") == "-parm -T binary")
+ check(
+ "extra switches pass through",
+ load_args("arm", 0, "-T binary") == "-parm -T binary",
+ )
- check("the processor list leads with the common targets",
- [n for n, _ in PROCESSORS[:2]] == ["arm", "arm:ARMv7-A"],
- f"{[n for n, _ in PROCESSORS[:3]]}")
+ check(
+ "the processor list leads with the common targets",
+ [n for n, _ in PROCESSORS[:2]] == ["arm", "arm:ARMv7-A"],
+ f"{[n for n, _ in PROCESSORS[:3]]}",
+ )
# 32-bit ARM has to be offered SEPARATELY from bare 'arm', which gives a
# 64-bit database. That isn't cosmetic: Hex-Rays refuses a 32-bit function
# in a 64-bit database, and Thumb doesn't exist in AArch64 at all, so a
# firmware image loaded as plain 'arm' can never be decompiled — and the
# database's bitness cannot be corrected after load.
- check("a 32-bit ARM variant is offered",
- any(n.startswith("arm:ARMv") for n, _ in PROCESSORS),
- f"{[n for n, _ in PROCESSORS if n.startswith('arm')]}")
- check("and the labels say which is 32- vs 64-bit",
- all(("32-bit" in d or "64-bit" in d)
- for n, d in PROCESSORS if n == "arm" or n.startswith("arm:")),
- f"{[(n, d) for n, d in PROCESSORS if n.startswith('arm')]}")
+ check(
+ "a 32-bit ARM variant is offered",
+ any(n.startswith("arm:ARMv") for n, _ in PROCESSORS),
+ f"{[n for n, _ in PROCESSORS if n.startswith('arm')]}",
+ )
+ check(
+ "and the labels say which is 32- vs 64-bit",
+ all(
+ ("32-bit" in d or "64-bit" in d)
+ for n, d in PROCESSORS
+ if n == "arm" or n.startswith("arm:")
+ ),
+ f"{[(n, d) for n, d in PROCESSORS if n.startswith('arm')]}",
+ )
# Every offered name must have been checked against a real IDA, because a
# wrong one is REJECTED (rc=4) with nothing useful said — handing the user a
@@ -103,16 +143,40 @@ def main() -> int:
# output of tools/verify_procs.py; adding a processor without re-running it
# fails here on purpose.
VERIFIED = {
- "arm", "armb", "metapc", "mipsl", "mipsb", "ppc", "ppcl", "sh4", "68k",
- "riscv", "tricore", "xtensa", "avr", "z80", "tms320c6", "m32r", "arc",
- "h8300", "sparcb", "sparcl", "s390",
+ "arm",
+ "armb",
+ "metapc",
+ "mipsl",
+ "mipsb",
+ "ppc",
+ "ppcl",
+ "sh4",
+ "68k",
+ "riscv",
+ "tricore",
+ "xtensa",
+ "avr",
+ "z80",
+ "tms320c6",
+ "m32r",
+ "arc",
+ "h8300",
+ "sparcb",
+ "sparcl",
+ "s390",
# variants: tools/verify_procs.py checks these report the base module
# AND change the database bitness, which is the reason they exist
- "arm:ARMv7-A", "arm:ARMv7-M", "arm:ARMv6-M", "arm:ARMv5TE",
+ "arm:ARMv7-A",
+ "arm:ARMv7-M",
+ "arm:ARMv6-M",
+ "arm:ARMv5TE",
}
offered = {n for n, _ in PROCESSORS}
- check("every offered processor name is IDA-verified",
- offered <= VERIFIED, f"unverified: {sorted(offered - VERIFIED)}")
+ check(
+ "every offered processor name is IDA-verified",
+ offered <= VERIFIED,
+ f"unverified: {sorted(offered - VERIFIED)}",
+ )
# These are module FILENAMES or common aliases, not -p names. IDA refuses
# them; they were in the list until a real run said otherwise.
@@ -123,16 +187,28 @@ def main() -> int:
def finds(q):
ql = q.lower()
return [n for n, d in PROCESSORS if ql in n.lower() or ql in d.lower()]
- check("typing 'arm64' still finds ARM", "arm" in finds("arm64"), f"{finds('arm64')}")
+
+ check(
+ "typing 'arm64' still finds ARM", "arm" in finds("arm64"), f"{finds('arm64')}"
+ )
check("typing 'aarch64' still finds ARM", "arm" in finds("aarch64"))
check("typing 'm68k' still finds 68k", "68k" in finds("m68k"), f"{finds('m68k')}")
- check("typing 'mips' finds both endiannesses",
- set(finds("mips")) == {"mipsl", "mipsb"}, f"{finds('mips')}")
- check("every processor entry has a human label",
- all(n and d for n, d in PROCESSORS))
- check("endianness is spelled out where it matters",
- all(any(w in d.lower() for w in ("endian",))
- for n, d in PROCESSORS if n in ("arm", "armb", "mipsb", "mipsl")))
+ check(
+ "typing 'mips' finds both endiannesses",
+ set(finds("mips")) == {"mipsl", "mipsb"},
+ f"{finds('mips')}",
+ )
+ check(
+ "every processor entry has a human label", all(n and d for n, d in PROCESSORS)
+ )
+ check(
+ "endianness is spelled out where it matters",
+ all(
+ any(w in d.lower() for w in ("endian",))
+ for n, d in PROCESSORS
+ if n in ("arm", "armb", "mipsb", "mipsl")
+ ),
+ )
print(f"\n{PASS} passed, {FAIL} failed")
return 1 if FAIL else 0
diff --git a/tests/test_graph.py b/tests/test_graph.py
index 146092d..e3b9143 100644
--- a/tests/test_graph.py
+++ b/tests/test_graph.py
@@ -9,6 +9,7 @@ suite runs anywhere, but when present it is the interesting half: real functions
are where the degenerate shapes (switch fan-out, irreducible loops, 400-block
monsters) actually live.
"""
+
from __future__ import annotations
#: the layout engine is pure: no IDA, no Textual.
@@ -41,21 +42,43 @@ def sizer(b: G.Block) -> tuple[int, int]:
return (len(f"loc_{b.start:X}") + 6, 4)
+#: Which layout engine the current pass is exercising. Every invariant here is
+#: a claim about the DRAWING, not about how it was arrived at, so the whole
+#: suite runs once per available engine (see main()).
+ENGINE = "native"
+
+
+def layout(blocks, sz=None, entry=None) -> G.Layout:
+ return G.layout(blocks, sz or sizer, entry=entry, engine=ENGINE)
+
+
def mk(edges: dict[int, list[tuple[int, str]]], n: int | None = None) -> list[G.Block]:
ids = set(edges) | {d for v in edges.values() for d, _ in v}
if n:
ids |= set(range(n))
- return [G.Block(id=i, start=0x1000 + i * 0x10, end=0x1000 + i * 0x10 + 8,
- succs=list(edges.get(i, []))) for i in sorted(ids)]
+ return [
+ G.Block(
+ id=i,
+ start=0x1000 + i * 0x10,
+ end=0x1000 + i * 0x10 + 8,
+ succs=list(edges.get(i, [])),
+ )
+ for i in sorted(ids)
+ ]
# ------------------------------------------------------------ invariants
+
def no_box_overlap(lay: G.Layout) -> bool:
for i, a in enumerate(lay.nodes):
- for b in lay.nodes[i + 1:]:
- if (a.x <= b.right and b.x <= a.right
- and a.y <= b.y + b.h - 1 and b.y <= a.y + a.h - 1):
+ for b in lay.nodes[i + 1 :]:
+ if (
+ a.x <= b.right
+ and b.x <= a.right
+ and a.y <= b.y + b.h - 1
+ and b.y <= a.y + a.h - 1
+ ):
return False
return True
@@ -87,15 +110,18 @@ def all_edges_drawn(lay: G.Layout) -> bool:
def invariants(lay: G.Layout, name: str) -> None:
check(no_box_overlap(lay), f"{name}: boxes must not overlap")
check(no_edge_through_box(lay) == 0, f"{name}: no edge may cross a box")
- check(all(n.x >= 0 and n.y >= 0 for n in lay.nodes),
- f"{name}: no negative coordinates")
+ check(
+ all(n.x >= 0 and n.y >= 0 for n in lay.nodes),
+ f"{name}: no negative coordinates",
+ )
check(lay.width > 0 and lay.height > 0, f"{name}: canvas has extent")
# ---------------------------------------------------------------- cases
+
def t_linear() -> None:
- lay = G.layout(mk({0: [(1, "uncond")], 1: [(2, "uncond")]}), sizer)
+ lay = layout(mk({0: [(1, "uncond")], 1: [(2, "uncond")]}), sizer)
invariants(lay, "linear")
ranks = [lay.by_id[i].rank for i in (0, 1, 2)]
check(ranks == [0, 1, 2], f"linear: ranks stack ({ranks})")
@@ -103,8 +129,10 @@ def t_linear() -> None:
def t_diamond() -> None:
- lay = G.layout(mk({0: [(1, "jump"), (2, "fall")],
- 1: [(3, "uncond")], 2: [(3, "uncond")]}), sizer)
+ lay = layout(
+ mk({0: [(1, "jump"), (2, "fall")], 1: [(3, "uncond")], 2: [(3, "uncond")]}),
+ sizer,
+ )
invariants(lay, "diamond")
check(lay.by_id[3].rank == 2, "diamond: join sits below both arms")
check(lay.by_id[1].rank == lay.by_id[2].rank, "diamond: arms share a rank")
@@ -115,8 +143,10 @@ def t_diamond() -> None:
def t_selfloop() -> None:
"""A self-loop must not stall the ranking — the bug that collapsed a whole
function into three layers and made the graph 280 columns wide."""
- lay = G.layout(mk({0: [(1, "uncond")], 1: [(1, "jump"), (2, "fall")],
- 2: [(3, "uncond")]}), sizer)
+ lay = layout(
+ mk({0: [(1, "uncond")], 1: [(1, "jump"), (2, "fall")], 2: [(3, "uncond")]}),
+ sizer,
+ )
invariants(lay, "selfloop")
ranks = [lay.by_id[i].rank for i in (0, 1, 2, 3)]
check(ranks == [0, 1, 2, 3], f"selfloop: ranking still stacks ({ranks})")
@@ -124,46 +154,104 @@ def t_selfloop() -> None:
def t_loop() -> None:
- lay = G.layout(mk({0: [(1, "uncond")], 1: [(2, "jump"), (3, "fall")],
- 2: [(1, "uncond")]}), sizer)
+ lay = layout(
+ mk({0: [(1, "uncond")], 1: [(2, "jump"), (3, "fall")], 2: [(1, "uncond")]}),
+ sizer,
+ )
invariants(lay, "loop")
check(any(e.back for e in lay.edges), "loop: a back edge is detected")
check(lay.by_id[1].rank < lay.by_id[2].rank, "loop: header above the body")
back = [e for e in lay.edges if e.back][0]
- check((2, G.E_BACK) in [(a, s) for a, s in lay.pred[1]]
- or (1, G.E_BACK) in [(a, s) for a, s in lay.succ[2]],
- "loop: the back edge reads 2 -> 1 despite being reversed for layout")
+ check(
+ (2, G.E_BACK) in [(a, s) for a, s in lay.pred[1]]
+ or (1, G.E_BACK) in [(a, s) for a, s in lay.succ[2]],
+ "loop: the back edge reads 2 -> 1 despite being reversed for layout",
+ )
def t_switch() -> None:
- lay = G.layout(mk({0: [(i, "switch") for i in range(1, 9)],
- **{i: [(9, "uncond")] for i in range(1, 9)}}), sizer)
+ lay = layout(
+ mk(
+ {
+ 0: [(i, "switch") for i in range(1, 9)],
+ **{i: [(9, "uncond")] for i in range(1, 9)},
+ }
+ ),
+ sizer,
+ )
invariants(lay, "switch")
- check(len({lay.by_id[i].rank for i in range(1, 9)}) == 1,
- "switch: all cases share a rank")
+ check(
+ len({lay.by_id[i].rank for i in range(1, 9)}) == 1,
+ "switch: all cases share a rank",
+ )
check(lay.by_id[9].rank == 2, "switch: the join is below the cases")
def t_unreachable() -> None:
"""A block reachable only through a reversed edge must still get a rank."""
- lay = G.layout(mk({0: [(1, "uncond")], 2: [(2, "jump")]}, n=3), sizer)
+ lay = layout(mk({0: [(1, "uncond")], 2: [(2, "jump")]}, n=3), sizer)
invariants(lay, "unreachable")
check(len(lay.nodes) == 3, "unreachable: every block is placed")
+def t_unreachable_entry() -> None:
+ """Blocks the entry cannot reach, including an entry with no successors.
+
+ IDA hands these out routinely -- dead code, an unresolved jump table -- and
+ triskel's root is whichever node was created first, with every analysis
+ walking out from there. Anything it cannot reach is undefined behaviour:
+ this exact 7-block shape SEGFAULTED the interpreter, and lesser versions
+ threw "EMPTY BL" from its SESE bracket lists. A crash cannot be fallen back
+ from, so the engine must never be handed one.
+ """
+ # entry 0 is a sink; 2 and 3 jump INTO it; 1 and 6 self-loop.
+ lay = layout(
+ mk(
+ {
+ 0: [],
+ 1: [(5, "switch"), (1, "fall"), (4, "switch")],
+ 2: [(5, "jump"), (0, "uncond")],
+ 3: [(0, "switch")],
+ 4: [],
+ 5: [(4, "jump")],
+ 6: [(2, "jump"), (6, "switch"), (3, "jump")],
+ }
+ ),
+ entry=0,
+ )
+ invariants(lay, "unreachable_entry")
+ check(len(lay.nodes) == 7, "unreachable_entry: every block is placed")
+ check(
+ lay.stats.get("engine_error") is None,
+ f"unreachable_entry: no fallback ({lay.stats.get('engine_error')})",
+ )
+
+ # An entry that reaches nothing at all, with everything hanging off nodes
+ # it cannot see, is the degenerate version of the same thing.
+ lay = layout(mk({0: [], 1: [(2, "jump")], 2: [(1, "jump")]}), entry=0)
+ invariants(lay, "orphan_pair")
+ check(len(lay.nodes) == 3, "orphan_pair: every block is placed")
+
+
def t_long_edge() -> None:
"""An edge spanning many layers gets dummies, so it reserves real space."""
chain = {i: [(i + 1, "uncond")] for i in range(6)}
chain[0] = [(1, "fall"), (6, "jump")]
- lay = G.layout(mk(chain), sizer)
+ lay = layout(mk(chain), sizer)
invariants(lay, "long_edge")
- check(lay.stats["dummies"] >= 4,
- f"long_edge: the skip edge is padded ({lay.stats['dummies']} dummies)")
+ # Dummy nodes are how the NATIVE engine reserves horizontal space for a
+ # long edge. Triskel reaches the same end -- an edge that crosses no box,
+ # checked by invariants() above -- without them, so this is engine-specific.
+ if ENGINE == "native":
+ check(
+ lay.stats["dummies"] >= 4,
+ f"long_edge: the skip edge is padded ({lay.stats['dummies']} dummies)",
+ )
check(all_edges_drawn(lay), "long_edge: the long edge is drawn")
def t_empty() -> None:
- lay = G.layout([], sizer)
+ lay = layout([], sizer)
check(lay.nodes == [], "empty: no nodes")
check(lay.width >= 1 and lay.height >= 1, "empty: canvas is still sane")
@@ -171,18 +259,22 @@ def t_empty() -> None:
def t_row_query() -> None:
"""cells_at_row must be windowed: asking for a slice returns only that
slice, which is what keeps a 13M-cell graph renderable."""
- lay = G.layout(mk({0: [(1, "jump"), (2, "fall")],
- 1: [(3, "uncond")], 2: [(3, "uncond")]}), sizer)
+ lay = layout(
+ mk({0: [(1, "jump"), (2, "fall")], 1: [(3, "uncond")], 2: [(3, "uncond")]}),
+ sizer,
+ )
for row in range(lay.height):
full = lay.painting.cells_at_row(row, 0, lay.width)
part = lay.painting.cells_at_row(row, 5, 12)
check(all(5 <= c < 12 for c in part), f"row {row}: window respected")
- check(all(full.get(c) == v for c, v in part.items()),
- f"row {row}: window agrees with the full row")
+ check(
+ all(full.get(c) == v for c, v in part.items()),
+ f"row {row}: window agrees with the full row",
+ )
def t_hit_test() -> None:
- lay = G.layout(mk({0: [(1, "jump"), (2, "fall")]}), sizer)
+ lay = layout(mk({0: [(1, "jump"), (2, "fall")]}), sizer)
n = lay.nodes[0]
check(lay.node_at(n.y, n.x) is n, "hit: top-left corner hits the node")
check(lay.node_at(n.y + 1, n.x + 1) is n, "hit: interior hits the node")
@@ -192,42 +284,119 @@ def t_hit_test() -> None:
# ---------------------------------------------------------------- corpus
+
def t_corpus(path: str) -> None:
recs = json.load(open(path))
print(f"\ncorpus: {len(recs)} functions from {path}")
worst_ms = 0.0
worst_name = ""
+ fellback: list[tuple[str, str | None]] = []
+ worst_any_ms = 0.0
+ worst_any_name = ""
t0 = time.perf_counter()
for rec in recs:
- blocks = [G.Block(id=b["id"], start=b["start"], end=b["end"],
- succs=[(d, k) for d, k in b["succs"]])
- for b in rec["blocks"]]
- lay = G.layout(blocks, sizer)
- if lay.stats["ms"] > worst_ms:
+ blocks = [
+ G.Block(
+ id=b["id"],
+ start=b["start"],
+ end=b["end"],
+ succs=[(d, k) for d, k in b["succs"]],
+ )
+ for b in rec["blocks"]
+ ]
+ lay = layout(blocks, sizer)
+ # A SILENT fallback is the failure mode that matters here: the engine
+ # under test quietly stops being the engine under test, and every
+ # invariant below then passes for the wrong reason. `ls` main (329
+ # blocks) used to fall back on all three zoom levels because a final
+ # approach was routed through the block above its target.
+ #
+ # Falling back is legitimate -- it is how an upstream layout defect is
+ # kept off the screen -- so this asserts it is rare and explained,
+ # not that it never happens.
+ if ENGINE != "auto" and lay.stats["engine"] != ENGINE:
+ fellback.append((rec["name"], lay.stats.get("engine_error")))
+ check(
+ bool(lay.stats.get("engine_error")),
+ f"corpus {rec['name']}: a fallback must record its reason",
+ )
+ # Time the engine only on the functions it would actually be ASKED for.
+ # `auto` hands anything over AUTO_TRISKEL_MAX_BLOCKS to native, and the
+ # view refuses to draw past 400 blocks at all, so a forced triskel run
+ # on a 495-block monster times a call the app cannot make.
+ reachable = ENGINE != "triskel" or len(blocks) <= G.AUTO_TRISKEL_MAX_BLOCKS
+ if reachable and lay.stats["ms"] > worst_ms:
worst_ms, worst_name = lay.stats["ms"], rec["name"]
+ if lay.stats["ms"] > worst_any_ms:
+ worst_any_ms, worst_any_name = lay.stats["ms"], rec["name"]
check(no_box_overlap(lay), f"corpus {rec['name']}: boxes must not overlap")
- check(len(lay.nodes) == len(blocks),
- f"corpus {rec['name']}: every block is placed")
+ check(
+ len(lay.nodes) == len(blocks),
+ f"corpus {rec['name']}: every block is placed",
+ )
# The full cell sweep is O(canvas); only affordable on the small ones,
# but that is where a routing bug would show up anyway.
if lay.width * lay.height < 400_000:
- check(no_edge_through_box(lay) == 0,
- f"corpus {rec['name']}: no edge may cross a box")
+ check(
+ no_edge_through_box(lay) == 0,
+ f"corpus {rec['name']}: no edge may cross a box",
+ )
total = (time.perf_counter() - t0) * 1000
- print(f" laid out {len(recs)} functions in {total:.0f} ms "
- f"(worst {worst_ms:.0f} ms: {worst_name})")
- check(worst_ms < 2000, f"corpus: worst layout under 2s ({worst_ms:.0f} ms)")
+ print(
+ f" laid out {len(recs)} functions in {total:.0f} ms "
+ f"(worst {worst_ms:.0f} ms: {worst_name})"
+ )
+ if fellback:
+ print(f" {len(fellback)} fell back to native:")
+ for name, why in fellback:
+ print(f" {name}: {why}")
+ check(
+ len(fellback) <= max(2, len(recs) // 20),
+ f"corpus: {ENGINE} fell back on {len(fellback)}/{len(recs)} functions",
+ )
+ check(
+ worst_ms < 2000,
+ f"corpus: worst REACHABLE layout under 2s ({worst_ms:.0f} ms: {worst_name})",
+ )
+ # Nothing may blow up quadratically even when forced past its own limits.
+ check(
+ worst_any_ms < 5000,
+ f"corpus: worst layout at any size under 5s "
+ f"({worst_any_ms:.0f} ms: {worst_any_name})",
+ )
def main() -> int:
+ global ENGINE
print("idatui.graph layout tests")
- for fn in (t_linear, t_diamond, t_selfloop, t_loop, t_switch,
- t_unreachable, t_long_edge, t_empty, t_row_query, t_hit_test):
- print(f" {fn.__name__}")
- fn()
- for path in sys.argv[1:]:
- if os.path.exists(path):
- t_corpus(path)
+ from idatui import graph_triskel
+
+ engines = ["native"]
+ if graph_triskel.available():
+ engines.append("triskel")
+ else:
+ print(" (pytriskel not importable: skipping the triskel engine)")
+ for engine in engines:
+ ENGINE = engine
+ print(f"\nengine: {engine}")
+ for fn in (
+ t_linear,
+ t_diamond,
+ t_selfloop,
+ t_loop,
+ t_switch,
+ t_unreachable,
+ t_unreachable_entry,
+ t_long_edge,
+ t_empty,
+ t_row_query,
+ t_hit_test,
+ ):
+ print(f" {fn.__name__}")
+ fn()
+ for path in sys.argv[1:]:
+ if os.path.exists(path):
+ t_corpus(path)
print(f"\n{CHECKS} checks, {len(FAILED)} failed")
for f in FAILED:
print(f" - {f}")
diff --git a/tests/test_index.py b/tests/test_index.py
index f6761b1..5850821 100644
--- a/tests/test_index.py
+++ b/tests/test_index.py
@@ -15,8 +15,13 @@ import tempfile
import time
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
-from idatui.index import (KIND_EXPORT, KIND_FUNC, KIND_IMPORT, # noqa: E402
- KIND_STRING, ProjectIndex)
+from idatui.index import ( # noqa: E402
+ KIND_EXPORT,
+ KIND_FUNC,
+ KIND_IMPORT,
+ KIND_STRING,
+ ProjectIndex,
+)
PASS = FAIL = 0
@@ -41,70 +46,106 @@ def main() -> int:
check("a fresh index is empty", idx.total() == 0 and idx.counts() == {})
check("an unindexed binary is stale", idx.is_stale("libfoo", src))
- n = idx.reindex("libfoo", [
- (KIND_FUNC, 0x1000, "SSL_CTX_new"),
- (KIND_FUNC, 0x1100, "SSL_read"),
- (KIND_FUNC, 0x1200, "sub_1200"),
- (KIND_STRING, 0x8000, "error opening socket"),
- (KIND_STRING, 0x8100, "/etc/ssl/certs"),
- ], source=src)
+ n = idx.reindex(
+ "libfoo",
+ [
+ (KIND_FUNC, 0x1000, "SSL_CTX_new"),
+ (KIND_FUNC, 0x1100, "SSL_read"),
+ (KIND_FUNC, 0x1200, "sub_1200"),
+ (KIND_STRING, 0x8000, "error opening socket"),
+ (KIND_STRING, 0x8100, "/etc/ssl/certs"),
+ ],
+ source=src,
+ )
check("reindex reports what it stored", n == 5, f"n={n}")
check("the entries are there", idx.total() == 5, f"{idx.total()}")
check("an indexed binary is fresh", not idx.is_stale("libfoo", src))
# -- substring search (the thing a prefix index can't do) ----------- #
hits = idx.search("SSL", kind=KIND_FUNC)
- check("finds symbols by substring", {h.text for h in hits} ==
- {"SSL_CTX_new", "SSL_read"}, f"{[h.text for h in hits]}")
- check("matching ignores case across kinds (SSL also hits /etc/ssl)",
- {h.text for h in idx.search("SSL")} ==
- {"SSL_CTX_new", "SSL_read", "/etc/ssl/certs"},
- f"{[h.text for h in idx.search('SSL')]}")
+ check(
+ "finds symbols by substring",
+ {h.text for h in hits} == {"SSL_CTX_new", "SSL_read"},
+ f"{[h.text for h in hits]}",
+ )
+ check(
+ "matching ignores case across kinds (SSL also hits /etc/ssl)",
+ {h.text for h in idx.search("SSL")}
+ == {"SSL_CTX_new", "SSL_read", "/etc/ssl/certs"},
+ f"{[h.text for h in idx.search('SSL')]}",
+ )
hits = idx.search("socket")
- check("finds strings by substring mid-text",
- len(hits) == 1 and hits[0].kind == KIND_STRING
- and hits[0].addr == 0x8000, f"{hits}")
- check("hits carry the owning binary",
- all(h.binary == "libfoo" for h in idx.search("SSL")))
- check("search is case-insensitive",
- {h.text for h in idx.search("ssl_read")} == {"SSL_read"},
- f"{[h.text for h in idx.search('ssl_read')]}")
- check("kind filter narrows to strings",
- [h.text for h in idx.search("ss", kind=KIND_STRING)] == ["/etc/ssl/certs"],
- f"{[h.text for h in idx.search('ss', kind=KIND_STRING)]}")
+ check(
+ "finds strings by substring mid-text",
+ len(hits) == 1 and hits[0].kind == KIND_STRING and hits[0].addr == 0x8000,
+ f"{hits}",
+ )
+ check(
+ "hits carry the owning binary",
+ all(h.binary == "libfoo" for h in idx.search("SSL")),
+ )
+ check(
+ "search is case-insensitive",
+ {h.text for h in idx.search("ssl_read")} == {"SSL_read"},
+ f"{[h.text for h in idx.search('ssl_read')]}",
+ )
+ check(
+ "kind filter narrows to strings",
+ [h.text for h in idx.search("ss", kind=KIND_STRING)] == ["/etc/ssl/certs"],
+ f"{[h.text for h in idx.search('ss', kind=KIND_STRING)]}",
+ )
# -- the <3 char fallback (trigram silently matches nothing) -------- #
- check("2-char query still works (LIKE fallback)",
- {h.text for h in idx.search("ss")} == {"SSL_CTX_new", "SSL_read",
- "/etc/ssl/certs"},
- f"{[h.text for h in idx.search('ss')]}")
- check("1-char query still works",
- len(idx.search("/")) == 1, f"{idx.search('/')}")
+ check(
+ "2-char query still works (LIKE fallback)",
+ {h.text for h in idx.search("ss")}
+ == {"SSL_CTX_new", "SSL_read", "/etc/ssl/certs"},
+ f"{[h.text for h in idx.search('ss')]}",
+ )
+ check(
+ "1-char query still works", len(idx.search("/")) == 1, f"{idx.search('/')}"
+ )
check("an empty query matches nothing", idx.search(" ") == [])
- check("a query with FTS operators is treated literally",
- idx.search('SSL OR "') == [] or True) # must not raise
+ check(
+ "a query with FTS operators is treated literally",
+ idx.search('SSL OR "') == [] or True,
+ ) # must not raise
# -- multi-binary: the whole point ---------------------------------- #
- idx.reindex("httpd", [
- (KIND_FUNC, 0x2000, "handle_ssl_request"),
- (KIND_STRING, 0x9000, "socket bind failed"),
- ])
+ idx.reindex(
+ "httpd",
+ [
+ (KIND_FUNC, 0x2000, "handle_ssl_request"),
+ (KIND_STRING, 0x9000, "socket bind failed"),
+ ],
+ )
hits = idx.search("ssl")
- check("search spans binaries",
- {h.binary for h in hits} == {"libfoo", "httpd"},
- f"{[(h.binary, h.text) for h in hits]}")
- check("counts are per binary",
- idx.counts() == {"libfoo": 5, "httpd": 2}, f"{idx.counts()}")
+ check(
+ "search spans binaries",
+ {h.binary for h in hits} == {"libfoo", "httpd"},
+ f"{[(h.binary, h.text) for h in hits]}",
+ )
+ check(
+ "counts are per binary",
+ idx.counts() == {"libfoo": 5, "httpd": 2},
+ f"{idx.counts()}",
+ )
# -- incremental: reindexing one binary leaves the others alone ----- #
idx.reindex("libfoo", [(KIND_FUNC, 0x1000, "SSL_CTX_new_v2")], source=src)
- check("reindex replaces only that binary's entries",
- idx.counts() == {"libfoo": 1, "httpd": 2}, f"{idx.counts()}")
- check("the stale entries are gone",
- [h.text for h in idx.search("SSL_read")] == [],
- f"{idx.search('SSL_read')}")
- check("the other binary survived untouched",
- len(idx.search("socket bind")) == 1)
+ check(
+ "reindex replaces only that binary's entries",
+ idx.counts() == {"libfoo": 1, "httpd": 2},
+ f"{idx.counts()}",
+ )
+ check(
+ "the stale entries are gone",
+ [h.text for h in idx.search("SSL_read")] == [],
+ f"{idx.search('SSL_read')}",
+ )
+ check(
+ "the other binary survived untouched", len(idx.search("socket bind")) == 1
+ )
# -- staleness follows the source ----------------------------------- #
time.sleep(0.01)
@@ -112,90 +153,128 @@ def main() -> int:
f.write(b"\x7fELF binary rebuilt, different size")
os.utime(src, (1, 1))
check("a changed source goes stale", idx.is_stale("libfoo", src))
- check("a missing source does NOT wipe the index",
- not idx.is_stale("libfoo", os.path.join(tmp, "gone")))
+ check(
+ "a missing source does NOT wipe the index",
+ not idx.is_stale("libfoo", os.path.join(tmp, "gone")),
+ )
# -- forget ----------------------------------------------------------- #
idx.forget("httpd")
- check("forget drops a binary entirely",
- idx.counts() == {"libfoo": 1} and idx.search("socket bind") == [],
- f"{idx.counts()}")
+ check(
+ "forget drops a binary entirely",
+ idx.counts() == {"libfoo": 1} and idx.search("socket bind") == [],
+ f"{idx.counts()}",
+ )
# -- cross-binary linkage join (phase 3) ------------------------------ #
- idx.reindex("app", [
- (KIND_FUNC, 0x1000, "main"),
- (KIND_IMPORT, 0x2000, "strcmp"),
- (KIND_IMPORT, 0x2008, "read"),
- (KIND_IMPORT, 0x2010, "SSL_new"),
- ])
- idx.reindex("libc", [
- (KIND_EXPORT, 0x8000, "strcmp"),
- (KIND_EXPORT, 0x8100, "read"),
- (KIND_EXPORT, 0x8200, "pread"),
- (KIND_EXPORT, 0x8300, "read_line"),
- (KIND_FUNC, 0x8000, "strcmp"),
- ])
+ idx.reindex(
+ "app",
+ [
+ (KIND_FUNC, 0x1000, "main"),
+ (KIND_IMPORT, 0x2000, "strcmp"),
+ (KIND_IMPORT, 0x2008, "read"),
+ (KIND_IMPORT, 0x2010, "SSL_new"),
+ ],
+ )
+ idx.reindex(
+ "libc",
+ [
+ (KIND_EXPORT, 0x8000, "strcmp"),
+ (KIND_EXPORT, 0x8100, "read"),
+ (KIND_EXPORT, 0x8200, "pread"),
+ (KIND_EXPORT, 0x8300, "read_line"),
+ (KIND_FUNC, 0x8000, "strcmp"),
+ ],
+ )
idx.reindex("libssl", [(KIND_EXPORT, 0x9000, "SSL_new")])
prov = idx.providers("strcmp", exclude="app")
- check("an import resolves to the binary that exports it",
- [(h.binary, h.addr) for h in prov] == [("libc", 0x8000)],
- f"{[(h.binary, hex(h.addr)) for h in prov]}")
+ check(
+ "an import resolves to the binary that exports it",
+ [(h.binary, h.addr) for h in prov] == [("libc", 0x8000)],
+ f"{[(h.binary, hex(h.addr)) for h in prov]}",
+ )
# The whole point of exact(): substring search would drag in pread,
# read_line and thread_start, and 'read' is also below the trigram floor
# for some engines — an import must bind to its exact name or nothing.
prov = idx.providers("read", exclude="app")
- check("the join is exact, not substring",
- [(h.binary, h.addr) for h in prov] == [("libc", 0x8100)],
- f"{[(h.binary, h.text) for h in prov]}")
+ check(
+ "the join is exact, not substring",
+ [(h.binary, h.addr) for h in prov] == [("libc", 0x8100)],
+ f"{[(h.binary, h.text) for h in prov]}",
+ )
- check("a short name still resolves (below the trigram floor)",
- [h.binary for h in idx.providers("SSL_new", exclude="app")] == ["libssl"],
- f"{idx.providers('SSL_new')}")
+ check(
+ "a short name still resolves (below the trigram floor)",
+ [h.binary for h in idx.providers("SSL_new", exclude="app")] == ["libssl"],
+ f"{idx.providers('SSL_new')}",
+ )
- check("an unprovided import resolves to nothing",
- idx.providers("dlopen", exclude="app") == [])
+ check(
+ "an unprovided import resolves to nothing",
+ idx.providers("dlopen", exclude="app") == [],
+ )
- check("exclude keeps a binary from resolving to itself",
- idx.providers("strcmp", exclude="libc") == [],
- f"{idx.providers('strcmp', exclude='libc')}")
+ check(
+ "exclude keeps a binary from resolving to itself",
+ idx.providers("strcmp", exclude="libc") == [],
+ f"{idx.providers('strcmp', exclude='libc')}",
+ )
imp = idx.importers("strcmp")
- check("the reverse join finds who imports an export",
- [(h.binary, h.addr) for h in imp] == [("app", 0x2000)],
- f"{[(h.binary, hex(h.addr)) for h in imp]}")
+ check(
+ "the reverse join finds who imports an export",
+ [(h.binary, h.addr) for h in imp] == [("app", 0x2000)],
+ f"{[(h.binary, hex(h.addr)) for h in imp]}",
+ )
- check("kind keeps functions out of the linkage join",
- [h.binary for h in idx.providers("strcmp")] == ["libc"],
- "a KIND_FUNC row named strcmp must not answer as an export")
+ check(
+ "kind keeps functions out of the linkage join",
+ [h.binary for h in idx.providers("strcmp")] == ["libc"],
+ "a KIND_FUNC row named strcmp must not answer as an export",
+ )
idx.forget("libc")
- check("forgetting a provider unresolves its imports",
- idx.providers("strcmp", exclude="app") == [])
+ check(
+ "forgetting a provider unresolves its imports",
+ idx.providers("strcmp", exclude="app") == [],
+ )
# -- ELF symbol versioning -------------------------------------------- #
# The importer sees strrchr@@GLIBC_2.2.5 while the provider may export a
# different spelling; raw names would resolve almost nothing. link_name
# cuts at the first '@' so both sides meet on the bare symbol.
from idatui.domain import link_name
- check("link_name strips an ELF version suffix",
- link_name("strrchr@@GLIBC_2.2.5") == "strrchr",
- link_name("strrchr@@GLIBC_2.2.5"))
- check("link_name leaves an unversioned name alone",
- link_name("strrchr") == "strrchr")
- check("link_name handles a single-@ version",
- link_name("SSL_new@OPENSSL_3.0.0") == "SSL_new")
- check("link_name doesn't eat a leading @",
- link_name("@weird") == "@weird", link_name("@weird"))
+
+ check(
+ "link_name strips an ELF version suffix",
+ link_name("strrchr@@GLIBC_2.2.5") == "strrchr",
+ link_name("strrchr@@GLIBC_2.2.5"),
+ )
+ check(
+ "link_name leaves an unversioned name alone",
+ link_name("strrchr") == "strrchr",
+ )
+ check(
+ "link_name handles a single-@ version",
+ link_name("SSL_new@OPENSSL_3.0.0") == "SSL_new",
+ )
+ check(
+ "link_name doesn't eat a leading @",
+ link_name("@weird") == "@weird",
+ link_name("@weird"),
+ )
# -- persistence ------------------------------------------------------ #
path = idx.path
idx.close()
idx2 = ProjectIndex(path)
- check("the index persists across sessions",
- [h.text for h in idx2.search("SSL_CTX")] == ["SSL_CTX_new_v2"],
- f"{idx2.search('SSL_CTX')}")
+ check(
+ "the index persists across sessions",
+ [h.text for h in idx2.search("SSL_CTX")] == ["SSL_CTX_new_v2"],
+ f"{idx2.search('SSL_CTX')}",
+ )
idx2.close()
print(f"\n{PASS} passed, {FAIL} failed")
diff --git a/tests/test_kittygfx.py b/tests/test_kittygfx.py
new file mode 100644
index 0000000..270b4e7
--- /dev/null
+++ b/tests/test_kittygfx.py
@@ -0,0 +1,258 @@
+#!/usr/bin/env python3
+"""Kitty graphics escapes: what we actually send to the terminal (no IDA).
+
+The splash re-anchors itself on every progress note, so the escape it sends has
+to be a REPLACEMENT, not another copy. That is one key (``p``) and it is
+invisible in every screenshot, which is exactly why it needs a test.
+
+Also the cross-platform contract: graphics are optional everywhere, so a
+missing ``termios`` (native Windows) or a failing terminal probe must disable
+the splash, never prevent TUI startup.
+"""
+
+#: pure stdlib escape-construction checks; no IDA, no Textual.
+#: Read by tests/run.py (--fast skips every NEEDS_IDA file).
+NEEDS_IDA = False
+import builtins
+import os
+import re
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from idatui import kittygfx # noqa: E402
+
+PASS = FAIL = 0
+
+
+def check(name, ok, detail=""):
+ global PASS, FAIL
+ if ok:
+ PASS += 1
+ print(f" ok {name}")
+ else:
+ FAIL += 1
+ print(f" FAIL {name} {detail}")
+
+
+class Tty:
+ """Capture what kittygfx writes, in place of the real stdout."""
+
+ def __init__(self):
+ self.sent = []
+ self._real = kittygfx._write
+
+ def __enter__(self):
+ kittygfx._write = lambda data: (self.sent.append(data), True)[1]
+ return self
+
+ def __exit__(self, *exc):
+ kittygfx._write = self._real
+
+ @property
+ def blob(self):
+ return "".join(self.sent)
+
+ def cmds(self, action):
+ """Every graphics command with the given ``a=`` action."""
+ return [
+ c
+ for c in re.findall(r"\x1b_G([^;\x1b]*)", self.blob)
+ if f"a={action}" in c.split(",")
+ ]
+
+
+def keys(cmd):
+ return dict(kv.split("=", 1) for kv in cmd.split(",") if "=" in kv)
+
+
+def t_no_termios_falls_back():
+ """Native Windows has no termios; the splash must simply use ANSI art."""
+ original_import = builtins.__import__
+
+ def without_termios(name, *args, **kwargs):
+ if name == "termios":
+ raise ModuleNotFoundError("No module named 'termios'")
+ return original_import(name, *args, **kwargs)
+
+ builtins.__import__ = without_termios
+ try:
+ check("missing termios disables graphics", kittygfx._query_tty(0) is False)
+ except Exception as exc: # the original Windows startup crash
+ check("missing termios does not escape", False, f"{type(exc).__name__}: {exc}")
+ finally:
+ builtins.__import__ = original_import
+
+
+def t_probe_failure_is_never_fatal():
+ """Even an unexpected platform/probe error cannot prevent TUI startup."""
+ original_query = kittygfx._query_tty
+ original_stdout = sys.__stdout__
+ original_supported = kittygfx._supported
+ old_env = os.environ.pop("IDATUI_KITTY", None)
+
+ class FakeTty:
+ def isatty(self):
+ return True
+
+ def broken_query():
+ raise RuntimeError("terminal API failed")
+
+ try:
+ sys.__stdout__ = FakeTty()
+ kittygfx._query_tty = broken_query
+ kittygfx._supported = None
+ check("probe exception disables graphics", kittygfx.supported() is False)
+ check("failed result is cached", kittygfx.supported() is False)
+ except Exception as exc:
+ check("probe exception does not escape", False, f"{type(exc).__name__}: {exc}")
+ finally:
+ kittygfx._query_tty = original_query
+ kittygfx._supported = original_supported
+ sys.__stdout__ = original_stdout
+ if old_env is not None:
+ os.environ["IDATUI_KITTY"] = old_env
+
+
+def main() -> int:
+ # -- graphics stay optional on every platform ---------------------------- #
+ t_no_termios_falls_back()
+ t_probe_failure_is_never_fatal()
+
+ kittygfx._uploaded[kittygfx.LOGO_ID] = (768, 801) # pretend it's uploaded
+
+ # -- the bug: anonymous placements STACK ------------------------------- #
+ # A placement is identified by (image id, placement id). With no p key
+ # every place() adds another copy at the same cell: a long load left the
+ # terminal compositing hundreds of copies of an RGBA image over itself.
+ with Tty() as tty:
+ for _ in range(50):
+ kittygfx.place(4, 10, 60, 26)
+ placements = tty.cmds("p")
+ check(
+ "place() emits one command per call",
+ len(placements) == 50,
+ f"{len(placements)}",
+ )
+ check(
+ "every placement carries a placement id (replaces, not stacks)",
+ all("p" in keys(c) for c in placements),
+ f"{placements[0] if placements else '(none)'}",
+ )
+ check(
+ "the placement id is the same every time (one image on screen)",
+ len({keys(c)["p"] for c in placements}) == 1,
+ f"{sorted({keys(c).get('p') for c in placements})}",
+ )
+ check(
+ "...and it is non-zero (p=0 means anonymous)",
+ keys(placements[0])["p"] not in ("0", ""),
+ f"{placements[0]}",
+ )
+
+ # -- the rest of the escape still says what it used to ------------------ #
+ with Tty() as tty:
+ ok = kittygfx.place(4, 10, 60, 26)
+ k = keys(tty.cmds("p")[0])
+ check("place() reports success", ok)
+ check(
+ "image id, source pixels and cell box are unchanged",
+ (k["i"], k["s"], k["v"], k["c"], k["r"])
+ == (str(kittygfx.LOGO_ID), "768", "801", "60", "26"),
+ f"{k}",
+ )
+ check(
+ "the terminal is told not to move the cursor (C=1)",
+ k.get("C") == "1",
+ f"{k}",
+ )
+ check(
+ "the cursor is saved and restored around the placement",
+ tty.blob.startswith("\x1b[s") and tty.blob.endswith("\x1b[u"),
+ repr(tty.blob[:8] + "..." + tty.blob[-8:]),
+ )
+ check(
+ "the placement is positioned 1-based (row 4 -> line 5)",
+ "\x1b[5;11H" in tty.blob,
+ repr(tty.blob[:24]),
+ )
+
+ # -- deleting still removes EVERY placement of the image ---------------- #
+ # d=i is by image id, so it takes the placement with us regardless of p.
+ with Tty() as tty:
+ kittygfx.clear()
+ k = keys(tty.cmds("d")[0])
+ check(
+ "clear() deletes by image id (d=i), keeping the upload",
+ k.get("d") == "i" and k.get("i") == str(kittygfx.LOGO_ID),
+ f"{k}",
+ )
+ check(
+ "clear() does not free the image data (lowercase d)",
+ kittygfx.is_uploaded(),
+ "upload was dropped",
+ )
+
+ with Tty() as tty:
+ kittygfx.delete()
+ k = keys(tty.cmds("d")[0])
+ check("delete() frees the image data too (d=I)", k.get("d") == "I", f"{k}")
+ check(
+ "...and forgets the upload, so the next place() refuses",
+ not kittygfx.is_uploaded() and kittygfx.place(0, 0, 10, 10) is False,
+ )
+
+ # -- refusals ----------------------------------------------------------- #
+ kittygfx._uploaded[kittygfx.LOGO_ID] = (768, 801)
+ with Tty() as tty:
+ check(
+ "a zero-sized box is refused, not sent",
+ kittygfx.place(0, 0, 0, 10) is False
+ and kittygfx.place(0, 0, 10, 0) is False
+ and not tty.sent,
+ f"{tty.sent}",
+ )
+ kittygfx._uploaded.pop(kittygfx.LOGO_ID, None)
+
+ # -- fit(): aspect ratio against non-square cells ----------------------- #
+ check(
+ "fit() keeps the aspect ratio for 9x22 cells",
+ kittygfx.fit((768, 801), 60, 99, cell=(9, 22)) == (60, 26),
+ f"{kittygfx.fit((768, 801), 60, 99, cell=(9, 22))}",
+ )
+ check(
+ "fit() shrinks to the row budget instead of overflowing",
+ kittygfx.fit((768, 801), 60, 10, cell=(9, 22))[1] == 10,
+ f"{kittygfx.fit((768, 801), 60, 10, cell=(9, 22))}",
+ )
+ check(
+ "fit() never returns a zero dimension",
+ all(v >= 1 for v in kittygfx.fit((768, 801), 1, 1, cell=(9, 22))),
+ )
+ check(
+ "fit() survives a degenerate image size",
+ kittygfx.fit((0, 0), 60, 26) == (60, 26),
+ )
+
+ # -- png_size() reads the header, not the pixels ------------------------ #
+ logo = os.path.join(
+ os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "logo.png"
+ )
+ if os.path.exists(logo):
+ check(
+ "png_size() reads logo.png's IHDR",
+ kittygfx.png_size(logo) == (768, 801),
+ f"{kittygfx.png_size(logo)}",
+ )
+ check("png_size() returns None for a non-PNG", kittygfx.png_size(__file__) is None)
+ check(
+ "png_size() returns None for a missing file",
+ kittygfx.png_size("/nonexistent/nope.png") is None,
+ )
+
+ print(f"\n{PASS} passed, {FAIL} failed")
+ return 1 if FAIL else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/test_launch.py b/tests/test_launch.py
index d57ee5f..5c05d08 100644
--- a/tests/test_launch.py
+++ b/tests/test_launch.py
@@ -3,13 +3,14 @@
The old `_sweep_locks` deleted `.id0/.id1/.id2/.nam/.til` next to the user's
binary when a database failed to open. That was only defensible while the TUI
-exclusively owned a private worker; under Code Mode a GUI or another client may
+exclusively owned a private worker; under IDA Nexus a GUI or another client may
own the database, so the sweep is gone. Its tests are replaced by one that keeps
it gone -- deleting a shared database's working files is unrecoverable, and this
is the cheapest guard against someone reintroducing the "helpful" cleanup.
-Pure: no IDA, no Code Mode library, no Textual.
+Pure: no IDA, no IDA Nexus library, no Textual.
"""
+
from __future__ import annotations
import os
@@ -47,15 +48,17 @@ def touch(*paths):
def t_no_lock_sweeping():
"""The launcher must not delete database working files any more.
- Code Mode's registry locks, health probes and IDA itself arbitrate database
+ IDA Nexus's registry locks, health probes and IDA itself arbitrate database
ownership now. A sweep here would delete files out from under a live GUI.
"""
check("_sweep_locks is gone", not hasattr(launch, "_sweep_locks"))
check("the scratch-suffix list is gone", not hasattr(launch, "_LOCK_SUFFIXES"))
src = open(launch.__file__, encoding="utf-8").read()
- check("the launcher does not remove files at all",
- "os.remove" not in src and "shutil.rmtree" not in src,
- "launch.py deletes something again")
+ check(
+ "the launcher does not remove files at all",
+ "os.remove" not in src and "shutil.rmtree" not in src,
+ "launch.py deletes something again",
+ )
def t_load_args():
@@ -66,10 +69,16 @@ def t_load_args():
# -b is in PARAGRAPHS, not bytes: 0x8000 >> 4 == 0x800.
check("a base is converted to paragraphs", "-b800" in a, a)
b = _load_args({"base": "0x1000"})
- check("a base given as a hex STRING is accepted (project files write those)",
- "-b100" in b, b)
- check("no base means no -b switch", "-b" not in _load_args({"processor": "arm"}),
- _load_args({"processor": "arm"}))
+ check(
+ "a base given as a hex STRING is accepted (project files write those)",
+ "-b100" in b,
+ b,
+ )
+ check(
+ "no base means no -b switch",
+ "-b" not in _load_args({"processor": "arm"}),
+ _load_args({"processor": "arm"}),
+ )
c = _load_args({"ida_args": "-p1"})
check("extra ida_args are passed through", "-p1" in c, c)
@@ -81,6 +90,7 @@ def main() -> int:
fn()
except Exception as e: # noqa: BLE001
import traceback
+
check(f"{fn.__name__} did not crash", False, f"{type(e).__name__}: {e}")
traceback.print_exc()
print(f"\n{PASS} passed, {FAIL} failed")
diff --git a/tests/test_nexus_client.py b/tests/test_nexus_client.py
new file mode 100644
index 0000000..834a8d6
--- /dev/null
+++ b/tests/test_nexus_client.py
@@ -0,0 +1,499 @@
+"""IDA-free contract tests for the IDA Nexus client adapter."""
+
+from __future__ import annotations
+
+import os
+import queue
+import sys
+import tempfile
+import threading
+import time
+from dataclasses import dataclass
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+import idatui.nexus_client as module # noqa: E402
+from idatui import remote_ops # noqa: E402
+from idatui.errors import IDAConnectionError, IDAToolError # noqa: E402
+from idatui.nexus_client import NexusClient, _parse_load_args # noqa: E402
+
+#: Pure: fakes the DatabaseHandle, never touches IDA or the IDA Nexus library.
+NEEDS_IDA = False
+
+PASS = FAIL = 0
+
+
+def check(name: str, condition: bool, detail="") -> None:
+ global PASS, FAIL
+ if condition:
+ PASS += 1
+ print(f" ok {name}")
+ else:
+ FAIL += 1
+ print(f" FAIL {name} {detail}")
+
+
+@dataclass(frozen=True)
+class FakeEntry:
+ pid: int = 123
+ backend: str = "gui"
+ record_id: str = "123-abcdef"
+ exe_path: str = ""
+ idb_path: str = ""
+ managed: bool = False
+
+
+_CLOSED = object()
+
+
+class FakeSubscription:
+ def __init__(self) -> None:
+ self._queue: queue.Queue = queue.Queue()
+ self.closed = False
+
+ def __iter__(self):
+ return self
+
+ def __next__(self):
+ item = self._queue.get()
+ if isinstance(item, BaseException):
+ raise item
+ if item is _CLOSED:
+ raise StopIteration
+ return item
+
+ def emit(self, event: dict) -> None:
+ self._queue.put(event)
+
+ def close(self) -> None:
+ if not self.closed:
+ self.closed = True
+ self._queue.put(_CLOSED)
+
+
+class FakeHandle:
+ def __init__(self, path: str) -> None:
+ self.connected = True
+ self.instance = FakeEntry(exe_path=path, idb_path=path + ".i64")
+ self.waited = None
+ self.saved = 0
+ self.closed = False
+ self.code = ""
+ self.codes = []
+ self.code_timeout = None
+ self.operation_label = None
+ self.event_origin_id = "fake-handle-origin"
+ self.owns_checks = 0
+ self.subscription = FakeSubscription()
+ self.shutdown_calls = []
+ self.shutdown_error = None
+
+ def wait_autoanalysis(self, timeout=None):
+ self.waited = timeout
+ return {"complete": True, "status": "complete"}
+
+ def execute_python(
+ self,
+ code,
+ timeout=None,
+ *,
+ operation_id=None,
+ operation_label=None,
+ persist_globals=False,
+ filename=None,
+ ):
+ self.code = code
+ self.codes.append(code)
+ self.code_timeout = timeout
+ self.operation_label = operation_label
+ result = (
+ {
+ "__remote_ida_status__": "ok",
+ "__remote_ida_value__": {"sentinel": 7},
+ }
+ if ".modules.get(" in code
+ else True
+ )
+ return {"result": result, "stdout": "", "stderr": ""}
+
+ def subscribe_idb_events(self):
+ return self.subscription
+
+ def owns_event(self, event):
+ self.owns_checks += 1
+ return event.get("origin_id") == self.event_origin_id
+
+ def save_database(self):
+ self.saved += 1
+ return {"saved": True, "idb_path": self.instance.idb_path}
+
+ def shutdown_database(self, *, save=True):
+ self.shutdown_calls.append(save)
+ if self.shutdown_error is not None:
+ raise module.RemoteError(self.shutdown_error, self.shutdown_error, 409)
+ return {"shutting_down": True, "save": save}
+
+ def close(self):
+ self.subscription.close()
+ self.connected = False
+ self.closed = True
+
+
+class FakeDatabaseHandle:
+ opened = None
+ opens = 0
+ kwargs = None
+
+ @classmethod
+ def open(cls, path, **kwargs):
+ cls.opens += 1
+ cls.opened = path
+ cls.kwargs = kwargs
+ return FakeHandle(path)
+
+
+@dataclass(frozen=True)
+class FakeOpenOptions:
+ """Stand-in for DatabaseOpenOptions when the library is not installed.
+
+ Deliberately STRICT (no **kwargs): an option the adapter invents would
+ raise here, and `_option_fields_are_real` checks the surviving names
+ against the real dataclass wherever it is importable.
+ """
+
+ spawn: bool = True
+ startup_timeout: float = 120.0
+ output_database: str | None = None
+ processor: str | None = None
+ image_base: int | None = None
+ file_type: str | None = None
+ new_database: bool = False
+
+
+class FakeBusy(Exception):
+ """Stand-in for DatabaseBusyError: `except None` is a TypeError."""
+
+
+class FakeDisconnected(Exception):
+ """Stand-in for DatabaseDisconnectedError in stdlib-only runs."""
+
+
+class FakeRemoteError(Exception):
+ """Stand-in for RemoteError: (code, message, status, details)."""
+
+ def __init__(self, code, message, status=500, details=None):
+ super().__init__(message)
+ self.code = code
+ self.status = status
+ self.details = details or {}
+
+
+class FakeRemoteModule:
+ """Stand-in for ida_nexus.RemoteModule in stdlib-only runs.
+
+ Speaks the same two-step wire contract FakeHandle.execute_python answers:
+ install the module's real source once, then send each call as a snippet
+ that looks the function up in the installed module registry (the
+ ``.modules.get(`` marker the fake keys on), carrying the operation label.
+ """
+
+ def __init__(self, path, *, operation_label=None, codec="json"):
+ with open(path) as file:
+ self._source = file.read()
+ self._label = operation_label
+ self._installed = False
+
+ def function(self, declaration, timeout=None):
+ name = getattr(declaration, "__name__", str(declaration))
+
+ def remote(handle, **args):
+ label = self._label() if callable(self._label) else self._label
+ if not self._installed:
+ handle.execute_python(self._source, operation_label=label)
+ self._installed = True
+ response = handle.execute_python(
+ f"__mod = __registry.modules.get(...) # call {name}",
+ operation_label=label,
+ )
+ result = response["result"]
+ if isinstance(result, dict) and result.get("__remote_ida_status__") == "ok":
+ return result.get("__remote_ida_value__")
+ raise module.RemoteError(name, f"remote call failed: {result!r}", 500)
+
+ return remote
+
+
+def _open_kwargs_are_real(sent: dict):
+ """(ok, detail) for the kwargs the adapter passes to DatabaseHandle.open.
+
+ Skips (passes) when ida_nexus is not installed, so the file stays pure.
+ """
+ try:
+ import inspect
+
+ from ida_nexus import DatabaseHandle as Real
+ except ImportError:
+ return True, "ida_nexus not installed - signature not checked"
+ accepted = set(inspect.signature(Real.open).parameters)
+ unknown = sorted(set(sent) - accepted)
+ return not unknown, f"open() rejects {unknown}"
+
+
+def _option_fields_are_real(options):
+ """(ok, detail) for the option names the adapter fills in.
+
+ The open() signature no longer names the loader options -- they moved
+ inside DatabaseOpenOptions -- so the `loading_address` class of bug now
+ hides there instead. Check it in the same way.
+ """
+ try:
+ import dataclasses
+
+ from ida_nexus import DatabaseOpenOptions as Real
+ except ImportError:
+ return True, "ida_nexus not installed - fields not checked"
+ accepted = {field.name for field in dataclasses.fields(Real)}
+ unknown = sorted({f.name for f in dataclasses.fields(options)} - accepted)
+ return not unknown, f"DatabaseOpenOptions rejects {unknown}"
+
+
+def main() -> int:
+ proc, base, file_type = _parse_load_args("-parm:ARMv7-M -b800000 -TRaw")
+ check(
+ "legacy switches map to typed IDA Nexus options",
+ (proc, base, file_type) == ("arm:ARMv7-M", 0x8000000, "Raw"),
+ (proc, base, file_type),
+ )
+ try:
+ _parse_load_args("-parm -zcustom")
+ except ValueError as exc:
+ check("arbitrary IDA switches fail loudly", "cannot represent" in str(exc), exc)
+ else:
+ check("arbitrary IDA switches fail loudly", False)
+
+ original = module.DatabaseHandle
+ module.DatabaseHandle = FakeDatabaseHandle
+ # The library's own names when it is installed; strict fakes when it is not
+ # (this file must keep running under a stdlib-only python3).
+ original_options = module.DatabaseOpenOptions
+ original_busy = module.DatabaseBusyError
+ original_disconnected = module.DatabaseDisconnectedError
+ original_remote_error = module.RemoteError
+ module.DatabaseOpenOptions = original_options or FakeOpenOptions
+ module.DatabaseBusyError = original_busy or FakeBusy
+ module.DatabaseDisconnectedError = original_disconnected or FakeDisconnected
+ module.RemoteError = original_remote_error or FakeRemoteError
+ # The binding seam in remote_ops, same rule: the real RemoteModule when the
+ # library is installed, this file's fake otherwise -- and the lazy binding
+ # cache reset around it so this run binds through whichever is active.
+ original_remote_module = remote_ops.RemoteModule
+ remote_ops.RemoteModule = original_remote_module or FakeRemoteModule
+ remote_ops._BOUND = None
+ try:
+ with tempfile.TemporaryDirectory() as tmp:
+ path = os.path.join(tmp, "sample.bin")
+ with open(path, "wb") as file:
+ file.write(b"sample")
+ client = NexusClient(path, load_args="-parm:ARMv7-A -b100")
+ notes = []
+ client.connect(timeout=42, progress=notes.append)
+ handle = client._handle
+ check(
+ "connect delegates database discovery to DatabaseHandle.open",
+ FakeDatabaseHandle.opened == path and handle is not None,
+ )
+ options = FakeDatabaseHandle.kwargs["options"]
+ check(
+ "typed loader options cross the dependency boundary",
+ options.processor == "arm:ARMv7-A" and options.image_base == 0x1000,
+ options,
+ )
+ check(
+ "every open option exists in the real library",
+ *_option_fields_are_real(options),
+ )
+ # A fake that swallows **kwargs cannot catch a keyword the real
+ # library does not have -- which is exactly how this port shipped
+ # `loading_address` (the real name is `image_base`) and would have
+ # raised TypeError on the very first connect. Check the names we
+ # send against the real signature whenever it is importable.
+ check(
+ "every open() keyword exists in the real library",
+ *_open_kwargs_are_real(FakeDatabaseHandle.kwargs),
+ )
+ check(
+ "connect waits for IDA Nexus autoanalysis",
+ handle.waited == 42,
+ getattr(handle, "waited", None),
+ )
+ check(
+ "progress distinguishes discovery and backend attachment",
+ len(notes) == 2 and "gui" in notes[-1],
+ notes,
+ )
+ result = client.call(
+ remote_ops.list_funcs, queries=[{"offset": 0, "count": 2}]
+ )
+ check(
+ "remote operation returns its JSON result",
+ result == {"sentinel": 7},
+ result,
+ )
+ check(
+ "operation source is real Python installed through ida-domain",
+ any("db.functions.get_all()" in code for code in handle.codes),
+ handle.codes[0][:200],
+ )
+ check(
+ "remote operations attribute IDB events to IDA TUI",
+ handle.operation_label == "IDA TUI",
+ handle.operation_label,
+ )
+ batches = []
+ delivered = threading.Event()
+
+ def changed(batch):
+ batches.append(batch)
+ delivered.set()
+
+ watcher = client.watch_idb_events(changed, debounce=0.05)
+ handle.subscription.emit({"event_name": "renamed", "origin_id": "peer-1"})
+ handle.subscription.emit(
+ {"event_name": "cmt_changed", "origin_id": "peer-2"}
+ )
+ check(
+ "event bursts produce one debounced refresh",
+ delivered.wait(1) and len(batches) == 1 and len(batches[0]) == 2,
+ batches,
+ )
+ delivered.clear()
+ handle.subscription.emit(
+ {"event_name": "renamed", "origin_id": handle.event_origin_id}
+ )
+ time.sleep(0.1)
+ check(
+ "the listener uses handle ownership to ignore its own events",
+ not delivered.is_set()
+ and len(batches) == 1
+ and handle.owns_checks >= 3,
+ (batches, handle.owns_checks),
+ )
+ handle.subscription.emit(
+ {"event_name": "byte_patched", "origin_id": "peer-3"}
+ )
+ watcher.close()
+ time.sleep(0.1)
+ check(
+ "closing drops a pending debounced refresh",
+ not delivered.is_set() and len(batches) == 1,
+ batches,
+ )
+ check(
+ "health exposes registry identity",
+ client.health()["record_id"] == "123-abcdef",
+ )
+ client.save_database()
+ check("save uses the public IDA Nexus save route", handle.saved == 1)
+ check(
+ "GUI leases transfer rather than claiming discard",
+ client.discard_database() is False and handle.shutdown_calls == [],
+ handle.shutdown_calls,
+ )
+ handle.instance = FakeEntry(
+ backend="idalib", managed=True, exe_path=path, idb_path=path + ".i64"
+ )
+ check(
+ "a final managed lease discards without saving",
+ client.discard_database() is True and handle.shutdown_calls == [False],
+ handle.shutdown_calls,
+ )
+ handle.shutdown_error = "instance_shared"
+ check(
+ "a shared managed lease transfers finalization",
+ client.discard_database() is False,
+ handle.shutdown_calls,
+ )
+ handle.shutdown_error = "instance_busy"
+ try:
+ client.discard_database(timeout=0)
+ except IDAToolError as exc:
+ check(
+ "a busy final lease never silently saves",
+ exc.tool == "shutdown_database",
+ exc,
+ )
+ else:
+ check("a busy final lease never silently saves", False)
+ handle.shutdown_error = None
+ handle.instance = FakeEntry(exe_path=path, idb_path=path + ".i64")
+ opens = FakeDatabaseHandle.opens
+ handle.connected = False
+ try:
+ client.health()
+ except IDAConnectionError as exc:
+ check(
+ "a disconnected handle requires explicit rediscovery",
+ "explicit rediscovery" in str(exc)
+ and FakeDatabaseHandle.opens == opens,
+ (exc, FakeDatabaseHandle.opens, opens),
+ )
+ else:
+ check("a disconnected handle requires explicit rediscovery", False)
+ client.close()
+ check("close releases only the handle lease", handle.closed)
+ check(
+ "GUI lifetime is never claimed by the client",
+ client.wait_released(0) is False,
+ )
+ disconnected = NexusClient(path).connect()
+ stream_errors = []
+ stream_failed = threading.Event()
+
+ def failed(error):
+ stream_errors.append(error)
+ stream_failed.set()
+
+ stream_watch = disconnected.watch_idb_events(
+ lambda _batch: None, on_error=failed, debounce=0
+ )
+ disconnected._handle.subscription.emit(
+ module.DatabaseDisconnectedError("GUI database closed")
+ )
+ check(
+ "stream disconnects become application connection errors",
+ stream_failed.wait(1)
+ and isinstance(stream_errors[0], IDAConnectionError),
+ stream_errors,
+ )
+ stream_watch.close()
+ disconnected.close()
+ finally:
+ module.DatabaseHandle = original
+ module.DatabaseOpenOptions = original_options
+ module.DatabaseBusyError = original_busy
+ module.DatabaseDisconnectedError = original_disconnected
+ module.RemoteError = original_remote_error
+
+ client = NexusClient(__file__)
+
+ def unknown_operation():
+ pass
+
+ try:
+ client.call(unknown_operation)
+ except IDAToolError as exc:
+ check(
+ "unknown adapter operations are explicit",
+ exc.tool == "unknown_operation",
+ )
+ else:
+ check("unknown adapter operations are explicit", False)
+ finally:
+ remote_ops.RemoteModule = original_remote_module
+ remote_ops._BOUND = None
+
+ print(f"\n{PASS} passed, {FAIL} failed")
+ return 1 if FAIL else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/test_pool.py b/tests/test_pool.py
index 9fc1446..203ce95 100644
--- a/tests/test_pool.py
+++ b/tests/test_pool.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-"""Unit tests for idatui.pool (Code Mode lease residency and LRU budget).
+"""Unit tests for idatui.pool (IDA Nexus lease residency and LRU budget).
A fake client keeps the policy testable without IDA or Textual.
@@ -31,13 +31,15 @@ def check(name, cond, detail=""):
class FakeClient:
- """Stands in for a CodeModeClient lease and records saves/closes."""
+ """Stands in for a NexusClient lease and records saves/closes."""
- def __init__(self, ref, mem=100, backend="idalib"):
+ def __init__(self, ref, mem=100, backend="idalib", discardable=True):
self.ref = ref
self.mem = mem
self.backend = backend
self.saved = 0
+ self.discarded = 0
+ self.discardable = discardable
self.closed = False
self.connected = False
@@ -49,6 +51,10 @@ class FakeClient:
self.saved += 1
return {"saved": True}
+ def discard_database(self):
+ self.discarded += 1
+ return self.discardable
+
def close(self, grace=None):
self.closed = True
@@ -75,14 +81,18 @@ def main() -> int:
made[ref.label] = c
return c
- pool = DatabasePool(proj, budget_mb=350, spawn=spawn,
- mem_fn=lambda c: c.mem)
+ pool = DatabasePool(proj, budget_mb=350, spawn=spawn, mem_fn=lambda c: c.mem)
# -- lazy spawn + reuse -------------------------------------------- #
a = pool.get("bin0")
- check("get() spawns a database lease on first use", a is made["bin0"] and a.connected)
- check("get() stages the binary first",
- os.path.isfile(proj.by_label("bin0").staged))
+ check(
+ "get() spawns a database lease on first use",
+ a is made["bin0"] and a.connected,
+ )
+ check(
+ "get() stages the binary first",
+ os.path.isfile(proj.by_label("bin0").staged),
+ )
check("get() reuses the resident lease", pool.get("bin0") is a)
check("resident() reports it", pool.resident() == ["bin0"], pool.resident())
@@ -90,28 +100,40 @@ def main() -> int:
pool.get("bin1")
pool.get("bin2")
pool.get("bin0") # touch: bin0 becomes most-recent
- check("LRU order tracks use", pool.resident() == ["bin1", "bin2", "bin0"],
- pool.resident())
+ check(
+ "LRU order tracks use",
+ pool.resident() == ["bin1", "bin2", "bin0"],
+ pool.resident(),
+ )
# -- budget eviction -------------------------------------------------- #
check("pool reports its memory", pool.memory_mb() == 300, pool.memory_mb())
pool.get("bin3") # 400MB > 350MB budget -> evict LRU (bin1)
- check("exceeding the budget evicts the least-recently-used",
- pool.evicted == ["bin1"] and not pool.is_resident("bin1"),
- f"evicted={pool.evicted} resident={pool.resident()}")
+ check(
+ "exceeding the budget evicts the least-recently-used",
+ pool.evicted == ["bin1"] and not pool.is_resident("bin1"),
+ f"evicted={pool.evicted} resident={pool.resident()}",
+ )
check("the just-attached lease is never the victim", pool.is_resident("bin3"))
check("eviction saves the database first", made["bin1"].saved == 1)
check("eviction closes the lease", made["bin1"].closed)
- check("pool is back within budget", pool.memory_mb() <= pool.budget_mb,
- f"{pool.memory_mb()}/{pool.budget_mb}")
+ check(
+ "pool is back within budget",
+ pool.memory_mb() <= pool.budget_mb,
+ f"{pool.memory_mb()}/{pool.budget_mb}",
+ )
# -- the active binary is never evicted ------------------------------- #
pool.set_active("bin2")
- check("set_active touches the LRU", pool.resident()[-1] == "bin2",
- pool.resident())
+ check(
+ "set_active touches the LRU", pool.resident()[-1] == "bin2", pool.resident()
+ )
pool.get("bin1") # over budget again -> must evict, but not bin2
- check("the active binary survives eviction", pool.is_resident("bin2"),
- f"resident={pool.resident()}")
+ check(
+ "the active binary survives eviction",
+ pool.is_resident("bin2"),
+ f"resident={pool.resident()}",
+ )
# -- pinning ---------------------------------------------------------- #
pool.close_all()
@@ -120,8 +142,11 @@ def main() -> int:
pool2.pin("bin0")
pool2.get("bin1")
pool2.get("bin2") # 300 > 250 -> evict, but bin0 is pinned
- check("pinned binaries are never evicted", pool2.is_resident("bin0"),
- f"resident={pool2.resident()} evicted={pool2.evicted}")
+ check(
+ "pinned binaries are never evicted",
+ pool2.is_resident("bin0"),
+ f"resident={pool2.resident()} evicted={pool2.evicted}",
+ )
check("an unpinned one went instead", "bin1" in pool2.evicted, pool2.evicted)
# -- everything pinned/active: stop evicting rather than thrash -------- #
@@ -129,21 +154,30 @@ def main() -> int:
pool2.set_active("bin2")
n_before = len(pool2.evicted)
pool2._enforce_budget()
- check("nothing evictable -> gives up instead of thrashing",
- len(pool2.evicted) == n_before, pool2.evicted)
+ check(
+ "nothing evictable -> gives up instead of thrashing",
+ len(pool2.evicted) == n_before,
+ pool2.evicted,
+ )
# -- status for the switcher UI ---------------------------------------- #
st = {s["label"]: s for s in pool2.status()}
check("status() covers every project binary", len(st) == 4, list(st))
- check("status() marks resident/pinned/active",
- st["bin0"]["resident"] and st["bin0"]["pinned"]
- and st["bin2"]["active"] and not st["bin3"]["resident"],
- f"{st}")
+ check(
+ "status() marks resident/pinned/active",
+ st["bin0"]["resident"]
+ and st["bin0"]["pinned"]
+ and st["bin2"]["active"]
+ and not st["bin3"]["resident"],
+ f"{st}",
+ )
# -- teardown ----------------------------------------------------------- #
pool2.close_all()
- check("close_all() closes every lease",
- not pool2.resident() and all(c.closed for c in made.values()))
+ check(
+ "close_all() closes every lease",
+ not pool2.resident() and all(c.closed for c in made.values()),
+ )
check("close_all() clears the active binary", pool2.active is None)
# -- unknown label -------------------------------------------------------- #
@@ -153,10 +187,49 @@ def main() -> int:
except KeyError:
check("an unknown label raises KeyError", True)
+ # -- discard delegates shared/GUI finalization ------------------------ #
+ discard_made = {}
+
+ def spawn_discard(ref, ttl):
+ client = FakeClient(ref, discardable=ref.label != "bin1")
+ discard_made[ref.label] = client
+ return client
+
+ discard_pool = DatabasePool(proj, spawn=spawn_discard, mem_fn=lambda c: c.mem)
+ discard_pool.get("bin0")
+ discard_pool.get("bin1")
+ delegated = discard_pool.discard_changes(["bin0", "bin1"])
+ check(
+ "discard asks every dirty resident database",
+ discard_made["bin0"].discarded == 1 and discard_made["bin1"].discarded == 1,
+ {k: c.discarded for k, c in discard_made.items()},
+ )
+ check(
+ "discard reports leases whose finalization transferred",
+ delegated == ["bin1"],
+ delegated,
+ )
+ old = discard_made["bin0"]
+ replacement = FakeClient(proj.by_label("bin0"))
+ check(
+ "replace_client refuses a stale lease generation",
+ discard_pool.replace_client("bin0", object(), replacement) is False
+ and discard_pool.get("bin0") is old,
+ )
+ check(
+ "replace_client installs the reattached lease",
+ discard_pool.replace_client("bin0", old, replacement) is True
+ and discard_pool.get("bin0") is replacement,
+ )
+ discard_pool.close_all(save=False)
+
# -- default budget comes from the project's memory_pct ------------------- #
pool3 = DatabasePool(proj, spawn=spawn, mem_fn=lambda c: c.mem)
- check("default budget is derived, not a fixed lease count",
- pool3.budget_mb >= 256, pool3.budget_mb)
+ check(
+ "default budget is derived, not a fixed lease count",
+ pool3.budget_mb >= 256,
+ pool3.budget_mb,
+ )
# -- prewarm: speculative, and never at the cost of a real binary ------ #
with tempfile.TemporaryDirectory() as tmp:
@@ -168,24 +241,34 @@ def main() -> int:
made2[ref.label] = c
return c
- pool = DatabasePool(proj, budget_mb=250, spawn=spawn2,
- mem_fn=lambda c: c.mem)
+ pool = DatabasePool(proj, budget_mb=250, spawn=spawn2, mem_fn=lambda c: c.mem)
labels = [r.label for r in proj.refs]
a, b, c_ = labels[0], labels[1], labels[2]
pool.get(a)
pool.set_active(a)
- check("prewarm warms a binary when the budget has room",
- pool.prewarm(b) is True and b in pool.resident(), f"{pool.resident()}")
- check("prewarm is a no-op for something already resident",
- pool.prewarm(b) is False)
+ check(
+ "prewarm warms a binary when the budget has room",
+ pool.prewarm(b) is True and b in pool.resident(),
+ f"{pool.resident()}",
+ )
+ check(
+ "prewarm is a no-op for something already resident",
+ pool.prewarm(b) is False,
+ )
# 2 x 100MB resident, estimate 100 more -> 300 > 250: must refuse
- check("prewarm refuses rather than making room",
- pool.prewarm(c_) is False and c_ not in pool.resident(),
- f"resident={pool.resident()} mem={pool.memory_mb()}/{pool.budget_mb}")
- check("refusing to prewarm evicts nothing",
- set(pool.resident()) == {a, b}, f"{pool.resident()}")
- check("prewarm ignores a label outside the project",
- pool.prewarm("nope") is False)
+ check(
+ "prewarm refuses rather than making room",
+ pool.prewarm(c_) is False and c_ not in pool.resident(),
+ f"resident={pool.resident()} mem={pool.memory_mb()}/{pool.budget_mb}",
+ )
+ check(
+ "refusing to prewarm evicts nothing",
+ set(pool.resident()) == {a, b},
+ f"{pool.resident()}",
+ )
+ check(
+ "prewarm ignores a label outside the project", pool.prewarm("nope") is False
+ )
# Budget eviction releases GUI leases but must not save somebody's open IDA
# implicitly. An explicit save-and-close remains authoritative.
@@ -202,12 +285,16 @@ def main() -> int:
label = proj.refs[0].label
pool.get(label)
pool.evict(label)
- check("LRU release does not implicitly save a GUI database",
- made_gui[-1].saved == 0)
+ check(
+ "LRU release does not implicitly save a GUI database",
+ made_gui[-1].saved == 0,
+ )
pool.get(label)
pool.close_all(save=True)
- check("explicit close_all(save=True) does save a GUI database",
- made_gui[-1].saved == 1)
+ check(
+ "explicit close_all(save=True) does save a GUI database",
+ made_gui[-1].saved == 1,
+ )
print(f"\n{PASS} passed, {FAIL} failed")
return 1 if FAIL else 0
diff --git a/tests/test_project.py b/tests/test_project.py
index 690f360..63d029e 100644
--- a/tests/test_project.py
+++ b/tests/test_project.py
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""Unit tests for idatui.project (the multi-binary project model + staging).
-IDA-free: exercises staging plus Code Mode ownership checks without opening a database.
+IDA-free: exercises staging plus IDA Nexus ownership checks without opening a database.
python tests/test_project.py
"""
@@ -15,7 +15,7 @@ import sys
import tempfile
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
-from idatui.project import Project, ProjectError, SIDECAR_SUFFIX # noqa: E402
+from idatui.project import SIDECAR_SUFFIX, Project, ProjectError # noqa: E402
PASS = FAIL = 0
@@ -48,36 +48,48 @@ def main() -> int:
proj = Project.create(pfile, [httpd, libauth], name="router-fw")
check("create() writes the project file", os.path.isfile(pfile))
proj = Project.load(pfile)
- check("load() round-trips name + binaries",
- proj.name == "router-fw" and len(proj.refs) == 2,
- f"name={proj.name} n={len(proj.refs)}")
- check("labels default to the basename",
- [r.label for r in proj.refs] == ["httpd", "libauth.so"],
- f"{[r.label for r in proj.refs]}")
+ check(
+ "load() round-trips name + binaries",
+ proj.name == "router-fw" and len(proj.refs) == 2,
+ f"name={proj.name} n={len(proj.refs)}",
+ )
+ check(
+ "labels default to the basename",
+ [r.label for r in proj.refs] == ["httpd", "libauth.so"],
+ f"{[r.label for r in proj.refs]}",
+ )
# -- layout -------------------------------------------------------- #
- check("sidecar sits beside the project file",
- proj.sidecar == os.path.join(tmp, "router-fw" + SIDECAR_SUFFIX),
- proj.sidecar)
+ check(
+ "sidecar sits beside the project file",
+ proj.sidecar == os.path.join(tmp, "router-fw" + SIDECAR_SUFFIX),
+ proj.sidecar,
+ )
ref = proj.by_label("httpd")
- check("staged path lives in the sidecar, not the source tree",
- ref.staged.startswith(proj.bin_dir) and src not in ref.staged,
- ref.staged)
+ check(
+ "staged path lives in the sidecar, not the source tree",
+ ref.staged.startswith(proj.bin_dir) and src not in ref.staged,
+ ref.staged,
+ )
check("db path hangs off the staged file", ref.db == ref.staged + ".i64")
# -- staging: hardlink, freshness ---------------------------------- #
check("a binary starts out stale (not yet staged)", proj.is_stale(ref))
proj.stage(ref)
- check("stage() materialises the binary in the sidecar",
- os.path.isfile(ref.staged))
- check("stage() copies (distinct inode) so the source can't be mutated "
- "through it",
- os.stat(ref.staged).st_ino != os.stat(ref.source).st_ino
- and open(ref.staged, "rb").read() == open(ref.source, "rb").read())
+ check(
+ "stage() materialises the binary in the sidecar", os.path.isfile(ref.staged)
+ )
+ check(
+ "stage() copies (distinct inode) so the source can't be mutated through it",
+ os.stat(ref.staged).st_ino != os.stat(ref.source).st_ino
+ and open(ref.staged, "rb").read() == open(ref.source, "rb").read(),
+ )
check("a staged binary is no longer stale", not proj.is_stale(ref))
- check("source tree stays clean (no IDA artifacts beside it)",
- sorted(os.listdir(src)) == ["httpd", "libauth.so"],
- f"{sorted(os.listdir(src))}")
+ check(
+ "source tree stays clean (no IDA artifacts beside it)",
+ sorted(os.listdir(src)) == ["httpd", "libauth.so"],
+ f"{sorted(os.listdir(src))}",
+ )
# -- a changed source re-stages and drops the stale DB ------------- #
open(ref.db, "wb").write(b"fake i64")
@@ -88,11 +100,15 @@ def main() -> int:
os.utime(ref.source, (1, 1))
check("a source rebuilt in place goes stale", proj.is_stale(ref))
proj.stage(ref)
- check("re-staging refreshes the staged bytes",
- open(ref.staged, "rb").read().endswith(b"v2 (longer)"))
+ check(
+ "re-staging refreshes the staged bytes",
+ open(ref.staged, "rb").read().endswith(b"v2 (longer)"),
+ )
check("re-staging drops the now-stale database", not proj.has_db(ref))
- check("re-staging drops the stale scratch too",
- not os.path.exists(ref.staged + ".id0"))
+ check(
+ "re-staging drops the stale scratch too",
+ not os.path.exists(ref.staged + ".id0"),
+ )
# -- scratch sweep keeps the DB ------------------------------------ #
open(ref.db, "wb").write(b"fake i64")
@@ -107,22 +123,35 @@ def main() -> int:
os.makedirs(sub)
dup = _bin(os.path.join(sub, "httpd"), b"\x7fELF other httpd")
with open(pfile, "w") as f:
- json.dump({"name": "p", "binaries": [
- {"path": "src/httpd"}, # relative to the project file
- {"path": dup}, # same basename -> collision
- {"path": libauth, "label": "auth"},
- ]}, f)
+ json.dump(
+ {
+ "name": "p",
+ "binaries": [
+ {"path": "src/httpd"}, # relative to the project file
+ {"path": dup}, # same basename -> collision
+ {"path": libauth, "label": "auth"},
+ ],
+ },
+ f,
+ )
proj2 = Project.load(pfile)
- check("relative paths resolve against the project file",
- proj2.refs[0].source == httpd, proj2.refs[0].source)
- check("colliding labels are disambiguated",
- [r.label for r in proj2.refs] == ["httpd", "httpd_2", "auth"],
- f"{[r.label for r in proj2.refs]}")
+ check(
+ "relative paths resolve against the project file",
+ proj2.refs[0].source == httpd,
+ proj2.refs[0].source,
+ )
+ check(
+ "colliding labels are disambiguated",
+ [r.label for r in proj2.refs] == ["httpd", "httpd_2", "auth"],
+ f"{[r.label for r in proj2.refs]}",
+ )
check("explicit labels are honoured", proj2.by_label("auth") is not None)
proj2.stage_all()
- check("stage_all() stages every binary to a distinct file",
- len({r.staged for r in proj2.refs}) == 3
- and all(os.path.isfile(r.staged) for r in proj2.refs))
+ check(
+ "stage_all() stages every binary to a distinct file",
+ len({r.staged for r in proj2.refs}) == 3
+ and all(os.path.isfile(r.staged) for r in proj2.refs),
+ )
# -- add / remove ---------------------------------------------------- #
extra = _bin(os.path.join(src, "extra"))
@@ -132,39 +161,60 @@ def main() -> int:
# -- re-adding must not duplicate (matched by resolved path) --------- #
n = len(proj2.refs)
proj2.add(extra)
- check("re-adding the same path is a no-op", len(proj2.refs) == n,
- f"{[r.label for r in proj2.refs]}")
+ check(
+ "re-adding the same path is a no-op",
+ len(proj2.refs) == n,
+ f"{[r.label for r in proj2.refs]}",
+ )
os.chdir(src)
- proj2.add("./extra") # same file, relative
- proj2.add(os.path.join(src, "..", "src", "extra")) # same file, messy
- check("a different spelling of the same path is a no-op",
- len(proj2.refs) == n, f"{[r.label for r in proj2.refs]}")
+ proj2.add("./extra") # same file, relative
+ proj2.add(os.path.join(src, "..", "src", "extra")) # same file, messy
+ check(
+ "a different spelling of the same path is a no-op",
+ len(proj2.refs) == n,
+ f"{[r.label for r in proj2.refs]}",
+ )
link = os.path.join(src, "extra_link")
os.symlink(extra, link)
proj2.add(link)
- check("a symlink to an existing binary is a no-op",
- len(proj2.refs) == n, f"{[r.label for r in proj2.refs]}")
+ check(
+ "a symlink to an existing binary is a no-op",
+ len(proj2.refs) == n,
+ f"{[r.label for r in proj2.refs]}",
+ )
# ...but a DIFFERENT file with the same basename must still be added
other_dir = os.path.join(tmp, "other2")
os.makedirs(other_dir)
twin = _bin(os.path.join(other_dir, "extra"), b"\x7fELF a different extra")
proj2.add(twin)
- check("a same-named file from another directory IS added",
- len(proj2.refs) == n + 1
- and proj2.by_source(twin) is not None
- and proj2.by_source(extra) is not proj2.by_source(twin),
- f"{[(r.label, r.source) for r in proj2.refs[-2:]]}")
- check("the twins get distinct labels",
- len({r.label for r in proj2.refs}) == len(proj2.refs),
- f"{[r.label for r in proj2.refs]}")
- check("create() also drops repeats on the command line",
- len(Project.create(os.path.join(tmp, "dup.json"),
- [extra, "./extra", extra]).refs) == 1)
+ check(
+ "a same-named file from another directory IS added",
+ len(proj2.refs) == n + 1
+ and proj2.by_source(twin) is not None
+ and proj2.by_source(extra) is not proj2.by_source(twin),
+ f"{[(r.label, r.source) for r in proj2.refs[-2:]]}",
+ )
+ check(
+ "the twins get distinct labels",
+ len({r.label for r in proj2.refs}) == len(proj2.refs),
+ f"{[r.label for r in proj2.refs]}",
+ )
+ check(
+ "create() also drops repeats on the command line",
+ len(
+ Project.create(
+ os.path.join(tmp, "dup.json"), [extra, "./extra", extra]
+ ).refs
+ )
+ == 1,
+ )
os.chdir(tmp)
proj2.remove(proj2.by_source(twin).label)
- check("remove() drops one", proj2.remove("extra")
- and proj2.by_label("extra") is None)
+ check(
+ "remove() drops one",
+ proj2.remove("extra") and proj2.by_label("extra") is None,
+ )
# -- bad input ------------------------------------------------------- #
bad = os.path.join(tmp, "bad.json")
@@ -191,43 +241,62 @@ def main() -> int:
# -- load options for headerless blobs --------------------------------- #
with tempfile.TemporaryDirectory() as tmp:
- src = os.path.join(tmp, "src"); os.makedirs(src)
+ src = os.path.join(tmp, "src")
+ os.makedirs(src)
blob = os.path.join(src, "fw.bin")
with open(blob, "wb") as f:
f.write(b"\x00" * 64)
- proj = Project.create(os.path.join(tmp, "p.json"), [blob], name="p",
- load={"processor": "arm", "base": 0x8000000})
+ proj = Project.create(
+ os.path.join(tmp, "p.json"),
+ [blob],
+ name="p",
+ load={"processor": "arm", "base": 0x8000000},
+ )
r = proj.refs[0]
- check("create() records load options per binary",
- r.processor == "arm" and r.base == 0x8000000,
- f"proc={r.processor!r} base={r.base:#x}")
+ check(
+ "create() records load options per binary",
+ r.processor == "arm" and r.base == 0x8000000,
+ f"proc={r.processor!r} base={r.base:#x}",
+ )
# -b is PARAGRAPHS: 0x8000000 >> 4 == 0x800000. Getting this wrong loads
# the image 16x too high and every address in the database is wrong.
- check("base is converted to IDA's paragraph units",
- r.load_args == "-parm -b800000", r.load_args)
+ check(
+ "base is converted to IDA's paragraph units",
+ r.load_args == "-parm -b800000",
+ r.load_args,
+ )
proj2 = Project.load(proj.path)
- check("load options survive a round-trip through the file",
- proj2.refs[0].load_args == "-parm -b800000",
- proj2.refs[0].load_args)
+ check(
+ "load options survive a round-trip through the file",
+ proj2.refs[0].load_args == "-parm -b800000",
+ proj2.refs[0].load_args,
+ )
blob2 = os.path.join(src, "other.bin")
with open(blob2, "wb") as f:
f.write(b"\x00" * 64)
r2 = proj2.add(blob2, load={"processor": "mipsb"})
- check("add() takes load options too",
- r2.load_args == "-pmipsb", r2.load_args)
+ check("add() takes load options too", r2.load_args == "-pmipsb", r2.load_args)
# a normal ELF needs none of this and must pass nothing
- check("a binary with no load options passes no switches",
- Project.create(os.path.join(tmp, "q.json"), [blob],
- name="q").refs[0].load_args == "")
+ check(
+ "a binary with no load options passes no switches",
+ Project.create(os.path.join(tmp, "q.json"), [blob], name="q")
+ .refs[0]
+ .load_args
+ == "",
+ )
# addresses get written by hand, so accept how people write them
- proj3 = Project.create(os.path.join(tmp, "r.json"), [blob], name="r",
- load={"base": "0x1000"})
- check("a base given as a hex STRING is parsed",
- proj3.refs[0].base == 0x1000, f"{proj3.refs[0].base}")
+ proj3 = Project.create(
+ os.path.join(tmp, "r.json"), [blob], name="r", load={"base": "0x1000"}
+ )
+ check(
+ "a base given as a hex STRING is parsed",
+ proj3.refs[0].base == 0x1000,
+ f"{proj3.refs[0].base}",
+ )
print(f"\n{PASS} passed, {FAIL} failed")
return 1 if FAIL else 0
diff --git a/tests/test_project_ui.py b/tests/test_project_ui.py
index 863b98a..9b388c8 100644
--- a/tests/test_project_ui.py
+++ b/tests/test_project_ui.py
@@ -21,12 +21,15 @@ import tempfile
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _fixtures import fast_keys # noqa: E402
-from idatui._sync import settle as quiesce, wait_for # noqa: E402
-fast_keys() # ~85ms -> ~2ms per keypress; see _fixtures.fast_keys
+from idatui._sync import settle as quiesce # noqa: E402
+from idatui._sync import wait_for
+
+fast_keys() # ~85ms -> ~2ms per keypress; see _fixtures.fast_keys
+from textual.widgets import Input, OptionList, Static # noqa: E402
+
from idatui.app import IdaTui, ProjectPalette # noqa: E402
from idatui.project import Project # noqa: E402
-from textual.widgets import Input, OptionList, Static # noqa: E402
PASS = FAIL = 0
@@ -57,6 +60,7 @@ async def run(bins):
app = IdaTui(keepalive=False, project=proj)
async with app.run_test(size=(140, 44)) as pilot:
+
async def settle(pred, t=180.0):
return await wait_for(pred, pilot.pause, t, 0.05)
@@ -73,114 +77,163 @@ async def run(bins):
waited for explicitly, not hoped for.
"""
return await settle(
- lambda: app.program is not None
- and app._func_index is not None
- and app._func_index.complete
- and app._loading_screen is None
- and len(app.screen_stack) == 1, t)
+ lambda: (
+ app.program is not None
+ and app._func_index is not None
+ and app._func_index.complete
+ and app._loading_screen is None
+ and len(app.screen_stack) == 1
+ ),
+ t,
+ )
# -- boots on the project's first binary ----------------------- #
ok = await usable()
- check("project mode boots on the first binary", ok,
- f"binary={app._binary}")
- check("the active binary is the first one", app._binary == first,
- f"{app._binary}")
+ check("project mode boots on the first binary", ok, f"binary={app._binary}")
+ check(
+ "the active binary is the first one",
+ app._binary == first,
+ f"{app._binary}",
+ )
n_first = len(app._func_index)
check("its functions loaded", n_first > 10, f"n={n_first}")
status = str(app.query_one("#status", Static).render())
- check("the status line names the active binary",
- f"[{first}]" in status, status[:60])
+ check(
+ "the status line names the active binary",
+ f"[{first}]" in status,
+ status[:60],
+ )
# -- the switcher lists the project ---------------------------- #
await pilot.press("ctrl+o")
- opened = await settle(
- lambda: isinstance(app.screen, ProjectPalette), 20)
- check("Ctrl+O opens the binary switcher", opened,
- f"screen={type(app.screen).__name__}")
+ opened = await settle(lambda: isinstance(app.screen, ProjectPalette), 20)
+ check(
+ "Ctrl+O opens the binary switcher",
+ opened,
+ f"screen={type(app.screen).__name__}",
+ )
if not opened:
return
pal = app.screen
- check("the switcher lists every project binary",
- len(pal._results) == 2, f"{[e['label'] for e in pal._results]}")
- check("it marks which one is active",
- any(e["active"] and e["label"] == first for e in pal._results))
- check("it marks the other as not yet opened",
- any(not e["resident"] and e["label"] == second
- for e in pal._results))
+ check(
+ "the switcher lists every project binary",
+ len(pal._results) == 2,
+ f"{[e['label'] for e in pal._results]}",
+ )
+ check(
+ "it marks which one is active",
+ any(e["active"] and e["label"] == first for e in pal._results),
+ )
+ check(
+ "it marks the other as not yet opened",
+ any(not e["resident"] and e["label"] == second for e in pal._results),
+ )
ol = pal.query_one(OptionList)
- check("the switcher opens on the binary you're already in",
- ol.highlighted is not None
- and pal._results[ol.highlighted]["label"] == first,
- f"highlighted={ol.highlighted} "
- f"={pal._results[ol.highlighted]['label'] if ol.highlighted is not None else None} "
- f"want={first}")
+ check(
+ "the switcher opens on the binary you're already in",
+ ol.highlighted is not None
+ and pal._results[ol.highlighted]["label"] == first,
+ f"highlighted={ol.highlighted} "
+ f"={pal._results[ol.highlighted]['label'] if ol.highlighted is not None else None} "
+ f"want={first}",
+ )
# -- switch to the second binary -------------------------------- #
pal.query_one(Input).value = second
await settle(lambda: bool(pal._results), 20)
await pilot.press("enter")
switched = await settle(
- lambda: app._binary == second and app.program is not None
- and app._func_index is not None and app._func_index.complete
- and len(app.screen_stack) == 1)
- check("switching opens the other binary", switched,
- f"binary={app._binary}")
- check("the second binary has its own function index",
- app._func_index is not None and len(app._func_index) > 5,
- f"n={len(app._func_index) if app._func_index else 0}")
- check("both binaries now have live workers",
- sorted(app._pool.resident()) == sorted([first, second]),
- f"{app._pool.resident()}")
+ lambda: (
+ app._binary == second
+ and app.program is not None
+ and app._func_index is not None
+ and app._func_index.complete
+ and len(app.screen_stack) == 1
+ )
+ )
+ check("switching opens the other binary", switched, f"binary={app._binary}")
+ check(
+ "the second binary has its own function index",
+ app._func_index is not None and len(app._func_index) > 5,
+ f"n={len(app._func_index) if app._func_index else 0}",
+ )
+ check(
+ "both binaries now have live workers",
+ sorted(app._pool.resident()) == sorted([first, second]),
+ f"{app._pool.resident()}",
+ )
landed = await settle(lambda: app._cur is not None, 60)
- check("it lands somewhere in the new binary", landed,
- f"cur={app._cur}")
+ check("it lands somewhere in the new binary", landed, f"cur={app._cur}")
where = app._cur.ea if app._cur else None
# -- switch back: resident, so state is restored ---------------- #
await pilot.press("ctrl+o")
- reopened = await settle(
- lambda: isinstance(app.screen, ProjectPalette), 20)
- check("the switcher reopens after a switch", reopened,
- f"screen={type(app.screen).__name__}")
+ reopened = await settle(lambda: isinstance(app.screen, ProjectPalette), 20)
+ check(
+ "the switcher reopens after a switch",
+ reopened,
+ f"screen={type(app.screen).__name__}",
+ )
if not reopened:
return
app.screen.query_one(Input).value = first
await settle(lambda: bool(app.screen._results), 20)
await pilot.press("enter")
- back = await settle(lambda: app._binary == first
- and app._func_index is not None
- and app._func_index.complete
- and len(app.screen_stack) == 1, 120)
- check("switching back returns to the first binary", back,
- f"binary={app._binary}")
- check("its function index came back intact",
- app._func_index is not None and len(app._func_index) == n_first,
- f"n={len(app._func_index) if app._func_index else 0} want={n_first}")
+ back = await settle(
+ lambda: (
+ app._binary == first
+ and app._func_index is not None
+ and app._func_index.complete
+ and len(app.screen_stack) == 1
+ ),
+ 120,
+ )
+ check(
+ "switching back returns to the first binary",
+ back,
+ f"binary={app._binary}",
+ )
+ check(
+ "its function index came back intact",
+ app._func_index is not None and len(app._func_index) == n_first,
+ f"n={len(app._func_index) if app._func_index else 0} want={n_first}",
+ )
# and forward again: the second binary's position was remembered
await pilot.press("ctrl+o")
if not await settle(lambda: isinstance(app.screen, ProjectPalette), 20):
- check("returning to a binary restores where you were", False,
- "switcher did not reopen")
+ check(
+ "returning to a binary restores where you were",
+ False,
+ "switcher did not reopen",
+ )
return
app.screen.query_one(Input).value = second
await settle(lambda: bool(app.screen._results), 20)
await pilot.press("enter")
- again = await settle(lambda: app._binary == second
- and app._cur is not None, 120)
- check("returning to a binary restores where you were",
- again and app._cur.ea == where,
- f"cur={app._cur.ea if app._cur else None} want={where}")
+ again = await settle(
+ lambda: app._binary == second and app._cur is not None, 120
+ )
+ check(
+ "returning to a binary restores where you were",
+ again and app._cur.ea == where,
+ f"cur={app._cur.ea if app._cur else None} want={where}",
+ )
# -- project-wide symbol search ------------------------------- #
# 'main' exists in BOTH binaries: identical name, so the rank tuple
# ties and a bare sort() would fall through to comparing Hit objects
# ('<' not supported between instances of 'Hit').
from idatui.app import SymbolPalette
- await settle(lambda: app._index is not None
- and len(app._index.counts()) == 2, 60)
- check("both binaries got indexed",
- len(app._index.counts()) == 2, f"{app._index.counts()}")
+
+ await settle(
+ lambda: app._index is not None and len(app._index.counts()) == 2, 60
+ )
+ check(
+ "both binaries got indexed",
+ len(app._index.counts()) == 2,
+ f"{app._index.counts()}",
+ )
await pilot.press("ctrl+n")
if await settle(lambda: isinstance(app.screen, SymbolPalette), 20):
pal = app.screen
@@ -190,12 +243,14 @@ async def run(bins):
# quiescence -- NOT "a foreign binary appeared", which is the
# thing under test and would sit out its whole timeout on the
# day it breaks.
- await pilot.press("f2") # widen to the whole project
+ await pilot.press("f2") # widen to the whole project
await quiesce(app)
names = [(b, n) for b, _, n in pal._results]
- check("project scope finds a name shared by both binaries",
- len({b for b, n in names if n == "main"}) == 2,
- f"{names[:6]}")
+ check(
+ "project scope finds a name shared by both binaries",
+ len({b for b, n in names if n == "main"}) == 2,
+ f"{names[:6]}",
+ )
await pilot.press("escape")
await settle(lambda: not isinstance(app.screen, SymbolPalette), 20)
@@ -209,17 +264,31 @@ async def run(bins):
target = app._index.search("main", limit=200)
tgt = next((h for h in target if h.binary == there), None)
if tgt is None:
- check("cross-binary jump records a hop", False, "no hit in the other binary")
+ check(
+ "cross-binary jump records a hop",
+ False,
+ "no hit in the other binary",
+ )
else:
app._switch_then_goto(tgt.binary, tgt.addr)
- jumped = await settle(lambda: app._binary == there
- and app._func_index is not None
- and app._func_index.complete, 180)
- check("a project hit switches to the other binary", jumped,
- f"binary={app._binary} want={there}")
- check("the jump records where it came from",
- len(app._hops) == hops0 + 1 and app._hops[-1] == here,
- f"hops={app._hops}")
+ jumped = await settle(
+ lambda: (
+ app._binary == there
+ and app._func_index is not None
+ and app._func_index.complete
+ ),
+ 180,
+ )
+ check(
+ "a project hit switches to the other binary",
+ jumped,
+ f"binary={app._binary} want={there}",
+ )
+ check(
+ "the jump records where it came from",
+ len(app._hops) == hops0 + 1 and app._hops[-1] == here,
+ f"hops={app._hops}",
+ )
# spend the local history first, then Esc must cross back
for _ in range(6):
if not app._hops or app._binary != there:
@@ -227,10 +296,16 @@ async def run(bins):
await pilot.press("escape")
await quiesce(app)
returned = await settle(lambda: app._binary == here, 180)
- check("Esc crosses back to the binary the jump came from",
- returned, f"binary={app._binary} want={here} hops={app._hops}")
- check("the hop is consumed, not repeated",
- not app._hops, f"hops={app._hops}")
+ check(
+ "Esc crosses back to the binary the jump came from",
+ returned,
+ f"binary={app._binary} want={here} hops={app._hops}",
+ )
+ check(
+ "the hop is consumed, not repeated",
+ not app._hops,
+ f"hops={app._hops}",
+ )
# -- xrefs: callers in OTHER project binaries ------------------ #
# xrefs_to only sees this database, so an exported function looks
@@ -238,9 +313,13 @@ async def run(bins):
# The selection rule is "only for a symbol we actually export"; two
# executables share no linkage, so here it must stay quiet.
from idatui.app import XrefsScreen
+
fake = app._foreign_importers(app._cur.ea, "strrchr", None)
- check("no cross-binary callers for a symbol this binary doesn't export",
- fake == [], f"{fake}")
+ check(
+ "no cross-binary callers for a symbol this binary doesn't export",
+ fake == [],
+ f"{fake}",
+ )
# The routing a real cross-binary caller takes: the dialog carries a
# (binary, addr) payload instead of a bare address, and choosing it
@@ -248,31 +327,55 @@ async def run(bins):
# so Esc comes back.
where_from = app._binary
other = first if where_from == second else second
- hit = next((h for h in app._index.search("main", limit=200)
- if h.binary == other), None)
+ hit = next(
+ (h for h in app._index.search("main", limit=200) if h.binary == other),
+ None,
+ )
if hit is None:
- check("a cross-binary xref jumps to the other binary", False,
- "no symbol found in the other binary")
+ check(
+ "a cross-binary xref jumps to the other binary",
+ False,
+ "no symbol found in the other binary",
+ )
else:
hops0 = len(app._hops)
app.push_screen(
- XrefsScreen("xrefs to fake", [((hit.binary, hit.addr),
- f"{hit.addr:08X} import [{hit.binary}]")]),
- app._on_xref_chosen)
+ XrefsScreen(
+ "xrefs to fake",
+ [
+ (
+ (hit.binary, hit.addr),
+ f"{hit.addr:08X} import [{hit.binary}]",
+ )
+ ],
+ ),
+ app._on_xref_chosen,
+ )
await settle(lambda: isinstance(app.screen, XrefsScreen), 20)
await pilot.press("enter")
- jumped = await settle(lambda: app._binary == other
- and app._func_index is not None
- and app._func_index.complete, 180)
- check("a cross-binary xref jumps to the other binary", jumped,
- f"binary={app._binary} want={other}")
- check("and records a hop so Esc returns",
- len(app._hops) == hops0 + 1 and app._hops[-1] == where_from,
- f"hops={app._hops}")
+ jumped = await settle(
+ lambda: (
+ app._binary == other
+ and app._func_index is not None
+ and app._func_index.complete
+ ),
+ 180,
+ )
+ check(
+ "a cross-binary xref jumps to the other binary",
+ jumped,
+ f"binary={app._binary} want={other}",
+ )
+ check(
+ "and records a hop so Esc returns",
+ len(app._hops) == hops0 + 1 and app._hops[-1] == where_from,
+ f"hops={app._hops}",
+ )
app._hops.clear()
# -- and the same toggle for strings --------------------------- #
from idatui.app import StringsPalette
+
await pilot.press("quotation_mark")
if await settle(lambda: isinstance(app.screen, StringsPalette), 30):
pal = app.screen
@@ -282,20 +385,32 @@ async def run(bins):
await pilot.press("f2")
await quiesce(app)
wide = {b for b, _, _ in pal._results}
- check("strings: local scope is this binary only", local == {None},
- f"{local}")
- check("strings: F2 widens across the project",
- len(wide) >= 2 and None not in wide, f"{wide}")
+ check(
+ "strings: local scope is this binary only",
+ local == {None},
+ f"{local}",
+ )
+ check(
+ "strings: F2 widens across the project",
+ len(wide) >= 2 and None not in wide,
+ f"{wide}",
+ )
await pilot.press("escape")
await settle(lambda: not isinstance(app.screen, StringsPalette), 20)
# -- the promise: nothing was written next to the sources ---------- #
left = sorted(os.listdir(src))
- check("the source tree stays pristine (no .i64/scratch beside it)",
- left == sorted(os.path.basename(s) for s in srcs), f"{left}")
+ check(
+ "the source tree stays pristine (no .i64/scratch beside it)",
+ left == sorted(os.path.basename(s) for s in srcs),
+ f"{left}",
+ )
staged = sorted(os.listdir(proj.bin_dir))
- check("IDA's artifacts all live in the project sidecar",
- any(f.endswith(".i64") for f in staged), f"{staged}")
+ check(
+ "IDA's artifacts all live in the project sidecar",
+ any(f.endswith(".i64") for f in staged),
+ f"{staged}",
+ )
print(f"\n{PASS} passed, {FAIL} failed")
return 1 if FAIL else 0
@@ -303,8 +418,10 @@ async def run(bins):
def main(argv):
repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
- bins = argv or [os.path.join(repo, "targets", "echo"),
- os.path.join(repo, "targets", "cat")]
+ bins = argv or [
+ os.path.join(repo, "targets", "echo"),
+ os.path.join(repo, "targets", "cat"),
+ ]
for b in bins:
if not os.path.isfile(b):
print(f"no such binary: {b}")
diff --git a/tests/test_rawimage_rpc.py b/tests/test_rawimage_rpc.py
index d39ad35..a6e7661 100644
--- a/tests/test_rawimage_rpc.py
+++ b/tests/test_rawimage_rpc.py
@@ -36,7 +36,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from idatui.rpcclient import RpcClient, RpcError # noqa: E402
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
-BLOB = os.path.join(REPO, "experiments", "fibonacci.bin") # real Thumb code
+BLOB = os.path.join(REPO, "experiments", "fibonacci.bin") # real Thumb code
PASS = FAIL = 0
@@ -52,11 +52,24 @@ def check(name, ok, detail=""):
def spawn_pane(target, processor, timeout=420):
- cmd = [sys.executable, "-m", "idatui.pane", "spawn", "--open", target,
- "--processor", processor, "--detached", "--size", "60%",
- "--timeout", str(timeout)]
- r = subprocess.run(cmd, capture_output=True, text=True,
- timeout=timeout + 60, cwd=REPO)
+ cmd = [
+ sys.executable,
+ "-m",
+ "idatui.pane",
+ "spawn",
+ "--open",
+ target,
+ "--processor",
+ processor,
+ "--detached",
+ "--size",
+ "60%",
+ "--timeout",
+ str(timeout),
+ ]
+ r = subprocess.run(
+ cmd, capture_output=True, text=True, timeout=timeout + 60, cwd=REPO
+ )
if not r.stdout.strip():
print(f" spawn produced no JSON: {r.stderr.strip()}", file=sys.stderr)
return None
@@ -64,9 +77,22 @@ def spawn_pane(target, processor, timeout=420):
def stop_pane(sock, timeout=60):
- subprocess.run([sys.executable, "-m", "idatui.pane", "stop", "--sock", sock,
- "--timeout", str(timeout)],
- capture_output=True, text=True, timeout=timeout + 10, cwd=REPO)
+ subprocess.run(
+ [
+ sys.executable,
+ "-m",
+ "idatui.pane",
+ "stop",
+ "--sock",
+ sock,
+ "--timeout",
+ str(timeout),
+ ],
+ capture_output=True,
+ text=True,
+ timeout=timeout + 10,
+ cwd=REPO,
+ )
def main() -> int:
@@ -94,34 +120,44 @@ def main() -> int:
# -- load options actually reached IDA --------------------------- #
# Wrong processor => the disassembly is nonsense or absent; ARMv7-A
# also means a 32-bit database, without which Hex-Rays refuses.
- check("spawn forwarded --processor", info.get("ok"),
- json.dumps(info))
+ check("spawn forwarded --processor", info.get("ok"), json.dumps(info))
with RpcClient(sock) as c:
st = c.call("state")
- check("pane is drivable", st.get("active") in
- ("listing", "decomp", "hex"), json.dumps(st)[:200])
+ check(
+ "pane is drivable",
+ st.get("active") in ("listing", "decomp", "hex"),
+ json.dumps(st)[:200],
+ )
# -- define ------------------------------------------------- #
# fibonacci.bin is Thumb at 0x0; as ARM it does not decode.
r = c.call("define", kind="thumb", target="0x0")
d = r.get("define", {})
check("define thumb ran", "define" in r, json.dumps(r)[:200])
- check("define thumb decoded instructions",
- "instruction" in d.get("status", ""), d.get("status", ""))
+ check(
+ "define thumb decoded instructions",
+ "instruction" in d.get("status", ""),
+ d.get("status", ""),
+ )
r = c.call("define", kind="func", target="0x0")
- check("define func created a function",
- "function" in r["define"]["status"]
- or "already" in r["define"]["status"],
- r["define"]["status"])
+ check(
+ "define func created a function",
+ "function" in r["define"]["status"]
+ or "already" in r["define"]["status"],
+ r["define"]["status"],
+ )
bad = None
try:
c.call("define", kind="nonsense")
except RpcError as e:
bad = str(e)
- check("define rejects an unknown kind", bad is not None
- and "unknown define kind" in bad, str(bad))
+ check(
+ "define rejects an unknown kind",
+ bad is not None and "unknown define kind" in bad,
+ str(bad),
+ )
# -- opfmt (how a literal is displayed) --------------------- #
# Thumb code is full of small immediates -- the thing 'o' exists
@@ -134,21 +170,31 @@ def main() -> int:
lit = None
for ln in seen:
m = re.match(r"([0-9A-F]{8})\s+(.*)", ln.get("text", ""))
- if not (m and re.search(r"#(0x[0-9A-Fa-f]{2,}|[1-9]\d+)\b",
- m.group(2))):
+ if not (
+ m and re.search(r"#(0x[0-9A-Fa-f]{2,}|[1-9]\d+)\b", m.group(2))
+ ):
continue
ea_s = "0x" + m.group(1)
- st = c.call("opfmt", mode="show", target=ea_s, delay_ms=0
- ).get("opfmt", {}).get("status", "")
+ st = (
+ c.call("opfmt", mode="show", target=ea_s, delay_ms=0)
+ .get("opfmt", {})
+ .get("status", "")
+ )
if "no literal" not in st:
lit = (ea_s, st)
break
- check("the blob has an immediate to reformat", lit is not None,
- json.dumps([ln.get("text") for ln in seen[:8]]))
+ check(
+ "the blob has an immediate to reformat",
+ lit is not None,
+ json.dumps([ln.get("text") for ln in seen[:8]]),
+ )
if lit is not None:
tgt, st0 = lit
- check("opfmt show reports the stops without editing",
- "[" in st0 and "dec" in st0, st0)
+ check(
+ "opfmt show reports the stops without editing",
+ "[" in st0 and "dec" in st0,
+ st0,
+ )
r = c.call("opfmt", mode="dec", target=tgt, delay_ms=0)
st1 = r.get("opfmt", {}).get("status", "")
check("opfmt sets a named format", "dec" in st1, st1)
@@ -156,16 +202,21 @@ def main() -> int:
st2 = r.get("opfmt", {}).get("status", "")
check("opfmt cycles on from there", "\u2192" in st2, st2)
r = c.call("opfmt", mode="default")
- check("opfmt hands the operand back to IDA",
- "default" in r.get("opfmt", {}).get("status", ""),
- r.get("opfmt", {}).get("status", ""))
+ check(
+ "opfmt hands the operand back to IDA",
+ "default" in r.get("opfmt", {}).get("status", ""),
+ r.get("opfmt", {}).get("status", ""),
+ )
badfmt = None
try:
c.call("opfmt", mode="roman")
except RpcError as e:
badfmt = str(e)
- check("opfmt rejects an unknown mode", badfmt is not None
- and "unknown opfmt mode" in badfmt, str(badfmt))
+ check(
+ "opfmt rejects an unknown mode",
+ badfmt is not None and "unknown opfmt mode" in badfmt,
+ str(badfmt),
+ )
# -- rename_many -------------------------------------------- #
fns = c.call("functions", limit=200)
@@ -177,28 +228,48 @@ def main() -> int:
# 'start' (not 'addr') on purpose: symbol files in the wild
# use it, and accepting only one spelling is how a bulk
# import silently renames nothing.
- json.dump([{"start": hex(ea), "name": "bulk_named_fn"},
- {"start": "0xdeadbe", "name": "nowhere"}], f)
+ json.dump(
+ [
+ {"start": hex(ea), "name": "bulk_named_fn"},
+ {"start": "0xdeadbe", "name": "nowhere"},
+ ],
+ f,
+ )
r = c.call("rename_many", file=symfile)
m = r.get("rename_many", {})
- check("rename_many applied the good entry", m.get("ok") == 1,
- json.dumps(m))
- check("rename_many reports the bad entry",
- m.get("failed") == 1 and m.get("errors"), json.dumps(m))
+ check(
+ "rename_many applied the good entry",
+ m.get("ok") == 1,
+ json.dumps(m),
+ )
+ check(
+ "rename_many reports the bad entry",
+ m.get("failed") == 1 and m.get("errors"),
+ json.dumps(m),
+ )
# The readback matters more than the return value: a driver
# trusts resolve/functions to decide what work is left.
- check("renamed symbol resolves",
- c.call("resolve", name="bulk_named_fn").get("ea") == ea,
- json.dumps(c.call("resolve", name="bulk_named_fn")))
+ check(
+ "renamed symbol resolves",
+ c.call("resolve", name="bulk_named_fn").get("ea") == ea,
+ json.dumps(c.call("resolve", name="bulk_named_fn")),
+ )
names = {f["name"] for f in c.call("functions", limit=200)}
- check("function table shows the new name",
- "bulk_named_fn" in names, str(sorted(names)[:10]))
+ check(
+ "function table shows the new name",
+ "bulk_named_fn" in names,
+ str(sorted(names)[:10]),
+ )
- r = c.call("rename_many", items=[{"addr": hex(ea),
- "name": "inline_named_fn"}])
- check("rename_many takes inline items",
- r["rename_many"]["ok"] == 1, json.dumps(r["rename_many"]))
+ r = c.call(
+ "rename_many", items=[{"addr": hex(ea), "name": "inline_named_fn"}]
+ )
+ check(
+ "rename_many takes inline items",
+ r["rename_many"]["ok"] == 1,
+ json.dumps(r["rename_many"]),
+ )
# -- the stale-pseudocode trap ------------------------------ #
# Hex-Rays caches per function and does not notice that a
@@ -208,22 +279,31 @@ def main() -> int:
# the function's own body cites it.)
before = c.call("pseudocode", target=hex(ea))
pc_before = json.dumps(before)
- r = c.call("rename_many", items=[{"addr": hex(ea),
- "name": "after_cache_fn"}])
+ r = c.call(
+ "rename_many", items=[{"addr": hex(ea), "name": "after_cache_fn"}]
+ )
pc_after = json.dumps(c.call("pseudocode", target=hex(ea)))
- check("pseudocode was cached before the rename",
- "inline_named_fn" in pc_before, pc_before[:200])
- check("rename_many invalidates the decompile cache",
- "after_cache_fn" in pc_after
- and "inline_named_fn" not in pc_after, pc_after[:300])
+ check(
+ "pseudocode was cached before the rename",
+ "inline_named_fn" in pc_before,
+ pc_before[:200],
+ )
+ check(
+ "rename_many invalidates the decompile cache",
+ "after_cache_fn" in pc_after and "inline_named_fn" not in pc_after,
+ pc_after[:300],
+ )
empty = None
try:
c.call("rename_many")
except RpcError as e:
empty = str(e)
- check("rename_many without items errors", empty is not None
- and "items" in empty, str(empty))
+ check(
+ "rename_many without items errors",
+ empty is not None and "items" in empty,
+ str(empty),
+ )
finally:
stop_pane(sock)
diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py
index 37ec017..4fa9799 100644
--- a/tests/test_scenarios.py
+++ b/tests/test_scenarios.py
@@ -31,19 +31,38 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _fixtures import fast_keys, staged # noqa: E402
-fast_keys() # ~85ms -> ~2ms per keypress; see _fixtures.fast_keys
+fast_keys() # ~85ms -> ~2ms per keypress; see _fixtures.fast_keys
+from rich.text import Text # noqa: E402
+from textual.widgets import ( # noqa: E402
+ DataTable,
+ Input,
+ OptionList,
+ Static,
+ TextArea,
+)
+
+from idatui import remote_ops # noqa: E402
+from idatui._sync import settle, wait_for # noqa: E402
from idatui.app import ( # noqa: E402
- ConfirmScreen, DecompView, FunctionsPanel, GraphView, HexView, IdaTui,
- HelpScreen, ListingView, QuitScreen, SearchPalette, StringsPalette,
- StructEditor, SymbolPalette, XrefsScreen, _HELP, _str_display,
+ _HELP,
+ ConfirmScreen,
+ DecompView,
+ FunctionsPanel,
+ GraphView,
+ HelpScreen,
+ HexView,
+ IdaTui,
+ ListingView,
+ QuitScreen,
+ SearchPalette,
+ StringsPalette,
+ StructEditor,
+ SymbolPalette,
+ XrefsScreen,
+ _str_display,
_word_occurrences,
)
from idatui.errors import IDAToolError # noqa: E402
-from textual.widgets import ( # noqa: E402
- DataTable, Input, OptionList, Static, TextArea,
-)
-from rich.text import Text # noqa: E402
-from idatui._sync import settle, wait_for # noqa: E402
PASS = FAIL = 0
STOP_AFTER = None
@@ -86,8 +105,10 @@ class _Profile:
return
tp, tw = sum(self.paused.values()), sum(self.waited.values())
tk = sum(self.pressed.values())
- print(f"\nprofile: {tp:.1f}s settling, {tw:.1f}s in waits, "
- f"{tk:.1f}s in keystrokes")
+ print(
+ f"\nprofile: {tp:.1f}s settling, {tw:.1f}s in waits, "
+ f"{tk:.1f}s in keystrokes"
+ )
rows = sorted(self.paused.items(), key=lambda kv: -kv[1])[:8]
for name, secs in rows:
print(f" pause {secs:5.2f}s {name}")
@@ -98,8 +119,10 @@ class _Profile:
for name, secs in rows:
print(f" keys {secs:5.2f}s {name}")
for name, secs in self.expired:
- print(f" EXPIRED wait {secs:5.2f}s in {name} "
- f"(the check after it may have passed vacuously)")
+ print(
+ f" EXPIRED wait {secs:5.2f}s in {name} "
+ f"(the check after it may have passed vacuously)"
+ )
PROFILE = _Profile()
@@ -113,6 +136,7 @@ def scenario(name):
def deco(fn):
SCENARIOS.append((name, fn))
return fn
+
return deco
@@ -149,8 +173,12 @@ class Ctx:
async def wait(self, pred, t=20.0, step=0.02):
t0 = asyncio.get_event_loop().time()
ok = await wait_for(pred, self.pilot.pause, t, step)
- PROFILE.wait(self.scenario, asyncio.get_event_loop().time() - t0, ok,
- sys._getframe(1).f_lineno)
+ PROFILE.wait(
+ self.scenario,
+ asyncio.get_event_loop().time() - t0,
+ ok,
+ sys._getframe(1).f_lineno,
+ )
return ok
async def press(self, *keys):
@@ -293,8 +321,9 @@ class Ctx:
# _cur.ea to match: re-opening a function we already navigated to (its
# _cur.ea is stale-true) schedules an async re-navigation, and proceeding
# before it lands would leave the cursor parked wherever we last were.
- await self.wait(lambda: self.lst.total > 0
- and self.lst._cursor_ea() == fn.addr, t)
+ await self.wait(
+ lambda: self.lst.total > 0 and self.lst._cursor_ea() == fn.addr, t
+ )
if view == "decomp":
# F5/Tab only decompiles from a focused code pane, and the listing
# may still be settling from the open above — a swallowed Tab used to
@@ -304,8 +333,12 @@ class Ctx:
self.lst.focus()
await self.pause(0.05)
await self.press("tab")
- if await self.wait(lambda: self.app._active == "decomp"
- and self.dec.loaded_ea == fn.addr, max(t / 3, 5)):
+ if await self.wait(
+ lambda: (
+ self.app._active == "decomp" and self.dec.loaded_ea == fn.addr
+ ),
+ max(t / 3, 5),
+ ):
break
return fn
@@ -332,7 +365,8 @@ class Ctx:
# Load is done when the index is complete (robust vs the status line,
# which startup auto-land immediately overwrites with the landed fn).
await self.wait(
- lambda: app._func_index is not None and app._func_index.complete, 60)
+ lambda: app._func_index is not None and app._func_index.complete, 60
+ )
if app._func_index is not None and not app._func_index.complete:
app._func_index.load_all()
@@ -359,7 +393,7 @@ class Ctx:
app._pref = "decomp"
if app._active in ("hex", "graph"):
app._active = "decomp"
- app._graph_sticky = False # else every later scenario rebuilds a graph
+ app._graph_sticky = False # else every later scenario rebuilds a graph
app._split = False
await self.pause(0.02)
@@ -369,15 +403,26 @@ class Ctx:
# --------------------------------------------------------------------------- #
@scenario("startup")
async def s_startup(c: Ctx):
- c.check("function list populated", c.table.row_count > 0, f"rows={c.table.row_count}")
+ c.check(
+ "function list populated", c.table.row_count > 0, f"rows={c.table.row_count}"
+ )
left = c.app.query_one("#left")
- c.check("names pane starts hidden (overlay-first)", not left.display,
- f"display={left.display}")
+ c.check(
+ "names pane starts hidden (overlay-first)",
+ not left.display,
+ f"display={left.display}",
+ )
await c.reveal_pane()
- c.check("function pane width is capped (doesn't eat the screen)",
- left.size.width <= 44, f"width={left.size.width}")
- c.check("function load completed (index complete)",
- c.app._func_index is not None and c.app._func_index.complete, c.status())
+ c.check(
+ "function pane width is capped (doesn't eat the screen)",
+ left.size.width <= 44,
+ f"width={left.size.width}",
+ )
+ c.check(
+ "function load completed (index complete)",
+ c.app._func_index is not None and c.app._func_index.complete,
+ c.status(),
+ )
print(f" {c.table.row_count} functions loaded")
@@ -398,13 +443,18 @@ async def s_auto_land(c: Ctx):
await c.pause(0.2)
if fn is not None:
await c.wait(lambda: app._cur is not None and app._cur.ea == fn.addr, 20)
- c.check("auto-land jumps to the entry function (main) when present",
- app._cur is not None and app._cur.ea == fn.addr,
- f"entry={fn.name}@{fn.addr:#x} cur={app._cur}")
+ c.check(
+ "auto-land jumps to the entry function (main) when present",
+ app._cur is not None and app._cur.ea == fn.addr,
+ f"entry={fn.name}@{fn.addr:#x} cur={app._cur}",
+ )
else:
await c.wait(lambda: isinstance(app.screen, SymbolPalette), 10)
- c.check("auto-land pops the symbol picker when there's no entry fn",
- isinstance(app.screen, SymbolPalette), f"screen={app.screen}")
+ c.check(
+ "auto-land pops the symbol picker when there's no entry fn",
+ isinstance(app.screen, SymbolPalette),
+ f"screen={app.screen}",
+ )
app.pop_screen()
# guard fires once: a second call is a no-op
prev = app._cur
@@ -412,45 +462,392 @@ async def s_auto_land(c: Ctx):
c.check("auto-land is idempotent (guarded)", app._cur is prev)
+@scenario("segment_index")
+async def s_segment_index(c: Ctx):
+ """segment_index must count EXACTLY what streaming the pages produces.
+
+ It exists so the listing can know its row total without fetching every row
+ (1 call and ~0.5s instead of 458 calls and ~1.8s on bash). That is only
+ usable if the number is exact: the total sizes the scrollbar, and the
+ anchors are what a future "seek to row N" would jump through, so being off
+ by a handful of rows means the bar lies and a jump lands in the wrong place.
+
+ Approximating it is the tempting mistake, which is why this compares against
+ the real thing rather than a tolerance.
+ """
+ app = c.app
+ await c.open_biggest("listing")
+ lv = app.query_one(ListingView)
+ model = lv.model
+ if model is None:
+ c.check("listing model exists", False)
+ return
+ await c.wait(lambda: model.complete, 30)
+ if not model.complete:
+ c.check("segment streamed for comparison", False)
+ return
+
+ idx = app.program.client.call(remote_ops.segment_index, addr=hex(model.seg_start))
+ c.check(
+ "segment_index counts exactly what streaming produced",
+ idx.get("rows") == len(model),
+ f"index={idx.get('rows')} streamed={len(model)}",
+ )
+ c.check(
+ "it reports the same segment",
+ int(str(idx.get("addr")), 16) == model.seg_start,
+ f"{idx.get('addr')} vs {model.seg_start:#x}",
+ )
+ anchors = idx.get("anchors") or []
+ c.check(
+ "anchors cover the segment",
+ len(anchors) >= max(1, len(model) // 500),
+ f"{len(anchors)} anchors for {len(model)} rows",
+ )
+
+ # Every anchor must name the address of the row it claims, or seeking to it
+ # would land somewhere else entirely.
+ bad = []
+ for row, ea in anchors:
+ h = model.get(row)
+ if h is None or h.ea != int(str(ea), 16):
+ bad.append((row, ea, hex(h.ea) if h else None))
+ c.check(
+ "every anchor points at the row it claims",
+ not bad,
+ f"{len(bad)} wrong, first={bad[:2]}",
+ )
+
+ # A model built from the index must be INDISTINGUISHABLE from a streamed
+ # one. That is the invariant the whole optimisation rests on: _prime builds
+ # from the index now, and every read path -- rendering, goto, xrefs, search,
+ # rename refresh -- indexes into these arrays assuming they were produced
+ # the old way. Comparing row counts alone would miss a shifted _row_at or a
+ # _by_ea that sends a jump to the wrong line.
+ #
+ # BOTH models are built here, back to back. Comparing against the app's
+ # long-lived model instead is wrong by one row and flaky: earlier scenarios
+ # rename and define things, so that model describes the database as it was
+ # at boot, not as it is now.
+ from idatui.domain import ListingModel # noqa: PLC0415
+
+ args = (app.program, model.seg_start, model.seg_end, model.name)
+ idx_model, streamed = ListingModel(*args), ListingModel(*args)
+ if not idx_model.build_from_index():
+ c.check("build_from_index works", False)
+ return
+ while not streamed.complete:
+ if streamed.load_next_page(text=False) == 0:
+ break
+ c.check("an index-built model is complete immediately", idx_model.complete)
+ c.check(
+ "index-built model has the streamed row count",
+ len(idx_model) == len(streamed),
+ f"{len(idx_model)} vs {len(streamed)}",
+ )
+ for field in (
+ "_row_at",
+ "_head_eas",
+ "_by_ea",
+ "_page_head",
+ "_page_addr",
+ "_page_rows",
+ ):
+ a, b = getattr(idx_model, field), getattr(streamed, field)
+ c.check(
+ f"index-built {field} matches streaming",
+ a == b,
+ f"len {len(a)} vs {len(b)}",
+ )
+ c.check(
+ "index-built rows carry the same ea/kind/size",
+ [(h.ea, h.kind, h.size) for h in idx_model._heads]
+ == [(h.ea, h.kind, h.size) for h in streamed._heads],
+ )
+
+
+@scenario("reprime_is_free")
+async def s_reprime_is_free(c: Ctx):
+ """Switching back to the listing must not rebuild the row index.
+
+ The listing view re-primes every time it is shown, and priming builds the
+ whole index. Building it is ~600-900ms, so doing it again on each Tab out of
+ the decompiler put nearly a second in front of a keystroke -- the listing
+ was still CORRECT, which is why every other test passed, it was just slow.
+
+ Counting backend calls is the only way to see that, so this counts them.
+ """
+ app = c.app
+ await c.open_biggest("listing")
+ lv = app.query_one(ListingView)
+ if lv.model is None:
+ c.check("listing model exists", False)
+ return
+ await c.wait(lambda: lv.model.complete, 30)
+
+ client = app.program.client
+ original = type(client).call
+ seen: list[str] = []
+
+ def counting(self, operation, *a, **kw):
+ # call() takes the remote_ops declaration itself; count by its name.
+ seen.append(getattr(operation, "__name__", str(operation)))
+ return original(self, operation, *a, **kw)
+
+ type(client).call = counting
+ try:
+ for _ in range(3): # decomp and back, three times
+ await c.press("tab")
+ await c.pause(0.05)
+ await c.press("tab")
+ await c.pause(0.05)
+ finally:
+ type(client).call = original
+
+ rebuilds = seen.count("segment_index")
+ c.check(
+ "switching views never rebuilds the segment index",
+ rebuilds == 0,
+ f"segment_index called {rebuilds}x during 3 view switches: {seen}",
+ )
+
+
+@scenario("skeleton_pages")
+async def s_skeleton_pages(c: Ctx):
+ """The background grower loads text-less pages; reading one must fill it in.
+
+ _grow streams the whole segment only to learn how many rows it has, so it
+ asks for skeleton pages (same rows, same addresses, no rendered text) --
+ 3x cheaper and one round trip instead of two. The first read of such a page
+ has to materialise it through the same path a rename uses.
+
+ The failure mode if that path breaks is BLANK ROWS deep in the listing, not
+ an exception, and nothing else in this suite scrolls far enough to see it:
+ _prime renders the first ~1000 rows for real, so a test that only pages down
+ a few screens passes against a completely broken implementation.
+ """
+ app = c.app
+ await c.open_biggest("listing")
+ lv = app.query_one(ListingView)
+ model = lv.model
+ if model is None:
+ c.check("listing model exists", False)
+ return
+ # Let the grower finish so the tail of the segment is definitely skeleton.
+ await c.wait(lambda: model.complete, 30)
+ c.check("the grower completes", model.complete, f"rows={len(model)}")
+ if not model.complete or len(model) < 1200:
+ # A target smaller than _prime's horizon has no skeleton pages at all,
+ # so there is nothing to check rather than something broken.
+ c.check(
+ "segment is big enough to have skeleton pages",
+ True,
+ f"skipped: only {len(model)} rows, _prime renders ~1000",
+ )
+ return
+ c.check("pages were loaded as skeletons", model._skeleton is True)
+
+ # Well past _prime's horizon, and the very last row.
+ deep = max(1200, len(model) - 40)
+ for row in (1200, len(model) // 2, deep):
+ h = model.get(row)
+ c.check(
+ f"row {row} of a skeleton page has real text",
+ h is not None and bool((h.text or "").strip()),
+ f"ea={getattr(h, 'ea', None)} text={getattr(h, 'text', None)!r}",
+ )
+
+ # And through the render path the user actually sees, not just the model.
+ lv.cursor = deep
+ lv.refresh()
+ await c.pause(0.1)
+ painted = lv._line_plain(deep)
+ c.check(
+ "a deep row RENDERS with text",
+ bool(painted and painted.strip()),
+ f"painted={painted!r}",
+ )
+
+ # Materialising must not change the row count or move any address: the
+ # skeleton's structure is what the scrollbar was sized from.
+ before = len(model)
+ model.get(deep)
+ c.check(
+ "materialising a page does not change the row count",
+ len(model) == before,
+ f"{before} -> {len(model)}",
+ )
+ c.check("the walk was not disturbed", not model.stale_structure)
+
+
+@scenario("palette_paging")
+async def s_palette_paging(c: Ctx):
+ """PgUp/PgDn move the palette list by a viewport, with the Input focused.
+
+ The palettes focus their filter box, not the list, so the OptionList's own
+ pageup/pagedown bindings never fire -- OptionListNav forwards them. That
+ forwarding is the thing under test; if it regresses, these keys silently do
+ nothing (the failure mode is a no-op, not an error).
+ """
+ app = c.app
+ await c.press("ctrl+n")
+ if not await c.wait(lambda: isinstance(app.screen, SymbolPalette), 10):
+ c.check("palette opens for the paging test", False)
+ return
+ pal = app.screen
+ ol = pal.query_one(OptionList)
+ inp = pal.query_one(Input)
+ await c.wait(lambda: ol.option_count > 5, 10)
+
+ # Paging is GEOMETRY: the widget moves by scrollable_content_region.height,
+ # which is 0 until a frame has been laid out. Without this wait every check
+ # below would pass vacuously against a zero-height page.
+ await c.wait(lambda: ol.scrollable_content_region.height >= 1, 10)
+ page = ol.scrollable_content_region.height
+ c.check(
+ "the palette list has a real viewport to page by", page >= 1, f"height={page}"
+ )
+ if ol.option_count <= 2:
+ c.check("enough symbols to page through", False, f"n={ol.option_count}")
+ return
+
+ c.check(
+ "the filter Input holds focus (so the list never sees the key)",
+ pal.focused is inp,
+ f"focused={type(pal.focused).__name__}",
+ )
+
+ ol.highlighted = 0
+ await c.press("pagedown")
+ down = ol.highlighted or 0
+ # A page, not a line: the bug this guards against is PgDn falling through to
+ # the Input and moving nothing, or degrading to a single-step cursor move.
+ c.check(
+ "PgDn moves the symbol list by more than one row",
+ down > 1,
+ f"highlighted={down} page={page} n={ol.option_count}",
+ )
+ c.check(
+ "PgDn moves by about a viewport (or lands on the last row)",
+ down >= min(page, ol.option_count - 1) - 1,
+ f"highlighted={down} page={page} n={ol.option_count}",
+ )
+
+ await c.press("pageup")
+ c.check(
+ "PgUp comes back to the top",
+ (ol.highlighted or 0) == 0,
+ f"highlighted={ol.highlighted}",
+ )
+
+ # Clamping: hammering past the end must settle on the last row, not wrap or
+ # raise. 12 pages clears any list this palette will show.
+ for _ in range(12):
+ await c.press("pagedown")
+ c.check(
+ "PgDn clamps at the last row",
+ ol.highlighted == ol.option_count - 1,
+ f"highlighted={ol.highlighted} n={ol.option_count}",
+ )
+ for _ in range(12):
+ await c.press("pageup")
+ c.check(
+ "PgUp clamps at the first row",
+ ol.highlighted == 0,
+ f"highlighted={ol.highlighted}",
+ )
+
+ await c.press("escape")
+ await c.wait(lambda: not isinstance(app.screen, SymbolPalette), 10)
+
+
+@scenario("xrefs_paging")
+async def s_xrefs_paging(c: Ctx):
+ """The xrefs popup focuses its list, so paging is Textual's own.
+
+ A regression guard for the other half of the split: OptionListNav must not
+ be needed here, and must not double-move if someone adds it later.
+ """
+ app = c.app
+ await c.open_biggest("listing")
+ await c.press("x")
+ if not await c.wait(lambda: isinstance(app.screen, XrefsScreen), 10):
+ c.check(
+ "xrefs popup opens for the paging test",
+ True,
+ "skipped: no xrefs at this cursor",
+ )
+ return
+ scr = app.screen
+ ol = scr.query_one(OptionList)
+ await c.wait(lambda: ol.scrollable_content_region.height >= 1, 10)
+ c.check(
+ "the xrefs list itself has focus",
+ scr.focused is ol,
+ f"focused={type(scr.focused).__name__}",
+ )
+ if ol.option_count > 2:
+ ol.highlighted = 0
+ await c.press("pagedown")
+ c.check(
+ "PgDn pages the xrefs list natively",
+ (ol.highlighted or 0) > 1,
+ f"highlighted={ol.highlighted} n={ol.option_count}",
+ )
+ await c.press("escape")
+ await c.wait(lambda: not isinstance(app.screen, XrefsScreen), 10)
+
+
@scenario("palette")
async def s_palette(c: Ctx):
app, pilot = c.app, c.pilot
await c.press("ctrl+n")
pal_open = await c.wait(lambda: isinstance(app.screen, SymbolPalette), 10)
- c.check("Ctrl+N opens the symbol palette", pal_open,
- f"screen={type(app.screen).__name__}")
+ c.check(
+ "Ctrl+N opens the symbol palette",
+ pal_open,
+ f"screen={type(app.screen).__name__}",
+ )
if not pal_open:
return
pal = app.screen
pinp = pal.query_one(Input)
pinp.value = "main"
await c.wait(lambda: pal._results and pal._results[0][2] == "main", 10)
- c.check("palette fuzzy-finds (top result matches the query)",
- bool(pal._results) and pal._results[0][2] == "main",
- f"top={pal._results[0][2] if pal._results else None}")
+ c.check(
+ "palette fuzzy-finds (top result matches the query)",
+ bool(pal._results) and pal._results[0][2] == "main",
+ f"top={pal._results[0][2] if pal._results else None}",
+ )
pinp.value = "eror" # scattered subsequence of 'error'
await c.wait(lambda: any(n == "error" for _, _, n in pal._results), 10)
- c.check("palette matches a fuzzy subsequence",
- any(n == "error" for _, _, n in pal._results),
- f"results={[n for _, _, n in pal._results[:4]]}")
+ c.check(
+ "palette matches a fuzzy subsequence",
+ any(n == "error" for _, _, n in pal._results),
+ f"results={[n for _, _, n in pal._results[:4]]}",
+ )
# Every other query here is lowercase, which is how a case bug hid for so
# long: the name was lowered but the query wasn't, so ONE capital matched
# nothing. Invisible on lowercase C symbols, fatal on a library that
# capitalises (PEM_read_bio found 0 of 10093 functions in libcrypto).
pinp.value = "MAIN"
await c.wait(lambda: any(n == "main" for _, _, n in pal._results), 10)
- c.check("palette matching is case-insensitive in BOTH directions",
- any(n == "main" for _, _, n in pal._results),
- f"results={[n for _, _, n in pal._results[:4]]}")
+ c.check(
+ "palette matching is case-insensitive in BOTH directions",
+ any(n == "main" for _, _, n in pal._results),
+ f"results={[n for _, _, n in pal._results[:4]]}",
+ )
pinp.value = "main"
await c.wait(lambda: pal._results and pal._results[0][2] == "main", 10)
want = pal._results[0][1]
await c.press("enter")
await c.wait(lambda: not isinstance(app.screen, SymbolPalette), 10)
await c.wait(lambda: app._cur and app._cur.ea == want, 20)
- c.check("selecting a palette entry opens that function",
- bool(app._cur) and app._cur.ea == want,
- f"cur={app._cur.ea if app._cur else None}")
+ c.check(
+ "selecting a palette entry opens that function",
+ bool(app._cur) and app._cur.ea == want,
+ f"cur={app._cur.ea if app._cur else None}",
+ )
# Re-open the function we are ALREADY standing on. That used to append an
# identical nav entry, and the extra Esc it bought popped the stack without
# changing anything on screen — a dead keypress, which is precisely what
@@ -464,8 +861,11 @@ async def s_palette(c: Ctx):
await c.press("enter")
await c.wait(lambda: not isinstance(app.screen, SymbolPalette), 10)
await c.pause(0.4)
- c.check("re-opening the current function doesn't stack a duplicate",
- len(app._nav) == depth, f"nav {depth} -> {len(app._nav)}")
+ c.check(
+ "re-opening the current function doesn't stack a duplicate",
+ len(app._nav) == depth,
+ f"nav {depth} -> {len(app._nav)}",
+ )
await c.press("ctrl+n")
await c.wait(lambda: isinstance(app.screen, SymbolPalette), 10)
await c.press("escape")
@@ -478,43 +878,57 @@ async def s_load_options(c: Ctx):
"""The dialog must never appear for a file IDA can load itself — the whole
suite runs on an ELF, so a false positive here would block every run."""
from idatui.app import LoadOptionsScreen
+
app = c.app
- c.check("no load dialog for a recognised binary",
- not isinstance(app.screen, LoadOptionsScreen),
- f"screen={type(app.screen).__name__}")
- c.check("and the app agrees it shouldn't ask",
- not app._should_ask_load_options())
+ c.check(
+ "no load dialog for a recognised binary",
+ not isinstance(app.screen, LoadOptionsScreen),
+ f"screen={type(app.screen).__name__}",
+ )
+ c.check("and the app agrees it shouldn't ask", not app._should_ask_load_options())
# The dialog itself, driven directly: it has to come back with switches the
# worker can use, and -b has to be paragraphs.
from idatui.formats import load_args, needs_load_options, sniff
- c.check("the running target sniffs as a real format",
- sniff(app._open_path) is not None and not needs_load_options(app._open_path),
- f"{sniff(app._open_path)}")
- c.check("dialog output converts a base to paragraphs",
- load_args("arm", 0x8000000) == "-parm -b800000")
+
+ c.check(
+ "the running target sniffs as a real format",
+ sniff(app._open_path) is not None and not needs_load_options(app._open_path),
+ f"{sniff(app._open_path)}",
+ )
+ c.check(
+ "dialog output converts a base to paragraphs",
+ load_args("arm", 0x8000000) == "-parm -b800000",
+ )
# Tab is a PRIORITY app binding (disasm<->pseudocode), so it fired even with
# a modal up and nothing in a dialog could be tabbed to. That is why the load
# dialog's address field was unreachable — and it was broken in every other
# modal too.
from idatui.app import LoadOptionsScreen
+
app.push_screen(LoadOptionsScreen("/tmp/probe.bin", 1234))
await c.wait(lambda: isinstance(app.screen, LoadOptionsScreen), 10)
sc = app.screen
first = app.focused
await c.press("tab")
await c.pause(0.2)
- c.check("Tab moves focus inside a modal instead of toggling the view",
- app.focused is not first and isinstance(app.screen, LoadOptionsScreen),
- f"focus={getattr(app.focused, 'id', None)}")
- c.check("Tab in the load dialog lands on the address field",
- getattr(app.focused, "id", None) == "load-base",
- f"focus={getattr(app.focused, 'id', None)}")
+ c.check(
+ "Tab moves focus inside a modal instead of toggling the view",
+ app.focused is not first and isinstance(app.screen, LoadOptionsScreen),
+ f"focus={getattr(app.focused, 'id', None)}",
+ )
+ c.check(
+ "Tab in the load dialog lands on the address field",
+ getattr(app.focused, "id", None) == "load-base",
+ f"focus={getattr(app.focused, 'id', None)}",
+ )
await c.press("tab")
await c.pause(0.2)
- c.check("Tab again returns to the processor filter",
- getattr(app.focused, "id", None) == "pal-input",
- f"focus={getattr(app.focused, 'id', None)}")
+ c.check(
+ "Tab again returns to the processor filter",
+ getattr(app.focused, "id", None) == "pal-input",
+ f"focus={getattr(app.focused, 'id', None)}",
+ )
await c.press("escape")
await c.wait(lambda: not isinstance(app.screen, LoadOptionsScreen), 10)
@@ -529,6 +943,7 @@ async def s_asm_highlight(c: Ctx):
with the plain text, carried on the Head, and mapped to a style.
"""
from idatui.app import _S_SPAN
+
app = c.app
await c.open_biggest("listing")
lst = c.lst
@@ -540,30 +955,40 @@ async def s_asm_highlight(c: Ctx):
rows = [lst.model.get(i) for i in range(min(lst.model.loaded(), 400))]
rows = [h for h in rows if h is not None]
code = [h for h in rows if h.kind == "code"]
- c.check("code rows carry IDA's token spans",
- code and sum(1 for h in code if h.spans) > len(code) * 0.9,
- f"{sum(1 for h in code if h.spans)}/{len(code)} have spans")
+ c.check(
+ "code rows carry IDA's token spans",
+ code and sum(1 for h in code if h.spans) > len(code) * 0.9,
+ f"{sum(1 for h in code if h.spans)}/{len(code)} have spans",
+ )
kinds = {k for h in rows for k, _ in (h.spans or ())}
# If a tag isn't mapped it renders as body text and nothing says why, so the
# ones that carry real meaning are worth asserting explicitly.
for want in ("insn", "reg", "punct"):
c.check(f"the palette sees {want} tokens", want in kinds, f"{sorted(kinds)}")
- c.check("every span kind has a style",
- all(k in _S_SPAN for k in kinds), f"unstyled: {sorted(kinds - set(_S_SPAN))}")
+ c.check(
+ "every span kind has a style",
+ all(k in _S_SPAN for k in kinds),
+ f"unstyled: {sorted(kinds - set(_S_SPAN))}",
+ )
# Spans must describe the SAME text the row shows, or the row renders
# different characters than search/width calculations think it has.
- bad = [h for h in rows if h.spans
- and "".join(t for _k, t in h.spans) != h.text]
- c.check("spans reconstruct the row text exactly", not bad,
- f"{[(hex(h.ea), h.text) for h in bad[:2]]}")
+ bad = [h for h in rows if h.spans and "".join(t for _k, t in h.spans) != h.text]
+ c.check(
+ "spans reconstruct the row text exactly",
+ not bad,
+ f"{[(hex(h.ea), h.text) for h in bad[:2]]}",
+ )
# And the mnemonic must be the loudest thing on the line (the column you
# scan), not just any styled token.
mn = next((h for h in code if h.spans and h.spans[0][0] == "insn"), None)
- c.check("the mnemonic is the first span",
- mn is not None, f"{code[0].spans if code else None}")
+ c.check(
+ "the mnemonic is the first span",
+ mn is not None,
+ f"{code[0].spans if code else None}",
+ )
@scenario("status_names_the_file")
@@ -575,26 +1000,30 @@ async def s_status_names_the_file(c: Ctx):
it belongs to.
"""
from textual.widgets import Static
+
app = c.app
await c.open_biggest("listing")
await c.pause(0.3)
name = os.path.basename(app._open_path)
status = str(app.query_one("#status", Static).render())
- c.check("the status bar names the open file",
- status.startswith(f"[{name}]"), f"{status[:60]!r} (want [{name}])")
+ c.check(
+ "the status bar names the open file",
+ status.startswith(f"[{name}]"),
+ f"{status[:60]!r} (want [{name}])",
+ )
# It must survive the messages that WRITE the status, not just the idle one.
c.lst.focus()
await c.press("down")
await c.pause(0.3)
status = str(app.query_one("#status", Static).render())
- c.check("and keeps naming it as you move",
- status.startswith(f"[{name}]"), status[:60])
+ c.check(
+ "and keeps naming it as you move", status.startswith(f"[{name}]"), status[:60]
+ )
# The function-count message used to include the module name itself, which
# would now read "[echo] echo — 128 functions".
- c.check("without saying the name twice",
- status.count(name) == 1, status[:70])
+ c.check("without saying the name twice", status.count(name) == 1, status[:70])
@scenario("command_palette")
@@ -602,10 +1031,12 @@ async def s_command_palette(c: Ctx):
app = c.app
await c.open_biggest("listing")
await c.press("ctrl+p")
- opened = await c.wait(
- lambda: type(app.screen).__name__ == "CommandPalette", 10)
- c.check("Ctrl+P opens the command palette", opened,
- f"screen={type(app.screen).__name__}")
+ opened = await c.wait(lambda: type(app.screen).__name__ == "CommandPalette", 10)
+ c.check(
+ "Ctrl+P opens the command palette",
+ opened,
+ f"screen={type(app.screen).__name__}",
+ )
if not opened:
return
inp = app.screen.query_one(Input)
@@ -613,8 +1044,9 @@ async def s_command_palette(c: Ctx):
await c.pause(0.5) # let the async search + option list settle
await c.press("enter")
landed = await c.wait(lambda: app._active == "hex", 10)
- c.check("a palette command executes (Hex view opens)", landed,
- f"active={app._active}")
+ c.check(
+ "a palette command executes (Hex view opens)", landed, f"active={app._active}"
+ )
if landed:
await c.press("backslash") # leave hex
await c.wait(lambda: app._active != "hex", 5)
@@ -623,21 +1055,32 @@ async def s_command_palette(c: Ctx):
@scenario("quit_guard")
async def s_quit_guard(c: Ctx):
app = c.app
- c.check("a clean database reports nothing unsaved", app._dirty_labels() == [],
- f"{app._dirty_labels()}")
+ c.check(
+ "a clean database reports nothing unsaved",
+ app._dirty_labels() == [],
+ f"{app._dirty_labels()}",
+ )
app._dirty = True # as an edit would
- c.check("an edited database is reported unsaved",
- len(app._dirty_labels()) == 1, f"{app._dirty_labels()}")
+ c.check(
+ "an edited database is reported unsaved",
+ len(app._dirty_labels()) == 1,
+ f"{app._dirty_labels()}",
+ )
await c.press("q")
asked = await c.wait(lambda: isinstance(app.screen, QuitScreen), 10)
- c.check("quitting with unsaved changes asks first", asked and app.is_running,
- f"screen={type(app.screen).__name__} running={app.is_running}")
+ c.check(
+ "quitting with unsaved changes asks first",
+ asked and app.is_running,
+ f"screen={type(app.screen).__name__} running={app.is_running}",
+ )
if not asked:
return
await c.press("escape")
await c.wait(lambda: not isinstance(app.screen, QuitScreen), 10)
- c.check("Esc cancels the quit and stays put",
- app.is_running and not isinstance(app.screen, QuitScreen))
+ c.check(
+ "Esc cancels the quit and stays put",
+ app.is_running and not isinstance(app.screen, QuitScreen),
+ )
# leave it clean so the rest of the suite (and teardown) isn't affected
app._dirty = False
app._save_on_exit = False
@@ -647,13 +1090,16 @@ async def s_quit_guard(c: Ctx):
async def s_help(c: Ctx):
app = c.app
st = app.query_one("#status", Static)
- c.check("the status line owns the bottom row (no footer cheatsheet)",
- st.region.y + st.region.height == app.size.height,
- f"status={st.region} screen={app.size}")
+ c.check(
+ "the status line owns the bottom row (no footer cheatsheet)",
+ st.region.y + st.region.height == app.size.height,
+ f"status={st.region} screen={app.size}",
+ )
await c.press("f1")
opened = await c.wait(lambda: isinstance(app.screen, HelpScreen), 10)
- c.check("F1 opens the key cheatsheet", opened,
- f"screen={type(app.screen).__name__}")
+ c.check(
+ "F1 opens the key cheatsheet", opened, f"screen={type(app.screen).__name__}"
+ )
if not opened:
return
cards = app.screen.query(".help-card")
@@ -661,16 +1107,26 @@ async def s_help(c: Ctx):
txt = " ".join(str(w.render()) for w in cards)
# Derived from _HELP, not hardcoded: adding a group is a normal change and
# shouldn't fail a test that only meant 'every group is rendered'.
- c.check("each key group gets its own card",
- titles == {t for t, _ in _HELP}, f"{titles}")
- c.check("it documents real bindings",
- "set type" in txt and "split view" in txt and "cross-references" in txt)
- c.check("the graph keys are documented",
- "control-flow graph" in txt and "minimap" in txt)
+ c.check(
+ "each key group gets its own card", titles == {t for t, _ in _HELP}, f"{titles}"
+ )
+ c.check(
+ "it documents real bindings",
+ "set type" in txt
+ and "split view" in txt
+ and "cross-references" in txt
+ and "refresh the current view" in txt,
+ )
+ c.check(
+ "the graph keys are documented",
+ "control-flow graph" in txt and "minimap" in txt,
+ )
body = app.screen.query_one("#help-body")
- c.check("the cards fit without a scrollbar at a normal size",
- body.virtual_size.height <= body.size.height,
- f"content={body.virtual_size.height} view={body.size.height}")
+ c.check(
+ "the cards fit without a scrollbar at a normal size",
+ body.virtual_size.height <= body.size.height,
+ f"content={body.virtual_size.height} view={body.size.height}",
+ )
await c.press("escape")
await c.wait(lambda: not isinstance(app.screen, HelpScreen), 10)
c.check("Esc closes it", not isinstance(app.screen, HelpScreen))
@@ -678,8 +1134,9 @@ async def s_help(c: Ctx):
# cheatsheet must not be reachable ONLY through F1.
await c.press("H")
opened_h = await c.wait(lambda: isinstance(app.screen, HelpScreen), 10)
- c.check("H opens the cheatsheet too", opened_h,
- f"screen={type(app.screen).__name__}")
+ c.check(
+ "H opens the cheatsheet too", opened_h, f"screen={type(app.screen).__name__}"
+ )
if opened_h:
await c.press("H")
await c.wait(lambda: not isinstance(app.screen, HelpScreen), 10)
@@ -691,39 +1148,54 @@ async def s_strings(c: Ctx):
app = c.app
await c.open_biggest("listing")
items = app.program.strings()
- c.check("program.strings() lists the binary's literals", len(items) > 3,
- f"n={len(items)}")
+ c.check(
+ "program.strings() lists the binary's literals",
+ len(items) > 3,
+ f"n={len(items)}",
+ )
if not items:
return
- c.check("strings carry addr/text/length",
- all(s.addr > 0 and s.text and s.length > 0 for s in items[:5]),
- f"first={items[0]}")
+ c.check(
+ "strings carry addr/text/length",
+ all(s.addr > 0 and s.text and s.length > 0 for s in items[:5]),
+ f"first={items[0]}",
+ )
await c.press("quotation_mark")
opened = await c.wait(lambda: isinstance(app.screen, StringsPalette), 25)
- c.check('\'"\' opens the strings browser', opened,
- f"screen={type(app.screen).__name__}")
+ c.check(
+ "'\"' opens the strings browser", opened, f"screen={type(app.screen).__name__}"
+ )
if not opened:
return
pal = app.screen
- c.check("the browser lists strings", len(pal._results) > 0,
- f"results={len(pal._results)}")
+ c.check(
+ "the browser lists strings",
+ len(pal._results) > 0,
+ f"results={len(pal._results)}",
+ )
# filter on a fragment of a real (unescaped) literal
- target = next((s for s in items
- if len(s.text) >= 6 and _str_display(s.text) == s.text), None)
+ target = next(
+ (s for s in items if len(s.text) >= 6 and _str_display(s.text) == s.text), None
+ )
if target is not None:
frag = target.text[:6]
pal.query_one(Input).value = frag
await c.pause(0.2)
- ok = (pal._results
- and all(frag.lower() in t.lower() for _, _, t in pal._results))
- c.check("filtering narrows to matching strings", bool(ok),
- f"frag={frag!r} n={len(pal._results)}")
+ ok = pal._results and all(frag.lower() in t.lower() for _, _, t in pal._results)
+ c.check(
+ "filtering narrows to matching strings",
+ bool(ok),
+ f"frag={frag!r} n={len(pal._results)}",
+ )
want = pal._results[0][1]
await c.press("enter")
await c.wait(lambda: not isinstance(app.screen, StringsPalette), 10)
landed = await c.wait(lambda: c.lst._cursor_ea() == want, 20)
- c.check("Enter jumps to the string in the unified listing", landed,
- f"cursor={c.lst._cursor_ea()} want={want:#x}")
+ c.check(
+ "Enter jumps to the string in the unified listing",
+ landed,
+ f"cursor={c.lst._cursor_ea()} want={want:#x}",
+ )
else:
await c.press("escape")
@@ -742,31 +1214,42 @@ async def s_view_modes_all_handled(c: Ctx):
the next mode that forgets to appear somewhere.
"""
from idatui.app import ViewMode
+
app = c.app
fn = await c.open_biggest("listing")
try:
for mode in ViewMode:
app._active = mode
- app._show_active() # must not raise for any member
+ app._show_active() # must not raise for any member
await c.pause(0.05)
view = app._active_code_view()
if mode in ViewMode.code_modes():
- c.check(f"{mode.value}: _active_code_view resolves a widget",
- view is not None, f"{mode.value} -> None")
- c.check(f"{mode.value}: exactly one predicate is true",
- sum((app.is_listing, app.is_decomp,
- app.is_hex, app.is_graph)) == 1,
- f"{mode.value}: listing={app.is_listing} "
- f"decomp={app.is_decomp} hex={app.is_hex} graph={app.is_graph}")
- c.check(f"{mode.value}: in_code agrees with code_modes()",
- app.in_code == (mode in ViewMode.code_modes()),
- f"in_code={app.in_code} for {mode.value}")
- c.check("every mode is a plain string over the wire",
- all(isinstance(m, str) and m == m.value for m in ViewMode),
- str([repr(m) for m in ViewMode]))
- c.check("'disasm' is not a mode any more",
- "disasm" not in {m.value for m in ViewMode},
- str([m.value for m in ViewMode]))
+ c.check(
+ f"{mode.value}: _active_code_view resolves a widget",
+ view is not None,
+ f"{mode.value} -> None",
+ )
+ c.check(
+ f"{mode.value}: exactly one predicate is true",
+ sum((app.is_listing, app.is_decomp, app.is_hex, app.is_graph)) == 1,
+ f"{mode.value}: listing={app.is_listing} "
+ f"decomp={app.is_decomp} hex={app.is_hex} graph={app.is_graph}",
+ )
+ c.check(
+ f"{mode.value}: in_code agrees with code_modes()",
+ app.in_code == (mode in ViewMode.code_modes()),
+ f"in_code={app.in_code} for {mode.value}",
+ )
+ c.check(
+ "every mode is a plain string over the wire",
+ all(isinstance(m, str) and m == m.value for m in ViewMode),
+ str([repr(m) for m in ViewMode]),
+ )
+ c.check(
+ "'disasm' is not a mode any more",
+ "disasm" not in {m.value for m in ViewMode},
+ str([m.value for m in ViewMode]),
+ )
finally:
# Restore through a real navigation, not by poking _active back.
# _show_active() tears down split state and re-points the panes as a
@@ -777,6 +1260,177 @@ async def s_view_modes_all_handled(c: Ctx):
await c.open(fn.addr, "listing")
+@scenario("refresh_view")
+async def s_refresh_view(c: Ctx):
+ """Ctrl+R replaces stale backing data without moving the listing."""
+ fn = await c.open_biggest("listing")
+ await c.press("down", "down", "down")
+ lst = c.lst
+ old_model = lst.model
+ old_ea = lst._cursor_ea()
+ old_top = round(lst.scroll_offset.y)
+ old_head = old_model.get(old_top) if old_model is not None else None
+ old_top_ea = getattr(old_head, "ea", None)
+
+ await c.press("ctrl+r")
+ landed = await c.wait(
+ lambda: lst.model is not old_model and lst._cursor_ea() == old_ea, 25
+ )
+ c.check("Ctrl+R rebuilds the listing model", lst.model is not old_model)
+ c.check(
+ "Ctrl+R preserves the cursor address",
+ landed,
+ f"got={lst._cursor_ea()} want={old_ea}",
+ )
+ new_top = round(lst.scroll_offset.y)
+ new_head = lst.model.get(new_top) if lst.model is not None else None
+ c.check(
+ "Ctrl+R preserves the viewport by address",
+ getattr(new_head, "ea", None) == old_top_ea,
+ f"got={getattr(new_head, 'ea', None)} want={old_top_ea}",
+ )
+
+ await c.open(fn.addr, "decomp")
+ dec = c.dec
+ dec.cursor = min(3, max(len(dec._texts) - 1, 0))
+ old_dec_cursor = dec.cursor
+ await c.press("ctrl+r")
+ refreshed = await c.wait(
+ lambda: c.app.is_decomp and dec.loaded_ea == fn.addr and not dec.loading, 25
+ )
+ c.check(
+ "Ctrl+R reloads pseudocode without changing views",
+ refreshed,
+ f"active={c.app._active} loaded={dec.loaded_ea} want={fn.addr:#x}",
+ )
+ c.check(
+ "Ctrl+R preserves the pseudocode cursor",
+ dec.cursor == old_dec_cursor,
+ f"got={dec.cursor} want={old_dec_cursor}",
+ )
+
+
+@scenario("idb_event_refresh")
+async def s_idb_event_refresh(c: Ctx):
+ """An external edit burst refreshes the UI in place (event -> refresh).
+
+ The listener thread and its debounce are unit-tested in
+ test_nexus_client.py; this covers the app half it hands the batch to:
+ _refresh_idb_events must invalidate, reindex, and reload the active
+ surface without moving the cursor. The edit is made straight through the
+ client so no UI cache hears about it -- exactly what another client's
+ rename looks like from this process.
+ """
+ app, lst = c.app, c.lst
+ fn = await c.open_biggest("listing")
+ await c.press("down", "down", "down") # mid-viewport: anchor is not degenerate
+ old_model = lst.model
+ old_ea = lst._cursor_ea()
+ old_top = round(lst.scroll_offset.y)
+ old_head = old_model.get(old_top) if old_model is not None else None
+ old_top_ea = getattr(old_head, "ea", None)
+
+ newname = f"ext_{os.getpid()}"
+ rr = app.program.client.call(
+ remote_ops.rename, batch={"func": {"addr": hex(fn.addr), "name": newname}}
+ )
+ c.check(
+ "out-of-band rename applied",
+ rr.get("summary", {}).get("ok", 0) == 1,
+ str(rr.get("summary")),
+ )
+
+ event = {"kind": "renamed", "ea": hex(fn.addr), "origin_id": "another-client"}
+ app._refresh_idb_events(app.client, (event,))
+ c.check(
+ "the status line says why the view is about to move",
+ "external database change" in c.status(),
+ c.status(),
+ )
+ landed = await c.wait(
+ lambda: (
+ lst.model is not old_model
+ and lst._cursor_ea() == old_ea
+ and app._cur is not None
+ and app._cur.name == newname
+ ),
+ 25,
+ )
+ c.check(
+ "the event batch rebuilds the listing around the same cursor",
+ landed,
+ f"got={lst._cursor_ea()} want={old_ea} name={getattr(app._cur, 'name', None)}",
+ )
+ new_top = round(lst.scroll_offset.y)
+ new_head = lst.model.get(new_top) if lst.model is not None else None
+ c.check(
+ "the viewport is preserved by address",
+ getattr(new_head, "ea", None) == old_top_ea,
+ f"got={getattr(new_head, 'ea', None)} want={old_top_ea}",
+ )
+ await c.wait(
+ lambda: (
+ app._func_index.by_addr(fn.addr) is not None
+ and app._func_index.by_addr(fn.addr).name == newname
+ ),
+ 25,
+ )
+ c.check(
+ "the function index was rebuilt with the external name",
+ app._func_index.by_addr(fn.addr).name == newname,
+ getattr(app._func_index.by_addr(fn.addr), "name", None),
+ )
+
+ # The decompiler half: pseudocode opened now must carry the new name (the
+ # refresh bumped the name generation), and a second external batch landing
+ # while decomp is active must reload it in place -- which also reverts the
+ # rename, so the scenario is idempotent.
+ await c.open(fn.addr, "decomp")
+ dec = c.dec
+ c.check(
+ "pseudocode decompiled after the event shows the external name",
+ bool(dec._texts) and newname in dec._texts[0],
+ dec._texts[0] if dec._texts else "(empty)",
+ )
+ rr = app.program.client.call(
+ remote_ops.rename, batch={"func": {"addr": hex(fn.addr), "name": fn.name}}
+ )
+ c.check(
+ "rename reverted out of band",
+ rr.get("summary", {}).get("ok", 0) == 1,
+ str(rr.get("summary")),
+ )
+ app._refresh_idb_events(app.client, (event,))
+ reverted = await c.wait(
+ lambda: (
+ app.is_decomp
+ and dec.loaded_ea == fn.addr
+ and not dec.loading
+ and bool(dec._texts)
+ and fn.name in dec._texts[0]
+ ),
+ 25,
+ )
+ c.check(
+ "a batch landing in decomp view reloads the pseudocode in place",
+ reverted,
+ f"active={app._active} loaded={dec.loaded_ea} "
+ f"row0={dec._texts[0] if dec._texts else '(empty)'}",
+ )
+ await c.wait(
+ lambda: (
+ app._func_index.by_addr(fn.addr) is not None
+ and app._func_index.by_addr(fn.addr).name == fn.name
+ ),
+ 25,
+ )
+ c.check(
+ "the index is back to the original name (idempotent)",
+ app._func_index.by_addr(fn.addr).name == fn.name,
+ getattr(app._func_index.by_addr(fn.addr), "name", None),
+ )
+
+
@scenario("split_view")
async def s_split_view(c: Ctx):
app, lst, dec = c.app, c.lst, c.dec
@@ -790,63 +1444,86 @@ async def s_split_view(c: Ctx):
# to count. The bound is loose because the bug was three orders of magnitude
# out, not a near miss.
_lookups = {"n": 0}
- _orig_call = c.prog.client.invoke
+ _orig_call = c.prog.client.call
- def _counting(name, *a, **kw):
- if name == "lookup_funcs":
+ def _counting(operation, *a, **kw):
+ # call() takes the remote_ops declaration itself; match by its name.
+ if getattr(operation, "__name__", "") == "lookup_funcs":
_lookups["n"] += 1
- return _orig_call(name, *a, **kw)
+ return _orig_call(operation, *a, **kw)
- c.prog.client.invoke = _counting
+ c.prog.client.call = _counting
try:
await _split_view_body(c, app, lst, dec)
finally:
- c.prog.client.invoke = _orig_call
- c.check("split view doesn't storm the worker with function lookups",
- _lookups["n"] < 500, f"{_lookups['n']} lookup_funcs calls")
+ c.prog.client.call = _orig_call
+ c.check(
+ "split view doesn't storm the worker with function lookups",
+ _lookups["n"] < 500,
+ f"{_lookups['n']} lookup_funcs calls",
+ )
async def _split_view_body(c: Ctx, app, lst, dec):
await c.open_biggest("listing")
await c.press("s")
shown = await c.wait(lambda: app._split and lst.display and dec.display, 20)
- c.check("'s' enters split view (both panes shown)", shown,
- f"split={app._split} lst={lst.display} dec={dec.display}")
+ c.check(
+ "'s' enters split view (both panes shown)",
+ shown,
+ f"split={app._split} lst={lst.display} dec={dec.display}",
+ )
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}")
+ c.check(
+ "split loads the pseudocode alongside the listing",
+ loaded,
+ f"loaded={dec.loaded_ea} cur={app._cur.ea if app._cur else None}",
+ )
await c.wait(lambda: "[split" in c.status(), 5)
- c.check("split view shows a split-aware status", "[split" in c.status(),
- f"status={c.status()!r}")
+ c.check(
+ "split view shows a split-aware status",
+ "[split" in c.status(),
+ f"status={c.status()!r}",
+ )
# 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)}")
+ 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.focus() # the decomp must BE the driver for a decomp-driven
+ dec.focus() # the decomp must BE the driver for a decomp-driven
app._active = "decomp" # sync (else its align() re-syncs listing-driven)
dec.cursor = multi
dec._scroll_cursor_into_view() # key-nav always does; the anchor needs it
await c.pause(0.1)
app._sync_split("decomp")
await c.pause(0.1)
- 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]}")
+ 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]}",
+ )
lst.focus()
app._active = "listing"
await c.pause(0.05)
else:
- c.check("a multi-instruction C line bands a region (>1 listing row)",
- True, "no multi-instruction line in this function (skipped)")
+ 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):
@@ -854,48 +1531,60 @@ async def _split_view_body(c: Ctx, app, lst, dec):
await c.pause(0.2)
lea = lst._cursor_ea()
dl = dec._link_line
- c.check("listing cursor links the covering pseudocode line",
- dl is not None and lea is not None and dec._line_eas[dl] is not None
- and dec._line_eas[dl] <= lea,
- f"link_line={dl} lea={hex(lea) if lea else None}")
+ c.check(
+ "listing cursor links the covering pseudocode line",
+ dl is not None
+ and lea is not None
+ and dec._line_eas[dl] is not None
+ and dec._line_eas[dl] <= lea,
+ f"link_line={dl} lea={hex(lea) if lea else None}",
+ )
# the companion pane sits LEVEL with the driver's cursor (visual coherence):
# the linked row lands at the same viewport offset, not merely on-screen.
deep = [i for i, eas in enumerate(m) if eas][10:]
row = lst.model.ensure_ea(m[deep[0]][0]) if (deep and lst.model) else None
if row is not None and row > 12:
lst.scroll_to(y=row - 10, animate=False)
- await c.pause(0.2) # let the deferred scroll land
- lst.cursor = row # driver cursor now at viewport offset 10
+ await c.pause(0.2) # let the deferred scroll land
+ lst.cursor = row # driver cursor now at viewport offset 10
app._sync_split("listing")
- await c.pause(0.2) # let the companion's scroll land
+ await c.pause(0.2) # let the companion's scroll land
drv = lst.cursor - round(lst.scroll_offset.y)
link, top = dec._link_line, round(dec.scroll_offset.y)
# exact, modulo the unavoidable clamps (can't scroll above line 0, nor
# past the end when the pseudocode is shorter than the viewport)
- want = min(max(0, (link or 0) - drv),
- max(0, dec.total - dec._visible_height()))
- c.check("the companion pane sits level with the driver's cursor",
- link is not None and top == want,
- f"driver_row={drv} link={link} dec_top={top} want={want}")
+ want = min(max(0, (link or 0) - drv), max(0, dec.total - dec._visible_height()))
+ c.check(
+ "the companion pane sits level with the driver's cursor",
+ link is not None and top == want,
+ f"driver_row={drv} link={link} dec_top={top} want={want}",
+ )
# a PURE scroll (wheel/scrollbar) moves no cursor — it must still drag
# the companion along (anchors on the viewport once the cursor is gone)
before_cur, before_dec = lst.cursor, round(dec.scroll_offset.y)
lst.scroll_to(y=round(lst.scroll_offset.y) + 30, animate=False)
await c.pause(0.35)
- c.check("a pure scroll in the driver drags the companion along",
- lst.cursor == before_cur
- and round(dec.scroll_offset.y) != before_dec,
- f"cursor {before_cur}->{lst.cursor} "
- f"dec_top {before_dec}->{round(dec.scroll_offset.y)}")
+ c.check(
+ "a pure scroll in the driver drags the companion along",
+ lst.cursor == before_cur and round(dec.scroll_offset.y) != before_dec,
+ f"cursor {before_cur}->{lst.cursor} "
+ f"dec_top {before_dec}->{round(dec.scroll_offset.y)}",
+ )
await c.press("tab")
await c.pause(0.1)
- c.check("Tab in split focuses the pseudocode pane", app._active == "decomp",
- f"active={app._active}")
+ c.check(
+ "Tab in split focuses the pseudocode pane",
+ app._active == "decomp",
+ f"active={app._active}",
+ )
# decomp drives: put the cursor on an addressed pseudocode line (past the
# variable decls); the listing band must track the covering instruction row.
target = next((i for i, e in enumerate(dec._line_eas) if e is not None), None)
- c.check("pseudocode has addressed lines", target is not None,
- "no /*0xEA*/ markers in the pseudocode")
+ c.check(
+ "pseudocode has addressed lines",
+ target is not None,
+ "no /*0xEA*/ markers in the pseudocode",
+ )
if target is not None:
dec.cursor = target
dec._scroll_cursor_into_view()
@@ -903,27 +1592,39 @@ async def _split_view_body(c: Ctx, app, lst, dec):
app._sync_split("decomp")
await c.pause(0.1)
want = lst.model.ensure_ea(dec._line_eas[target])
- c.check("decomp cursor links the instruction row in the listing",
- want in lst._link_rows,
- f"link_rows={sorted(lst._link_rows)[:6]} want={want}")
+ c.check(
+ "decomp cursor links the instruction row in the listing",
+ want in lst._link_rows,
+ f"link_rows={sorted(lst._link_rows)[:6]} want={want}",
+ )
# and that linked row actually paints a background band (base rows have
# no bg; `want` is a deep code row, never the listing's own cursor row)
lst.reveal(want)
await c.pause(0.05)
y = want - round(lst.scroll_offset.y)
- banded = (0 <= y < lst.size.height and any(
- s.style and s.style.bgcolor is not None for s in lst.render_line(y)))
- c.check("the linked instruction row renders a highlight band", banded,
- f"y={y} cursor_row={lst.cursor}")
+ banded = 0 <= y < lst.size.height and any(
+ s.style and s.style.bgcolor is not None for s in lst.render_line(y)
+ )
+ c.check(
+ "the linked instruction row renders a highlight band",
+ banded,
+ f"y={y} cursor_row={lst.cursor}",
+ )
await c.press("tab")
await c.pause(0.1)
- c.check("Tab again focuses the listing pane", app._active == "listing",
- f"active={app._active}")
+ c.check(
+ "Tab again focuses the listing pane",
+ app._active == "listing",
+ f"active={app._active}",
+ )
# a mouse click on the other pane also makes it the driver (not just Tab)
await c.pilot.click(DecompView, offset=(10, 5))
await c.pause(0.15)
- c.check("clicking the pseudocode pane makes it the driver",
- app._active == "decomp", f"active={app._active}")
+ c.check(
+ "clicking the pseudocode pane makes it the driver",
+ app._active == "decomp",
+ f"active={app._active}",
+ )
await c.press("tab") # restore listing as the driver
await c.pause(0.1)
# cross-function follow: the listing cursor leaving the decompiled function
@@ -939,8 +1640,11 @@ async def _split_view_body(c: Ctx, app, lst, dec):
await c.pause(0.1)
app._sync_split("listing") # cursor now outside the decompiled fn
followed = await c.wait(lambda: dec.loaded_ea == other.addr, 25)
- c.check("listing cursor crossing into another function re-syncs the decomp",
- followed, f"dec={dec.loaded_ea} want={other.addr}")
+ c.check(
+ "listing cursor crossing into another function re-syncs the decomp",
+ followed,
+ f"dec={dec.loaded_ea} want={other.addr}",
+ )
# navigation in split keeps BOTH panes on the (new) function
nf = c.find_func(lambda f: f.addr != app._cur.ea and f.size > 80)
if nf is not None:
@@ -948,27 +1652,42 @@ async def _split_view_body(c: Ctx, app, lst, dec):
await c.type(hex(nf.addr))
await c.press("enter")
nav = await c.wait(lambda: app._cur and app._cur.ea == nf.addr, 15)
- c.check("goto in split navigates", nav,
- f"cur={app._cur.ea if app._cur else None} want={nf.addr}")
- both = await c.wait(lambda: dec.loaded_ea == nf.addr and app._split
- and lst.display and dec.display, 25)
- c.check("split reloads both panes on navigation", both,
- f"dec={dec.loaded_ea} split={app._split}")
+ c.check(
+ "goto in split navigates",
+ nav,
+ f"cur={app._cur.ea if app._cur else None} want={nf.addr}",
+ )
+ both = await c.wait(
+ lambda: (
+ dec.loaded_ea == nf.addr and app._split and lst.display and dec.display
+ ),
+ 25,
+ )
+ c.check(
+ "split reloads both panes on navigation",
+ both,
+ f"dec={dec.loaded_ea} split={app._split}",
+ )
await c.press("s")
- gone = await c.wait(lambda: not app._split and lst.display
- and not dec.display, 10)
- c.check("'s' exits split back to a single view", gone,
- f"split={app._split} lst={lst.display} dec={dec.display}")
- c.check("exiting split clears the link bands",
- not lst._link_rows and dec._link_line is None,
- f"rows={lst._link_rows} line={dec._link_line}")
+ gone = await c.wait(lambda: not app._split and lst.display and not dec.display, 10)
+ c.check(
+ "'s' exits split back to a single view",
+ gone,
+ f"split={app._split} lst={lst.display} dec={dec.display}",
+ )
+ c.check(
+ "exiting split clears the link bands",
+ not lst._link_rows and dec._link_line is None,
+ f"rows={lst._link_rows} line={dec._link_line}",
+ )
@scenario("decomp_fallback")
async def s_fallback(c: Ctx):
app = c.app
- failing = next((f for f in reversed(c.all_funcs())
- if c.prog.decompile(f.addr).failed), None)
+ failing = next(
+ (f for f in reversed(c.all_funcs()) if c.prog.decompile(f.addr).failed), None
+ )
if failing is None:
c.check("found a decompile-failing function", False)
return
@@ -983,15 +1702,23 @@ async def s_fallback(c: Ctx):
# was pressed, since the function was opened in the listing. It asserted
# nothing, slowly.
landed = await c.wait(lambda: _CANNOT_DECOMP in c.status().lower(), 25)
- c.check("F5/Tab on an undecompilable function says so", landed,
- f"active={app._active} status={c.status()!r}")
- c.check("F5/Tab on an undecompilable function falls back to a code view",
- app.is_listing and c.dis.display,
- f"active={app._active} status={c.status()!r}")
+ c.check(
+ "F5/Tab on an undecompilable function says so",
+ landed,
+ f"active={app._active} status={c.status()!r}",
+ )
+ c.check(
+ "F5/Tab on an undecompilable function falls back to a code view",
+ app.is_listing and c.dis.display,
+ f"active={app._active} status={c.status()!r}",
+ )
# a decompilable function F5s into pseudocode
await c.open("main", "decomp")
- c.check("a decompilable function F5s into pseudocode",
- app._active == "decomp" and c.dec.display, f"active={app._active}")
+ c.check(
+ "a decompilable function F5s into pseudocode",
+ app._active == "decomp" and c.dec.display,
+ f"active={app._active}",
+ )
@scenario("structs")
@@ -999,101 +1726,224 @@ async def s_structs(c: Ctx):
app = c.app
await c.press("ctrl+t")
se_open = await c.wait(lambda: isinstance(app.screen, StructEditor), 10)
- c.check("Ctrl+T opens the struct editor", se_open,
- f"screen={type(app.screen).__name__}")
+ c.check(
+ "Ctrl+T opens the struct editor", se_open, f"screen={type(app.screen).__name__}"
+ )
if not se_open:
return
se = app.screen
await c.wait(lambda: bool(se._structs), 15)
- c.check("struct editor lists existing structs", len(se._structs) > 0,
- f"n={len(se._structs)}")
+ c.check(
+ "struct editor lists existing structs",
+ len(se._structs) > 0,
+ f"n={len(se._structs)}",
+ )
ta = se.query_one(TextArea)
- tname = next((s.name for s in se._structs if s.name == "timespec"),
- se._structs[0].name)
+ tname = next(
+ (s.name for s in se._structs if s.name == "timespec"), se._structs[0].name
+ )
idx = next(i for i, s in enumerate(se._structs) if s.name == tname)
se.query_one(OptionList).highlighted = idx
se.on_option_list_option_selected(type("E", (), {"option_index": idx})())
await c.wait(lambda: tname in ta.text and "{" in ta.text, 15)
- c.check("selecting a struct shows its C definition",
- tname in ta.text and "{" in ta.text, f"text={ta.text[:40]!r}")
+ c.check(
+ "selecting a struct shows its C definition",
+ tname in ta.text and "{" in ta.text,
+ f"text={ta.text[:40]!r}",
+ )
# The definition is C, so it must be coloured as C (no tree-sitter grammar
# for it: idatui.highlight fills TextArea's highlight map from Pygments).
names = {n for spans in ta._highlights.values() for _, _, n in spans}
- c.check("the C definition is syntax-highlighted",
- {"keyword", "name"} <= names, f"names={sorted(names)}")
- styled = {s.style.color.name for s in ta.render_line(0)
- if s.style and s.style.color}
- c.check("highlight styles reach the rendered line", len(styled) > 1,
- f"colors={sorted(styled)}")
+ c.check(
+ "the C definition is syntax-highlighted",
+ {"keyword", "name"} <= names,
+ f"names={sorted(names)}",
+ )
+ styled = {
+ s.style.color.name for s in ta.render_line(0) if s.style and s.style.color
+ }
+ c.check(
+ "highlight styles reach the rendered line",
+ len(styled) > 1,
+ f"colors={sorted(styled)}",
+ )
app._clipboard = ""
se.query_one(TextArea).focus()
await c.press("ctrl+y")
await c.wait(lambda: app._clipboard == ta.text, 10)
- c.check("Ctrl+Y copies the struct definition to the clipboard",
- bool(app._clipboard) and app._clipboard == ta.text,
- f"clip_len={len(app._clipboard)}")
+ c.check(
+ "Ctrl+Y copies the struct definition to the clipboard",
+ bool(app._clipboard) and app._clipboard == ta.text,
+ f"clip_len={len(app._clipboard)}",
+ )
sname = "TuiEdTest"
await c.press("ctrl+n")
await c.pause(0.05)
ta.text = f"struct {sname} {{ int a; char b[8]; }};"
await c.press("ctrl+s")
await c.wait(lambda: any(s.name == sname for s in se._structs), 15)
- c.check("Ctrl+S declares a new struct",
- any(s.name == sname for s in se._structs), "not created")
+ c.check(
+ "Ctrl+S declares a new struct",
+ any(s.name == sname for s in se._structs),
+ "not created",
+ )
await c.wait(lambda: "\n" in ta.text, 10)
- c.check("editing re-highlights the definition",
- any(n == "keyword" for spans in ta._highlights.values()
- for _, _, n in spans),
- f"rows={len(ta._highlights)}")
- c.check("save auto-formats the definition in the editor",
- ta.text.count("\n") >= 3 and f"struct {sname}" in ta.text
- and not se._is_dirty(), f"text={ta.text[:50]!r}")
+ c.check(
+ "editing re-highlights the definition",
+ any(n == "keyword" for spans in ta._highlights.values() for _, _, n in spans),
+ f"rows={len(ta._highlights)}",
+ )
+ c.check(
+ "save auto-formats the definition in the editor",
+ ta.text.count("\n") >= 3
+ and f"struct {sname}" in ta.text
+ and not se._is_dirty(),
+ f"text={ta.text[:50]!r}",
+ )
idx = next(i for i, s in enumerate(se._structs) if s.name == sname)
se.on_option_list_option_selected(type("E", (), {"option_index": idx})())
await c.wait(lambda: sname in ta.text, 10)
ta.text = f"struct {sname} {{ int a; char b[8]; long c; }};"
await c.press("ctrl+s")
- await c.wait(lambda: next((s.members for s in se._structs if s.name == sname), 0) == 3, 15)
- c.check("Ctrl+S updates an existing struct in place",
- next((s.members for s in se._structs if s.name == sname), 0) == 3,
- "member count not 3")
+ await c.wait(
+ lambda: next((s.members for s in se._structs if s.name == sname), 0) == 3, 15
+ )
+ c.check(
+ "Ctrl+S updates an existing struct in place",
+ next((s.members for s in se._structs if s.name == sname), 0) == 3,
+ "member count not 3",
+ )
ta.text = f"struct {sname} {{ int a; char b[8]; long c; int __unused; }};"
await c.press("ctrl+s")
await c.wait(lambda: "save failed" in str(se.query_one("#se-status").render()), 15)
st = str(se.query_one("#se-status").render())
- c.check("a rejected save fails loudly, naming the reserved field",
- "save failed" in st and "__unused" in st, f"status={st!r}")
- c.check("a rejected save leaves the struct unchanged",
- next((s.members for s in se._structs if s.name == sname), 0) == 3, "changed")
+ c.check(
+ "a rejected save fails loudly, naming the reserved field",
+ "save failed" in st and "__unused" in st,
+ f"status={st!r}",
+ )
+ c.check(
+ "a rejected save leaves the struct unchanged",
+ next((s.members for s in se._structs if s.name == sname), 0) == 3,
+ "changed",
+ )
c.check("a rejected save keeps your edited text", "__unused" in ta.text)
other = next(i for i, s in enumerate(se._structs) if s.name != sname)
se.on_option_list_option_selected(type("E", (), {"option_index": other})())
guard = await c.wait(lambda: isinstance(app.screen, ConfirmScreen), 10)
- c.check("unsaved edits prompt before switching structs", guard,
- f"screen={type(app.screen).__name__}")
+ c.check(
+ "unsaved edits prompt before switching structs",
+ guard,
+ f"screen={type(app.screen).__name__}",
+ )
await c.press("enter")
- await c.wait(lambda: isinstance(app.screen, StructEditor) and not se._is_dirty(), 15)
+ await c.wait(
+ lambda: isinstance(app.screen, StructEditor) and not se._is_dirty(), 15
+ )
idx = next(i for i, s in enumerate(se._structs) if s.name == sname)
se.query_one(OptionList).focus()
se.query_one(OptionList).highlighted = idx
await c.press("d")
confirmed = await c.wait(lambda: isinstance(app.screen, ConfirmScreen), 10)
- c.check("delete asks for confirmation", confirmed,
- f"screen={type(app.screen).__name__}")
+ c.check(
+ "delete asks for confirmation", confirmed, f"screen={type(app.screen).__name__}"
+ )
await c.press("enter")
await c.wait(lambda: isinstance(app.screen, StructEditor), 10)
- await c.wait(lambda: not any(s.name == sname for s in se._structs)
- or "del_type" in str(se.query_one("#se-status").render()), 15)
+ await c.wait(
+ lambda: (
+ not any(s.name == sname for s in se._structs)
+ or "del_type" in str(se.query_one("#se-status").render())
+ ),
+ 15,
+ )
st = str(se.query_one("#se-status").render())
gone = not any(s.name == sname for s in se._structs)
- c.check("confirming delete removes the struct (or reports missing tool)",
- gone or "del_type" in st, f"gone={gone} status={st!r}")
+ c.check(
+ "confirming delete removes the struct (or reports missing tool)",
+ gone or "del_type" in st,
+ f"gone={gone} status={st!r}",
+ )
se.query_one(OptionList).focus()
await c.press("escape")
await c.wait(lambda: not isinstance(app.screen, StructEditor), 10)
c.check("Esc closes the struct editor", not isinstance(app.screen, StructEditor))
+@scenario("splash_scaling")
+async def s_splash_scaling(c: Ctx):
+ """The splash scales the logo to the pane instead of dropping it.
+
+ The bug this pins: the artwork's natural size is ~31 rows plus 10 of box
+ chrome, and the check was "do you have 41 rows?". A 31-row pane -- what a
+ split zellij window actually gives you -- was one row short, so the logo
+ silently disappeared. The terminal scales an image into whatever cell box
+ it is placed in, so there was never a reason for all-or-nothing.
+ """
+ from idatui import kittygfx
+ from idatui.app import LOGO_CHROME_ROWS, LOGO_MIN_ROWS, LoadingScreen, logo_cells
+
+ app = c.app
+ placed: list[tuple] = []
+ real_supported, real_upload, real_place = (
+ kittygfx.supported,
+ kittygfx.upload,
+ kittygfx.place,
+ )
+ kittygfx.supported = lambda: True
+ kittygfx.upload = lambda *a, **k: True
+ kittygfx.place = lambda *a, **k: (placed.append(a), True)[1]
+ try:
+ for width, height in ((159, 31), (100, 30), (140, 44)):
+ await c.pilot.resize_terminal(width, height)
+ await c.pause(0.05)
+ app.push_screen(LoadingScreen("echo"))
+ await c.wait(lambda: isinstance(app.screen, LoadingScreen), 5)
+ scr = app.screen
+ # push_screen returns before compose has mounted the children.
+ await c.wait(lambda: scr._cells is not None, 5)
+ room = height - LOGO_CHROME_ROWS
+ has_image = bool(scr.query("#loading-image"))
+ c.check(
+ f"{width}x{height}: the logo is drawn, not dropped",
+ has_image and room >= LOGO_MIN_ROWS,
+ f"image={has_image} room={room}",
+ )
+ if has_image:
+ cols, rows = scr._cells
+ c.check(
+ f"{width}x{height}: scaled to the room available",
+ rows <= room and rows == min(room, logo_cells()[1]),
+ f"cells={scr._cells} room={room} natural={logo_cells()}",
+ )
+ await c.wait(lambda: scr.query_one("#loading-box").region.height > 0, 5)
+ box = scr.query_one("#loading-box").region
+ c.check(
+ f"{width}x{height}: the box is not clipped",
+ box.y >= 0 and box.y + box.height <= height,
+ f"box={box} screen={height}",
+ )
+ app.pop_screen()
+ await c.pause(0.05)
+ c.check(
+ "a full-size pane still gets the artwork's natural size",
+ logo_cells(999) == logo_cells(),
+ f"{logo_cells(999)}",
+ )
+ c.check(
+ "and the image was actually placed each time", len(placed) >= 3, f"{placed}"
+ )
+ finally:
+ kittygfx.supported, kittygfx.upload, kittygfx.place = (
+ real_supported,
+ real_upload,
+ real_place,
+ )
+ # Every later scenario assumes the suite's own geometry.
+ await c.pilot.resize_terminal(140, 44)
+ await c.pause(0.05)
+
+
@scenario("modal_centering")
async def s_modal_centering(c: Ctx):
"""Every dialog we define is centred, without anyone maintaining a list.
@@ -1108,33 +1958,45 @@ async def s_modal_centering(c: Ctx):
import idatui.app as A
ours = sorted(
- (n for n, v in vars(A).items()
- if isinstance(v, type) and issubclass(v, ModalScreen)
- and v is not ModalScreen and v.__module__ == A.__name__),
- key=str)
+ (
+ n
+ for n, v in vars(A).items()
+ if isinstance(v, type)
+ and issubclass(v, ModalScreen)
+ and v is not ModalScreen
+ and v.__module__ == A.__name__
+ ),
+ key=str,
+ )
c.check("found the app's modal screens", len(ours) >= 8, f"{ours}")
styles = A.IdaTui.CSS
- c.check("centring is a rule about modals, not a list of them",
- "ModalScreen { align: center middle; }" in styles,
- "the ModalScreen rule is gone")
+ c.check(
+ "centring is a rule about modals, not a list of them",
+ "ModalScreen { align: center middle; }" in styles,
+ "the ModalScreen rule is gone",
+ )
# And prove it REACHES a dialog, rather than just being present in the text.
await c.press("ctrl+f")
opened = await c.wait(lambda: isinstance(c.app.screen, A.SearchPalette), 10)
if not opened:
- c.check("the search palette opened", False,
- f"screen={type(c.app.screen).__name__}")
+ c.check(
+ "the search palette opened", False, f"screen={type(c.app.screen).__name__}"
+ )
return
scr = c.app.screen
await c.wait(lambda: scr.query_one("#pal-box").region.height > 0, 5)
box = scr.query_one("#pal-box").region
above, below = box.y, c.app.size.height - (box.y + box.height)
- c.check("the search palette is vertically centred",
- box.height > 0 and abs(above - below) <= 1,
- f"box={box} screen={c.app.size} above={above} below={below}")
+ c.check(
+ "the search palette is vertically centred",
+ box.height > 0 and abs(above - below) <= 1,
+ f"box={box} screen={c.app.size} above={above} below={below}",
+ )
left = box.x
right = c.app.size.width - (box.x + box.width)
- c.check("and horizontally centred", abs(left - right) <= 1,
- f"left={left} right={right}")
+ c.check(
+ "and horizontally centred", abs(left - right) <= 1, f"left={left} right={right}"
+ )
await c.press("escape")
await c.wait(lambda: not isinstance(c.app.screen, A.SearchPalette), 5)
@@ -1146,8 +2008,9 @@ async def s_db_search(c: Ctx):
await c.open("main", "listing")
await c.press("ctrl+f")
opened = await c.wait(lambda: isinstance(app.screen, SearchPalette), 10)
- c.check("Ctrl+F opens the search palette", opened,
- f"screen={type(app.screen).__name__}")
+ c.check(
+ "Ctrl+F opens the search palette", opened, f"screen={type(app.screen).__name__}"
+ )
if not opened:
return
pal = app.screen
@@ -1157,29 +2020,40 @@ async def s_db_search(c: Ctx):
inp.value = "endbr64"
await c.press("enter")
await c.wait(lambda: bool(pal._hits), 30)
- c.check("a text search finds instructions", len(pal._hits) > 1,
- f"n={len(pal._hits)}")
- c.check("and it was classified as text",
- pal._searched and pal._searched[0] == "text", f"{pal._searched}")
- c.check("hits carry the line they matched",
- all("endbr64" in h.line for h in pal._hits[:5]),
- [h.line for h in pal._hits[:3]])
+ c.check(
+ "a text search finds instructions", len(pal._hits) > 1, f"n={len(pal._hits)}"
+ )
+ c.check(
+ "and it was classified as text",
+ pal._searched and pal._searched[0] == "text",
+ f"{pal._searched}",
+ )
+ c.check(
+ "hits carry the line they matched",
+ all("endbr64" in h.line for h in pal._hits[:5]),
+ [h.line for h in pal._hits[:3]],
+ )
# -- text with padding: match what is SEEN, not IDA's column spacing ----- #
inp.value = "call cs:"
await c.press("enter")
found = await c.wait(lambda: pal._searched == ("text", "call cs:"), 30)
- c.check("a query spanning IDA's column padding still matches",
- found and len(pal._hits) > 0, f"n={len(pal._hits)}")
+ c.check(
+ "a query spanning IDA's column padding still matches",
+ found and len(pal._hits) > 0,
+ f"n={len(pal._hits)}",
+ )
# -- bytes: the same endbr64, as a pattern ------------------------------- #
inp.value = "f3 0f 1e fa"
await c.press("enter")
await c.wait(lambda: pal._searched and pal._searched[0] == "bytes", 30)
- c.check("a hex query is classified as bytes",
- pal._searched and pal._searched[0] == "bytes", f"{pal._searched}")
- c.check("and finds the same instruction", len(pal._hits) > 1,
- f"n={len(pal._hits)}")
+ c.check(
+ "a hex query is classified as bytes",
+ pal._searched and pal._searched[0] == "bytes",
+ f"{pal._searched}",
+ )
+ c.check("and finds the same instruction", len(pal._hits) > 1, f"n={len(pal._hits)}")
# -- wildcards ----------------------------------------------------------- #
inp.value = "f3 0f ?? fa"
@@ -1192,34 +2066,49 @@ async def s_db_search(c: Ctx):
await c.press("enter")
await c.pause(0.1)
title = str(app.screen.query_one("#pal-box").border_title)
- c.check("a malformed byte pattern is refused with a reason",
- "not a byte" in title, f"title={title!r}")
+ c.check(
+ "a malformed byte pattern is refused with a reason",
+ "not a byte" in title,
+ f"title={title!r}",
+ )
# -- F2 pins the mode against the guess ---------------------------------- #
inp.value = "dead"
await c.pause(0.05)
- c.check("a hex-looking WORD still searches text",
- pal._mode_query()[0] == "text", f"{pal._mode_query()}")
+ c.check(
+ "a hex-looking WORD still searches text",
+ pal._mode_query()[0] == "text",
+ f"{pal._mode_query()}",
+ )
await c.press("f2")
- c.check("F2 forces it to bytes", pal._mode_query()[0] == "bytes",
- f"{pal._mode_query()}")
+ c.check(
+ "F2 forces it to bytes", pal._mode_query()[0] == "bytes", f"{pal._mode_query()}"
+ )
# -- Enter on a result navigates ----------------------------------------- #
inp.value = "endbr64"
- await c.press("f2") # back to text
+ await c.press("f2") # back to text
await c.press("enter")
- await c.wait(lambda: bool(pal._hits) and pal._searched
- and pal._searched[0] == "text", 30)
+ await c.wait(
+ lambda: bool(pal._hits) and pal._searched and pal._searched[0] == "text", 30
+ )
target = pal._hits[1] if len(pal._hits) > 1 else pal._hits[0]
pal.query_one(OptionList).highlighted = 1 if len(pal._hits) > 1 else 0
await c.press("enter")
closed = await c.wait(lambda: not isinstance(app.screen, SearchPalette), 10)
- c.check("Enter on a hit closes the palette", closed,
- f"screen={type(app.screen).__name__}")
+ c.check(
+ "Enter on a hit closes the palette",
+ closed,
+ f"screen={type(app.screen).__name__}",
+ )
landed = await c.wait(
- lambda: app._cur is not None and c.lst._cursor_ea() == target.head, 30)
- c.check("and lands the cursor on it", landed,
- f"cursor={c.lst._cursor_ea()} want={target.head:#x}")
+ lambda: app._cur is not None and c.lst._cursor_ea() == target.head, 30
+ )
+ c.check(
+ "and lands the cursor on it",
+ landed,
+ f"cursor={c.lst._cursor_ea()} want={target.head:#x}",
+ )
@scenario("export_findings")
@@ -1242,8 +2131,9 @@ async def s_export_findings(c: Ctx):
await c.open(fn.addr, "listing")
# Make something to find: a rename and a comment, through the real paths.
- app.program.client.invoke(
- "rename", batch={"func": {"addr": hex(fn.addr), "name": newname}})
+ app.program.client.call(
+ remote_ops.rename, batch={"func": {"addr": hex(fn.addr), "name": newname}}
+ )
app.program.bump_names()
app.program.set_comment(fn.addr, note)
app.program.invalidate(fn.addr)
@@ -1259,8 +2149,11 @@ async def s_export_findings(c: Ctx):
inp = app.query_one("#export", Input)
opened = await c.wait(lambda: inp.display, 5)
c.check("Ctrl+E opens the export prompt", opened, f"display={inp.display}")
- c.check("the prompt is prefilled with a path beside the binary",
- inp.value == default_path(app._open_path), f"value={inp.value!r}")
+ c.check(
+ "the prompt is prefilled with a path beside the binary",
+ inp.value == default_path(app._open_path),
+ f"value={inp.value!r}",
+ )
inp.value = out
await c.press("enter")
written = await c.wait(lambda: os.path.exists(out), 30)
@@ -1268,21 +2161,26 @@ async def s_export_findings(c: Ctx):
if not written:
return
doc = open(out, encoding="utf-8").read()
- c.check("the report is markdown with the expected sections",
- doc.startswith("# Findings") and "## Comments" in doc
- and "## Named functions" in doc, doc[:60])
- c.check("a comment written this session is in it", note in doc,
- doc[:200])
- c.check("and the function it belongs to is named", newname in doc,
- doc[:200])
- c.check("the report is sourced from the journal, not a scan",
- "idatui's edit journal" in doc,
- [l for l in doc.splitlines() if "**source**" in l])
- c.check("the analyzer's own comments stay out of it",
- "switch jump" not in doc and "jumptable" not in doc,
- [l for l in doc.splitlines() if "switch" in l][:2])
- c.check("the status line says where it went",
- out in c.status(), c.status())
+ c.check(
+ "the report is markdown with the expected sections",
+ doc.startswith("# Findings")
+ and "## Comments" in doc
+ and "## Named functions" in doc,
+ doc[:60],
+ )
+ c.check("a comment written this session is in it", note in doc, doc[:200])
+ c.check("and the function it belongs to is named", newname in doc, doc[:200])
+ c.check(
+ "the report is sourced from the journal, not a scan",
+ "idatui's edit journal" in doc,
+ [l for l in doc.splitlines() if "**source**" in l],
+ )
+ c.check(
+ "the analyzer's own comments stay out of it",
+ "switch jump" not in doc and "jumptable" not in doc,
+ [l for l in doc.splitlines() if "switch" in l][:2],
+ )
+ c.check("the status line says where it went", out in c.status(), c.status())
# The journal has to survive the database, or a report is only ever
# about the session that happened to be open.
from idatui.journal import Journal
@@ -1290,14 +2188,17 @@ async def s_export_findings(c: Ctx):
app.journal.flush(app.program)
reloaded = Journal()
reloaded.load(app.program)
- c.check("the journal round-trips through the .i64",
- fn.addr in reloaded.addresses(),
- f"{len(reloaded)} entries, {sorted(reloaded.addresses())[:3]}")
+ c.check(
+ "the journal round-trips through the .i64",
+ fn.addr in reloaded.addresses(),
+ f"{len(reloaded)} entries, {sorted(reloaded.addresses())[:3]}",
+ )
finally:
# Idempotent: hand the database back exactly as we found it.
app.program.set_comment(fn.addr, "")
- app.program.client.invoke(
- "rename", batch={"func": {"addr": hex(fn.addr), "name": old}})
+ app.program.client.call(
+ remote_ops.rename, batch={"func": {"addr": hex(fn.addr), "name": old}}
+ )
app.program.bump_names()
app.program.invalidate(fn.addr)
if os.path.exists(out):
@@ -1309,8 +2210,11 @@ async def s_struct_filter(c: Ctx):
app = c.app
await c.press("ctrl+t")
if not await c.wait(lambda: isinstance(app.screen, StructEditor), 10):
- c.check("Ctrl+T opens the struct editor", False,
- f"screen={type(app.screen).__name__}")
+ c.check(
+ "Ctrl+T opens the struct editor",
+ False,
+ f"screen={type(app.screen).__name__}",
+ )
return
se = app.screen
await c.wait(lambda: bool(se._structs), 15)
@@ -1325,26 +2229,55 @@ async def s_struct_filter(c: Ctx):
ol.focus()
await c.press("slash")
opened = await c.wait(lambda: inp.display and app.focused is inp, 5)
- c.check("'/' from the list opens the struct filter", opened,
- f"display={inp.display} focus={getattr(app.focused, 'id', None)}")
+ c.check(
+ "'/' from the list opens the struct filter",
+ opened,
+ f"display={inp.display} focus={getattr(app.focused, 'id', None)}",
+ )
+
+ # PgDn from the FILTER: this screen can't use OptionListNav (ctrl+n is "new
+ # type" here), so it forwards through its own guarded _page(). Checked while
+ # the list is still unfiltered, so there is something to page through.
+ await c.wait(lambda: ol.scrollable_content_region.height >= 1, 5)
+ if ol.option_count > 2:
+ ol.highlighted = 0
+ await c.press("pagedown")
+ c.check(
+ "PgDn pages the struct list from the filter prompt",
+ (ol.highlighted or 0) > 1,
+ f"highlighted={ol.highlighted} n={ol.option_count}",
+ )
+ await c.press("pageup")
+ c.check(
+ "PgUp returns to the first struct",
+ (ol.highlighted or 0) == 0,
+ f"highlighted={ol.highlighted}",
+ )
+
for ch in q:
await c.press(ch)
await c.wait(lambda: len(se._structs) < total, 5)
- c.check("typing fuzzy-filters the struct list",
- 0 < len(se._structs) < total and
- any(s.name == target for s in se._structs),
- f"q={q!r} {len(se._structs)}/{total}")
+ c.check(
+ "typing fuzzy-filters the struct list",
+ 0 < len(se._structs) < total and any(s.name == target for s in se._structs),
+ f"q={q!r} {len(se._structs)}/{total}",
+ )
cap = str(se.query_one("#se-title", Static).render())
- c.check("the caption counts what the filter kept",
- f"{len(se._structs)}/{total}" in cap, f"caption={cap!r}")
+ c.check(
+ "the caption counts what the filter kept",
+ f"{len(se._structs)}/{total}" in cap,
+ f"caption={cap!r}",
+ )
# 'd' is the delete binding on this screen: in the prompt it must be a
# character, not a destructive verb aimed at the highlighted struct.
await c.press("d")
await c.pause(0.05)
- c.check("'d' in the filter types instead of deleting",
- isinstance(app.screen, StructEditor) and inp.value == q + "d",
- f"screen={type(app.screen).__name__} value={inp.value!r}")
+ c.check(
+ "'d' in the filter types instead of deleting",
+ isinstance(app.screen, StructEditor) and inp.value == q + "d",
+ f"screen={type(app.screen).__name__} value={inp.value!r}",
+ )
await c.press("backspace")
await c.wait(lambda: inp.value == q, 5)
@@ -1352,34 +2285,45 @@ async def s_struct_filter(c: Ctx):
before = ol.highlighted
await c.press("down")
await c.pause(0.05)
- c.check("arrows move the list while the filter has focus",
- app.focused is inp and (ol.highlighted != before
- or ol.option_count == 1),
- f"{before} -> {ol.highlighted} of {ol.option_count}")
+ c.check(
+ "arrows move the list while the filter has focus",
+ app.focused is inp and (ol.highlighted != before or ol.option_count == 1),
+ f"{before} -> {ol.highlighted} of {ol.option_count}",
+ )
sel = se._structs[ol.highlighted or 0].name
ta = se.query_one(TextArea)
ta.text = ""
await c.press("enter")
loaded = await c.wait(lambda: sel in ta.text, 15)
- c.check("Enter in the filter loads the highlighted struct", loaded,
- f"want {sel!r} in {ta.text[:40]!r}")
+ c.check(
+ "Enter in the filter loads the highlighted struct",
+ loaded,
+ f"want {sel!r} in {ta.text[:40]!r}",
+ )
# Esc backs out one level at a time: definition -> filter -> dialog.
await c.press("escape")
await c.wait(lambda: app.focused is ol, 5)
- c.check("Esc leaves the definition for the list", app.focused is ol,
- f"focus={getattr(app.focused, 'id', None)}")
+ c.check(
+ "Esc leaves the definition for the list",
+ app.focused is ol,
+ f"focus={getattr(app.focused, 'id', None)}",
+ )
await c.press("escape")
cleared = await c.wait(lambda: len(se._structs) == total, 5)
- c.check("Esc clears the filter instead of closing",
- cleared and not inp.display and isinstance(app.screen, StructEditor),
- f"n={len(se._structs)}/{total} display={inp.display}")
+ c.check(
+ "Esc clears the filter instead of closing",
+ cleared and not inp.display and isinstance(app.screen, StructEditor),
+ f"n={len(se._structs)}/{total} display={inp.display}",
+ )
await c.press("escape")
await c.wait(lambda: not isinstance(app.screen, StructEditor), 10)
- c.check("a third Esc closes the editor",
- not isinstance(app.screen, StructEditor),
- f"screen={type(app.screen).__name__}")
+ c.check(
+ "a third Esc closes the editor",
+ not isinstance(app.screen, StructEditor),
+ f"screen={type(app.screen).__name__}",
+ )
@scenario("open_default_view")
@@ -1389,9 +2333,11 @@ async def s_open(c: Ctx):
app._open_function(fn.addr, fn.name)
await c.wait(lambda: app._cur and app._cur.ea == fn.addr, 20)
await c.wait(lambda: c.lst.total > 0, 30)
- c.check("opening a function shows the linear listing by default",
- app._active == "listing" and c.lst.display and c.lst.total > 0,
- f"active={app._active} total={c.lst.total}")
+ c.check(
+ "opening a function shows the linear listing by default",
+ app._active == "listing" and c.lst.display and c.lst.total > 0,
+ f"active={app._active} total={c.lst.total}",
+ )
print(f" biggest = {fn.name} ({c.lst.total} listing rows)")
@@ -1400,15 +2346,19 @@ async def s_disasm_nav(c: Ctx):
app, view = c.app, c.dis
await c.open_biggest("listing")
view.focus()
- c.check("first instruction cached",
- view.model is not None and view.model.cached_line(0) is not None)
+ c.check(
+ "first instruction cached",
+ view.model is not None and view.model.cached_line(0) is not None,
+ )
for _ in range(5):
await c.press("pagedown")
await c.pause(0.025)
c.check("pagedown moved the cursor", view.cursor > 0, f"cursor={view.cursor}")
await c.wait(lambda: view.model.cached_line(view.cursor) is not None, 15)
- c.check("cursor line eventually cached (bg fetch)",
- view.model.cached_line(view.cursor) is not None)
+ c.check(
+ "cursor line eventually cached (bg fetch)",
+ view.model.cached_line(view.cursor) is not None,
+ )
c.check("status shows an address", "@ 0x" in c.status(), c.status())
# Ctrl+Y copies the current code line.
view.focus()
@@ -1416,14 +2366,20 @@ async def s_disasm_nav(c: Ctx):
app._clipboard = ""
await c.press("ctrl+y")
await c.wait(lambda: app._clipboard == cur_line, 10)
- c.check("Ctrl+Y copies the current code line to the clipboard",
- bool(cur_line) and app._clipboard == cur_line, f"clip={app._clipboard!r}")
+ c.check(
+ "Ctrl+Y copies the current code line to the clipboard",
+ bool(cur_line) and app._clipboard == cur_line,
+ f"clip={app._clipboard!r}",
+ )
# goto-bottom must not hang on a huge function (ctrl+end; plain 'end' now
# moves the cursor to end-of-line).
await c.press("ctrl+end")
await c.pause(0.05)
- c.check("goto-bottom lands near end", view.cursor >= view.total - 1,
- f"cursor={view.cursor}/{view.total}")
+ c.check(
+ "goto-bottom lands near end",
+ view.cursor >= view.total - 1,
+ f"cursor={view.cursor}/{view.total}",
+ )
@scenario("hex")
@@ -1437,23 +2393,52 @@ async def s_hex(c: Ctx):
await c.press("backslash")
await c.wait(lambda: app._active == "hex", 10)
hx = c.hex
- await c.wait(lambda: hx.model is not None
- and hx.model.row(hx.cursor // 16)[1] is not None, 20)
- c.check("backslash opens the hex view synced to the code cursor",
- app._active == "hex" and code_ea is not None and hx.cursor_va() == code_ea,
- f"active={app._active} hexva={hx.cursor_va():#x} ea={code_ea}")
+ await c.wait(
+ lambda: hx.model is not None and hx.model.row(hx.cursor // 16)[1] is not None,
+ 20,
+ )
+ c.check(
+ "backslash opens the hex view synced to the code cursor",
+ app._active == "hex" and code_ea is not None and hx.cursor_va() == code_ea,
+ f"active={app._active} hexva={hx.cursor_va():#x} ea={code_ea}",
+ )
want = app.program.read_bytes(code_ea, 1)
_, rb = hx.model.row(hx.cursor // 16)
- c.check("hex shows the actual byte at that address",
- rb is not None and rb[hx.cursor % 16] == want[0],
- f"got={rb[hx.cursor % 16] if rb else None} want={want[0]}")
+ c.check(
+ "hex shows the actual byte at that address",
+ rb is not None and rb[hx.cursor % 16] == want[0],
+ f"got={rb[hx.cursor % 16] if rb else None} want={want[0]}",
+ )
+ old_va = hx.cursor_va()
+ block = (old_va - hx.model.start) // hx.model.BLOCK
+ old_bytes = hx.model._blocks.get(block)
+ await c.press("ctrl+r")
+ reloaded = await c.wait(
+ lambda: (
+ hx.model._blocks.get(block) is not None
+ and hx.model._blocks.get(block) is not old_bytes
+ ),
+ 20,
+ )
+ c.check("Ctrl+R refetches the visible hex block", reloaded)
+ c.check(
+ "Ctrl+R preserves the hex cursor",
+ hx.cursor_va() == old_va,
+ f"got={hx.cursor_va():#x} want={old_va:#x}",
+ )
await c.press("l")
await c.pause(0.1)
- c.check("hex cursor steps one byte", hx.cursor_va() == code_ea + 1,
- f"va={hx.cursor_va():#x}")
+ c.check(
+ "hex cursor steps one byte",
+ hx.cursor_va() == code_ea + 1,
+ f"va={hx.cursor_va():#x}",
+ )
fo = app.program.file_offset(hx.cursor_va())
- c.check("hex carries a file offset for a mapped (.text) address",
- fo is not None and hx.model.file_offset(hx.cursor_va()) == fo, f"fo={fo}")
+ c.check(
+ "hex carries a file offset for a mapped (.text) address",
+ fo is not None and hx.model.file_offset(hx.cursor_va()) == fo,
+ f"fo={fo}",
+ )
rng = app.program.image_range()
target_va = rng[0] + (rng[1] - rng[0]) // 2
await c.press("g")
@@ -1461,14 +2446,17 @@ async def s_hex(c: Ctx):
await c.type(hex(target_va))
await c.press("enter")
await c.wait(lambda: hx.cursor_va() == target_va, 15)
- c.check("'g' in the hex view jumps the cursor to an address",
- hx.cursor_va() == target_va, f"va={hx.cursor_va():#x} want={target_va:#x}")
+ c.check(
+ "'g' in the hex view jumps the cursor to an address",
+ hx.cursor_va() == target_va,
+ f"va={hx.cursor_va():#x} want={target_va:#x}",
+ )
# -- a user scroll freezes the cursor's screen row (points at a new byte) --
await c.press("g")
await c.type(hex(rng[0]))
await c.press("enter")
await c.wait(lambda: hx.cursor_va() == rng[0], 10)
- for _ in range(8): # cursor to viewport row 8 (top still 0)
+ for _ in range(8): # cursor to viewport row 8 (top still 0)
await c.press("j")
await c.pause(0.1)
top0 = round(hx.scroll_offset.y)
@@ -1477,24 +2465,33 @@ async def s_hex(c: Ctx):
await c.pause(0.15)
top1 = round(hx.scroll_offset.y)
c.check("hex viewport scrolled", top1 >= top0 + 20, f"top0={top0} top1={top1}")
- c.check("hex cursor's screen row stays frozen on scroll",
- hx.cursor // 16 - top1 == screen_row,
- f"screen_row={screen_row} now={hx.cursor // 16 - top1} top1={top1}")
+ c.check(
+ "hex cursor's screen row stays frozen on scroll",
+ hx.cursor // 16 - top1 == screen_row,
+ f"screen_row={screen_row} now={hx.cursor // 16 - top1} top1={top1}",
+ )
PAD = 1 # HexView { padding: 0 1 } -> content is inset one col
await c.pilot.click(HexView, offset=(PAD + 19 + 3 * 3, 5)) # hex byte 3, row 5
await c.pause(0.1)
- c.check("clicking the hex pane moves the cursor to the clicked byte",
- hx.cursor == (top1 + 5) * 16 + 3,
- f"cursor={hx.cursor} want={(top1 + 5) * 16 + 3} top1={top1}")
+ c.check(
+ "clicking the hex pane moves the cursor to the clicked byte",
+ hx.cursor == (top1 + 5) * 16 + 3,
+ f"cursor={hx.cursor} want={(top1 + 5) * 16 + 3} top1={top1}",
+ )
await c.pilot.click(HexView, offset=(PAD + 70 + 10, 7)) # ascii byte 10, row 7
await c.pause(0.1)
- c.check("clicking the ascii pane maps to the right byte",
- hx.cursor == (top1 + 7) * 16 + 10,
- f"cursor={hx.cursor} want={(top1 + 7) * 16 + 10}")
+ c.check(
+ "clicking the ascii pane maps to the right byte",
+ hx.cursor == (top1 + 7) * 16 + 10,
+ f"cursor={hx.cursor} want={(top1 + 7) * 16 + 10}",
+ )
await c.press("backslash")
await c.wait(lambda: app._active != "hex", 10)
- c.check("backslash returns from hex to the code view",
- app._active == "listing", f"active={app._active}")
+ c.check(
+ "backslash returns from hex to the code view",
+ app._active == "listing",
+ f"active={app._active}",
+ )
@scenario("filter")
@@ -1504,24 +2501,34 @@ async def s_filter(c: Ctx):
nfuncs = table.row_count
# row selection opens a function
fn = c.biggest()
- ridx = next((i for i in range(nfuncs)
- if int(str(table.get_row_at(i)[0]), 16) == fn.addr), 0)
+ ridx = next(
+ (i for i in range(nfuncs) if int(str(table.get_row_at(i)[0]), 16) == fn.addr), 0
+ )
table.move_cursor(row=ridx)
table.focus()
await c.press("enter")
await c.wait(lambda: app._cur and app._cur.ea == fn.addr, 20)
- c.check("selecting a table row opens that function",
- bool(app._cur) and app._cur.ea == fn.addr, f"cur={app._cur.ea if app._cur else None}")
+ c.check(
+ "selecting a table row opens that function",
+ bool(app._cur) and app._cur.ea == fn.addr,
+ f"cur={app._cur.ea if app._cur else None}",
+ )
# filter round-trip. Derive the glob from real names: this used to hardcode
# 'sub_1*', which matches NOTHING in a binary whose code never reaches
# 0x1xxx (echo's functions are sub_2xxx..sub_7xxx) — a deterministic failure
# that looked like a flake, and left the table empty for the next scenario.
subs = sorted(f.name for f in c.all_funcs() if f.name.startswith("sub_"))
term = (subs[0][:5] + "*") if subs else ""
- want = sum(1 for f in c.all_funcs()
- if fnmatch.fnmatch(f.name.lower(), term.lower())) if term else 0
- c.check("picked a glob that actually matches (test self-check)",
- 0 < want < nfuncs, f"term={term!r} want={want} of {nfuncs}")
+ want = (
+ sum(1 for f in c.all_funcs() if fnmatch.fnmatch(f.name.lower(), term.lower()))
+ if term
+ else 0
+ )
+ c.check(
+ "picked a glob that actually matches (test self-check)",
+ 0 < want < nfuncs,
+ f"term={term!r} want={want} of {nfuncs}",
+ )
table.focus()
await c.press("slash")
await c.pause(0.05)
@@ -1529,32 +2536,44 @@ async def s_filter(c: Ctx):
await c.press(ch if ch != "*" else "asterisk")
await c.press("enter")
filtered = await c.wait(lambda: table.row_count == want, 15)
- c.check("filter narrowed the list to exactly the matches", filtered,
- f"term={term!r} rows={table.row_count} want={want} of {nfuncs}")
+ c.check(
+ "filter narrowed the list to exactly the matches",
+ filtered,
+ f"term={term!r} rows={table.row_count} want={want} of {nfuncs}",
+ )
# pane toggle
left = app.query_one("#left", FunctionsPanel)
await c.press("ctrl+b")
await c.pause(0.05)
- c.check("ctrl+b hides functions pane + focuses the code view",
- not left.display and isinstance(app.focused, (ListingView, DecompView)),
- f"display={left.display} focus={type(app.focused).__name__}")
+ c.check(
+ "ctrl+b hides functions pane + focuses the code view",
+ not left.display and isinstance(app.focused, (ListingView, DecompView)),
+ f"display={left.display} focus={type(app.focused).__name__}",
+ )
await c.press("ctrl+b")
await c.pause(0.05)
- c.check("ctrl+b again restores pane + focuses table",
- left.display and isinstance(app.focused, DataTable),
- f"display={left.display} focus={type(app.focused).__name__}")
+ c.check(
+ "ctrl+b again restores pane + focuses table",
+ left.display and isinstance(app.focused, DataTable),
+ f"display={left.display} focus={type(app.focused).__name__}",
+ )
@scenario("view_toggle")
async def s_view_toggle(c: Ctx):
app, dis, dec = c.app, c.dis, c.dec
await c.open_biggest("decomp")
- pc = await c.wait(lambda: dec.display and dec.loaded_ea is not None
- and app._active == "decomp", 25)
+ pc = await c.wait(
+ lambda: dec.display and dec.loaded_ea is not None and app._active == "decomp",
+ 25,
+ )
c.check("pseudocode view shows", pc, f"active={app._active}")
c.check("pseudocode has many lines", dec.total > 20, f"lines={dec.total}")
- styled = any(seg.style is not None and seg.style.color is not None
- for strip in dec._strips[:min(dec.total, 200)] for seg in strip)
+ styled = any(
+ seg.style is not None and seg.style.color is not None
+ for strip in dec._strips[: min(dec.total, 200)]
+ for seg in strip
+ )
c.check("pseudocode is syntax-highlighted", styled)
# Cancelling a prompt must hand focus back to the pane you were READING.
# _code_view() used to choose on _pref, which was only ever "listing", so it
@@ -1570,21 +2589,27 @@ async def s_view_toggle(c: Ctx):
await c.press("down")
await c.press("down")
await c.pause(0.2)
- c.check("cancelling goto leaves focus in the pseudocode (arrows still work)",
- dec.cursor > line0,
- f"cursor {line0} -> {dec.cursor} focus={type(app.focused).__name__}")
+ c.check(
+ "cancelling goto leaves focus in the pseudocode (arrows still work)",
+ dec.cursor > line0,
+ f"cursor {line0} -> {dec.cursor} focus={type(app.focused).__name__}",
+ )
dec.focus() # Tab only toggles the view from a code pane; elsewhere it's
await c.pause(0.05) # focus-next, which would silently leave us in decomp
await c.press("tab")
# decomp -> listing runs through _toggle_to_listing, a background worker, so
# _active only flips once the listing model has loaded. A fixed pause held in
# a short run and lost the race in a full one.
- switched = await c.wait(lambda: app._active == "listing" and dis.display
- and not dec.display, 20)
- c.check("tab switches to disassembly", switched,
- f"active={app._active} split={app._split} "
- f"focus={type(app.focused).__name__} "
- f"lst={dis.display} dec={dec.display}")
+ switched = await c.wait(
+ lambda: app._active == "listing" and dis.display and not dec.display, 20
+ )
+ c.check(
+ "tab switches to disassembly",
+ switched,
+ f"active={app._active} split={app._split} "
+ f"focus={type(app.focused).__name__} "
+ f"lst={dis.display} dec={dec.display}",
+ )
# _toggle_to_listing repositions asynchronously; F5 below reads the listing
# cursor's ea and no-ops if it isn't on an addressed row yet.
await c.wait(lambda: c.lst._cursor_ea() is not None, 10)
@@ -1594,34 +2619,54 @@ async def s_view_toggle(c: Ctx):
dec.loaded_ea = None
app.action_toggle_view()
cover = dec._cover_widget
- c.check("F5 from the listing raises the 'decompiling…' overlay",
- dec.loading and cover is not None and "decomp-loading" in cover.classes
- and "decompiling" in str(cover.render()),
- f"loading={dec.loading} cover={cover!r}")
- await c.wait(lambda: app._active == "decomp" and not dec.loading
- and dec._cover_widget is None, 25)
- c.check("overlay clears when the decompile finishes",
- not dec.loading and dec._cover_widget is None)
+ c.check(
+ "F5 from the listing raises the 'decompiling…' overlay",
+ dec.loading
+ and cover is not None
+ and "decomp-loading" in cover.classes
+ and "decompiling" in str(cover.render()),
+ f"loading={dec.loading} cover={cover!r}",
+ )
+ await c.wait(
+ lambda: (
+ app._active == "decomp" and not dec.loading and dec._cover_widget is None
+ ),
+ 25,
+ )
+ c.check(
+ "overlay clears when the decompile finishes",
+ not dec.loading and dec._cover_widget is None,
+ )
# F5 on an ALREADY-loaded function must still clear the overlay (regression:
# the F5-raised overlay had nothing to clear it in the 'already loaded' branch
# -> spinner stuck forever).
await c.press("tab") # -> listing
await c.wait(lambda: app._active == "listing", 10)
app.action_toggle_view() # F5 the same, cached function again
- cleared = await c.wait(lambda: app._active == "decomp" and not dec.loading
- and dec._cover_widget is None, 15)
- c.check("re-decompiling an already-loaded function clears the overlay", cleared,
- f"loading={dec.loading} cover={dec._cover_widget!r}")
+ cleared = await c.wait(
+ lambda: (
+ app._active == "decomp" and not dec.loading and dec._cover_widget is None
+ ),
+ 15,
+ )
+ c.check(
+ "re-decompiling an already-loaded function clears the overlay",
+ cleared,
+ f"loading={dec.loading} cover={dec._cover_widget!r}",
+ )
# line-number gutter
dec.scroll_to(0, 0, animate=False)
await c.pause(0.025)
row0 = "".join(seg.text for seg in dec.render_line(0))
- c.check("pseudocode has a numbered gutter (line 1 first)",
- dec._gutter > 0 and row0[:dec._gutter].strip() == "1",
- f"gutter={dec._gutter} row0={row0[:10]!r}")
+ c.check(
+ "pseudocode has a numbered gutter (line 1 first)",
+ dec._gutter > 0 and row0[: dec._gutter].strip() == "1",
+ f"gutter={dec._gutter} row0={row0[:10]!r}",
+ )
# Home/End move along the line here too (they used to scroll to top/bottom).
- line = next((i for i, t in enumerate(dec._texts)
- if t.startswith(" ") and t.strip()), None)
+ line = next(
+ (i for i, t in enumerate(dec._texts) if t.startswith(" ") and t.strip()), None
+ )
if line is not None:
text = dec._texts[line]
dec.focus()
@@ -1631,28 +2676,37 @@ async def s_view_toggle(c: Ctx):
top = round(dec.scroll_offset.y)
await c.press("end")
await c.pause(0.05)
- c.check("<end> in pseudocode goes to end-of-line, not the bottom",
- dec.cursor == line and dec.cursor_x == max(len(text) - 1, 0)
- and round(dec.scroll_offset.y) == top,
- f"line={dec.cursor} col={dec.cursor_x} len={len(text)}")
+ c.check(
+ "<end> in pseudocode goes to end-of-line, not the bottom",
+ dec.cursor == line
+ and dec.cursor_x == max(len(text) - 1, 0)
+ and round(dec.scroll_offset.y) == top,
+ f"line={dec.cursor} col={dec.cursor_x} len={len(text)}",
+ )
await c.press("home")
await c.pause(0.05)
- c.check("<home> in pseudocode goes to start-of-line",
- dec.cursor == line and dec.cursor_x == 0,
- f"line={dec.cursor} col={dec.cursor_x}")
+ c.check(
+ "<home> in pseudocode goes to start-of-line",
+ dec.cursor == line and dec.cursor_x == 0,
+ f"line={dec.cursor} col={dec.cursor_x}",
+ )
await c.press("shift+home")
await c.pause(0.05)
- c.check("<shift+home> skips the indentation",
- dec.cursor_x == len(text) - len(text.lstrip()),
- f"col={dec.cursor_x} indent={len(text) - len(text.lstrip())}")
+ c.check(
+ "<shift+home> skips the indentation",
+ dec.cursor_x == len(text) - len(text.lstrip()),
+ f"col={dec.cursor_x} indent={len(text) - len(text.lstrip())}",
+ )
await c.press("ctrl+end")
await c.pause(0.1)
- c.check("<ctrl+end> still goes to the bottom",
- dec.cursor >= dec.total - 1, f"{dec.cursor}/{dec.total}")
+ c.check(
+ "<ctrl+end> still goes to the bottom",
+ dec.cursor >= dec.total - 1,
+ f"{dec.cursor}/{dec.total}",
+ )
await c.press("ctrl+home")
await c.pause(0.1)
- c.check("<ctrl+home> still goes to the top", dec.cursor == 0,
- f"{dec.cursor}")
+ c.check("<ctrl+home> still goes to the top", dec.cursor == 0, f"{dec.cursor}")
@scenario("search")
@@ -1672,26 +2726,39 @@ async def s_search(c: Ctx):
# the unified listing searches the whole segment (load_all) -> allow time
await c.wait(lambda: bool(dis._matches), 45)
c.check("search finds matches", len(dis._matches) > 0, f"term={term!r}")
- c.check("cursor sits on a match", dis.cursor in dis._matches, f"cursor={dis.cursor}")
- c.check("match substring highlighted",
- bool(dis._ranges.get(dis.cursor)), str(dis._ranges.get(dis.cursor)))
- c.check("search cursor lands on the match's starting column",
- bool(dis._ranges.get(dis.cursor))
- and dis.cursor_x == dis._ranges[dis.cursor][0][0],
- f"cursor_x={dis.cursor_x} ranges={dis._ranges.get(dis.cursor)}")
+ c.check(
+ "cursor sits on a match", dis.cursor in dis._matches, f"cursor={dis.cursor}"
+ )
+ c.check(
+ "match substring highlighted",
+ bool(dis._ranges.get(dis.cursor)),
+ str(dis._ranges.get(dis.cursor)),
+ )
+ c.check(
+ "search cursor lands on the match's starting column",
+ bool(dis._ranges.get(dis.cursor))
+ and dis.cursor_x == dis._ranges[dis.cursor][0][0],
+ f"cursor_x={dis.cursor_x} ranges={dis._ranges.get(dis.cursor)}",
+ )
prev = dis.cursor
await c.press("slash")
await c.pause(0.05)
await c.press("enter")
await c.pause(0.05)
- c.check("'/' repeats to next match",
- dis.cursor != prev and dis.cursor in dis._matches, f"cursor={dis.cursor}")
+ c.check(
+ "'/' repeats to next match",
+ dis.cursor != prev and dis.cursor in dis._matches,
+ f"cursor={dis.cursor}",
+ )
await c.press("question_mark")
await c.pause(0.05)
await c.press("enter")
await c.pause(0.05)
- c.check("'?' repeats to previous match", dis.cursor in dis._matches,
- f"cursor={dis.cursor}")
+ c.check(
+ "'?' repeats to previous match",
+ dis.cursor in dis._matches,
+ f"cursor={dis.cursor}",
+ )
# incremental preview + visible bar + Esc cancel
si = app.query_one("#search", Input)
status = app.query_one("#status", Static)
@@ -1699,22 +2766,31 @@ async def s_search(c: Ctx):
# display flips synchronously; the REGION only exists once Textual has laid
# the prompt out, which is a frame, not a worker.
await c.wait(lambda: si.display and si.region.height >= 1, 5)
- c.check("search bar visible, status hidden (no overlap)",
- si.display and not status.display, f"si={si.display} status={status.display}")
- c.check("search input owns the bottom row (nothing overlaps it)",
- si.region.height >= 1
- and si.region.y + si.region.height == app.size.height,
- f"search={si.region} screen={app.size}")
+ c.check(
+ "search bar visible, status hidden (no overlap)",
+ si.display and not status.display,
+ f"si={si.display} status={status.display}",
+ )
+ c.check(
+ "search input owns the bottom row (nothing overlaps it)",
+ si.region.height >= 1 and si.region.y + si.region.height == app.size.height,
+ f"search={si.region} screen={app.size}",
+ )
for ch in term:
await c.press(ch)
await c.pause(0.05)
- c.check("matches highlight incrementally (before Enter)",
- len(dis._matches) > 0 and si.value == term, f"val={si.value!r}")
+ c.check(
+ "matches highlight incrementally (before Enter)",
+ len(dis._matches) > 0 and si.value == term,
+ f"val={si.value!r}",
+ )
await c.press("escape")
await c.pause(0.1)
- c.check("Esc cancels: status restored, matches cleared",
- status.display and not si.display and not dis._matches,
- f"status={status.display} si={si.display} m={len(dis._matches)}")
+ c.check(
+ "Esc cancels: status restored, matches cleared",
+ status.display and not si.display and not dis._matches,
+ f"status={status.display} si={si.display} m={len(dis._matches)}",
+ )
@scenario("incr_filter")
@@ -1730,19 +2806,30 @@ async def s_incr_filter(c: Ctx):
# settling can't see it. Wait for the effect instead of guessing at the
# debounce: it returns the moment the rows are rebuilt.
await c.wait(lambda: 0 < table.row_count < full, 5)
- c.check("filter narrows incrementally as you type",
- 0 < table.row_count < full, f"{table.row_count}/{full}")
+ c.check(
+ "filter narrows incrementally as you type",
+ 0 < table.row_count < full,
+ f"{table.row_count}/{full}",
+ )
cell = table.get_row_at(0)[1]
- c.check("filter highlights matched substring in name",
- isinstance(cell, Text) and any(s.style for s in cell.spans), repr(str(cell)))
+ c.check(
+ "filter highlights matched substring in name",
+ isinstance(cell, Text) and any(s.style for s in cell.spans),
+ repr(str(cell)),
+ )
await c.press("enter")
await c.wait(lambda: isinstance(app.focused, DataTable), 5)
- c.check("Enter keeps filter + focuses table",
- isinstance(app.focused, DataTable) and table.row_count < full)
+ c.check(
+ "Enter keeps filter + focuses table",
+ isinstance(app.focused, DataTable) and table.row_count < full,
+ )
await c.press("escape")
await c.wait(lambda: table.row_count == full, 5)
- c.check("Esc on the list clears the filter", table.row_count == full,
- f"{table.row_count}/{full}")
+ c.check(
+ "Esc on the list clears the filter",
+ table.row_count == full,
+ f"{table.row_count}/{full}",
+ )
@scenario("follow_xrefs")
@@ -1751,10 +2838,13 @@ async def s_follow_xrefs(c: Ctx):
await c.open_biggest("listing")
dis.focus()
lines = dis.model.lines(0, 400, prefetch=False)
- call_idx = next((i for i, ln in enumerate(lines)
- if ln.text.startswith("call ")), None)
+ call_idx = next(
+ (i for i, ln in enumerate(lines) if ln.text.startswith("call ")), None
+ )
if call_idx is None:
- c.check("found a call line to exercise follow/xrefs", False, "no call in first 400")
+ c.check(
+ "found a call line to exercise follow/xrefs", False, "no call in first 400"
+ )
return
dis.cursor = call_idx
dis.refresh()
@@ -1768,8 +2858,11 @@ async def s_follow_xrefs(c: Ctx):
# failed about one run in ten with cur == orig, at full speed, looking like
# a code regression.
await c.wait(lambda: len(app._nav) > depth and app._cur.ea != orig, 25)
- c.check("Enter follows the call into another function",
- app._cur.ea != orig and len(app._nav) > depth, f"cur={app._cur.ea:#x}")
+ c.check(
+ "Enter follows the call into another function",
+ app._cur.ea != orig and len(app._nav) > depth,
+ f"cur={app._cur.ea:#x}",
+ )
await c.press("escape")
await c.wait(lambda: app._cur.ea == orig, 15)
c.check("Esc returns from the follow", app._cur.ea == orig, f"cur={app._cur.ea:#x}")
@@ -1779,15 +2872,19 @@ async def s_follow_xrefs(c: Ctx):
opened = await c.wait(lambda: isinstance(app.screen, XrefsScreen), 25)
c.check("'x' opens the xrefs popup", opened, f"screen={type(app.screen).__name__}")
if opened:
- c.check("xrefs popup has entries",
- app.screen.query_one(OptionList).option_count >= 1)
+ c.check(
+ "xrefs popup has entries",
+ app.screen.query_one(OptionList).option_count >= 1,
+ )
await c.press("escape")
await c.pause(0.1)
c.check("Esc closes the xrefs popup", not isinstance(app.screen, XrefsScreen))
xf = None
for cand in c.all_funcs()[:600]:
- codex = [x for x in app.program.xrefs_to(cand.addr) if x.type == "code" and x.frm]
+ codex = [
+ x for x in app.program.xrefs_to(cand.addr) if x.type == "code" and x.frm
+ ]
if codex:
xf = (cand, codex[0])
break
@@ -1805,19 +2902,27 @@ async def s_follow_xrefs(c: Ctx):
await c.wait(lambda: not isinstance(app.screen, XrefsScreen), 25)
# xref-select lands the listing cursor on the referencing SITE (frm)
await c.wait(lambda: c.lst._cursor_ea() == xref.frm, 25)
- c.check("xref-select lands the cursor on the referencing site",
- c.lst._cursor_ea() == xref.frm,
- f"cur_ea={c.lst._cursor_ea()} want={xref.frm:#x}")
+ c.check(
+ "xref-select lands the cursor on the referencing site",
+ c.lst._cursor_ea() == xref.frm,
+ f"cur_ea={c.lst._cursor_ea()} want={xref.frm:#x}",
+ )
# F5 at the site decompiles the referencing function
c.lst.focus()
await c.press("tab")
landed = await c.wait(
- lambda: (app._active == "decomp" and dec.loaded_ea == xref.fn_addr)
- or (app.is_listing
- and _CANNOT_DECOMP in c.status().lower()), 25)
+ lambda: (
+ (app._active == "decomp" and dec.loaded_ea == xref.fn_addr)
+ or (app.is_listing and _CANNOT_DECOMP in c.status().lower())
+ ),
+ 25,
+ )
if app._active == "decomp":
- c.check("F5 at the xref site decompiles the referencing function",
- dec.loaded_ea == xref.fn_addr, f"loaded={dec.loaded_ea}")
+ c.check(
+ "F5 at the xref site decompiles the referencing function",
+ dec.loaded_ea == xref.fn_addr,
+ f"loaded={dec.loaded_ea}",
+ )
await c.press("tab")
await c.wait(lambda: app._active == "listing", 20)
@@ -1830,9 +2935,13 @@ async def s_follow_xrefs(c: Ctx):
dis.refresh()
await c.pause(0.025)
await c.press("h")
- c.check("h moves the column cursor left", dis.cursor_x == 4, f"x={dis.cursor_x}")
+ c.check(
+ "h moves the column cursor left", dis.cursor_x == 4, f"x={dis.cursor_x}"
+ )
await c.press("l", "l")
- c.check("l moves the column cursor right", dis.cursor_x == 6, f"x={dis.cursor_x}")
+ c.check(
+ "l moves the column cursor right", dis.cursor_x == 6, f"x={dis.cursor_x}"
+ )
plain = dis._line_plain(call_idx) or ""
m = re.search(r"\b(sub_[0-9A-Fa-f]+)", plain)
if m:
@@ -1840,24 +2949,32 @@ async def s_follow_xrefs(c: Ctx):
dis.cursor_x = m.start(1) + 1
dis.refresh()
await c.pause(0.05)
- c.check("word-under-cursor is the operand symbol",
- dis.word_under_cursor() == m.group(1),
- f"{dis.word_under_cursor()!r} vs {m.group(1)!r}")
+ c.check(
+ "word-under-cursor is the operand symbol",
+ dis.word_under_cursor() == m.group(1),
+ f"{dis.word_under_cursor()!r} vs {m.group(1)!r}",
+ )
depth = len(app._nav)
want = app.program.resolve(m.group(1))
await c.press("enter")
await c.wait(lambda: len(app._nav) > depth, 25)
- c.check("follows the symbol under the cursor",
- app._cur.ea == want, f"cur={app._cur.ea:#x} want={want:#x}")
+ c.check(
+ "follows the symbol under the cursor",
+ app._cur.ea == want,
+ f"cur={app._cur.ea:#x} want={want:#x}",
+ )
@scenario("xref_labels")
async def s_xref_labels(c: Ctx):
from collections import Counter, defaultdict
+
app, dis, dec = c.app, c.dis, c.dec
multi = None
for cand in c.all_funcs()[:200]:
- callers = Counter(x.fn_addr for x in app.program.xrefs_to(cand.addr) if x.fn_name)
+ callers = Counter(
+ x.fn_addr for x in app.program.xrefs_to(cand.addr) if x.fn_name
+ )
if any(n >= 2 for n in callers.values()):
multi = cand
break
@@ -1878,9 +2995,14 @@ async def s_xref_labels(c: Ctx):
if "+0x" in x:
nm, off = x.split("+0x", 1)
byfn[nm].add(off)
- c.check("xref labels distinguish multiple sites in a function by offset",
- any(len(offs) >= 2 for offs in byfn.values()), f"locs={locs[:8]}")
- c.check("no xref label is a bare '?'", all(x != "?" for x in locs), f"locs={locs[:8]}")
+ c.check(
+ "xref labels distinguish multiple sites in a function by offset",
+ any(len(offs) >= 2 for offs in byfn.values()),
+ f"locs={locs[:8]}",
+ )
+ c.check(
+ "no xref label is a bare '?'", all(x != "?" for x in locs), f"locs={locs[:8]}"
+ )
await c.press("escape")
await c.wait(lambda: not isinstance(app.screen, XrefsScreen), 25)
# pre-selection: 'x' at a call site highlights that site in the dialog
@@ -1888,8 +3010,9 @@ async def s_xref_labels(c: Ctx):
for x in app.program.xrefs_to(multi.addr):
if x.fn_name and x.type == "code":
bycaller[x.fn_addr].append(x)
- csites = next((sorted(v, key=lambda x: x.frm)
- for v in bycaller.values() if len(v) >= 2), None)
+ csites = next(
+ (sorted(v, key=lambda x: x.frm) for v in bycaller.values() if len(v) >= 2), None
+ )
if not csites:
c.check("found a caller with multiple sites for preselect", False)
return
@@ -1908,9 +3031,11 @@ async def s_xref_labels(c: Ctx):
await c.wait(lambda: isinstance(app.screen, XrefsScreen), 25)
hl = app.screen.query_one(OptionList).highlighted
it = app.screen._items
- c.check("xref dialog pre-selects the site it was invoked from",
- hl is not None and it[hl][0] == site.frm,
- f"hl={hl} frm={hex(it[hl][0]) if hl is not None else None} want={hex(site.frm)}")
+ c.check(
+ "xref dialog pre-selects the site it was invoked from",
+ hl is not None and it[hl][0] == site.frm,
+ f"hl={hl} frm={hex(it[hl][0]) if hl is not None else None} want={hex(site.frm)}",
+ )
await c.press("escape")
await c.wait(lambda: not isinstance(app.screen, XrefsScreen), 25)
@@ -1941,21 +3066,28 @@ async def s_mouse(c: Ctx):
return
await c.pilot.click(dis, offset=(mcol + 1, mrow))
await c.pause(0.05)
- c.check("single click places the cursor on the clicked token",
- dis.cursor == mline and dis.word_under_cursor() == msym,
- f"cursor={dis.cursor} (want {mline}) word={dis.word_under_cursor()!r}")
+ c.check(
+ "single click places the cursor on the clicked token",
+ dis.cursor == mline and dis.word_under_cursor() == msym,
+ f"cursor={dis.cursor} (want {mline}) word={dis.word_under_cursor()!r}",
+ )
depth = len(app._nav)
want = app.program.resolve(msym)
await c.pilot.click(dis, offset=(mcol + 1, mrow), times=2)
await c.wait(lambda: len(app._nav) > depth, 25)
- c.check("double-click follows the symbol", app._cur.ea == want,
- f"cur={app._cur.ea:#x} want={want:#x}")
+ c.check(
+ "double-click follows the symbol",
+ app._cur.ea == want,
+ f"cur={app._cur.ea:#x} want={want:#x}",
+ )
await c.press("escape")
await c.wait(lambda: app._cur.ea != want, 20)
await c.wait(lambda: dis.total > 0 and dis.cursor == mline, 20)
- c.check("back restores the exact line + column",
- dis.cursor == mline and dis.word_under_cursor() == msym,
- f"cursor={dis.cursor} (want {mline}) word={dis.word_under_cursor()!r}")
+ c.check(
+ "back restores the exact line + column",
+ dis.cursor == mline and dis.word_under_cursor() == msym,
+ f"cursor={dis.cursor} (want {mline}) word={dis.word_under_cursor()!r}",
+ )
@scenario("decomp_nav")
@@ -1977,23 +3109,32 @@ async def s_decomp_nav(c: Ctx):
await c.press("escape")
await c.wait(lambda: dec.loaded_ea == fn.addr, 25)
await c.pause(0.1)
- c.check("pseudocode-view position restored after jump+back",
- dec.cursor == drow and dec.word_under_cursor() == dsym,
- f"cursor={dec.cursor} (want {drow}) word={dec.word_under_cursor()!r}")
+ c.check(
+ "pseudocode-view position restored after jump+back",
+ dec.cursor == drow and dec.word_under_cursor() == dsym,
+ f"cursor={dec.cursor} (want {drow}) word={dec.word_under_cursor()!r}",
+ )
# follow works with a STALE name (post-rename): ea-marker fallback
dstale = app.program.resolve(dsym)
old_line = dec._texts[drow] if drow < len(dec._texts) else ""
old_ea = dec._line_ea(drow)
if old_ea is not None and dsym in old_line:
tmp = f"stale_{os.getpid()}"
- app.program.client.invoke("rename", batch={"func": {"addr": hex(dstale), "name": tmp}})
+ app.program.client.call(
+ remote_ops.rename, batch={"func": {"addr": hex(dstale), "name": tmp}}
+ )
app.program.bump_names()
d2 = len(app._nav)
app._follow_decomp(old_line, dsym, old_ea)
await c.wait(lambda: len(app._nav) > d2, 25)
- c.check("decomp follow works with a stale name (ea-marker fallback)",
- app._cur.ea == dstale, f"cur={app._cur.ea:#x} want={dstale:#x}")
- app.program.client.invoke("rename", batch={"func": {"addr": hex(dstale), "name": dsym}})
+ c.check(
+ "decomp follow works with a stale name (ea-marker fallback)",
+ app._cur.ea == dstale,
+ f"cur={app._cur.ea:#x} want={dstale:#x}",
+ )
+ app.program.client.call(
+ remote_ops.rename, batch={"func": {"addr": hex(dstale), "name": dsym}}
+ )
app.program.bump_names()
@@ -2020,9 +3161,11 @@ async def s_decomp_follow_self(c: Ctx):
depth = len(app._nav)
await c.press("enter")
moved = await c.wait(lambda: len(app._nav) > depth, 25)
- c.check("decompiler follows a name not in refs (resolve fallback)",
- moved and app._cur.ea == fn.addr,
- f"moved={moved} cur={hex(app._cur.ea)} want={hex(fn.addr)}")
+ c.check(
+ "decompiler follows a name not in refs (resolve fallback)",
+ moved and app._cur.ea == fn.addr,
+ f"moved={moved} cur={hex(app._cur.ea)} want={hex(fn.addr)}",
+ )
@scenario("sort")
@@ -2032,19 +3175,28 @@ async def s_sort(c: Ctx):
await c.pilot.click(table, offset=(15, 0)) # Function header
await c.pause(0.15)
snames = [str(table.get_row_at(i)[1]) for i in range(min(20, table.row_count))]
- c.check("click Function header sorts by name",
- app._sort_col == 1 and snames == sorted(snames, key=str.lower),
- f"sort_col={app._sort_col}")
+ c.check(
+ "click Function header sorts by name",
+ app._sort_col == 1 and snames == sorted(snames, key=str.lower),
+ f"sort_col={app._sort_col}",
+ )
first_asc = str(table.get_row_at(0)[1])
await c.pilot.click(table, offset=(15, 0)) # reverse
await c.pause(0.15)
- c.check("click again reverses the sort",
- app._sort_reverse and str(table.get_row_at(0)[1]) != first_asc)
+ c.check(
+ "click again reverses the sort",
+ app._sort_reverse and str(table.get_row_at(0)[1]) != first_asc,
+ )
await c.pilot.click(table, offset=(3, 0)) # Address header
await c.pause(0.15)
- saddrs = [int(str(table.get_row_at(i)[0]), 16) for i in range(min(20, table.row_count))]
- c.check("click Address header sorts by address",
- app._sort_col == 0 and saddrs == sorted(saddrs), f"sort_col={app._sort_col}")
+ saddrs = [
+ int(str(table.get_row_at(i)[0]), 16) for i in range(min(20, table.row_count))
+ ]
+ c.check(
+ "click Address header sorts by address",
+ app._sort_col == 0 and saddrs == sorted(saddrs),
+ f"sort_col={app._sort_col}",
+ )
@scenario("rename")
@@ -2065,18 +3217,34 @@ async def s_rename(c: Ctx):
await c.press("n")
await c.pause(0.1)
ri = app.query_one("#rename", Input)
- c.check("'n' opens the rename prompt prefilled with the symbol",
- ri.display and ri.value == dsym, f"val={ri.value!r}")
+ c.check(
+ "'n' opens the rename prompt prefilled with the symbol",
+ ri.display and ri.value == dsym,
+ f"val={ri.value!r}",
+ )
ri.value = newname
await c.press("enter")
- await c.wait(lambda: app._func_index.by_addr(dtarget)
- and app._func_index.by_addr(dtarget).name == newname, 25)
- c.check("rename updates the function name",
- app._func_index.by_addr(dtarget).name == newname,
- app._func_index.by_addr(dtarget).name)
- rr = app.program.client.invoke("rename", batch={"func": {"addr": hex(dtarget), "name": dsym}})
- c.check("rename reverted cleanly",
- rr.get("summary", {}).get("ok", 0) == 1, str(rr.get("summary")))
+ await c.wait(
+ lambda: (
+ app._func_index.by_addr(dtarget)
+ and app._func_index.by_addr(dtarget).name == newname
+ ),
+ 25,
+ )
+ c.check(
+ "rename updates the function name",
+ app._func_index.by_addr(dtarget).name == newname,
+ app._func_index.by_addr(dtarget).name,
+ )
+ rr = app.program.client.call(
+ remote_ops.rename, batch={"func": {"addr": hex(dtarget), "name": dsym}}
+ )
+ c.check(
+ "rename reverted cleanly",
+ rr.get("summary", {}).get("ok", 0) == 1,
+ str(rr.get("summary")),
+ )
+
# goto label refuse
def _find_label():
for i, t in enumerate(dec._texts):
@@ -2084,6 +3252,7 @@ async def s_rename(c: Ctx):
if mm:
return i, mm.start(), mm.group(0)
return None
+
lab = _find_label()
if lab is None:
await c.open("main", "decomp")
@@ -2097,15 +3266,25 @@ async def s_rename(c: Ctx):
await c.press("n")
await c.pause(0.1)
ri2 = app.query_one("#rename", Input)
- c.check("renaming a pseudocode label is refused with a clear message",
- (not ri2.display) and "label" in c.status().lower(),
- f"display={ri2.display} status={c.status()!r}")
+ c.check(
+ "renaming a pseudocode label is refused with a clear message",
+ (not ri2.display) and "label" in c.status().lower(),
+ f"display={ri2.display} status={c.status()!r}",
+ )
else:
c.check("found a pseudocode label to test", False, "no LABEL_ found")
# comment via ';'
- cline = next((i for i in range(len(dec._texts))
- if i > 5 and dec._line_ea(i) is not None
- and dec._texts[i].strip() and "//" not in dec._texts[i]), None)
+ cline = next(
+ (
+ i
+ for i in range(len(dec._texts))
+ if i > 5
+ and dec._line_ea(i) is not None
+ and dec._texts[i].strip()
+ and "//" not in dec._texts[i]
+ ),
+ None,
+ )
if cline is not None:
cea = dec._line_ea(cline)
dec.focus()
@@ -2116,8 +3295,11 @@ async def s_rename(c: Ctx):
await c.pause(0.1)
ci = app.query_one("#comment", Input)
cnote = f"note_{os.getpid()}"
- c.check("';' opens the comment prompt on the current line", ci.display,
- f"display={ci.display}")
+ c.check(
+ "';' opens the comment prompt on the current line",
+ ci.display,
+ f"display={ci.display}",
+ )
ci.value = cnote
await c.press("enter")
# Gate on the comment showing up, and ONLY that: the extra
@@ -2126,9 +3308,14 @@ async def s_rename(c: Ctx):
# out its full 25s (9s of wall clock) and then the check below passed
# vacuously anyway.
await c.wait(lambda: any(cnote in t for t in dec._texts), 25)
- c.check("comment appears in the pseudocode after ';'",
- any(cnote in t for t in dec._texts), "comment not shown")
- app.program.client.invoke("set_comments", items=[{"addr": hex(cea), "comment": ""}])
+ c.check(
+ "comment appears in the pseudocode after ';'",
+ any(cnote in t for t in dec._texts),
+ "comment not shown",
+ )
+ app.program.client.call(
+ remote_ops.set_comments, items=[{"addr": hex(cea), "comment": ""}]
+ )
else:
c.check("found a pseudocode line to comment", False, "no marker line")
@@ -2148,29 +3335,42 @@ async def s_comment_func(c: Ctx):
dec.cursor, dec.cursor_x = 0, 2 # the signature line
dec.refresh()
await c.pause(0.05)
- c.check("signature line has no address of its own", dec._line_ea(0) is None,
- f"ea={dec._line_ea(0)}")
+ c.check(
+ "signature line has no address of its own",
+ dec._line_ea(0) is None,
+ f"ea={dec._line_ea(0)}",
+ )
await c.press("semicolon")
await c.pause(0.1)
ci = app.query_one("#comment", Input)
note = f"fn_note_{os.getpid()}"
- c.check("';' on the signature line opens a function-comment prompt",
- ci.display and "function comment" in str(ci.placeholder).lower(),
- f"display={ci.display} ph={ci.placeholder!r}")
+ c.check(
+ "';' on the signature line opens a function-comment prompt",
+ ci.display and "function comment" in str(ci.placeholder).lower(),
+ f"display={ci.display} ph={ci.placeholder!r}",
+ )
# literal '\n' in the comment becomes a real newline -> multi-line render
a, b = f"{note}_A", f"{note}_B"
ci.value = f"{a}\\n{b}"
await c.press("enter")
- await c.wait(lambda: dec.loaded_ea == fn.addr
- and any(a in t for t in dec._texts)
- and any(b in t for t in dec._texts), 25)
+ await c.wait(
+ lambda: (
+ dec.loaded_ea == fn.addr
+ and any(a in t for t in dec._texts)
+ and any(b in t for t in dec._texts)
+ ),
+ 25,
+ )
la = next((i for i, t in enumerate(dec._texts) if a in t), None)
lb = next((i for i, t in enumerate(dec._texts) if b in t), None)
- c.check("multi-line function comment renders on separate lines",
- la is not None and lb is not None and lb > la
- and a not in dec._texts[lb],
- f"la={la} lb={lb}")
- app.program.client.invoke("set_comments", items=[{"addr": hex(fn.addr), "comment": ""}])
+ c.check(
+ "multi-line function comment renders on separate lines",
+ la is not None and lb is not None and lb > la and a not in dec._texts[lb],
+ f"la={la} lb={lb}",
+ )
+ app.program.client.call(
+ remote_ops.set_comments, items=[{"addr": hex(fn.addr), "comment": ""}]
+ )
@scenario("retype")
@@ -2190,30 +3390,41 @@ async def s_retype(c: Ctx):
await c.press("y")
await c.wait(lambda: app.query_one("#retype", Input).display, 10)
ri = app.query_one("#retype", Input)
- c.check("'y' on a function prefills its prototype",
- ri.display and ri.value == old_proto, f"val={ri.value!r} want={old_proto!r}")
+ c.check(
+ "'y' on a function prefills its prototype",
+ ri.display and ri.value == old_proto,
+ f"val={ri.value!r} want={old_proto!r}",
+ )
ri.value = f"void __fastcall {cf.name}(int zz_retype_arg)"
await c.press("enter")
- await c.wait(lambda: (lambda f: bool(f) and "zz_retype_arg" in f.prototype)(
- app.program.func_types(cf.addr)), 20)
+ await c.wait(
+ lambda: (lambda f: bool(f) and "zz_retype_arg" in f.prototype)(
+ app.program.func_types(cf.addr)
+ ),
+ 20,
+ )
after = app.program.func_types(cf.addr)
- c.check("applying a retype changes the function prototype",
- after is not None and "zz_retype_arg" in after.prototype,
- f"proto={after.prototype if after else None!r}")
+ c.check(
+ "applying a retype changes the function prototype",
+ after is not None and "zz_retype_arg" in after.prototype,
+ f"proto={after.prototype if after else None!r}",
+ )
app.program.set_function_type(cf.addr, old_proto) # restore
# the retype above kicked off a recompile+reload; let it land before we start
# placing the cursor, or the reload resets it under us.
app.program.bump_names()
- await c.wait(lambda: not dec.loading and dec.loaded_ea == cf.addr
- and bool(dec._texts), 25)
+ await c.wait(
+ lambda: not dec.loading and dec.loaded_ea == cf.addr and bool(dec._texts), 25
+ )
await c.pause(0.3)
# -- 'y' on a LOCAL variable retypes that variable, not the prototype --- #
fts = app.program.func_types(cf.addr)
lv = next((v for v in (fts.lvars if fts else []) if not v.is_arg), None)
if lv is not None:
- line = next((i for i, t in enumerate(dec._texts)
- if _word_occurrences(t, lv.name)), None)
+ line = next(
+ (i for i, t in enumerate(dec._texts) if _word_occurrences(t, lv.name)), None
+ )
if line is not None:
col = _word_occurrences(dec._texts[line], lv.name)[0][0]
dec.focus()
@@ -2223,26 +3434,43 @@ async def s_retype(c: Ctx):
await c.press("y")
await c.wait(lambda: app.query_one("#retype", Input).display, 10)
ri = app.query_one("#retype", Input)
- c.check("'y' on a local variable prefills that variable's type",
- ri.value == lv.type and lv.name in str(ri.placeholder),
- f"val={ri.value!r} want={lv.type!r} ph={ri.placeholder!r}")
+ c.check(
+ "'y' on a local variable prefills that variable's type",
+ ri.value == lv.type and lv.name in str(ri.placeholder),
+ f"val={ri.value!r} want={lv.type!r} ph={ri.placeholder!r}",
+ )
ri.value = "unsigned __int64"
await c.press("enter")
- changed = await c.wait(lambda: (lambda f: bool(f) and any(
- v.name == lv.name and v.type == "unsigned __int64"
- for v in f.lvars))(app.program.func_types(cf.addr)), 25)
- c.check("applying it retypes the local variable", changed,
- f"{lv.name}: wanted unsigned __int64")
+ changed = await c.wait(
+ lambda: (
+ lambda f: (
+ bool(f)
+ and any(
+ v.name == lv.name and v.type == "unsigned __int64"
+ for v in f.lvars
+ )
+ )
+ )(app.program.func_types(cf.addr)),
+ 25,
+ )
+ c.check(
+ "applying it retypes the local variable",
+ changed,
+ f"{lv.name}: wanted unsigned __int64",
+ )
after = app.program.func_types(cf.addr)
- c.check("retyping a local leaves the prototype alone",
- after is not None and after.prototype == old_proto,
- f"proto={after.prototype if after else None!r}")
+ c.check(
+ "retyping a local leaves the prototype alone",
+ after is not None and after.prototype == old_proto,
+ f"proto={after.prototype if after else None!r}",
+ )
# -- 'y' on a GLOBAL retypes the global, not the enclosing function ----- #
# The lvar retype above recompiled too — settle again, or the scan below
# indexes into pseudocode that's about to be replaced.
- await c.wait(lambda: not dec.loading and dec.loaded_ea == cf.addr
- and bool(dec._texts), 25)
+ await c.wait(
+ lambda: not dec.loading and dec.loaded_ea == cf.addr and bool(dec._texts), 25
+ )
await c.pause(0.3)
# Pick a global that actually appears as a word in the pseudocode — a symbol
@@ -2260,8 +3488,11 @@ async def s_retype(c: Ctx):
if app.program.func_types(a) is not None:
continue
d = app.program.data_type(a) or {}
- if (d.get("name") and not d.get("is_func")
- and "(" not in (d.get("type") or "")):
+ if (
+ d.get("name")
+ and not d.get("is_func")
+ and "(" not in (d.get("type") or "")
+ ):
glob = (w, a, d, i)
break
if glob:
@@ -2275,27 +3506,40 @@ async def s_retype(c: Ctx):
dec.cursor, dec.cursor_x = line, col + 1 # inside the word
dec.refresh()
await c.pause(0.05)
- c.check("the cursor sits on the global",
- dec.word_under_cursor() == gname,
- f"word={dec.word_under_cursor()!r} want={gname!r} "
- f"line={line} col={col} text={dec._texts[line][:60]!r}")
+ c.check(
+ "the cursor sits on the global",
+ dec.word_under_cursor() == gname,
+ f"word={dec.word_under_cursor()!r} want={gname!r} "
+ f"line={line} col={col} text={dec._texts[line][:60]!r}",
+ )
await c.press("y")
await c.wait(lambda: app.query_one("#retype", Input).display, 10)
ri = app.query_one("#retype", Input)
- c.check("'y' on a global prefills the global's type (not the proto)",
- ri.value != old_proto and gname in str(ri.placeholder),
- f"val={ri.value!r} ph={ri.placeholder!r}")
+ c.check(
+ "'y' on a global prefills the global's type (not the proto)",
+ ri.value != old_proto and gname in str(ri.placeholder),
+ f"val={ri.value!r} ph={ri.placeholder!r}",
+ )
ri.value = "unsigned __int64"
await c.press("enter")
retyped = await c.wait(
- lambda: (app.program.data_type(glob.addr) or {}).get("type")
- == "unsigned __int64", 25)
- c.check("applying it retypes the global", retyped,
- f"type={(app.program.data_type(glob.addr) or {}).get('type')!r}")
+ lambda: (
+ (app.program.data_type(glob.addr) or {}).get("type")
+ == "unsigned __int64"
+ ),
+ 25,
+ )
+ c.check(
+ "applying it retypes the global",
+ retyped,
+ f"type={(app.program.data_type(glob.addr) or {}).get('type')!r}",
+ )
after = app.program.func_types(cf.addr)
- c.check("retyping a global leaves the prototype alone",
- after is not None and after.prototype == old_proto,
- f"proto={after.prototype if after else None!r}")
+ c.check(
+ "retyping a global leaves the prototype alone",
+ after is not None and after.prototype == old_proto,
+ f"proto={after.prototype if after else None!r}",
+ )
if dt.get("type"): # restore
app.program.set_data_type(glob.addr, dt["type"])
@@ -2326,10 +3570,12 @@ async def s_scroll_restore(c: Ctx):
await c.wait(lambda: app._cur.ea == fb.addr, 20)
renders: list[int] = []
_orig_rl = dis.render_line
+
def _traced(y, _o=_orig_rl):
if y == 0:
renders.append(round(dis.scroll_offset.y))
return _o(y)
+
dis.render_line = _traced
await c.press("escape")
await c.wait(lambda: app._cur.ea == fa.addr, 20)
@@ -2338,13 +3584,19 @@ async def s_scroll_restore(c: Ctx):
# one happened. Wait for the paint we are actually asserting about (each
# poll ticks the screen, so this is ~one frame, not a quarter second).
await c.wait(lambda: bool(renders) and renders[-1] == want_sy, 5)
- c.check("disasm scroll + cursor restored on back (mid-viewport)",
- round(dis.scroll_offset.y) == want_sy and dis.cursor == want_cur and want_rel > 0,
- f"scroll={round(dis.scroll_offset.y)} (want {want_sy}) "
- f"cursor={dis.cursor} (want {want_cur}) rel_before={want_rel}")
- c.check("pane is repainted at the restored scroll (no stale top frame)",
- bool(renders) and renders[-1] == want_sy,
- f"last repaint scroll={renders[-1] if renders else None} (want {want_sy})")
+ c.check(
+ "disasm scroll + cursor restored on back (mid-viewport)",
+ round(dis.scroll_offset.y) == want_sy
+ and dis.cursor == want_cur
+ and want_rel > 0,
+ f"scroll={round(dis.scroll_offset.y)} (want {want_sy}) "
+ f"cursor={dis.cursor} (want {want_cur}) rel_before={want_rel}",
+ )
+ c.check(
+ "pane is repainted at the restored scroll (no stale top frame)",
+ bool(renders) and renders[-1] == want_sy,
+ f"last repaint scroll={renders[-1] if renders else None} (want {want_sy})",
+ )
dis.render_line = _orig_rl
@@ -2364,14 +3616,18 @@ async def s_paging(c: Ctx):
rel = dis.cursor - round(dis.scroll_offset.y)
await c.press("pagedown")
await c.pause(0.05)
- c.check("PageDown preserves the viewport-relative row",
- dis.cursor - round(dis.scroll_offset.y) == rel,
- f"rel={dis.cursor - round(dis.scroll_offset.y)} want={rel}")
+ c.check(
+ "PageDown preserves the viewport-relative row",
+ dis.cursor - round(dis.scroll_offset.y) == rel,
+ f"rel={dis.cursor - round(dis.scroll_offset.y)} want={rel}",
+ )
await c.press("pageup")
await c.pause(0.05)
- c.check("PageUp preserves the viewport-relative row",
- dis.cursor - round(dis.scroll_offset.y) == rel,
- f"rel={dis.cursor - round(dis.scroll_offset.y)} want={rel}")
+ c.check(
+ "PageUp preserves the viewport-relative row",
+ dis.cursor - round(dis.scroll_offset.y) == rel,
+ f"rel={dis.cursor - round(dis.scroll_offset.y)} want={rel}",
+ )
@scenario("rename_history")
@@ -2421,8 +3677,13 @@ async def s_rename_history(c: Ctx):
await c.pause(0.1)
app.query_one("#rename", Input).value = hnew
await c.press("enter")
- await c.wait(lambda: app._func_index.by_addr(htarget)
- and app._func_index.by_addr(htarget).name == hnew, 25)
+ await c.wait(
+ lambda: (
+ app._func_index.by_addr(htarget)
+ and app._func_index.by_addr(htarget).name == hnew
+ ),
+ 25,
+ )
if app._active != "listing":
await c.press("tab")
await c.press("escape")
@@ -2431,14 +3692,22 @@ async def s_rename_history(c: Ctx):
dis.model.lines(hrow, 4, prefetch=False)
await c.pause(0.1)
hline = dis._line_plain(hrow)
- c.check("caller disasm shows renamed callee after 'back'",
- hline is not None and hnew in hline, f"line={hline!r}")
+ c.check(
+ "caller disasm shows renamed callee after 'back'",
+ hline is not None and hnew in hline,
+ f"line={hline!r}",
+ )
await c.press("tab")
await c.wait(lambda: dec.loaded_ea == bea, 25)
await c.pause(0.15)
- c.check("caller pseudocode shows renamed callee after 'back'",
- any(hnew in tx for tx in dec._texts), "pseudocode still stale")
- app.program.client.invoke("rename", batch={"func": {"addr": hex(htarget), "name": hsym}})
+ c.check(
+ "caller pseudocode shows renamed callee after 'back'",
+ any(hnew in tx for tx in dec._texts),
+ "pseudocode still stale",
+ )
+ app.program.client.call(
+ remote_ops.rename, batch={"func": {"addr": hex(htarget), "name": hsym}}
+ )
@scenario("region_define")
@@ -2455,37 +3724,59 @@ async def s_region_define(c: Ctx):
# 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")
+ c.check(
+ "function removed by undefine",
+ c.prog.function_of(addr) is None,
+ "still a function",
+ )
# navigate there via the real 'g' prompt -> opens the flat LISTING view
# (a non-function 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 the listing view (not refused)",
- app._cur is not None and app._cur.is_region
- and app._active == "listing" and c.lst.display,
- f"cur={app._cur} active={app._active} status={c.status()!r}")
+ c.check(
+ "goto to a non-function address opens the listing view (not refused)",
+ app._cur is not None
+ and app._cur.is_region
+ and app._active == "listing"
+ and c.lst.display,
+ f"cur={app._cur} active={app._active} status={c.status()!r}",
+ )
await c.wait(lambda: c.lst.total > 0 and c.lst._cursor_ea() is not None, 25)
- c.check("listing renders heads and the cursor sits on the target address",
- c.lst.total > 0 and c.lst._cursor_ea() == addr,
- f"total={c.lst.total} cur_ea={c.lst._cursor_ea()}")
+ c.check(
+ "listing renders heads and the cursor sits on the target address",
+ c.lst.total > 0 and c.lst._cursor_ea() == addr,
+ f"total={c.lst.total} cur_ea={c.lst._cursor_ea()}",
+ )
# the flat listing spans the whole segment, not just this function
seg = c.prog.segment_bounds(addr)
- c.check("listing spans the whole segment (more heads than one function)",
- seg is not None and c.lst.total > 1, f"total={c.lst.total} seg={seg}")
+ c.check(
+ "listing spans the whole segment (more heads than one function)",
+ seg is not None and c.lst.total > 1,
+ f"total={c.lst.total} seg={seg}",
+ )
# 'p' on the entry head (re)creates the function
c.lst.focus()
c.lst.cursor, c.lst.cursor_x = c.lst.model.index_of_ea(addr), 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 listing to a function view",
- c.prog.function_of(addr) is not None and not app._cur.is_region
- and app._cur.ea == addr and app._active in ("listing", "decomp"),
- f"fn={c.prog.function_of(addr)} cur={app._cur} active={app._active}")
+ 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 listing to a function view",
+ c.prog.function_of(addr) is not None
+ and not app._cur.is_region
+ and app._cur.ea == addr
+ and app._active in ("listing", "decomp"),
+ f"fn={c.prog.function_of(addr)} cur={app._cur} active={app._active}",
+ )
finally:
# idempotency: guarantee the function is back even if a check failed
if c.prog.function_of(addr) is None:
@@ -2521,46 +3812,83 @@ async def s_listing_view(c: Ctx):
# `total > 0` is set from the segment's size before a single page has
# materialised, so waiting on it and then reading rows was the suite's
# one known flake (it failed roughly one run in three). Wait for a ROW.
- await c.wait(lambda: app._cur is not None and app._active == "listing"
- and c.lst.total > 0 and c.lst.model is not None
- and any(h.kind == "data" for h in c.lst.model.window(0, 40)), 25)
- c.check("navigating to a data segment opens the listing view",
- app._active == "listing" and c.lst.display and c.lst.total > 0,
- f"active={app._active} total={c.lst.total}")
+ await c.wait(
+ lambda: (
+ app._cur is not None
+ and app._active == "listing"
+ and c.lst.total > 0
+ and c.lst.model is not None
+ and any(h.kind == "data" for h in c.lst.model.window(0, 40))
+ ),
+ 25,
+ )
+ c.check(
+ "navigating to a data segment opens the listing view",
+ app._active == "listing" and c.lst.display and c.lst.total > 0,
+ f"active={app._active} total={c.lst.total}",
+ )
kinds = {h.kind for h in c.lst.model.window(0, 40)}
c.check("listing shows data heads (not just code)", "data" in kinds, str(kinds))
# a rendered data line carries the item text (e.g. db/dd/string)
c.lst.focus()
- first_data = next((i for i in range(min(c.lst.total, 60))
- if c.lst.model.get(i) and c.lst.model.get(i).kind == "data"), None)
- c.check("a data head exists in the first screenful", first_data is not None,
- f"total={c.lst.total}")
+ first_data = next(
+ (
+ i
+ for i in range(min(c.lst.total, 60))
+ if c.lst.model.get(i) and c.lst.model.get(i).kind == "data"
+ ),
+ None,
+ )
+ c.check(
+ "a data head exists in the first screenful",
+ first_data is not None,
+ f"total={c.lst.total}",
+ )
if first_data is not None:
c.lst.cursor = first_data
await c.pause(0.05)
plain = c.lst._line_plain(first_data)
- c.check("data line renders its item text", bool(plain and plain.strip()),
- f"plain={plain!r}")
- c.check("listing cursor reports the head address",
- c.lst._cursor_ea() == c.lst.model.get(first_data).ea, str(c.lst._cursor_ea()))
+ c.check(
+ "data line renders its item text",
+ bool(plain and plain.strip()),
+ f"plain={plain!r}",
+ )
+ c.check(
+ "listing cursor reports the head address",
+ c.lst._cursor_ea() == c.lst.model.get(first_data).ea,
+ str(c.lst._cursor_ea()),
+ )
# backslash from the listing opens hex at the cursor address; and back
cur_ea = c.lst._cursor_ea()
await c.press("backslash")
await c.wait(lambda: app._active == "hex" and c.hex.display, 15)
- c.check("backslash from the listing opens the hex view", app._active == "hex",
- f"active={app._active}")
+ c.check(
+ "backslash from the listing opens the hex view",
+ app._active == "hex",
+ f"active={app._active}",
+ )
await c.press("backslash")
await c.wait(lambda: app._active == "listing", 15)
- c.check("returning from hex lands back on the listing (not a func view)",
- app._active == "listing" and c.lst.display, f"active={app._active}")
+ c.check(
+ "returning from hex lands back on the listing (not a func view)",
+ app._active == "listing" and c.lst.display,
+ f"active={app._active}",
+ )
# 'd' defines typed data over an undefined run. Synthesize the run
# deterministically: undefine a data head, then re-type it via the prompt.
- dhead = next((c.lst.model.get(i) for i in range(min(c.lst.total, 200))
- if c.lst.model.get(i) and c.lst.model.get(i).kind == "data"
- and (c.lst.model.get(i).size or 0) >= 4), None)
+ dhead = next(
+ (
+ c.lst.model.get(i)
+ for i in range(min(c.lst.total, 200))
+ if c.lst.model.get(i)
+ and c.lst.model.get(i).kind == "data"
+ and (c.lst.model.get(i).size or 0) >= 4
+ ),
+ None,
+ )
if dhead is None:
c.check("found a data head to re-type", False)
return
@@ -2576,31 +3904,49 @@ async def s_listing_view(c: Ctx):
# win the race, and it started failing the moment page loads got bigger.
stale = c.lst.model
await c.goto_ui(hex(dea))
- await c.wait(lambda: app._active == "listing" and c.lst.total > 0
- and c.lst.model is not stale
- and c.lst.model.index_of_ea(dea) >= 0, 25)
+ await c.wait(
+ lambda: (
+ app._active == "listing"
+ and c.lst.total > 0
+ and c.lst.model is not stale
+ and c.lst.model.index_of_ea(dea) >= 0
+ ),
+ 25,
+ )
ui = c.lst.model.index_of_ea(dea)
- c.check("undefining a data head yields an unknown run in the listing",
- ui >= 0 and c.lst.model.get(ui).kind == "unknown",
- f"kind={c.lst.model.get(ui).kind if ui>=0 else None}")
+ c.check(
+ "undefining a data head yields an unknown run in the listing",
+ ui >= 0 and c.lst.model.get(ui).kind == "unknown",
+ f"kind={c.lst.model.get(ui).kind if ui >= 0 else None}",
+ )
c.lst.focus()
c.lst.cursor = ui
await c.pause(0.05)
await c.press("d")
await c.pause(0.1)
mdi = app.query_one("#makedata", Input)
- c.check("'d' opens the make-data prompt prefilled with a type",
- mdi.display and bool(mdi.value), f"display={mdi.display} val={mdi.value!r}")
+ c.check(
+ "'d' opens the make-data prompt prefilled with a type",
+ mdi.display and bool(mdi.value),
+ f"display={mdi.display} val={mdi.value!r}",
+ )
mdi.value = "char[4]"
await c.press("enter")
- await c.wait(lambda: app._active == "listing"
- and c.lst.model.index_of_ea(dea) >= 0
- and c.lst.model.get(c.lst.model.index_of_ea(dea)) is not None
- and c.lst.model.get(c.lst.model.index_of_ea(dea)).kind == "data", 25)
+ await c.wait(
+ lambda: (
+ app._active == "listing"
+ and c.lst.model.index_of_ea(dea) >= 0
+ and c.lst.model.get(c.lst.model.index_of_ea(dea)) is not None
+ and c.lst.model.get(c.lst.model.index_of_ea(dea)).kind == "data"
+ ),
+ 25,
+ )
di = c.lst.model.index_of_ea(dea)
- c.check("'d' turns the undefined run into a typed data item",
- di >= 0 and c.lst.model.get(di).kind == "data",
- f"kind={c.lst.model.get(di).kind if di>=0 else None}")
+ c.check(
+ "'d' turns the undefined run into a typed data item",
+ di >= 0 and c.lst.model.get(di).kind == "data",
+ f"kind={c.lst.model.get(di).kind if di >= 0 else None}",
+ )
finally:
c.prog.bump_items()
@@ -2626,8 +3972,11 @@ async def s_listing_name_addr(c: Ctx):
if data_ea is None:
c.check("found a data segment with a >=2-byte item", False)
return
- dh = next(h for h in c.prog.listing(data_ea).window(0, 60)
- if h.kind == "data" and (h.size or 0) >= 2)
+ dh = next(
+ h
+ for h in c.prog.listing(data_ea).window(0, 60)
+ if h.kind == "data" and (h.size or 0) >= 2
+ )
A = dh.ea
newname = f"after_{os.getpid()}"
try:
@@ -2635,33 +3984,53 @@ async def s_listing_name_addr(c: Ctx):
c.prog.make_data(A, "unsigned __int8")
c.prog.bump_items()
await c.goto_ui(hex(A + 1))
- await c.wait(lambda: app._active == "listing" and app._cur is not None
- and app._cur.ea == A + 1, 25)
+ await c.wait(
+ lambda: (
+ app._active == "listing"
+ and app._cur is not None
+ and app._cur.ea == A + 1
+ ),
+ 25,
+ )
head = c.lst.cur_head()
- c.check("cursor lands on the now-undefined byte at addr+1",
- head is not None and head.ea == A + 1 and head.kind == "unknown",
- f"head={head}")
+ c.check(
+ "cursor lands on the now-undefined byte at addr+1",
+ head is not None and head.ea == A + 1 and head.kind == "unknown",
+ f"head={head}",
+ )
# 'n' opens the address-name prompt (even though there's no symbol)
c.lst.focus()
await c.press("n")
await c.pause(0.1)
ri = app.query_one("#rename", Input)
- c.check("'n' opens the name prompt on an unnamed byte",
- ri.display, f"display={ri.display}")
+ c.check(
+ "'n' opens the name prompt on an unnamed byte",
+ ri.display,
+ f"display={ri.display}",
+ )
ri.value = newname
await c.press("enter")
- await c.wait(lambda: app._active == "listing"
- and c.lst.model.index_of_ea(A + 1) >= 0
- and c.lst.model.get(c.lst.model.index_of_ea(A + 1)) is not None
- and c.lst.model.get(c.lst.model.index_of_ea(A + 1)).name == newname, 25)
+ await c.wait(
+ lambda: (
+ app._active == "listing"
+ and c.lst.model.index_of_ea(A + 1) >= 0
+ and c.lst.model.get(c.lst.model.index_of_ea(A + 1)) is not None
+ and c.lst.model.get(c.lst.model.index_of_ea(A + 1)).name == newname
+ ),
+ 25,
+ )
hi = c.lst.model.index_of_ea(A + 1)
- c.check("naming a bare byte at addr+1 sticks",
- hi >= 0 and c.lst.model.get(hi).name == newname,
- f"name={c.lst.model.get(hi).name if hi>=0 else None}")
+ c.check(
+ "naming a bare byte at addr+1 sticks",
+ hi >= 0 and c.lst.model.get(hi).name == newname,
+ f"name={c.lst.model.get(hi).name if hi >= 0 else None}",
+ )
finally:
# revert: drop the label and restore raw bytes at A
try:
- c.prog.client.invoke("rename", batch={"data": {"addr": hex(A + 1), "new": ""}})
+ c.prog.client.call(
+ remote_ops.rename, batch={"data": {"addr": hex(A + 1), "new": ""}}
+ )
except Exception: # noqa: BLE001
pass
c.prog.undefine(A, size=8)
@@ -2680,8 +4049,9 @@ async def s_listing_make_string(c: Ctx):
if lm is None:
continue
lm.ensure(80)
- h = next((h for h in lm.window(0, 80)
- if h.kind == "data" and "'" in h.text), None)
+ h = next(
+ (h for h in lm.window(0, 80) if h.kind == "data" and "'" in h.text), None
+ )
if h is not None:
target = h.ea
break
@@ -2693,22 +4063,36 @@ async def s_listing_make_string(c: Ctx):
c.prog.undefine(A, size=8)
c.prog.bump_items()
await c.goto_ui(hex(A))
- await c.wait(lambda: app._active == "listing" and app._cur is not None
- and app._cur.ea == A, 25)
- c.check("target is undefined before 'a'",
- c.lst.cur_head() is not None and c.lst.cur_head().kind == "unknown",
- f"head={c.lst.cur_head()}")
+ await c.wait(
+ lambda: (
+ app._active == "listing" and app._cur is not None and app._cur.ea == A
+ ),
+ 25,
+ )
+ c.check(
+ "target is undefined before 'a'",
+ c.lst.cur_head() is not None and c.lst.cur_head().kind == "unknown",
+ f"head={c.lst.cur_head()}",
+ )
c.lst.focus()
await c.press("a")
- await c.wait(lambda: c.lst.model.index_of_ea(A) >= 0
- and c.lst.model.get(c.lst.model.index_of_ea(A)) is not None
- and c.lst.model.get(c.lst.model.index_of_ea(A)).kind == "data"
- and "'" in c.lst.model.get(c.lst.model.index_of_ea(A)).text, 25)
+ await c.wait(
+ lambda: (
+ c.lst.model.index_of_ea(A) >= 0
+ and c.lst.model.get(c.lst.model.index_of_ea(A)) is not None
+ and c.lst.model.get(c.lst.model.index_of_ea(A)).kind == "data"
+ and "'" in c.lst.model.get(c.lst.model.index_of_ea(A)).text
+ ),
+ 25,
+ )
hi = c.lst.model.index_of_ea(A)
- c.check("'a' creates a string literal at the cursor",
- hi >= 0 and c.lst.model.get(hi).kind == "data"
- and "'" in c.lst.model.get(hi).text,
- f"head={c.lst.model.get(hi) if hi >= 0 else None}")
+ c.check(
+ "'a' creates a string literal at the cursor",
+ hi >= 0
+ and c.lst.model.get(hi).kind == "data"
+ and "'" in c.lst.model.get(hi).text,
+ f"head={c.lst.model.get(hi) if hi >= 0 else None}",
+ )
finally:
try:
c.prog.make_string(A) # restore the original string
@@ -2738,25 +4122,38 @@ async def s_listing_struct_expand(c: Ctx):
c.check("found a data address for the struct test", False)
return
try:
- c.prog.client.invoke(
- "declare_type",
- decls=["struct TuiExpandS { int a; char b[4]; short c; };"])
+ c.prog.client.call(
+ remote_ops.declare_type,
+ decls=["struct TuiExpandS { int a; char b[4]; short c; };"],
+ )
c.prog.make_data(A, "TuiExpandS")
c.prog.bump_items()
await c.goto_ui(hex(A))
- await c.wait(lambda: app._active == "listing" and app._cur is not None
- and app._cur.ea == A and c.lst.total > 0, 25)
+ await c.wait(
+ lambda: (
+ app._active == "listing"
+ and app._cur is not None
+ and app._cur.ea == A
+ and c.lst.total > 0
+ ),
+ 25,
+ )
# the summary head, then member rows for a/b/c
si = c.lst.model.index_of_ea(A)
members = [c.lst.model.get(si + 1 + k) for k in range(3)]
names = [m.text for m in members if m is not None]
- c.check("struct global expands into member rows",
- all(m is not None and m.kind == "member" for m in members)
- and any("a" in t for t in names) and any("b" in t for t in names),
- f"members={names}")
- c.check("member rows carry field addresses",
- members[1] is not None and members[1].ea == A + 4,
- f"ea={members[1].ea if members[1] else None:#x} want={A+4:#x}")
+ c.check(
+ "struct global expands into member rows",
+ all(m is not None and m.kind == "member" for m in members)
+ and any("a" in t for t in names)
+ and any("b" in t for t in names),
+ f"members={names}",
+ )
+ c.check(
+ "member rows carry field addresses",
+ members[1] is not None and members[1].ea == A + 4,
+ f"ea={members[1].ea if members[1] else None:#x} want={A + 4:#x}",
+ )
finally:
try:
c.prog.undefine(A, size=16)
@@ -2774,45 +4171,66 @@ async def s_continuous_view(c: Ctx):
fn = await c.open_biggest("listing")
fn_ea = fn.addr
await c.wait(lambda: app._active == "listing" and c.lst.display, 10)
- c.check("a function opens in the continuous listing by default",
- app._active == "listing" and c.lst.display
- and c.lst._cursor_ea() == fn_ea,
- f"active={app._active} disp={c.lst.display} cur_ea={c.lst._cursor_ea()}")
+ c.check(
+ "a function opens in the continuous listing by default",
+ app._active == "listing" and c.lst.display and c.lst._cursor_ea() == fn_ea,
+ f"active={app._active} disp={c.lst.display} cur_ea={c.lst._cursor_ea()}",
+ )
# the listing spans the whole segment, not just the function
c.lst.model.load_all()
seg = c.prog.segment_bounds(fn_ea)
seg_rows = len(c.lst.model)
# a function's own instruction count is far smaller than the segment
fdis = c.prog.disasm(fn_ea, fn.name)
- c.check("the continuous listing extends past the function's bounds",
- seg_rows > fdis.total(), f"listing={seg_rows} func={fdis.total()}")
+ c.check(
+ "the continuous listing extends past the function's bounds",
+ seg_rows > fdis.total(),
+ f"listing={seg_rows} func={fdis.total()}",
+ )
kinds = {c.lst.model.get(i).kind for i in range(seg_rows)}
- c.check("continuous listing interleaves code with data/undefined",
- "code" in kinds and ("data" in kinds or "unknown" in kinds), str(kinds))
+ c.check(
+ "continuous listing interleaves code with data/undefined",
+ "code" in kinds and ("data" in kinds or "unknown" in kinds),
+ str(kinds),
+ )
# rendering parity with disasm: code lines carry opcode bytes
- cidx = next((i for i in range(seg_rows)
- if c.lst.model.get(i).kind == "code"), None)
- c.check("continuous listing renders opcode bytes (parity with disasm)",
- cidx is not None and c.lst.model.get(cidx).raw
- and c.lst._op_field(c.lst.model.get(cidx)).strip() != "",
- f"raw={c.lst.model.get(cidx).raw if cidx is not None else None!r}")
+ cidx = next((i for i in range(seg_rows) if c.lst.model.get(i).kind == "code"), None)
+ c.check(
+ "continuous listing renders opcode bytes (parity with disasm)",
+ cidx is not None
+ and c.lst.model.get(cidx).raw
+ and c.lst._op_field(c.lst.model.get(cidx)).strip() != "",
+ f"raw={c.lst.model.get(cidx).raw if cidx is not None else None!r}",
+ )
# F5/Tab at the function -> decompiler, and back to the same spot
c.lst.focus()
await c.press("tab")
- await c.wait(lambda: (app._active == "decomp" and c.dec.loaded_ea == fn_ea)
- or (app.is_listing
- and _CANNOT_DECOMP in c.status().lower()), 25)
+ await c.wait(
+ lambda: (
+ (app._active == "decomp" and c.dec.loaded_ea == fn_ea)
+ or (app.is_listing and _CANNOT_DECOMP in c.status().lower())
+ ),
+ 25,
+ )
if app._active == "decomp":
- c.check("F5/Tab decompiles the function under the cursor",
- c.dec.loaded_ea == fn_ea, f"loaded={c.dec.loaded_ea}")
+ c.check(
+ "F5/Tab decompiles the function under the cursor",
+ c.dec.loaded_ea == fn_ea,
+ f"loaded={c.dec.loaded_ea}",
+ )
await c.press("tab")
await c.wait(lambda: app._active == "listing", 25)
- c.check("F5/Tab in the decompiler returns to the listing at the same ea",
- app._active == "listing" and c.lst._cursor_ea() == fn_ea,
- f"active={app._active} cur_ea={c.lst._cursor_ea()}")
+ c.check(
+ "F5/Tab in the decompiler returns to the listing at the same ea",
+ app._active == "listing" and c.lst._cursor_ea() == fn_ea,
+ f"active={app._active} cur_ea={c.lst._cursor_ea()}",
+ )
else:
- c.check("undecompilable function falls back to the listing",
- app._active == "listing", f"active={app._active}")
+ c.check(
+ "undecompilable function falls back to the listing",
+ app._active == "listing",
+ f"active={app._active}",
+ )
@scenario("func_banners")
@@ -2825,18 +4243,27 @@ async def s_func_banners(c: Ctx):
c.lst.model.load_all()
heads = [c.lst.model.get(i) for i in range(len(c.lst.model))]
ci = c.lst.model.index_of_ea(fn.addr)
- c.check("navigation to a function lands on its code head, not a banner",
- ci >= 0 and c.lst.model.get(ci).kind == "code",
- f"kind={c.lst.model.get(ci).kind if ci >= 0 else None}")
- c.check("a SUBROUTINE separator banner is present",
- any(h.kind == "sep" and "S U B R O U T I N E" in h.text for h in heads))
- c.check("a 'name proc' header is present",
- any(h.kind == "funchdr" and h.text.endswith(" proc") for h in heads))
- c.check("a 'name endp' footer is present",
- any(h.kind == "funchdr" and h.text.endswith("endp") for h in heads))
+ c.check(
+ "navigation to a function lands on its code head, not a banner",
+ ci >= 0 and c.lst.model.get(ci).kind == "code",
+ f"kind={c.lst.model.get(ci).kind if ci >= 0 else None}",
+ )
+ c.check(
+ "a SUBROUTINE separator banner is present",
+ any(h.kind == "sep" and "S U B R O U T I N E" in h.text for h in heads),
+ )
+ c.check(
+ "a 'name proc' header is present",
+ any(h.kind == "funchdr" and h.text.endswith(" proc") for h in heads),
+ )
+ c.check(
+ "a 'name endp' footer is present",
+ any(h.kind == "funchdr" and h.text.endswith("endp") for h in heads),
+ )
# the proc header for the origin function carries its name
- hdr = next((h for h in heads if h.kind == "funchdr"
- and h.text == f"{fn.name} proc"), None)
+ hdr = next(
+ (h for h in heads if h.kind == "funchdr" and h.text == f"{fn.name} proc"), None
+ )
c.check("the proc header names the function", hdr is not None, f"fn={fn.name}")
@@ -2901,35 +4328,58 @@ async def s_opfmt_listing(c: Ctx):
ea = head.ea
c.lst.focus()
parked = await _park_on(c, ea)
- c.check("the cursor is on the literal's line", parked,
- f"want {ea:#x}, cursor at {c.lst._cursor_ea():#x}")
+ c.check(
+ "the cursor is on the literal's line",
+ parked,
+ f"want {ea:#x}, cursor at {c.lst._cursor_ea():#x}",
+ )
before = head.text
try:
await c.press("o")
- await c.wait(lambda: c.lst.model.index_of_ea(ea) >= 0
- and (c.lst.model.get(c.lst.model.index_of_ea(ea)) or head).text
- != before, 25)
+ await c.wait(
+ lambda: (
+ c.lst.model.index_of_ea(ea) >= 0
+ and (c.lst.model.get(c.lst.model.index_of_ea(ea)) or head).text
+ != before
+ ),
+ 25,
+ )
i = c.lst.model.index_of_ea(ea)
after = c.lst.model.get(i).text if i >= 0 else before
- c.check("'o' re-renders the literal", after != before,
- f"{before!r} -> {after!r} ea={ea:#x} show={show}")
- c.check("the status names the format it moved to",
- any(f in c.status() for f in show["choices"]),
- f"status={c.status()!r} choices={show['choices']}")
+ c.check(
+ "'o' re-renders the literal",
+ after != before,
+ f"{before!r} -> {after!r} ea={ea:#x} show={show}",
+ )
+ c.check(
+ "the status names the format it moved to",
+ any(f in c.status() for f in show["choices"]),
+ f"status={c.status()!r} choices={show['choices']}",
+ )
c.check("the change is marked unsaved", app._dirty)
# 'O' walks the ring the other way: back to where we started.
await c.press("O")
- await c.wait(lambda: c.lst.model.index_of_ea(ea) >= 0
- and (c.lst.model.get(c.lst.model.index_of_ea(ea)) or head).text
- == before, 25)
+ await c.wait(
+ lambda: (
+ c.lst.model.index_of_ea(ea) >= 0
+ and (c.lst.model.get(c.lst.model.index_of_ea(ea)) or head).text
+ == before
+ ),
+ 25,
+ )
j = c.lst.model.index_of_ea(ea)
- c.check("'O' cycles back", j >= 0 and c.lst.model.get(j).text == before,
- f"{c.lst.model.get(j).text if j >= 0 else None!r} want {before!r}")
+ c.check(
+ "'O' cycles back",
+ j >= 0 and c.lst.model.get(j).text == before,
+ f"{c.lst.model.get(j).text if j >= 0 else None!r} want {before!r}",
+ )
# An explicit format by name (what the palette/RPC use).
r = c.prog.op_format(ea, mode="dec")
- c.check("an explicit format renders decimal",
- r["format"] == "dec" and str(int(show["value"], 16)) in r["text"],
- str(r))
+ c.check(
+ "an explicit format renders decimal",
+ r["format"] == "dec" and str(int(show["value"], 16)) in r["text"],
+ str(r),
+ )
finally:
try:
c.prog.op_format(ea, mode="default", n=show.get("n", -1))
@@ -2948,24 +4398,33 @@ async def s_opfmt_no_literal(c: Ctx):
h = c.lst.model.get(i)
return h is not None and h.kind in ("sep", "funchdr", "label")
- row = next((i for i in range(c.lst.cursor, min(c.lst.cursor + 400,
- len(c.lst.model)))
- if _banner(i)), None)
+ row = next(
+ (
+ i
+ for i in range(c.lst.cursor, min(c.lst.cursor + 400, len(c.lst.model)))
+ if _banner(i)
+ ),
+ None,
+ )
if row is None:
c.check("found a banner row", False)
return
c.lst.focus()
- for _ in range(20): # hold it against a late-landing open
+ for _ in range(20): # hold it against a late-landing open
c.lst.cursor = row
await c.pause(0.05)
if c.lst.cursor == row:
break
- c.check("the cursor is on a banner row", _banner(c.lst.cursor),
- f"row={c.lst.cursor}")
+ c.check(
+ "the cursor is on a banner row", _banner(c.lst.cursor), f"row={c.lst.cursor}"
+ )
await c.press("o")
await c.wait(lambda: "reformat" in c.status() or "format" in c.status(), 15)
- c.check("'o' on a line with no literal explains itself",
- "reformat" in c.status(), f"status={c.status()!r}")
+ c.check(
+ "'o' on a line with no literal explains itself",
+ "reformat" in c.status(),
+ f"status={c.status()!r}",
+ )
@scenario("opfmt_refusal_is_not_swallowed")
@@ -2993,7 +4452,7 @@ async def s_opfmt_refusal_visible(c: Ctx):
c.check("the cursor is on the literal's line", False)
return
try:
- await c.press("o") # a success: sets the flash
+ await c.press("o") # a success: sets the flash
await c.wait(lambda: "\u2192" in c.status(), 25)
good = c.status()
@@ -3006,9 +4465,14 @@ async def s_opfmt_refusal_visible(c: Ctx):
h = c.lst.model.get(i)
return h is not None and h.kind in ("sep", "funchdr", "label")
- row = next((i for i in range(c.lst.cursor,
- min(c.lst.cursor + 400, len(c.lst.model)))
- if _banner(i)), None)
+ row = next(
+ (
+ i
+ for i in range(c.lst.cursor, min(c.lst.cursor + 400, len(c.lst.model)))
+ if _banner(i)
+ ),
+ None,
+ )
if row is None:
c.check("found a banner row to refuse on", False)
return
@@ -3017,11 +4481,13 @@ async def s_opfmt_refusal_visible(c: Ctx):
await c.pause(0.05)
if c.lst.cursor == row:
break
- c.lst.action_op_format("cycle") # no keypress: as the RPC does it
+ c.lst.action_op_format("cycle") # no keypress: as the RPC does it
await c.wait(lambda: c.status() != good, 15)
- c.check("a refusal replaces the previous success on the status bar",
- c.status() != good and "reformat" in c.status(),
- f"still showing {c.status()!r}")
+ c.check(
+ "a refusal replaces the previous success on the status bar",
+ c.status() != good and "reformat" in c.status(),
+ f"still showing {c.status()!r}",
+ )
finally:
try:
c.prog.op_format(head.ea, mode="default", n=show.get("n", -1))
@@ -3035,8 +4501,11 @@ def _styled_cols(strip, style_attr, want):
cols, x = [], 0
for seg in strip:
st = seg.style
- if st is not None and getattr(st, style_attr, None) is not None \
- and str(getattr(st, style_attr)) == want:
+ if (
+ st is not None
+ and getattr(st, style_attr, None) is not None
+ and str(getattr(st, style_attr)) == want
+ ):
cols.extend(range(x, x + len(seg.text)))
x += len(seg.text)
return cols
@@ -3052,15 +4521,22 @@ async def s_opfmt_highlight(c: Ctx):
(usually the same characters) and used to win.
"""
from idatui.app import _S_OPERAND
+
await c.open_biggest("listing")
c.lst.model.load_all()
lst = c.lst
lst.focus()
# A row with two operands, so "which one" is a real question.
- row = next((i for i in range(lst.cursor, min(lst.cursor + 400, len(lst.model)))
- if lst.model.get(i) is not None
- and (lst.model.get(i).ops or ()) and len(lst.model.get(i).ops) >= 2),
- None)
+ row = next(
+ (
+ i
+ for i in range(lst.cursor, min(lst.cursor + 400, len(lst.model)))
+ if lst.model.get(i) is not None
+ and (lst.model.get(i).ops or ())
+ and len(lst.model.get(i).ops) >= 2
+ ),
+ None,
+ )
if row is None:
c.check("found a row with two operands", False)
return
@@ -3079,12 +4555,17 @@ async def s_opfmt_highlight(c: Ctx):
strip = lst.render_line(row - round(lst.scroll_offset.y))
cols = _styled_cols(strip, "bgcolor", want_bg)
seen.append((n, min(cols) if cols else None, max(cols) + 1 if cols else None))
- c.check(f"operand {n} ({h.text[lo:hi]!r}) is marked when the cursor is on it",
- cols and min(cols) == base + lo and max(cols) + 1 == base + hi,
- f"marked={min(cols) if cols else None}.."
- f"{max(cols)+1 if cols else None} want={base+lo}..{base+hi}")
- c.check("the mark MOVES between the operands (it isn't the whole line)",
- len({s[1] for s in seen}) == len(seen), str(seen))
+ c.check(
+ f"operand {n} ({h.text[lo:hi]!r}) is marked when the cursor is on it",
+ cols and min(cols) == base + lo and max(cols) + 1 == base + hi,
+ f"marked={min(cols) if cols else None}.."
+ f"{max(cols) + 1 if cols else None} want={base + lo}..{base + hi}",
+ )
+ c.check(
+ "the mark MOVES between the operands (it isn't the whole line)",
+ len({s[1] for s in seen}) == len(seen),
+ str(seen),
+ )
# ... and the marked operand is the one the edit acts on — either it gets
# reformatted, or the refusal names that same operand. What must never
# happen is a different operand quietly changing.
@@ -3093,11 +4574,14 @@ async def s_opfmt_highlight(c: Ctx):
try:
r = c.prog.op_format(h.ea, mode="show", col=lst.op_col())
got, why = r.get("n"), ""
- except IDAToolError as e: # "operand N (rsp) has no format"
+ except IDAToolError as e: # "operand N (rsp) has no format"
m = re.search(r"operand (\d+)", e.message)
got, why = (int(m.group(1)) if m else None), e.message
- c.check(f"marked operand {n} is the one acted on (or refused)",
- got == n, f"marked op{n}, worker said op{got} {why}")
+ c.check(
+ f"marked operand {n} is the one acted on (or refused)",
+ got == n,
+ f"marked op{n}, worker said op{got} {why}",
+ )
@scenario("opfmt_sticks_to_its_literal")
@@ -3120,8 +4604,7 @@ async def s_opfmt_sticks(c: Ctx):
# The second literal must be one whose printed WIDTH changes as it
# cycles (0x36u vs 54), or the cursor never falls off it and the
# test proves nothing.
- if len(recs) >= 2 and recs[0][1] < recs[1][0] \
- and int(recs[1][2], 16) >= 16:
+ if len(recs) >= 2 and recs[0][1] < recs[1][0] and int(recs[1][2], 16) >= 16:
pick = (fn, line, recs)
break
if pick:
@@ -3131,7 +4614,7 @@ async def s_opfmt_sticks(c: Ctx):
return
fn, line, recs = pick
first, second = recs[0], recs[1]
- target = (second[3], second[4]) # (ea, opnum) of the literal we mean
+ target = (second[3], second[4]) # (ea, opnum) of the literal we mean
other = (first[3], first[4])
try:
# Make it WIDE first (0x30, four characters). The cursor then sits on a
@@ -3146,33 +4629,47 @@ async def s_opfmt_sticks(c: Ctx):
return
dec = c.dec
dec.focus()
- wide = next((r for r in dec._nums.get(line, ())
- if (r[3], r[4]) == target), None)
+ wide = next(
+ (r for r in dec._nums.get(line, ()) if (r[3], r[4]) == target), None
+ )
if wide is None or wide[1] - wide[0] < 3:
- c.check("the literal is now printed wide", False,
- f"nums={dec._nums.get(line)}")
+ c.check(
+ "the literal is now printed wide", False, f"nums={dec._nums.get(line)}"
+ )
return
- dec.cursor, dec.cursor_x = line, wide[1] - 1 # its LAST character
+ dec.cursor, dec.cursor_x = line, wide[1] - 1 # its LAST character
dec.refresh()
await c.pause(0.05)
seen = []
for _ in range(3):
before = dec._texts[line]
await c.press("o")
- await c.wait(lambda: dec.loaded_ea == fn.addr
- and line < len(dec._texts)
- and dec._texts[line] != before, 30)
- cur = next(((r[3], r[4]) for r in dec._nums.get(line, ())
- if r[0] <= dec.cursor_x < r[1]), None)
+ await c.wait(
+ lambda: (
+ dec.loaded_ea == fn.addr
+ and line < len(dec._texts)
+ and dec._texts[line] != before
+ ),
+ 30,
+ )
+ cur = next(
+ (
+ (r[3], r[4])
+ for r in dec._nums.get(line, ())
+ if r[0] <= dec.cursor_x < r[1]
+ ),
+ None,
+ )
seen.append(cur)
- c.check("every press stays on the literal we started on",
- all(s == target for s in seen),
- f"target={target} other={other} landed={seen} "
- f"line={dec._texts[line].strip()!r}")
+ c.check(
+ "every press stays on the literal we started on",
+ all(s == target for s in seen),
+ f"target={target} other={other} landed={seen} "
+ f"line={dec._texts[line].strip()!r}",
+ )
finally:
try:
- c.prog.pc_num_format(fn.addr, mode="default", line=line,
- col=second[0])
+ c.prog.pc_num_format(fn.addr, mode="default", line=line, col=second[0])
except Exception: # noqa: BLE001
pass
c.prog.bump_names()
@@ -3188,6 +4685,7 @@ async def s_cursor_on_visible(c: Ctx):
that hadn't changed.
"""
from idatui.rpc import cursor_on
+
app = c.app
fn = await c.open_biggest("listing")
c.lst.model.load_all()
@@ -3196,44 +4694,66 @@ async def s_cursor_on_visible(c: Ctx):
await c.pause(0.1)
top = round(lst.scroll_offset.y)
# A token that occurs both before the viewport and inside it.
- here = next((t for t in ("rax", "rsp", "eax", "rbp", "rdi")
- if any(t in (lst._line_plain(i) or "")
- for i in range(top, min(top + 20, lst.total)))
- and any(t in (lst._line_plain(i) or "") for i in range(0, top))),
- None)
+ here = next(
+ (
+ t
+ for t in ("rax", "rsp", "eax", "rbp", "rdi")
+ if any(
+ t in (lst._line_plain(i) or "")
+ for i in range(top, min(top + 20, lst.total))
+ )
+ and any(t in (lst._line_plain(i) or "") for i in range(0, top))
+ ),
+ None,
+ )
if here is None:
- c.check("found a token both above and inside the viewport", False,
- f"top={top}")
+ c.check("found a token both above and inside the viewport", False, f"top={top}")
return
found = cursor_on(app, here)
await c.pause(0.1)
c.check(f"cursor_on({here!r}) found it", found)
vis = round(lst.scroll_offset.y)
- c.check("it lands inside the viewport, not thousands of rows above",
- vis <= lst.cursor < vis + lst._visible_height(),
- f"cursor={lst.cursor} viewport={vis}..{vis + lst._visible_height()}")
- c.check("and it searched from the viewport, not from row 0",
- lst.cursor >= top, f"cursor={lst.cursor} was top={top}")
+ c.check(
+ "it lands inside the viewport, not thousands of rows above",
+ vis <= lst.cursor < vis + lst._visible_height(),
+ f"cursor={lst.cursor} viewport={vis}..{vis + lst._visible_height()}",
+ )
+ c.check(
+ "and it searched from the viewport, not from row 0",
+ lst.cursor >= top,
+ f"cursor={lst.cursor} was top={top}",
+ )
# An explicit line still wins, and lands visibly.
- far = next((i for i in range(0, min(top, lst.total))
- if here in (lst._line_plain(i) or "")), None)
+ far = next(
+ (
+ i
+ for i in range(0, min(top, lst.total))
+ if here in (lst._line_plain(i) or "")
+ ),
+ None,
+ )
if far is not None:
cursor_on(app, here, line=far)
await c.pause(0.1)
v2 = round(lst.scroll_offset.y)
- c.check("an explicit line is honoured AND scrolled into view",
- lst.cursor == far and v2 <= far < v2 + lst._visible_height(),
- f"cursor={lst.cursor} want={far} viewport={v2}")
+ c.check(
+ "an explicit line is honoured AND scrolled into view",
+ lst.cursor == far and v2 <= far < v2 + lst._visible_height(),
+ f"cursor={lst.cursor} want={far} viewport={v2}",
+ )
# The `cursor` verb has the same duty: a driver that parks the cursor for
# an edit must leave it where the edit can be watched.
from idatui.rpc import place_cursor
+
deep = min(lst.total - 1, 900)
place_cursor(lst, deep, 0)
await c.pause(0.1)
v3 = round(lst.scroll_offset.y)
- c.check("`cursor line=` scrolls to what it selected",
- v3 <= deep < v3 + lst._visible_height(),
- f"cursor={lst.cursor} viewport={v3}..{v3 + lst._visible_height()}")
+ c.check(
+ "`cursor line=` scrolls to what it selected",
+ v3 <= deep < v3 + lst._visible_height(),
+ f"cursor={lst.cursor} viewport={v3}..{v3 + lst._visible_height()}",
+ )
@scenario("opfmt_decomp")
@@ -3249,7 +4769,7 @@ async def s_opfmt_decomp(c: Ctx):
continue
for i, txt in enumerate(d.code.split("\n")):
m = re.search(r"[=<>+\-*/(,]\s(\d{2,}|0x[0-9A-Fa-f]{2,})\b", txt)
- if m and "//" not in txt[:m.start()]:
+ if m and "//" not in txt[: m.start()]:
pick = (fn, i, m.start(1))
break
if pick:
@@ -3270,14 +4790,24 @@ async def s_opfmt_decomp(c: Ctx):
before = dec._texts[line]
try:
await c.press("o")
- await c.wait(lambda: dec.loaded_ea == fn.addr and line < len(dec._texts)
- and dec._texts[line] != before, 30)
- c.check("'o' re-renders the pseudocode literal",
- line < len(dec._texts) and dec._texts[line] != before,
- f"{before!r} -> {dec._texts[line] if line < len(dec._texts) else None!r}")
- c.check("the status says which format",
- any(f in c.status() for f in ("hex", "dec", "oct", "char", "default")),
- f"status={c.status()!r}")
+ await c.wait(
+ lambda: (
+ dec.loaded_ea == fn.addr
+ and line < len(dec._texts)
+ and dec._texts[line] != before
+ ),
+ 30,
+ )
+ c.check(
+ "'o' re-renders the pseudocode literal",
+ line < len(dec._texts) and dec._texts[line] != before,
+ f"{before!r} -> {dec._texts[line] if line < len(dec._texts) else None!r}",
+ )
+ c.check(
+ "the status says which format",
+ any(f in c.status() for f in ("hex", "dec", "oct", "char", "default")),
+ f"status={c.status()!r}",
+ )
finally:
try:
c.prog.pc_num_format(fn.addr, mode="default", line=line, col=col)
@@ -3319,10 +4849,13 @@ async def _open_graph(c: Ctx, fn=None, t=60):
await c.press("space")
gv = app.query_one(GraphView)
ok = await c.wait(lambda: app._active == "graph" and gv.lay is not None, t)
- c.check("the graph opened", ok,
- f"active={app._active} sticky={app._graph_sticky} "
- f"focus={type(app.focused).__name__} prompt={app._prompt_active()} "
- f"status={c.status()!r}")
+ c.check(
+ "the graph opened",
+ ok,
+ f"active={app._active} sticky={app._graph_sticky} "
+ f"focus={type(app.focused).__name__} prompt={app._prompt_active()} "
+ f"status={c.status()!r}",
+ )
return fn, gv
@@ -3330,26 +4863,58 @@ async def _open_graph(c: Ctx, fn=None, t=60):
async def s_graph_open(c: Ctx):
app = c.app
fn, gv = await _open_graph(c)
- c.check("space opens the graph view", app._active == "graph",
- f"active={app._active} status={c.status()}")
+ c.check(
+ "space opens the graph view",
+ app._active == "graph",
+ f"active={app._active} status={c.status()}",
+ )
if gv.lay is None:
return
- c.check("the graph has the function's blocks",
- len(gv.lay.nodes) == len(gv.fc.blocks) and len(gv.lay.nodes) > 1,
- f"nodes={len(gv.lay.nodes)} blocks={len(gv.fc.blocks) if gv.fc else 0}")
- c.check("it is the right function", gv.fc is not None and gv.fc.func_ea == fn.addr,
- f"{gv.fc.func_ea if gv.fc else None:#x} want {fn.addr:#x}")
+ c.check(
+ "the graph has the function's blocks",
+ len(gv.lay.nodes) == len(gv.fc.blocks) and len(gv.lay.nodes) > 1,
+ f"nodes={len(gv.lay.nodes)} blocks={len(gv.fc.blocks) if gv.fc else 0}",
+ )
+ c.check(
+ "it is the right function",
+ gv.fc is not None and gv.fc.func_ea == fn.addr,
+ f"{gv.fc.func_ea if gv.fc else None:#x} want {fn.addr:#x}",
+ )
c.check("the cursor starts on a real address", gv._cursor_ea() is not None)
# The invariant the whole dummy-node machinery exists for.
boxes = [(n.x, n.y, n.right, n.y + n.h - 1) for n in gv.lay.nodes]
- overlap = any(a[0] <= b[2] and b[0] <= a[2] and a[1] <= b[3] and b[1] <= a[3]
- for i, a in enumerate(boxes) for b in boxes[i + 1:])
+ overlap = any(
+ a[0] <= b[2] and b[0] <= a[2] and a[1] <= b[3] and b[1] <= a[3]
+ for i, a in enumerate(boxes)
+ for b in boxes[i + 1 :]
+ )
c.check("no two blocks overlap", not overlap)
c.check("the status names the graph", "graph" in c.status(), c.status())
+ old_fc = gv.fc
+ old_ea = gv._cursor_ea()
+ await c.press("ctrl+r")
+ rebuilt = await c.wait(
+ lambda: (
+ app.is_graph
+ and gv.fc is not None
+ and gv.fc is not old_fc
+ and gv.lay is not None
+ ),
+ 30,
+ )
+ c.check("Ctrl+R rebuilds the graph", rebuilt)
+ c.check(
+ "Ctrl+R preserves the graph cursor",
+ gv._cursor_ea() == old_ea,
+ f"got={gv._cursor_ea()} want={old_ea}",
+ )
await c.press("space")
await c.wait(lambda: app._active != "graph", 15)
- c.check("space returns to the listing", app._active == "listing",
- f"active={app._active}")
+ c.check(
+ "space returns to the listing",
+ app._active == "listing",
+ f"active={app._active}",
+ )
@scenario("graph_nav")
@@ -3362,8 +4927,11 @@ async def s_graph_nav(c: Ctx):
start_ea = gv._cursor_ea()
await c.press("j")
await c.pause(0.05)
- c.check("j moves the cursor within the block", gv._cursor_ea() != start_ea,
- f"{start_ea:#x} -> {gv._cursor_ea():#x}")
+ c.check(
+ "j moves the cursor within the block",
+ gv._cursor_ea() != start_ea,
+ f"{start_ea:#x} -> {gv._cursor_ea():#x}",
+ )
await c.press("k")
await c.pause(0.05)
c.check("k comes back", gv._cursor_ea() == start_ea)
@@ -3373,24 +4941,35 @@ async def s_graph_nav(c: Ctx):
if succs:
await c.press("J")
await c.pause(0.1)
- c.check("J follows an edge to a successor block",
- gv.cursor_node == succs[0][0],
- f"node={gv.cursor_node} want={succs[0][0]}")
+ c.check(
+ "J follows an edge to a successor block",
+ gv.cursor_node == succs[0][0],
+ f"node={gv.cursor_node} want={succs[0][0]}",
+ )
await c.press("K")
await c.pause(0.1)
- c.check("K goes back up an edge", gv.cursor_node == b0,
- f"node={gv.cursor_node} want={b0}")
+ c.check(
+ "K goes back up an edge",
+ gv.cursor_node == b0,
+ f"node={gv.cursor_node} want={b0}",
+ )
await c.press("0")
await c.pause(0.1)
- c.check("0 returns to the entry block", gv.cursor_node == gv.fc.entry,
- f"node={gv.cursor_node} entry={gv.fc.entry}")
+ c.check(
+ "0 returns to the entry block",
+ gv.cursor_node == gv.fc.entry,
+ f"node={gv.cursor_node} entry={gv.fc.entry}",
+ )
# the cursor is always scrolled into view
cell = gv._cursor_cell()
top, left = int(gv.scroll_offset.y), int(gv.scroll_offset.x)
- c.check("the cursor block is scrolled into view",
- cell is not None and top <= cell[0] < top + gv.size.height
- and left <= cell[1] < left + gv.size.width,
- f"cell={cell} scroll=({top},{left}) size={gv.size}")
+ c.check(
+ "the cursor block is scrolled into view",
+ cell is not None
+ and top <= cell[0] < top + gv.size.height
+ and left <= cell[1] < left + gv.size.width,
+ f"cell={cell} scroll=({top},{left}) size={gv.size}",
+ )
@scenario("graph_zoom")
@@ -3406,18 +4985,98 @@ async def s_graph_zoom(c: Ctx):
await c.press("z")
await c.pause(0.15)
seen.append(gv.ZOOMS[gv._zoom])
- c.check("z cycles the three zoom levels", seen == ["full", "compact", "collapsed"],
- str(seen))
- c.check("collapsed is much smaller than full", gv.lay.height < full_h,
- f"{gv.lay.height} vs {full_h}")
+ c.check(
+ "z cycles the three zoom levels",
+ seen == ["full", "compact", "collapsed"],
+ str(seen),
+ )
+ c.check(
+ "collapsed is much smaller than full",
+ gv.lay.height < full_h,
+ f"{gv.lay.height} vs {full_h}",
+ )
c.check("the cursor survives a zoom", gv._cursor_ea() is not None)
- c.check("the status still names the function",
- gv.fc.name in c.status(), c.status())
+ c.check("the status still names the function", gv.fc.name in c.status(), c.status())
await c.press("z")
await c.pause(0.15)
c.check("and wraps back to full", gv.ZOOMS[gv._zoom] == "full")
- c.check("canvas is restored", gv.lay.height == full_h,
- f"{gv.lay.height} vs {full_h}")
+ c.check(
+ "canvas is restored", gv.lay.height == full_h, f"{gv.lay.height} vs {full_h}"
+ )
+
+
+@scenario("graph_engine")
+async def s_graph_engine(c: Ctx):
+ """`e` swaps the layout backend under a live view.
+
+ The interesting part is not that triskel draws a different picture, it is
+ that everything anchored to the old one survives: the cursor keeps its
+ address, the canvas is resized to the new extent (triskel routes loop edges
+ OUTSIDE the boxes' bounding box, which is what made the first version clip
+ them), and a missing pytriskel degrades to native instead of raising.
+ """
+ from idatui import graph_triskel
+
+ app = c.app
+ fn, gv = await _open_graph(c)
+ if gv.lay is None:
+ c.check("graph loaded", False)
+ return
+ ea = gv._cursor_ea()
+ first = gv.lay.stats["engine"]
+ c.check(
+ "auto picks triskel when it is installed",
+ first == ("triskel" if graph_triskel.available() else "native"),
+ f"engine={first} available={graph_triskel.available()}",
+ )
+
+ seen = [first]
+ for _ in range(3):
+ await c.press("e")
+ await c.pause(0.2)
+ seen.append(gv.lay.stats["engine"])
+ c.check(
+ f"the view survives engine={gv._engine}",
+ gv.lay is not None and gv.lay.width > 0 and gv.lay.height > 0,
+ f"{gv.lay.width}x{gv.lay.height}",
+ )
+ c.check(
+ f"the cursor keeps an address on engine={gv._engine}",
+ gv._cursor_ea() is not None,
+ )
+ c.check(
+ f"the canvas covers every edge on engine={gv._engine}",
+ all(
+ 0 <= col < gv.lay.width and 0 <= row < gv.lay.height
+ for rt in _routes_of(gv.lay)
+ for row, col in rt
+ ),
+ f"canvas {gv.lay.width}x{gv.lay.height}",
+ )
+ c.check("e cycles back round", seen[0] == seen[-1], str(seen))
+ c.check("native was one of them", "native" in seen, str(seen))
+ c.check(
+ "the status names the engine",
+ "graph:" in c.status() or gv.fc.name in c.status(),
+ c.status(),
+ )
+ if ea is not None:
+ c.check(
+ "the cursor address is unchanged by relayout", gv._cursor_ea() is not None
+ )
+
+
+def _routes_of(lay):
+ """Every painted point, as (row, col) pairs, straight out of the index."""
+ out = []
+ for row, runs in lay.painting.hruns.items():
+ out.append(
+ [(row, lo) for lo, _hi, _s, _e in runs]
+ + [(row, hi) for _lo, hi, _s, _e in runs]
+ )
+ for lo, hi, col, _s, _e in lay.painting.vruns:
+ out.append([(lo, col), (hi, col)])
+ return out
@scenario("graph_render")
@@ -3430,6 +5089,7 @@ async def s_graph_render(c: Ctx):
if gv.lay is None:
c.check("graph loaded", False)
return
+
# The layout being ready (`gv.lay`) is not the same as the view having a
# SIZE to render into -- that needs a laid-out frame, and reading glyphs
# before one lands scrapes an empty canvas. Gate on the paint itself.
@@ -3438,17 +5098,28 @@ async def s_graph_render(c: Ctx):
await c.wait(lambda: gv.size.height > 0 and "\u250c" in _blob(), 10)
blob = _blob()
- c.check("boxes are drawn", blob.count("\u250c") >= 1 and blob.count("\u2502") > 4,
- f"corners={blob.count(chr(0x250c))} verts={blob.count(chr(0x2502))}")
- c.check("edges are drawn", any(ch in blob for ch in "\u25bc\u2570\u256d\u256e\u256f"),
- "no edge glyphs on screen")
+ c.check(
+ "boxes are drawn",
+ blob.count("\u250c") >= 1 and blob.count("\u2502") > 4,
+ f"corners={blob.count(chr(0x250C))} verts={blob.count(chr(0x2502))}",
+ )
+ c.check(
+ "edges are drawn",
+ any(ch in blob for ch in "\u25bc\u2570\u256d\u256e\u256f"),
+ "no edge glyphs on screen",
+ )
ea = gv._cursor_ea()
head = gv.cur_head()
- c.check("the cursor block's instruction text is on screen",
- head is not None and head.text.split(" ")[0] in blob,
- f"mnem={head.text.split(' ')[0] if head else None}")
- c.check("the address gutter renders at full zoom",
- ea is not None and f"{ea:08X}" in blob, f"ea={ea:#x}")
+ c.check(
+ "the cursor block's instruction text is on screen",
+ head is not None and head.text.split(" ")[0] in blob,
+ f"mnem={head.text.split(' ')[0] if head else None}",
+ )
+ c.check(
+ "the address gutter renders at full zoom",
+ ea is not None and f"{ea:08X}" in blob,
+ f"ea={ea:#x}",
+ )
# minimap on/off actually changes the picture
before = blob
await c.press("m")
@@ -3480,20 +5151,26 @@ async def s_graph_click(c: Ctx):
top, left = int(gv.scroll_offset.y), int(gv.scroll_offset.x)
target = None
for n in gv.lay.nodes:
- if (n.id != gv.cursor_node and top <= n.y + 1 < top + gv.size.height - 1
- and left <= n.x + 2 < left + gv.size.width - 2):
+ if (
+ n.id != gv.cursor_node
+ and top <= n.y + 1 < top + gv.size.height - 1
+ and left <= n.x + 2 < left + gv.size.width - 2
+ ):
target = n
break
if target is None:
c.check("a second block is visible to click", True, "(skipped: none on screen)")
return
- PAD = 1 # GraphView { padding: 0 1 }
- await c.pilot.click(GraphView,
- offset=(PAD + target.x + 2 - left, target.y + 1 - top))
+ PAD = 1 # GraphView { padding: 0 1 }
+ await c.pilot.click(
+ GraphView, offset=(PAD + target.x + 2 - left, target.y + 1 - top)
+ )
await c.pause(0.15)
- c.check("clicking a block moves the cursor into it",
- gv.cursor_node == target.id,
- f"node={gv.cursor_node} want={target.id}")
+ c.check(
+ "clicking a block moves the cursor into it",
+ gv.cursor_node == target.id,
+ f"node={gv.cursor_node} want={target.id}",
+ )
@scenario("graph_minimap")
@@ -3510,14 +5187,19 @@ async def s_graph_minimap(c: Ctx):
# frame -- not just a settled app.
await c.wait(lambda: gv._minimap_rect() is not None, 5)
rect = gv._minimap_rect()
- c.check("the minimap has a hit-box while it's shown", rect is not None,
- f"size={gv.size} shown={gv._show_minimap}")
+ c.check(
+ "the minimap has a hit-box while it's shown",
+ rect is not None,
+ f"size={gv.size} shown={gv._show_minimap}",
+ )
if rect is None:
return
left, top, mw, mh = rect
- c.check("it sits inside the pane, clear of the scrollbar",
- left + mw <= gv.size.width - 1,
- f"left={left} w={mw} pane={gv.size.width}")
+ c.check(
+ "it sits inside the pane, clear of the scrollbar",
+ left + mw <= gv.size.width - 1,
+ f"left={left} w={mw} pane={gv.size.width}",
+ )
# a big graph, so the overview actually maps to somewhere far away
big = c.find_func(lambda f: f.size > 0x300) or fn
@@ -3545,33 +5227,45 @@ async def s_graph_minimap(c: Ctx):
PAD = 1
await c.pilot.click(GraphView, offset=(PAD + left + mw // 2, top + mh - 2))
await c.pause(0.2)
- c.check("clicking low on the minimap scrolls the view down",
- gv.scroll_offset.y > 0, f"scroll_y={gv.scroll_offset.y}")
+ c.check(
+ "clicking low on the minimap scrolls the view down",
+ gv.scroll_offset.y > 0,
+ f"scroll_y={gv.scroll_offset.y}",
+ )
# Most of a graph is padding, so a coordinate-accurate jump would park you
# in empty space with the cursor left behind: every minimap click must land
# on a block and take the cursor with it.
landed = gv.lay.by_id.get(gv.cursor_node)
- c.check("it snaps the cursor onto a real block",
- landed is not None and landed.block is not None,
- f"node={gv.cursor_node}")
- c.check("and that block is what the viewport is showing",
- landed is not None
- and int(gv.scroll_offset.y) <= landed.y + landed.h
- and landed.y <= int(gv.scroll_offset.y) + gv.size.height,
- f"node.y={landed.y if landed else None} "
- f"scroll={gv.scroll_offset.y} h={gv.size.height}")
+ c.check(
+ "it snaps the cursor onto a real block",
+ landed is not None and landed.block is not None,
+ f"node={gv.cursor_node}",
+ )
+ c.check(
+ "and that block is what the viewport is showing",
+ landed is not None
+ and int(gv.scroll_offset.y) <= landed.y + landed.h
+ and landed.y <= int(gv.scroll_offset.y) + gv.size.height,
+ f"node.y={landed.y if landed else None} "
+ f"scroll={gv.scroll_offset.y} h={gv.size.height}",
+ )
low_node = gv.cursor_node
# and the top of the minimap brings it back to a block up there
await c.pilot.click(GraphView, offset=(PAD + left + mw // 2, top + 1))
await c.pause(0.2)
top_node = gv.lay.by_id.get(gv.cursor_node)
- c.check("clicking high on the minimap goes back up",
- top_node is not None and gv.cursor_node != low_node
- and top_node.y < gv.lay.by_id[low_node].y,
- f"top={gv.cursor_node} low={low_node}")
- c.check("the cursor still has a real address after a minimap jump",
- gv._cursor_ea() is not None)
+ c.check(
+ "clicking high on the minimap goes back up",
+ top_node is not None
+ and gv.cursor_node != low_node
+ and top_node.y < gv.lay.by_id[low_node].y,
+ f"top={gv.cursor_node} low={low_node}",
+ )
+ c.check(
+ "the cursor still has a real address after a minimap jump",
+ gv._cursor_ea() is not None,
+ )
# with the minimap hidden the same click is an ordinary canvas click
await c.press("m")
@@ -3582,16 +5276,19 @@ async def s_graph_minimap(c: Ctx):
# Panning into the padding (which is most of the canvas) must not strand
# you on a blank screen with nothing to navigate back by.
- gv.scroll_to(y=max(gv.lay.height - 1, 0), x=max(gv.lay.width - 1, 0),
- animate=False)
+ gv.scroll_to(y=max(gv.lay.height - 1, 0), x=max(gv.lay.width - 1, 0), animate=False)
await c.pause(0.1)
- c.check("a pan past the graph leaves the viewport empty",
- not gv._viewport_has_block() or True) # setup, not an assertion
+ c.check(
+ "a pan past the graph leaves the viewport empty",
+ not gv._viewport_has_block() or True,
+ ) # setup, not an assertion
gv._snap_into_view()
await c.pause(0.1)
- c.check("panning into empty padding snaps back to a block",
- gv._viewport_has_block(),
- f"scroll={gv.scroll_offset} canvas={gv.lay.width}x{gv.lay.height}")
+ c.check(
+ "panning into empty padding snaps back to a block",
+ gv._viewport_has_block(),
+ f"scroll={gv.scroll_offset} canvas={gv.lay.width}x{gv.lay.height}",
+ )
@scenario("graph_rename")
@@ -3610,8 +5307,10 @@ async def s_graph_rename(c: Ctx):
new = f"gtest_{os.getpid()}"
await c.press("n")
await c.wait(lambda: app.query_one("#rename", Input).display, 10)
- c.check("n opens the rename prompt from the graph",
- app.query_one("#rename", Input).display)
+ c.check(
+ "n opens the rename prompt from the graph",
+ app.query_one("#rename", Input).display,
+ )
inp = app.query_one("#rename", Input)
inp.value = ""
await c.type(new)
@@ -3622,12 +5321,16 @@ async def s_graph_rename(c: Ctx):
got = app.program.resolve(new)
except Exception: # noqa: BLE001
got = None
- c.check("the rename reached the database", got == ea,
- f"resolve({new}) -> {got if got is None else hex(got)} want {ea:#x}")
+ c.check(
+ "the rename reached the database",
+ got == ea,
+ f"resolve({new}) -> {got if got is None else hex(got)} want {ea:#x}",
+ )
# revert, so the suite stays idempotent
if got is not None:
- app.program.client.invoke(
- "rename", batch={"data": {"addr": hex(ea), "new": ""}})
+ app.program.client.call(
+ remote_ops.rename, batch={"data": {"addr": hex(ea), "new": ""}}
+ )
app.program.bump_names()
@@ -3645,17 +5348,30 @@ async def s_graph_sticky(c: Ctx):
c.check("a second function exists", True, "(skipped)")
return
app._goto(other.name)
- ok = await c.wait(lambda: app._cur is not None and app._cur.ea == other.addr
- and app._active == "graph"
- and gv.fc is not None and gv.fc.func_ea == other.addr, 60)
- c.check("a goto from the graph lands in the next function's graph", ok,
- f"active={app._active} sticky={app._graph_sticky} "
- f"cur={app._cur.ea if app._cur else None} "
- f"fc={gv.fc.func_ea if gv.fc else None} want={other.addr:#x}")
+ ok = await c.wait(
+ lambda: (
+ app._cur is not None
+ and app._cur.ea == other.addr
+ and app._active == "graph"
+ and gv.fc is not None
+ and gv.fc.func_ea == other.addr
+ ),
+ 60,
+ )
+ c.check(
+ "a goto from the graph lands in the next function's graph",
+ ok,
+ f"active={app._active} sticky={app._graph_sticky} "
+ f"cur={app._cur.ea if app._cur else None} "
+ f"fc={gv.fc.func_ea if gv.fc else None} want={other.addr:#x}",
+ )
await c.press("space")
await c.wait(lambda: app._active != "graph", 15)
- c.check("space still leaves graph mode", app._active == "listing",
- f"active={app._active}")
+ c.check(
+ "space still leaves graph mode",
+ app._active == "listing",
+ f"active={app._active}",
+ )
c.check("and it stops being sticky", app._graph_sticky is False)
@@ -3673,13 +5389,14 @@ async def run(binary, only=None):
#
# So: work on a scratch copy, seeded from a golden database that nothing
# ever writes back to.
- async with staged(binary, lambda p: IdaTui(open_path=p, keepalive=False),
- prefix="idatui-pilot-") as target:
+ async with staged(
+ binary, lambda p: IdaTui(open_path=p, keepalive=False), prefix="idatui-pilot-"
+ ) as target:
await _run_on(target, only)
async def _run_on(binary, only=None):
- # Code Mode attaches a registered GUI or starts/reuses a managed worker.
+ # IDA Nexus attaches a registered GUI or starts/reuses a managed worker.
app = IdaTui(open_path=binary, keepalive=False)
async with app.run_test(size=(140, 44)) as pilot:
c = Ctx(app, pilot)
@@ -3696,7 +5413,9 @@ async def _run_on(binary, only=None):
except _StopSuite:
raise
except Exception as e: # noqa: BLE001 — isolate: one scenario's crash
- print(f"── {name} ({asyncio.get_event_loop().time() - _t0:.1f}s) CRASHED")
+ print(
+ f"── {name} ({asyncio.get_event_loop().time() - _t0:.1f}s) CRASHED"
+ )
c.check("scenario did not crash", False, f"{type(e).__name__}: {e}")
traceback.print_exc()
# Headless run_test does not reliably emit App.Unmount; explicitly release
@@ -3732,7 +5451,9 @@ def main(argv):
if binary is None: # default target for the pilot
binary = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
- "targets", "echo")
+ "targets",
+ "echo",
+ )
try:
asyncio.run(run(binary, only))
except _StopSuite:
diff --git a/tests/test_search.py b/tests/test_search.py
index 6fd1a25..7b80dda 100644
--- a/tests/test_search.py
+++ b/tests/test_search.py
@@ -16,8 +16,13 @@ import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from idatui.search import ( # noqa: E402
- BYTES, TEXT, classify, looks_like_bytes, normalise_pattern,
- pattern_problem, probably_meant_bytes,
+ BYTES,
+ TEXT,
+ classify,
+ looks_like_bytes,
+ normalise_pattern,
+ pattern_problem,
+ probably_meant_bytes,
)
PASS = FAIL = 0
@@ -35,63 +40,89 @@ def check(name, cond, detail=""):
def main() -> int:
# -- the asymmetry: hex-looking WORDS must stay text --------------------- #
- for word in ("add", "dead", "beef", "cafe", "ff", "0", "abcdef",
- "decode", "face"):
- check(f"{word!r} searches text, not bytes",
- classify(word)[0] == TEXT, classify(word))
+ for word in ("add", "dead", "beef", "cafe", "ff", "0", "abcdef", "decode", "face"):
+ check(
+ f"{word!r} searches text, not bytes",
+ classify(word)[0] == TEXT,
+ classify(word),
+ )
# -- unambiguous byte patterns ------------------------------------------ #
- for pat in ("48 8b ?? c3", "B8 ? ? ? ? 90", "48,8b,05", "de ad be ef",
- "48 8? ?? 24", "??"):
- check(f"{pat!r} searches bytes", classify(pat)[0] == BYTES,
- classify(pat))
+ for pat in (
+ "48 8b ?? c3",
+ "B8 ? ? ? ? 90",
+ "48,8b,05",
+ "de ad be ef",
+ "48 8? ?? 24",
+ "??",
+ ):
+ check(f"{pat!r} searches bytes", classify(pat)[0] == BYTES, classify(pat))
- check("a quoted literal is a byte pattern",
- classify('"Hello", 0')[0] == BYTES)
+ check("a quoted literal is a byte pattern", classify('"Hello", 0')[0] == BYTES)
# A TYPO in a byte pattern must stay a byte pattern, so it can be refused
# with a reason. Falling back to text answers "no match", which is
# indistinguishable from "those bytes are not in this binary".
- check("a typo'd byte pattern is still a byte pattern",
- classify("48 zz c3")[0] == BYTES, classify("48 zz c3"))
- check("and it is refused by name",
- "'zz'" in (pattern_problem("48 zz c3") or ""))
- check("but a word among bytes is prose",
- classify("add ff")[0] == TEXT and classify("mov rdi, rax")[0] == TEXT,
- classify("add ff"))
+ check(
+ "a typo'd byte pattern is still a byte pattern",
+ classify("48 zz c3")[0] == BYTES,
+ classify("48 zz c3"),
+ )
+ check("and it is refused by name", "'zz'" in (pattern_problem("48 zz c3") or ""))
+ check(
+ "but a word among bytes is prose",
+ classify("add ff")[0] == TEXT and classify("mov rdi, rax")[0] == TEXT,
+ classify("add ff"),
+ )
check("prose stays text", classify("mov rdi, rax")[0] == TEXT)
check("a call target stays text", classify("call cs:__isoc99_scanf")[0] == TEXT)
- check("an empty query is text (nothing to search yet)",
- classify("")[0] == TEXT and not looks_like_bytes(""))
+ check(
+ "an empty query is text (nothing to search yet)",
+ classify("")[0] == TEXT and not looks_like_bytes(""),
+ )
# -- explicit wins over any guess --------------------------------------- #
check("hex: forces bytes", classify("hex: dead") == (BYTES, "dead"))
check("bytes: forces bytes too", classify("bytes:dead") == (BYTES, "dead"))
check("text: forces text", classify("text: 48 8b c3") == (TEXT, "48 8b c3"))
- check("F2's forced mode beats the shape",
- classify("dead", forced=BYTES) == (BYTES, "dead")
- and classify("48 8b c3", forced=TEXT) == (TEXT, "48 8b c3"))
- check("a prefix beats even the forced mode",
- classify("text:48 8b c3", forced=BYTES)[0] == TEXT)
+ check(
+ "F2's forced mode beats the shape",
+ classify("dead", forced=BYTES) == (BYTES, "dead")
+ and classify("48 8b c3", forced=TEXT) == (TEXT, "48 8b c3"),
+ )
+ check(
+ "a prefix beats even the forced mode",
+ classify("text:48 8b c3", forced=BYTES)[0] == TEXT,
+ )
# -- the shapes people paste -------------------------------------------- #
check("commas become spaces", normalise_pattern("48,8b,05") == "48 8b 05")
- check("a run with no separators is split into bytes",
- normalise_pattern("488B05C3") == "48 8B 05 C3")
+ check(
+ "a run with no separators is split into bytes",
+ normalise_pattern("488B05C3") == "48 8B 05 C3",
+ )
check("whitespace is squeezed", normalise_pattern(" 48 8b\t05 ") == "48 8b 05")
- check("a quoted literal keeps its own spacing",
- normalise_pattern('"Hello, world", 0') == '"Hello, world", 0')
+ check(
+ "a quoted literal keeps its own spacing",
+ normalise_pattern('"Hello, world", 0') == '"Hello, world", 0',
+ )
# -- refusing a bad pattern with a reason -------------------------------- #
- check("an empty pattern says what to type",
- "48 8b" in (pattern_problem("") or ""))
- check("a non-hex token is named",
- "'zz'" in (pattern_problem("48 zz c3") or ""), pattern_problem("48 zz c3"))
- check("a good pattern has no complaint",
- pattern_problem("48 8b ?? c3") is None
- and pattern_problem('"Hi", 0') is None)
- check("an odd-length run is refused rather than silently split",
- pattern_problem("488B0") is not None, pattern_problem("488B0"))
+ check("an empty pattern says what to type", "48 8b" in (pattern_problem("") or ""))
+ check(
+ "a non-hex token is named",
+ "'zz'" in (pattern_problem("48 zz c3") or ""),
+ pattern_problem("48 zz c3"),
+ )
+ check(
+ "a good pattern has no complaint",
+ pattern_problem("48 8b ?? c3") is None and pattern_problem('"Hi", 0') is None,
+ )
+ check(
+ "an odd-length run is refused rather than silently split",
+ pattern_problem("488B0") is not None,
+ pattern_problem("488B0"),
+ )
print(f"\n{PASS} passed, {FAIL} failed")
return 1 if FAIL else 0
diff --git a/tests/test_thumb_ui.py b/tests/test_thumb_ui.py
index f12fbc4..d55fa91 100644
--- a/tests/test_thumb_ui.py
+++ b/tests/test_thumb_ui.py
@@ -22,17 +22,20 @@ import tempfile
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+from _fixtures import fast_keys # noqa: E402
from textual.widgets import Static # noqa: E402
-from _fixtures import fast_keys # noqa: E402
from idatui._sync import settle # noqa: E402
-fast_keys() # ~85ms -> ~2ms per keypress; see _fixtures.fast_keys
+fast_keys() # ~85ms -> ~2ms per keypress; see _fixtures.fast_keys
from idatui.app import DecompView, IdaTui, ListingView # noqa: E402
PASS = FAIL = 0
-BIN = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
- "experiments", "fibonacci.bin")
+BIN = os.path.join(
+ os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
+ "experiments",
+ "fibonacci.bin",
+)
def check(name, ok, detail=""):
@@ -45,12 +48,11 @@ def check(name, ok, detail=""):
print(f" FAIL {name} {detail}")
-
#: Every phase gets its OWN copy of the fixture.
#:
#: This suite used to delete <BIN>.i64 and reopen the SAME path for each phase.
#: That was safe when the TUI owned a private worker that died with it; under
-#: Code Mode the database is leased and the previous phase's worker can still
+#: IDA Nexus the database is leased and the previous phase's worker can still
#: hold it through its lease grace, so the delete raced a live owner and the
#: next open never produced a listing (the crash this fixed). Separate paths
#: cannot collide, and nothing has to wait for anyone else to let go.
@@ -89,19 +91,22 @@ async def wait(pred, pilot, t=240.0):
async def run() -> int:
# A fresh database every time: the T flag and the segment's addressing mode
# are SAVED in the .i64, so a previous run would answer the question for us.
- app = IdaTui(open_path=fresh_copy(BIN, "arm"), keepalive=False,
- load_args="-parm")
+ app = IdaTui(open_path=fresh_copy(BIN, "arm"), keepalive=False, load_args="-parm")
async with app.run_test(size=(140, 44)) as pilot:
- await wait(lambda: app._func_index is not None
- and app._func_index.complete, pilot)
+ await wait(
+ lambda: app._func_index is not None and app._func_index.complete, pilot
+ )
await wait(lambda: app._cur is not None, pilot, 60)
lst = app.query_one(ListingView)
lst.focus()
lst.cursor = lst.model.index_of_ea(0)
lst._scroll_cursor_into_view()
await settle(app)
- check("starts undefined at the entry", lst.model.get(lst.cursor).kind == "unknown",
- f"{lst.model.get(lst.cursor).text!r}")
+ check(
+ "starts undefined at the entry",
+ lst.model.get(lst.cursor).kind == "unknown",
+ f"{lst.model.get(lst.cursor).text!r}",
+ )
# `c` in the wrong mode: this is the failure being fixed. It must NOT
# quietly carve garbage — either it refuses, or whatever it makes is not
@@ -113,9 +118,11 @@ async def run() -> int:
# queue doesn't say better.
await settle(app)
h = lst.model.get(lst.model.index_of_ea(0))
- check("`c` alone does not produce the Thumb prologue",
- h is None or h.kind != "code" or "PUSH" not in h.text.upper(),
- f"{h.text if h else None!r}")
+ check(
+ "`c` alone does not produce the Thumb prologue",
+ h is None or h.kind != "code" or "PUSH" not in h.text.upper(),
+ f"{h.text if h else None!r}",
+ )
m1 = lst.model
await pilot.press("t")
@@ -130,23 +137,31 @@ async def run() -> int:
check("the status says it switched to Thumb", "Thumb" in status, status[:90])
# Thumb doesn't exist in AArch64, and -parm on a headerless blob gives a
# 64-bit segment, so setting T alone would change nothing and look broken.
- check("and says it forced the segment to 32-bit",
- "32-bit" in status, status[:90])
+ check(
+ "and says it forced the segment to 32-bit", "32-bit" in status, status[:90]
+ )
m = lst.model
rows = [m.get(m.index_of_ea(ea)) for ea in (0x0, 0x2, 0x4)]
- check("the entry decodes as Thumb",
- rows[0] is not None and rows[0].kind == "code"
- and "PUSH" in rows[0].text.upper(),
- f"{rows[0].text if rows[0] else None!r}")
+ check(
+ "the entry decodes as Thumb",
+ rows[0] is not None
+ and rows[0].kind == "code"
+ and "PUSH" in rows[0].text.upper(),
+ f"{rows[0].text if rows[0] else None!r}",
+ )
# 16-bit instructions: the addresses are 2 apart, which is the whole
# point — in ARM mode these would be one 4-byte instruction.
- check("instructions are 16-bit wide",
- all(r is not None and r.kind == "code" and r.size == 2 for r in rows),
- f"{[(hex(r.ea), r.size, r.text) for r in rows if r]}")
- check("and it kept disassembling past the first one",
- sum(1 for i in range(20) if (m.get(i) or h).kind == "code") > 5,
- "expected a run of instructions, not one")
+ check(
+ "instructions are 16-bit wide",
+ all(r is not None and r.kind == "code" and r.size == 2 for r in rows),
+ f"{[(hex(r.ea), r.size, r.text) for r in rows if r]}",
+ )
+ check(
+ "and it kept disassembling past the first one",
+ sum(1 for i in range(20) if (m.get(i) or h).kind == "code") > 5,
+ "expected a run of instructions, not one",
+ )
# Toggling back must be possible — the mode is a guess and guesses get
# revised.
@@ -164,11 +179,13 @@ async def run() -> int:
# disassembly that F5 can never turn into pseudocode. The database's bitness
# is fixed at load and cannot be corrected afterwards, so the only honest
# thing is to say so.
- app = IdaTui(open_path=fresh_copy(BIN, "arm64"), keepalive=False,
- load_args="-parm") # 64-bit
+ app = IdaTui(
+ open_path=fresh_copy(BIN, "arm64"), keepalive=False, load_args="-parm"
+ ) # 64-bit
async with app.run_test(size=(140, 44)) as pilot:
- await wait(lambda: app._func_index is not None
- and app._func_index.complete, pilot)
+ await wait(
+ lambda: app._func_index is not None and app._func_index.complete, pilot
+ )
await wait(lambda: app._cur is not None, pilot, 60)
lst = app.query_one(ListingView)
lst.focus()
@@ -179,8 +196,11 @@ async def run() -> int:
await pilot.press("t")
await settle(app, lambda: "64-bit" in status_of(app), timeout=60)
status = status_of(app)
- check("a 64-bit database warns that Hex-Rays won't decompile",
- "64-bit" in status and "decompile" in status, status[:120])
+ check(
+ "a 64-bit database warns that Hex-Rays won't decompile",
+ "64-bit" in status and "decompile" in status,
+ status[:120],
+ )
check("and names the fix", "ARMv7-A" in status, status[:120])
# And if you ignore that and carry on, the failure has to say WHY. The
@@ -193,66 +213,108 @@ async def run() -> int:
await pilot.press("p")
# The function appearing in the index IS the signal; the model identity
# never was one.
- await settle(app, lambda: app._func_index is not None
- and len(app._func_index) > 0, timeout=60)
+ await settle(
+ app,
+ lambda: app._func_index is not None and len(app._func_index) > 0,
+ timeout=60,
+ )
await pilot.press("tab")
- await wait(lambda: "cannot decompile" in
- str(app.query_one("#status", Static).render()), pilot, 90)
+ await wait(
+ lambda: (
+ "cannot decompile" in str(app.query_one("#status", Static).render())
+ ),
+ pilot,
+ 90,
+ )
status = str(app.query_one("#status", Static).render())
# The message must say what to DO. Hex-Rays' own sentence ("only 64-bit
# functions can be decompiled in the current database") describes the
# database, not the fix, and is long enough that a status bar cuts off
# the end — which is where an appended hint would have lived.
- check("a failed decompile names the fix, not just the diagnosis",
- "Ctrl+L" in status and "ARMv7-A" in status, status[:130])
- check("and the reason survives the view reloading under it",
- "cannot decompile" in status, status[:130])
- check("the message fits a narrow status bar",
- len(status) < 110, f"{len(status)} chars: {status[:130]}")
+ check(
+ "a failed decompile names the fix, not just the diagnosis",
+ "Ctrl+L" in status and "ARMv7-A" in status,
+ status[:130],
+ )
+ check(
+ "and the reason survives the view reloading under it",
+ "cannot decompile" in status,
+ status[:130],
+ )
+ check(
+ "the message fits a narrow status bar",
+ len(status) < 110,
+ f"{len(status)} chars: {status[:130]}",
+ )
# -- the whole point: a 32-bit database decompiles ---------------------- #
- app = IdaTui(open_path=fresh_copy(BIN, "armv7a"), keepalive=False,
- load_args="-parm:ARMv7-A")
+ app = IdaTui(
+ open_path=fresh_copy(BIN, "armv7a"), keepalive=False, load_args="-parm:ARMv7-A"
+ )
async with app.run_test(size=(140, 44)) as pilot:
- await wait(lambda: app._func_index is not None
- and app._func_index.complete, pilot)
+ await wait(
+ lambda: app._func_index is not None and app._func_index.complete, pilot
+ )
# A 32-bit ARM database also lets auto-analysis do its job on Thumb code,
# which is why this one lands in the symbol picker rather than nowhere.
- check("a 32-bit ARM database finds functions by itself",
- len(app._func_index) > 5, f"n={len(app._func_index)}")
+ check(
+ "a 32-bit ARM database finds functions by itself",
+ len(app._func_index) > 5,
+ f"n={len(app._func_index)}",
+ )
await pilot.press("escape")
await settle(app, lambda: type(app.screen).__name__ == "Screen")
f = app._func_index.all_loaded()[0]
app._goto_ea(f.addr, push=True)
- await wait(lambda: app._cur is not None
- and app.query_one(ListingView).model is not None, pilot, 60)
+ await wait(
+ lambda: (
+ app._cur is not None and app.query_one(ListingView).model is not None
+ ),
+ pilot,
+ 60,
+ )
app.query_one(ListingView).focus()
await pilot.press("tab")
dec = app.query_one(DecompView)
got = await wait(lambda: dec.display and dec._texts, pilot, 90)
- check("Tab decompiles a Thumb function", got and len(dec._texts) > 3,
- f"lines={len(dec._texts or [])}")
- check("and it reads like C",
- any("(" in t and ")" in t for t in (dec._texts or [])[:3]),
- f"{(dec._texts or [])[:3]}")
+ check(
+ "Tab decompiles a Thumb function",
+ got and len(dec._texts) > 3,
+ f"lines={len(dec._texts or [])}",
+ )
+ check(
+ "and it reads like C",
+ any("(" in t and ")" in t for t in (dec._texts or [])[:3]),
+ f"{(dec._texts or [])[:3]}",
+ )
# -- Thumb entry points from a vector table ----------------------------- #
# An ARM function pointer carries the mode in bit 0: odd means Thumb. A
# Cortex-M vector table is therefore a list of Thumb entry points, and IDA
# won't follow them on a headerless image because nothing says those words
# are pointers at all.
- vec = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
- "experiments", "cortexm.bin")
+ vec = os.path.join(
+ os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
+ "experiments",
+ "cortexm.bin",
+ )
if not os.path.isfile(vec):
check("the cortexm fixture exists", False, vec)
else:
- app = IdaTui(open_path=fresh_copy(vec, "cortexm"), keepalive=False,
- load_args="-parm:ARMv7-M")
+ app = IdaTui(
+ open_path=fresh_copy(vec, "cortexm"),
+ keepalive=False,
+ load_args="-parm:ARMv7-M",
+ )
async with app.run_test(size=(140, 44)) as pilot:
- await wait(lambda: app._func_index is not None
- and app._func_index.complete, pilot)
- check("a bare vector table gives IDA nothing to go on",
- len(app._func_index) == 0, f"n={len(app._func_index)}")
+ await wait(
+ lambda: app._func_index is not None and app._func_index.complete, pilot
+ )
+ check(
+ "a bare vector table gives IDA nothing to go on",
+ len(app._func_index) == 0,
+ f"n={len(app._func_index)}",
+ )
if type(app.screen).__name__ != "Screen":
await pilot.press("escape")
await settle(app, lambda: type(app.screen).__name__ == "Screen")
@@ -264,20 +326,32 @@ async def run() -> int:
lst._scroll_cursor_into_view()
await settle(app)
await pilot.press("T")
- await wait(lambda: app._func_index is not None
- and len(app._func_index) >= 3, pilot, 90)
+ await wait(
+ lambda: app._func_index is not None and len(app._func_index) >= 3,
+ pilot,
+ 90,
+ )
names = sorted(f.name for f in app._func_index.all_loaded())
- check("scanning the table finds the Thumb handlers",
- names == ["sub_200", "sub_240", "sub_280"], f"{names}")
+ check(
+ "scanning the table finds the Thumb handlers",
+ names == ["sub_200", "sub_240", "sub_280"],
+ f"{names}",
+ )
# The table also holds an even word (the initial stack pointer), an
# even in-range word and an odd word pointing outside the image. All
# three must be ignored — marking a data word as code corrupts the
# listing, so the cost of a false positive is high.
- check("and ignores the words that aren't Thumb pointers",
- len(app._func_index) == 3, f"n={len(app._func_index)}")
+ check(
+ "and ignores the words that aren't Thumb pointers",
+ len(app._func_index) == 3,
+ f"n={len(app._func_index)}",
+ )
status = str(app.query_one("#status", Static).render())
- check("the result survives the reload AND the reindex",
- "3 Thumb entries" in status, status[:90])
+ check(
+ "the result survives the reload AND the reindex",
+ "3 Thumb entries" in status,
+ status[:90],
+ )
drop_scratch()
diff --git a/tests/test_trace.py b/tests/test_trace.py
index 8803cda..661e350 100644
--- a/tests/test_trace.py
+++ b/tests/test_trace.py
@@ -31,7 +31,7 @@ LINES = [
"rip=0x40100b,mw=0xff4:2a000000",
"rax=0x2a,rip=0x40100e,mr=0xff4:2a000000",
"rbp=0x0,rsp=0x1000,rip=0x401010,mr=0xff8:0000000000000000",
- "rip=0x401000", # loop back: 0x401000 executes twice
+ "rip=0x401000", # loop back: 0x401000 executes twice
"rip=0x401001",
]
@@ -56,148 +56,220 @@ def main() -> int:
t = Trace.load(path)
check("every line is one timestamp", t.length == len(LINES), f"{t.length}")
- check("the sidecar .info is picked up",
- t.info is not None and t.info.arch == "x86_64" and
- t.info.start_code == 0x401000)
- check("PC is tracked per timestamp",
- [t.ip(i) for i in range(6)] ==
- [0x401000, 0x401001, 0x401004, 0x40100b, 0x40100e, 0x401010])
+ check(
+ "the sidecar .info is picked up",
+ t.info is not None
+ and t.info.arch == "x86_64"
+ and t.info.start_code == 0x401000,
+ )
+ check(
+ "PC is tracked per timestamp",
+ [t.ip(i) for i in range(6)]
+ == [0x401000, 0x401001, 0x401004, 0x40100B, 0x40100E, 0x401010],
+ )
# -- register reconstruction --------------------------------------- #
- check("a register keeps its value until it changes",
- t.register("rsp", 0) == 0x1000 and t.register("rsp", 1) == 0xff8
- and t.register("rsp", 4) == 0xff8 and t.register("rsp", 5) == 0x1000)
- check("the full first line seeds every register",
- t.register("rbx", 3) == 0)
- check("an unknown register is None, not 0",
- t.register("r15", 0) is None)
- check("changed() is what the INSTRUCTION did, not the state",
- t.changed(4) == {"rax", "rip"}, f"{t.changed(4)}")
+ check(
+ "a register keeps its value until it changes",
+ t.register("rsp", 0) == 0x1000
+ and t.register("rsp", 1) == 0xFF8
+ and t.register("rsp", 4) == 0xFF8
+ and t.register("rsp", 5) == 0x1000,
+ )
+ check("the full first line seeds every register", t.register("rbx", 3) == 0)
+ check("an unknown register is None, not 0", t.register("r15", 0) is None)
+ check(
+ "changed() is what the INSTRUCTION did, not the state",
+ t.changed(4) == {"rax", "rip"},
+ f"{t.changed(4)}",
+ )
# "which instruction set this register?" — the question a trace exists
# to answer.
- check("last_write finds the instruction that set a value",
- t.last_write("rax", 5) == 4 and t.last_write("rbp", 4) == 2,
- f"{t.last_write('rax', 5)}, {t.last_write('rbp', 4)}")
- check("next_write looks forward",
- t.next_write("rbp", 2) == 5 and t.next_write("rbp", 5) is None)
+ check(
+ "last_write finds the instruction that set a value",
+ t.last_write("rax", 5) == 4 and t.last_write("rbp", 4) == 2,
+ f"{t.last_write('rax', 5)}, {t.last_write('rbp', 4)}",
+ )
+ check(
+ "next_write looks forward",
+ t.next_write("rbp", 2) == 5 and t.next_write("rbp", 5) is None,
+ )
# -- memory --------------------------------------------------------- #
ops = t.memory_ops(3)
- check("a write is captured with its bytes",
- len(ops) == 1 and ops[0].write and ops[0].addr == 0xff4
- and ops[0].data == bytes.fromhex("2a000000"), f"{ops}")
- check("a read is captured and marked as a read",
- [o.write for o in t.memory_ops(4)] == [False])
+ check(
+ "a write is captured with its bytes",
+ len(ops) == 1
+ and ops[0].write
+ and ops[0].addr == 0xFF4
+ and ops[0].data == bytes.fromhex("2a000000"),
+ f"{ops}",
+ )
+ check(
+ "a read is captured and marked as a read",
+ [o.write for o in t.memory_ops(4)] == [False],
+ )
check("an instruction with no memory has none", t.memory_ops(2) == [])
# -- memory state at a timestamp ------------------------------------ #
# The trace wrote 2a000000 at 0xff4 (t=3) and read it back (t=4); it
# pushed/popped 8 zero bytes at 0xff8 (t=1 write, t=5 read).
- d, k = t.memory(0xff4, 4, 3)
- check("memory reflects a write as of that timestamp",
- d == bytes.fromhex("2a000000") and k == b"\x01" * 4, f"{d.hex()} {k.hex()}")
- d, k = t.memory(0xff4, 4, 2)
- check("and does NOT reflect it before the write happened",
- k == b"\x00" * 4, f"{d.hex()} known={k.hex()}")
+ d, k = t.memory(0xFF4, 4, 3)
+ check(
+ "memory reflects a write as of that timestamp",
+ d == bytes.fromhex("2a000000") and k == b"\x01" * 4,
+ f"{d.hex()} {k.hex()}",
+ )
+ d, k = t.memory(0xFF4, 4, 2)
+ check(
+ "and does NOT reflect it before the write happened",
+ k == b"\x00" * 4,
+ f"{d.hex()} known={k.hex()}",
+ )
# A trace only knows what it saw. A byte nobody touched is unknown, and
# must not be reported as zero — that distinction is the entire reason
# to read memory from a trace instead of from the database.
d, k = t.memory(0x5000, 4, t.length - 1)
- check("untouched memory is unknown, not zero",
- k == b"\x00" * 4 and d == b"\x00" * 4, f"known={k.hex()}")
+ check(
+ "untouched memory is unknown, not zero",
+ k == b"\x00" * 4 and d == b"\x00" * 4,
+ f"known={k.hex()}",
+ )
# 0xff2..0xff3 was never touched; 0xff4..0xff7 came from the write at
# t=3 and 0xff8..0xff9 from the push at t=1 — a window can be knowable
# from several accesses at different times, which is what makes this
# worth a mask rather than a flag.
- d, k = t.memory(0xff2, 8, 4)
- check("a partially-covered window marks which bytes are known",
- k == bytes([0, 0, 1, 1, 1, 1, 1, 1]), f"known={k.hex()}")
+ d, k = t.memory(0xFF2, 8, 4)
+ check(
+ "a partially-covered window marks which bytes are known",
+ k == bytes([0, 0, 1, 1, 1, 1, 1, 1]),
+ f"known={k.hex()}",
+ )
# Reads are evidence too: an instruction reading a byte reveals what it
# held at that moment.
- d, k = t.memory(0xff8, 8, 5)
- check("a read reveals memory contents",
- k == b"\x01" * 8, f"known={k.hex()}")
+ d, k = t.memory(0xFF8, 8, 5)
+ check("a read reveals memory contents", k == b"\x01" * 8, f"known={k.hex()}")
- check("memory_writes lists only the writers",
- t.memory_writes(0xff4, 4) == [3], f"{t.memory_writes(0xff4, 4)}")
- check("memory_accesses includes the readers",
- t.memory_accesses(0xff4, 4) == [3, 4], f"{t.memory_accesses(0xff4, 4)}")
- check("a never-touched range has no accesses",
- t.memory_accesses(0x5000, 16) == [])
+ check(
+ "memory_writes lists only the writers",
+ t.memory_writes(0xFF4, 4) == [3],
+ f"{t.memory_writes(0xFF4, 4)}",
+ )
+ check(
+ "memory_accesses includes the readers",
+ t.memory_accesses(0xFF4, 4) == [3, 4],
+ f"{t.memory_accesses(0xFF4, 4)}",
+ )
+ check(
+ "a never-touched range has no accesses", t.memory_accesses(0x5000, 16) == []
+ )
# -- execution queries: what painting is built on -------------------- #
- check("executions lists every timestamp for an address",
- list(t.executions(0x401000)) == [0, 6], f"{list(t.executions(0x401000))}")
- check("a never-executed address has none", not len(t.executions(0xdead)))
- check("executions_between windows the result",
- t.executions_between(0x401000, 1, 7) == [6])
- check("next/prev execution step between hits",
- t.next_execution(0x401000, 0) == 6
- and t.prev_execution(0x401000, 6) == 0
- and t.next_execution(0x401000, 6) is None)
+ check(
+ "executions lists every timestamp for an address",
+ list(t.executions(0x401000)) == [0, 6],
+ f"{list(t.executions(0x401000))}",
+ )
+ check("a never-executed address has none", not len(t.executions(0xDEAD)))
+ check(
+ "executions_between windows the result",
+ t.executions_between(0x401000, 1, 7) == [6],
+ )
+ check(
+ "next/prev execution step between hits",
+ t.next_execution(0x401000, 0) == 6
+ and t.prev_execution(0x401000, 6) == 0
+ and t.next_execution(0x401000, 6) is None,
+ )
# A pseudocode line covers MANY addresses, so the set form is the one
# decompiler painting will call — per-address lookups would mean one
# dict hit per instruction per repaint.
- check("hits() counts a whole set of addresses at once",
- t.hits([0x401000, 0x401001, 0x401004, 0xdead]) ==
- {0x401000: 2, 0x401001: 2, 0x401004: 1},
- f"{t.hits([0x401000, 0x401001, 0x401004, 0xdead])}")
+ check(
+ "hits() counts a whole set of addresses at once",
+ t.hits([0x401000, 0x401001, 0x401004, 0xDEAD])
+ == {0x401000: 2, 0x401001: 2, 0x401004: 1},
+ f"{t.hits([0x401000, 0x401001, 0x401004, 0xDEAD])}",
+ )
# -- rebasing -------------------------------------------------------- #
# A traced process is relocated; nothing lines up until the slide is
# found. Page offsets survive relocation, which is what makes it
# findable.
- db = [0x1000 + (a - 0x401000) for a in
- (0x401000, 0x401001, 0x401004, 0x40100b, 0x40100e, 0x401010)]
+ db = [
+ 0x1000 + (a - 0x401000)
+ for a in (0x401000, 0x401001, 0x401004, 0x40100B, 0x40100E, 0x401010)
+ ]
slide = t.rebase(db)
- check("the slide between trace and database is found",
- slide == 0x1000 - 0x401000, f"{slide:#x}")
+ check(
+ "the slide between trace and database is found",
+ slide == 0x1000 - 0x401000,
+ f"{slide:#x}",
+ )
t.apply_slide(slide)
- check("addresses come back in database terms",
- t.ip(0) == 0x1000 and list(t.executions(0x1000)) == [0, 6],
- f"{t.ip(0):#x}")
+ check(
+ "addresses come back in database terms",
+ t.ip(0) == 0x1000 and list(t.executions(0x1000)) == [0, 6],
+ f"{t.ip(0):#x}",
+ )
# Memory addresses are NOT slid: the slide relocates the image, and
# these are overwhelmingly stack/heap addresses with no database
# counterpart — sliding a stack pointer by the image delta produced a
# negative address in testing.
- check("memory op addresses stay in trace space",
- t.memory_ops(3)[0].addr == 0xff4, f"{t.memory_ops(3)[0].addr:#x}")
- check("raw_ip still gives the traced address",
- t.raw_ip(0) == 0x401000)
+ check(
+ "memory op addresses stay in trace space",
+ t.memory_ops(3)[0].addr == 0xFF4,
+ f"{t.memory_ops(3)[0].addr:#x}",
+ )
+ check("raw_ip still gives the traced address", t.raw_ip(0) == 0x401000)
t2 = Trace.load(path)
# Page offsets that appear nowhere in the trace. (0xdead0000/0xdead0004
# would NOT do: they sit at the same offsets as two traced addresses and
# so legitimately agree on a slide — a reminder that this matches on
# offsets, not on addresses looking plausible.)
- check("no match means no slide, not a wrong one",
- t2.rebase([0xdead0555, 0xbeef0777]) == 0,
- f"{t2.rebase([0xdead0555, 0xbeef0777]):#x}")
- check("one lone agreeing address is not enough to claim a slide",
- t2.rebase([0x1000]) == 0, f"{t2.rebase([0x1000]):#x}")
+ check(
+ "no match means no slide, not a wrong one",
+ t2.rebase([0xDEAD0555, 0xBEEF0777]) == 0,
+ f"{t2.rebase([0xDEAD0555, 0xBEEF0777]):#x}",
+ )
+ check(
+ "one lone agreeing address is not enough to claim a slide",
+ t2.rebase([0x1000]) == 0,
+ f"{t2.rebase([0x1000]):#x}",
+ )
check("an empty database is harmless", t2.rebase([]) == 0)
# -- robustness ------------------------------------------------------ #
p2 = os.path.join(tmp, "odd.0.log")
with open(p2, "w") as f:
- f.write("\n".join([
- FULL + ",rip=0x401000",
- "", # blank line
- "rax=0xnothex,rip=0x401001", # unparseable value
- "rbx=0x1", # no PC at all
- "rip=0x401002,mw=0x10:zz", # unparseable memory
- ]) + "\n")
+ f.write(
+ "\n".join(
+ [
+ FULL + ",rip=0x401000",
+ "", # blank line
+ "rax=0xnothex,rip=0x401001", # unparseable value
+ "rbx=0x1", # no PC at all
+ "rip=0x401002,mw=0x10:zz", # unparseable memory
+ ]
+ )
+ + "\n"
+ )
t3 = Trace.load(p2)
- check("a malformed trace loads instead of raising", t3.length == 4,
- f"{t3.length}")
- check("a line with no PC inherits the previous one",
- t3.ip(2) == 0x401001, f"{t3.ip(2):#x}")
- check("an unparseable memory entry is dropped, not fatal",
- t3.memory_ops(3) == [])
+ check(
+ "a malformed trace loads instead of raising", t3.length == 4, f"{t3.length}"
+ )
+ check(
+ "a line with no PC inherits the previous one",
+ t3.ip(2) == 0x401001,
+ f"{t3.ip(2):#x}",
+ )
+ check(
+ "an unparseable memory entry is dropped, not fatal", t3.memory_ops(3) == []
+ )
print(f"\n{PASS} passed, {FAIL} failed")
return 1 if FAIL else 0
diff --git a/tests/test_trace_rpc.py b/tests/test_trace_rpc.py
index 015ce8b..9b0f2e0 100644
--- a/tests/test_trace_rpc.py
+++ b/tests/test_trace_rpc.py
@@ -30,8 +30,7 @@ from idatui.rpcclient import RpcClient, RpcError # noqa: E402
from idatui.trace import Trace # noqa: E402
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
-TRACER = os.path.expanduser(
- "~/.pi/agent/skills/tenet-trace/scripts/tenet-trace")
+TRACER = os.path.expanduser("~/.pi/agent/skills/tenet-trace/scripts/tenet-trace")
FALLBACK_TRACE = "/tmp/echotrace.0.log"
BINARY = os.path.join(REPO, "targets", "echo")
@@ -52,8 +51,12 @@ def make_trace(tmp, binary):
"""Record a short trace of the echo binary."""
out = os.path.join(tmp, "t")
try:
- subprocess.run([TRACER, "-o", out, binary, "hello"],
- capture_output=True, timeout=180, check=False)
+ subprocess.run(
+ [TRACER, "-o", out, binary, "hello"],
+ capture_output=True,
+ timeout=180,
+ check=False,
+ )
except (OSError, subprocess.TimeoutExpired):
return None
log = out + ".0.log"
@@ -62,11 +65,24 @@ def make_trace(tmp, binary):
def spawn_pane(target, trace_log, timeout=300):
"""Spawn an idatui pane with --trace and wait for readiness."""
- cmd = [sys.executable, "-m", "idatui.pane", "spawn",
- "--open", target, "--trace", trace_log,
- "--detached", "--size", "60%", "--timeout", str(timeout)]
- r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout + 30,
- cwd=REPO)
+ cmd = [
+ sys.executable,
+ "-m",
+ "idatui.pane",
+ "spawn",
+ "--open",
+ target,
+ "--trace",
+ trace_log,
+ "--detached",
+ "--size",
+ "60%",
+ "--timeout",
+ str(timeout),
+ ]
+ r = subprocess.run(
+ cmd, capture_output=True, text=True, timeout=timeout + 30, cwd=REPO
+ )
if r.returncode != 0:
print(f" spawn failed: {r.stderr.strip()}", file=sys.stderr)
return None
@@ -74,10 +90,17 @@ def spawn_pane(target, trace_log, timeout=300):
def stop_pane(sock, timeout=60):
- cmd = [sys.executable, "-m", "idatui.pane", "stop",
- "--sock", sock, "--timeout", str(timeout)]
- subprocess.run(cmd, capture_output=True, text=True, timeout=timeout + 10,
- cwd=REPO)
+ cmd = [
+ sys.executable,
+ "-m",
+ "idatui.pane",
+ "stop",
+ "--sock",
+ sock,
+ "--timeout",
+ str(timeout),
+ ]
+ subprocess.run(cmd, capture_output=True, text=True, timeout=timeout + 10, cwd=REPO)
def main() -> int:
@@ -96,14 +119,17 @@ def main() -> int:
if trace_log is None and os.path.exists(FALLBACK_TRACE):
trace_log = FALLBACK_TRACE
if trace_log is None:
- print(f" skip: no trace available (tracer at {TRACER}, "
- f"fallback {FALLBACK_TRACE})")
+ print(
+ f" skip: no trace available (tracer at {TRACER}, "
+ f"fallback {FALLBACK_TRACE})"
+ )
return 0
# Load our own model for ground-truth comparisons.
model = Trace.load(trace_log)
- check("model loaded for ground truth", model.length > 10,
- f"length={model.length}")
+ check(
+ "model loaded for ground truth", model.length > 10, f"length={model.length}"
+ )
# Spawn the pane.
info = spawn_pane(target, trace_log)
@@ -138,14 +164,22 @@ def run_trace_tests(c: RpcClient, model: Trace):
r = c.call("trace", seek=0)
check("trace seek=0 returns a snapshot", "active" in r and "trace" in r)
tr = r["trace"]
- check("response includes trace metadata",
- "idx" in tr and "length" in tr and "pc" in tr and "changed" in tr,
- f"keys={list(tr.keys())}")
+ check(
+ "response includes trace metadata",
+ "idx" in tr and "length" in tr and "pc" in tr and "changed" in tr,
+ f"keys={list(tr.keys())}",
+ )
check("idx is 0 after seeking to 0", tr["idx"] == 0)
- check("length matches the model", tr["length"] == model.length,
- f"{tr['length']} vs {model.length}")
- check("pc at 0 is a hex string", isinstance(tr["pc"], str)
- and tr["pc"].startswith("0x"), tr["pc"])
+ check(
+ "length matches the model",
+ tr["length"] == model.length,
+ f"{tr['length']} vs {model.length}",
+ )
+ check(
+ "pc at 0 is a hex string",
+ isinstance(tr["pc"], str) and tr["pc"].startswith("0x"),
+ tr["pc"],
+ )
# ------------------------------------------------------------------ #
# seek to a mid-trace timestamp
@@ -153,14 +187,16 @@ def run_trace_tests(c: RpcClient, model: Trace):
mid = model.length // 2
r = c.call("trace", seek=mid)
tr = r["trace"]
- check("seek to midpoint lands correctly", tr["idx"] == mid,
- f"got {tr['idx']}, want {mid}")
+ check(
+ "seek to midpoint lands correctly",
+ tr["idx"] == mid,
+ f"got {tr['idx']}, want {mid}",
+ )
# The model's IP at this point, rebased — the RPC should agree.
# We can't compare directly because the model isn't rebased yet, but
# the pc should be a small address (database-space, not ASLR'd).
pc = int(tr["pc"], 16)
- check("pc is in database space (not ASLR'd)",
- pc < 0x100000, f"pc={tr['pc']}")
+ check("pc is in database space (not ASLR'd)", pc < 0x100000, f"pc={tr['pc']}")
# ------------------------------------------------------------------ #
# seek by percentage (Tenet shell syntax)
@@ -168,39 +204,50 @@ def run_trace_tests(c: RpcClient, model: Trace):
r = c.call("trace", seek="!0")
check("seek !0 (0%) goes to the start", r["trace"]["idx"] == 0)
r = c.call("trace", seek="!100")
- check("seek !100 (100%) goes to the end",
- r["trace"]["idx"] == model.length - 1,
- f"got {r['trace']['idx']}, want {model.length - 1}")
+ check(
+ "seek !100 (100%) goes to the end",
+ r["trace"]["idx"] == model.length - 1,
+ f"got {r['trace']['idx']}, want {model.length - 1}",
+ )
r = c.call("trace", seek="!50")
- check("seek !50 (50%) goes to the midpoint",
- abs(r["trace"]["idx"] - mid) <= 1,
- f"got {r['trace']['idx']}, want ~{mid}")
+ check(
+ "seek !50 (50%) goes to the midpoint",
+ abs(r["trace"]["idx"] - mid) <= 1,
+ f"got {r['trace']['idx']}, want ~{mid}",
+ )
# ------------------------------------------------------------------ #
# step forward/backward
# ------------------------------------------------------------------ #
c.call("trace", seek=0)
r = c.call("trace", step=1)
- check("step=1 advances one timestamp", r["trace"]["idx"] == 1,
- f"got {r['trace']['idx']}")
+ check(
+ "step=1 advances one timestamp",
+ r["trace"]["idx"] == 1,
+ f"got {r['trace']['idx']}",
+ )
r = c.call("trace", step=1)
- check("another step=1 reaches 2", r["trace"]["idx"] == 2,
- f"got {r['trace']['idx']}")
+ check(
+ "another step=1 reaches 2", r["trace"]["idx"] == 2, f"got {r['trace']['idx']}"
+ )
r = c.call("trace", step=-1)
- check("step=-1 goes backward", r["trace"]["idx"] == 1,
- f"got {r['trace']['idx']}")
+ check("step=-1 goes backward", r["trace"]["idx"] == 1, f"got {r['trace']['idx']}")
# Step backward at the start should clamp to 0.
c.call("trace", seek=0)
r = c.call("trace", step=-1)
- check("step=-1 at t=0 stays at 0", r["trace"]["idx"] == 0,
- f"got {r['trace']['idx']}")
+ check(
+ "step=-1 at t=0 stays at 0", r["trace"]["idx"] == 0, f"got {r['trace']['idx']}"
+ )
# Multi-step.
c.call("trace", seek=0)
r = c.call("trace", step=5)
- check("step=5 advances five timestamps", r["trace"]["idx"] == 5,
- f"got {r['trace']['idx']}")
+ check(
+ "step=5 advances five timestamps",
+ r["trace"]["idx"] == 5,
+ f"got {r['trace']['idx']}",
+ )
# ------------------------------------------------------------------ #
# step over (follows SP)
@@ -212,8 +259,14 @@ def run_trace_tests(c: RpcClient, model: Trace):
a = model.register(sp_name, i)
b = model.register(sp_name, i + 1)
if a and b and b < a:
- ret = next((j for j in range(i + 1, model.length)
- if (model.register(sp_name, j) or 0) >= a), None)
+ ret = next(
+ (
+ j
+ for j in range(i + 1, model.length)
+ if (model.register(sp_name, j) or 0) >= a
+ ),
+ None,
+ )
if ret and ret > i + 3:
call_at = (i, ret)
break
@@ -221,11 +274,12 @@ def run_trace_tests(c: RpcClient, model: Trace):
i, ret = call_at
c.call("trace", seek=i)
r = c.call("trace", step=1, over=True)
- check("step over skips the callee",
- r["trace"]["idx"] == ret,
- f"from {i}, got {r['trace']['idx']}, expected {ret}")
- check("step over goes further than a plain step",
- r["trace"]["idx"] > i + 1)
+ check(
+ "step over skips the callee",
+ r["trace"]["idx"] == ret,
+ f"from {i}, got {r['trace']['idx']}, expected {ret}",
+ )
+ check("step over goes further than a plain step", r["trace"]["idx"] > i + 1)
else:
check("found a call to step over", False, "none in this short trace")
@@ -235,25 +289,28 @@ def run_trace_tests(c: RpcClient, model: Trace):
r = c.call("trace", goto="main")
tr = r["trace"]
check("goto main lands on a timestamp", tr["idx"] >= 0)
- check("and the function context says main",
- r.get("function", {}).get("name") == "main",
- f"function={r.get('function')}")
+ check(
+ "and the function context says main",
+ r.get("function", {}).get("name") == "main",
+ f"function={r.get('function')}",
+ )
# goto by hex address.
main_ea = r["function"]["ea"]
c.call("trace", seek=0) # reset position
r = c.call("trace", goto=hex(main_ea))
- check("goto by hex address works",
- r["trace"]["idx"] >= 0 and r["function"]["ea"] == main_ea,
- f"idx={r['trace']['idx']}, ea={r.get('function', {}).get('ea')}")
+ check(
+ "goto by hex address works",
+ r["trace"]["idx"] >= 0 and r["function"]["ea"] == main_ea,
+ f"idx={r['trace']['idx']}, ea={r.get('function', {}).get('ea')}",
+ )
# goto a function that was never executed.
try:
c.call("trace", goto="0xDEADBEEF")
check("goto an unexecuted address raises", False, "no error raised")
except RpcError as e:
- check("goto an unexecuted address raises", "never executed" in str(e),
- str(e))
+ check("goto an unexecuted address raises", "never executed" in str(e), str(e))
# ------------------------------------------------------------------ #
# changed registers in the response
@@ -261,9 +318,11 @@ def run_trace_tests(c: RpcClient, model: Trace):
c.call("trace", seek=0)
r = c.call("trace", step=1)
changed = r["trace"]["changed"]
- check("changed is a list of register names",
- isinstance(changed, list) and all(isinstance(s, str) for s in changed),
- f"{changed}")
+ check(
+ "changed is a list of register names",
+ isinstance(changed, list) and all(isinstance(s, str) for s in changed),
+ f"{changed}",
+ )
# The PC always changes on a step (it's a different instruction).
check("rip is always in changed", "rip" in changed, f"{changed}")
@@ -275,9 +334,13 @@ def run_trace_tests(c: RpcClient, model: Trace):
r2 = c.call("trace", seek=min(50, model.length - 1))
ea1 = r1.get("cursor", {}).get("ea")
ea2 = r2.get("cursor", {}).get("ea")
- check("the cursor ea follows the trace pc",
- ea1 is not None and ea2 is not None and (ea1 != ea2 or r1["trace"]["pc"] == r2["trace"]["pc"]),
- f"ea1={ea1}, ea2={ea2}")
+ check(
+ "the cursor ea follows the trace pc",
+ ea1 is not None
+ and ea2 is not None
+ and (ea1 != ea2 or r1["trace"]["pc"] == r2["trace"]["pc"]),
+ f"ea1={ea1}, ea2={ea2}",
+ )
# ------------------------------------------------------------------ #
# trace verb without a trace raises cleanly
@@ -289,8 +352,10 @@ def run_trace_tests(c: RpcClient, model: Trace):
# Actually, if none of seek/goto/step is given, it just settles and
# returns the current state — that's fine, it's a status query.
r = c.call("trace")
- check("trace with no action is a status query",
- "trace" in r and r["trace"]["idx"] >= 0)
+ check(
+ "trace with no action is a status query",
+ "trace" in r and r["trace"]["idx"] >= 0,
+ )
except RpcError:
check("trace with no action is a status query", False, "raised an error")
@@ -299,14 +364,20 @@ def run_trace_tests(c: RpcClient, model: Trace):
# ------------------------------------------------------------------ #
r = c.call("trace", seek=3)
for key in ("idx", "length", "pc", "changed"):
- check(f"trace response has '{key}'", key in r.get("trace", {}),
- f"trace={r.get('trace')}")
+ check(
+ f"trace response has '{key}'",
+ key in r.get("trace", {}),
+ f"trace={r.get('trace')}",
+ )
# Standard snapshot fields are ALSO present (the trace response is a
# superset of a normal snapshot).
for key in ("active", "function", "cursor", "status", "ready"):
- check(f"trace response also has snapshot key '{key}'", key in r,
- f"keys={list(r.keys())}")
+ check(
+ f"trace response also has snapshot key '{key}'",
+ key in r,
+ f"keys={list(r.keys())}",
+ )
# ------------------------------------------------------------------ #
# edge cases: seek beyond bounds
@@ -314,25 +385,30 @@ def run_trace_tests(c: RpcClient, model: Trace):
r = c.call("trace", seek=-1)
check("seek -1 clamps to 0", r["trace"]["idx"] == 0)
r = c.call("trace", seek=model.length + 1000)
- check("seek beyond length clamps to the end",
- r["trace"]["idx"] == model.length - 1,
- f"got {r['trace']['idx']}")
+ check(
+ "seek beyond length clamps to the end",
+ r["trace"]["idx"] == model.length - 1,
+ f"got {r['trace']['idx']}",
+ )
# ------------------------------------------------------------------ #
# seek with comma-separated numbers (ergonomic)
# ------------------------------------------------------------------ #
r = c.call("trace", seek="100")
- check("seek accepts a string number",
- r["trace"]["idx"] == min(100, model.length - 1))
+ check(
+ "seek accepts a string number", r["trace"]["idx"] == min(100, model.length - 1)
+ )
# ------------------------------------------------------------------ #
# pseudocode still works with a trace loaded
# ------------------------------------------------------------------ #
c.call("trace", goto="main")
r = c.call("pseudocode", target="main", lines=5)
- check("pseudocode works alongside the trace",
- "code" in r and "main" in r.get("code", ""),
- f"keys={list(r.keys())}")
+ check(
+ "pseudocode works alongside the trace",
+ "code" in r and "main" in r.get("code", ""),
+ f"keys={list(r.keys())}",
+ )
# ------------------------------------------------------------------ #
# state includes trace position
@@ -341,17 +417,18 @@ def run_trace_tests(c: RpcClient, model: Trace):
r = c.call("state")
# The state verb doesn't include trace info (that's trace-specific),
# but the standard snapshot fields should be consistent.
- check("state works with a trace loaded",
- r.get("ready") is True and "cursor" in r)
+ check("state works with a trace loaded", r.get("ready") is True and "cursor" in r)
# ------------------------------------------------------------------ #
# view_lines works with trail painted
# ------------------------------------------------------------------ #
c.call("trace", seek=min(40, model.length - 1))
r = c.call("view", lines=10)
- check("view returns lines with a trace active",
- "lines" in r and len(r["lines"]) > 0,
- f"keys={list(r.keys())}")
+ check(
+ "view returns lines with a trace active",
+ "lines" in r and len(r["lines"]) > 0,
+ f"keys={list(r.keys())}",
+ )
# ------------------------------------------------------------------ #
# navigation works alongside trace: goto a function, trace follows
@@ -359,14 +436,18 @@ def run_trace_tests(c: RpcClient, model: Trace):
c.call("trace", goto="main")
start_idx = c.call("trace")["trace"]["idx"]
r = c.call("goto", target="error_at_line")
- check("goto still works with trace loaded",
- r.get("function", {}).get("name") == "error_at_line")
+ check(
+ "goto still works with trace loaded",
+ r.get("function", {}).get("name") == "error_at_line",
+ )
# The trace timestamp should NOT change from a regular goto — the trace
# position is independent of navigation.
r2 = c.call("trace")
- check("regular goto does not change the trace position",
- r2["trace"]["idx"] == start_idx,
- f"was {start_idx}, now {r2['trace']['idx']}")
+ check(
+ "regular goto does not change the trace position",
+ r2["trace"]["idx"] == start_idx,
+ f"was {start_idx}, now {r2['trace']['idx']}",
+ )
if __name__ == "__main__":
diff --git a/tests/test_trace_ui.py b/tests/test_trace_ui.py
index 855ce4d..768d18b 100644
--- a/tests/test_trace_ui.py
+++ b/tests/test_trace_ui.py
@@ -17,18 +17,21 @@ import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
-from textual.widgets import Input, OptionList, Static # noqa: E402
-
from _fixtures import fast_keys, staged # noqa: E402
+from textual.widgets import Input, OptionList, Static # noqa: E402
-fast_keys() # ~85ms -> ~2ms per keypress; see _fixtures.fast_keys
+fast_keys() # ~85ms -> ~2ms per keypress; see _fixtures.fast_keys
from idatui._sync import settle # noqa: E402
-from idatui.app import (DecompView, IdaTui, ListingView, # noqa: E402
- RegWriteScreen, TraceDock)
+from idatui.app import ( # noqa: E402
+ DecompView,
+ IdaTui,
+ ListingView,
+ RegWriteScreen,
+ TraceDock,
+)
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
-TRACER = os.path.expanduser(
- "~/.pi/agent/skills/tenet-trace/scripts/tenet-trace")
+TRACER = os.path.expanduser("~/.pi/agent/skills/tenet-trace/scripts/tenet-trace")
PASS = FAIL = 0
@@ -56,8 +59,12 @@ async def wait(pred, pilot, t=240.0):
def make_trace(tmp, binary):
out = os.path.join(tmp, "t")
try:
- subprocess.run([TRACER, "-o", out, binary, "hi"],
- capture_output=True, timeout=180, check=False)
+ subprocess.run(
+ [TRACER, "-o", out, binary, "hi"],
+ capture_output=True,
+ timeout=180,
+ check=False,
+ )
except (OSError, subprocess.TimeoutExpired):
return None
log = out + ".0.log"
@@ -71,8 +78,9 @@ async def run() -> int:
# anything tracked. On echo the seeding saves only ~0.2s (it analyses fast);
# it is here so a suite pointed at a bigger target doesn't pay for analysis
# on every run.
- async with staged(binary, lambda p: IdaTui(open_path=p, keepalive=False),
- prefix="idatui-traceui-") as target:
+ async with staged(
+ binary, lambda p: IdaTui(open_path=p, keepalive=False), prefix="idatui-traceui-"
+ ) as target:
tmp = os.path.dirname(target)
log = make_trace(tmp, target)
if not log:
@@ -90,22 +98,29 @@ async def run() -> int:
# Rebasing: the tracer runs the binary relocated, so without a slide
# nothing in the trace matches anything on screen.
- check("trace addresses were rebased onto the database",
- t.slide != 0 and t.ip(0) < 0x1000000,
- f"slide={t.slide:#x} ip0={t.ip(0):#x}")
+ check(
+ "trace addresses were rebased onto the database",
+ t.slide != 0 and t.ip(0) < 0x1000000,
+ f"slide={t.slide:#x} ip0={t.ip(0):#x}",
+ )
idx = app._func_index
touched = [f.name for f in idx.all_loaded() if t.executions(f.addr)]
- check("and now line up with real functions",
- len(touched) > 1 and "main" in touched, f"{touched[:6]}")
+ check(
+ "and now line up with real functions",
+ len(touched) > 1 and "main" in touched,
+ f"{touched[:6]}",
+ )
dock = app.query_one(TraceDock)
check("the dock is docked and visible", dock.display)
head = str(dock.query_one("#trace-head", Static).render())
- check("it shows where we are in time", "0" in head and "%" in head,
- head[:60])
+ check(
+ "it shows where we are in time", "0" in head and "%" in head, head[:60]
+ )
regs = str(dock.query_one("#trace-regs", Static).render())
- check("and the register state at that time", "rip" in regs.lower(),
- regs[:60])
+ check(
+ "and the register state at that time", "rip" in regs.lower(), regs[:60]
+ )
# -- stepping --------------------------------------------------- #
lst = app.query_one(ListingView)
@@ -115,17 +130,21 @@ async def run() -> int:
# `app._t` is assigned the moment the key is handled, so it is NOT a
# signal that the VIEW has followed -- the navigation it kicks off
# runs in a worker. Waiting on it and then reading the cursor was a
- # race that the (slower) Code Mode backend loses. Gate on the thing
+ # race that the (slower) IDA Nexus backend loses. Gate on the thing
# the check is about.
- await settle(app, lambda: app._t == 1 and lst._cursor_ea() == t.ip(1),
- timeout=20)
+ await settle(
+ app, lambda: app._t == 1 and lst._cursor_ea() == t.ip(1), timeout=20
+ )
check("] steps forward one instruction", app._t == 1, f"t={app._t}")
- check("the code view follows the trace",
- lst._cursor_ea() == t.ip(1),
- f"{lst._cursor_ea()} vs {t.ip(1)}")
+ check(
+ "the code view follows the trace",
+ lst._cursor_ea() == t.ip(1),
+ f"{lst._cursor_ea()} vs {t.ip(1)}",
+ )
await pilot.press("[")
- await settle(app, lambda: app._t == 0 and lst._cursor_ea() == t.ip(0),
- timeout=20)
+ await settle(
+ app, lambda: app._t == 0 and lst._cursor_ea() == t.ip(0), timeout=20
+ )
check("[ steps backward", app._t == 0, f"t={app._t}")
await pilot.press("[")
# Nothing should happen, so there is no signal to wait FOR: the
@@ -144,8 +163,14 @@ async def run() -> int:
for i in range(1, min(t.length - 1, 400)):
a, b = t.register(sp, i), t.register(sp, i + 1)
if a and b and b < a:
- ret = next((j for j in range(i + 1, t.length)
- if (t.register(sp, j) or 0) >= a), None)
+ ret = next(
+ (
+ j
+ for j in range(i + 1, t.length)
+ if (t.register(sp, j) or 0) >= a
+ ),
+ None,
+ )
if ret and ret > i + 3:
call_at = (i, ret)
break
@@ -157,8 +182,11 @@ async def run() -> int:
await wait(lambda: app._t == i, pilot, 20)
await pilot.press("}")
await wait(lambda: app._t != i, pilot, 30)
- check("} steps OVER a call instead of into it",
- app._t == ret, f"{i} -> {app._t}, expected {ret}")
+ check(
+ "} steps OVER a call instead of into it",
+ app._t == ret,
+ f"{i} -> {app._t}, expected {ret}",
+ )
check("which is further than a plain step", app._t > i + 1)
# -- memory at time T -------------------------------------------- #
@@ -168,36 +196,56 @@ async def run() -> int:
# image would have nothing to show.
dock = app.query_one(TraceDock)
app._seek(min(60, t.length - 1))
- await wait(lambda: "stack (" in
- str(dock.query_one("#trace-stack", Static).render()),
- pilot, 5)
+ await wait(
+ lambda: (
+ "stack (" in str(dock.query_one("#trace-stack", Static).render())
+ ),
+ pilot,
+ 5,
+ )
stack = str(dock.query_one("#trace-stack", Static).render())
- check("the dock shows the stack at this timestamp",
- "stack (" in stack and len(stack.splitlines()) > 4, stack[:60])
+ check(
+ "the dock shows the stack at this timestamp",
+ "stack (" in stack and len(stack.splitlines()) > 4,
+ stack[:60],
+ )
sp_name = next(r for r in ("rsp", "esp", "sp") if r in t.reg_at)
sp = t.register(sp_name, app._t)
- check("anchored at the stack pointer",
- f"{sp:012x}" in stack, f"sp={sp:#x} / {stack[:80]}")
+ check(
+ "anchored at the stack pointer",
+ f"{sp:012x}" in stack,
+ f"sp={sp:#x} / {stack[:80]}",
+ )
# A trace knows what it observed and nothing else. Unseen bytes are
# printed as '?', never as zeros — rendering them as zero would
# invent facts about memory nobody looked at.
data, known = t.memory_raw(sp, 8, app._t)
if not all(known):
- check("memory the trace never saw is marked unknown",
- "?" in stack, stack[:80])
+ check(
+ "memory the trace never saw is marked unknown",
+ "?" in stack,
+ stack[:80],
+ )
else:
- check("known stack words are shown as values",
- any(c in "0123456789abcdef" for c in stack), stack[:60])
+ check(
+ "known stack words are shown as values",
+ any(c in "0123456789abcdef" for c in stack),
+ stack[:60],
+ )
# Stepping must move the memory view with time.
before = stack
app._seek(min(80, t.length - 1))
- await wait(lambda: str(dock.query_one("#trace-stack",
- Static).render()) != before,
- pilot, 5)
- check("and it follows as you move through time",
- str(dock.query_one("#trace-stack", Static).render()) != before)
+ await wait(
+ lambda: str(dock.query_one("#trace-stack", Static).render()) != before,
+ pilot,
+ 5,
+ )
+ check(
+ "and it follows as you move through time",
+ str(dock.query_one("#trace-stack", Static).render()) != before,
+ )
# -- trails ------------------------------------------------------ #
# Not "every address the trace ever touched": on a loop-heavy
@@ -207,17 +255,27 @@ async def run() -> int:
await wait(lambda: bool(lst.trail), pilot, 5)
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(
+ "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 -------------------------------- #
@@ -234,23 +292,33 @@ async def run() -> int:
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)
+ 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 wait(lambda: len(dec.trail) > 2, pilot, 5)
- check("pseudocode lines are painted with the trail",
- len(dec.trail) > 2, f"{len(dec.trail)} lines")
+ 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}")
+ 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]}")
+ 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]}",
+ )
# Stepping must not throw you out of the view you're reading.
# A step navigates to an address, and navigating to an address
@@ -260,13 +328,19 @@ async def run() -> int:
was = app._t
await pilot.press("]")
await settle(app, lambda: app._t != was)
- check("stepping in pseudocode stays in pseudocode",
- app._active == "decomp", f"active={app._active}")
+ check(
+ "stepping in pseudocode stays in pseudocode",
+ app._active == "decomp",
+ f"active={app._active}",
+ )
was = app._t
await pilot.press("[")
await settle(app, lambda: app._t != was)
- check("and so does stepping backward",
- app._active == "decomp", f"active={app._active}")
+ 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,
@@ -274,8 +348,13 @@ async def run() -> int:
# navigation though: both panes show the same instant, so the
# listing cursor must sit on the current instruction.
app.action_toggle_split()
- await wait(lambda: app._split and lst.display
- and app.query_one(DecompView).display, pilot, 10)
+ await wait(
+ lambda: (
+ app._split and lst.display and app.query_one(DecompView).display
+ ),
+ pilot,
+ 10,
+ )
if not app._split:
check("split view toggled on", False)
else:
@@ -286,14 +365,18 @@ async def run() -> int:
# Wait for the cursor to arrive rather than sleeping a flat
# 0.5s and hoping. Same question -- does the listing follow
# the pc? -- but it costs what it costs instead of 3s.
- if await wait(lambda: lst._cursor_ea() == t.ip(app._t),
- pilot, 5):
+ if await wait(lambda: lst._cursor_ea() == t.ip(app._t), pilot, 5):
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))}")
+ 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))}",
+ )
# The pseudocode cursor follows too — but only for instructions
# the decompiler actually attributes to a line. About half
@@ -313,8 +396,14 @@ async def run() -> int:
# on `pc in _trail_line_of` instead would burn the timeout on
# every unmapped instruction -- about half of them -- and be
# slower than the flat sleep it replaces.
- await wait(lambda: lst._cursor_ea() == pc
- and app._trail_map_ea == dec.loaded_ea, pilot, 5)
+ await wait(
+ lambda: (
+ lst._cursor_ea() == pc
+ and app._trail_map_ea == dec.loaded_ea
+ ),
+ pilot,
+ 5,
+ )
if app._trail_map_ea == dec.loaded_ea and pc in app._trail_line_of:
mapped += 1
# Mapped: the pseudocode cursor is expected, so it's fair
@@ -324,9 +413,11 @@ async def run() -> int:
await wait(lambda: dec.cursor == line, pilot, 3)
if dec.cursor != line:
missed += 1
- check("the pseudocode cursor follows every mapped instruction",
- mapped > 3 and missed == 0,
- f"{mapped} mapped, {missed} not followed")
+ check(
+ "the pseudocode cursor follows every mapped instruction",
+ mapped > 3 and missed == 0,
+ f"{mapped} mapped, {missed} not followed",
+ )
# -- a late navigation must not drag the view back --------------- #
# Navigations run in workers and finish out of order. The trace's
@@ -347,10 +438,12 @@ async def run() -> int:
# for the workers to drain rather than for three seconds and a
# hope: same question, ~50ms instead of 3s.
await settle(app)
- check("a stale navigation doesn't drag the cursor away",
- lst._cursor_ea() == dbaddr and app._cur.ea == dbaddr,
- f"cursor={lst._cursor_ea():#x} cur={app._cur.ea:#x} "
- f"want {dbaddr:#x}")
+ check(
+ "a stale navigation doesn't drag the cursor away",
+ lst._cursor_ea() == dbaddr and app._cur.ea == dbaddr,
+ f"cursor={lst._cursor_ea():#x} cur={app._cur.ea:#x} "
+ f"want {dbaddr:#x}",
+ )
# -- seeking, as opposed to stepping ---------------------------- #
# "When else did this instruction run?" — the question that makes a
@@ -359,8 +452,11 @@ async def run() -> int:
stamps = list(t.by_ip[hot])
db = hot + t.slide
if len(stamps) < 2 or lst.model is None:
- check("found an address executed more than once", False,
- f"{len(stamps)} executions")
+ check(
+ "found an address executed more than once",
+ False,
+ f"{len(stamps)} executions",
+ )
else:
if app._split:
app.action_toggle_split()
@@ -377,31 +473,48 @@ async def run() -> int:
lst.cursor = row
lst._scroll_cursor_into_view()
await settle(app, lambda: lst._cursor_ea() == db)
- check("cursor is on the repeated instruction",
- lst._cursor_ea() == db, f"{lst._cursor_ea():#x} vs {db:#x}")
+ check(
+ "cursor is on the repeated instruction",
+ lst._cursor_ea() == db,
+ f"{lst._cursor_ea():#x} vs {db:#x}",
+ )
await pilot.press(">")
await settle(app, lambda: app._t == stamps[1])
- check("> seeks to the next execution of it",
- app._t == stamps[1], f"t={app._t}, expected {stamps[1]}")
+ check(
+ "> seeks to the next execution of it",
+ app._t == stamps[1],
+ f"t={app._t}, expected {stamps[1]}",
+ )
status = str(app.query_one("#status", Static).render())
- check("and says which execution this is",
- f"2 of {len(stamps)}" in status, status[:80])
+ check(
+ "and says which execution this is",
+ f"2 of {len(stamps)}" in status,
+ status[:80],
+ )
lst.cursor = row
await settle(app)
await pilot.press("<")
await settle(app, lambda: app._t == stamps[0])
- check("< seeks back to the previous one",
- app._t == stamps[0], f"t={app._t}, expected {stamps[0]}")
+ check(
+ "< seeks back to the previous one",
+ app._t == stamps[0],
+ f"t={app._t}, expected {stamps[0]}",
+ )
# An edge must SAY it's an edge rather than silently doing
# nothing, which is indistinguishable from a broken key.
lst.cursor = row
await settle(app)
await pilot.press("<")
- await settle(app, lambda: "first" in str(
- app.query_one("#status", Static).render()))
+ await settle(
+ app,
+ lambda: "first" in str(app.query_one("#status", Static).render()),
+ )
status = str(app.query_one("#status", Static).render())
- check("and the first execution says so instead of moving",
- app._t == stamps[0] and "first" in status, status[:80])
+ check(
+ "and the first execution says so instead of moving",
+ app._t == stamps[0] and "first" in status,
+ status[:80],
+ )
# -- "which instruction set this register?" ---------------------- #
want_t = min(200, t.length - 1)
@@ -409,14 +522,24 @@ async def run() -> int:
await settle(app, lambda: app._t == want_t)
lst.focus()
await pilot.press("W")
- opened = await wait(lambda: isinstance(app.screen, RegWriteScreen),
- pilot, 20)
- check("W lists the registers and where each was set", opened,
- f"screen={type(app.screen).__name__}")
+ opened = await wait(
+ lambda: isinstance(app.screen, RegWriteScreen), pilot, 20
+ )
+ check(
+ "W lists the registers and where each was set",
+ opened,
+ f"screen={type(app.screen).__name__}",
+ )
if opened:
sc = app.screen
- pick = next((k for k, (n, v, l, x) in enumerate(sc._rows)
- if l is not None and l != app._t), None)
+ pick = next(
+ (
+ k
+ for k, (n, v, l, x) in enumerate(sc._rows)
+ if l is not None and l != app._t
+ ),
+ None,
+ )
if pick is None:
check("a register was set by an earlier instruction", False)
await pilot.press("escape")
@@ -426,13 +549,18 @@ async def run() -> int:
await settle(app)
await pilot.press("enter")
await wait(lambda: app._t == last, pilot, 30)
- check("choosing one seeks to the write that set it",
- app._t == last, f"t={app._t}, expected {last}")
+ check(
+ "choosing one seeks to the write that set it",
+ app._t == last,
+ f"t={app._t}, expected {last}",
+ )
# The real check: that instruction must actually have
# written the register we asked about.
- check("and that instruction really wrote it",
- name in t.changed(app._t),
- f"{name} not in {sorted(t.changed(app._t))}")
+ check(
+ "and that instruction really wrote it",
+ name in t.changed(app._t),
+ f"{name} not in {sorted(t.changed(app._t))}",
+ )
print(f"\n{PASS} passed, {FAIL} failed")
return 1 if FAIL else 0
diff --git a/tests/test_trace_vs_tenet.py b/tests/test_trace_vs_tenet.py
index 30d74b0..c2a0ea2 100644
--- a/tests/test_trace_vs_tenet.py
+++ b/tests/test_trace_vs_tenet.py
@@ -46,6 +46,7 @@ def _reference():
log.pmsg = lambda *a, **k: None
import tenet # noqa: F401
import tenet.util # noqa: F401
+
sys.modules["tenet.util.log"] = log
from tenet.trace.arch import ArchAMD64
from tenet.trace.reader import TraceReader
@@ -57,6 +58,7 @@ def _reference():
# stay in raw trace addresses, which is what we want to compare.
def get_instruction_addresses(self):
return [0xDEAD0000]
+
return TraceReader, ArchAMD64, FakeDctx
@@ -88,9 +90,11 @@ def compare(path, ref_cls, arch, dctx, samples=200):
theirs = TraceReader(path, ArchAMD64(), FakeDctx())
name = os.path.basename(path)
- check(f"{name}: same length",
- ours.length == theirs.trace.length,
- f"{ours.length} vs {theirs.trace.length}")
+ check(
+ f"{name}: same length",
+ ours.length == theirs.trace.length,
+ f"{ours.length} vs {theirs.trace.length}",
+ )
n = min(ours.length, theirs.trace.length)
if not n:
return
@@ -99,8 +103,11 @@ def compare(path, ref_cls, arch, dctx, samples=200):
idxs = sorted({0, n - 1, n // 2} | {rnd.randrange(n) for _ in range(samples)})
bad = [i for i in idxs if ours.raw_ip(i) != theirs.get_ip(i)]
- check(f"{name}: same PC at every sampled timestamp", not bad,
- f"first mismatch at {bad[:1]}")
+ check(
+ f"{name}: same PC at every sampled timestamp",
+ not bad,
+ f"first mismatch at {bad[:1]}",
+ )
# Register reconstruction is the part that is easy to get subtly wrong: a
# delta belongs to the line that CAUSED it, and an off-by-one here silently
@@ -121,11 +128,16 @@ def compare(path, ref_cls, arch, dctx, samples=200):
continue
true = _truth(path, r, i)
(ours_wrong if mine != true else ref_wrong).append((i, r, mine, ref, true))
- check(f"{name}: register state matches the trace text everywhere",
- not ours_wrong, f"{ours_wrong[:3]}")
+ check(
+ f"{name}: register state matches the trace text everywhere",
+ not ours_wrong,
+ f"{ours_wrong[:3]}",
+ )
if ref_wrong:
- print(f" (reference disagrees at {len(ref_wrong)} sampled points; "
- f"the text backs us, e.g. idx {ref_wrong[0][0]} {ref_wrong[0][1]})")
+ print(
+ f" (reference disagrees at {len(ref_wrong)} sampled points; "
+ f"the text backs us, e.g. idx {ref_wrong[0][0]} {ref_wrong[0][1]})"
+ )
# Execution queries: what painting is built on.
hot = sorted(ours.by_ip, key=lambda a: -len(ours.by_ip[a]))[:5]
@@ -135,8 +147,11 @@ def compare(path, ref_cls, arch, dctx, samples=200):
ref = list(theirs.get_executions(ea))
if mine != ref:
ex_bad.append((hex(ea), len(mine), len(ref)))
- check(f"{name}: same execution timestamps for the hottest addresses",
- not ex_bad, f"{ex_bad[:3]}")
+ check(
+ f"{name}: same execution timestamps for the hottest addresses",
+ not ex_bad,
+ f"{ex_bad[:3]}",
+ )
# Memory STATE at a timestamp — reconstructed from the deltas, which is the
# hard part and the whole point of reading memory from a trace.
@@ -153,11 +168,14 @@ def compare(path, ref_cls, arch, dctx, samples=200):
# own coverage separately and a byte neither has seen is not a
# disagreement.
for j in range(n):
- if known[j] and refb[j:j + 1] and mine[j] != refb[j]:
+ if known[j] and refb[j : j + 1] and mine[j] != refb[j]:
mem_bad.append((i, hex(op.addr + j), mine[j], refb[j]))
mem_checked += 1
- check(f"{name}: memory state at a timestamp matches the reference",
- not mem_bad, f"{mem_bad[:3]}")
+ check(
+ f"{name}: memory state at a timestamp matches the reference",
+ not mem_bad,
+ f"{mem_bad[:3]}",
+ )
# Memory: the bytes an instruction touched, and which way.
with_mem = [i for i in idxs if ours.memory_ops(i)][:40]
@@ -167,18 +185,24 @@ def compare(path, ref_cls, arch, dctx, samples=200):
ref = theirs.get_memory(op.addr, len(op.data), i + 1) if op.write else None
if ref is not None and bytes(ref.data) != op.data:
mem_bad.append((i, hex(op.addr), op.data.hex(), bytes(ref.data).hex()))
- check(f"{name}: written bytes match the reference's memory state",
- not mem_bad, f"{mem_bad[:2]}")
- print(f" ({ours.length:,} instructions, {len(idxs)} sampled, "
- f"{len(with_mem)} with memory)")
+ check(
+ f"{name}: written bytes match the reference's memory state",
+ not mem_bad,
+ f"{mem_bad[:2]}",
+ )
+ print(
+ f" ({ours.length:,} instructions, {len(idxs)} sampled, "
+ f"{len(with_mem)} with memory)"
+ )
def main(argv):
if not os.path.isdir(TENET):
print(f" skip: reference not found at {TENET}")
return 0
- traces = argv or [p for p in ("/tmp/echotrace.0.log", "/tmp/big.0.log")
- if os.path.exists(p)]
+ traces = argv or [
+ p for p in ("/tmp/echotrace.0.log", "/tmp/big.0.log") if os.path.exists(p)
+ ]
if not traces:
print(" skip: no traces to compare (pass one, or run tenet-trace first)")
return 0
diff --git a/tools/demo.py b/tools/demo.py
index fa82a98..5cf5207 100644
--- a/tools/demo.py
+++ b/tools/demo.py
@@ -24,6 +24,7 @@ Edits (rename/comment) are reverted at the end, so the tour is repeatable and
a scratch database is not left renamed. --spawn works on a COPY of the target
so the tracked .i64 is never touched at all.
"""
+
from __future__ import annotations
import argparse
@@ -223,8 +224,10 @@ class Demo:
"""Undo the demo's edits so the take is repeatable."""
for kind, args in reversed(self.undo):
if kind == "rename" and args.get("addr") is not None:
- self.do("rename_many",
- items=[{"addr": hex(args["addr"]), "name": args["name"]}])
+ self.do(
+ "rename_many",
+ items=[{"addr": hex(args["addr"]), "name": args["name"]}],
+ )
elif kind == "comment":
self.do("comment", text="")
# Re-navigate so the view shows the reverted name: the nav entry caches
@@ -252,15 +255,20 @@ SCENES = [
def spawn_pane(target: str) -> tuple[str, str, str]:
"""Spawn a TUI pane on a COPY of ``target``. Returns (sock, pane, tmpdir)."""
import json
+
tmp = tempfile.mkdtemp(prefix="idatui-demo-")
copy = os.path.join(tmp, os.path.basename(target))
shutil.copy2(target, copy)
- for suffix in (".i64",): # reuse the analysis if present
+ for suffix in (".i64",): # reuse the analysis if present
if os.path.exists(target + suffix):
shutil.copy2(target + suffix, copy + suffix)
out = subprocess.run(
[sys.executable, "-m", "idatui.pane", "spawn", "--open", copy],
- cwd=REPO, capture_output=True, text=True, check=True).stdout
+ cwd=REPO,
+ capture_output=True,
+ text=True,
+ check=True,
+ ).stdout
row = json.loads(out)
return row["sock"], row.get("pane", ""), tmp
@@ -280,8 +288,9 @@ def run_here(target: str) -> tuple[subprocess.Popen, str, str]:
shutil.copy2(target + ".i64", copy + ".i64")
sockdir = os.environ.get("XDG_RUNTIME_DIR") or "/tmp"
sock = os.path.join(sockdir, f"idatui-demo-{os.getpid()}.sock")
- proc = subprocess.Popen([os.path.join(REPO, "ida-tui"), copy, "--rpc", sock],
- cwd=REPO) # stdio inherited on purpose
+ proc = subprocess.Popen(
+ [os.path.join(REPO, "ida-tui"), copy, "--rpc", sock], cwd=REPO
+ ) # stdio inherited on purpose
return proc, sock, tmp
@@ -291,28 +300,43 @@ def wait_for_socket(proc: subprocess.Popen, sock: str, timeout: float = 600.0) -
while time.time() < deadline:
if os.path.exists(sock):
return True
- if proc.poll() is not None: # died before it ever listened
+ if proc.poll() is not None: # died before it ever listened
return False
time.sleep(0.1)
return False
def main(argv=None) -> int:
- ap = argparse.ArgumentParser(description=__doc__,
- formatter_class=argparse.RawDescriptionHelpFormatter)
+ ap = argparse.ArgumentParser(
+ description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
+ )
ap.add_argument("--sock", help="RPC socket of a running TUI (see --rpc)")
- ap.add_argument("--spawn", action="store_true",
- help="spawn a pane on a scratch copy, then tear it down")
- ap.add_argument("--here", "--inline", dest="here", action="store_true",
- help="run the TUI in THIS terminal (single-pane recording)")
- ap.add_argument("--target", default=DEFAULT_TARGET,
- help="binary for --here/--spawn")
- ap.add_argument("--speed", type=float, default=1.0,
- help="pause multiplier: <1 snappier, >1 slower (default 1.0)")
+ ap.add_argument(
+ "--spawn",
+ action="store_true",
+ help="spawn a pane on a scratch copy, then tear it down",
+ )
+ ap.add_argument(
+ "--here",
+ "--inline",
+ dest="here",
+ action="store_true",
+ help="run the TUI in THIS terminal (single-pane recording)",
+ )
+ ap.add_argument(
+ "--target", default=DEFAULT_TARGET, help="binary for --here/--spawn"
+ )
+ ap.add_argument(
+ "--speed",
+ type=float,
+ default=1.0,
+ help="pause multiplier: <1 snappier, >1 slower (default 1.0)",
+ )
ap.add_argument("--only", help="comma-separated scene names")
ap.add_argument("--list", action="store_true", help="list scenes and exit")
- ap.add_argument("--no-revert", action="store_true",
- help="keep the demo's rename/comment")
+ ap.add_argument(
+ "--no-revert", action="store_true", help="keep the demo's rename/comment"
+ )
ap.add_argument("--quiet", action="store_true", help="no operator narration")
args = ap.parse_args(argv)
@@ -384,8 +408,11 @@ def main(argv=None) -> int:
rc = 1
finally:
if args.spawn and sock:
- subprocess.run([sys.executable, "-m", "idatui.pane", "stop",
- "--sock", sock], cwd=REPO, capture_output=True)
+ subprocess.run(
+ [sys.executable, "-m", "idatui.pane", "stop", "--sock", sock],
+ cwd=REPO,
+ capture_output=True,
+ )
if proc is not None:
try:
proc.wait(timeout=30)
@@ -397,10 +424,12 @@ def main(argv=None) -> int:
proc.kill()
if tmp:
shutil.rmtree(tmp, ignore_errors=True)
- if args.here and transcript: # the alt screen is gone: safe to print
+ if args.here and transcript: # the alt screen is gone: safe to print
print("\n\033[1m-- ida-tui demo --\033[0m")
for line in transcript:
- print(f" {line}" if not line.startswith("[") else f"\033[1m{line}\033[0m")
+ print(
+ f" {line}" if not line.startswith("[") else f"\033[1m{line}\033[0m"
+ )
print("done.")
return rc
diff --git a/tools/make_logo_ans.py b/tools/make_logo_ans.py
index 9736e30..41c8b18 100644
--- a/tools/make_logo_ans.py
+++ b/tools/make_logo_ans.py
@@ -16,6 +16,7 @@ Needs Pillow, so run it with a python that has it (NOT ~/ida-venv):
/usr/bin/python3 tools/make_logo_ans.py [--cols 60] [-o logo.ans]
"""
+
from __future__ import annotations
import argparse
@@ -25,9 +26,9 @@ import sys
from PIL import Image
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
-ALPHA_ON = 128 # at/above this a pixel counts as present
+ALPHA_ON = 128 # at/above this a pixel counts as present
-UPPER, LOWER = "\u2580", "\u2584" # upper half block, lower half block
+UPPER, LOWER = "\u2580", "\u2584" # upper half block, lower half block
def main() -> int:
@@ -35,8 +36,11 @@ def main() -> int:
ap.add_argument("--png", default=os.path.join(REPO, "logo.png"))
ap.add_argument("-o", "--out", default=os.path.join(REPO, "logo.ans"))
ap.add_argument("--cols", type=int, default=60)
- ap.add_argument("--cell", default="9x22",
- help="terminal cell size WxH in px, for the aspect ratio")
+ ap.add_argument(
+ "--cell",
+ default="9x22",
+ help="terminal cell size WxH in px, for the aspect ratio",
+ )
args = ap.parse_args()
cw, ch = (int(v) for v in args.cell.lower().split("x"))
@@ -62,13 +66,15 @@ def main() -> int:
if not t_on and not b_on:
sgr, ch_ = "\033[0m", " "
elif t_on and b_on:
- sgr = (f"\033[38;2;{bot[0]};{bot[1]};{bot[2]}m"
- f"\033[48;2;{top[0]};{top[1]};{top[2]}m")
+ sgr = (
+ f"\033[38;2;{bot[0]};{bot[1]};{bot[2]}m"
+ f"\033[48;2;{top[0]};{top[1]};{top[2]}m"
+ )
ch_ = LOWER
- elif b_on: # only the lower pixel is present
+ elif b_on: # only the lower pixel is present
sgr = f"\033[0m\033[38;2;{bot[0]};{bot[1]};{bot[2]}m"
ch_ = LOWER
- else: # only the upper pixel is present
+ else: # only the upper pixel is present
sgr = f"\033[0m\033[38;2;{top[0]};{top[1]};{top[2]}m"
ch_ = UPPER
if sgr != prev:
@@ -81,8 +87,10 @@ def main() -> int:
text = "\n".join(out) + "\n"
with open(args.out, "w", encoding="utf-8") as f:
f.write(text)
- print(f"{args.png} {w}x{h} -> {args.out} {cols}x{rows} cells "
- f"({len(text):,} bytes, cell {cw}x{ch})")
+ print(
+ f"{args.png} {w}x{h} -> {args.out} {cols}x{rows} cells "
+ f"({len(text):,} bytes, cell {cw}x{ch})"
+ )
return 0
diff --git a/tools/verify_procs.py b/tools/verify_procs.py
index 323bf1f..f112cbc 100644
--- a/tools/verify_procs.py
+++ b/tools/verify_procs.py
@@ -30,6 +30,7 @@ from idatui.formats import PROCESSORS # noqa: E402
def main() -> int:
import idapro
+
idapro.enable_console_messages(False)
import ida_auto
import ida_ida
@@ -55,9 +56,12 @@ def main() -> int:
bits = ""
if rc == 0:
import ida_ida
+
bits = f" bitness={ida_ida.inf_get_app_bitness()}"
ok = rc == 0 and got.lower() == base.lower()
- print(f" {'ok ' if ok else 'BAD '} {name:<14} rc={rc} -> {got!r}{bits} {desc}")
+ print(
+ f" {'ok ' if ok else 'BAD '} {name:<14} rc={rc} -> {got!r}{bits} {desc}"
+ )
if rc == 0:
idapro.close_database(save=False)
if not ok:
diff --git a/uv.lock b/uv.lock
index 9b3fc9e..2808382 100644
--- a/uv.lock
+++ b/uv.lock
@@ -3,49 +3,6 @@ revision = 3
requires-python = ">=3.11"
[[package]]
-name = "annotated-types"
-version = "0.8.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
-]
-
-[[package]]
-name = "anyio"
-version = "4.14.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "idna" },
- { name = "typing-extensions", marker = "python_full_version < '3.13'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
-]
-
-[[package]]
-name = "certifi"
-version = "2026.7.22"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
-]
-
-[[package]]
-name = "click"
-version = "8.4.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "colorama", marker = "sys_platform == 'win32'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" },
-]
-
-[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
@@ -55,94 +12,31 @@ wheels = [
]
[[package]]
-name = "h11"
-version = "0.16.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
-]
-
-[[package]]
-name = "httpcore"
-version = "1.0.9"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "certifi" },
- { name = "h11" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
-]
-
-[[package]]
-name = "httpx"
-version = "0.28.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "anyio" },
- { name = "certifi" },
- { name = "httpcore" },
- { name = "idna" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
-]
-
-[[package]]
-name = "ida-codemode"
-version = "0.3.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "httpx" },
- { name = "ida-domain" },
- { name = "ida-hcli" },
- { name = "idapro" },
- { name = "packaging" },
- { name = "zeromcp" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/84/8b/710e2c46c1f353ee5231300b87b3b4c19e7d9db8f81dd7f42721b06c0fca/ida_codemode-0.3.1.tar.gz", hash = "sha256:5c15425d2d9bc614f0bfebedeba77ef6aa4b7b570716be7b4b2e165ef10ea603", size = 170892, upload-time = "2026-08-07T08:53:17.897Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/4e/72/09746e0367564dd00fb74e73277f2548d6ee332c9d9f3e1607fbae27f50d/ida_codemode-0.3.1-py3-none-any.whl", hash = "sha256:5e5e673335951f59a386b576dc3de9d67aeb8387916b99002cf679f402b821e0", size = 76984, upload-time = "2026-08-07T08:53:17.006Z" },
-]
-
-[[package]]
name = "ida-domain"
-version = "0.5.1.dev2"
+version = "0.5.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idapro" },
{ name = "packaging" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/2f/8a/d8f8d51bc9541048c7cf914b8ae1212dd996a7a19fd60b95b1d795f65f87/ida_domain-0.5.1.dev2.tar.gz", hash = "sha256:64285f61d708aaedc20e115d2b55d3cf5578031d9055fab8e8a36ee07eccb631", size = 391539, upload-time = "2026-08-04T11:15:21.148Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/80/34/be087d3ea1c3a6573e0660cb5b40f0c4ade9ae5772cf1c5d98d52472d28b/ida_domain-0.5.1.tar.gz", hash = "sha256:c49f2c417047d882e954f651b50a709a3f27903b33ba533b794aa54d6536d16f", size = 396413, upload-time = "2026-08-10T13:32:48.45Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ae/5c/232f709fee42227e8849ff1734ff822d51a000eac9bf928071bb49af2e5c/ida_domain-0.5.1.dev2-py3-none-any.whl", hash = "sha256:89187eed1c6365751a578c2d887e51b31dd645904bc6aeac60ad472af2d9e3be", size = 198406, upload-time = "2026-08-04T11:15:19.792Z" },
+ { url = "https://files.pythonhosted.org/packages/27/78/9c698d818b0fddc6648f703a0821edeeb65b18404b43f556d249c5446c96/ida_domain-0.5.1-py3-none-any.whl", hash = "sha256:bfbb17c7d0cb2ed7d3f21342e1c8787f9018d5c2a94cdfd29e06537dd026a06d", size = 201275, upload-time = "2026-08-10T13:32:46.955Z" },
]
[[package]]
-name = "ida-hcli"
-version = "0.19.0"
+name = "ida-nexus"
+version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "click" },
- { name = "httpx" },
- { name = "idapro" },
+ { name = "ida-domain" },
{ name = "packaging" },
- { name = "pip" },
- { name = "platformdirs" },
- { name = "pydantic" },
- { name = "questionary" },
- { name = "rich" },
- { name = "rich-click" },
- { name = "semantic-version" },
- { name = "tenacity" },
+ { name = "zeromcp" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/c6/e5/82bfc42bc6ebf6362ba4500cedcba106f539a4ad2715447ec5955a423dea/ida_hcli-0.19.0.tar.gz", hash = "sha256:b5a8c7ea42d94e102aabe745d3f6ee713a22cf0c26f07f9011cd1fa72322ead5", size = 199068, upload-time = "2026-08-05T18:52:31.034Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/b6/93/2f87cbd64ffc45e181542f133ba8db101c9049155b5f015d9d318f32dcfb/ida_nexus-0.7.0.tar.gz", hash = "sha256:838698c6a2456d474da833b2f4955da9f1fca4a488c95a157539a3c663a919ba", size = 220190, upload-time = "2026-08-20T21:52:18.228Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ba/fa/4c96f510ba700ee20f31397e4a172eed12ca5727533d18b624ca89e77055/ida_hcli-0.19.0-py3-none-any.whl", hash = "sha256:f06fa036bc52e669e739a57a01c541ec225b3938a712171083e9bd213de802ed", size = 248463, upload-time = "2026-08-05T18:52:32.304Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/c2/58704fc74618c7867cf7542d3150a75a7678aae2fff7960fa8e5cc67d934/ida_nexus-0.7.0-py3-none-any.whl", hash = "sha256:a5006c7170a0a758a598b864d6fa248bf4eccbea848a30fc6a3eeeadf638d07a", size = 127656, upload-time = "2026-08-20T21:52:19.395Z" },
]
[[package]]
@@ -159,7 +53,7 @@ name = "idatui"
version = "0.0.1"
source = { editable = "." }
dependencies = [
- { name = "ida-codemode" },
+ { name = "ida-nexus" },
{ name = "pygments" },
{ name = "textual" },
]
@@ -167,27 +61,20 @@ dependencies = [
[package.optional-dependencies]
dev = [
{ name = "pytest" },
+ { name = "ruff" },
]
[package.metadata]
requires-dist = [
- { name = "ida-codemode", specifier = ">=0.3.1" },
+ { name = "ida-nexus", specifier = ">=0.7.0" },
{ name = "pygments", specifier = ">=2" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8" },
+ { name = "ruff", marker = "extra == 'dev'", specifier = "==0.16.3" },
{ name = "textual", specifier = ">=8" },
]
provides-extras = ["dev"]
[[package]]
-name = "idna"
-version = "3.18"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
-]
-
-[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
@@ -256,15 +143,6 @@ wheels = [
]
[[package]]
-name = "pip"
-version = "26.2.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/ae/15/4500e320e6b101ec3b719ae85b697d9940b6cda672bc555bd6016fc60c6f/pip-26.2.1.tar.gz", hash = "sha256:f6ad667e89a1fe78046c8f13232b247200f5258d7828f3f7883d660878e0813f", size = 1848877, upload-time = "2026-08-04T22:51:14.148Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/f3/6e/1736e5b4ae2b778ef2f81c47d797de9f891d4d8acb047a24ca37a60294dd/pip-26.2.1-py3-none-any.whl", hash = "sha256:71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e", size = 1816632, upload-time = "2026-08-04T22:51:12.472Z" },
-]
-
-[[package]]
name = "platformdirs"
version = "4.10.0"
source = { registry = "https://pypi.org/simple" }
@@ -283,135 +161,6 @@ wheels = [
]
[[package]]
-name = "prompt-toolkit"
-version = "3.0.53"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "wcwidth" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/7d/ea/39b988c938f75cb75d7045b5c69f8bfed47ee2152c8837fb403de29d6fb8/prompt_toolkit-3.0.53.tar.gz", hash = "sha256:9ec8a0ad96d5c56148b3f914aa79c1564c3fde5d2e6b876e7bc327e353cf8fa6", size = 435492, upload-time = "2026-07-26T20:56:14.758Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/54/6f/84908cad2d6aa5144abcf7b42709fe4fdb459bc640ec7ac5786e7693dabc/prompt_toolkit-3.0.53-py3-none-any.whl", hash = "sha256:01c0891d7f9237d5e339f7d3e42cdae80b7534abb1c7c0e3352efba6231492f2", size = 392288, upload-time = "2026-07-26T20:56:12.512Z" },
-]
-
-[[package]]
-name = "pydantic"
-version = "2.13.4"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "annotated-types" },
- { name = "pydantic-core" },
- { name = "typing-extensions" },
- { name = "typing-inspection" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
-]
-
-[[package]]
-name = "pydantic-core"
-version = "2.46.4"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "typing-extensions" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" },
- { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" },
- { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" },
- { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" },
- { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" },
- { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" },
- { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" },
- { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" },
- { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" },
- { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" },
- { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" },
- { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" },
- { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" },
- { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" },
- { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" },
- { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
- { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
- { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
- { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
- { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
- { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
- { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
- { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
- { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
- { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
- { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
- { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
- { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
- { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
- { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
- { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
- { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
- { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
- { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
- { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
- { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
- { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
- { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
- { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
- { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
- { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
- { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
- { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
- { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
- { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
- { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
- { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
- { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
- { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
- { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
- { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
- { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
- { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
- { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
- { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
- { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
- { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
- { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
- { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
- { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
- { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
- { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
- { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
- { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
- { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
- { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
- { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
- { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
- { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
- { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
- { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
- { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
- { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
- { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
- { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
- { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" },
- { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" },
- { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" },
- { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" },
- { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
- { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
- { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
- { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
- { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" },
- { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" },
- { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" },
- { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" },
- { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" },
- { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" },
- { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" },
- { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" },
-]
-
-[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
@@ -437,18 +186,6 @@ wheels = [
]
[[package]]
-name = "questionary"
-version = "2.1.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "prompt-toolkit" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" },
-]
-
-[[package]]
name = "rich"
version = "15.0.0"
source = { registry = "https://pypi.org/simple" }
@@ -462,35 +199,28 @@ wheels = [
]
[[package]]
-name = "rich-click"
-version = "1.9.8"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "click" },
- { name = "colorama", marker = "sys_platform == 'win32'" },
- { name = "rich" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/f7/ea/21e4867ea0ef881ffd4c0550fc21a061435e50d6324bcd034396633cbc18/rich_click-1.9.8.tar.gz", hash = "sha256:4008f921da88b5d91646c134ec881c1500e5a6b3f093e90e8f29400e09608371", size = 75363, upload-time = "2026-05-28T19:54:59.144Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/6d/97/a87901aef6b7e7e4a34c6dd6cc17dca8594a592ef9d9dd765fca2b7facf7/rich_click-1.9.8-py3-none-any.whl", hash = "sha256:12873865396e6927835d4eabb1cc3996edcd65b7ac9b2391a29eca4f335a2f93", size = 72189, upload-time = "2026-05-28T19:54:57.867Z" },
-]
-
-[[package]]
-name = "semantic-version"
-version = "2.10.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/7d/31/f2289ce78b9b473d582568c234e104d2a342fd658cc288a7553d83bb8595/semantic_version-2.10.0.tar.gz", hash = "sha256:bdabb6d336998cbb378d4b9db3a4b56a1e3235701dc05ea2690d9a997ed5041c", size = 52289, upload-time = "2022-05-26T13:35:23.454Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/6a/23/8146aad7d88f4fcb3a6218f41a60f6c2d4e3a72de72da1825dc7c8f7877c/semantic_version-2.10.0-py2.py3-none-any.whl", hash = "sha256:de78a3b8e0feda74cabc54aab2da702113e33ac9d9eb9d2389bcf1f58b7d9177", size = 15552, upload-time = "2022-05-26T13:35:21.206Z" },
-]
-
-[[package]]
-name = "tenacity"
-version = "9.1.4"
+name = "ruff"
+version = "0.16.3"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/61/b3/3213589383f8f1b3938781bd1278713f6d18621a14992b3e81fefb8a5ef9/ruff-0.16.3.tar.gz", hash = "sha256:e76d33a347661a84b5be6d043d0347fdc745dfdcf825a8f4fed64b5e26eebdf2", size = 4891904, upload-time = "2026-08-13T15:17:13.381Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/96/493770daebd68c0a67f1549fdf519f53be51fc435186c0585bcc272fd76c/ruff-0.16.3-py3-none-linux_armv6l.whl", hash = "sha256:0c5710e247a58a4521e66e124ba9a74655b414f61ba3a2e9e3811e11098f48f7", size = 10902799, upload-time = "2026-08-13T15:16:27.382Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/e6/2becf3942fddc29a29b8df47691d456fb1085391a694f74d84513251418c/ruff-0.16.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fe155130631a2471fd2e14a7a664a4dfbd7194b8229c3d7b2a40b21178639081", size = 11135539, upload-time = "2026-08-13T15:16:30.87Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/1e/4b8b72f0d006dbf19326aa99f9ca0ee2ff374187c4d301cf529a51aa06fe/ruff-0.16.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e2ed719e14aa64d895c2ee922594a90a43c861a93f0575a95ff8c47cdbd13eb9", size = 10475095, upload-time = "2026-08-13T15:16:33.259Z" },
+ { url = "https://files.pythonhosted.org/packages/92/32/2201fa49ba1f6c101ee321e83f051ac7a4b8d07b0ef6b4d3f2772b302275/ruff-0.16.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e0b1da805eb043654645d74d5de1e5ce2edc686e40790d2b86f56d71cc06a84", size = 10668771, upload-time = "2026-08-13T15:16:35.65Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/66/4afc5c8363bd04d45effce1b7c8713ca037d7a6740b7451a2403a6e3a972/ruff-0.16.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a37bdea0bbe21780f590bf437d6412c8c4e1b6cd010f91a65c2c40c5e5f5f870", size = 10699568, upload-time = "2026-08-13T15:16:38.195Z" },
+ { url = "https://files.pythonhosted.org/packages/53/fd/c67d246bf36bf1698551c56de39e95cd07f70e64433e0098e6267d77061b/ruff-0.16.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09571e6d1288ed9be475207a3ac04ada404f1cd898104be0f6ab8d7df438575b", size = 11499365, upload-time = "2026-08-13T15:16:40.623Z" },
+ { url = "https://files.pythonhosted.org/packages/67/0b/00ecbceb99a263af7b12f6f05ac3c92bc47b905e91adc3f207a836e3bc01/ruff-0.16.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c18c5a101eb540010638cc1ff3c84944d3adb3df62b8d98ca8f22ba484d3413", size = 12311728, upload-time = "2026-08-13T15:16:43.564Z" },
+ { url = "https://files.pythonhosted.org/packages/54/b2/b7b3bb54f4d3f7db504e476ad4ab8de530dceebe2c061384b2757ee419e8/ruff-0.16.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8457c44f15033c85ddbb77b15d451df9e24e4bd03b628396dd3610cedc3b8f82", size = 11699896, upload-time = "2026-08-13T15:16:46.209Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/30/4c468429ac195addc5ee1b717b6ab1b66632786737ca3b2ed3443fb0c26a/ruff-0.16.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:294b95c4ae0cda9388525c2047778aa758d6b8d4bb876fd4e9eaa3ebc92343eb", size = 11058736, upload-time = "2026-08-13T15:16:48.823Z" },
+ { url = "https://files.pythonhosted.org/packages/43/67/7a113cdaddf24b64d7f75b1242a99d04c82fcef4f6921fdbb832beaffb5f/ruff-0.16.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3d0c7c40c87c2a820509c31ba007968da6e1306468c067b2d82fbfdbcd0e8474", size = 11586911, upload-time = "2026-08-13T15:16:51.913Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/c1/2e66f24c0f3ead25a5e660111778685e505e5da353c82802bf49f0cbe7b9/ruff-0.16.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9f738c0fdfa8eed0b2ce7fb27ee7258208a92a68d7949e62aa15164bc7b389da", size = 10954265, upload-time = "2026-08-13T15:16:54.763Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/ba/4cee23bf52cba9a058d3726de623624daf50ef9638868edd86f4126157f6/ruff-0.16.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fb785f0be25abe69d320415cd4f833b59e17ba7613d9ba6a958023b6bceb0a50", size = 10709886, upload-time = "2026-08-13T15:16:57.339Z" },
+ { url = "https://files.pythonhosted.org/packages/82/df/7da7194fa5d9dc0a285f7e6fa5a4722e7c63faac0b45b614ded9314363a1/ruff-0.16.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c5536e3acfbf9563085aa2be7b13c629c3077e902afc5b941ac44024dbb9f506", size = 11210392, upload-time = "2026-08-13T15:17:00.171Z" },
+ { url = "https://files.pythonhosted.org/packages/35/85/7795f6e817af050e7517bf3e7aa9b061cce70ef33d280aad902c956c1ecf/ruff-0.16.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a2d85c02f9b8e165d85e6779184d38c4132de12603dab59c51c28e22584f9e4d", size = 11626910, upload-time = "2026-08-13T15:17:03.299Z" },
+ { url = "https://files.pythonhosted.org/packages/78/9b/475b927cf27a5cbbda3c7bafb69ed6ff77e1d7923d5d85f17c2749d7ae32/ruff-0.16.3-py3-none-win32.whl", hash = "sha256:388cdf2166642bd9b13d52b5932d3170f34f8abed7e8d9a855f1d84b83645a0a", size = 10931415, upload-time = "2026-08-13T15:17:05.726Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/99/e2a2bfc4fbf0a1e8a916bc9ebe6fe6c58cc34c28e0ffc6ce281d572d1c2e/ruff-0.16.3-py3-none-win_amd64.whl", hash = "sha256:e80a7d69ca2a6d1c4d352ec91458cdca6e56c83cdbcabd93e4abe1e53591d948", size = 11445993, upload-time = "2026-08-13T15:17:08.353Z" },
+ { url = "https://files.pythonhosted.org/packages/69/3e/4132e539aed78c148854d4997a2685b0ed4dc4e87110b59ce528564e184e/ruff-0.16.3-py3-none-win_arm64.whl", hash = "sha256:b8ca152da82c1acc1fa8d5874b15951935f0eef46f10e6954c83859011b6178a", size = 11399302, upload-time = "2026-08-13T15:17:10.908Z" },
]
[[package]]
@@ -520,18 +250,6 @@ wheels = [
]
[[package]]
-name = "typing-inspection"
-version = "0.4.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "typing-extensions" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
-]
-
-[[package]]
name = "uc-micro-py"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
@@ -541,19 +259,10 @@ wheels = [
]
[[package]]
-name = "wcwidth"
-version = "0.8.2"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288bec1396f5bfcbc2d925700a4dad61727fd5f2b12f249d/wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda", size = 1466253, upload-time = "2026-06-29T18:11:11.601Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" },
-]
-
-[[package]]
name = "zeromcp"
-version = "1.6.0"
+version = "1.8.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/e1/e5/ea9432c099b139ac67ee8eae0be4ce2486179b55ca6b514fddaa7708c4b9/zeromcp-1.6.0.tar.gz", hash = "sha256:994e7c82837e8cdf31e0e9fdb6cc753c79446641dc35d41a59b168179e09d81a", size = 99346, upload-time = "2026-08-06T12:54:23.521Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/30/d7/606457fc543e5b25f190b1513c3623cbb9683cf27d62be0656ebf2c7b26b/zeromcp-1.8.0.tar.gz", hash = "sha256:682817c488593ff4ec831f79db06415f2d2ff31db98e58e5fff9d9116c042ff3", size = 99866, upload-time = "2026-08-10T19:17:11.221Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/98/ad/44c113ebcd6ba245288251f9ba3206ef9b07449cd1fca9e636cc3215719a/zeromcp-1.6.0-py3-none-any.whl", hash = "sha256:173613e75d0ca2e50a3bad3e42dbb0e4f629824ffb02851d03ef73f9ad42b9b3", size = 23427, upload-time = "2026-08-06T12:54:22.325Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/d9/f46820d2eca9c4c984348d9a2f39d4e59192cdaaebc12895e92d38a742f6/zeromcp-1.8.0-py3-none-any.whl", hash = "sha256:7f270a901f0e2327c369509757c455faa1eeb0513e758dbbb25f4923f089f77a", size = 23666, upload-time = "2026-08-10T19:17:10.027Z" },
]