aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README.md14
-rw-r--r--docs/GRAPH_VIEW.md145
-rw-r--r--experiments/cfg_dump.py119
-rw-r--r--experiments/graph_shot.py66
-rw-r--r--experiments/graph_smoke.py107
-rw-r--r--experiments/graph_spike.py149
6 files changed, 600 insertions, 0 deletions
diff --git a/README.md b/README.md
index b9b171c..ba72e59 100644
--- a/README.md
+++ b/README.md
@@ -49,6 +49,20 @@ don't file expectations. **Use at your own risk.**
offset), rename (`n`), retype (`y`), comment (`;`), incremental search (`/`
`?`), history (`back`), hex view (`\`), and `home`/`end`/`shift+home` line
motions.
+- **Literal formats** (`o`, IDA's own key): cycle how the number under the
+ cursor is displayed — hex → decimal → binary → character → offset → IDA's
+ own choice, `O` to go the other way. Only the stops that make sense for that
+ value are visited (no `char` unless it prints as one, no `offset` unless the
+ target is something you could name), so no press is a silent no-op. It works
+ in the pseudocode too, on Hex-Rays' separate number formats. The opcode-bytes
+ column, which used to own `o`, moved to `B`.
+
+ A line usually holds more than one literal (`test byte ptr [rsi+rax*2+1], 20h`
+ has two), so **the one the cursor is on is marked** — that mark is what `o`
+ changes, and it keeps up as the text reflows (`0x30` ↔ `48` move everything
+ after them). Land on something with no format of its own — a register — and it
+ says so and names the operand that does, rather than quietly reformatting a
+ different one.
- A **functions panel** (fuzzy symbol palette on `Ctrl+N`), a **strings browser**
(`"`, filterable, Enter jumps to the literal), **hex viewer**, **struct
editor**, and inline **make code/data/function/string** edits.
diff --git a/docs/GRAPH_VIEW.md b/docs/GRAPH_VIEW.md
new file mode 100644
index 0000000..ccddfe7
--- /dev/null
+++ b/docs/GRAPH_VIEW.md
@@ -0,0 +1,145 @@
+# Graph view — the function's control flow, in character cells
+
+**Space** swaps the code view for an IDA-style basic-block graph of the current
+function. Space again returns to the text. It is opt-in, off by default, and
+lives in its own module: with graph mode off nothing else in the app does any
+extra work.
+
+```
+ ┌─ sub_61D0 ──────────────────────┐
+ │ 000061D0 sub_61D0 endbr64 │ ┌─ 9 blocks ──────┐
+ │ 000061D4 push rbp │ │ █████████ │
+ │ ... │ │ ·███████████ │
+ │ 00006200 jmp short loc_622C │ │ ████·███████ │
+ └─────────────────┬───────────────┘ └─────────────────┘
+ │
+ ┌─ loc_622C ───────▼─────────────────┐
+ │ 0000622C loc_622C mov eax, [rcx] │
+ │ 00006231 jbe short loc_6208 │
+ └──────────┬─────────────┬───────────┘
+ ╭─────────╯ ╰──────────╮
+```
+
+## Keys
+
+| key | |
+|---|---|
+| `Space` | graph ⇄ text |
+| `j` `k` | line up/down, crossing into the next/previous block |
+| `h` `l` | column left / right |
+| `J` `K` | follow an edge to a successor / predecessor block |
+| `w` `b` | next / previous block in layout order |
+| `0` | jump to the entry block |
+| `z` | zoom: full → compact → collapsed |
+| `m` | show / hide the minimap |
+| `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 |
+| `Tab` | leave for the pseudocode of the block you're on |
+| mouse | drag to pan, click to place the cursor, double-click to follow |
+
+Graph mode is **sticky**: following a call from the graph lands in the callee's
+graph rather than dumping you back in the listing.
+
+## Why the boxes are cheap
+
+A node's body is **the same `Head` rows the listing renders** — fetched with the
+same `heads` tool, carrying IDA's own colour tags, names and operand text. That
+is the whole reason this feature is a few hundred lines instead of a rewrite:
+syntax highlighting, the word-under-cursor highlight, the execution trail and
+every editing verb work inside a box because they are working on listing rows.
+Growing a second disassembly renderer for graph mode would have been the real
+cost.
+
+The backend adds exactly one tool, `flowchart(addr)` in
+`server/patch_server.py`, which returns block ranges and typed edges — **not**
+text.
+
+## Layout (`idatui/graph.py`)
+
+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,
+the same shape IDA's own graph uses:
+
+| step | what | notes |
+|---|---|---|
+| 1 | break cycles | DFS gray-set; back edges reversed for layout only |
+| 2 | layer | longest-path ranking |
+| 3 | dummies | a k-layer edge becomes k−1 dummy nodes |
+| 4 | order | median sweeps + adjacent transposition |
+| 5 | x-coords | priority/median sweeps, variable node widths |
+| 6 | route | ports per border, one lane-packed channel per layer gap |
+
+Step 3 is what makes step 6 tractable: because every long edge occupies real
+horizontal space as dummy nodes, **no edge ever has to cross a box**. That is
+measured, not hoped — `tests/test_graph.py` counts edge cells landing inside a
+box across a 128-function corpus and requires 0.
+
+Edges are coloured by IDA's convention: green = branch taken, red = falls
+through, blue = the block's only successor, purple = loops back, amber = one arm
+of an n-way switch. The edges touching the block under the cursor are brightened.
+A self-loop is a `↺` on the block's top border rather than an edge.
+
+### Two traps worth remembering
+
+- **Self-loops deadlock the Kahn ranking.** A block that jumps to itself never
+ drains its own in-degree, so ranking stalls and every downstream block stays at
+ rank 0 — the graph collapses into three layers and comes out 280 columns wide.
+ They are dropped from the layout graph and drawn as a marker. `_assign_ranks`
+ also force-releases the most-constrained survivor if the queue ever drains
+ early, so a residual cycle degrades instead of exploding.
+- **Crossing minimisation is where the time goes.** The naive transposition pass
+ recounts crossings globally per candidate swap: O(n³), which made a 424-block
+ function take **20.4 seconds**. Counting inversions with a Fenwick tree and
+ computing only the local `O(deg(a)·deg(b))` delta per swap took the same
+ function to **152 ms**, and the whole 128-function corpus from 21 s to 224 ms.
+
+## Rendering
+
+Nothing is pre-painted. A 424-block function lays out to ~13M cells, so
+`graph.Painting` is an *index* — per-row horizontal runs, a bucketed interval
+index of vertical runs, and point marks — and `GraphView.render_line(y)` asks it
+for one row at a time, exactly like `ListingView`. Cost per frame is proportional
+to the viewport, not the graph.
+
+Three zoom levels (`z`) trade detail for shape: **full** (address gutter +
+instructions), **compact** (instructions only), **collapsed** (one summary row
+per block). On `main` (87 blocks) that is a 1378×518 canvas down to 545×289.
+
+The **minimap** (`m`) is a coarse occupancy grid of the whole graph with the
+viewport marked, drawn top-right and inset two columns — a `ScrollView` paints
+its scrollbar over the last column, which otherwise eats the minimap's border.
+
+## Limits
+
+Above **400 blocks** the graph is refused with a message and you stay in the
+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.
+
+## Driving it
+
+`graph` is an RPC verb (see `docs/RPC.md`), and it reports **structure**, not box
+drawing characters — a driver wants blocks and edges, not glyphs:
+
+```bash
+drive raw graph action=open # Space
+drive raw graph action=show # blocks, edges, ranks, cursor
+drive raw graph action=block target=0x6250
+drive raw graph action=succ # J
+drive raw graph action=zoom
+```
+
+## Offline tools
+
+- `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.
+- `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).
diff --git a/experiments/cfg_dump.py b/experiments/cfg_dump.py
new file mode 100644
index 0000000..30b6ba9
--- /dev/null
+++ b/experiments/cfg_dump.py
@@ -0,0 +1,119 @@
+#!/usr/bin/env python3
+"""Dump real basic-block CFGs to JSON, so graph-layout work can be done offline.
+
+Run with a python that has ``idapro`` (i.e. the worker python, /usr/bin/python3):
+
+ /usr/bin/python3 experiments/cfg_dump.py targets/echo -o /tmp/cfg-echo.json
+
+Copies the binary to a scratch dir first (never touches the tracked .i64), opens
+it with idalib, and writes one record per function:
+
+ {"name","ea","blocks":[{"id","start","end","lines":[str],"succs":[[id,kind]]}]}
+
+``kind`` is "fall" (falls through to the next address), "jump" (unconditional or
+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
+import json
+import os
+import shutil
+import sys
+import tempfile
+
+
+def dump(path: str, want: list[str], max_blocks: int) -> list[dict]:
+ import idapro
+
+ scratch = tempfile.mkdtemp(prefix="cfgdump-")
+ local = os.path.join(scratch, os.path.basename(path))
+ shutil.copy2(path, local)
+ if idapro.open_database(local, run_auto_analysis=True) != 0:
+ raise SystemExit(f"failed to open {local}")
+
+ import ida_funcs
+ import ida_gdl
+ import ida_lines
+ import ida_bytes
+ import idautils
+ import idaapi
+
+ out = []
+ try:
+ for fea in idautils.Functions():
+ fn = ida_funcs.get_func(fea)
+ if not fn:
+ continue
+ name = ida_funcs.get_func_name(fea)
+ if want and not any(w in name for w in want):
+ continue
+ fc = ida_gdl.FlowChart(fn, flags=ida_gdl.FC_PREDS)
+ blocks = []
+ index = {}
+ for i, bb in enumerate(fc):
+ index[bb.start_ea] = i
+ for bb in fc:
+ lines = []
+ 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 "")
+ lines.append(txt.rstrip())
+ nxt = ida_bytes.next_head(ea, bb.end_ea)
+ if nxt <= ea:
+ break
+ ea = nxt
+ succs = []
+ sl = list(bb.succs())
+ for s in sl:
+ if s.start_ea not in index:
+ continue
+ 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": 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})
+ finally:
+ idapro.close_database(save=False)
+ shutil.rmtree(scratch, ignore_errors=True)
+ return out
+
+
+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")
+ args = ap.parse_args()
+
+ recs = dump(os.path.abspath(args.binary), args.func, args.max_blocks)
+ recs.sort(key=lambda r: len(r["blocks"]), reverse=True)
+ with open(args.out, "w") as f:
+ json.dump(recs, f)
+ tot = sum(len(r["blocks"]) for r in recs)
+ print(f"{len(recs)} functions, {tot} blocks -> {args.out}")
+ for r in recs[:12]:
+ print(f" {len(r['blocks']):4d} blocks {r['name']}")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/experiments/graph_shot.py b/experiments/graph_shot.py
new file mode 100644
index 0000000..5f0d45a
--- /dev/null
+++ b/experiments/graph_shot.py
@@ -0,0 +1,66 @@
+"""Render the graph view headless at a chosen size and print it.
+
+ ~/ida-venv/bin/python experiments/graph_shot.py [func] [cols] [rows] [zoom]
+
+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
+import sys
+
+REPO = os.path.expanduser("~/dev/ida-tui-maybe")
+sys.path.insert(0, REPO)
+os.chdir(REPO)
+
+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
+
+src, tmp = f"{REPO}/targets/echo", "/tmp/echo_shot"
+shutil.copy(src, tmp)
+for e in ".i64 .id0 .id1 .id2 .nam .til".split():
+ try:
+ os.remove(tmp + e)
+ 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
+
+
+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)
+ 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 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 not ok:
+ print("graph never opened:", app.query_one("#status").render())
+ return
+ for _ in range(zoom):
+ await pilot.press("z")
+ await pilot.pause(0.2)
+ await pilot.pause(0.4)
+ 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])
+ app._save_on_exit = False
+
+
+asyncio.run(main())
diff --git a/experiments/graph_smoke.py b/experiments/graph_smoke.py
new file mode 100644
index 0000000..1b6e21e
--- /dev/null
+++ b/experiments/graph_smoke.py
@@ -0,0 +1,107 @@
+"""End-to-end smoke for the graph path: the `flowchart` tool -> domain ->
+idatui.graph layout, through a real idalib worker.
+
+ ~/ida-venv/bin/python experiments/graph_smoke.py [funcname]
+
+Wants: VERDICT: OK
+"""
+import os
+import shutil
+import sys
+import time
+
+REPO = os.path.expanduser("~/dev/ida-tui-maybe")
+sys.path.insert(0, REPO)
+os.chdir(REPO)
+
+src, tmp = f"{REPO}/targets/echo", "/tmp/echo_graph"
+shutil.copy(src, tmp)
+for e in ".i64 .id0 .id1 .id2 .nam .til".split():
+ try:
+ os.remove(tmp + e)
+ 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
+
+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)
+prog = Program(cl)
+
+ok = True
+ea = prog.resolve(want)
+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)
+if fc is None:
+ print("VERDICT: FAIL (no flowchart)")
+ raise SystemExit(1)
+
+print(f" {fc.name} @ {fc.func_ea:#x}: {len(fc.blocks)} blocks, entry={fc.entry}")
+nrows = sum(len(b.rows) for b in fc.blocks)
+print(f" {nrows} listing rows attached")
+ok &= nrows > 0
+if not nrows:
+ print(" !! no rows attached to any block")
+
+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]]}")
+
+b0 = fc.blocks[fc.entry]
+print(f" entry block {b0.start:#x}-{b0.end:#x}:")
+for h in b0.rows[:5]:
+ tags = ",".join(k for k, _ in (h.spans or ()))[:40]
+ print(f" {h.ea:#010x} {h.kind:<7} {h.text[:42]!r} spans[{tags}]")
+ok &= any(h.spans for b in fc.blocks for h in b.rows)
+if not any(h.spans for b in fc.blocks for h in b.rows):
+ print(" !! no colour spans came through \u2014 highlighting would be dead")
+
+# rows must tile the block exactly, or boxes will have holes
+for b in fc.blocks:
+ eas = [h.ea for h in b.rows]
+ if eas and (min(eas) < b.start or max(eas) >= b.end):
+ 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]
+
+
+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
+ 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" canvas {lay.width}x{lay.height}")
+ok &= len(lay.nodes) == len(blocks)
+
+# every block must be reachable in the drawing, and rows must index it
+covered = {n.id for r in range(lay.height) for n in lay.nodes_at_row(r)}
+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")
+ok &= hits > 0
+
+cl.close()
+print("VERDICT:", "OK" if ok else "FAIL")
+raise SystemExit(0 if ok else 1)
diff --git a/experiments/graph_spike.py b/experiments/graph_spike.py
new file mode 100644
index 0000000..547c829
--- /dev/null
+++ b/experiments/graph_spike.py
@@ -0,0 +1,149 @@
+#!/usr/bin/env python3
+"""Render a CFG with idatui.graph, offline, to stdout.
+
+The layout engine now lives in ``idatui/graph.py`` (this started life as the
+spike that proved it). What survives here is the offline harness: feed it a
+corpus from ``cfg_dump.py`` and look at a function, or lay out the whole corpus
+and check the cost. No IDA, no Textual, no worker -- so it iterates in
+milliseconds when you're changing layout heuristics.
+
+ /usr/bin/python3 experiments/cfg_dump.py targets/echo -o /tmp/cfg-echo.json
+ 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
+import json
+import os
+import sys
+
+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_BACK: "\033[38;5;135m",
+}
+DIM, RESET = "\033[38;5;244m", "\033[0m"
+PAD = 1
+
+
+def build(rec: dict, max_lines: int):
+ """(blocks, sizer, texts) for one cfg_dump record."""
+ texts: dict[int, list[str]] = {}
+ 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
+ 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]
+ label = f"loc_{b.start:X}"
+ widest = max([len(label) + 4] + [len(l) for l in lines] or [4])
+ return (widest + 2 * PAD + 2, max(len(lines), 1) + 2)
+
+ return blocks, sizer, texts
+
+
+def render(lay: G.Layout, texts: dict[int, list[str]], color: bool) -> str:
+ rows = []
+ 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():
+ 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 ""
+ if row == n.y:
+ cells[n.x] = (G.BOX["tl"], DIM)
+ for i in range(1, n.w - 1):
+ cells[n.x + i] = (G.BOX["h"], DIM)
+ cells[n.x + n.w - 1] = (G.BOX["tr"], DIM)
+ tag = f" {label} "
+ if len(tag) <= n.w - 4:
+ for k, c in enumerate(tag):
+ cells[n.x + 2 + k] = (c, DIM)
+ if n.block is not None and n.block.selfloop:
+ cells[n.x + n.w - 2] = ("\u21ba", COLOR[G.E_BACK])
+ elif row == n.y + n.h - 1:
+ cells[n.x] = (G.BOX["bl"], DIM)
+ for i in range(1, n.w - 1):
+ cells[n.x + i] = (G.BOX["h"], DIM)
+ cells[n.x + n.w - 1] = (G.BOX["br"], DIM)
+ else:
+ cells[n.x] = (G.BOX["v"], DIM)
+ cells[n.x + n.w - 1] = (G.BOX["v"], DIM)
+ for i in range(1, n.w - 1):
+ cells[n.x + i] = (" ", "")
+ lines = texts.get(n.id, [])
+ i = row - n.y - 1
+ if 0 <= i < len(lines):
+ for k, c in enumerate(lines[i]):
+ cells[n.x + 1 + PAD + k] = (c, "")
+ line, cur, last = [], "", -1
+ for col in sorted(cells):
+ ch, st = cells[col]
+ line.append(" " * (col - last - 1))
+ if color and st != cur:
+ line.append(st or RESET)
+ cur = st
+ line.append(ch)
+ last = col
+ if color and cur:
+ line.append(RESET)
+ rows.append("".join(line))
+ return "\n".join(rows)
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser()
+ 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("--no-color", action="store_true")
+ 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")
+ 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)
+ 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"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)
+ 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)
+ 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)
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())