aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-07-23 00:41:19 +0200
committerblasty <blasty@local>2026-07-23 00:41:19 +0200
commit4b5584002802b3b41262764c8e770598cedb35fe (patch)
treef40e9b446632f8b9c9b81d5a0ce39c9ac9af69b4
parentlaunch: `ida-tui foo.elf` one-shot wrapper (server + locks + session) (diff)
downloadida-tui-4b5584002802b3b41262764c8e770598cedb35fe.tar.gz
ida-tui-4b5584002802b3b41262764c8e770598cedb35fe.tar.xz
ida-tui-4b5584002802b3b41262764c8e770598cedb35fe.zip
perf: read_raw tool — bulk byte reads for the hex view (5–8x)
The hex view's lazy-load was dominated by the byte-read path, which was slow on two axes: * server: get_bytes uses read_bytes_bss_safe, a per-byte loop (is_loaded + get_byte = 2 IDA calls/byte → ~8k calls for a 4KB block), and encodes the result as "0x00 0x01 ..." text (~5x wire bloat); * client: read_bytes re-parsed that with int(tok,16) per byte; * and get_bytes silently TRUNCATES ≥16KB responses to "0x..." (wrong data). New injected `read_raw` tool does a single bulk ida_bytes.get_bytes (C-speed) and only re-checks is_loaded for the sparse 0xFF bss-sentinel bytes, returning one contiguous hex string. Client decodes with bytes.fromhex (C-speed). domain read_bytes uses it with a transparent fallback to get_bytes on older servers (cached _no_read_raw flag). HEX_BLOCK 4096→16384 now that big blocks are cheap and no longer truncate → 4x fewer round-trips while scrolling. Measured (echo, warm): 4KB 22.7ms→4.7ms (4.9x), 8KB 51.6ms→6.6ms (7.8x), and 16KB works (15.7ms) where get_bytes truncates outright. Byte-exact match vs the old path; disasm opcode bytes (also read_bytes) benefit too. Pilot hex + disasm_nav 12/0. Needs a supervisor restart (new server tool).
-rw-r--r--idatui/domain.py25
-rw-r--r--server/patch_server.py39
2 files changed, 62 insertions, 2 deletions
diff --git a/idatui/domain.py b/idatui/domain.py
index 4f33211..31cb82d 100644
--- a/idatui/domain.py
+++ b/idatui/domain.py
@@ -35,7 +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
+HEX_BLOCK = 16384 # bytes per cached/fetched hex block (compact read_raw -> cheap)
DECOMPILE_TIMEOUT = 15.0 # s; cap per decompile so a failing one can't hang the CLI
_TRUNC_RE = re.compile(r"\[(\d+) chars total\]\s*$")
@@ -714,6 +714,7 @@ class Program:
self._sections: list[tuple[int, int, str]] | None = None
self._fileregions: list[tuple[int, int, int]] | None = None
self._hexmodel: "HexModel | None" = None
+ self._no_read_raw = False # set if the server lacks the read_raw tool
self._lock = threading.Lock()
# -- prefetch plumbing ------------------------------------------------- #
@@ -802,9 +803,29 @@ class Program:
return None
def read_bytes(self, ea: int, n: int) -> bytes:
- """Raw bytes [ea, ea+n) from IDA (gaps read as zero)."""
+ """Raw bytes [ea, ea+n) from IDA (gaps read as zero).
+
+ Fast path: the injected ``read_raw`` tool returns one contiguous hex
+ string (C-speed both ends). Falls back to the stock ``get_bytes`` (a
+ per-byte '0x..'-with-spaces string) on an older server without it.
+ """
if n <= 0:
return b""
+ if not self._no_read_raw:
+ try:
+ r = self.client.call("read_raw", addr=hex(ea), size=int(n))
+ h = r.get("hex") if isinstance(r, dict) else None
+ if isinstance(h, str):
+ out = bytes.fromhex(h)
+ return out[:n] if len(out) >= n else out + b"\x00" * (n - len(out))
+ except IDAToolError as e:
+ # Tool missing on this server: stop trying it, use get_bytes.
+ if "read_raw" in str(e) or "Unknown tool" in str(e) or "not found" in str(e):
+ self._no_read_raw = True
+ else:
+ return b"\x00" * n
+ except (ValueError, KeyError):
+ pass # malformed hex -> fall through to the legacy decoder
try:
r = self.client.call("get_bytes", regions=[{"addr": hex(ea), "size": int(n)}])
except IDAToolError:
diff --git a/server/patch_server.py b/server/patch_server.py
index 52b81a6..4764367 100644
--- a/server/patch_server.py
+++ b/server/patch_server.py
@@ -180,6 +180,45 @@ def file_regions() -> dict:
return {"regions": out}
+@tool
+@idasync
+def read_raw(
+ addr: Annotated[str, "Start address (hex or name)"],
+ size: Annotated[int, "Number of bytes to read"],
+) -> dict:
+ """Read ``size`` bytes at ``addr`` as ONE contiguous lowercase hex string
+ (no per-byte '0x'/spaces). The hot path for the hex view and disasm opcode
+ bytes.
+
+ Fast: does a single bulk ``ida_bytes.get_bytes`` (C-speed) instead of the
+ per-byte read_bytes_bss_safe loop (2 IDA calls/byte). Unloaded bytes come
+ back from IDA as the 0xFF sentinel, so we only re-check is_loaded for the
+ (usually sparse) 0xFF bytes and zero the genuinely-unloaded ones — matching
+ get_bytes' bss semantics without paying per-byte for the whole range.
+
+ Encoding is compact hex (~2.5x smaller than get_bytes' '0x..'-with-spaces)
+ and, unlike get_bytes, does not truncate on large reads."""
+ import ida_bytes
+
+ ea = parse_address(addr)
+ n = max(int(size), 0)
+ if n == 0:
+ return {"addr": addr, "hex": "", "n": 0}
+ raw = ida_bytes.get_bytes(ea, n)
+ if raw is None or len(raw) < n: # nothing (or not all) mapped
+ base = bytearray(raw or b"")
+ base.extend(b"\\xff" * (n - len(base)))
+ raw = bytes(base)
+ ba = bytearray(raw)
+ # Only unloaded bytes read as 0xFF; correct just those to 0 (bss => zero).
+ i = ba.find(0xFF)
+ while i != -1:
+ if not ida_bytes.is_loaded(ea + i):
+ ba[i] = 0
+ i = ba.find(0xFF, i + 1)
+ return {"addr": addr, "hex": bytes(ba).hex(), "n": len(ba)}
+
+
def _idatui_head_row(ea):
"""One flat-listing row for the head at ``ea``: kind (code/data/unknown),
byte size, rendered text, and any symbol name."""