diff options
| author | blasty <blasty@local> | 2026-08-10 01:18:27 +0200 |
|---|---|---|
| committer | blasty <blasty@local> | 2026-08-10 01:18:27 +0200 |
| commit | 70a1fc453f64aad246e1074df3303b275a9e3032 (patch) | |
| tree | f05bfca9c1050376d3bb06a71b37dc2b08576d54 | |
| parent | domain: build Head positionally, and drop a dead test from _as_int (diff) | |
| download | ida-tui-70a1fc453f64aad246e1074df3303b275a9e3032.tar.gz ida-tui-70a1fc453f64aad246e1074df3303b275a9e3032.tar.xz ida-tui-70a1fc453f64aad246e1074df3303b275a9e3032.zip | |
segment_index: the row total in one call instead of 458
The listing streams a whole segment for one reason -- to know how many rows
it has, so the scrollbar and paging are right. Even as skeletons that is 458
round trips and 227k rows for a 1.2MB bash, none of which is displayed.
segment_index walks the same items and counts what heads() WOULD emit,
building none of them, and returns the total plus [row, ea] anchors every 500
rows. Measured on bash, same process and database:
segment_index : 228,659 rows, 1 call, 501ms
streaming : 228,659 rows, 458 calls, 1836ms 3.7x
Exactness is the whole point, so it mirrors _rows_for's arithmetic rather
than approximating it: 3 banner rows at a function start, a label row for a
named code head that is not one, the head row, struct members for data, 2
footer rows at a function end, and an undefined run counted as its byte
length because the client presents one collapsed row as that many logical
rows. A count that is off by a handful means the scrollbar lies and a seek
lands on the wrong row, so the test compares against a fully streamed model
in the same process rather than against a tolerance, and checks that every
anchor names the address of the row it claims.
Getting that right took a false alarm worth recording: the count first looked
35 rows short of a model built by the pilot, which turned out to be a
DIFFERENT DATABASE (tests run on a pristine scratch copy). Against the same
database it matches exactly, head for head, with zero differing addresses.
Nothing consumes this yet. Spending it means teaching ListingModel to hold
sparse pages seeked through the anchors instead of one dense array grown from
the segment start, which is a real change to the core view and wants its own
run at it.
Full gate: 1054 passed.
| -rw-r--r-- | idatui/codemode_client.py | 6 | ||||
| -rw-r--r-- | idatui/remote_tools.py | 89 | ||||
| -rw-r--r-- | tests/test_scenarios.py | 48 |
3 files changed, 143 insertions, 0 deletions
diff --git a/idatui/codemode_client.py b/idatui/codemode_client.py index d0d1937..2361e08 100644 --- a/idatui/codemode_client.py +++ b/idatui/codemode_client.py @@ -1205,6 +1205,12 @@ _OPERATIONS["pc_num_format"] = _remote_op( # would reformat) and had no digest/expect support (so every page was re-sent # after any edit), and whose span walk was the per-character loop our own # version had already been rewritten to avoid. +#: Row count + seek anchors for a whole segment, in ONE call. See +#: remote_tools.segment_index: the alternative is fetching every row. +_OPERATIONS["segment_index"] = _remote_op( + 'segment_index(addr=a["addr"], end=a.get("end", ""),' + ' page_rows=int(a.get("page_rows", 500)))') + _HEADS = _remote_op( 'heads(addr=a["addr"], count=int(a.get("count", 200)),' ' offset=int(a.get("offset", 0)), end=a.get("end", ""),' diff --git a/idatui/remote_tools.py b/idatui/remote_tools.py index d2cb02e..614fba4 100644 --- a/idatui/remote_tools.py +++ b/idatui/remote_tools.py @@ -510,6 +510,95 @@ def _idatui_func_footer_rows(ea, func): ] +def segment_index( + addr: Annotated[str, "Any address in the segment to index"], + end: Annotated[str, "Optional exclusive end address; default = segment end"] = "", + page_rows: Annotated[int, "Rows between anchors (default 500)"] = 500, +) -> dict: + """How many listing rows a segment has, and where to seek into it. + + The listing needs a total row count to size its scrollbar, and the only way + to get one used to be to fetch every row: 455 round trips and 227k rows for + a 1.2MB bash, none of which is looked at. This walks the same items and + counts what ``heads`` WOULD emit, without building or rendering any of them. + + Returns ``{rows, heads, anchors}`` where anchors is ``[[row, ea], ...]`` + every ``page_rows`` logical rows -- enough to turn "show me row N" into a + ``heads(addr=anchor)`` call, so pages can be fetched on demand instead of + streamed in order. + + **The count must match what heads() actually emits, exactly**, or the + scrollbar lies and a jump lands on the wrong row. It therefore mirrors + _rows_for's arithmetic rather than approximating it: 3 banner rows at a + function start, a label row for a named code head that is not one, the head + row itself, struct member rows for data, 2 footer rows at a function end, + and an undefined run counted as its byte length (the client presents one + collapsed row as that many logical rows). Verified equal to summing the real + pages, head for head, over a whole segment. + """ + + count = max(int(page_rows), 1) + try: + start = parse_address(addr) + except Exception as e: + return {"addr": str(addr), "error": str(e), "rows": 0, "anchors": []} + import ida_segment + seg = ida_segment.getseg(start) + if not seg: + return {"addr": str(addr), "error": "no segment", "rows": 0, "anchors": []} + lo, hi = seg.start_ea, seg.end_ea + if end: + try: + hi = min(hi, parse_address(end)) + except Exception: + pass + + get_flags = ida_bytes.get_flags + get_item_end = ida_bytes.get_item_end + next_head = ida_bytes.next_head + get_ea_name = ida_name.get_ea_name + get_func = idaapi.get_func + BAD = idaapi.BADADDR + + rows = 0 + n_heads = 0 + anchors = [] + fn = None + ea = ida_bytes.get_item_head(lo) + while ea != BAD and ea < hi: + if rows // count >= len(anchors): + anchors.append([rows, hex(ea)]) + f = get_flags(ea) + cls = f & _MS_CLS + if cls != _FF_CODE and cls != _FF_DATA: + # An undefined run is ONE emitted row that PRESENTS as one logical + # row per byte (see _idatui_unknown_row and the client's _span). + nh = next_head(ea, hi) + stop = nh if (nh != BAD and ea < nh <= hi) else hi + run = stop - ea + rows += run if run > 1 else 1 + n_heads += 1 + ea = stop + continue + if fn is None or not (fn.start_ea <= ea < fn.end_ea): + fn = get_func(ea) + n = 1 + if fn is not None and fn.start_ea == ea: + n += 3 # blank, banner, `proc` + elif cls == _FF_CODE and get_ea_name(ea): + n += 1 # loc_XXX label on its own row + if cls == _FF_DATA: + n += len(_idatui_struct_member_rows(ea)) + item_end = get_item_end(ea) + if fn is not None and item_end >= fn.end_ea: + n += 2 # `endp` + separator + rows += n + n_heads += 1 + ea = item_end if item_end > ea else ea + 1 + return {"addr": hex(lo), "end": hex(hi), "rows": rows, + "heads": n_heads, "anchors": anchors} + + def heads( addr: Annotated[str, "Start address or name to walk from"], count: Annotated[int, "Max heads to return (default 200, max 2000)"] = 200, diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index 780459a..b256b80 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -412,6 +412,54 @@ async def s_auto_land(c: Ctx): c.check("auto-land is idempotent (guarded)", app._cur is prev) +@scenario("segment_index") +async def s_segment_index(c: Ctx): + """segment_index must count EXACTLY what streaming the pages produces. + + It exists so the listing can know its row total without fetching every row + (1 call and ~0.5s instead of 458 calls and ~1.8s on bash). That is only + usable if the number is exact: the total sizes the scrollbar, and the + anchors are what a future "seek to row N" would jump through, so being off + by a handful of rows means the bar lies and a jump lands in the wrong place. + + Approximating it is the tempting mistake, which is why this compares against + the real thing rather than a tolerance. + """ + app = c.app + await c.open_biggest("listing") + lv = app.query_one(ListingView) + model = lv.model + if model is None: + c.check("listing model exists", False) + return + await c.wait(lambda: model.complete, 30) + if not model.complete: + c.check("segment streamed for comparison", False) + return + + idx = app.program.client.invoke("segment_index", addr=hex(model.seg_start)) + c.check("segment_index counts exactly what streaming produced", + idx.get("rows") == len(model), + f"index={idx.get('rows')} streamed={len(model)}") + c.check("it reports the same segment", + int(str(idx.get("addr")), 16) == model.seg_start, + f"{idx.get('addr')} vs {model.seg_start:#x}") + anchors = idx.get("anchors") or [] + c.check("anchors cover the segment", + len(anchors) >= max(1, len(model) // 500), + f"{len(anchors)} anchors for {len(model)} rows") + + # Every anchor must name the address of the row it claims, or seeking to it + # would land somewhere else entirely. + bad = [] + for row, ea in anchors: + h = model.get(row) + if h is None or h.ea != int(str(ea), 16): + bad.append((row, ea, hex(h.ea) if h else None)) + c.check("every anchor points at the row it claims", not bad, + f"{len(bad)} wrong, first={bad[:2]}") + + @scenario("skeleton_pages") async def s_skeleton_pages(c: Ctx): """The background grower loads text-less pages; reading one must fill it in. |
