summaryrefslogtreecommitdiffstats
path: root/idatui
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-07 05:35:31 +0200
committeruser <user@clank>2026-08-07 05:35:31 +0200
commit44c311ae8b7f6b494cb82609ec02cf6a4970e015 (patch)
tree7a3c7e81251b84c9453f74d374d1828f02d0994d /idatui
parentRE-BASELINE (v7 bench). Rename — the commonest operation in reverse enginee... (diff)
downloadida-tui-44c311ae8b7f6b494cb82609ec02cf6a4970e015.tar.gz
ida-tui-44c311ae8b7f6b494cb82609ec02cf6a4970e015.tar.xz
ida-tui-44c311ae8b7f6b494cb82609ec02cf6a4970e015.zip
A rename keeps the listing's walk instead of throwing it away. bump_names now marks the rendered text stale (invalidate_text) and ListingModel re-renders a 500-head block at a time on demand, snapped out to whole address groups; a refetch that comes back with a different head sequence sets stale_structure so Program.listing() rebuilds. lg_rename 10055 -> 742.
Result: {"status":"keep","total_ms":25563.7,"lg_boot_ms":732.6,"lg_decomp_ms":2714,"lg_graph_ms":919.3,"lg_hex_ms":566.8,"lg_index_ms":71.4,"lg_listing_cold_ms":441.4,"lg_listing_warm_ms":411.7,"lg_nav_ms":6708,"lg_palette_ms":4.8,"lg_rename_ms":741.5,"lg_render_ms":227.7,"lg_search_ms":3314.1,"lg_split_ms":2596.9,"pure_graph_ms":213.6,"sm_boot_ms":422.9,"sm_decomp_ms":1284,"sm_graph_ms":789.5,"sm_hex_ms":468.1,"sm_index_ms":2.6,"sm_listing_cold_ms":258.7,"sm_listing_warm_ms":257.3,"sm_nav_ms":306.5,"sm_palette_ms":0.3,"sm_rename_ms":388.2,"sm_render_ms":256,"sm_search_ms":105.1,"sm_split_ms":1360.5,"fails":0}
Diffstat (limited to 'idatui')
-rw-r--r--idatui/domain.py138
1 files changed, 133 insertions, 5 deletions
diff --git a/idatui/domain.py b/idatui/domain.py
index b5081cf..386b3e8 100644
--- a/idatui/domain.py
+++ b/idatui/domain.py
@@ -642,6 +642,11 @@ class ListingModel:
"""
PAGE = 500 # heads per server call (well under the tool's 2000 cap)
+ #: Heads refreshed together when a rename makes their text stale. One server
+ #: call per block, so a viewport costs one round trip rather than forty --
+ #: and the same size as a load page, so refreshing everything costs about
+ #: what rebuilding everything would have.
+ TEXT_BLOCK = 500
def __init__(self, program: "Program", seg_start: int, seg_end: int,
name: str | None = None):
@@ -659,6 +664,19 @@ class ListingModel:
# demand. _row_at[i] is the logical row where physical head i starts.
self._row_at: list[int] = []
self._head_eas: list[int] = [] # parallel to _heads, for bisect
+ #: Which name generation each head's TEXT was rendered at, parallel to
+ #: _heads. A rename bumps :attr:`_text_gen`; the rows themselves stay
+ #: (their addresses and row numbers are unchanged) and are re-rendered a
+ #: block at a time when something asks for them. See invalidate_text.
+ self._head_gen: list[int] = []
+ self._text_gen = 0
+ #: Whether a rename has ever staled this model. Until one has, every
+ #: read takes exactly the path it always did.
+ self._renamed = False
+ #: Set if a text refresh came back with a different head sequence, which
+ #: means something DID move the walk. Program.listing() throws the model
+ #: away when it sees this, so the next read rebuilds from scratch.
+ self.stale_structure = False
self._rows = 0 # total logical rows loaded
self._ubytes: dict[int, bytes] = {} # lazily-read bytes for those rows
self._next: int | None = seg_start # next address to fetch from
@@ -739,6 +757,7 @@ class ListingModel:
cur = payload.get("cursor", {}) if isinstance(payload, dict) else {}
page = self._build_page(rows)
with self._lock:
+ gen = self._text_gen
for h in page:
# Banner/label rows (function headers, separators, code labels)
# are display-only; don't index them so navigation lands on the
@@ -747,6 +766,7 @@ class ListingModel:
self._by_ea.setdefault(h.ea, self._rows)
self._row_at.append(self._rows)
self._head_eas.append(h.ea)
+ self._head_gen.append(gen)
self._heads.append(h)
self._rows += self._span(h)
nxt = cur.get("next")
@@ -878,6 +898,78 @@ class ListingModel:
def __len__(self) -> int:
return self.loaded()
+ def invalidate_text(self) -> None:
+ """A rename changed how rows READ, not which rows exist.
+
+ Item boundaries are untouched by a rename, so every row keeps its
+ address and its row number — which the edit path already relies on, since
+ it restores the cursor by INDEX afterwards. Dropping the whole model
+ instead means the next jump re-walks the segment from its start: 6.4
+ seconds on bash's .text, after every single rename.
+
+ So keep the walk and mark the rendered text stale; :meth:`_ensure_text`
+ re-renders a block at a time, and refuses to splice anything back if the
+ head sequence has moved under it (which a rename cannot do, but a
+ mis-routed structural edit could).
+ """
+ with self._lock:
+ self._text_gen += 1
+ self._renamed = True
+
+ def _ensure_text(self, j0: int, j1: int) -> None:
+ """Re-render physical heads [j0, j1) if a rename staled them.
+
+ The block is snapped out to whole ADDRESS groups. A function start emits
+ three banner rows and its code row at the same ea, and a labelled
+ instruction emits two -- so a block boundary that fell inside one of
+ those groups would refetch the whole group and never line up again.
+ """
+ with self._lock:
+ gen = self._text_gen
+ n = len(self._heads)
+ j0 = max(j0, 0)
+ j1 = min(j1, n)
+ if j1 <= j0:
+ return
+ head_gen = self._head_gen
+ if all(head_gen[j] == gen for j in range(j0, j1)):
+ return
+ blk = self.TEXT_BLOCK
+ a = (j0 // blk) * blk
+ b = min(((j1 - 1) // blk + 1) * blk, n)
+ eas = self._head_eas
+ while a > 0 and eas[a - 1] == eas[a]:
+ a -= 1
+ while b < n and eas[b - 1] == eas[b]:
+ b += 1
+ last = self._heads[b - 1]
+ lo = eas[a]
+ hi = last.ea + max(last.size, 1)
+ want = [(h.ea, h.kind) for h in self._heads[a:b]]
+ try:
+ payload = self._prog.client.call(
+ "heads", addr=hex(lo), end=hex(hi),
+ count=min(len(want) + 64, 2000), annotate=True)
+ except Exception: # noqa: BLE001 -- keep the old text rather than blank
+ return
+ rows = payload.get("heads", []) if isinstance(payload, dict) else []
+ page = self._build_page(rows)[:len(want)]
+ with self._lock:
+ if self._text_gen != gen or len(self._heads) < b:
+ return
+ if [(h.ea, h.kind) for h in page] != want:
+ # Something moved the walk, which a rename cannot do -- so this
+ # was not one. Say so and let Program.listing() rebuild, rather
+ # than sit here re-fetching a block that will never line up (and
+ # showing the old names while doing it).
+ self.stale_structure = True
+ for j in range(a, b):
+ self._head_gen[j] = gen
+ return
+ self._heads[a:b] = page
+ for j in range(a, b):
+ self._head_gen[j] = gen
+
def get(self, i: int) -> Head | None:
with self._lock:
if not (0 <= i < self._rows):
@@ -885,8 +977,23 @@ class ListingModel:
j, off = self._phys(i)
if j < 0:
return None
- span = self._span(self._heads[j])
- h = self._heads[j]
+ stale = self._renamed and self._head_gen[j] != self._text_gen
+ if not stale:
+ span = self._span(self._heads[j])
+ h = self._heads[j]
+ if stale:
+ # A rename staled this row's text; re-render its block (one call for
+ # the block around it, so a viewport costs one round trip). Only
+ # this path re-takes the lock -- the ordinary read stays atomic.
+ self._ensure_text(j, j + 1)
+ with self._lock:
+ if not (0 <= i < self._rows):
+ return None
+ j, off = self._phys(i)
+ if j < 0:
+ return None
+ span = self._span(self._heads[j])
+ h = self._heads[j]
# Synthesis reads bytes, so do it OUTSIDE the lock: an RPC under the
# model lock deadlocks the page loader that is filling it.
return self._row_head(j, off) if span > 1 else h
@@ -895,6 +1002,16 @@ class ListingModel:
"""``count`` logical rows from ``start`` (synthesising undefined ones)."""
self.ensure(start + count)
with self._lock:
+ # _renamed stays set once a rename has happened; _ensure_text then
+ # does the precise, range-limited staleness check. Before the first
+ # rename this is one boolean and the read is exactly as it was.
+ dirty = self._renamed
+ if dirty:
+ j0 = max(self._phys(max(start, 0))[0], 0)
+ j1 = self._phys(max(min(self._rows, start + count) - 1, 0))[0] + 1
+ if dirty:
+ self._ensure_text(j0, j1)
+ with self._lock:
rows = min(self._rows, start + count)
spans = [self._phys(i) for i in range(max(start, 0), max(rows, 0))]
heads = self._heads
@@ -1223,6 +1340,8 @@ class Program:
start, end, name = seg
with self._lock:
m = self._listings.get(start)
+ if m is not None and m.stale_structure:
+ m = None # a refresh found the walk had moved; start over
if m is None:
m = ListingModel(self, start, end, name)
self._listings[start] = m
@@ -1406,15 +1525,24 @@ class Program:
def bump_names(self) -> None:
"""Signal that symbol names changed (a rename). Disasm/listing names are
- live in the IDB, so clearing the cached rows is enough for those;
- decompilation is generation-checked and force-recompiled lazily."""
+ live in the IDB, so the cached rows have to be re-rendered; decompilation
+ is generation-checked and force-recompiled lazily.
+
+ The listing keeps its WALK. A rename cannot move an item boundary, so
+ every row keeps its address and its row number -- the edit path already
+ assumes exactly that, since it restores the cursor by index afterwards.
+ Dropping the segment model instead made the reload re-walk it from the
+ start, which is 6.4 seconds on bash after every rename.
+ """
with self._lock:
self._name_gen += 1
models = list(self._disasm.values())
- self._listings.clear() # listing head rows cache names -> refetch
+ listings = list(self._listings.values())
self._pc_nums.clear() # a reformat moves every literal on its line
for m in models:
m.invalidate()
+ for lm in listings:
+ lm.invalidate_text()
def bump_items(self) -> None:
"""Signal that item/function STRUCTURE changed (define code/data/func,