aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/GRAPH_VIEW.md83
-rw-r--r--docs/TRISKEL_EVAL.md187
-rw-r--r--experiments/graph_compare.py158
-rw-r--r--experiments/graph_shot.py7
-rw-r--r--experiments/graph_spike.py6
-rw-r--r--idatui/app.py31
-rw-r--r--idatui/graph.py144
-rw-r--r--idatui/graph_triskel.py369
-rw-r--r--pyproject.toml6
-rw-r--r--tests/test_graph.py64
-rw-r--r--tests/test_scenarios.py56
11 files changed, 1054 insertions, 57 deletions
diff --git a/docs/GRAPH_VIEW.md b/docs/GRAPH_VIEW.md
index 29b280e..ea1f84d 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 |
@@ -56,7 +57,51 @@ The backend adds exactly one operation, `flowchart(addr)` in
`idatui/codemode_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 250
+blocks, and falls back to **native** otherwise — including if triskel raises,
+which is never fatal.
+
+| | native | triskel |
+|---|---|---|
+| algorithm | layered Sugiyama, below | SESE decomposition ([paper](https://hal.science/hal-04996939)) |
+| ships with | always, pure python | needs `pytriskel` (our fork) |
+| 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).
+
+`experiments/graph_compare.py` regenerates all of those numbers, and
+`docs/TRISKEL_EVAL.md` is the full evaluation, including what had to be fixed in
+triskel to make it usable at all.
+
+### 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, all of which is
+handled before the call: self-loops (drawn as `↺`, and they make triskel throw),
+disconnected components (laid out separately and stacked; IDA flowcharts do have
+unreachable blocks), and the one edge in the corpus that triskel routes *through*
+a block, which is detoured and then re-verified — if the detour fails the whole
+layout falls back to native rather than draw an edge through the disassembly.
+
+## 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 +175,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 +203,25 @@ 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_compare.py` — both engines over a corpus: crossings, canvas,
+ ambiguous cells, cost. `--real-sizer` sizes boxes from the disassembly text,
+ which is the only comparison worth reading.
- `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`.
+
+```bash
+uv pip install ~/dev/triskel/bindings/python # needs cmake, ninja, a C++23 compiler
+```
+
+That is **our fork**, not PyPI. 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. `~/dev/triskel/PATCHES.md`
+lists every change. `$IDATUI_TRISKEL_PATH` can point at a build tree instead.
diff --git a/docs/TRISKEL_EVAL.md b/docs/TRISKEL_EVAL.md
new file mode 100644
index 0000000..ea57515
--- /dev/null
+++ b/docs/TRISKEL_EVAL.md
@@ -0,0 +1,187 @@
+# Triskel for graph layout — evaluated, forked, integrated
+
+> **Outcome.** Shipped as the `triskel` engine behind `graph.layout(engine=...)`,
+> preferred by `auto` up to 250 blocks, off a local fork
+> (`~/dev/triskel`, branch `idatui`, see its `PATCHES.md`). The library needed
+> six fixes before it could be used from Python at all — including a segfault
+> and a binding bug that made edge routes unreachable. Everything below is the
+> evaluation that led there; `docs/GRAPH_VIEW.md` documents what shipped.
+
+
+[triskel](https://github.com/triskellib/triskel) (MPL-2.0, C++23, 126★) is a CFG
+layout engine from Inria, the implementation of *[Towards better CFG
+layouts](https://hal.science/hal-04996939)*. Its idea is genuinely better than
+ours: before running Sugiyama, split the CFG into **Single-Entry Single-Exit
+(SESE) regions**, lay each region out on its own, then paste the region layouts
+back in as single super-nodes. Divide and conquer, so crossings stay local.
+
+This is what happened when we actually ran it against `idatui.graph` on the
+128-function corpus in `.auto/cfg-corpus.json`.
+
+**Verdict as first written: don't link the library, port the idea.** That was
+reversed after the blockers turned out to be six small, independent patches
+rather than algorithm work — and one of them (settable spacing) removed the
+quantisation problem entirely instead of managing it. Reimplementing 350 lines
+of cycle-equivalence C++ in Python to avoid a `#include` would have been a poor
+trade. The licensing note at the end is why the fork stays a fork: MPL-2.0 is
+file-level copyleft, so linking it costs us nothing, and our changes to *their*
+files stay in *their* repo.
+
+## The quality gap is real
+
+Both engines fed identical blocks and identical cell sizes (triskel gets them as
+"pixels" at 16×32 per cell). Crossings are proper segment intersections counted
+on each engine's own edge polylines; `X` is that count, `None` = above the
+counting cap.
+
+```
+ blk edge | ours ms ours WxH X | tk ms tk WxH(cells) X | name
+ 9 14 | 0.5 89x62 5 | 0.3 81x71 1 | sub_61D0
+ 10 41 | 1.3 99x80 10 | 0.5 86x105 0 | sub_3500
+ 17 44 | 1.4 213x144 6 | 0.6 159x171 0 | sub_2FF0
+ 21 33 | 1.2 724x105 41 | 0.5 782x119 0 | sub_5CA0
+ 38 150 | 4.7 325x305 32 | 2.8 202x392 1 | sub_2C90
+ 87 428 | 14.8 1202x444 None | 30.3 850x775 None | main
+ 109 647 | 25.9 793x476 None | 53.6 363x970 None | sub_69C0
+ 424 3340 | 145.7 9222x1386 None | 1534.3 1872x3883 None | sub_3720
+total ours 207 ms triskel 1626 ms (full corpus in /tmp/tk_cmp.py)
+```
+
+Two things to take from that table:
+
+- **Crossings collapse to ~0.** Every function under 40 blocks lays out with 0
+ or 1 crossing, where ours has up to 41. That is the SESE decomposition doing
+ exactly what the paper claims.
+- **Canvases get narrow and tall.** `sub_69C0`: 793×476 → 363×970. `sub_3720`:
+ 9222×1386 → 1872×3883. For a terminal that is the right trade — vertical
+ scrolling is free, horizontal panning is the thing that makes our graph view
+ feel like peering through a letterbox.
+
+And it costs us on speed above ~40 blocks: 2× slower at 87–109 blocks, **10×
+slower at 424** (1.5 s vs 145 ms). So it would not let us raise the 400-block
+cap; it would argue for lowering it.
+
+## The output *is* renderable in character cells
+
+This was the thing that could have killed the idea outright, and it doesn't:
+
+- **Every segment is axis-aligned.** 0 diagonal segments out of 1708 (`main`)
+ and 2471 (`sub_69C0`). Box-drawing characters map straight onto it.
+- **No edge is routed through a box.** The "edge cells inside a box" count comes
+ out at exactly ~1 per edge — that is the polyline's first waypoint, which sits
+ at the source node's *centre*. Clip the first and last segment to the border
+ and it is clean.
+- **Quantisation is a knob, not a wall.** Triskel packs edges in continuous
+ space, so rounding to cells can drop two edges into one column. How often
+ depends entirely on the px-per-cell we feed it (`main`, 140 edges):
+
+ | px/cell | edge cells | cells shared by >1 edge |
+ |---|---|---|
+ | 8×16 | 50418 | 73 (0.1%) |
+ | 12×24 | 41536 | 69 (0.2%) |
+ | 16×32 | 36071 | 1026 (2.8%) |
+ | 24×48 | 28113 | 4525 (16.1%) |
+
+ The gutters are hardcoded constants (`X_GUTTER=50`, `Y_GUTTER=40`,
+ `EDGE_HEIGHT=30`), so px-per-cell is really "how many cells of gutter do I
+ buy". Cheap cells → wider canvas, unambiguous edges. This matters more for us
+ than for a pixel renderer: an ambiguous cell isn't just ugly, it breaks
+ click-to-select-edge and the incident-edge highlight, which assume a cell
+ belongs to one edge. Our lane-packed channels exist to make that impossible.
+
+## Why we can't just `pip install pytriskel` (all fixed in the fork)
+
+1. **No wheel we can use.** All ten releases ship `manylinux_2_34_x86_64` wheels
+ for cp37–cp313 and **no sdist**. Our venv is Python 3.14 → `pip install`
+ finds nothing. It is also x86_64-Linux only: no macOS, no arm64, no Windows.
+2. **The Python bindings can't return edge routes at all.** `pytriskel.cpp`
+ never includes `<pybind11/stl.h>`, so `get_waypoints()` raises
+ `Unable to convert function return value to a Python type` on every published
+ version. The `.pyi` stub gives it away: `get_waypoints(self, arg0: int) -> ...`.
+ From the shipped wheel you can get node coordinates and save a PNG — that is
+ it. A one-line patch fixes it (verified locally).
+3. **Building from source works but is heavy.** Verified here: clone, `cmake
+ -DENABLE_CAIRO=ON -DBUILD_BINDINGS=ON`, ~2 minutes, produces a working
+ `pytriskel.cpython-314-*.so`. But `BUILD_BINDINGS` is gated on
+ `ENABLE_CAIRO`, so a user installing a *TUI* would need cmake, a C++23
+ compiler, fmt and cairo dev headers to draw boxes made of `─`.
+4. **It crashes the process on degenerate input.**
+ - empty graph → **segfault** (not an exception — it takes the interpreter with
+ it, and with it your session)
+ - disconnected graph → `RuntimeError: EMPTY BL`, an internal bracket-list
+ assertion leaking out. IDA flowcharts do contain unreachable blocks.
+
+ Self-loops, parallel edges and 2-cycles are all handled fine.
+5. **Rough edges in the API.** `make_node(float height, float width)` is
+ documented in the Python stub as "with a width and height" — the arguments
+ are the other way round (this cost us a benchmark run). `get_height` is bound
+ twice, once over `get_width`, so graph width is unreachable from Python.
+ Node sizes can't be read back, and the SESE tree isn't exposed.
+
+## What we'd also lose
+
+`graph.py` doesn't just return coordinates. It returns ranks and per-layer
+order, which `w`/`b` navigation, the minimap and the RPC `graph show` verb all
+read. Triskel exposes neither — we'd re-derive ordering from y coordinates.
+And the whole engine is currently pure Python with no I/O, which is why
+`tests/test_graph.py` runs offline in milliseconds against a 128-function
+corpus. Linking a native layout engine costs us that property.
+
+## What integration actually cost
+
+Six patches to the fork (`~/dev/triskel/PATCHES.md`) and one new module,
+`idatui/graph_triskel.py`. The patch that mattered most was making `X_GUTTER` /
+`Y_GUTTER` / `EDGE_HEIGHT` settable: feeding the engine **cells instead of
+pixels** (3 / 1 / 1) makes its output integral, so the whole quantisation
+section above stops applying. Measured after the fact on the real pipeline, the
+fear was backwards — cells claimed by more than one edge across the small-corpus
+functions: **native 131, triskel 35**.
+
+Three things stayed on our side of the boundary because they are the caller's
+job, not the library's: self-loops (never passed — they throw), disconnected
+components (laid out separately and stacked — they throw), and the one corpus
+edge triskel routes through a block (detoured, then re-verified, else the whole
+layout falls back to native).
+
+The canvas also had to learn that edges can live outside the boxes' bounding
+box: triskel routes a loop around the side of the graph, and sizing the canvas
+on nodes alone — which is exact for the native engine, since its dummy nodes
+reserve the space — clipped exactly the edges that make its layouts worth having.
+
+## The road not taken: port the idea, not the code
+
+The win is the SESE decomposition, and that is ~350 lines of C++
+(`lib/src/analysis/sese.cpp`, cycle equivalence / program structure tree, plus
+`udfs.cpp`) and the region orchestration in `layout.cpp`. In Python, on top of
+the pipeline we already have, that is roughly:
+
+1. undirected DFS + cycle equivalence → the program structure tree (~200 lines)
+2. per-region layout: run our existing steps 2–5 on the region subgraph
+3. collapse each region into a super-node in its parent, then translate
+
+Steps 2 and 3 reuse `_assign_ranks` / `_order_layers` / `_assign_x` unchanged,
+and — this is the point — **our cell-native router and lane packing survive**, so
+we keep the 0-edge-cells-inside-a-box guarantee and unambiguous edge ownership
+instead of inheriting a quantisation problem.
+
+On licensing: MPL-2.0 is file-level copyleft. Linking the library unmodified
+imposes nothing on our code; copying their source into `graph.py` would arguably
+make that file MPL. Implementing from the paper and citing it keeps this clean.
+
+Worth doing regardless, as upstream is friendly and we may want the library
+later: file the missing `<pybind11/stl.h>`, the empty-graph segfault, and the
+`make_node` docstring order.
+
+## Reproducing
+
+The throwaway scripts that produced the tables above (`/tmp/tk_*.py`, driving
+pytriskel directly) have been replaced by one that drives the shipping pipeline:
+
+```bash
+python3 experiments/graph_compare.py .auto/cfg-corpus.json --real-sizer
+```
+
+and the engines are exercised side by side, on every invariant, by
+`tests/test_graph.py` — which runs its whole suite once per available engine, so
+"triskel draws no edge through a box" is checked on 128 real functions rather
+than asserted here.
diff --git a/experiments/graph_compare.py b/experiments/graph_compare.py
new file mode 100644
index 0000000..0714554
--- /dev/null
+++ b/experiments/graph_compare.py
@@ -0,0 +1,158 @@
+#!/usr/bin/env python3
+"""Compare the layout engines on a corpus: crossings, canvas, ambiguity, cost.
+
+ python3 experiments/graph_compare.py .auto/cfg-corpus.json
+ python3 experiments/graph_compare.py .auto/cfg-corpus.json --max-blocks 40
+
+Every number comes out of the shipping pipeline (``idatui.graph``), not out of a
+side channel, so it measures what the view will actually draw.
+
+The columns that matter:
+
+``X`` proper segment crossings -- what the SESE decomposition is for.
+``amb`` cells claimed by more than one edge. In a pixel renderer overlapping
+ lines are invisible; in a terminal one cell holds one character, so an
+ ambiguous cell is an edge the user cannot follow and ``edge_at`` gets
+ wrong under the cursor.
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import sys
+import time
+from collections import defaultdict
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from idatui import graph as G # noqa: E402
+
+
+def sizer(b: G.Block) -> tuple[int, int]:
+ return (len(f"loc_{b.start:X}") + 6, 4)
+
+
+def real_sizer(rec: dict, max_lines: int):
+ """Box sizes from the actual disassembly text, like the view's own sizer.
+
+ Box shape is not a detail here: it decides how much horizontal room a layer
+ needs, and therefore how far edges travel sideways. Comparing engines on
+ uniform 20-column stubs measures the wrong graph.
+ """
+ texts = {}
+ 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"]
+ texts[b["id"]] = lines
+
+ def size(b: G.Block) -> tuple[int, int]:
+ lines = texts[b.id]
+ return max((len(line) for line in lines), default=8) + 4, len(lines) + 2
+ return size
+
+
+def segments(lay: G.Layout) -> dict[int, list[tuple[int, int, int, int]]]:
+ """Per-edge segments as (x0, y0, x1, y1), rebuilt from the painting."""
+ segs: dict[int, list] = defaultdict(list)
+ for row, runs in lay.painting.hruns.items():
+ for lo, hi, _style, eid in runs:
+ segs[eid].append((lo, row, hi, row))
+ for lo, hi, col, _style, eid in lay.painting.vruns:
+ segs[eid].append((col, lo, col, hi))
+ return segs
+
+
+def crossings(segs: dict[int, list], cap: int = 400_000) -> int | None:
+ def orient(px, py, qx, qy, rx, ry):
+ v = (qx - px) * (ry - py) - (qy - py) * (rx - px)
+ return (v > 0) - (v < 0)
+
+ keys = list(segs)
+ n = pairs = 0
+ for i, a in enumerate(keys):
+ for b in keys[i + 1:]:
+ for ax, ay, bx, by in segs[a]:
+ for cx, cy, dx, dy in segs[b]:
+ pairs += 1
+ if pairs > cap:
+ return None
+ o1 = orient(ax, ay, bx, by, cx, cy)
+ o2 = orient(ax, ay, bx, by, dx, dy)
+ o3 = orient(cx, cy, dx, dy, ax, ay)
+ o4 = orient(cx, cy, dx, dy, bx, by)
+ if o1 != o2 and o3 != o4:
+ n += 1
+ return n
+
+
+def ambiguous(lay: G.Layout) -> tuple[int, int]:
+ """(cells claimed by >1 edge, total edge cells)."""
+ owners: dict[tuple[int, int], set[int]] = defaultdict(set)
+ for row, runs in lay.painting.hruns.items():
+ for lo, hi, _s, eid in runs:
+ for c in range(lo, hi + 1):
+ owners[(row, c)].add(eid)
+ for lo, hi, col, _s, eid in lay.painting.vruns:
+ for r in range(lo, hi + 1):
+ owners[(r, col)].add(eid)
+ return sum(1 for v in owners.values() if len(v) > 1), len(owners)
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser()
+ ap.add_argument("corpus")
+ ap.add_argument("--max-blocks", type=int, default=10 ** 9)
+ ap.add_argument("--min-blocks", type=int, default=6)
+ ap.add_argument("--real-sizer", action="store_true",
+ help="size boxes from the disassembly text, as the view does")
+ ap.add_argument("--max-lines", type=int, default=8)
+ args = ap.parse_args()
+
+ from idatui import graph_triskel
+ if not graph_triskel.available():
+ print("pytriskel not importable; set IDATUI_TRISKEL_PATH", file=sys.stderr)
+ return 2
+
+ recs = json.load(open(args.corpus))
+ recs = [r for r in recs
+ if args.min_blocks <= len(r["blocks"]) <= args.max_blocks]
+ recs.sort(key=lambda r: len(r["blocks"]))
+
+ print(f"{'blk':>4} | {'native ms':>9} {'canvas':>11} {'X':>5} {'amb':>5} | "
+ f"{'tk ms':>7} {'canvas':>11} {'X':>5} {'amb':>5} | name")
+ tot = {"native": 0.0, "triskel": 0.0}
+ won = tied = lost = 0
+ amb_tot = {"native": 0, "triskel": 0}
+ for rec in recs:
+ row = {}
+ for engine in ("native", "triskel"):
+ 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"]]
+ size = real_sizer(rec, args.max_lines) if args.real_sizer else sizer
+ t0 = time.perf_counter()
+ lay = G.layout(blocks, size, engine=engine)
+ ms = (time.perf_counter() - t0) * 1000
+ tot[engine] += ms
+ amb, _cells = ambiguous(lay)
+ amb_tot[engine] += amb
+ row[engine] = (ms, lay.width, lay.height, crossings(segments(lay)), amb)
+ (nm, nw, nh, nx, na) = row["native"]
+ (tm, tw, th, tx, ta) = row["triskel"]
+ if nx is not None and tx is not None:
+ won += tx < nx
+ tied += tx == nx
+ lost += tx > nx
+ print(f"{len(rec['blocks']):>4} | {nm:>9.1f} {f'{nw}x{nh}':>11} "
+ f"{str(nx):>5} {na:>5} | {tm:>7.1f} {f'{tw}x{th}':>11} "
+ f"{str(tx):>5} {ta:>5} | {rec['name']}")
+ print(f"\ntotal: native {tot['native']:.0f} ms, triskel {tot['triskel']:.0f} ms")
+ print(f"crossings: triskel better on {won}, equal on {tied}, worse on {lost}")
+ print(f"ambiguous cells: native {amb_tot['native']}, triskel {amb_tot['triskel']}")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/experiments/graph_shot.py b/experiments/graph_shot.py
index 5f0d45a..e024809 100644
--- a/experiments/graph_shot.py
+++ b/experiments/graph_shot.py
@@ -1,6 +1,6 @@
"""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
@@ -19,6 +19,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)
@@ -46,6 +47,10 @@ async def main() -> None:
await pilot.pause(0.2)
await pilot.press("space")
gv = app.query_one(GraphView)
+ 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:
diff --git a/experiments/graph_spike.py b/experiments/graph_spike.py
index 547c829..e8cf3b8 100644
--- a/experiments/graph_spike.py
+++ b/experiments/graph_spike.py
@@ -110,6 +110,8 @@ def main() -> int:
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))
@@ -119,7 +121,7 @@ def main() -> int:
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} "
@@ -138,7 +140,7 @@ def main() -> int:
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)
diff --git a/idatui/app.py b/idatui/app.py
index 8baff74..76aff62 100644
--- a/idatui/app.py
+++ b/idatui/app.py
@@ -2356,6 +2356,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),
@@ -2388,6 +2389,9 @@ class GraphView(NavMixin, ScrollView, can_focus=True):
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
@@ -2423,7 +2427,8 @@ class GraphView(NavMixin, ScrollView, can_focus=True):
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)
+ 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):
@@ -2657,6 +2662,26 @@ class GraphView(NavMixin, ScrollView, can_focus=True):
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 "?"
+ note = "" if graph_triskel.available() else " (pytriskel not installed)"
+ self.app._status(f"graph: engine {self._engine} \u2192 {got}{note}")
+
def action_center(self) -> None:
self._center_cursor()
self.refresh()
@@ -3664,6 +3689,7 @@ _HELP = (
("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"),
@@ -6502,9 +6528,10 @@ class IdaTui(App):
return
s = gv.lay.stats
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"{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:
diff --git a/idatui/graph.py b/idatui/graph.py
index baee597..fbaf87f 100644
--- a/idatui/graph.py
+++ b/idatui/graph.py
@@ -28,9 +28,13 @@ 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
@@ -100,6 +104,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:
@@ -151,6 +161,7 @@ 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
@@ -461,6 +472,9 @@ class Route:
pts: list[tuple[int, int]]
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]:
@@ -521,8 +535,8 @@ 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
@@ -646,20 +660,25 @@ 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)
+ 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 +686,70 @@ 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: triskel's SESE decomposition
+#: costs ~10x at 424 blocks (1.5s vs 145ms), and a layout that blocks the UI for
+#: a second is worse than a layout with more crossings. Measured, see
+#: docs/TRISKEL_EVAL.md.
+AUTO_TRISKEL_MAX_BLOCKS = 250
+
+
+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
+ 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.
+ _LOG.warning("triskel layout failed (%s), falling back", exc)
+ name = "native+triskel-failed"
+ 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,40 +782,60 @@ 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,
"ms": (time.perf_counter() - t0) * 1000,
}
return Layout(nodes=order, by_id={n.id: n for n in g.nodes.values()},
diff --git a/idatui/graph_triskel.py b/idatui/graph_triskel.py
new file mode 100644
index 0000000..e5b662e
--- /dev/null
+++ b/idatui/graph_triskel.py
@@ -0,0 +1,369 @@
+"""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. ``docs/TRISKEL_EVAL.md`` has the measurements.
+
+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
+
+#: Columns between two weakly-connected components laid out side by side.
+COMPONENT_GAP = 4
+
+_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 _components(g: G._Graph) -> list[list[int]]:
+ """Weakly-connected components, entry's component first.
+
+ Triskel raises ``EMPTY BL`` on a disconnected graph (its cycle-equivalence
+ bracket lists run dry), and IDA flowcharts do contain unreachable blocks --
+ ``tests/test_graph.py:t_unreachable`` is exactly that shape. So we split the
+ work ourselves and stack the pieces side by side, which is also what the
+ native engine effectively does.
+ """
+ adj: dict[int, set[int]] = {i: set() for i in g.nodes}
+ for e in g.edges:
+ adj[e.src].add(e.dst)
+ adj[e.dst].add(e.src)
+ seen: set[int] = set()
+ comps: list[list[int]] = []
+ for start in g.nodes:
+ if start in seen:
+ continue
+ stack, comp = [start], []
+ seen.add(start)
+ while stack:
+ i = stack.pop()
+ comp.append(i)
+ for j in adj[i]:
+ if j not in seen:
+ seen.add(j)
+ stack.append(j)
+ comps.append(sorted(comp))
+ return comps
+
+
+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))
+
+ by_src: dict[int, list[G.Edge]] = {}
+ for e in g.edges:
+ by_src.setdefault(e.src, []).append(e)
+
+ routes: list[G.Route] = []
+ x_off = 0
+ for comp in _components(g):
+ members = set(comp)
+ edges = [e for i in comp for e in by_src.get(i, []) if e.dst in members]
+ x_off = _layout_component(pt, g, comp, edges, routes, x_off)
+
+ # 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_component(pt, g: G._Graph, comp: list[int], edges: list[G.Edge],
+ routes: list[G.Route], x_off: int) -> int:
+ """Lay one component out, shifted right by ``x_off``. Returns the next x."""
+ builder = pt.make_layout_builder()
+ tid = {}
+ for nid in comp:
+ 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]
+ 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 comp]
+ ys = [lay.get_coords(tid[i]).y for i in comp]
+ 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)) + x_off
+
+ for nid in comp:
+ n = g.nodes[nid]
+ p = lay.get_coords(tid[nid])
+ n.y, n.x = cell(p.x, p.y)
+
+ right = max((g.nodes[i].x + g.nodes[i].w for i in comp), default=x_off)
+ 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)
+ pts = _clean(pts)
+ routes.append(G.Route(edge=e, pts=pts, head=True, tail=True,
+ flipped=False))
+ right = max(right, max(c for _, c in pts) + 1)
+ return right + COMPONENT_GAP
+
+
+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 _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)
+ moved = 0
+ for rt in routes:
+ for i in range(1, len(rt.pts) - 2):
+ p, q = rt.pts[i], rt.pts[i + 1]
+ for _ in range(4):
+ hit = _hits(by_col, by_row, p, q)
+ if not hit:
+ break
+ n = hit[0]
+ if p[1] == q[1]: # vertical: shift column
+ col = p[1]
+ near, far = n.x - 1, n.right + 1
+ if col - n.x > n.right - col or near < 0:
+ near, far = far, near # never detour off-canvas
+ p, q = (p[0], near), (q[0], near)
+ else: # horizontal: shift row
+ row = p[0]
+ near, far = n.y - 1, n.bottom + 1
+ if row - n.y > n.bottom - row or near < 0:
+ near, far = far, near
+ p, q = (near, p[1]), (near, q[1])
+ rt.pts[i], rt.pts[i + 1] = p, q
+ moved += 1
+ return moved
+
+
+def _verify(g: G._Graph, routes: list[G.Route]) -> None:
+ """Raise if any segment still crosses a box, so ``layout()`` falls back.
+
+ The invariant is worth more than the engine: a layout with more crossings
+ beats one that draws edges through the code.
+ """
+ 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/pyproject.toml b/pyproject.toml
index 1171107..80c28bf 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -13,6 +13,12 @@ dependencies = [
[project.optional-dependencies]
dev = ["pytest>=8"]
+# 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. Install our fork:
+# uv pip install ~/dev/triskel/bindings/python
+# See docs/TRISKEL_EVAL.md and ~/dev/triskel/PATCHES.md.
[project.scripts]
idatui = "idatui.launch:main"
diff --git a/tests/test_graph.py b/tests/test_graph.py
index 146092d..d882f94 100644
--- a/tests/test_graph.py
+++ b/tests/test_graph.py
@@ -41,6 +41,16 @@ 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:
@@ -95,7 +105,7 @@ def invariants(lay: G.Layout, name: str) -> None:
# ---------------------------------------------------------------- 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,7 +113,7 @@ def t_linear() -> None:
def t_diamond() -> None:
- lay = G.layout(mk({0: [(1, "jump"), (2, "fall")],
+ 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")
@@ -115,7 +125,7 @@ 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")],
+ 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)]
@@ -124,7 +134,7 @@ def t_selfloop() -> None:
def t_loop() -> None:
- lay = G.layout(mk({0: [(1, "uncond")], 1: [(2, "jump"), (3, "fall")],
+ 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")
@@ -136,7 +146,7 @@ def t_loop() -> None:
def t_switch() -> None:
- lay = G.layout(mk({0: [(i, "switch") for i in range(1, 9)],
+ 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,
@@ -146,7 +156,7 @@ def t_switch() -> None:
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")
@@ -155,15 +165,19 @@ 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,7 +185,7 @@ 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")],
+ 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)
@@ -182,7 +196,7 @@ def t_row_query() -> None:
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")
@@ -202,7 +216,7 @@ def t_corpus(path: str) -> None:
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)
+ lay = layout(blocks, sizer)
if lay.stats["ms"] > worst_ms:
worst_ms, worst_name = lay.stats["ms"], rec["name"]
check(no_box_overlap(lay), f"corpus {rec['name']}: boxes must not overlap")
@@ -220,14 +234,24 @@ def t_corpus(path: str) -> None:
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_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_scenarios.py b/tests/test_scenarios.py
index 5297f0c..d5090e0 100644
--- a/tests/test_scenarios.py
+++ b/tests/test_scenarios.py
@@ -3479,6 +3479,62 @@ async def s_graph_zoom(c: Ctx):
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")
async def s_graph_render(c: Ctx):
"""The drawing itself: boxes, instruction text and edge glyphs must actually