aboutsummaryrefslogtreecommitdiffstats
path: root/idatui
diff options
context:
space:
mode:
Diffstat (limited to 'idatui')
-rw-r--r--idatui/app.py31
-rw-r--r--idatui/graph.py144
-rw-r--r--idatui/graph_triskel.py369
3 files changed, 517 insertions, 27 deletions
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)