From 3ba7518a600fceeffaa9c077a8f2855490f92ac7 Mon Sep 17 00:00:00 2001 From: blasty Date: Sun, 26 Jul 2026 09:21:52 +0200 Subject: listing: make every undefined byte its own row, so you can carve anywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loading a blob and pressing `c` at 0 disassembles one instruction; everything after it collapsed into a single row — "db 2044 dup(?)" — with no cursor position anywhere inside it. There was no way to start a second instruction stream at an arbitrary offset, which is most of what carving a firmware image IS. In IDA every undefined byte is its own line and you just put the cursor on one. The collapse existed for a real reason (see the comment in the heads tool): a .bss or a fresh blob would otherwise be millions of one-byte rows, and this model materialises what it walks. Expanding physically would also make `g ` walk every byte in between. So the run stays ONE physical head and PRESENTS as N logical rows. _row_at is a prefix sum over heads, _phys() maps a row back to (head, byte offset), and the text for an interior row is synthesised on demand — "db 4Ah", the actual value, because the byte values are the whole point when you're looking for a stream. Memory is unchanged (libcrypto: 1 head for its 80-byte .bss, 61MB RSS), and index_of_ea into the middle of a run is 0.01ms via bisect. Now: cursor on any byte, `c`, and you get an instruction; the bytes before it stay individually addressable. Two bugs found on the way: * IDAToolError takes (tool, message) and five call sites in domain.py passed one string. Every one of those error paths raised TypeError INSTEAD of the real error — "define code @ 0x4020: Failed to create instruction" reached the user as "IDAToolError.__init__() missing 1 required positional argument". Fixed all five; the message that finally came through is what identified the next issue. * Searching now walks one row per undefined byte, so _index_for_search is capped at 400k lines and says when it truncated, rather than grinding through a multi-megabyte blob nobody wants to text-search. tests: test_blob_ui.py +8 — a run presents one row per byte, each is a single addressable byte showing its value, an interior address resolves to its own row, `c` on a chosen byte carves there, the carved row spans the instruction, and neighbouring bytes stay addressable. Uses PLANTED A64 instructions, because whether random bytes decode is chance and a test that depends on chance is worthless on the run where it fails. 19/0 blob, 202/0 scenarios, 30/0 project UI. --- idatui/app.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) (limited to 'idatui/app.py') diff --git a/idatui/app.py b/idatui/app.py index f1b4dec..4cf0ee0 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -783,13 +783,24 @@ class DisasmView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True return texts: list[str] = [] off, total = 0, self.total + # A freshly-loaded blob is one row per undefined byte, so "all lines" can + # be millions of `db 4Ah` that nobody searches for. Index a bounded + # prefix rather than hang; say so instead of silently finding nothing. + LIMIT = 400_000 + capped = total > LIMIT + total = min(total, LIMIT) while off < total: - lines = model.lines(off, DisasmModel.BLOCK, prefetch=False) + lines = model.lines(off, min(DisasmModel.BLOCK, total - off), + prefetch=False) if not lines: break texts.extend(self._fmt(ln) for ln in lines) off += len(lines) self._search_texts = texts + if capped: + self.app.call_from_thread( + self._app_status, + f"search covers the first {LIMIT:,} lines of {self.total:,}") self.app.call_from_thread(done) @work(thread=True, exclusive=True, group="disasm-prime") -- cgit v1.3.1-sl0p