aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-07-10 14:53:20 +0200
committerblasty <blasty@local>2026-07-10 14:53:20 +0200
commitb8d0335682758ef51eab1c6dea6435b11d4e0bd2 (patch)
treeb37fdb91e74dbe136f2d0d757c2062937be82b1c
parenthex view: raw image bytes (VA-addressed), synced to the code cursor (diff)
downloadida-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.py75
-rw-r--r--idatui/domain.py31
-rw-r--r--server/patch_server.py24
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"