From b59c892c9469eed1a2fc416037500a32d8229259 Mon Sep 17 00:00:00 2001 From: blasty Date: Sun, 26 Jul 2026 12:50:52 +0200 Subject: listing: syntax-highlight assembly from IDA's own token tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The listing showed the mnemonic bright and every operand in one body colour. IDA already classifies each token, for every processor it supports: generate_disasm_line() emits \x01text\x02 and the tag says what the text IS. We were calling tag_remove() and throwing that away. So: no lexer. A pygments asm lexer would be a worse guess and would need one dialect per architecture — this is arch-correct for free, including the ARM/MIPS blobs the loader work just made openable. lea rcx, function; "usage" insn reg punct name cmt _idatui_spans() parses the tags into [[kind, text], ...], heads rows carry "spans", Head.spans holds them, and _span_segments() renders them with a fallback to the old mnemonic/rest split for older workers. Palette rule: NEUTRALS for the machine (mnemonic brightest — it's the column you scan; registers at body weight because they're most of the text), HUES only where they mean something (numbers, strings, symbols), structure recedes so commas and brackets stop competing with operands. Two things that fail SILENTLY and are now encoded: * The constants are SCOLOR_DATNAME / SCOLOR_CODNAME. There is no SCOLOR_DNAME — a wrong guess leaves the tag unmapped, symbols render as plain body text, and nothing tells you why. Probed the live IDA to get the real names. * Spans must be whitespace-collapsed exactly as `text` is, walking characters rather than per span, because a run of IDA's column padding straddles span boundaries. A row only gets spans when they reconstruct `text` exactly, so a mismatch degrades to the old rendering instead of corrupting the line. The reason this was parked yesterday was NOT a bug in it. listing_view's "undefining a data head yields an unknown run" waits for `index_of_ea(dea) >= 0` — but dea is the head it just undefined, so it is in the OLD model too and the predicate passes instantly, asserting against pre-edit rows. It only ever passed because the model swap won the race; spans made pages 3x bigger, the swap lost, and the check accused working code. It now waits for the model to be REPLACED. Cost measured on libcrypto: 95KB per 500-row page, 50ms; model ensure(2000) 228ms. Acceptable for what it buys. tests: new asm_highlight scenario (+7) — >90% of code rows carry spans, insn/reg/ punct present, every span kind has a style, spans reconstruct the row text exactly, mnemonic is the first span. 202/0 scenarios, 26/0 blob, 30/0 project UI. TODO: DisasmView appears to be dead code (never instantiated; Ctx.dis returns ListingView), which is why this only needed doing once. --- idatui/app.py | 47 ++++++++++++++++++++++++++++++++++++++--------- idatui/domain.py | 7 +++++++ 2 files changed, 45 insertions(+), 9 deletions(-) (limited to 'idatui') diff --git a/idatui/app.py b/idatui/app.py index dd7c222..6506016 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -53,6 +53,23 @@ from .domain import DisasmModel, Func, Head, ListingModel, Program, Struct _S_ADDR = Style(color="#6b7684") _S_LABEL = Style(color="#7aa2f7", bold=True) _S_INSN = Style(color="#c3cad3") +#: IDA's token kinds -> the measured palette. The rule that keeps a dense +#: disassembly readable: NEUTRALS for the machine (mnemonic brightest because you +#: scan down that column, registers at body weight because they're most of the +#: text), HUES only where they mean something (numbers, strings, symbols), +#: structure recedes so brackets and commas stop competing with operands. +_S_SPAN = { + "insn": Style(color="#e8ecf2"), # 15.3:1 mnemonic / directive + "reg": Style(color="#c3cad3"), # 11.0:1 registers = body weight + "num": Style(color="#d8a657"), # 8.2:1 immediates, offsets + "str": Style(color="#9ece6a"), # 9.9:1 string literals + "name": Style(color="#7aa2f7"), # 7.2:1 symbols / xref targets + "seg": Style(color="#93aee0"), # 8.1:1 segment names + "cmt": Style(color="#7c8b9e", italic=True), # 5.2:1 + "punct": Style(color="#626c7a"), # 3.4:1 brackets, commas, +/- + "err": Style(color="#c9762f"), # IDA's own error marker + "text": Style(color="#c3cad3"), # 11.0:1 anything unclassified +} _S_MNEM = Style(color="#e8ecf2") _S_OPBYTES = Style(color="#5e6875") # raw opcode bytes column _S_DATA = Style(color="#d8a657") @@ -1077,6 +1094,24 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru return " ".join(f"{b:02X}" for b in raw[:_OP_LIMIT]) + "\u2026" return " ".join(f"{b:02X}" for b in raw) + @staticmethod + def _span_segments(h: Head, fallback: Style): + """Segments for a row's disassembly text. + + Uses IDA's own token classification when the worker supplied it; falls + back to the old mnemonic/rest split so an older worker (or a row whose + spans didn't match the text) still renders. + """ + if h.spans: + return [Segment(t, _S_SPAN.get(k, fallback)) for k, t in h.spans] + if h.kind == "code": + mnem, _, rest = h.text.partition(" ") + segs = [Segment(mnem, _S_MNEM)] + if rest: + segs.append(Segment(" " + rest, fallback)) + return segs + return [Segment(h.text, fallback)] + def _op_field(self, h: Head) -> str: """The padded opcode-bytes column (empty when hidden). Shared format so cursor/search offsets line up.""" @@ -1301,17 +1336,11 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru segs.append(Segment(_LST_INDENT, _S_MEMBER)) if h.name: segs.append(Segment(f"{h.name} ", _S_LABEL)) - if h.kind == "code": - mnem, _, rest = h.text.partition(" ") - segs.append(Segment(mnem, _S_MNEM)) - if rest: - segs.append(Segment(" " + rest, _S_INSN)) - elif h.kind == "data": - segs.append(Segment(h.text, _S_DATA)) - elif h.kind == "member": + if h.kind == "member": segs.append(Segment(h.text, _S_MEMBER)) else: - segs.append(Segment(h.text, _S_UNK)) + base = {"code": _S_INSN, "data": _S_DATA}.get(h.kind, _S_UNK) + segs.extend(self._span_segments(h, base)) strip = Strip(segs) linked = idx in self._link_rows if linked: diff --git a/idatui/domain.py b/idatui/domain.py index e4fbec9..606cf42 100644 --- a/idatui/domain.py +++ b/idatui/domain.py @@ -100,6 +100,10 @@ class Head: text: str name: str | None = None raw: bytes | None = None # opcode/item bytes (filled in for code by the model) + #: [(kind, text)] from IDA's own colour tags — mnem/reg/num/name/str/punct/… + #: None when the worker didn't provide them (older worker, or the spans + #: disagreed with the plain text, in which case the text wins). + spans: tuple[tuple[str, str], ...] | None = None @property def label(self) -> str | None: # Line-compatible alias @@ -107,12 +111,15 @@ class Head: @classmethod def from_raw(cls, d: dict) -> "Head": + sp = d.get("spans") return cls( ea=_as_int(d["ea"]), kind=d.get("kind", "unknown"), size=int(d.get("size", 0) or 0), text=d.get("text", ""), name=d.get("name"), + spans=(tuple((str(k), str(t)) for k, t in sp) + if isinstance(sp, list) and sp else None), ) -- cgit v1.3.1-sl0p