aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--experiments/opfmt_tools.py391
-rw-r--r--tests/test_rawimage_rpc.py45
-rw-r--r--tests/test_scenarios.py464
3 files changed, 900 insertions, 0 deletions
diff --git a/experiments/opfmt_tools.py b/experiments/opfmt_tools.py
new file mode 100644
index 0000000..535688c
--- /dev/null
+++ b/experiments/opfmt_tools.py
@@ -0,0 +1,391 @@
+"""Tool-level checks for op_format / pc_num_format against a live idalib
+database, run on the REAL tool sources (server/patch_server.py's injected BODY,
+exec'd here with the @tool/@idasync decorators stubbed out).
+
+Faster than the pilot suite and independent of the TUI, so it is the right place
+for the IDA-side edge cases: which stops a value gets offered, what an offset
+does to the database, what Hex-Rays will and won't render.
+
+ python3 experiments/opfmt_tools.py # 29 checks, ~40s
+"""
+import importlib.util
+import os
+import shutil
+import sys
+from typing import Annotated # noqa: F401 (the BODY annotates with it)
+
+REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+sys.path.insert(0, REPO)
+
+src = os.path.join(REPO, "targets", "echo")
+tmp = "/tmp/opfmt_tools_echo"
+shutil.copy(src, tmp)
+for e in ".i64 .id0 .id1 .id2 .nam .til".split():
+ try:
+ os.remove(tmp + e)
+ except OSError:
+ pass
+seed = src + ".pristine.i64"
+if os.path.exists(seed):
+ shutil.copy(seed, tmp + ".i64")
+
+import idapro # noqa: E402
+idapro.enable_console_messages(False)
+assert idapro.open_database(tmp, run_auto_analysis=True) == 0
+import ida_auto # noqa: E402
+ida_auto.auto_wait()
+
+import ida_typeinf, idaapi, ida_bytes, ida_lines # noqa: E402,F401
+
+spec = importlib.util.spec_from_file_location(
+ "_patch", os.path.join(REPO, "server", "patch_server.py"))
+patch = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(patch)
+
+
+def parse_address(s):
+ if isinstance(s, int):
+ return s
+ s = str(s).strip()
+ try:
+ return int(s, 0)
+ except ValueError:
+ ea = idaapi.get_name_ea(idaapi.BADADDR, s)
+ if ea == idaapi.BADADDR:
+ raise ValueError(f"bad address {s!r}")
+ return ea
+
+
+NS = {
+ "Annotated": Annotated,
+ "tool": lambda f: f,
+ "idasync": lambda f: f,
+ "ida_typeinf": ida_typeinf,
+ "parse_address": parse_address,
+ "_parse_type_tinfo": lambda s: None,
+}
+exec(compile(patch.BODY, "<idatui-ext>", "exec"), NS)
+op_format = NS["op_format"]
+pc_num_format = NS["pc_num_format"]
+heads = NS["heads"]
+
+OK = FAIL = 0
+
+
+def check(name, cond, detail=""):
+ global OK, FAIL
+ if cond:
+ OK += 1
+ print(f" ok {name}")
+ else:
+ FAIL += 1
+ print(f" FAIL {name} {detail}")
+
+
+def line(ea):
+ return NS["_idatui_line_text"](ea)
+
+
+print("\n=== listing: cycle an immediate ===")
+ea = parse_address("main")
+# an instruction with a >9 literal, so hex and decimal actually look different
+target = None
+e = ea
+while e < ea + 0x400:
+ c = NS["_idatui_op_candidates"](e)
+ for n in c:
+ v, _w = NS["_idatui_op_value"](e, n)
+ if v and v > 9 and not ida_bytes.is_mapped(v):
+ target = (e, n, v)
+ break
+ if target:
+ break
+ e = ida_bytes.next_head(e, ea + 0x800)
+print("target:", hex(target[0]), "n=", target[1], "value", hex(target[2]),
+ "|", line(target[0]))
+tea, tn, tv = target
+
+r = op_format(addr=hex(tea), mode="show")
+print(" show:", r)
+check("show reports the operand and its choices",
+ r.get("n") == tn and "hex" in r.get("choices", []), str(r))
+check("show doesn't change anything", r.get("applied") is False, str(r))
+
+seen = []
+for i in range(8):
+ r = op_format(addr=hex(tea), mode="cycle")
+ seen.append((r.get("format"), r.get("text")))
+ print(f" cycle -> {r.get('prev')} -> {r.get('format')}: {r.get('text')}")
+check("cycling returns to where it started",
+ seen[0][0] == seen[len(r.get("choices", []))][0]
+ if len(seen) > len(r.get("choices", [])) else True, str(seen))
+check("decimal renders differently from hex",
+ any(s[1] != seen[0][1] for s in seen), str(seen))
+
+r = op_format(addr=hex(tea), mode="dec")
+check("explicit dec sticks", r.get("format") == "dec" and str(tv) in r.get("text", ""),
+ str(r))
+r = op_format(addr=hex(tea), mode="back")
+print(" back ->", r.get("format"), r.get("text"))
+check("back steps the ring the other way", r.get("format") == "hex", str(r))
+r = op_format(addr=hex(tea), mode="default")
+check("default clears the user format", r.get("format") == "default", str(r))
+
+print("\n=== listing: a literal that IS an address becomes a reference ===")
+e, off_target = ea, None
+while e < ea + 0x800:
+ for n in NS["_idatui_op_candidates"](e):
+ v, _w = NS["_idatui_op_value"](e, n)
+ if v and ida_bytes.is_mapped(v):
+ off_target = (e, n)
+ break
+ if off_target:
+ break
+ e = ida_bytes.next_head(e, ea + 0x800)
+print("target:", off_target and hex(off_target[0]), "|",
+ off_target and line(off_target[0]))
+if off_target:
+ import ida_name # noqa: E402
+ oe, on = off_target
+ v, _w = NS["_idatui_op_value"](oe, on)
+ named = bool(ida_name.get_ea_name(v))
+ r = op_format(addr=hex(oe), n=on, mode="show")
+ print(" ", hex(oe), "n=", on, "|", r.get("text"), r.get("choices"),
+ "target named:", named)
+ check("an unnamed target is not a cycle stop (it would invent a name)",
+ ("offset" in r.get("choices", [])) == named, str(r))
+ r = op_format(addr=hex(oe), n=on, mode="offset")
+ print(" offset ->", r.get("text"))
+ check("but asking explicitly makes the reference",
+ r.get("format") == "offset" and "offset" in r.get("text", ""), str(r))
+ r = op_format(addr=hex(oe), n=on, mode="show")
+ check("and from then on the ring includes it",
+ "offset" in r.get("choices", []), str(r))
+ r = op_format(addr=hex(oe), n=on, mode="hex")
+ print(" hex ->", r.get("text"))
+ check("and back to a number", r.get("format") == "hex"
+ and "offset" not in r.get("text", ""), str(r))
+ op_format(addr=hex(oe), n=on, mode="default")
+else:
+ print(" (no literal-that-is-an-address in this function)")
+
+print("\n=== listing: column -> operand ===")
+txt = line(tea)
+spans = NS["_idatui_op_spans"](tea, txt)
+print(" text:", repr(txt), "spans:", spans)
+if len(spans) >= 2:
+ r = op_format(addr=hex(tea), col=spans[0][0], mode="show")
+ check("a column inside operand 0 picks operand 0 (or the first literal)",
+ r.get("n") in (spans[0][2], NS["_idatui_op_candidates"](tea)[0]), str(r))
+ r = op_format(addr=hex(tea), col=spans[-1][0], mode="show")
+ check("a column inside the last operand picks it", r.get("n") == spans[-1][2],
+ str(r))
+
+print("\n=== listing: an unmapped value refuses to become an offset ===")
+r = op_format(addr=hex(tea), n=tn, mode="offset")
+print(" ", r.get("error") or r)
+check("offset on a non-address is refused, not invented",
+ bool(r.get("error")) or ida_bytes.is_mapped(tv), str(r))
+
+print("\n=== listing: char is only offered when it renders as one ===")
+r = op_format(addr=hex(tea), n=tn, mode="show")
+check("0x%x isn't offered as a char" % tv,
+ ("char" in r["choices"]) == NS["_idatui_printable"](tv), str(r))
+
+print("\n=== listing: a stack variable can be cycled AND put back ===")
+stk = None
+for f in (parse_address("main"),):
+ e = f
+ while e < f + 0x600 and stk is None:
+ for n in NS["_idatui_op_candidates"](e):
+ if NS["_idatui_op_fmt"](e, n) == "stack":
+ stk = (e, n)
+ break
+ e = ida_bytes.next_head(e, f + 0x600)
+print(" stkvar operand:", stk and hex(stk[0]), "|", stk and line(stk[0]))
+if stk:
+ se, sn = stk
+ orig = line(se)
+ r = op_format(addr=hex(se), n=sn, mode="cycle")
+ print(" cycle ->", r.get("format"), r.get("text"), "|", r.get("warn"))
+ check("leaving a stack variable says so, and how to undo it",
+ "stack" in (r.get("warn") or ""), str(r))
+ ring = [op_format(addr=hex(se), n=sn, mode="cycle")
+ for _ in range(len(r["choices"]))]
+ print(" ring:", [(x["format"], x["text"]) for x in ring])
+ check("the ring is the same at every step (a lap comes home)",
+ [x["format"] for x in ring] == r["choices"][1:] + r["choices"][:1],
+ f"{[x['format'] for x in ring]} vs {r['choices']}")
+ r = op_format(addr=hex(se), n=sn, mode="stack")
+ check("'stack' puts the frame variable back",
+ r.get("format") == "stack" and r.get("text") == orig,
+ f"{r.get('text')!r} want {orig!r}")
+else:
+ print(" (no stack-variable operand found)")
+
+print("\n=== data item ===")
+import ida_segment # noqa: E402
+seg = ida_segment.get_segm_by_name(".data")
+if seg:
+ de = seg.start_ea
+ picked = None
+ for _ in range(24):
+ f = ida_bytes.get_flags(de)
+ if ida_bytes.is_data(f) and NS["_idatui_op_fmt"](de, 0) == "default":
+ v, _w = NS["_idatui_op_value"](de, 0)
+ if v:
+ picked = de
+ break
+ de = ida_bytes.next_head(de, seg.end_ea)
+ if de == idaapi.BADADDR:
+ break
+ print(" data item:", picked and hex(picked), "|", picked and line(picked))
+ if picked:
+ r = op_format(addr=hex(picked), mode="dec")
+ print(" dec ->", r.get("text"))
+ check("a data value reformats too", r.get("format") == "dec", str(r))
+ op_format(addr=hex(picked), mode="default")
+
+print("\n=== undefined bytes say what to do instead ===")
+seg = ida_segment.get_segm_by_name(".rodata") or ida_segment.getnseg(0)
+ue, e = None, seg.start_ea
+while e < seg.end_ea and ue is None:
+ f = ida_bytes.get_flags(e)
+ if not (ida_bytes.is_code(f) or ida_bytes.is_data(f)):
+ ue = e
+ e += 1
+if ue is not None:
+ r = op_format(addr=hex(ue), mode="cycle")
+ print(" ", hex(ue), "->", r.get("error"))
+ check("undefined bytes are refused with the fix, not a silent no-op",
+ "define" in (r.get("error") or ""), str(r))
+else:
+ print(" (no undefined bytes)")
+
+print("\n=== pseudocode ===")
+r = pc_num_format(addr="main", mode="show", line=-1)
+print(" show without a line:", r.get("error"))
+check("no line is an error, not a guess", bool(r.get("error")), str(r))
+
+import ida_hexrays # noqa: E402
+cf = ida_hexrays.decompile(parse_address("main"))
+sv = cf.get_pseudocode()
+pcline = None
+for i in range(len(sv)):
+ nums = NS["_idatui_pc_nums"](cf, sv[i])
+ if nums and nums[0]["value"] > 9:
+ pcline = (i, nums[0])
+ break
+print(" line", pcline[0], repr(ida_lines.tag_remove(sv[pcline[0]].line).strip()),
+ "num:", pcline[1])
+i, num = pcline
+r = pc_num_format(addr="main", line=i, mode="show")
+check("show finds the literal", r.get("ea") == hex(num["ea"]), str(r))
+r = pc_num_format(addr="main", line=i, mode="hex")
+print(" hex ->", r.get("text"))
+check("hex renders 0x in the pseudocode", "0x" in r.get("text", ""), str(r))
+r = pc_num_format(addr="main", line=i, mode="cycle")
+print(" cycle ->", r.get("format"), r.get("text"))
+r = pc_num_format(addr="main", line=i, mode="char")
+print(" char ->", r.get("format"), r.get("text"))
+r = pc_num_format(addr="main", line=i, mode="bin")
+print(" bin ->", r.get("error"))
+check("binary is refused with a reason", bool(r.get("error")), str(r))
+r = pc_num_format(addr="main", line=i, mode="default")
+print(" default ->", r.get("text"))
+check("default restores Hex-Rays' own choice",
+ r.get("text") == ida_lines.tag_remove(sv[i].line).strip(), str(r))
+r = pc_num_format(addr="main", line=i, mode="show")
+check("and the format reads back as default", r.get("format") == "default", str(r))
+print("\n=== pseudocode: the ring visits every stop ===")
+ring = []
+for _ in range(len(r.get("choices", [])) * 2):
+ rr = pc_num_format(addr="main", line=i, mode="cycle")
+ ring.append((rr.get("format"), rr.get("text")))
+print(" ", [x[0] for x in ring])
+check("every stop in the ring is reached",
+ set(x[0] for x in ring) == set(r["choices"]), f"{ring} vs {r['choices']}")
+check("the ring's renderings are distinct",
+ len({x[1] for x in ring}) >= len(r["choices"]) - 1, str(ring))
+pc_num_format(addr="main", line=i, mode="default")
+
+print("\n=== pseudocode: col picks the literal ===")
+multi = None
+for k in range(len(sv)):
+ nums = NS["_idatui_pc_nums"](cf, sv[k])
+ if len(nums) >= 2:
+ multi = (k, nums)
+ break
+if multi:
+ k, nums = multi
+ plain = ida_lines.tag_remove(sv[k].line)
+ print(" line", k, repr(plain.strip()), [(x["x0"], x["value"]) for x in nums])
+ compact = NS["_idatui_compact"](plain)
+ # a column in the compacted text that lands on the SECOND number
+ x = nums[1]["x0"]
+ # map forward: find where that char went in the compacted line
+ col = len(NS["_idatui_compact"](plain[:x]).rstrip()) if x else 0
+ r = pc_num_format(addr="main", line=k, col=col, mode="show")
+ print(" col", col, "->", r.get("ea"), r.get("value"))
+ check("a column selects the number under it",
+ r.get("value") == hex(nums[1]["value"])
+ or r.get("value") == hex(nums[0]["value"]), str(r))
+else:
+ print(" (no line with two literals)")
+
+print("\n=== operand extents ship with every listing row ===")
+r = NS["heads"](addr=hex(tea), count=6, annotate=False)
+row = next((h for h in r["heads"] if int(h["ea"], 16) == tea), None)
+print(" row:", {k: v for k, v in (row or {}).items() if k in ("ea", "text", "ops")})
+check("a code row carries operand extents", bool(row and row.get("ops")), str(row))
+if row and row.get("ops"):
+ t = row["text"]
+ for lo, hi, n in row["ops"]:
+ print(f" op{n}: {t[lo:hi]!r}")
+ check("the extents index the row's own text",
+ all(t[lo:hi].strip() for lo, hi, n in row["ops"]), str(row["ops"]))
+ # and they agree with what op_format picks for a column inside them
+ ok = True
+ for lo, hi, n in row["ops"]:
+ got = op_format(addr=hex(tea), col=(lo + hi) // 2, mode="show")
+ if not got.get("error") and got.get("n") != n:
+ ok = False
+ check("a column inside an extent selects that same operand", ok)
+
+print("\n=== the cursor on a non-formattable operand is told, not redirected ===")
+regop = None
+for lo, hi, n in (row or {}).get("ops", []):
+ if n not in NS["_idatui_op_candidates"](tea):
+ regop = (lo, hi, n)
+if regop:
+ lo, hi, n = regop
+ r = op_format(addr=hex(tea), col=(lo + hi) // 2, mode="cycle")
+ print(" ", repr(row["text"][lo:hi]), "->", r.get("error"))
+ check("it names the operand and the one that CAN change",
+ bool(r.get("error")) and "operand" in r["error"], str(r))
+else:
+ print(" (this instruction has no register-only operand)")
+
+print("\n=== pseudocode: every literal located in one call ===")
+pn = NS["pc_nums"](addr="main")
+print(" nums:", len(pn["nums"]), "over", pn["lines"], "lines")
+check("pc_nums finds literals", len(pn["nums"]) > 5, str(pn)[:200])
+multi2 = {}
+for rec in pn["nums"]:
+ multi2.setdefault(rec["line"], []).append(rec)
+two = next((v for v in multi2.values() if len(v) >= 2), None)
+if two:
+ import ida_hexrays as _hx
+ cf2 = _hx.decompile(parse_address("main"))
+ disp = NS["_idatui_compact"](ida_lines.tag_remove(cf2.get_pseudocode()[two[0]["line"]].line))
+ print(" line:", repr(disp.strip()))
+ for rec in two:
+ print(f" x{rec['x0']}..{rec['x1']} = {disp[rec['x0']:rec['x1']]!r} value {rec['value']}")
+ check("spans land on the literals in the DISPLAYED text",
+ all(disp[r0["x0"]:r0["x1"]].strip() for r0 in two), str(two))
+ check("distinct literals get distinct spans",
+ two[0]["x0"] != two[1]["x0"], str(two))
+
+print(f"\n{OK} passed, {FAIL} failed")
+idapro.close_database(save=False)
+sys.exit(1 if FAIL else 0)
diff --git a/tests/test_rawimage_rpc.py b/tests/test_rawimage_rpc.py
index 4da6348..df1166d 100644
--- a/tests/test_rawimage_rpc.py
+++ b/tests/test_rawimage_rpc.py
@@ -22,6 +22,7 @@ Requires: tmux or zellij, IDA (idalib). ~2min.
"""
import json
import os
+import re
import subprocess
import sys
import tempfile
@@ -118,6 +119,50 @@ def main() -> int:
check("define rejects an unknown kind", bad is not None
and "unknown define kind" in bad, str(bad))
+ # -- opfmt (how a literal is displayed) --------------------- #
+ # Thumb code is full of small immediates -- the thing 'o' exists
+ # for -- but the ENTRY POINT hasn't got one, so find a line that
+ # has. `show` asks without editing, which is how a driver does
+ # that: the rendered text alone can't be trusted (a listing read
+ # before an ARM/Thumb switch shows the old decoding).
+ c.call("goto", target="0x0", delay_ms=0)
+ seen = c.call("view", lines=40).get("lines", [])
+ lit = None
+ for ln in seen:
+ m = re.match(r"([0-9A-F]{8})\s+(.*)", ln.get("text", ""))
+ if not (m and re.search(r"#(0x[0-9A-Fa-f]{2,}|[1-9]\d+)\b",
+ m.group(2))):
+ continue
+ ea_s = "0x" + m.group(1)
+ st = c.call("opfmt", mode="show", target=ea_s, delay_ms=0
+ ).get("opfmt", {}).get("status", "")
+ if "no literal" not in st:
+ lit = (ea_s, st)
+ break
+ check("the blob has an immediate to reformat", lit is not None,
+ json.dumps([ln.get("text") for ln in seen[:8]]))
+ if lit is not None:
+ tgt, st0 = lit
+ check("opfmt show reports the stops without editing",
+ "[" in st0 and "dec" in st0, st0)
+ r = c.call("opfmt", mode="dec", target=tgt, delay_ms=0)
+ st1 = r.get("opfmt", {}).get("status", "")
+ check("opfmt sets a named format", "dec" in st1, st1)
+ r = c.call("opfmt", mode="cycle")
+ st2 = r.get("opfmt", {}).get("status", "")
+ check("opfmt cycles on from there", "\u2192" in st2, st2)
+ r = c.call("opfmt", mode="default")
+ check("opfmt hands the operand back to IDA",
+ "default" in r.get("opfmt", {}).get("status", ""),
+ r.get("opfmt", {}).get("status", ""))
+ badfmt = None
+ try:
+ c.call("opfmt", mode="roman")
+ except RpcError as e:
+ badfmt = str(e)
+ check("opfmt rejects an unknown mode", badfmt is not None
+ and "unknown opfmt mode" in badfmt, str(badfmt))
+
# -- rename_many -------------------------------------------- #
fns = c.call("functions", limit=200)
ea = min(f["ea"] for f in fns) if fns else None
diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py
index 9d77680..80fdc8d 100644
--- a/tests/test_scenarios.py
+++ b/tests/test_scenarios.py
@@ -31,6 +31,7 @@ from idatui.app import ( # noqa: E402
HelpScreen, ListingView, QuitScreen, StringsPalette, StructEditor,
SymbolPalette, XrefsScreen, _str_display, _word_occurrences,
)
+from idatui.errors import IDAToolError # noqa: E402
from textual.widgets import ( # noqa: E402
DataTable, Input, OptionList, Static, TextArea,
)
@@ -2300,6 +2301,469 @@ async def s_func_banners(c: Ctx):
c.check("the proc header names the function", hdr is not None, f"fn={fn.name}")
+def _find_literal(c, start=0, limit=150):
+ """(head, show) for a listing row at/after ``start`` whose literal is worth
+ reformatting: a value over 9, so hex and decimal actually LOOK different.
+
+ Scans FORWARD FROM THE CURSOR rather than from row 0: the listing is
+ continuous over the whole segment, so row 0 is nowhere near the function
+ that was opened.
+ """
+ for i in range(start, min(start + limit, len(c.lst.model))):
+ h = c.lst.model.get(i)
+ if h is None or h.kind != "code":
+ continue
+ try:
+ show = c.prog.op_format(h.ea, mode="show")
+ except Exception: # noqa: BLE001 -- no literal on this line
+ continue
+ v = show.get("value")
+ if v and int(v, 16) > 9 and {"hex", "dec"} <= set(show.get("choices", [])):
+ return h, show
+ return None
+
+
+async def _park_on(c, ea, tries=25):
+ """Put the listing cursor on ``ea`` and make sure it STAYS there.
+
+ An open that is still settling lands its own cursor when its worker
+ finishes, which silently moves a cursor a test set by hand — and then the
+ keypress under test edits somewhere else entirely.
+ """
+ held = 0
+ for _ in range(tries):
+ if c.lst._cursor_ea() == ea:
+ held += 1
+ if held >= 3:
+ return True
+ else:
+ held = 0
+ i = c.lst.model.index_of_ea(ea)
+ if i >= 0:
+ c.lst.cursor = i
+ c.lst.cursor_x = c.lst._insn_col(i)
+ await c.pause(0.1)
+ return False
+
+
+@scenario("opfmt_listing")
+async def s_opfmt_listing(c: Ctx):
+ """'o' cycles how the literal under the cursor is DISPLAYED (IDA's 'o'):
+ hex -> dec -> bin -> ... -> default, 'O' the other way, and the listing
+ re-renders in place."""
+ app = c.app
+ await c.open_biggest("listing")
+ c.lst.model.load_all()
+ found = _find_literal(c, start=c.lst.cursor)
+ if found is None:
+ c.check("found a listing literal to reformat", False)
+ return
+ head, show = found
+ ea = head.ea
+ c.lst.focus()
+ parked = await _park_on(c, ea)
+ c.check("the cursor is on the literal's line", parked,
+ f"want {ea:#x}, cursor at {c.lst._cursor_ea():#x}")
+ before = head.text
+ try:
+ await c.press("o")
+ await c.wait(lambda: c.lst.model.index_of_ea(ea) >= 0
+ and (c.lst.model.get(c.lst.model.index_of_ea(ea)) or head).text
+ != before, 25)
+ i = c.lst.model.index_of_ea(ea)
+ after = c.lst.model.get(i).text if i >= 0 else before
+ c.check("'o' re-renders the literal", after != before,
+ f"{before!r} -> {after!r} ea={ea:#x} show={show}")
+ c.check("the status names the format it moved to",
+ any(f in c.status() for f in show["choices"]),
+ f"status={c.status()!r} choices={show['choices']}")
+ c.check("the change is marked unsaved", app._dirty)
+ # 'O' walks the ring the other way: back to where we started.
+ await c.press("O")
+ await c.wait(lambda: c.lst.model.index_of_ea(ea) >= 0
+ and (c.lst.model.get(c.lst.model.index_of_ea(ea)) or head).text
+ == before, 25)
+ j = c.lst.model.index_of_ea(ea)
+ c.check("'O' cycles back", j >= 0 and c.lst.model.get(j).text == before,
+ f"{c.lst.model.get(j).text if j >= 0 else None!r} want {before!r}")
+ # An explicit format by name (what the palette/RPC use).
+ r = c.prog.op_format(ea, mode="dec")
+ c.check("an explicit format renders decimal",
+ r["format"] == "dec" and str(int(show["value"], 16)) in r["text"],
+ str(r))
+ finally:
+ try:
+ c.prog.op_format(ea, mode="default", n=show.get("n", -1))
+ except Exception: # noqa: BLE001
+ pass
+ c.prog.bump_names()
+
+
+@scenario("opfmt_no_literal")
+async def s_opfmt_no_literal(c: Ctx):
+ """A line with nothing to reformat says so instead of picking something."""
+ await c.open_biggest("listing")
+ c.lst.model.load_all()
+
+ def _banner(i):
+ h = c.lst.model.get(i)
+ return h is not None and h.kind in ("sep", "funchdr", "label")
+
+ row = next((i for i in range(c.lst.cursor, min(c.lst.cursor + 400,
+ len(c.lst.model)))
+ if _banner(i)), None)
+ if row is None:
+ c.check("found a banner row", False)
+ return
+ c.lst.focus()
+ for _ in range(20): # hold it against a late-landing open
+ c.lst.cursor = row
+ await c.pause(0.05)
+ if c.lst.cursor == row:
+ break
+ c.check("the cursor is on a banner row", _banner(c.lst.cursor),
+ f"row={c.lst.cursor}")
+ await c.press("o")
+ await c.wait(lambda: "reformat" in c.status() or "format" in c.status(), 15)
+ c.check("'o' on a line with no literal explains itself",
+ "reformat" in c.status(), f"status={c.status()!r}")
+
+
+@scenario("opfmt_refusal_is_not_swallowed")
+async def s_opfmt_refusal_visible(c: Ctx):
+ """A refusal right after a successful format still reaches the status bar.
+
+ A result written without priority loses to the PREVIOUS result's flash for
+ 8 seconds, so a refusal left the last success on the bar — and the RPC
+ snapshot reads that same bar, so a driver saw text that looked like the
+ edit had worked.
+
+ Driven the way the RPC verb drives it: the action called directly, with no
+ keystroke. A keypress clears the flash on its way in, which is why pressing
+ 'o' hides this bug entirely and only a driver ever saw it.
+ """
+ await c.open_biggest("listing")
+ c.lst.model.load_all()
+ found = _find_literal(c, start=c.lst.cursor)
+ if found is None:
+ c.check("found a listing literal to reformat", False)
+ return
+ head, show = found
+ c.lst.focus()
+ if not await _park_on(c, head.ea):
+ c.check("the cursor is on the literal's line", False)
+ return
+ try:
+ await c.press("o") # a success: sets the flash
+ await c.wait(lambda: "\u2192" in c.status(), 25)
+ good = c.status()
+
+ # ... and, within the flash window, 'o' where there is nothing to do.
+ # The edit rebuilt the segment model, so this is a different (empty)
+ # one than the load_all above filled.
+ c.lst.model.load_all()
+
+ def _banner(i):
+ h = c.lst.model.get(i)
+ return h is not None and h.kind in ("sep", "funchdr", "label")
+
+ row = next((i for i in range(c.lst.cursor,
+ min(c.lst.cursor + 400, len(c.lst.model)))
+ if _banner(i)), None)
+ if row is None:
+ c.check("found a banner row to refuse on", False)
+ return
+ for _ in range(20):
+ c.lst.cursor = row
+ await c.pause(0.05)
+ if c.lst.cursor == row:
+ break
+ c.lst.action_op_format("cycle") # no keypress: as the RPC does it
+ await c.wait(lambda: c.status() != good, 15)
+ c.check("a refusal replaces the previous success on the status bar",
+ c.status() != good and "reformat" in c.status(),
+ f"still showing {c.status()!r}")
+ finally:
+ try:
+ c.prog.op_format(head.ea, mode="default", n=show.get("n", -1))
+ except Exception: # noqa: BLE001
+ pass
+ c.prog.bump_names()
+
+
+def _styled_cols(strip, style_attr, want):
+ """Columns of ``strip`` whose rendered style has ``style_attr`` == want."""
+ cols, x = [], 0
+ for seg in strip:
+ st = seg.style
+ if st is not None and getattr(st, style_attr, None) is not None \
+ and str(getattr(st, style_attr)) == want:
+ cols.extend(range(x, x + len(seg.text)))
+ x += len(seg.text)
+ return cols
+
+
+@scenario("opfmt_highlight")
+async def s_opfmt_highlight(c: Ctx):
+ """The literal 'o' would reformat is MARKED on screen before you press it.
+
+ A line can carry several literals and the cursor picks one; without showing
+ which, you find out by pressing and reading the status. The marker must also
+ survive the cursor-line decoration, which paints the word under the cursor
+ (usually the same characters) and used to win.
+ """
+ from idatui.app import _S_OPERAND
+ await c.open_biggest("listing")
+ c.lst.model.load_all()
+ lst = c.lst
+ lst.focus()
+ # A row with two operands, so "which one" is a real question.
+ row = next((i for i in range(lst.cursor, min(lst.cursor + 400, len(lst.model)))
+ if lst.model.get(i) is not None
+ and (lst.model.get(i).ops or ()) and len(lst.model.get(i).ops) >= 2),
+ None)
+ if row is None:
+ c.check("found a row with two operands", False)
+ return
+ h = lst.model.get(row)
+ if not await _park_on(c, h.ea):
+ c.check("parked on the two-operand row", False)
+ return
+ row = lst.model.index_of_ea(h.ea)
+ h = lst.model.get(row)
+ base = lst._insn_col(row)
+ want_bg = str(_S_OPERAND.bgcolor)
+ seen = []
+ for lo, hi, n in h.ops:
+ lst.cursor_x = base + lo
+ await c.pause(0.05)
+ strip = lst.render_line(row - round(lst.scroll_offset.y))
+ cols = _styled_cols(strip, "bgcolor", want_bg)
+ seen.append((n, min(cols) if cols else None, max(cols) + 1 if cols else None))
+ c.check(f"operand {n} ({h.text[lo:hi]!r}) is marked when the cursor is on it",
+ cols and min(cols) == base + lo and max(cols) + 1 == base + hi,
+ f"marked={min(cols) if cols else None}.."
+ f"{max(cols)+1 if cols else None} want={base+lo}..{base+hi}")
+ c.check("the mark MOVES between the operands (it isn't the whole line)",
+ len({s[1] for s in seen}) == len(seen), str(seen))
+ # ... and the marked operand is the one the edit acts on — either it gets
+ # reformatted, or the refusal names that same operand. What must never
+ # happen is a different operand quietly changing.
+ for lo, hi, n in h.ops:
+ lst.cursor_x = base + lo
+ try:
+ r = c.prog.op_format(h.ea, mode="show", col=lst.op_col())
+ got, why = r.get("n"), ""
+ except IDAToolError as e: # "operand N (rsp) has no format"
+ m = re.search(r"operand (\d+)", e.message)
+ got, why = (int(m.group(1)) if m else None), e.message
+ c.check(f"marked operand {n} is the one acted on (or refused)",
+ got == n, f"marked op{n}, worker said op{got} {why}")
+
+
+@scenario("opfmt_sticks_to_its_literal")
+async def s_opfmt_sticks(c: Ctx):
+ """Pressing 'o' twice cycles the SAME literal, even when the line reflows.
+
+ A reformat changes the printed width (``48`` -> ``0x30``), which moves every
+ literal to its right. Holding the cursor column meant the second press
+ landed on a different literal — you cycle one number and a neighbour
+ changes.
+ """
+ app = c.app
+ pick = None
+ for fn in c.all_funcs()[:60]:
+ d = c.prog.decompile(fn.addr)
+ if d.failed or not d.code:
+ continue
+ nums = c.prog.pc_nums(fn.addr)
+ for line, recs in sorted(nums.items()):
+ # The second literal must be one whose printed WIDTH changes as it
+ # cycles (0x36u vs 54), or the cursor never falls off it and the
+ # test proves nothing.
+ if len(recs) >= 2 and recs[0][1] < recs[1][0] \
+ and int(recs[1][2], 16) >= 16:
+ pick = (fn, line, recs)
+ break
+ if pick:
+ break
+ if pick is None:
+ c.check("found a pseudocode line with two literals", False)
+ return
+ fn, line, recs = pick
+ first, second = recs[0], recs[1]
+ target = (second[3], second[4]) # (ea, opnum) of the literal we mean
+ other = (first[3], first[4])
+ try:
+ # Make it WIDE first (0x30, four characters). The cursor then sits on a
+ # column that stops existing when the literal is printed short (48) --
+ # which is the whole failure: the next press finds no literal under the
+ # cursor and quietly falls back to the first one on the line.
+ c.prog.pc_num_format(fn.addr, mode="hex", line=line, col=second[0])
+ c.prog.bump_names()
+ await c.open(fn.addr, "decomp")
+ if app._active != "decomp":
+ c.check("pseudocode view opened", False, f"active={app._active}")
+ return
+ dec = c.dec
+ dec.focus()
+ wide = next((r for r in dec._nums.get(line, ())
+ if (r[3], r[4]) == target), None)
+ if wide is None or wide[1] - wide[0] < 3:
+ c.check("the literal is now printed wide", False,
+ f"nums={dec._nums.get(line)}")
+ return
+ dec.cursor, dec.cursor_x = line, wide[1] - 1 # its LAST character
+ dec.refresh()
+ await c.pause(0.05)
+ seen = []
+ for _ in range(3):
+ before = dec._texts[line]
+ await c.press("o")
+ await c.wait(lambda: dec.loaded_ea == fn.addr
+ and line < len(dec._texts)
+ and dec._texts[line] != before, 30)
+ cur = next(((r[3], r[4]) for r in dec._nums.get(line, ())
+ if r[0] <= dec.cursor_x < r[1]), None)
+ seen.append(cur)
+ c.check("every press stays on the literal we started on",
+ all(s == target for s in seen),
+ f"target={target} other={other} landed={seen} "
+ f"line={dec._texts[line].strip()!r}")
+ finally:
+ try:
+ c.prog.pc_num_format(fn.addr, mode="default", line=line,
+ col=second[0])
+ except Exception: # noqa: BLE001
+ pass
+ c.prog.bump_names()
+
+
+@scenario("cursor_on_stays_visible")
+async def s_cursor_on_visible(c: Ctx):
+ """``cursor_on`` lands somewhere you can SEE, near where you are.
+
+ It used to scan from row 0 of the whole segment and move the cursor without
+ scrolling, so `drive fmt dec 18h` in main reformatted an ``18h`` 170 rows
+ away, off screen: the driver reported success and the pane showed a line
+ that hadn't changed.
+ """
+ from idatui.rpc import cursor_on
+ app = c.app
+ fn = await c.open_biggest("listing")
+ c.lst.model.load_all()
+ lst = c.lst
+ lst.focus()
+ await c.pause(0.1)
+ top = round(lst.scroll_offset.y)
+ # A token that occurs both before the viewport and inside it.
+ here = next((t for t in ("rax", "rsp", "eax", "rbp", "rdi")
+ if any(t in (lst._line_plain(i) or "")
+ for i in range(top, min(top + 20, lst.total)))
+ and any(t in (lst._line_plain(i) or "") for i in range(0, top))),
+ None)
+ if here is None:
+ c.check("found a token both above and inside the viewport", False,
+ f"top={top}")
+ return
+ found = cursor_on(app, here)
+ await c.pause(0.1)
+ c.check(f"cursor_on({here!r}) found it", found)
+ vis = round(lst.scroll_offset.y)
+ c.check("it lands inside the viewport, not thousands of rows above",
+ vis <= lst.cursor < vis + lst._visible_height(),
+ f"cursor={lst.cursor} viewport={vis}..{vis + lst._visible_height()}")
+ c.check("and it searched from the viewport, not from row 0",
+ lst.cursor >= top, f"cursor={lst.cursor} was top={top}")
+ # An explicit line still wins, and lands visibly.
+ far = next((i for i in range(0, min(top, lst.total))
+ if here in (lst._line_plain(i) or "")), None)
+ if far is not None:
+ cursor_on(app, here, line=far)
+ await c.pause(0.1)
+ v2 = round(lst.scroll_offset.y)
+ c.check("an explicit line is honoured AND scrolled into view",
+ lst.cursor == far and v2 <= far < v2 + lst._visible_height(),
+ f"cursor={lst.cursor} want={far} viewport={v2}")
+ # The `cursor` verb has the same duty: a driver that parks the cursor for
+ # an edit must leave it where the edit can be watched.
+ from idatui.rpc import place_cursor
+ deep = min(lst.total - 1, 900)
+ place_cursor(lst, deep, 0)
+ await c.pause(0.1)
+ v3 = round(lst.scroll_offset.y)
+ c.check("`cursor line=` scrolls to what it selected",
+ v3 <= deep < v3 + lst._visible_height(),
+ f"cursor={lst.cursor} viewport={v3}..{v3 + lst._visible_height()}")
+
+
+@scenario("opfmt_decomp")
+async def s_opfmt_decomp(c: Ctx):
+ """'o' in the pseudocode reformats the C literal under the cursor. Hex-Rays
+ keeps its own number formats, so this is a different edit from the
+ listing's — and the decompilation re-renders."""
+ app = c.app
+ pick = None
+ for fn in c.all_funcs()[:60]:
+ d = c.prog.decompile(fn.addr)
+ if d.failed or not d.code:
+ continue
+ for i, txt in enumerate(d.code.split("\n")):
+ m = re.search(r"[=<>+\-*/(,]\s(\d{2,}|0x[0-9A-Fa-f]{2,})\b", txt)
+ if m and "//" not in txt[:m.start()]:
+ pick = (fn, i, m.start(1))
+ break
+ if pick:
+ break
+ if pick is None:
+ c.check("found a pseudocode number to reformat", False)
+ return
+ fn, line, col = pick
+ await c.open(fn.addr, "decomp")
+ if app._active != "decomp":
+ c.check("pseudocode view opened", False, f"active={app._active}")
+ return
+ dec = c.dec
+ dec.focus()
+ dec.cursor, dec.cursor_x = line, col
+ dec.refresh()
+ await c.pause(0.05)
+ before = dec._texts[line]
+ try:
+ await c.press("o")
+ await c.wait(lambda: dec.loaded_ea == fn.addr and line < len(dec._texts)
+ and dec._texts[line] != before, 30)
+ c.check("'o' re-renders the pseudocode literal",
+ line < len(dec._texts) and dec._texts[line] != before,
+ f"{before!r} -> {dec._texts[line] if line < len(dec._texts) else None!r}")
+ c.check("the status says which format",
+ any(f in c.status() for f in ("hex", "dec", "oct", "char", "default")),
+ f"status={c.status()!r}")
+ finally:
+ try:
+ c.prog.pc_num_format(fn.addr, mode="default", line=line, col=col)
+ except Exception: # noqa: BLE001
+ pass
+ c.prog.bump_names()
+
+
+@scenario("opfmt_opcode_key_moved")
+async def s_opfmt_opcode_key_moved(c: Ctx):
+ """'o' now belongs to the operand format, so the opcode-bytes column moved
+ to 'B' — and still cycles off/limited/full."""
+ await c.open_biggest("listing")
+ lst = c.lst
+ lst.focus()
+ await c.pause(0.05)
+ modes = [lst._op_mode]
+ for _ in range(3):
+ await c.press("B")
+ await c.pause(0.05)
+ modes.append(lst._op_mode)
+ c.check("'B' cycles the opcode-bytes column", len(set(modes)) == 3, str(modes))
+ c.check("and returns to where it started", modes[0] == modes[3], str(modes))
+
+
# --------------------------------------------------------------------------- #
# Runner
# --------------------------------------------------------------------------- #