From 636a6cb34e83523c3c513fb62ca39e0f6c10098f Mon Sep 17 00:00:00 2001 From: blasty Date: Fri, 10 Jul 2026 14:03:16 +0200 Subject: hex view: raw image bytes (VA-addressed), synced to the code cursor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New HexView: a line-virtualized 16-bytes/row dump of the whole loaded image, addressed by virtual address. Format-agnostic — the range/segments come from IDA (image_range over sections()), not any file header; gaps read back as zeros. Backslash toggles it, synced to the address under the disasm/pseudocode cursor (see the naked bytes of some code/data). In hex: hjkl/paging navigate a byte cursor (status shows the section), Enter jumps the code view to the byte, Tab/Esc/backslash return to the preferred code view. A third _active state; block -cached + prefetched like the disasm model. domain: HexModel + Program.hex_model/read_bytes/image_range (get_bytes regions). Fixed a self-deadlock (hex_model held _lock while sections() re-locked). Pilot: sync, actual bytes, byte-step, return. full suite 98/98. --- idatui/app.py | 277 +++++++++++++++++++++++++++++++++++++++++++++++++++++- idatui/domain.py | 119 +++++++++++++++++++++++ tests/test_tui.py | 30 +++++- 3 files changed, 421 insertions(+), 5 deletions(-) diff --git a/idatui/app.py b/idatui/app.py index 3ea0f3e..aa780cc 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -1023,6 +1023,205 @@ class DecompView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True self._move(len(self._strips)) +# --------------------------------------------------------------------------- # +# Hex view (raw bytes of the loaded image, VA-addressed, virtualized) +# --------------------------------------------------------------------------- # +_S_HEX = Style(color="grey74") +_S_ASCII = Style(color="#6a9955") + + +class HexView(ScrollView, can_focus=True): + """A line-virtualized hex dump of the whole loaded image (16 bytes/row), + addressed by virtual address. The cursor is a byte offset; it can be synced + to/from the code views to see the naked bytes under the disasm/pseudocode + cursor.""" + + BINDINGS = [ + Binding("j,down", "move(16)", show=False), + Binding("k,up", "move(-16)", show=False), + Binding("l,right", "move(1)", show=False), + Binding("h,left", "move(-1)", show=False), + Binding("ctrl+d", "half_page(1)", show=False), + Binding("ctrl+u", "half_page(-1)", show=False), + Binding("pagedown", "page(1)", show=False), + Binding("pageup", "page(-1)", show=False), + Binding("home", "goto_top", show=False), + Binding("G,end", "goto_bottom", show=False), + Binding("enter", "to_code", "To code"), + Binding("escape", "leave", "Back"), + Binding("tab,shift+tab", "app.toggle_view", "Code", priority=True), + ] + + cursor = reactive(0, repaint=False) + + class Moved(Message): + def __init__(self, va: int) -> None: + super().__init__() + self.va = va + + class Leave(Message): + pass + + class ToCode(Message): + def __init__(self, va: int) -> None: + super().__init__() + self.va = va + + def __init__(self) -> None: + super().__init__(id="hex") + self.model = None + self.total = 0 + + # -- public API -------------------------------------------------------- # + def load(self, model, va: int | None = None) -> None: # type: ignore[no-untyped-def] + self.model = model + self.total = model.total_rows() + self.virtual_size = Size(0, self.total) + if va is not None: + off = max(0, min(model.size - 1, va - model.start)) + self.cursor = off + self._prime(center=True) + + def goto_va(self, va: int) -> None: + if self.model is None: + return + self.cursor = max(0, min(self.model.size - 1, va - self.model.start)) + self._prime(center=True) + + def cursor_va(self) -> int: + return (self.model.start + self.cursor) if self.model else 0 + + @work(thread=True, exclusive=True, group="hex-prime") + def _prime(self, center: bool = False) -> None: + model = self.model + if model is None: + return + row = self.cursor // 16 + height = max(self.size.height, 1) + model.ensure(max(row - 2, 0), height + 4) + self.app.call_from_thread(self._after_prime, center) + + def _after_prime(self, center: bool) -> None: + self._scroll_to_cursor(center=center) + self.refresh() + self.post_message(HexView.Moved(self.cursor_va())) + + # -- scrolling --------------------------------------------------------- # + def _visible_height(self) -> int: + return max(self.size.height, 1) + + def _apply_scroll(self, y: int) -> None: + y = max(0, y) + self.scroll_to(y=y, animate=False) + + def _fix(yy: int = y) -> None: + self.scroll_to(y=yy, animate=False) + self.refresh(layout=True) + + self.call_after_refresh(_fix) + + def _scroll_to_cursor(self, center: bool = False) -> None: + height = self._visible_height() + row = self.cursor // 16 + if center: + top = max(row - height // 2, 0) + else: + top = round(self.scroll_offset.y) + if row < top: + top = row + elif row >= top + height: + top = row - height + 1 + top = max(0, min(top, max(self.total - 1, 0))) + self._apply_scroll(top) + + # -- navigation -------------------------------------------------------- # + def _move(self, delta: int) -> None: + if self.model is None or self.model.size == 0: + return + before = round(self.scroll_offset.y) + self.cursor = max(0, min(self.model.size - 1, self.cursor + delta)) + self._scroll_to_cursor(center=False) + if round(self.scroll_offset.y) == before: + self.refresh() # cursor moved within the viewport + self.post_message(HexView.Moved(self.cursor_va())) + + def action_move(self, delta: int) -> None: + self._move(delta) + + def action_page(self, direction: int) -> None: + self._move(direction * self._visible_height() * 16) + + def action_half_page(self, direction: int) -> None: + self._move(direction * (self._visible_height() // 2) * 16) + + def action_goto_top(self) -> None: + self._move(-self.cursor) + + def action_goto_bottom(self) -> None: + self._move(self.model.size if self.model else 0) + + def action_to_code(self) -> None: + self.post_message(HexView.ToCode(self.cursor_va())) + + def action_leave(self) -> None: + self.post_message(HexView.Leave()) + + # -- rendering --------------------------------------------------------- # + def _ensure_window(self, top: int) -> None: + if self.model is None: + return + height = max(self.size.height, 1) + if not self.model.is_cached(top, height): + self._fetch_window(top, height) + else: + self.model.ensure_async(top, height) + + @work(thread=True, exclusive=False, group="hex-fetch") + def _fetch_window(self, top: int, height: int) -> None: + model = self.model + if model is None: + return + model.ensure(max(top - 2, 0), height + 4) + self.app.call_from_thread(self.refresh) + + def render_line(self, y: int) -> Strip: + model = self.model + width = self.size.width + if model is None or self.total == 0: + return Strip([Segment("".ljust(width), _S_DIM)]) + top = round(self.scroll_offset.y) + if y == 0: + self._ensure_window(top) + r = top + y + if r >= self.total: + return Strip([Segment("".ljust(width), _S_HEX)]) + va, data = model.row(r) + cur_row, cur_col = self.cursor // 16, self.cursor % 16 + segs: list[Segment] = [Segment(f"{va:08X} ", _S_ADDR)] + if data is None: + segs.append(Segment("… fetching", _S_DIM)) + else: + n = len(data) + for i in range(16): + if i == 8: + segs.append(Segment(" ", _S_HEX)) + if i < n: + st = _S_CELL if (r == cur_row and i == cur_col) else _S_HEX + segs.append(Segment(f"{data[i]:02X} ", st)) + else: + segs.append(Segment(" ", _S_HEX)) + segs.append(Segment(" |", _S_DIM)) + for i in range(16): + if i < n: + ch = chr(data[i]) if 32 <= data[i] < 127 else "." + st = _S_CELL if (r == cur_row and i == cur_col) else _S_ASCII + else: + ch, st = " ", _S_ASCII + segs.append(Segment(ch, st)) + segs.append(Segment("|", _S_DIM)) + return Strip(segs).adjust_cell_length(width, _S_HEX) + + # --------------------------------------------------------------------------- # # Function list panel # --------------------------------------------------------------------------- # @@ -1460,6 +1659,7 @@ class IdaTui(App): #func-filter { dock: top; } DisasmView { width: 1fr; padding: 0 1; } DecompView { width: 1fr; } + HexView { width: 1fr; padding: 0 1; } #search { height: 1; border: none; padding: 0 1; background: $primary-darken-2; color: $text; @@ -1511,6 +1711,7 @@ class IdaTui(App): Binding("q", "quit", "Quit"), Binding("ctrl+n", "symbols", "Symbols"), Binding("ctrl+t", "structs", "Structs"), + Binding("backslash", "hex", "Hex"), Binding("g", "goto", "Goto"), Binding("slash", "filter", "Filter", show=False), Binding("ctrl+b", "toggle_functions", "Names", show=False), @@ -1538,6 +1739,7 @@ class IdaTui(App): self._sort_reverse = False self._pref = "decomp" # preferred code view (changed by Tab) self._active = "decomp" # currently shown view (falls back on decomp fail) + self._hex_pending_ea: int | None = None self._cur: NavEntry | None = None self._search_ctx: tuple[object | None, int] = (None, 1) self._rename_ctx: tuple[object | None, str] = (None, "") @@ -1557,6 +1759,9 @@ class IdaTui(App): dis.display = False yield dis yield DecompView() # pseudocode is the default view + hx = HexView() + hx.display = False + yield hx si = Input(id="search") si.display = False si.can_focus = False @@ -1797,13 +2002,36 @@ class IdaTui(App): self._open_function(addr, f.name if f else hex(addr)) def action_toggle_view(self) -> None: - """Tab: switch the code pane between disassembly and pseudocode.""" + """Tab: switch the code pane between disassembly and pseudocode (or leave + the hex view back to the preferred code view).""" if self._cur is None: return + if self._active == "hex": + self._active = self._pref + self._show_active() + return self._active = "decomp" if self._active == "disasm" else "disasm" self._pref = self._active # an explicit toggle sets the preference self._show_active() + def action_hex(self) -> None: + """Backslash: show the raw bytes of the loaded image, synced to the code + cursor; press again (or Tab/Esc) to return to the code view.""" + if self._cur is None or self.program is None: + return + if self._active == "hex": + self._active = self._pref + self._show_active() + return + if self._active == "disasm": + ea = self.query_one(DisasmView)._cursor_ea() + else: + dec = self.query_one(DecompView) + ea = dec._line_ea(dec.cursor) + self._hex_pending_ea = ea if ea is not None else self._cur.ea + self._active = "hex" + self._show_active() + def action_filter(self) -> None: inp = self.query_one("#func-filter", Input) inp.placeholder = "filter names… Enter=keep Esc=clear" @@ -2584,13 +2812,25 @@ class IdaTui(App): def _show_active(self) -> None: dis = self.query_one(DisasmView) dec = self.query_one(DecompView) + hx = self.query_one(HexView) + dis.display = dec.display = hx.display = False if self._active == "disasm": - dec.display = False dis.display = True dis.focus() self._status_for_cur("disasm") + elif self._active == "hex": + hx.display = True + hx.focus() + ea = getattr(self, "_hex_pending_ea", None) + self._hex_pending_ea = None + if hx.model is None: + self._status("hex — loading image…") + self._load_hex_model(ea) + elif ea is not None: + hx.goto_va(ea) + else: + self._hex_status(hx.cursor_va()) else: - dis.display = False dec.display = True dec.focus() if self._cur is not None and dec.loaded_ea != self._cur.ea: @@ -2600,6 +2840,37 @@ class IdaTui(App): else: self._status_for_cur("pseudocode") + # -- hex view ---------------------------------------------------------- # + @work(thread=True, exclusive=True, group="hex-load") + def _load_hex_model(self, ea: int | None) -> None: + assert self.program is not None + model = self.program.hex_model() + self.app.call_from_thread(self._apply_hex_model, model, ea) + + def _apply_hex_model(self, model, ea: int | None) -> None: # type: ignore[no-untyped-def] + hx = self.query_one(HexView) + if model is None: + self._status("hex: no loaded segments") + return + hx.load(model, ea) + if self._active == "hex": + hx.focus() + + def _hex_status(self, va: int) -> None: + sec = self.program.section_of(va) if self.program else None + self._status(f"hex @ {va:#x} [{sec or '?'}] (Enter→code, Tab/Esc/\\→back)") + + def on_hex_view_moved(self, msg: HexView.Moved) -> None: + self._hex_status(msg.va) + + def on_hex_view_leave(self, msg: HexView.Leave) -> None: + self._active = self._pref + self._show_active() + + def on_hex_view_to_code(self, msg: HexView.ToCode) -> None: + self._active = self._pref + self._goto_ea(msg.va, push=True) + def _status_for_cur(self, mode: str) -> None: if self._cur is not None: self._status(f"{self._cur.name} @ {self._cur.ea:#x} [{mode}]") diff --git a/idatui/domain.py b/idatui/domain.py index 279ef49..602d54d 100644 --- a/idatui/domain.py +++ b/idatui/domain.py @@ -35,6 +35,7 @@ from .client import IDAClient, IDAToolError # Clamps derived from measured caps (list ~700, disasm ~500). Margin included. LIST_PAGE = 500 DISASM_BLOCK = 256 # instructions per cached/fetched block (<= disasm cap) +HEX_BLOCK = 4096 # bytes per cached/fetched hex block _TRUNC_RE = re.compile(r"\[(\d+) chars total\]\s*$") @@ -395,6 +396,86 @@ class DisasmModel: self._ea_list = None +# --------------------------------------------------------------------------- # +# Hex model: block-cached byte view over the loaded image (VA-addressed) +# --------------------------------------------------------------------------- # +class HexModel: + """Windowed, block-cached raw bytes of the loaded image, addressed by virtual + address. Format-agnostic: the range/segments come from IDA, not from any + file header. Gaps between segments read back as zeros.""" + + BLOCK = HEX_BLOCK + + def __init__(self, program: "Program", start: int, end: int): + self._prog = program + self.start = start + self.end = end + self.size = max(end - start, 0) + self._blocks: dict[int, bytes] = {} + self._lock = threading.Lock() + self._inflight: set[int] = set() + + def total_rows(self) -> int: + return (self.size + 15) // 16 + + def _fetch_block(self, b: int) -> bytes: + addr = self.start + b * self.BLOCK + n = min(self.BLOCK, self.end - addr) + data = self._prog.read_bytes(addr, n) if n > 0 else b"" + with self._lock: + self._blocks[b] = data + self._inflight.discard(b) + return data + + def _prefetch(self, b: int) -> None: + if b < 0 or b * self.BLOCK >= self.size: + return + with self._lock: + if b in self._blocks or b in self._inflight: + return + self._inflight.add(b) + self._prog.submit(self._fetch_block, b) + + def row(self, r: int, prefetch: bool = True) -> tuple[int, bytes | None]: + """Return (va, bytes<=16) for row ``r``, or (va, None) if not yet cached. + Non-blocking; used by the virtualized view's render path.""" + off = r * 16 + va = self.start + off + b = off // self.BLOCK + with self._lock: + block = self._blocks.get(b) + if block is None: + return (va, None) + bo = off - b * self.BLOCK + return (va, block[bo:bo + 16]) + + def ensure(self, r0: int, count: int) -> None: + """Blocking: fetch the blocks covering rows [r0, r0+count) if missing.""" + if count <= 0: + return + b0 = (r0 * 16) // self.BLOCK + b1 = ((r0 + count) * 16) // self.BLOCK + for b in range(b0, b1 + 1): + with self._lock: + have = b in self._blocks + if not have: + self._fetch_block(b) + + def is_cached(self, r0: int, count: int) -> bool: + if count <= 0: + return True + b0 = (r0 * 16) // self.BLOCK + b1 = ((r0 + count - 1) * 16) // self.BLOCK + with self._lock: + return all(b in self._blocks for b in range(b0, b1 + 1)) + + def ensure_async(self, r0: int, count: int) -> None: + b0 = (r0 * 16) // self.BLOCK + b1 = ((r0 + max(count, 1) - 1) * 16) // self.BLOCK + for b in range(b0 - 1, b1 + 2): + self._prefetch(b) + + # --------------------------------------------------------------------------- # # Program: top-level handle, model registry, prefetch pool # --------------------------------------------------------------------------- # @@ -411,6 +492,7 @@ class Program: self._decomp: dict[int, tuple[Decompilation, int]] = {} self._name_gen = 0 # bumped on rename; invalidates stale name caches self._sections: list[tuple[int, int, str]] | None = None + self._hexmodel: "HexModel | None" = None self._lock = threading.Lock() # -- prefetch plumbing ------------------------------------------------- # @@ -454,6 +536,43 @@ class Program: self._sections = secs return secs + def image_range(self) -> tuple[int, int] | None: + """[start, end) spanning all loaded segments (the hex view's document).""" + secs = self.sections() + if not secs: + return None + return (min(s[0] for s in secs), max(s[1] for s in secs)) + + def hex_model(self) -> "HexModel | None": + """Cached block-backed byte view over the whole loaded image.""" + if self._hexmodel is not None: + return self._hexmodel + rng = self.image_range() # calls sections() -> don't hold _lock (it re-locks) + with self._lock: + if self._hexmodel is None and rng is not None: + self._hexmodel = HexModel(self, rng[0], rng[1]) + return self._hexmodel + + def read_bytes(self, ea: int, n: int) -> bytes: + """Raw bytes [ea, ea+n) from IDA (gaps read as zero).""" + if n <= 0: + return b"" + try: + r = self.client.call("get_bytes", regions=[{"addr": hex(ea), "size": int(n)}]) + except IDAToolError: + return b"\x00" * n + res = r.get("result", []) if isinstance(r, dict) else [] + data = res[0].get("data", "") if res and isinstance(res[0], dict) else "" + out = bytearray() + for tok in data.split(): + try: + out.append(int(tok, 16) & 0xFF) + except ValueError: + out.append(0) + if len(out) < n: # pad short reads (unmapped tail) + out.extend(b"\x00" * (n - len(out))) + return bytes(out[:n]) + def section_of(self, ea: int) -> str | None: """Name of the segment/section containing ``ea`` (e.g. '.got', '.text', '.data.rel.ro', 'LOAD'), or None if unmapped.""" diff --git a/tests/test_tui.py b/tests/test_tui.py index 84037a2..6d77fc2 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -19,8 +19,8 @@ import sys sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from idatui.app import ( # noqa: E402 - ConfirmScreen, DecompView, DisasmView, FunctionsPanel, IdaTui, StructEditor, - SymbolPalette, XrefsScreen, + ConfirmScreen, DecompView, DisasmView, FunctionsPanel, HexView, IdaTui, + StructEditor, SymbolPalette, XrefsScreen, ) from textual.widgets import DataTable, Input, OptionList, Static, TextArea # noqa: E402 from rich.text import Text # noqa: E402 @@ -310,6 +310,32 @@ async def run(db): check("goto-bottom lands near end", view.cursor >= view.total - 1, f"cursor={view.cursor}/{view.total}") + # Hex view: backslash shows the raw image bytes synced to the code cursor. + view.focus() + code_ea = view._cursor_ea() + await pilot.press("backslash") + await wait_until(pilot, lambda: app._active == "hex", 10) + hx = app.query_one(HexView) + await wait_until( + pilot, lambda: hx.model is not None + and hx.model.row(hx.cursor // 16)[1] is not None, 20) + check("backslash opens the hex view synced to the code cursor", + app._active == "hex" and code_ea is not None and hx.cursor_va() == code_ea, + f"active={app._active} hexva={hx.cursor_va():#x} ea={code_ea}") + want = app.program.read_bytes(code_ea, 1) + _, rb = hx.model.row(hx.cursor // 16) + check("hex shows the actual byte at that address", + rb is not None and rb[hx.cursor % 16] == want[0], + f"got={rb[hx.cursor % 16] if rb else None} want={want[0]}") + await pilot.press("l") + await pilot.pause(0.1) + check("hex cursor steps one byte", hx.cursor_va() == code_ea + 1, + f"va={hx.cursor_va():#x}") + await pilot.press("backslash") # back to the code view + await wait_until(pilot, lambda: app._active != "hex", 10) + check("backslash returns from hex to the code view", + app._active == "disasm", f"active={app._active}") + # Filter round-trip. '/' is context-sensitive: it filters when the # function table is focused (and searches when a code view is focused), # so focus the table first. -- cgit v1.3.1-sl0p