| Commit message (Collapse) | Author | Age | Files | Lines |
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The listing showed the mnemonic bright and every operand in one body colour.
IDA already classifies each token, for every processor it supports:
generate_disasm_line() emits \x01<tag>text\x02<tag> and the tag says what the
text IS. We were calling tag_remove() and throwing that away.
So: no lexer. A pygments asm lexer would be a worse guess and would need one
dialect per architecture — this is arch-correct for free, including the ARM/MIPS
blobs the loader work just made openable.
lea rcx, function; "usage"
insn reg punct name cmt
_idatui_spans() parses the tags into [[kind, text], ...], heads rows carry
"spans", Head.spans holds them, and _span_segments() renders them with a
fallback to the old mnemonic/rest split for older workers.
Palette rule: NEUTRALS for the machine (mnemonic brightest — it's the column you
scan; registers at body weight because they're most of the text), HUES only where
they mean something (numbers, strings, symbols), structure recedes so commas and
brackets stop competing with operands.
Two things that fail SILENTLY and are now encoded:
* The constants are SCOLOR_DATNAME / SCOLOR_CODNAME. There is no SCOLOR_DNAME —
a wrong guess leaves the tag unmapped, symbols render as plain body text, and
nothing tells you why. Probed the live IDA to get the real names.
* Spans must be whitespace-collapsed exactly as `text` is, walking characters
rather than per span, because a run of IDA's column padding straddles span
boundaries. A row only gets spans when they reconstruct `text` exactly, so a
mismatch degrades to the old rendering instead of corrupting the line.
The reason this was parked yesterday was NOT a bug in it. listing_view's
"undefining a data head yields an unknown run" waits for
`index_of_ea(dea) >= 0` — but dea is the head it just undefined, so it is in the
OLD model too and the predicate passes instantly, asserting against pre-edit
rows. It only ever passed because the model swap won the race; spans made pages
3x bigger, the swap lost, and the check accused working code. It now waits for
the model to be REPLACED.
Cost measured on libcrypto: 95KB per 500-row page, 50ms; model ensure(2000)
228ms. Acceptable for what it buys.
tests: new asm_highlight scenario (+7) — >90% of code rows carry spans, insn/reg/
punct present, every span kind has a style, spans reconstruct the row text
exactly, mnemonic is the first span. 202/0 scenarios, 26/0 blob, 30/0 project UI.
TODO: DisasmView appears to be dead code (never instantiated; Ctx.dis returns
ListingView), which is why this only needed doing once.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Two bugs in two days had the same shape — an edit rebuilds the model and
whatever ran last wins. The status message got clobbered by the reload's own
status write; the scroll position got recomputed from a row index that no longer
meant the same thing. Both were patched by hand. A third was coming.
ViewAnchor makes it one thing: where you are looking, in ADDRESSES, plus the
message the rebuild must not eat. _anchor() captures it on the UI thread before
the edit; _anchor_rows() resolves it against the rebuilt model; _edit_done()
settles the aftermath. The edit paths (_do_edit_item, _do_make_data) now hand one
object through instead of threading positions and messages separately.
Addresses, not indices, because an edit can change how many rows an item takes:
four undefined byte rows collapse into one instruction row, undefining does the
reverse. An index means a different place afterwards.
_reload_active_code deliberately does NOT use it. Renames and comments don't
change row structure, and the model that path rebuilds is constructed empty —
index_of_ea returns -1 until pages load, so an anchor would resolve to nothing
while costing an extra model build on the UI thread. Wrote that out and reverted
it rather than leave an abstraction applied where it does nothing.
Also fixed in passing: _do_make_data had the same latent bug (no scroll
preservation at all) and now goes through the same path.
The bigger find is in TODO. The scenario suite mutates targets/echo.i64 and
SAVES it, so a scenario that undefines an instruction breaks later runs
permanently — decomp_follow_self had been failing on a polluted database, not on
any code change. It also made an edit-position check look flaky one run in
three, which I nearly wrote up as a race. Coverage for this lives in
test_blob_ui.py instead, which builds a throwaway binary and can mutate freely.
tests: +1 blob UI (commenting leaves the view where it was), alongside the
carve checks. 26/0 blob, 202/0 scenarios (on a fresh .i64), 30/0 project UI.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Pressing `c` jumped the scroll position. Defining an item reloads the view, and
the reload only carried the cursor's row index — the viewport was recomputed
from scratch, so you landed somewhere else and lost your place mid-carve.
Row indices are the wrong thing to remember across this reload anyway: carving
COLLAPSES rows (four undefined byte rows become one instruction row), so the row
that was at the top is a different address afterwards. The anchor has to be the
top visible ADDRESS, resolved back to a row after the model is rebuilt.
on_edit_item_requested captures it before the edit, _do_edit_item resolves it
against the new model, and _open_at grew a scroll_y so the entry can carry it.
Verified: cursor at 0x4800 with the top of the screen at 0x47da, press `c`, and
both are unchanged afterwards.
tests: +3 blob UI (scrolled far enough to have something to lose, top address
unchanged, cursor address unchanged). 25/0 blob, 202/0 scenarios.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
One instruction per keypress means pressing `c` once per opcode for the length
of a routine, which on a raw image is the whole job. IDA's `c` runs; ours now
does too.
New define_code_run tool: create instructions consecutively and report why it
stopped — 'undecodable' (bytes aren't an instruction), 'flow' (control flow ends
here), 'defined' (ran into existing code/data), 'segment' or 'limit'. It loops
inside the worker; from the client this would be one round trip per instruction,
minutes on a real image.
Stops AT a ret rather than past it: beyond the end of a routine the bytes are
usually padding or data, and running on turns a clean carve into something you
have to undo by hand. Stopping at already-defined items is the same principle —
undefining someone's existing work to keep a speculative run going isn't a trade
the user asked for.
The ret test is ida_idp.is_ret_insn, NOT canonical features: on AArch64
insn.get_canon_feature() returns 0 for RET, so a CF_STOP check silently never
fires and the run walks straight through the end of the function. Verified
against a live IDA before relying on it.
`c` on something already defined now says "already defined @ addr" instead of
claiming the instruction failed to be created — count==0 from a run means two
very different things.
Also: the result message survives the reload. Defining an item rebuilds the view,
and the reload's own cursor handler had the last word, so every edit reported
itself as "ROM @ 0x4040 [listing]". A one-shot _flash is handed to whichever
status write lands first after the edit. (Third time this clobber pattern has
turned up: split view, the no-functions hint, now this.)
Verified: nop/nop/nop/ret at 0x4040 -> "defined 4 instructions (0x4040–0x4050) —
control flow ends here", with 0x4050 left as an undefined byte. Starting on
existing code -> no-op. Random bytes -> stops at the first that won't decode.
tests: +4 blob UI (runs to the end of flow, stops at the ret, doesn't touch the
junk after it, and the status reports it). 22/0 blob, 202/0 scenarios, 30/0
project UI.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Loading a blob and pressing `c` at 0 disassembles one instruction; everything
after it collapsed into a single row — "db 2044 dup(?)" — with no cursor
position anywhere inside it. There was no way to start a second instruction
stream at an arbitrary offset, which is most of what carving a firmware image
IS. In IDA every undefined byte is its own line and you just put the cursor on
one.
The collapse existed for a real reason (see the comment in the heads tool): a
.bss or a fresh blob would otherwise be millions of one-byte rows, and this
model materialises what it walks. Expanding physically would also make
`g <far address>` walk every byte in between.
So the run stays ONE physical head and PRESENTS as N logical rows. _row_at is a
prefix sum over heads, _phys() maps a row back to (head, byte offset), and the
text for an interior row is synthesised on demand — "db 4Ah", the actual value,
because the byte values are the whole point when you're looking for a stream.
Memory is unchanged (libcrypto: 1 head for its 80-byte .bss, 61MB RSS), and
index_of_ea into the middle of a run is 0.01ms via bisect.
Now: cursor on any byte, `c`, and you get an instruction; the bytes before it
stay individually addressable.
Two bugs found on the way:
* IDAToolError takes (tool, message) and five call sites in domain.py passed one
string. Every one of those error paths raised TypeError INSTEAD of the real
error — "define code @ 0x4020: Failed to create instruction" reached the user
as "IDAToolError.__init__() missing 1 required positional argument". Fixed all
five; the message that finally came through is what identified the next issue.
* Searching now walks one row per undefined byte, so _index_for_search is capped
at 400k lines and says when it truncated, rather than grinding through a
multi-megabyte blob nobody wants to text-search.
tests: test_blob_ui.py +8 — a run presents one row per byte, each is a single
addressable byte showing its value, an interior address resolves to its own row,
`c` on a chosen byte carves there, the carved row spans the instruction, and
neighbouring bytes stay addressable. Uses PLANTED A64 instructions, because
whether random bytes decode is chance and a test that depends on chance is
worthless on the run where it fails. 19/0 blob, 202/0 scenarios, 30/0 project UI.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Pick a random .bin, say ARM at 0x4000, and you got two empty panes and
"functions still loading…" — which was a lie; loading had finished. _auto_land
falls back to the symbol picker when there's no entry function, and the picker
answers an empty index with that message. Nothing ever opened.
Zero functions is not a corner case. It's exactly what a real firmware image
looks like when it's described wrongly, and IDA has no complaint of its own to
make about it, so this was the last silent-wrong-answer in the blob path.
Now:
* Land in the listing at the start of the image. The bytes exist even when no
code was recognised, so there is always something to show.
* Say so, in the status bar, for as long as it stays true — an image with no
functions is a property of the database, not an event, and writing it once
meant the next status write erased it (the same clobber that bit the split
view's "decompiling…").
* Ctrl+L re-asks. The .i64 has the old processor and base baked in and takes
precedence over any switches, so reloading means deleting it; the confirmation
says what that costs, and when there are no functions it says nothing is lost.
Verified with real keys on a random 64K blob: ARM @ 0x4000 lands showing
"db 65536 dup(?)" with the hint in the status; Ctrl+L -> confirm -> dialog ->
metapc reloads at 0. The hint survives scrolling.
tests: new tests/test_blob_ui.py (11) driving a real random blob end to end —
lands, has rows, right base, honest status, hint survives navigation, Ctrl+L
offers the reload and declining leaves the binary open. 202/0 scenarios, 30/0
project UI.
(Harness note for future me: tmux send-keys reads "0x4000" as a hex KEY CODE and
sends U+4000. Use send-keys -l for literal text.)
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
bad answer
Three bugs, reported together, with one shared root: you couldn't get to the
address field, so the address went into the processor filter, so IDA got a
nonsense processor name and refused to open — and the app dead-ended with a
misleading error.
**Tab never reached any modal.** Binding("tab,shift+tab", "toggle_view",
priority=True) is an APP binding, and priority bindings run before the focus
chain. Nothing in any dialog in this app could ever be tabbed to; the load
dialog is just where it finally mattered. action_toggle_view now hands the key
back when a modal is up, which fixes it everywhere.
**...and DOM order was the wrong tab order anyway.** focus_next() stopped at the
processor list, which is arrow-driven and has nothing to type. LoadOptionsScreen
overrides it to cycle the two fields you actually type into.
**...and the dialog outgrew the terminal.** With the palette's default
max-height the 21-row list pushed the address field and help line off the bottom
of the screen. Nothing errors — the field simply isn't there, which reads as
"Tab does nothing". Capped per-dialog.
**Project mode never asked.** _should_ask_load_options bailed on
`self._project is not None` with the comment "project mode carries per-binary
options already" — true only if someone had already filled them in. A raw blob
added to a project got the silent x86-at-0 treatment the dialog exists to
prevent. Now asked at boot AND on switching to an undescribed binary, and the
answer is written back to the project entry (Project.set_load), so it is asked
once per binary, not once per run.
**A rejected answer dead-ended.** Getting a processor wrong is an ordinary
mistake; it left an empty app with "connect failed: worker exited (code 1)" and
a message blaming a locked .i64. The worker now names the real suspect when load
switches were in play, and the app re-opens the dialog instead of giving up.
Verified with real keys in a tmux pane, which is the only way any of this shows
up: Tab -> address field -> 0x8000000 -> Enter -> 35 functions at 0x80039AC; a
bogus processor -> "those load options were rejected — try again" with the
dialog back; project mode -> asks, loads at the right base, and the answer is in
the project file.
tests: +3 scenarios (Tab moves focus under a modal, lands on the address field,
cycles back). 202/0 scenarios, 39/0 project, 32/0 formats, 30/0 project UI.
docs/TEXTUAL_NOTES.md gets the priority-binding and clipped-modal traps.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The curated list was written from the procs/ directory listing. Two of the
twenty names were wrong, and wrong here is not a soft failure: IDA REFUSES to
open the database (rc=4) with nothing useful said. The load dialog would have
handed people a dead end from inside the UI that exists to rescue them — the
same silent-failure class the dialog was built to kill.
h8 -> h8300 (h8.so is the module FILENAME, not a processor name)
sparc -> sparcb / sparcl (and SPARC has endianness variants, like MIPS/PPC)
Also probed the aliases people reach for first: arm64, aarch64, mips, m68k are
all invalid. 'arm' covers AArch64 (verified: an AArch64 blob analyses to 35
functions under -parm), so those names now live in the human labels, where the
filter still finds them — typing "arm64" finds ARM, "m68k" finds 68k, "mips"
finds both endiannesses.
tools/verify_procs.py does the check: open a scratch blob with -p<name>, read
back inf_get_procname(), compare. Fresh temp dir per name, because once a
database exists IDA ignores the load switches and every name after the first
would "pass". 21/21 verified.
tests: +11 formats, including the verified-set guard (adding a processor without
re-running the script fails on purpose) and checks that the rejected aliases are
NOT offered but ARE still findable by typing them. 32/0 formats, 199/0 scenarios.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Last commit let you SAY how to load a blob. This one notices when you should
have. Interactive IDA pops a dialog when no loader matches; we silently loaded
as x86 at 0 and analysed to nothing, so the flag only helped people who already
knew they needed it — which is exactly the people who don't need help.
formats.sniff() recognises the formats IDA definitely handles (ELF, PE, Mach-O,
dex, wasm, ar, COFF, Intel HEX, S-records). Anything else gets
LoadOptionsScreen: a filterable processor list with human labels, a load-address
field, Enter to accept, Esc to load it the way IDA would have anyway.
Deliberate asymmetry: the sniff only claims formats it is sure about. A false
"unknown" costs one dismissible dialog; a false "known" is the silent wrong
answer this exists to kill. Esc is always an escape hatch.
The list offers 20 processors, not IDA's 73 — most of the rest are museum
pieces, and a name typed into the filter that matches nothing is taken literally
so nothing is actually unreachable. Endianness is spelled out (arm vs armb)
because getting it backwards is the most common route to zero functions.
Asked only when nobody has answered yet: not with --processor, not in project
mode (entries carry their own), and not when a database exists — the .i64
already records how the image was loaded.
Textual trap worth recording: the screen stored the file size in self._size,
which is Widget's own backing field for outer_size. Assigning an int to it
crashes layout with "'int' object has no attribute 'region'" from deep inside
_set_dirty, nowhere near the cause. Same family as the _render collision.
The paragraph conversion now lives in exactly one place (formats.load_args);
BinaryRef and launch both call it.
Verified on a real AArch64 blob: dialog appears, filtering to "arm" leaves two
entries, base 0x8000000 accepted -> "-parm -b800000" -> 35 functions at
0x8002440. An ELF never asks, and neither does a blob that already has a .i64.
tests: new tests/test_formats.py (21) and a load_options scenario asserting the
dialog stays out of the way for a recognised binary. 199/0 scenarios, 39/0
project, 30/0 project UI, 36/0 index, 27/0 pool, 21/0 formats.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A raw firmware dump has no format to detect, so IDA fell back to x86 at address
0. It doesn't fail — it opens, analyses, and finds nothing. An AArch64 image
loaded this way gave 0 functions; told the truth it gives 35.
ida-tui fw.bin --processor arm --base 0x8000000
and per binary in a project, which is what a multi-image firmware actually
needs:
{"path": "app.bin", "processor": "arm", "base": "0x8000000"}
idapro.open_database() already accepted IDA command-line switches; nothing was
passing any. Plumbed BinaryRef -> WorkerPool -> WorkerClient -> worker argv, plus
a load_args for the single-binary path that has no project ref.
base is written the way people say it (0x8000000, int or string, any base).
IDA's -b is in PARAGRAPHS — -b1000 loads at 0x10000 — so BinaryRef.load_args
converts, and a base that isn't 16-byte aligned is refused rather than silently
landing 16x off. ida_args passes anything else through.
Two bugs found by testing the whole path rather than the happy one:
* Project.load() whitelisted path/label when normalising entries, so the load
options were dropped the first time a project was reopened — set a processor,
come back tomorrow, it's gone.
* Re-passing the switches to an EXISTING database makes IDA refuse the open
(rc != 0, no functions). The .i64 already records how the image was loaded, so
the worker skips them once a database exists. My first guard checked
splitext(path) + ".i64" and never fired, because IDA names it "<file>.i64" —
keeping the extension. It checks both spellings now.
Verified on a real AArch64 blob: fresh load 35 functions based at 0x8002440,
reopen 35 again, and the CLI rejects an unaligned or non-numeric --base.
tests: +6 project (options recorded, paragraph conversion, file round-trip,
add() takes them, an ELF passes nothing, hex-string base). 39/0 project, 195/0
scenarios, 30/0 project UI, 36/0 index, 27/0 pool.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
xrefs_to only ever sees the current database, so an exported function looks
unused from the inside even when the rest of the project calls it. The import
side of the phase-3 linkage index already knew better; nothing surfaced it.
_foreign_importers appends those callers to the xrefs dialog. From libc's
strrchr, with echo in the project:
0000C318 import [echo] strrchr
Read from the on-disk index, so a caller appears whether or not its worker is
resident. Choosing one carries a (binary, addr) payload instead of a bare
address; _on_xref_chosen routes that through _switch_then_goto — the same path a
project search hit takes — so it records a hop and Esc comes back.
Only fires for a symbol this binary actually EXPORTS. A local name that happens
to collide with some other binary's import is not a caller of ours, and without
that check every common name (main, read, error) would sprout fictional callers.
Names are compared after link_name(), so ELF versioning doesn't hide the match.
Verified on a real echo+libc project: the dialog lists echo's call site, and
selecting it switches to echo, lands on 0xc318 and leaves hops=['libc.so.6'].
tests: +3 project UI — a symbol we don't export gets no cross-binary callers,
the (binary, addr) payload jumps to the other binary, and it records the hop.
195/0 scenarios, 30/0 project UI.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Three things, all following from phase 3 making cross-binary jumps ordinary.
**A cross-binary jump was a one-way door.** Nav history is per-binary, so
arriving in another binary — a project search hit, or now following an import
into the library that implements it — landed you in an empty history with nothing
to take you back. _switch_then_goto records the binary it came FROM, and
action_back falls through to that hop once local history is spent: Esc walks back
through the function you were in, then the binary you were in. Manual Ctrl+O
switching records nothing, because that isn't navigation.
**Pre-warm follows the linkage graph, not list order.** _prewarm_provider warms
the binary providing the most of this one's imports — where a follow is most
likely to go, so its startup is paid before you ask for it. "Next in the list"
would have been arbitrary; phase 3 gave us something better to ask.
pool.prewarm() refuses rather than making room. Evicting a binary the user
visited to speculatively load one they haven't is a straight downgrade, and it
throws away that binary's caches as well; at a tight budget pre-warm just does
nothing. The cost of a worker that doesn't exist yet can only be estimated, so it
uses the largest resident one (same program, different database) — and if that
estimate proves wrong, the speculative worker is the one evicted, never a chosen
one.
**Driving a project.** pane spawn --project FILE [--open BIN]; `binaries` lists
the inventory (active / resident / indexed / where Esc returns to) and `switch
{binary,addr?}` makes another active — with an address it takes the search-hit
path, so it records a hop. state gains `binary` and `hops`, which it should have
had the moment project mode existed.
Verified on real sessions: drive binaries/switch against an echo+cat project
pane; Esc crossing back from a switch; and prewarm on echo+libc picking libc
(provider of echo's imports) and warming it after an evict.
tests: +5 pool (prewarm warms, no-ops when resident, refuses at budget, evicts
nothing when refusing, ignores unknown labels) and +4 project UI (jump records
the hop, Esc crosses back, hop consumed). Confirmed the Esc-back checks fail with
the branch removed. 195/0 scenarios, 27/0 project UI, 36/0 index, 27/0 pool,
33/0 project.
Left open: project-level persistence across sessions.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Following a call to strcmp reached the PLT/extern entry and stopped there —
Hex-Rays has nothing to decompile, because the code lives in a library this
binary only references. With the other binary open in the same project we
already had everything needed to cross that gap; we just weren't indexing it.
Index each binary's imports and exports (KIND_IMPORT / KIND_EXPORT) alongside its
functions and strings. On a follow, _import_stub asks whether the target address
is one of this binary's import stubs; if so _cross_binary_impl asks the index who
exports that name, and we switch there instead of landing on the thunk.
Verified end to end on a real echo + libc project: Enter on `strrchr(a1, 47)` in
echo's pseudocode switches to libc.so.6 and lands on strrchr at 0xaf960.
Three things it turns on:
* ELF symbol versioning. The importer sees strrchr@@GLIBC_2.2.5 while the
provider may export any of three spellings, so raw names resolve almost
nothing. domain.link_name() cuts at the first '@'; Linkage.raw keeps what IDA
reported, which is what the listing shows.
* Exact match, not substring — ProjectIndex.exact(), so `read` doesn't bind to
pread/read_line/thread_start. It also answers below the 3-char trigram floor,
and plenty of real exports are that short.
* Resolution reads the on-disk index, so a provider resolves while its worker is
evicted. That's what the index was for.
When nothing in the project provides the symbol _follow_import declines and the
normal navigation runs: landing on the stub is still the honest answer, and a
single-binary session is unchanged. The PLT-stub PRESENTATION item stays open —
an unprovided import should say "imported, provider not in project" rather than
show a decompiler error.
server/patch_server.py gains list_linkage (idautils.Entries + enum_import_names);
a worker without it degrades to no linkage rather than failing.
tests: index join +8 (exact vs substring, short names, exclude-self, reverse
join, kind isolation, forget unresolves) and link_name +4. 36/0 index, 195/0
scenarios, 23/0 project UI, 33/0 project, 22/0 pool.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The app carried three overlapping ideas of the current pane: _active, _pref, and
Textual focus. 190e28b tied focus to _active in split; this removes _pref, which
turns out never to have been a variable at all.
_pref was assigned "listing" in __init__ and "listing" on a project binary
switch. Nothing else ever wrote it. But _code_view() branched on it:
return self.query_one(DecompView if self._pref == "decomp" else ListingView)
so it always returned the listing, whatever you were reading. Its two callers put
focus back after the goto prompt closes — so cancelling `g` while in the
pseudocode focused the HIDDEN listing, and the pane you were looking at stopped
answering the keyboard. Arrows did nothing until you clicked. (It also explains
why routing follow through _code_view() earlier made Enter a dead key: the helper
had been quietly lying the whole time.)
_code_view() now returns the active code pane. _pref is gone from the app,
BinaryState and the RPC snapshot keeps "pref" for wire compat, sourced from
_code_mode() — the one place that answers "which code view do we return to from
hex", and a constant by design in the unified layout.
Verified on a live pane both ways: g then Esc in pseudocode, then two Downs.
Fixed, the cursor moves 0 -> 2; with the old lookup restored it sits at 0 with
focus=ListingView. Same check added to the view_toggle scenario, and it fails
without the fix. 195/0, project UI 23/23.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Walking back with Esc ended in a dead keypress: nav_depth dropped but the screen
didn't change. Measured on a live pane, four Escs after one follow:
Esc #1 -> sub_3500 L7 (where we followed from) good
Esc #2 -> main L54 decomp (where we followed from) good
Esc #3 -> main L344 listing good
Esc #4 -> main L344 listing -- nav 2 -> 1, nothing moved
The app auto-lands on main at startup, and opening main again from Ctrl+N
appended a second, identical entry. Every "navigate to where you already are"
did this; the extra Esc it bought is invisible except that it does nothing, which
is exactly what "back is broken" feels like from the keyboard.
Both push sites now go through _push_nav, which replaces the top entry instead of
appending when the target is the same (ea, view, line) — the newer entry's
metadata still wins. _same_spot compares dec_cursor for pseudocode and cursor for
the listing.
Note what is NOT a duplicate: the first follow after Tab-to-pseudocode still
pushes twice (nav 1 -> 3). That's deliberate — Tab leaves _cur transient and off
the stack, so the follow records the pseudocode position first, which is why Esc
#1 above returns to main L54 rather than dropping you into the listing. Verified
that subsequent follows push exactly one each.
tests: the palette scenario re-opens the function it is already on and asserts
nav depth is unchanged. Fails without the fix (nav 2 -> 3). 194/0.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Ctrl+N found nothing on libcrypto. Typing "PEM_read_bio" returned 0 of 10093
functions while the backend resolved the very same name to 0x1d6290.
_fuzzy lowercased the NAME but not the QUERY, then walked the query's characters
through the lowered name. One capital letter and the subsequence walk fails at
the first character, so the match is not merely worse — it is None, and the
palette shows nothing at all.
Invisible on the test binary because C symbols there are lowercase (main, strlen,
error) and every existing palette check typed a lowercase query. Fatal on any
library that capitalises: OpenSSL, most SDKs, Windows binaries. The paging index
was the obvious suspect and was innocent — all_loaded() had all 10093.
Verified on a live libcrypto pane: "PEM_read_bio" now returns 34 hits, exact
match ranked first.
tests: the palette scenario now types "MAIN" and expects "main". Confirmed it
fails without the fix (results=[]) and passes with it. 193/0.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`--project fw.json a.elf b.elf` where those are already listed appended them
again, so the project grew a second copy on every launch — each duplicate then
staged its own file, opened its own worker and got its own index entries.
Dedupe on the RESOLVED SOURCE PATH, which is the only identity that's actually
correct here: two different foo.elf from different directories are different
binaries and must both be accepted (the label disambiguator already gives them
foo.elf and foo.elf_2), while ./a.elf, /abs/a.elf and a symlink to it are all the
same file and must collapse to one entry.
* Project.by_source(path) — lookup by realpath.
* Project.add() returns the existing entry instead of appending a duplicate.
* Project.create() drops repeats on one command line too.
* launch.py reports what it did: "added N binary(ies)" / "N already in the
project (matched by path) — left alone", and only rewrites the file when
something actually changed.
Not deduped in _build_refs on load: remove() maps refs to entries by index, so
collapsing there would desync them, and a hand-edited duplicate still works
(labels disambiguate).
tests/test_project.py +8 checks: re-add is a no-op, so are a relative spelling,
a messy ../ path and a symlink; a same-named file from another directory IS
added and gets a distinct label; create() drops repeats. 33/33. Verified on the
real CLI: re-running the reported command leaves the project at 3 entries.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Five sections stacked vertically ran ~45 rows, so F1 scrolled on any terminal
under ~50 lines. Each section is now its own bordered card (title in the border),
and the cards flow into as many columns as the width allows.
Textual CSS has no media queries, so the column count is computed in compose from
the real app width. Sizing off the WIDEST section would let one long row (Move's
"Ctrl+Home / Ctrl+End") inflate every column and cost a column that would
otherwise fit, so _columns() measures the actual layout instead: chunk the
sections, sum the per-chunk maxima, take the most columns that fit. Keys are
right-aligned per card rather than globally, so a card of short keys stays narrow.
200x50 -> 3 cols 140x44 -> 2 cols 100x36 -> 1 col (scrolls)
160x44 -> 3 cols 120x40 -> 2 cols
The scroll container stays, so a very small terminal degrades to scrolling
instead of clipping — but at any normal size nothing scrolls. The box hugs its
content (width:auto all the way down; VerticalScroll needed width:auto too or it
filled to max-width) and stays centred.
Tried a real CSS Grid first: it collapsed to zero height at narrow widths, and
grid sizing from on_mount read a stale app width. Explicit columns are
predictable. Also shortened one description that was pushing the widest card out.
help scenario now asserts the five cards exist and that the content fits without
a scrollbar. Suite 192/0.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Completes phase 2 — the index already carried strings (KIND_STRING), the palette
just didn't offer the toggle. StringsPalette now mirrors SymbolPalette: F2 flips
between this binary and the project, project rows are prefixed with their binary,
and choosing a literal in another binary switches to it and jumps.
Ranking matches the symbol side: the trigram index guarantees the match, so
ordering is earliest-match then shortest, with a (binary, addr) tiebreak — the
same tie that crashed symbol search when two binaries shared a name, avoided here
by construction. Project scope caps at 60 like symbols.
_results is now (binary, addr, text) tuples in both palettes, so _on_string_chosen
takes the same (binary, addr) choice and routes through _switch_then_goto.
test_project_ui.py: local scope stays single-binary, F2 spans >=2 binaries
(23 checks). Suite 191/0.
|
| |
|
|
|
|
|
|
| |
It always highlighted row 0, so switching away and back meant hunting for your
current position in the list. Preselect the entry marked active instead; if a
filter excludes it, fall back to the first row as before.
Locked in test_project_ui.py (21 checks).
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
TypeError: '<' not supported between instances of 'Hit' and 'Hit'.
The rank tuple built for project scope ended with the Hit itself, so when two
binaries contain the SAME symbol name the first three fields (match position,
length, text) tied and sort() fell through to comparing Hit dataclasses, which
aren't orderable. Shared names — main, textdomain, the whole libc surface — are
the norm in a project, so this fired almost immediately.
Sort on an explicit key that stops at the orderable fields and breaks ties on
(binary, addr), which also makes the ordering deterministic instead of
input-order dependent.
Regression test in test_project_ui.py, where 'main' exists in both echo and cat:
widen to project scope and assert the shared name is found in both binaries — it
crashed before the fix. 20/20 there, suite 191/0.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
idatui/index.py — one on-disk index (<sidecar>/idx/project.db) over every binary
in a project, so search works for binaries whose worker isn't running.
Indexing choice, measured rather than guessed:
* SQLite FTS5 with the TRIGRAM tokenizer — stdlib, no dependency (nothing else
was installed and nothing is needed), and unlike a prefix index it matches
arbitrary substrings, which is what symbol names and string bodies need.
* 300k-entry corpus: 1.9 ms per query vs 11.8 ms for a Python scan and 28.9 ms
for plain LIKE; 0.2 ms per incremental insert.
* Size was the stated worry and turned out not to bite: bash contributes 5.9k
entries / 0.15MB of text, libcrypto.so.3 30.7k / 0.52MB. At ~5.7x the text a
20-binary project is ~12-23MB — against .i64 files already in the sidecar
(libcrypto's alone is 72MB), roughly 1% of what the project already costs. The
reason to be on disk is residency, not size.
* Trigram can't answer queries under 3 chars and returns nothing rather than
erroring, so search() falls back to LIKE — otherwise incremental typing would
look broken until the third keystroke.
Wiring: after a binary's functions load, its symbols + strings are folded into
the index (skipped when the source's size/mtime is unchanged). Ctrl+N gains a
scope toggle on F2 — not ctrl+a, which the focused Input binds to "home" so it
never reaches the palette. Project scope narrows via the index then ranks with
the existing _fuzzy, keeping the same feel; hits are prefixed with their binary,
and choosing one elsewhere switches binary and jumps to it.
Also fixes another instance of the Textual-markup trap: the palette titles ate
"[project]" as a style tag (same class of bug as the status bar), so the pal
titles are markup=False now.
tests/test_index.py: 24 stdlib checks — substring/case-insensitive matching, kind
filter, the <3 char fallback, multi-binary search, per-binary incremental
reindex, staleness, forget, persistence. Suite 191/0.
Strings (") still needs the same scope toggle; the index already carries them.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Quitting used to be silent AND inconsistent. Single-binary mode closed the worker
with save=False, so renames/types/comments were DROPPED without a word; project
mode did the opposite and saved everything silently via the pool. Neither told
you anything.
Now 'q'/ctrl+q routes through action_quit: clean databases exit immediately, and
anything unsaved raises a QuitScreen naming the affected databases with
s save & quit d discard & quit Esc cancel
Saving happens with an overlay up, because writing a large .i64 takes seconds and
doing it during teardown would look like a hang with no UI left to explain it.
_dirty_labels() covers project mode too: the active binary plus any still-resident
one that was edited. Evicted binaries were already saved on the way out, so they
can't be silently lost.
on_unmount now distinguishes an explicit choice from an unexpected teardown:
_save_on_exit is None (crash/kill -> save defensively, including single-binary
mode which previously discarded), False (user chose discard, or we already saved).
Verified end-to-end across two sessions on a temp copy: rename + 's' -> the rename
is still there on reopen; rename + 'd' -> it is not. Plus a quit_guard scenario
(clean exits immediately, dirty asks, Esc cancels).
Also hardens Ctx.open(view="decomp"): F5/Tab only decompiles from a focused code
pane and the listing may still be settling, so a swallowed Tab surfaced much later
as "pseudocode view shows: active=listing". It now retries instead of assuming the
first Tab takes. Full suite 191/0, green twice in a row.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Neither was a flake; both were test bugs that happened to be timing/binary
dependent.
1. filter: the scenario hardcoded the glob 'sub_1*', which matches NOTHING in a
binary whose code never reaches 0x1xxx — echo's functions are sub_2xxx..
sub_7xxx, so it failed deterministically, every run. Derive the glob from real
names instead (first sub_ prefix present) and assert the row count equals the
expected match count, which is stronger than the old 0 < n < total. A
self-check asserts the derived glob actually matches something, so this can't
silently rot again on another binary.
2. view_toggle: "tab switches to disassembly" asserted after a fixed pause(0.1),
but decomp -> listing runs through _toggle_to_listing, a BACKGROUND WORKER —
_active only flips once the listing model has loaded. The pause held in short
runs and lost the race in a full one, which is why this looked like collateral
from the filter failure. Wait for the state instead. Two related hardenings:
focus the decomp pane before Tab (elsewhere Tab is focus-next, silently
leaving us in the decompiler), and wait for the listing cursor to carry an ea
before the F5 check (F5 legitimately no-ops on an unaddressed row).
Full suite now 187 passed / 0 failed, the first fully green run — previously
174-182 with 2-6 "known flakes" whose count drifted with machine load.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The permanent Footer spent a screen row on a truncated, always-visible key list.
Remove it (the status line now owns the bottom row) and put the full cheatsheet
behind F1 — grouped by task (Navigate / Views / Move / Edit / Search) rather than
by widget, which is what makes it readable. Esc, F1 or q closes it; there's also
a "Keyboard shortcuts" command-palette entry.
F1 rather than '?' because '?' is already search-backwards in the code views.
Textual leaves F1 unbound (App only claims ctrl+q/ctrl+c), so it traps cleanly.
Gotcha worth recording: the helper that builds the cheatsheet was first called
_render, which collides with Widget._render — Textual invoked ours internally and
got a rich Text where it wanted a Visual, so the whole screen failed to paint
("'Text' object has no attribute 'render_strips'"). Renamed to _cheatsheet.
The search scenario's "rendered above the footer" check now asserts the search
input owns the bottom row instead. New `help` scenario: footer gone, F1 opens it,
groups + real bindings present, Esc closes (5 checks).
Suite note: view_toggle fails in a full run ONLY as collateral from the standing
`filter` flake, which runs immediately before it — when filter leaves the table
empty, view_toggle's open_biggest has nothing to open. Verified: view_toggle
passes alone (13/13) and directly after `help` (18/18), and reproduces as a
cascade with --only filter,view_toggle. Not a regression from this change.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The pseudocode view still had the old mapping (home -> goto_top, end ->
goto_bottom), so <End> jumped you to the bottom of the function instead of the
end of the line. Bring it in line with the listing view:
home start of line ctrl+home top of the function
shift+home first non-blank ctrl+end/G bottom of the function
end end of line
shift+home skips the C indentation — the pseudocode analogue of the listing's
skip-the-address-gutter.
Verified live and locked in view_toggle (5 checks): <end> stays on the line with
the scroll unchanged, <home> hits column 0, <shift+home> lands on the indent
width, and ctrl+home/ctrl+end still reach top/bottom. Full suite 179/2-flake.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
In the decompiler, 'y' on a local variable already worked (func_types -> lvars ->
set_lvar_type), but a GLOBAL fell through every case and silently retyped the
ENCLOSING FUNCTION'S PROTOTYPE — worse than not working, since the prompt said
"prototype" while you thought you were typing a variable.
* server/patch_server.py: new data_type tool — {addr,name,type,size,is_func} for
a data item, so the prompt can prefill the current type and the caller can tell
a global from a function.
* domain: Program.data_type() + set_data_type() (set_type with kind="global").
* app: _prepare_retype gains the data case between "function" and the
current-function fallback, with a size-based prefill when the global is still
untyped; _do_retype routes kind="data" to set_data_type.
Classification verified on echo/main: 'v3' -> lvar (prefill 'char *'), 'stdout'
-> data (prefill 'FILE *'), 'main' -> func prototype, an unresolvable token ->
the enclosing prototype (unchanged fallback).
Also fixes a latent crash found while probing this: on_listing/decomp_view_
cursor_moved called self.query_one(ListingView), but App.query_one searches the
TOP screen — a cursor-moved message landing while any modal is up (loading
overlay, project switch) raised NoMatches out of a message handler and killed the
app. Both handlers now go through _try_view().
Pilot `retype` extended to 9 checks covering all three flavours, each asserting
the other targets are left alone. Two of the new checks needed settles: applying
a retype recompiles asynchronously, so scanning/indexing the pseudocode without
waiting reads text that's about to be replaced (this also cut the scenario from
30s to 2.6s of previously-wasted timeout). Full suite 174/2-flake.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Wires the project model + worker pool into the app. Project mode is ADDITIVE —
without --project the app is exactly the single-binary tool it was, which is what
keeps the 167-check pilot meaningful.
* IdaTui(project=...) builds a WorkerPool and opens the project's first binary;
_open_worker_client asks the pool instead of spawning directly.
* BinaryState snapshots what a switch leaves behind (program, func_index, nav,
cur, view prefs, filter). Switching reuses the _after_reconnect shape: swap
client+program, rebuild the index, reopen the entry. A still-resident binary
restores instantly (Program + index are in memory); an evicted one gets a fresh
worker but keeps its nav history, which is just addresses.
* ProjectPalette (Ctrl+O, + a "Switch binary…" palette command): the project's
binaries with resident/analysed/pinned/active state and memory, filterable.
* launch.py --project FILE, creating the project when binaries are also given;
stages everything up front so the source tree is never written to.
Two bugs found while testing:
* _did_auto_land is app-wide, but landing is per-binary: after the first binary
landed, a cold switch never landed at all AND left the switch overlay up
forever. Reset it per switch.
* PRE-EXISTING: the status Static had Textual markup enabled, so a single-word
bracket marker parses as a style tag and is silently eaten — [listing] and
[pseudocode] have never actually rendered (only [split · listing] survived,
because the · makes it an invalid tag). Status is plain text with brackets and
symbol names, so markup=False.
tests/test_project_ui.py: end-to-end pilot on two real binaries (18 checks) —
boot, switcher contents, switch, per-binary index, both workers resident,
switch back with state intact, return-to-where-you-were, and the promise that
the source tree stays pristine while every artifact lands in the sidecar.
Full single-binary suite unchanged at 167/2 (the standing flakes).
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
idatui/pool.py — WorkerPool keeps one live worker per project binary:
* lazy spawn on first use; staging + a scratch sweep happen first, so a database
wedged by a previously hard-killed worker reopens instead of crash-looping.
* residency is bounded by a MEMORY BUDGET (default project.memory_pct of RAM),
not a worker count — a count is the wrong knob when one project holds a 50KB
helper and a 6MB crypto lib. Cost is measured per worker from
/proc/<pid>/smaps_rollup (PSS, which splits shared pages so summing means
something).
* over budget -> evict least-recently-used, never the active or pinned binary,
and give up rather than thrash when nothing is evictable. Eviction calls
idb_save first, so returning to a binary is a DB load, not a re-analysis.
* status() feeds the switcher UI (resident/pinned/active/analysed/memory).
worker_client: fix the wedge-file bug this depends on. close() sent
__shutdown__ and then IMMEDIATELY SIGTERMed, killing the worker mid
close_database() — which is what leaves the unpacked .id0/.id1/... behind and
makes the .i64 refuse to reopen. Now it waits out a grace period (the worker
returns from serve() on __shutdown__ and closes the DB in its finally) and only
escalates if it's genuinely stuck. Also expose .pid for memory accounting.
Verified: a pilot run that used to leave echo.id0/.id1/.id2/.nam/.til now leaves
only echo.i64, and teardown is no slower (14 checks in 5s).
tests/test_pool.py: 22 checks with an injected fake client — LRU order, budget
eviction, active/pinned protection, save-before-close, thrash avoidance,
status(), teardown. Pure stdlib, no idalib.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
First slice of multi-binary projects (docs/PROJECTS.md): the on-disk model, with
no runtime wiring yet.
idatui/project.py (stdlib-only, like domain/worker):
* Project.load/create/save — an explicit JSON project file listing binaries;
paths resolve relative to it, labels default to the basename and are
disambiguated on collision (they name files).
* A sidecar dir beside the project file (<stem>.idatui.d/) holds bin/ (staged
binaries), their .i64 + scratch, and idx/ for phase 2. IDA opens the STAGED
file, so nothing lands in the source tree — today targets/ carries ~244MB of
IDA litter around ~13MB of binaries, much of it stale wedge files.
* stage() copies rather than hardlinks. A hardlink is free but makes source and
staged one inode, so an in-place rebuild (cp over the path truncates instead of
replacing) would silently swap the bytes under an analysed DB with nothing to
detect it. The unit test caught exactly that. A copy also leaves the sidecar
self-contained once the sources are gone.
* Re-staging a changed source drops its now-stale DB; sweep_scratch() clears the
unpacked working files a hard-killed worker leaves behind (never the .i64).
tests/test_project.py: 27 checks, pure stdlib (no IDA/textual/worker), <1s.
docs/PROJECTS.md: the full design — the one-worker-per-DB constraint with
measured costs (bash worker = 126MB RSS/117MB PSS; libcrypto's DB is 72MB, so
residency is budgeted by MEMORY, not a worker count), the switch between
"switching needs a live worker" and "searching doesn't (cached index)", and
phases 1-4.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The sync only ran off CursorMoved, so a wheel-scroll or scrollbar drag — which
moves the viewport but never the cursor — left the other pane behind.
* ListingView/DecompView post a new Scrolled message from watch_scroll_y when the
rounded scroll changes; the app re-syncs on it (guarded to the active pane, so
a companion's align() can't feed back).
* _split_anchor(view): the sync now anchors on the driver's cursor while it is
visible, else on the top visible row. Cursor moves behave exactly as before
(key-nav always scrolls the cursor into view); once a pure scroll takes the
cursor off-screen the viewport itself becomes the anchor, so the companion
keeps following what you're actually looking at — including re-decompiling as
you scroll across function boundaries.
Pilot split_view gains a pure-scroll check (viewport moves, cursor doesn't, the
companion still moves): 20/20. Full suite 167/2-flake.
Three existing checks were setting .cursor without scrolling it into view, which
the anchor correctly treats as "cursor not visible"; they now scroll like real
key-nav. The multi-region check also had to make the decomp the ACTIVE pane
before a decomp-driven sync — otherwise the companion's align() fires Scrolled
and re-syncs listing-driven, clobbering the link (impossible in real usage, where
the companion is by definition not the active pane).
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Scrolling either pane left the other one wherever it happened to be: the
companion only scrolled when the linked row went off-screen (reveal()), so the
link could sit at the bottom edge while the driver's cursor was mid-viewport —
visually incoherent, your eye had to hunt for it.
Add ListingView/DecompView.align(row, screen_row): scroll so the linked row lands
at the SAME viewport offset as the driver's cursor, and use it in _sync_split for
both directions. The two panes now track each other line-for-line, so the eye
reads straight across. Best-effort at the ends (can't scroll above line 0, nor
past the end when the pseudocode is shorter than the viewport).
Pilot split_view: deterministic alignment check — park the listing cursor at a
known viewport offset deep in the function, sync, and assert the decomp's scroll
top is exactly the aligned value (accounting for both clamps). 19/19; full suite
166/2-flake.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
ida-pro-mcp exposes no full strings list (only a filtered/capped "interesting"
survey), so this is a new injected tool plus a filterable browser.
* server/patch_server.py: list_strings(offset,count,min_len,refresh) — every
literal from idautils.Strings() as {addr,text,len,type}, paginated, with a
module-level cache keyed by min_len (rebuilding is O(n) and the browser pages
the whole list).
* domain: StrLit dataclass + Program.strings() — pages the full list once and
caches it.
* app: StringsPalette modal (mirrors SymbolPalette) — case-insensitive substring
filter with the match highlighted, addr/len/text columns, ↑↓/Enter/Esc.
Bodies are sanitized to one printable line (\n/\r/\t escaped, non-printables
dropped, long strings clipped) so control chars can't break the layout;
display strings are pre-rendered+pre-lowered once since filtering runs per
keystroke. Enter jumps to the literal in the unified listing via _goto_ea.
Bound to '"' and Shift+F12, plus a "Strings…" command-palette entry.
Verified on echo: 150 strings listed with addr/len/text, filtering 'usage'
narrows to 2 (case-insensitive), Enter lands the listing cursor on the literal.
Pilot `strings` scenario 6/6; full suite 165/2-flake.
|
| |
|
|
|
|
|
|
|
|
|
|
|
| |
The unified listing spans many functions, but the decomp pane was pinned to the
one function it was opened on — scrolling the listing cursor past that function's
bounds stopped syncing. Now _sync_split tracks the decompiled function's ea span
(_split_range, from decomp_map) and, when the listing cursor leaves it, re-points
the decomp pane to the function under the cursor: _resync_decomp (exclusive
worker) -> function_of(ea) -> _apply_resync re-decompiles + reloads the region
map, or just drops the band over data/undefined (keeping the last function).
Pilot split_view: moving the listing cursor into another function re-syncs the
decomp (18/18).
|
| |
|
|
|
|
|
|
|
|
|
|
| |
The sync direction follows the FOCUSED pane, but that was only discoverable via
Tab. Add on_descendant_focus: in split, focusing a pane (Tab or a mouse click)
makes it the leading/driver pane and re-syncs — so clicking into the pseudocode
and cursoring around now drives the listing, matching intuition (previously a
click focused the widget but left _active — and thus the sync direction —
pointing at the other pane). The split status now ends with "(Tab/click: drive
<other pane>)" so it's obvious.
Pilot split_view: clicking the pseudocode pane makes it the driver (17/17).
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Polish for the split view:
* _split_status(): status line now reads "name @ ea [split · pseudocode line N
↔ K insn]" / "[split · listing]" from the focused pane, instead of the
single-view [pseudocode]/[listing] labels clobbering it. Wired into both
cursor-moved handlers and _apply_decomp.
* min-width gate: action_toggle_split refuses to enter split below
_SPLIT_MIN_WIDTH (100 cols) so two usable code panes always have room.
* navigation keeps both panes on the same function: verified (not fixed —
goto/follow in split already routes _open_entry -> _show_active split branch,
reloading the listing + decomp + region map for the new function).
Review turned two roadmap items into non-issues: split is a persistent mode
orthogonal to nav entries (back/forward just navigate within split), and search
is already per-focused-pane.
Pilot split_view gains: split-aware status, goto-in-split navigates, and both
panes reload on navigation (16/16). Full suite 157/2-flake. Split view is
feature-complete (phases 1-4).
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The Ghidra "region band": moving the pseudocode cursor now lights up EVERY
instruction that C line owns, not just one.
* server/patch_server.py: new decomp_map tool — sweeps cfunc.get_line_item across
each pseudocode line's columns and collects the ea from each item's dstr()
('EA: desc', matching the /*ea*/ marker source so it aligns with the display
lines). Returns {addr, lines:[{ea, eas:[...]}]}. (First tried item.get_ea(),
which reports a different ea and didn't align — dstr() is the right source.)
* domain: Program.decomp_map(ea) -> per-line ea lists, cached by name-gen.
* app: _load_split_map fetches it off-thread into _split_eamap/_split_ea2line;
_sync_split bands the full instruction region for a C line (decomp drives) and
uses the exact ea->line inverse (listing drives), falling back to the single
marker until the map lands. Maps cleared on leaving split.
Pilot split_view gains: decomp_map returns/aligns with the markers, and a
multi-instruction C line bands >1 listing row (13/13). Full suite 154/2-flake.
The idalib spike ran on the pilot's own worker (the standalone worker kept
getting reaped in this sandbox).
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The Ghidra sync: in split, the focused pane drives and the companion shows a
subtle band (_S_LINK) on the linked location + scrolls it into view. The
companion's cursor never moves (band + scroll only), so there's no echo/
ping-pong and no guard is needed.
* _sync_split(source): listing drives -> DecompView.line_for_ea (largest /*ea*/
marker <= cursor ea) bands the covering C line; decomp drives ->
ListingModel.ensure_ea bands the covering instruction row.
* wired into on_listing/decomp_view_cursor_moved (gated on the focused pane),
the Tab focus-switch (re-link from the new driver), _apply_decomp (link once
the pseudocode loads) and _apply_enter_split; bands cleared on leaving split.
* ListingView/DecompView gain _link_rows/_link_line + set_link/reveal, a
render_line apply_style(_S_LINK) band, and DecompView.line_for_ea.
Still single-ea per line (one instruction highlighted) — the full instruction
range is phase 3 (the decomp_map tool). Pilot split_view now covers both sync
directions + that the band actually paints (10/10); full suite 151/2-flake.
|
| |
|
|
|
|
|
|
| |
Two small correctness tidies before phase 2:
* action_toggle_split now no-ops while a search/rename/... prompt owns the
keyboard (parity with the other app actions; a stray 's' can't split mid-edit).
* the pilot reset() clears app._split so split state can't leak into the next
scenario if one crashes mid-run.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The Ghidra-style dual view, layout + toggle (no cursor sync yet — that's phase 2).
's' (and a "Split view" palette command) toggles a _split mode where the listing
(left) and pseudocode (right) show together, divided by a keyline, one focused.
Entering split loads the listing + decompiles the current function into both
panes; Tab/F5 switches the focused pane; any single-view target (hex, etc.) or 's'
again collapses back to the focused pane.
* app: _split flag; _show_active gains a split branch (both panes, load decomp,
focus active, #panes.split class); action_toggle_split + _enter_split worker;
action_toggle_view switches panes when split; 's' binding + palette entry;
#panes.split ListingView divider CSS.
* docs/SPLIT_VIEW.md: the full design + phased roadmap (the hard part — line<->EA
set mapping via a sweep of cfunc.get_line_item — is scoped for phase 3).
* tests: split_view scenario (enter/load/tab-focus/exit, 5 checks). Also fix
disasm_nav's stale goto-bottom (press ctrl+end; plain 'end' is end-of-line now).
Verified live over RPC (both panes render, Tab flips focus, 's' exits) and pilot
split_view 5/5.
|
| |
|
|
|
|
|
|
|
|
|
| |
The F5-from-listing overlay fix raises dec.loading=True before _show_active, but
_show_active only clears it via _load_decomp when the function needs
(re)decompiling. F5 on a function already shown in the pseudocode pane
(dec.loaded_ea == cur.ea) took the else branch, which never cleared loading -> the
spinner stayed up forever. Clear dec.loading=False in that branch.
Pilot view_toggle gains a check: F5 the same cached function again and assert the
overlay clears (8/8).
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The F5/Tab-from-listing path decompiled the function inside _decomp_from_listing
(via _decomp_line_for) in a background thread with no overlay, THEN _show_active
re-decompiled it -- but that second call hit the cache and returned instantly, so
dec.loading was set and cleared within a frame and the overlay only flashed. The
long wait (the real decompile) happened with nothing on screen.
Raise the pseudocode pane + loading overlay synchronously in action_toggle_view
before launching the background decompile, so the wait is covered. Non-function
F5 restores the listing via _decomp_from_listing_failed.
Pilot view_toggle now drives the real path (action_toggle_view from the listing)
and asserts the overlay is raised synchronously; F5 paths (decomp_fallback,
continuous_view, region_define) all still pass (20/20 + view_toggle 7/7).
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The Ctrl+P palette was Textual's stock system commands (change theme / take
screenshot / quit) -- useless for RE. Add an IdaCommands command Provider and set
IdaTui.COMMANDS = {IdaCommands} so the palette lists real actions instead:
goto, find symbol, follow, xrefs, toggle disasm/pseudocode, continuous listing,
hex, rename, retype, comment, define code/func, make data/string, undefine,
toggle opcodes, structs editor, filter, names pane, save, quit -- each with its
keybind as help text, fuzzy-searchable.
App-level actions run directly; cursor-scoped ones (rename/xrefs/comment/...) are
dispatched to the active code view via IdaTui._palette_action (focus + run the
view's action_*), so a palette pick does exactly what the key does.
Verified live over RPC: 'hex' switches view, 'goto' opens the prompt, 'struct'
opens the StructEditor modal, and the stock 'theme' command is gone. Pilot
command_palette scenario: Ctrl+P opens it and a command executes (7/7).
|
| |
|
|
|
|
|
|
|
|
|
|
| |
Replace the clamp-to-nearest-edge follow with a true screen-position freeze:
watch_scroll_y now shifts the byte cursor by the exact scroll delta on a user
scroll (wheel/scrollbar), so it points at a new byte but stays on the same screen
row. Cursor-driven scrolls (_scroll_to_cursor via _apply_scroll) are marked with
_internal_top and skipped, so key-nav/click don't double-move the cursor. All
scroll sources funnel through the one watch point.
Pilot `hex` scenario: put the cursor mid-viewport, scroll 30 rows, assert its
screen row is unchanged (+ clicks still land on the exact byte). 10/10 pass.
|
| |
|
|
|
|
|
|
|
|
|
|
| |
Override watch_scroll_y so a viewport scroll drags the byte cursor along: when
the rounded scroll changes, clamp the cursor's row into the visible range
[top, top+height) keeping its column, so it rides the nearest edge instead of
being left off-screen. No feedback loop -- key-nav's _scroll_to_cursor already
puts the cursor in view (clamp is then a no-op), and HexView.Moved only updates
the status line. Pairs with the click-to-place support.
Pilot `hex` scenario updated: after a scroll the cursor must now be within the
visible rows (was: asserted it stayed put). 9/9 hex checks pass.
|
| |
|
|
|
|
|
|
|
|
|
|
|
| |
HexView subclasses ScrollView directly and had no mouse handler, so clicking
never moved the byte cursor -- after a wheel-scroll the cursor was stuck off
screen with no way to reposition it by mouse. Add on_click: map the content
offset (scroll_offset + click, past the 1-col padding) to a row, and the x column
to a byte 0..15 across both the hex cells (3 cols each, +1 gap before byte 8) and
the ascii pane. Double-click jumps to code, mirroring Enter.
Pilot scenario extends `hex`: scroll the viewport (cursor goes off-screen), then
click in the hex pane and the ascii pane and assert the cursor lands on the exact
clicked row+byte. 9/9 hex checks pass.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The idalib worker is the only backend now, so remove the dead HTTP/supervisor
surface entirely (~2200 lines):
* deleted idatui/client.py (the IDAClient HTTP/JSON-RPC transport + session
manager), idatui/tui.py (the old mcp TUI entry, superseded by launch.py),
spawn.sh, and systemd/ (the supervisor unit).
* deleted the mcp-only tests (stress_client, smoke_client, test_keepalive,
stress_paging, rpc_smoke, serverctl.sh, pane_smoke, test_domain) -- the worker
pilot (tests/test_scenarios.py) supersedes them.
* migrated the tmux RPC harness (idatui/pane.py) to the worker: it spawns
`idatui.launch <binary> --rpc <sock>` instead of the mcp `idatui.tui`, drops
the supervisor auto-start/ensure machinery, and reaps our own worker
(idatui/worker.py) instead of ida_pro_mcp.idalib_server. --db/--url/--no-
ensure-server are gone; --open is required.
* __init__ / __main__ / domain no longer import client (exceptions come from
errors.py, the domain client hint is WorkerClient); pyproject points both
console scripts at idatui.launch; README + ida-tui header describe the
worker-only flow.
What stays (by design): the ida_pro_mcp *package* (the worker reuses its @tool
functions in-process) and server/patch_server.py (the worker injects its custom
tools on startup). Verified: whole package imports + IdaTui constructs + pilot
lists 31 scenarios. The worker pilot (134 pass / 2 known flakes) is the E2E gate.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The idalib worker is now the sole backend for opening a binary, so remove the
ida-pro-mcp code paths from the hot path:
* app.py: IdaTui.__init__ drops url/db/ensure_server/backend (now just
open_path/keepalive/rpc_path/ttl); _connect calls _open_worker_client directly;
_open_mcp_client deleted; _reconnect respawns the worker only; self.client and
_after_reconnect typed WorkerClient. No more IDAClient import.
* launch.py: rewritten worker-only -- validate the binary, sweep stale locks,
spawn the TUI (which starts the private worker behind its overlay). The whole
supervisor dance (_ensure_server/_start_supervisor/_open_binary/
_existing_session) is gone; `ida-tui foo.elf` is the one usage.
* tests/test_scenarios.py: pilot is worker-only (run(binary); binary via
positional/--worker/--binary, defaults to targets/echo).
Verified: app + launch + pilot import and construct; pilot lists 31 scenarios.
The mcp modules (client.py/pane.py/tui.py/spawn.sh) still exist as dead code and
are deleted in the next commit. Worker pilot (134/2-known-flakes) still the gate.
|
| |
|
|
|
|
| |
IdaTui.__init__ requires url and db positionally (the launcher passes url=args.url,
db=None even for the worker). The pilot's --worker branch omitted them ->
TypeError. Pass url="" (unused by the worker backend) and db=None.
|
| |
|
|
|
|
|
|
|
|
|
|
|
| |
run()/main() gain a worker path: IdaTui(open_path=binary, backend="worker",
ensure_server=False) instead of attaching to an mcp supervisor via --db. boot()
and every scenario are backend-agnostic (they drive app.program), so the full
31-scenario suite runs unchanged on the worker. This is the verification gate
before deleting the mcp transport:
~/ida-venv/bin/python tests/test_scenarios.py --worker targets/echo
The default (no --worker) still uses the mcp supervisor + --db, so nothing
regresses until we pull the plug.
|