diff options
| author | blasty <blasty@local> | 2026-07-10 14:53:20 +0200 |
|---|---|---|
| committer | blasty <blasty@local> | 2026-07-10 14:53:20 +0200 |
| commit | b8d0335682758ef51eab1c6dea6435b11d4e0bd2 (patch) | |
| tree | b37fdb91e74dbe136f2d0d757c2062937be82b1c | |
| parent | hex view: raw image bytes (VA-addressed), synced to the code cursor (diff) | |
| download | ida-tui-b8d0335682758ef51eab1c6dea6435b11d4e0bd2.tar.gz ida-tui-b8d0335682758ef51eab1c6dea6435b11d4e0bd2.tar.xz ida-tui-b8d0335682758ef51eab1c6dea6435b11d4e0bd2.zip | |
hex view: file offsets + 'g' goto (dedicated input)
Add a file_regions server tool (get_fileregion_offset per segment) and
Program.file_regions/file_offset so a virtual address maps to its raw
on-disk offset without a format-specific header parser. The hex view now
shows a VA column and a file-offset column side by side, and the status
line reports file+off.
Goto moves to its own top-level #goto Input instead of overloading the
function-filter box (which is hidden with the names pane, so goto was
unreachable there). In the hex view 'g' jumps the cursor to an address
(bounds-checked against the image); elsewhere it navigates as before.
| -rw-r--r-- | idatui/app.py | 75 | ||||
| -rw-r--r-- | idatui/domain.py | 31 | ||||
| -rw-r--r-- | server/patch_server.py | 24 |
3 files changed, 116 insertions, 14 deletions
diff --git a/idatui/app.py b/idatui/app.py index aa780cc..11d54aa 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -1028,6 +1028,7 @@ class DecompView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True # --------------------------------------------------------------------------- # _S_HEX = Style(color="grey74") _S_ASCII = Style(color="#6a9955") +_S_FOFF = Style(color="#c586c0") # file-offset column (distinct from the VA) class HexView(ScrollView, can_focus=True): @@ -1197,7 +1198,12 @@ class HexView(ScrollView, can_focus=True): 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)] + fo = model.file_offset(va) + fo_str = f"{fo:08X}" if fo is not None else "--------" + segs: list[Segment] = [ + Segment(f"{va:08X} ", _S_ADDR), + Segment(f"{fo_str} ", _S_FOFF), + ] if data is None: segs.append(Segment("… fetching", _S_DIM)) else: @@ -1676,6 +1682,10 @@ class IdaTui(App): height: 1; border: none; padding: 0 1; background: $accent-darken-2; color: $text; } + #goto { + height: 1; border: none; padding: 0 1; + background: $primary-darken-3; color: $text; + } #status { height: 1; background: $panel; color: $text; padding: 0 1; } .decomp-loading { width: 100%; height: 100%; content-align: center middle; @@ -1778,6 +1788,10 @@ class IdaTui(App): ti.display = False ti.can_focus = False yield ti + gi = Input(id="goto") + gi.display = False + gi.can_focus = False + yield gi yield Static("connecting…", id="status") yield Footer() @@ -2041,13 +2055,20 @@ class IdaTui(App): inp.focus() def action_goto(self) -> None: - inp = self.query_one("#func-filter", Input) - inp.display = True - inp.placeholder = "goto: name or 0xADDR — Enter" + inp = self.query_one("#goto", Input) + inp.placeholder = ("hex goto: 0xADDR or name — Enter" if self._active == "hex" + else "goto: name or 0xADDR — Enter") inp.can_focus = True + inp.display = True inp.value = "" + self.query_one("#status", Static).display = False inp.focus() - self._goto_mode = True + + def _end_goto(self) -> None: + inp = self.query_one("#goto", Input) + inp.display = False + inp.can_focus = False + self.query_one("#status", Static).display = True def action_back(self) -> None: table = self.query_one("#func-table", DataTable) @@ -2652,7 +2673,7 @@ class IdaTui(App): if view is not None: view.search_update(event.value) return - if event.input.id == "func-filter" and not getattr(self, "_goto_mode", False): + if event.input.id == "func-filter": self._pending_filter = event.value if self._filter_timer is not None: self._filter_timer.stop() @@ -2692,16 +2713,20 @@ class IdaTui(App): event.prevent_default() self._end_retype() return + if self.query_one("#goto", Input).display: + event.stop() + event.prevent_default() + self._end_goto() + (self.query_one(HexView) if self._active == "hex" + else self._code_view()).focus() + return fi = self.query_one("#func-filter", Input) if fi.display: event.stop() event.prevent_default() fi.display = False fi.can_focus = False - if getattr(self, "_goto_mode", False): - self._goto_mode = False - else: - self.clear_filter() # Esc in the filter box clears it + self.clear_filter() # Esc in the filter box clears it self.query_one("#func-table", DataTable).focus() def on_input_submitted(self, event: Input.Submitted) -> None: @@ -2736,9 +2761,10 @@ class IdaTui(App): if view is not None and value: self._do_retype(kind, subject, word, value) return - if getattr(self, "_goto_mode", False): - self._goto_mode = False - self.query_one("#func-table", DataTable).focus() + if inp.id == "goto": + self._end_goto() + (self.query_one(HexView) if self._active == "hex" + else self._code_view()).focus() if value: self._goto(value) return @@ -2746,6 +2772,9 @@ class IdaTui(App): self._apply_filter(value) self.query_one("#func-table", DataTable).focus() + def _code_view(self): # type: ignore[no-untyped-def] + return self.query_one(DecompView if self._pref == "decomp" else DisasmView) + @work(thread=True, exclusive=True, group="goto") def _goto(self, target: str) -> None: assert self.program is not None @@ -2754,10 +2783,25 @@ class IdaTui(App): except Exception as e: # noqa: BLE001 self.app.call_from_thread(self._status, f"goto: {e}") return + if self._active == "hex": + self.app.call_from_thread(self._hex_goto, ea) + return # Navigate to the containing function at the right line (handles both a # function name and a mid-function address). self._do_navigate(ea, push=True) + def _hex_goto(self, ea: int) -> None: + hx = self.query_one(HexView) + rng = self.program.image_range() if self.program else None + if rng is not None and not (rng[0] <= ea < rng[1]): + self._status(f"{ea:#x} is outside the image ({rng[0]:#x}..{rng[1]:#x})") + return + if hx.model is None: + self._hex_pending_ea = ea + self._load_hex_model(ea) + else: + hx.goto_va(ea) + # -- opening functions ------------------------------------------------- # def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: ea = int(event.row_key.value) @@ -2858,7 +2902,10 @@ class IdaTui(App): 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)") + fo = self.program.file_offset(va) if self.program else None + foff = f"file+{fo:#x}" if fo is not None else "file:--" + self._status(f"hex va={va:#x} {foff} [{sec or '?'}] " + "(g goto · Enter→code · Tab/Esc/\\→back)") def on_hex_view_moved(self, msg: HexView.Moved) -> None: self._hex_status(msg.va) diff --git a/idatui/domain.py b/idatui/domain.py index 602d54d..826d0f7 100644 --- a/idatui/domain.py +++ b/idatui/domain.py @@ -418,6 +418,9 @@ class HexModel: def total_rows(self) -> int: return (self.size + 15) // 16 + def file_offset(self, va: int) -> int | None: + return self._prog.file_offset(va) + def _fetch_block(self, b: int) -> bytes: addr = self.start + b * self.BLOCK n = min(self.BLOCK, self.end - addr) @@ -492,6 +495,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._fileregions: list[tuple[int, int, int]] | None = None self._hexmodel: "HexModel | None" = None self._lock = threading.Lock() @@ -553,6 +557,33 @@ class Program: self._hexmodel = HexModel(self, rng[0], rng[1]) return self._hexmodel + def file_regions(self) -> list[tuple[int, int, int]]: + """Sorted [(start, end, file_off)] mapping loaded segments to raw file + offsets (file_off == -1 for non-file-backed, e.g. .bss). Cached; needs + the injected ``file_regions`` server tool.""" + if self._fileregions is not None: + return self._fileregions + regions: list[tuple[int, int, int]] = [] + try: + r = self.client.call("file_regions") + for d in (r.get("regions", []) if isinstance(r, dict) else []): + if isinstance(d, dict) and "start" in d: + regions.append((_as_int(d["start"]), _as_int(d["end"]), + int(d.get("file_off", -1)))) + except IDAToolError: + regions = [] + regions.sort() + with self._lock: + self._fileregions = regions + return regions + + def file_offset(self, va: int) -> int | None: + """Raw on-disk file offset for ``va``, or None if not file-backed.""" + for start, end, fo in self.file_regions(): + if start <= va < end: + return (fo + (va - start)) if fo >= 0 else None + return None + def read_bytes(self, ea: int, n: int) -> bytes: """Raw bytes [ea, ea+n) from IDA (gaps read as zero).""" if n <= 0: diff --git a/server/patch_server.py b/server/patch_server.py index eeebfae..f42cc3a 100644 --- a/server/patch_server.py +++ b/server/patch_server.py @@ -154,6 +154,30 @@ def set_lvar_type( ok = bool(ida_hexrays.modify_user_lvar_info( f.start_ea, ida_hexrays.MLI_TYPE, lsi)) return {"addr": hex(f.start_ea), "variable": variable, "type": type, "ok": ok} + + +@tool +@idasync +def file_regions() -> dict: + """Loaded segments mapped to their raw file offsets (get_fileregion_offset), + so clients can convert a virtual address to an on-disk file offset without a + format-specific header parser. file_off is -1 for non-file-backed segments + (e.g. .bss).""" + import ida_segment + import idaapi + + out = [] + seg = ida_segment.get_first_seg() + while seg is not None: + try: + fo = int(idaapi.get_fileregion_offset(seg.start_ea)) + except Exception: + fo = -1 + if fo < 0 or fo >= (1 << 48): + fo = -1 + out.append({"start": hex(seg.start_ea), "end": hex(seg.end_ea), "file_off": fo}) + seg = ida_segment.get_next_seg(seg.start_ea) + return {"regions": out} ''' SNIPPET = f"{BEGIN}\n{BODY.strip()}\n{END}\n" |
