summaryrefslogtreecommitdiffstats
path: root/idatui/app.py (follow)
Commit message (Collapse)AuthorAgeFilesLines
* Highlight ranges are computed per line on demand instead of for every match. ↵user2 days1-16/+79
| | | | | | Searching one character over bash matches 177k lines at 310k places, and all but the forty on screen were built and thrown away. _MatchRanges keeps the line SET eagerly and works out the offsets when a line is painted or the cursor lands on it; the blob scan now also skips to the next line after a hit. Result: {"status":"keep","total_ms":17829.9,"lg_boot_ms":724.4,"lg_decomp_ms":2353.5,"lg_graph_ms":1041,"lg_hex_ms":434.9,"lg_index_ms":95.5,"lg_listing_cold_ms":545.6,"lg_listing_warm_ms":407.9,"lg_nav_ms":6922,"lg_palette_ms":4.8,"lg_render_ms":218.1,"lg_search_ms":884.3,"pure_graph_ms":213.6,"sm_boot_ms":432,"sm_decomp_ms":1250.2,"sm_graph_ms":687.4,"sm_hex_ms":457.5,"sm_index_ms":2.6,"sm_listing_cold_ms":265.4,"sm_listing_warm_ms":288.8,"sm_nav_ms":303.2,"sm_palette_ms":0.3,"sm_render_ms":253.9,"sm_search_ms":43,"fails":0}
* Three targeted cuts: the graph's transposition pass counts keep and swap in ↵user2 days1-7/+34
| | | | | | one pass over the neighbour pairs (was four _pair_cross calls); the barycentre median answers degree 1 and 2 without sorting; and the search body is built from windowed model reads instead of one locked row lookup per line. Result: {"status":"keep","total_ms":17944.4,"lg_boot_ms":703.7,"lg_decomp_ms":2453.7,"lg_graph_ms":1000,"lg_hex_ms":478,"lg_index_ms":96,"lg_listing_cold_ms":453,"lg_listing_warm_ms":411.2,"lg_nav_ms":6555.9,"lg_palette_ms":4.9,"lg_render_ms":218.4,"lg_search_ms":1308.8,"pure_graph_ms":212.3,"sm_boot_ms":432.4,"sm_decomp_ms":1267.8,"sm_graph_ms":742.7,"sm_hex_ms":440,"sm_index_ms":2.5,"sm_listing_cold_ms":264.3,"sm_listing_warm_ms":289.5,"sm_nav_ms":305.4,"sm_palette_ms":0.3,"sm_render_ms":258.6,"sm_search_ms":45,"fails":0}
* Re-run of #18 (_CellRow slice assignment, one-string box borders, memoised ↵user2 days1-14/+42
| | | | | | Style sum), confirming it: 17590 -> 17501. lg_graph averages 951 over the two runs against 1136 before. Result: {"status":"keep","total_ms":17501.4,"lg_boot_ms":711,"lg_decomp_ms":2484.8,"lg_graph_ms":1017.2,"lg_hex_ms":431.9,"lg_index_ms":98.7,"lg_listing_cold_ms":528,"lg_listing_warm_ms":409.2,"lg_nav_ms":6488.2,"lg_palette_ms":4.7,"lg_render_ms":214.8,"lg_search_ms":1473.5,"pure_graph_ms":244.3,"sm_boot_ms":451.4,"sm_decomp_ms":596,"sm_graph_ms":691.3,"sm_hex_ms":444.5,"sm_index_ms":0,"sm_listing_cold_ms":263,"sm_listing_warm_ms":284.2,"sm_nav_ms":374.7,"sm_palette_ms":0.3,"sm_render_ms":252.1,"sm_search_ms":37.5,"fails":0}
* Re-run of #16 (keep the joined search body across a cancelled search and ↵user2 days1-11/+27
| | | | | | across navigation inside the same segment), confirming it. total 17784 -> 17590; lg_search 1917 -> 1400, sm_search 70 -> 48. Also lands .auto/check_search.py in the checks gate: it compares both search fast paths against the plain per-line loop for every typed prefix. Result: {"status":"keep","total_ms":17589.8,"lg_boot_ms":710.6,"lg_decomp_ms":2404.9,"lg_graph_ms":1136.1,"lg_hex_ms":425.6,"lg_index_ms":103.4,"lg_listing_cold_ms":417.2,"lg_listing_warm_ms":508.1,"lg_nav_ms":6553.8,"lg_palette_ms":4.8,"lg_render_ms":215.7,"lg_search_ms":1400.3,"pure_graph_ms":239.3,"sm_boot_ms":443.2,"sm_decomp_ms":668.4,"sm_graph_ms":698.1,"sm_hex_ms":443.6,"sm_index_ms":0,"sm_listing_cold_ms":267.3,"sm_listing_warm_ms":286,"sm_nav_ms":356.6,"sm_palette_ms":0.3,"sm_render_ms":258.6,"sm_search_ms":47.9,"fails":0}
* HexView.render_line emits style RUNS instead of one Segment per byte cell ↵user2 days1-6/+28
| | | | | | (35 -> 7 segments per row), and Head is a NamedTuple rather than a frozen dataclass (tuple.__new__ 1.9us vs a dataclass __init__ 2.9us, and it is built once per listing row walked). Result: {"status":"keep","total_ms":17784.1,"lg_boot_ms":680.8,"lg_decomp_ms":2344,"lg_graph_ms":1106.5,"lg_hex_ms":576.4,"lg_index_ms":96.5,"lg_listing_cold_ms":552.9,"lg_listing_warm_ms":431.3,"lg_nav_ms":6256.4,"lg_palette_ms":4.8,"lg_render_ms":224.1,"lg_search_ms":1916.5,"pure_graph_ms":241.3,"sm_boot_ms":442.6,"sm_decomp_ms":591.3,"sm_graph_ms":663.9,"sm_hex_ms":419.2,"sm_index_ms":0,"sm_listing_cold_ms":258.2,"sm_listing_warm_ms":281.8,"sm_nav_ms":374.4,"sm_palette_ms":0.3,"sm_render_ms":251.1,"sm_search_ms":69.8,"fails":0}
* Search the whole segment as ONE joined string. Every line is concatenated ↵user2 days1-26/+101
| | | | | | once (with a start-offset table) so finding a term is a C-level str.find walk instead of a python loop that rebuilds and case-folds 224k lines per keystroke. Falls back to the per-line loop if case-folding changes the string's length. Result: {"status":"keep","total_ms":18618.9,"lg_boot_ms":689.6,"lg_decomp_ms":2560,"lg_graph_ms":1035.1,"lg_hex_ms":684.2,"lg_index_ms":100.4,"lg_listing_cold_ms":551.1,"lg_listing_warm_ms":397.8,"lg_nav_ms":6687.8,"lg_palette_ms":4.7,"lg_render_ms":211.9,"lg_search_ms":1858.2,"pure_graph_ms":238.5,"sm_boot_ms":431.4,"sm_decomp_ms":671.2,"sm_graph_ms":728.1,"sm_hex_ms":552,"sm_index_ms":0,"sm_listing_cold_ms":257.4,"sm_listing_warm_ms":281,"sm_nav_ms":365.9,"sm_palette_ms":0.3,"sm_render_ms":242.8,"sm_search_ms":69.6,"fails":0}
* Only re-apply a scroll after the next refresh when it actually clamped. Both ↵user2 days1-5/+18
| | | | | | _apply_scroll implementations unconditionally scheduled a deferred scroll_to + refresh(layout=True) — a whole-screen re-arrange on every scroll — as a workaround for scrolling before the view's size is computed. Now the deferred pass runs only when scroll_offset didn't reach the target. Result: {"status":"keep","total_ms":19005.8,"lg_boot_ms":788.9,"lg_decomp_ms":2752.5,"lg_graph_ms":899,"lg_hex_ms":565.2,"lg_index_ms":71.9,"lg_listing_cold_ms":416.1,"lg_listing_warm_ms":402.3,"lg_nav_ms":6756.3,"lg_palette_ms":4.7,"lg_render_ms":217.1,"lg_search_ms":2335.8,"pure_graph_ms":235.3,"sm_boot_ms":537.4,"sm_decomp_ms":600.9,"sm_graph_ms":693.1,"sm_hex_ms":549.9,"sm_index_ms":0,"sm_listing_cold_ms":280.9,"sm_listing_warm_ms":261.2,"sm_nav_ms":309,"sm_palette_ms":0.3,"sm_render_ms":249.3,"sm_search_ms":78.7,"fails":0}
* Two hot-path fixes found by profiling the plain-line builder: the ↵user2 days1-3/+8
| | | | | | opcode-bytes column used a per-byte f-string generator where bytes.hex(' ').upper() does it in one C call (12x), and ListingModel._phys/_head_index_at re-imported bisect on every call. _line_plain 2.64 -> 1.57 us/row. Result: {"status":"keep","total_ms":19476.3,"lg_boot_ms":756.3,"lg_decomp_ms":2495.1,"lg_graph_ms":946,"lg_hex_ms":905.4,"lg_index_ms":72,"lg_listing_cold_ms":547.5,"lg_listing_warm_ms":410.4,"lg_nav_ms":6723.4,"lg_palette_ms":5,"lg_render_ms":218.8,"lg_search_ms":2312.4,"pure_graph_ms":239.5,"sm_boot_ms":535.3,"sm_decomp_ms":616.4,"sm_graph_ms":682.2,"sm_hex_ms":824.9,"sm_index_ms":0,"sm_listing_cold_ms":258.9,"sm_listing_warm_ms":261,"sm_nav_ms":339.5,"sm_palette_ms":0.3,"sm_render_ms":248.7,"sm_search_ms":77.3,"fails":0}
* Incremental search narrows instead of rescanning. Typing a character onto ↵user2 days1-14/+51
| | | | | | the term can only remove lines (a line holding "mov" holds "mo"), so _compute_matches rescans the previous hit list when the term grew and nothing else moved. Keyed on (term, case-fold, row count, line-source id) so a listing still streaming rows in behind the search falls back to a full scan. Result: {"status":"keep","total_ms":20835.7,"lg_boot_ms":754.4,"lg_decomp_ms":2593.7,"lg_graph_ms":929.5,"lg_hex_ms":1080.2,"lg_index_ms":76.1,"lg_listing_cold_ms":526.7,"lg_listing_warm_ms":410.1,"lg_nav_ms":6674.8,"lg_palette_ms":5,"lg_render_ms":225.2,"lg_search_ms":3310.3,"pure_graph_ms":239.3,"sm_boot_ms":537.9,"sm_decomp_ms":630.1,"sm_graph_ms":734.3,"sm_hex_ms":869.9,"sm_index_ms":0,"sm_listing_cold_ms":260.8,"sm_listing_warm_ms":262.3,"sm_nav_ms":334.8,"sm_palette_ms":0.3,"sm_render_ms":268.4,"sm_search_ms":111.6,"fails":0}
* app: the view mode is a type, and 'disasm' is goneblasty2 days1-55/+119
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | _active was a bare string with 49 comparisons across four modules and a fifth value nobody meant to keep. "disasm" was assigned on exactly one path -- a decompile that failed with nowhere to return to -- and named the same widget as "listing". Four sites understood it; five compared against "listing" alone and silently took the wrong branch: * Tab out of a failed decompile set "listing" instead of "decomp", so the first press appeared to do nothing. * rpc.py carried a workaround for a mode change that never arrived, keyed on being ALREADY in the ghost state -- so it fired in the rare case and not in the common one. Now keyed on LISTING, which is the case that happens. * drive.py asked the socket to show it "disasm", a value the app will now never report, and would have toggled twice and given up. ViewMode is a StrEnum on purpose: _active goes straight to drivers as cursor.kind and the pilot compares it to plain strings, so members being strings keeps every payload and comparison working. What it buys is one place that says which modes exist, and an AttributeError instead of silence on a typo. Read it through is_listing/is_decomp/is_hex/is_graph/in_code rather than ==. The bare comparisons are what let the ghost hide, and they are what the next mode would have to hunt down -- adding "graph" already cost one crash that way (_active_code_view returning None when a prompt closed). view_modes_all_handled walks the enum and asks the app the questions it asks itself. Verified it bites: adding a fifth unhandled member fails it twice. 746 checks, 142.3s.
* app: lift database edits out of IdaTui, and one prompt abstractionblasty2 days1-741/+99
| | | | | | | | | | | | | | | | | | | | | | Second cut at the 4000-line class, and the biggest: 702 contiguous lines of rename/comment/retype/make-data/literal-format/define, now EditController. What stays on IdaTui is what Textual insists on owning -- on_<Message> handlers, which it dispatches by name on the DOMNode, and @work entry points, whose worker machinery wants a DOMNode host. Both are one-line delegates. Underneath them was the duplication that made this worth doing rather than just moving lines. Opening a prompt (hide the status bar, set placeholder, can_focus, display, value, focus) was written out five times; closing it four; and Esc was a six-branch ladder in on_key with one copy of the same four lines per prompt. They had drifted -- search cancelled its highlight, goto restored focus, the edit prompts did neither consistently. Prompt/PromptBar own that discipline once, and close() HANDS BACK the context it was holding, so it can't be read twice or go stale: the listing's rename had to capture _rename_addr by hand before _end_rename cleared it, or the name went to address 0. _line_ea_for stays on the app -- rpc.py and the goto readback ask the same question, so it was never an edit helper.
* app: lift trace navigation out of IdaTuiblasty2 days1-335/+49
| | | | | | | | | | | | | | | | | | | | | | | First cut at the 4000-line class. Trace is the cleanest seam: 348 contiguous lines, one coherent job (where we are in time and everything that moves us), and two suites already covering it. TraceController owns the state now -- the trace, the timestamp, the trail maps. IdaTui keeps the keys, because Textual only merges BINDINGS from DOMNode subclasses and a mixin's would be silently dropped, and it keeps the @work entry points, because the worker machinery wants a DOMNode host. Both are one-line delegates. _trace/_t/_trail_map/_trail_map_ea/_trail_line_of stay readable on the app as properties: the pilot suite and rpc.py read the position by those names, and a property means one owner rather than a copy that can drift. rpc.py itself now goes through the controller. The parallel line-map that _apply_split_map used to poke into five attributes is now one adopt_map() call -- same single shared index, but the sharing is stated rather than implied by two places assigning the same fields. 731 checks, unchanged.
* splash: blasty's transparent logo, and stop guessing the cell aspectblasty3 days1-5/+22
| | | | | | | | | | | | | | | | | | | | | | | | | | | | The artwork is now a proper transparent PNG with soft edges (24% of its pixels carry partial alpha) instead of opaque art on black with a stray full-width scan line along the bottom. Cropped to its content and resized 1024 -> 768px, which halves the file and costs nothing visible; logo-trans.png keeps the master for future re-renders. Two things the new art exposed, both wrong before it: fit() assumed cells were 1:2. This terminal reports 9x22, i.e. 1:2.44. The old logo was 474x516 -- close enough to the assumption that nobody noticed -- but a square image at the hardcoded 60x33 would have been visibly stretched. The graphics query now asks for the cell size too (CSI 16 t rides along in the same round trip, before the DA1 that already synchronises it) and fit() uses the answer. The footprint was a constant. logo_cells() derives it from the artwork and the measured cell size, so the art can be replaced without anyone remembering to edit a number. logo.ans was stale: the block-art fallback for terminals that can't draw an image was still the OLD artwork, scan line included. tools/make_logo_ans.py regenerates it from logo.png so the two cannot drift again. It understands alpha -- a transparent cell emits no colour and lets the terminal background through, and a cell with only one opaque half uses the matching half block so the pixel lands on the correct side.
* help: reach the cheatsheet with H as well as F1blasty3 days1-5/+9
| | | | | | | | | | | | | F1 is swallowed before it ever reaches us on at least one setup here -- the app's own binding fires when the key is injected directly, zellij has no F1 binding of its own, and every common F1 encoding written straight into the pane (SS3 ESC O P, CSI ESC [11~, CSI-u ESC [1;1P) opens it. So the key is being eaten by something upstream, which is not ours to fix, and a cheatsheet reachable only through a function key is fragile anyway: terminals and multiplexers claim them routinely. H opens and closes it too. '?' stays with the incremental search, which is what it has always done in the code views.
* graph: navigate to blocks, not to coordinatesblasty3 days1-20/+91
| | | | | | | | | | | | | | | | | | | Clicking the minimap panned to the exact coordinate under the pointer and moved the cursor only if a block happened to sit there. Since one minimap cell covers many canvas cells, "there" was almost always padding: you got a jump into empty space and the cursor stayed behind, so you had to click a block afterwards to actually go anywhere. Blocks cover a few percent of a laid-out graph -- 4.6% of an 87-block function, 0.8% of a 424-block one -- and the rest is the space that keeps edges apart. So coordinates are the wrong thing to navigate by here. The minimap now snaps to the nearest block and takes the cursor with it, and a drag scrubs from block to block. Distance is measured with the column halved, because cells are twice as tall as they are wide and otherwise "nearest" is not what looks nearest. A drag-pan or ctrl+d/pageup that ends with no block on screen at all now eases to the nearest one too, since an empty screen leaves nothing to navigate back by. It only fires when nothing is visible, so a deliberate pan is never fought.
* graph: the minimap is clickable, and stops swallowing clicksblasty3 days1-8/+75
| | | | | | | | | | | | | | | | Click it to jump the view to that part of the graph, drag to scrub. If the point you clicked is over a block the cursor lands in it, so the keyboard carries on from where you pointed instead of snapping back. This also fixes a real bug rather than only adding a feature. The minimap FLOATS over the canvas -- it is pinned to the viewport, not drawn into the graph -- so a click on it was being translated into canvas coordinates and dropping the cursor into whatever block happened to lie underneath. It has to be hit-tested before the canvas, which is what on_click now does. _minimap_rect() is the one source of truth for where it is: the renderer and the hit-test both take the position from it, so the two-column inset that keeps it clear of the ScrollView's scrollbar can't drift between them.
* splash: draw the real logo on terminals that canblasty3 days1-10/+87
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | logo.ans is 60x33 cells of half-blocks -- a 60x66 pixel image. logo.png is 474x516. On a terminal that speaks the kitty graphics protocol we now send the real thing, in the same cell footprint (fit() lands on exactly 60x33, so the layout is unchanged), and fall back to the block art everywhere else. Three findings, each of which cost a round of "it renders nothing": Support cannot be sniffed from the environment. Under a multiplexer that passes the protocol through, TERM is xterm-256color and KITTY_WINDOW_ID, TERM_PROGRAM and COLORTERM are all empty while the protocol answers OK -- detection by terminal name would disable graphics on exactly the terminal that supports them. So we ask: a 1x1 graphics query plus a Primary Device Attributes request, with DA1 as the sync point. Unicode placeholders are not usable. The tidy way to put an image in a TUI is a virtual placement plus U+10EEEE cells that the compositor clips and moves like text -- and it is what every Textual image library builds on -- but this terminal answers ENOTSUPPORTED for placeholders while supporting everything else. So the image is placed directly, anchored to screen cells Textual knows nothing about. The splash therefore owns its lifetime: place after layout, re-anchor when the note repaints (throttled; a placement is one short escape with no image data), delete on unmount, or a leftover would sit on top of the disassembly forever. The query and the upload go on OPPOSITE sides of the alternate screen. The query must run before Textual starts, which reads stdin on its own thread and would eat the reply. The image must be uploaded after Textual has switched to the alternate screen: an image uploaded to the primary screen cannot be placed from the alternate one, and the placement reports success while drawing nothing. That silent failure is why detection lives in launch.py and upload lives in the splash's on_mount. $IDATUI_KITTY_LOG traces the decisions, since none of this is visible to a test -- correct escape sequences and visible pixels are not the same thing here. Off-tty (the pilot suite, a pipe) detection returns False and the block art is used, so the tests are unaffected.
* app: the graph viewblasty3 days1-10/+911
| | | | | | | | | | | | | | | | | | | | | Space swaps the code view for the function's basic blocks, IDA's own key. Boxes hold the SAME Head rows the listing renders, so IDA's colour tags, the word highlight, the execution trail and every editing verb work inside them for free. Nothing is pre-painted: each screen row is composed on demand from the edge index plus whichever boxes cover it, so cost tracks the viewport and not the graph. z cycles three zoom levels, m toggles a minimap, J/K walk edges, and the mode is sticky -- following a call lands in the callee's graph. Above 400 blocks it declines and says so, because nothing readable comes out at that size. Edges follow IDA's colours, and the ones touching the block under the cursor are brightened. Adding a value to _active means every consumer has to learn it: the one that was missed (_active_code_view returning None) crashed the app the first time a prompt closed in graph mode. A stale async load is also guarded now -- without it, a rename's queued rebuild landed later and dragged the user back into a graph they had already dismissed.
* app: 'o' cycles the literal under the cursorblasty3 days1-5/+222
| | | | | | | | | | | | IDA's own key, and that muscle memory is worth more than the opcode column's old claim on it -- the bytes column moves to B. The literal the cursor is on is MARKED, and that mark is what changes, because a line usually holds more than one. The cursor follows its literal across the edit: 48 <-> 0x30 reflows the line, and holding the column would put the next press on a neighbour. Land on a register -- something with no format of its own -- and it says so and names the operand that has one, rather than quietly reformatting a different one. Works in the pseudocode too, on Hex-Rays' separate number formats.
* rpc: stop lying to the driver about renames, modals and teardownuser9 days1-4/+42
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Five defects found while an agent drove a long RE session over the socket. Each one was reproduced on a live spawned pane first (an in-process pilot would not have shown any of them), then fixed: pane stop truncated the save. `stop` asked the app to quit, slept 400 ms, then unconditionally killed the pane. Quitting runs App.on_unmount, which writes every dirty database; a 90 MB .i64 takes tens of seconds, so the kill landed mid-write and a whole session's annotations went to /dev/null with a cheerful {"stopped": [...]} on stdout. Now it waits for the pane to actually exit (--timeout, default 600 s) and only force-kills on timeout, saying so. The quit verb bypassed the dirty check. It called app.exit() directly rather than the path a human gets, so the "unsaved changes" logic never ran. It now routes through _on_quit_choice and reports {saving, dirty}. Naming a function start from the listing never reached the function index. `goto <addr>` puts the cursor on the address token, so `n` takes the name-an-address path, which called bump_items() but left FunctionIndex holding the old name. Result: the rename response snapshot showed the section label, and functions()/names()/the palette all reported the rename had not happened — so a driver that trusts its readbacks redoes work it already did. Twice, in the session that prompted this. _do_name_addr now updates the index, the nav stack and the table cell when the address is a function start. A stripped binary with no entry function started up *inside a modal*. _auto_land pushed the symbol palette when main() was missing, while ping still answered ready:true. Every keystroke an RPC driver injected went into the palette's search box and was silently swallowed. It now lands on the first function instead and hints at Ctrl+N. Verbs that inject keystrokes now refuse when a modal is on top, naming it, instead of failing with "'goto' prompt did not open (word under cursor?)" — a message that blamed the cursor for what was always a focus problem. Also: `drive raw` passes k=v values through as strings, so `view lines=8` died with "'<' not supported between instances of 'int' and 'str'". Numeric params are now coerced centrally rather than at each call site. tests/test_scenarios.py: 212 passed, 0 failed.
* trace: a stale navigation no longer drags the view backblasty10 days1-1/+24
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | Fixes the bug found while building M3. Navigations run in workers and are applied when they land; the trace's OPENING seek goes to t=0, which for a normal binary is _start, and that navigation is slow. It arrived after later seeks and won, leaving the cursor and _cur on _start while the trace's pc was elsewhere — and it never settled, measured stable for 3+ seconds. Anything cursor-based done just after a seek (`>` asks about the address under the cursor) then acted on the wrong address. The decompiler path has had a staleness guard since 756589a; the listing path never got one. It has one now (_open_at_if_current), and a seek bumps _nav_seq so older in-flight navigations are dropped. Verified both directions on the exact reproduction: seek to the first execution of a repeated instruction, seek to the second, wait — cursor stays put with the guard, and with the guard removed it drifts to 0x34d0 (_start) exactly as reported. Scope, deliberately narrow. I first bumped _nav_seq in _goto_ea for EVERY navigation, which is the more general rule, and a full run then failed follow_xrefs — a follow can be dropped by whatever navigates next. That check has flaked before so it is not proof, but the mechanism is real and my evidence is only about seeks, so the bump lives in _seek. TODO records what would justify the general version and what test it needs. tests: +1 trace UI (39) — seek, seek again, wait 3s, and the cursor is still on the instruction the trace is at. Two consecutive full runs 212/0 after narrowing.
* trace: seek verbs — next/previous execution, and who set this registerblasty10 days1-1/+179
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | M3. Stepping walks time; seeking jumps to the next time THIS thing was touched, which is what makes a trace more than a very long single-step log. `>` / `<` — next/previous execution of whatever the focused view addresses. One pair of keys, two questions, because what's on screen already says which: * listing: the instruction under the cursor. "When else did this run?" * pseudocode: the whole C line, as the union of its instructions' executions. A line is not one address, and falling back to its single /*ea*/ marker would answer a narrower question — usually none at all, since most lines have no marker. * hex: the byte under the cursor, via memory_accesses. It says where you landed ("execution of 0x3160: 2 of 2 @ t=320") and, at either end, that you're AT the end rather than silently doing nothing — a key that does nothing is indistinguishable from a broken one. `W` — the registers with the instruction that set each to its current value, and the distance back. Enter seeks to that write, f seeks forward. Backward is the direction people want: you notice a bad value after it has been used. This is the question a trace exists to answer and it was already in the model (last_write/next_write), untested in anger until now. tests: +13 trace UI (38) — > and < move between the two executions of a repeated instruction, the status names which execution it is, both edges report instead of moving, W opens, and choosing a register lands on an instruction that REALLY wrote it (checked against the trace's own changed-set, not just the timestamp matching). Two things the tests taught me, both recorded: * focus() does not make a view active outside split mode — Tab does. My first seek test pressed > while _active was still "decomp", so it asked the pseudocode about a line with no instructions. * TODO gets a new entry: a stray late navigation to the entry function arrives after a seek and wins, leaving the cursor on 'start' while the pc is elsewhere. Same shape as the stale-decomp-result bug fixed in 756589a, which got a sequence guard the listing path never did. 212/0 scenarios, 35/0 model, 12/0 differential.
* trace: memory at time T — stack in the dock, live bytes in hexblasty11 days1-4/+83
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | M2. Trace.memory(addr, length, idx) reconstructs what memory held at a moment, returning the bytes AND a per-byte "known" mask. The mask is the point: a trace knows what it observed and nothing else, so a byte nobody read or wrote is genuinely unknown and must not be drawn as zero. That distinction is the whole reason to read memory from a trace instead of the database — the database has the file's bytes, the trace has what was actually there. Reads count as evidence, not just writes: an instruction reading a byte reveals what it held then. Indexed by ADDRESS (sorted once, bisect per query), because the question is "what was in this window at time t" and the accesses that matter are the few touching that window, not the tens of thousands in the trace. Where the memory actually is: measured, 0% of accesses in either real trace fall inside the image — every one is stack or heap. So the primary view is the STACK, in the dock, anchored at SP: stack (rsp) ▸7ffff6f99470 ???????????????? 7ffff6f99478 00007ffff6fb0b00 7ffff6f99488 00007ffff6fa94e5 The hex view overlays trace bytes on the file's contents (green = the trace saw this byte at this timestamp, grey = still the file's idea). Correct, and it will matter for a program that writes globals, but on these traces it shows nothing — which is why the stack pane is the deliverable and not a nice-to-have. One bug the work surfaced: MemOp.addr was having the image slide applied to it, which is nonsense for a stack address — it produced -0xc838. The slide relocates the IMAGE; stack and heap have no database counterpart. Memory op addresses now stay in trace space, and memory_raw() queries there, while memory() takes database addresses for the hex view. tests: +9 model (35) covering the known-mask, reads-as-evidence, partial coverage and the writers/accessors queries; +1 differential (12) checking reconstructed memory state against Tenet's own get_memory at sampled timestamps; +5 UI (30) for the stack pane — present, anchored at SP, marks unseen bytes, follows time. 212/0 scenarios.
* app: one instruction map for the decompiled function, not twoblasty12 days1-17/+29
| | | | | | | | | | | | | | | | | | | | | | | | | | | | Finishing the thing the last commit only worked around. The split view and the trace path each kept their own per-line instruction map of the same pseudocode — fetched separately, indexed separately, and keyed differently: the split one on _cur (the function the CURSOR is in), the trace one on the function the DECOMPILER has loaded. Those are not the same thing, which is precisely how the two ended up describing different functions and why I spent a commit chasing a "sparse decomp_map" that was never sparse. _apply_split_map now indexes once and both read it. Keyed on the decompiler's loaded function, and no longer conditional on split being on — the old guard dropped the result whenever _cur had moved while the fetch was in flight, which during stepping is almost always. Measured after: three decomp_map fetches across 28 steps (two for main, one for the function stepped into), the split map and the trace map are literally the same object, and both describe what is on screen. Process note, because this is the second time: my first attempt at this edit SILENTLY DID NOTHING — the pattern didn't match (a duplicated comment line I'd mangled), the old method stayed, and the new caller hit its `not self._split` guard, so the painting tests went from passing to "0 lines". Same failure mode as the key bindings that never got added. Structural edits now assert that the anchor was found and that the replacement is present before writing. 212/0 scenarios, 26/0 trace UI, 30/0 project UI, 20/0 split view.
* trace: stop the decompiler thrashing during a step (and correct the record)blasty12 days1-4/+22
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | I blamed decomp_map in the last commit. It was innocent: called directly it returns 769 lines, 475 with addresses, for exactly the function I said it returned four for. The four-line map belonged to a PLT stub the decompiler had momentarily switched to, and I sampled mid-bounce. The actual fault: _seek_split decided "has execution left the decompiled function?" from _split_range, which is maintained by a guarded async path (_apply_split_map drops its result if _cur moved while in flight) and therefore lags during stepping. A stale range made every step look like a function change, so the decompiler bounced main -> stub -> main, each bounce paying a synchronous 769-line map fetch on the UI thread. Now the decision comes from the map the trail painting already holds, keyed to what the decompiler currently HAS loaded. The bouncing is gone — three map fetches across twelve steps instead of one per step — and the pseudocode cursor follows every instruction the decompiler attributes to a line, including across a call into another function. What it does NOT do: guess. Roughly half of a function's instructions have no line attributed, and the obvious fallback (nearest mapped address at or before the pc) is unsound — C lines are not monotonic in address, and it put an instruction early in main on line 708, "sub_2040();", near the end. The cursor waits instead; the trail still marks where you are. tests: +1 trace UI (26) — over ~28 steps, every instruction that IS mapped is followed by the pseudocode cursor. 212/0 scenarios. TODO corrected: the entry blaming decomp_map now says what actually happened, including that _split_ea2line/_split_range are still fed by the laggy path and remain a latent issue for the split view's own sync.
* trace: a step in split view moves the listing cursor to the pcblasty12 days1-7/+81
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Normal navigation moves one pane and gives the companion a band, never a cursor — that rule exists so the two can't chase each other. A trace step isn't navigation: time is a single global position and both panes are showing the same instant, so the cursor belongs on it in both. _seek_split places the listing cursor on the current instruction, then hands off to the existing _sync_split so the companion still gets its band and align() at the driver's screen row. The anchoring machinery is used, not bypassed. PARTIAL, and the shortfall is worth stating plainly: the LISTING cursor tracks the pc reliably (tested over consecutive steps). The PSEUDOCODE cursor only follows when decomp_map covers that address, and for cat's main it covers almost nothing — four entries for a 700-line function. That is not something this commit introduced and not something I could fix responsibly without understanding it; TODO has what I measured, including that dec.goto(96) left the cursor at 0 in the same run, which may or may not be the same bug. One real fix along the way: _place_decomp_at prefers the map the trail painting keeps (keyed to the decompiler's currently loaded function) over the split view's _split_ea2line. The latter is refreshed by a guarded async path that drops its result if _cur moved while in flight, and a burst of steps moves _cur constantly — so during stepping it is frequently a map of the function you just left. tests: +2 trace UI (25) — stepping in split moves the listing cursor onto the pc for six consecutive steps, and the trail marks it 'now' in both panes. 212/0 scenarios.
* trace: stepping stays in the view you're readingblasty12 days1-1/+6
| | | | | | | | | | | | | | | | | | | | Caught while demoing this in a live pane, not by a test: press Tab to read the pseudocode, press ] once, and you're back in the disassembly. _seek() follows the trace by navigating to the new PC, and navigating to an ADDRESS opens the listing unless the decompiler is explicitly preferred. So every step out of C dropped you out of C — the painting work of the last commit was unusable in the view it was built for, from the first keypress. _seek now passes prefer_decomp=(self._active == "decomp"), the same thing the xref handler already does for the same reason. Worth noting what it looks like when it works: stepping in pseudocode follows execution INTO a callee and the view switches to that function's C, which is what you want and what makes the decompiler painting worth having. tests: +2 trace UI (23) asserting the view survives a step in both directions. 212/0 scenarios.
* status: name the open fileblasty12 days1-6/+12
| | | | | | | | | | | | | | | | | | The status bar said "0x2490" without saying what it belonged to. Obvious once there are two panes open, or after switching binaries in a project — which already prefixed its label, so single-binary sessions were the odd ones out. [cat] .text @ 0x472b [listing] (c code · p func · u undefine · Enter follow) Uses the opened file's basename, not _module(): that one asks the worker over RPC and this runs on every status write. Kept in step when the path changes (project switch, reload). Three messages already carried the module name themselves and would have read "[echo] echo — 128 functions"; they don't say it twice now. tests: +3 scenarios (212) — the bar names the file, keeps naming it as you move (the idle status is not the only writer), and doesn't say it twice.
* trace: paint the execution trail — including on the decompilerblasty12 days1-0/+85
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Both code views now show where you came from and where you're going: the instruction you're on ('now'), the ~96 steps behind it ('past', warm) and the ~96 ahead ('future', cool). A trail, not all of history. Painting every address the trace ever touched says almost nothing on a loop-heavy program; the last and next few dozen steps say how you GOT here. Where an address appears on both sides — a loop body, which is most of them — the nearer side wins, because that's the one explaining the step you just took or are about to. **The pseudocode is painted too**, which is the reason to build this here rather than use Tenet. A trace records instructions, so that's what Tenet paints. We already have decomp_map from the split-view work, saying which instructions each C line covers, so the same trail lands on the decompilation: line 46 now | v3 = getenv("POSIXLY_CORRECT"); line 47 future | v4 = (__int64)*a2; line 49 future | if ( v3 ) A C line covers many instructions, so it takes the strongest kind present: now beats past beats future — if the instruction you're standing on belongs to this line, this line is where you are. Two things kept cheap: the trail is recomputed per SEEK rather than per repaint (~200 lookups, and repaints vastly outnumber steps), and decomp_map is cached per function because it's an RPC and stepping is interactive. The colours sit deliberately under the code palette — the trail says "you came through here", the text still has to read as code. tests: +8 UI (21) — the listing carries now/past/future and it reaches the screen; pseudocode is painted; exactly ONE C line is 'now' and it is the line covering the current instruction (not merely some executed line, which is the mistake this check exists to catch). 209/0 scenarios, 27/0 model, 10/0 diff.
* trace: docked registers + timeline, and stepping through timeblasty12 days1-1/+210
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | M0 of the trace viewer. --trace FILE loads a Tenet trace beside the binary and docks a pane on the right: where you are in time, the register state there, and a timeline. Docked rather than modal on purpose. A trace turns every other view into "state at time T", so time and registers are context you read WHILE looking at code, not something you open and dismiss. The registers the current instruction WROTE are highlighted. That difference is the entire reason a delta trace is readable, and it's free — the trace already says which registers each line changed. ] / [ step one instruction. } / { step over, by following the stack pointer: a call pushes, so the callee runs with SP below where we started, and stepping until SP comes back up lands after the return. That's cheaper and more portable than recognising call instructions per architecture, and it degrades correctly — on an instruction that calls nothing, SP is already >= the start and it's one step. Verified on a real call: t=13 -> 18, past 5 instructions, where a plain step gives 14. The load waits for the function index because rebasing needs the database's addresses: our echo trace runs at 0x7ffff6faa000 and the same code sits at 0x2000 in the database. Rebased -0x7ffff6fa8000, 12 functions touched. Register values stay as the trace recorded them (they're machine state) while everything else on screen is in database addresses, so the header shows both — "pc 0x2aed (trace 0x7ffff6faaaed)" — rather than leaving the two to be puzzled over side by side. tests: test_trace_ui.py (13) records its own trace with the QEMU tracer and drives the real UI — loads, rebases onto real functions, the dock renders, ] and [ step and the code view follows, and } steps OVER a call found in that trace rather than at a hardcoded index. Skips with a message if the tracer isn't built. 209/0 scenarios. One thing worth recording: my first attempt to add the key bindings SILENTLY did nothing — the pattern contained a literal \\u2026 where the file has a real ellipsis, so the replace matched nothing and the bindings never appeared. The action worked when called directly, which made it look like a key-routing problem. Assert on the replacement, not on the diff looking plausible.
* arm: find Thumb entry points from a vector table (Shift+T)blasty13 days1-22/+39
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | An ARM function pointer carries the mode in bit 0: odd means Thumb. A Cortex-M vector table is therefore a list of Thumb entry points, and IDA won't follow them on a headerless image because nothing tells it those words are pointers at all. Shift+T scans forward from the cursor and marks them. 0 functions -> 3 Thumb entries found, 3 disassembled A word only counts when it is odd, lands in a loaded segment, and its target is executable and not already data. The even words in a vector table — the initial stack pointer — fail the first test, which is the point: marking a data word as code corrupts the listing, so a false positive costs more than a miss. The fixture includes an even in-range word and an odd OUT-of-range word to keep that honest. A note on how this started: I recommended this feature, then probed experiments/fibonacci.bin for the signal and found ZERO odd in-range pointers — it's a flat code blob, not a firmware image. Rather than build a detector I couldn't test, I wrote experiments/cortexm.bin: a real vector table pointing at small self-contained Thumb handlers. The first version of that fixture aimed its handlers into the middle of copied code, so two "entries" were really inside one function — the tool was right and the fixture was wrong, which is worth stating because I nearly filed it as a bug. Function creation goes through one _idatui_add_func helper now, shared with define_func_run: add_func(ea) alone fails on freshly-marked code (IDA can't find the end), and the scan hit exactly the same wall `p` did. Status precedence, fixed properly this time. An action's result kept being overwritten by the reload it triggered — cursor moved, filter re-applied, functions re-counted. I patched that at FIVE separate call sites before admitting it's one problem. _status(text, priority=True) now marks a result: it holds the bar for 8s or until the next keypress, and routine chatter can't outrank it. The per-site special cases are gone. tests: +4 thumb (20) — a bare vector table gives IDA nothing, scanning finds exactly the three handlers, the non-pointer words are ignored, and the result survives both the reload and the reindex. 209/0 scenarios, 30/0 blob, 30/0 project UI.
* decomp: say WHY a function won't decompileblasty14 days1-12/+33
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | "I can't decompile my freshly defined function" — and the app's answer was to flash and go back to the listing. It knew the reason and threw it away twice. The plain decompile tool reports "Decompilation failed at 0x0 (address: 0x0)". Hex-Rays itself fills in a hexrays_failure_t with the actual sentence, and in this case it is the whole answer: "only 64-bit functions can be decompiled in the current database". Nobody can guess that from a flash, and it is not fixable in place — the database's bitness is set at load — so without the message there is no way forward at all. New decomp_error tool returns Hex-Rays' own description; _load_decomp asks for it in the same worker when a decompile fails, and the status now reads: sub_0: cannot decompile — only 64-bit functions can be decompiled in the current database — Ctrl+L and pick arm:ARMv7-A Then it got thrown away a second time, by the reload. Falling back to the listing reopens it, and the reload writes its own status afterwards — the same clobber that has now bitten four times. The fix this round is the last one: _flash is SHOWN by idle status writes but no longer CONSUMED by them, because a reload emits several (prime, then cursor) and consuming on the first meant the second erased the message. It clears on the next keypress instead — when the user has actually moved on. (That also fixes a self-inflicted "status: None": the old code read _flash back after something else had already consumed it.) tests: +2 thumb (15) — a failed decompile says why in Hex-Rays' words, and the reason survives the view reloading under it. 209/0 scenarios, 30/0 blob, 30/0 project UI.
* app: rebuild the function index after an edit changes itblasty14 days1-2/+41
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | "even when I define functions with `p` I still get 'no functions: wrong processor/base?'". Two bugs, and the second is the worse one. The hint was LATCHED at load and only cleared on a reload, so it went on telling you the image was described wrongly long after you'd proved otherwise. It is now derived: the moment the index has a function, it stops being true. But the index never had one. Nothing rebuilt _func_index after an edit, so `p` gave you a function the rest of the app could not see — the names pane didn't list it and Ctrl+N couldn't find it. The hint was just the visible symptom of that. _edit_done now reindexes when the edit changed which functions exist (`p` and `u`; carving code doesn't, and a full walk after every `c` would be waste). It uses its own worker rather than _load_functions(), which is the BOOT path — that one clears the table, streams progress and auto-lands, which would yank the view off the function you just made. Verified on a blob with no functions: carve, `p`, and the status reads "created function 0x4040–0x404c", the index reports 1, and the hint is gone. Also: the reload confirmation said "1 functions". It counts now. tests: +4 blob UI (30) — no functions and the hint says so, `p` creates one the index can see, the stale hint is gone, the status names it. The Ctrl+L check became wrong in the good way and now asserts the confirmation counts what would be lost, since by then there IS something to lose. 209/0 scenarios, 13/0 thumb, 30/0 project UI.
* arm: offer 32-bit ARM at load, and fix `p` on carved codeblasty14 days1-1/+10
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Reported as "after c a few times and p at the entry point, Tab just flashes and nothing decompiles". Three separate things, found by following it down: **1. `p` failed on hand-carved code.** ida_funcs.add_func(ea) asks IDA to find the function's end and on carved code it often can't — a run ending in a tail call, or whose last instruction isn't recognised as a return, fails with no reason given. add_func(ea, end) with an explicit end succeeds. define_func_run tries IDA's way first, then falls back to the end of the contiguous instruction run, and says which it used. **2. The database was 64-bit, so Hex-Rays refused it regardless.** Bare `-parm` gives an AArch64 database. Ask Hex-Rays for the failure object rather than reading None as "dunno" and it says exactly what's wrong: "only 64-bit functions can be decompiled in the current database". So the disassembly looked right and F5 could never work. That is decided at LOAD and cannot be corrected — inf_set_app_bitness(32) afterwards makes the decompiler INTERR 50735. The fix is at the load dialog: arm:ARMv7-A (most firmware), arm:ARMv7-M / arm:ARMv6-M (Cortex-M, Thumb only) and arm:ARMv5TE now sit alongside 64-bit `arm`, labelled with their bitness. With arm:ARMv7-A, experiments/fibonacci.bin decompiles: void __fastcall __noreturn sub_0(int a1) { int v2; v2 = sub_E3C(a1, 0); ... } — and IDA's own auto-analysis finds 54 Thumb functions on load, versus none as plain `arm`. **3. `t` was silently building an undecompilable state.** It forced the SEGMENT to 32-bit in a 64-bit database, which produces correct-looking disassembly that F5 will never touch. It now says so and names the fix (Ctrl+L, arm:ARMv7-A) rather than leaving you to discover it. tools/verify_procs.py now reports each processor's resulting bitness, since that is the reason the variants exist — and it compares against the base module name, because a variant reports "ARM". tests: test_thumb_ui.py +5 (13 total) — a 64-bit database warns and names the fix, a 32-bit one finds functions by itself, Tab decompiles a Thumb function and the result reads like C. test_formats.py +2 (34) pinning that a 32-bit variant is offered and the ARM labels state their bitness. 209/0 scenarios, 26/0 blob, 30/0 project UI.
* arm: switch ARM/Thumb decoding with `t`blasty14 days1-1/+22
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `c` could not carve Thumb code. Thumb isn't a property of the bytes — it's a mode the CPU is in — so a raw image gives IDA nothing to detect: at a Thumb entry point it decodes 16-bit instructions as 32-bit ARM and produces confident nonsense. experiments/fibonacci.bin starts with `08 b5` = push {r3,lr}, which IDA reads as SVCLT 0xBF00. `t` on the listing switches the mode at the cursor and disassembles in it: Thumb @ 0x0 (segment set to 32-bit; Thumb needs ARM32) — 10 instructions 0x0 PUSH {R3,LR} 0x2 MOVS R1, #0 0x4 MOV R4, R0 0x6 BL unk_E3C Setting the T segment register is only half of it. Thumb does not exist in AArch64, and a headerless blob loaded with -parm comes up 64-bit, so T alone changes nothing and looks broken — I watched exactly that happen while probing the API. Asking for Thumb IS asking for ARM32, so set_thumb forces the segment to 32-bit and says so rather than doing it silently. It also has to del_items over the range first: the bytes are currently decoded in the old mode, and leaving that item defined pins the wrong instruction length so the new mode has nothing to apply to. Implemented as a `thumb` kind in the existing edit-item flow, so it inherits the shared reload — same cache bump, same ViewAnchor restore, same status flash. It switches AND disassembles, because flipping T and leaving the bytes undefined shows you nothing and reading the code was the point. tests: new tests/test_thumb_ui.py (8) driving the real Thumb binary — `c` alone does NOT produce the prologue, `t` does, the instructions are 16-bit wide (in ARM mode those three rows would be one 4-byte instruction), the run continues, the status explains the 32-bit forcing, and `t` toggles back. Deletes the .i64 first, because T and the segment's addressing mode are saved in it and a stale database would answer the question for us. 209/0 scenarios, 26/0 blob, 8/0 thumb.
* app: delete DisasmView, superseded by the unified listingblasty14 days1-328/+11
| | | | | | | | | | | | | | | | | | | | | | | | | | DisasmView was the function-scoped code view from before the unification. The app hasn't instantiated it since — compose() yields only ListingView, and even the test harness's Ctx.dis returns ListingView with a comment saying so. Its CursorMoved messages had no handler, so every one it posted went nowhere. 304 lines of rendering, search, cursor and navigation logic that never ran. It also cost real time this week: it made assembly highlighting look like a job that needed doing twice, and its isinstance branches in the follow and xrefs handlers were unreachable twins of the ListingView branch directly below them, which is exactly the kind of thing you read carefully before realising it can't execute. Gone with it: the dead branches (folded into the ListingView ones, keeping the fall-through-edge comment that was worth keeping), its CSS rule, and the comments that pointed at it as though it were a live alternative. DisasmModel STAYS — the domain still uses it to index a function's instructions (_do_edit_item resolves a row within a function that way). Only the widget was dead. 209/0 scenarios, 26/0 blob, 30/0 project UI, 36/0 index, 32/0 formats, 39/0 project, 27/0 pool. Smoke-tested a live pane afterwards: the listing renders, highlighted, and `drive where` answers.
* listing: syntax-highlight assembly from IDA's own token tagsblasty14 days1-9/+38
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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.
* app: one way to preserve view state across a model rebuild (ViewAnchor)blasty14 days1-36/+105
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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.
* listing: an edit must not move the viewblasty14 days1-7/+26
| | | | | | | | | | | | | | | | | | | | 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.
* listing: `c` disassembles until something stops itblasty14 days1-2/+40
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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.
* listing: make every undefined byte its own row, so you can carve anywhereblasty14 days1-1/+12
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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.
* loading: a blob that analyses to nothing is still openableblasty14 days1-3/+133
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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.)
* load dialog: reachable address field, project mode, and a way back from a ↵blasty2026-07-261-12/+109
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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.
* loading: ask how to load an unrecognised file, like IDA doesblasty2026-07-261-3/+171
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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.
* loading: say how to read a headerless blob (processor, base)blasty2026-07-251-3/+6
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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: show project binaries that import this exportblasty2026-07-251-8/+51
| | | | | | | | | | | | | | | | | | | | | | | | | | | | 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.
* projects phase 4: cross-binary back, linkage-guided pre-warm, project verbsblasty2026-07-251-1/+51
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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.
* projects phase 3: follow an import into the binary that implements itblasty2026-07-251-2/+57
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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.
* nav: one notion of "which pane you're in" — delete _prefblasty2026-07-251-6/+11
| | | | | | | | | | | | | | | | | | | | | | | | | | | | 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.
* nav: don't stack a history entry for the place you're already standing onblasty2026-07-251-3/+27
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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.