summaryrefslogtreecommitdiffstats
path: root/idatui (follow)
Commit message (Collapse)AuthorAgeFilesLines
* The page-freshness check carries the digest the client already holds ↵user38 hours1-20/+16
| | | | | | (heads(expect=...)) instead of asking first and fetching afterwards. A page that has NOT changed costs one round trip as before; a page that HAS changed now costs one instead of two. Also corrects the record: the item-edit bench hang is pilot start-up flakiness, not the _prime/_grow concurrency I blamed it on — proved with a stack dump. Result: {"status":"keep","total_ms":25121.9,"lg_boot_ms":694.8,"lg_decomp_ms":2370.8,"lg_graph_ms":917.1,"lg_hex_ms":456.1,"lg_index_ms":69.4,"lg_listing_cold_ms":442.7,"lg_listing_warm_ms":498.8,"lg_nav_ms":6548.8,"lg_palette_ms":4.8,"lg_rename_ms":711.4,"lg_render_ms":224.9,"lg_search_ms":3484.7,"lg_split_ms":2619.3,"pure_graph_ms":220.7,"sm_boot_ms":448.6,"sm_decomp_ms":1292.5,"sm_graph_ms":748.8,"sm_hex_ms":438.3,"sm_index_ms":2.4,"sm_listing_cold_ms":274.3,"sm_listing_warm_ms":266.5,"sm_nav_ms":302.7,"sm_palette_ms":0.3,"sm_rename_ms":392.5,"sm_render_ms":260.2,"sm_search_ms":59.6,"sm_split_ms":1370.9,"fails":0}
* An item edit (c/d/u/p) keeps the listing's walk in front of it instead of ↵user39 hours2-9/+75
| | | | | | discarding the model. bump_items now takes the edited address; rows before an edit keep their addresses and their row numbers, so only the pages from the edit onward are re-walked. Getting the listing back after undefining at the cursor on bash: 4890ms -> 19ms (257x). Adds .auto/check_edit.py to the gate. total_ms is flat — the bench has no item-edit phase, and the one I wrote hangs (reverted, cause recorded). Result: {"status":"keep","total_ms":25783,"lg_boot_ms":777.9,"lg_decomp_ms":2381.9,"lg_graph_ms":1209.5,"lg_hex_ms":450,"lg_index_ms":68,"lg_listing_cold_ms":434.6,"lg_listing_warm_ms":405.2,"lg_nav_ms":6801.2,"lg_palette_ms":4.7,"lg_rename_ms":744.7,"lg_render_ms":222.4,"lg_search_ms":3885.6,"lg_split_ms":2261.4,"pure_graph_ms":216.4,"sm_boot_ms":463.6,"sm_decomp_ms":1304.6,"sm_graph_ms":740.9,"sm_hex_ms":438.4,"sm_index_ms":2.4,"sm_listing_cold_ms":270.3,"sm_listing_warm_ms":267.2,"sm_nav_ms":312.4,"sm_palette_ms":0.3,"sm_rename_ms":415.8,"sm_render_ms":257.7,"sm_search_ms":60.8,"sm_split_ms":1385.2,"fails":0}
* heads(digest=True): the worker answers "does this page still render exactly ↵user40 hours1-51/+83
| | | | | | as you hold it?" with a hash and a count instead of the page. After a rename nearly every page is unchanged, so the pickling, transfer, unpickling and Head rebuild are all skipped. Redone at PAGE granularity end to end, which fixes both bugs of the first attempt. lg_search 5628 -> 3959. Result: {"status":"keep","total_ms":26491.7,"lg_boot_ms":808.6,"lg_decomp_ms":2600.2,"lg_graph_ms":901.2,"lg_hex_ms":448.7,"lg_index_ms":70.3,"lg_listing_cold_ms":432.8,"lg_listing_warm_ms":445.5,"lg_nav_ms":7004.8,"lg_palette_ms":4.8,"lg_rename_ms":758.6,"lg_render_ms":231.3,"lg_search_ms":3959.2,"lg_split_ms":2659.2,"pure_graph_ms":214.7,"sm_boot_ms":432.6,"sm_decomp_ms":1305.1,"sm_graph_ms":758.3,"sm_hex_ms":431.8,"sm_index_ms":2.3,"sm_listing_cold_ms":275.4,"sm_listing_warm_ms":289.4,"sm_nav_ms":295.6,"sm_palette_ms":0.3,"sm_rename_ms":424.3,"sm_render_ms":263.6,"sm_search_ms":61.1,"sm_split_ms":1411.9,"fails":0}
* CORRECTNESS FIX, kept despite a worse metric. The un-chunked refresh was ↵user41 hours1-11/+24
| | | | | | showing STALE NAMES on any wide read: one heads call for a search-sized window overflows the tool's 2000-row cap, the short response fails the sequence check, and the block is left with its old text. Refresh is now done a block at a time. Adds .auto/check_rename.py to the gate, which fails hard on the previous code and passes on this one. Result: {"status":"keep","total_ms":28651.3,"lg_boot_ms":720.6,"lg_decomp_ms":2427.4,"lg_graph_ms":1222.4,"lg_hex_ms":440.9,"lg_index_ms":72.6,"lg_listing_cold_ms":433.6,"lg_listing_warm_ms":465.9,"lg_nav_ms":6809.7,"lg_palette_ms":4.7,"lg_rename_ms":710.1,"lg_render_ms":228.7,"lg_search_ms":6891.2,"lg_split_ms":2259.2,"pure_graph_ms":214.5,"sm_boot_ms":457.7,"sm_decomp_ms":1303.6,"sm_graph_ms":700.2,"sm_hex_ms":445.4,"sm_index_ms":2.4,"sm_listing_cold_ms":271.4,"sm_listing_warm_ms":270.9,"sm_nav_ms":309.2,"sm_palette_ms":0.3,"sm_rename_ms":386.6,"sm_render_ms":265.8,"sm_search_ms":70.1,"sm_split_ms":1266.2,"fails":0}
* A rename keeps the listing's walk instead of throwing it away. bump_names ↵user41 hours1-5/+133
| | | | | | now marks the rendered text stale (invalidate_text) and ListingModel re-renders a 500-head block at a time on demand, snapped out to whole address groups; a refetch that comes back with a different head sequence sets stale_structure so Program.listing() rebuilds. lg_rename 10055 -> 742. Result: {"status":"keep","total_ms":25563.7,"lg_boot_ms":732.6,"lg_decomp_ms":2714,"lg_graph_ms":919.3,"lg_hex_ms":566.8,"lg_index_ms":71.4,"lg_listing_cold_ms":441.4,"lg_listing_warm_ms":411.7,"lg_nav_ms":6708,"lg_palette_ms":4.8,"lg_rename_ms":741.5,"lg_render_ms":227.7,"lg_search_ms":3314.1,"lg_split_ms":2596.9,"pure_graph_ms":213.6,"sm_boot_ms":422.9,"sm_decomp_ms":1284,"sm_graph_ms":789.5,"sm_hex_ms":468.1,"sm_index_ms":2.6,"sm_listing_cold_ms":258.7,"sm_listing_warm_ms":257.3,"sm_nav_ms":306.5,"sm_palette_ms":0.3,"sm_rename_ms":388.2,"sm_render_ms":256,"sm_search_ms":105.1,"sm_split_ms":1360.5,"fails":0}
* Keep a listing row's spans and operand extents exactly as they came off the ↵user42 hours1-11/+16
| | | | | | wire instead of copying them into tuples. The copy re-proved types the worker's own tool guarantees, and it destroyed the object sharing the worker's line cache had created — 228k rows now reference 125k span lists, not 228k private tuples. Result: {"status":"keep","total_ms":17700.4,"lg_boot_ms":776,"lg_decomp_ms":2690.1,"lg_graph_ms":888.1,"lg_hex_ms":449.1,"lg_index_ms":97.4,"lg_listing_cold_ms":439.4,"lg_listing_warm_ms":404.6,"lg_nav_ms":6762.9,"lg_palette_ms":4.6,"lg_render_ms":230.8,"lg_search_ms":762.9,"pure_graph_ms":217.1,"sm_boot_ms":452.6,"sm_decomp_ms":1270,"sm_graph_ms":698,"sm_hex_ms":433.2,"sm_index_ms":2.4,"sm_listing_cold_ms":259.1,"sm_listing_warm_ms":260.5,"sm_nav_ms":300.4,"sm_palette_ms":0.3,"sm_render_ms":258.3,"sm_search_ms":42.7,"fails":0}
* Highlight ranges are computed per line on demand instead of for every match. ↵user42 hours1-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}
* CORRECTNESS REPAIR, kept on its merits. The full suite (which the gate was ↵user43 hours1-11/+14
| | | | | | NOT running) revealed that the worker-connect poll change made test_project_ui flaky: 5ms polling on a background thread through a cold auto-analysis starved the UI thread enough that the loading overlay was still up when the test pressed Ctrl+O. Poll now backs off to a 25ms cap (keeps the boot win, no busy-wait), the racy boot wait is fixed, and checks.sh runs tests/run.py in full (830 checks) instead of just the scenario suite. Result: {"status":"keep","total_ms":18608,"lg_boot_ms":708.5,"lg_decomp_ms":2454.9,"lg_graph_ms":1034.5,"lg_hex_ms":431.9,"lg_index_ms":95.1,"lg_listing_cold_ms":530.2,"lg_listing_warm_ms":413.4,"lg_nav_ms":7057.9,"lg_palette_ms":5,"lg_render_ms":214.2,"lg_search_ms":1408.9,"pure_graph_ms":213.3,"sm_boot_ms":433.6,"sm_decomp_ms":1292,"sm_graph_ms":754,"sm_hex_ms":433.4,"sm_index_ms":2.6,"sm_listing_cold_ms":260.4,"sm_listing_warm_ms":280.5,"sm_nav_ms":286.1,"sm_palette_ms":0.3,"sm_render_ms":251,"sm_search_ms":46.5,"fails":0}
* Three targeted cuts: the graph's transposition pass counts keep and swap in ↵user43 hours2-13/+75
| | | | | | 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 ↵user43 hours1-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 ↵user44 hours1-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 ↵user44 hours2-12/+39
| | | | | | (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 ↵user44 hours1-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}
* Two independent constants: memoise the pygments token -> Rich style lookup ↵user44 hours2-5/+32
| | | | | | (a decompilation uses ~18 distinct token types but each token walked up to nine 'token in ttype' hierarchy checks), and hold the worker-connect poll at 5ms for the first 5s instead of backing off geometrically from the first probe. Result: {"status":"keep","total_ms":18856.8,"lg_boot_ms":689.7,"lg_decomp_ms":2484.2,"lg_graph_ms":1120,"lg_hex_ms":700.1,"lg_index_ms":96.5,"lg_listing_cold_ms":425.6,"lg_listing_warm_ms":511.5,"lg_nav_ms":6590.7,"lg_palette_ms":4.8,"lg_render_ms":215.1,"lg_search_ms":2195.5,"pure_graph_ms":238.1,"sm_boot_ms":431.5,"sm_decomp_ms":667.2,"sm_graph_ms":686.9,"sm_hex_ms":569.8,"sm_index_ms":0,"sm_listing_cold_ms":258.1,"sm_listing_warm_ms":283.2,"sm_nav_ms":365.2,"sm_palette_ms":0.3,"sm_render_ms":243.7,"sm_search_ms":79,"fails":0}
* Fetch a graph's listing rows from the blocks' MERGED EXTENTS, not their ↵user44 hours1-3/+32
| | | | | | convex hull, and assign them per block by bisect. IDA puts a function's cold/tail chunks far from its entry, so the hull of a 1.4KB function could be 680KB wide: it fetched 128k rows, took 3s, and still came back EMPTY for the far blocks because the pager's 64-page bound ran out first. lg_graph 9561 -> 1022. Result: {"status":"keep","total_ms":19062.1,"lg_boot_ms":751.3,"lg_decomp_ms":2605.7,"lg_graph_ms":1022,"lg_hex_ms":554.5,"lg_index_ms":103.9,"lg_listing_cold_ms":545,"lg_listing_warm_ms":405.6,"lg_nav_ms":6636.8,"lg_palette_ms":4.7,"lg_render_ms":212.2,"lg_search_ms":2269,"pure_graph_ms":239.9,"sm_boot_ms":538.5,"sm_decomp_ms":688.4,"sm_graph_ms":712.7,"sm_hex_ms":557.5,"sm_index_ms":0,"sm_listing_cold_ms":259.9,"sm_listing_warm_ms":260.2,"sm_nav_ms":363.8,"sm_palette_ms":0.3,"sm_render_ms":252.7,"sm_search_ms":77.5,"fails":0}
* Only re-apply a scroll after the next refresh when it actually clamped. Both ↵user44 hours1-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 ↵user44 hours2-5/+11
| | | | | | 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}
* Three micro-wins on the listing-row path: merge the colour-tag and ↵user44 hours1-2/+8
| | | | | | operand-tag dicts into one lookup, skip both isspace() probes when a span needs no whitespace collapsing at all (the common case), and give Head slots=True. Spans 11.07 -> 10.18 us/line; Head construction 3.18 -> 2.53 us/row. Result: {"status":"keep","total_ms":20412.8,"lg_boot_ms":752.9,"lg_decomp_ms":2418.6,"lg_graph_ms":811.6,"lg_hex_ms":1020.1,"lg_index_ms":73.2,"lg_listing_cold_ms":573.7,"lg_listing_warm_ms":456.7,"lg_nav_ms":6640.1,"lg_palette_ms":4.8,"lg_render_ms":237.6,"lg_search_ms":3210.2,"pure_graph_ms":242.5,"sm_boot_ms":536.5,"sm_decomp_ms":643.1,"sm_graph_ms":689.8,"sm_hex_ms":866.6,"sm_index_ms":0,"sm_listing_cold_ms":262.9,"sm_listing_warm_ms":265.9,"sm_nav_ms":330.1,"sm_palette_ms":0.3,"sm_render_ms":265.2,"sm_search_ms":110.5,"fails":0}
* Incremental search narrows instead of rescanning. Typing a character onto ↵user45 hours1-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}
* Re-apply #5 (lru_cache on the per-line render + Heads built with their ↵user45 hours1-35/+50
| | | | | | opcode bytes already attached) with the graph_minimap scenario's racy SETUP made deterministic: clear _graph_sticky before the second navigation so Space is known to be entering the graph, not leaving it. No assertion changed. Result: {"status":"keep","total_ms":22980.2,"lg_boot_ms":738.2,"lg_decomp_ms":2401.8,"lg_graph_ms":944.1,"lg_hex_ms":920.6,"lg_index_ms":75.2,"lg_listing_cold_ms":538.5,"lg_listing_warm_ms":411.1,"lg_nav_ms":6813.9,"lg_palette_ms":4.9,"lg_render_ms":221.8,"lg_search_ms":5630.1,"pure_graph_ms":240.7,"sm_boot_ms":537.5,"sm_decomp_ms":595.1,"sm_graph_ms":715.7,"sm_hex_ms":858.8,"sm_index_ms":0,"sm_listing_cold_ms":263.3,"sm_listing_warm_ms":265.3,"sm_nav_ms":335.2,"sm_palette_ms":0.3,"sm_render_ms":271.4,"sm_search_ms":196.5,"fails":0}
* Stop ida-pro-mcp installing a sys.setprofile hook around every tool call. ↵user45 hours1-1/+69
| | | | | | Its deadline mechanism profiles every python call/return so a pure-python tool loop can be interrupted; our tools are call-heavy, so it taxed the whole backend 3.3x. Worker now sets IDA_MCP_TOOL_TIMEOUT_SEC=0 and arms the deadline itself with one polling watchdog thread + ida_kernwin.set_cancelled() (the half that actually frees the IDA main thread). Also rewrote _idatui_spans to jump between colour tags instead of walking characters (byte-identical over 258k real lines). Result: {"status":"keep","total_ms":26923.9,"lg_boot_ms":762.2,"lg_decomp_ms":2631.7,"lg_graph_ms":941.8,"lg_hex_ms":1052,"lg_index_ms":67.2,"lg_listing_cold_ms":440.6,"lg_listing_warm_ms":530.5,"lg_nav_ms":10598.3,"lg_palette_ms":4.6,"lg_render_ms":227.8,"lg_search_ms":5265.5,"pure_graph_ms":237.9,"sm_boot_ms":535.3,"sm_decomp_ms":631.8,"sm_graph_ms":702.1,"sm_hex_ms":841.1,"sm_index_ms":0,"sm_listing_cold_ms":268.2,"sm_listing_warm_ms":269.4,"sm_nav_ms":443.6,"sm_palette_ms":0.3,"sm_render_ms":277.8,"sm_search_ms":194.4,"fails":0}
* diag: somewhere for swallowed errors to goblasty46 hours5-5/+161
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A TUI must not die because one background load failed, so this codebase catches broadly -- ~50 `except Exception` sites, two dozen resolving to `pass`. Right policy, one bad consequence: with 44 `@work(thread=True)` workers, a failure in a background load leaves no trace whatsoever. The view stays empty and there is nothing to read afterwards, because the app owns the screen. kittygfx already solved this for itself with $IDATUI_KITTY_LOG. idatui/diag.py is the same idea for everything else: $IDATUI_LOG writes every swallowed error plus its traceback to a file, and the last 50 are kept in memory regardless so a driver can ask a live app what went wrong. Unset, it costs an environ lookup. Wired in where losing the error changes a DECISION rather than just a pixel: * rename: a resolve() that throws renames as DATA instead of as a function. * name: a function_of() that throws means we never learn the address is a function start, so the index keeps the old name and every readback says the rename didn't happen. * retype: a resolve() that throws retypes the ENCLOSING function instead. * decompile: a failed full-body fetch silently returns CLIPPED pseudocode. * trail: a failed decomp_map stops the pseudocode being painted, silently. Deliberately NOT wired into the query_one guards -- a modal owning the screen is normal and constant, and logging it would bury the real entries in noise. New RPC verb `diag {n?, clear?}`, documented in docs/RPC.md: the answer to "the verb reported success and the pane shows nothing". Also a flake, same shape as the others: follow_xrefs waited on the nav depth but asserted on _cur, and a follow pushes the source entry BEFORE opening the target -- so the check could run in between and see the function it jumped from. About one run in ten. It waits on the postcondition it asserts now; three clean full runs since. 833 checks; --fast is 344 in 3.5s.
* launch: the sweep could delete the file it was asked to openblasty46 hours1-4/+22
| | | | | | | | | | | | | | | | | | | | | | | | _sweep_locks removes the scratch IDA unpacks beside a .i64 (.id0/.id1/.id2/ .nam/.til) when an open fails, keyed on both the full name and the stem. It never touched the .i64, which is the dangerous one everybody thinks of. It did delete the input. '.til' is an unpacked-DB suffix AND the extension of an IDA type library, so 'ida-tui mylib.til' swept its own argument out of existence -- irreversibly, on a path that runs automatically. Same for anything named *.id0/*.id1/*.id2/*.nam. Now the sweep skips whatever it was asked to open, compared as an absolute path so a relative argument is covered too. tests/test_launch.py pins the whole contract: what it takes, what it must never take (the .i64, the input, the neighbours), and what it reports. Pure, in the --fast tier. It is the right shape of test for code whose failure mode is deleting the wrong file. Also: _load_args parsed the base with bare int(), which raises on the '0x8000000' string a project file writes. Unreachable from our own CLI (which int()s first) but the asymmetry with project._as_addr was a trap, so both go through the same parser now. 813 checks; --fast is 324 in 3.4s.
* worker_client: tests, and stop resurrecting a closed workerblasty46 hours1-1/+19
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | The layer between the app and idalib had no tests, which is awkward: it is where failures are silent. A worker that dies during startup, a socket that drops mid-call, two UI threads sharing one socket -- none of those look like bugs from outside, they look like the TUI hanging or showing stale data. None of it needs IDA. WorkerClient spawns whatever _WORKER_PY points at, so the suite points it at a fake speaking the same length-prefixed pickle and tells it to misbehave on demand: die at startup, never bind, drop the connection, fail a tool, take its time. 40 checks in the --fast tier. Two things the tests found: call() reconnects when _sock is None, which is what makes a dropped socket recoverable -- but it made an explicitly CLOSED client resurrect too, spawning a whole new idalib worker to serve one stray call (verified: pid 1066961 -> 1066962). close() runs on teardown and on binary-switch while @work threads are still in flight, so quitting during a decompile could leave a fresh process re-opening the .i64 we had just released, which is the wedging hazard. A closed client now refuses; connect() still revives it, which is all _reconnect needs (it builds a new client anyway). connect() polled on a flat 0.2s sleep, so every caller paid a fifth of a second even when the worker was ready in milliseconds -- a seeded .i64, a small binary. Backs off from 5ms instead. 786 checks, 144.6s; --fast is 297 in 3.3s.
* app: the view mode is a type, and 'disasm' is goneblasty46 hours5-86/+150
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | _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 abstractionblasty47 hours3-741/+932
| | | | | | | | | | | | | | | | | | | | | | 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 days3-343/+474
| | | | | | | | | | | | | | | | | | | | | | | 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 aspectblasty2 days2-10/+53
| | | | | | | | | | | | | | | | | | | | | | | | | | | | 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 F1blasty2 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 coordinatesblasty2 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 clicksblasty2 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 canblasty2 days3-10/+344
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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.
* rpc: a graph verbblasty2 days1-2/+172
| | | | | | | | It reports STRUCTURE -- blocks, typed edges, ranks, box geometry, the cursor -- and not the box-drawing characters, which is what a driver actually wants; screen still gives you the drawing. show is a pure read. The line-oriented verbs refuse in the graph rather than reporting a (block, row) cursor as a line index some later edit would trust.
* app: the graph viewblasty2 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.
* domain: the control-flow graph, with each block's rowsblasty2 days1-0/+120
| | | | | | | Two calls rather than one per block: flowchart for the shape, then a single heads walk over the function's extent, sliced up by address. A hundred blocks would otherwise be a hundred round trips. Cached per function and dropped on a rename, since the rows carry live names.
* graph: a layered CFG layout engineblasty2 days1-0/+715
| | | | | | | | | | | | | | | | | | | | | | | | Textbook Sugiyama, the same shape IDA's own graph uses: break cycles, longest-path layering, dummy nodes, median/transposition ordering, priority x-coords, then port-and-channel edge routing. Pure python -- no IDA, no Textual, no I/O -- so it is tested offline in milliseconds with no worker, which is the whole reason the hard part is kept out of the UI. Dummy nodes are what make routing tractable: a long edge occupies real horizontal space, so no edge ever has to cross a box. The tests assert exactly that over a 128-function corpus, and it holds at 0. Two things cost real time to find. A self-loop never drains its own in-degree, so it deadlocks the ranking and collapses the graph into three layers, 280 columns wide -- they are dropped from the layout and drawn as a marker. And crossing minimisation is the entire runtime: recounting globally per candidate swap is O(n^3) and took 20.4s on a 424-block function, against 152ms for Fenwick inversion counting plus a local O(deg*deg) swap delta. The result is not a painted canvas -- that function is ~13M cells. It is an index: per-row runs, bucketed vertical intervals, and point marks, queried one row at a time.
* rpc: an opfmt verb, and 'drive fmt'blasty2 days2-14/+68
| | | | | | | | mode is cycle/back/show or an explicit format. 'show' reports the current format and the stops on offer without editing, which is what a driver needs: the rendered text alone can't be trusted (a listing read before an ARM/Thumb switch shows the old decoding). 'word' puts the cursor on a token first, so a literal can be named instead of steered to.
* app: 'o' cycles the literal under the cursorblasty2 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.
* domain: operand extents, and the formats behind themblasty2 days1-0/+86
| | | | | | | | Head carries `ops` (where each operand sits in the text) so a view can tell which one the cursor is on, and Program grows op_format / pc_nums / pc_num_format over the new tools. The pseudocode literal positions are cached with the decompilation and dropped on a rename, since a reformat moves every literal on its line.
* pane: drive panes under zellij as well as tmuxblasty2 days1-40/+301
| | | | | | | | | The multiplexer is auto-detected ($ZELLIJ then $TMUX) and every pane command works the same under both. Pane ids are self-identifying, so a mixed set of tmux and zellij panes can be tracked at once. zellij has no -l, so --size is ignored there, it always focuses a new pane (--detached is emulated), and it leaves an EXITED husk behind that stop/reap now clear. The pane tests skip on neither multiplexer rather than on no TMUX.
* drive: a note went to address 0, and a slow edit was reported as a failureagent7 days2-4/+17
| | | | | | | | | | | | | | | | | | | Two bugs found driving a 65KB ARM firmware image (one flat 42k-line listing, no ELF sections to break it up): `note` did goto + `cursor line=0` before commenting. Line 0 is the top of the function only in the DECOMPILER; in the listing it is the top of the SEGMENT, so every note landed at address 0 -- and scrolling a 42k-line listing there took so long the call timed out, which read as "comments are broken". goto already lands on the function's first line, so the cursor call just goes; note now also reports where it landed. The client's 90s timeout was too tight for the same reason: comments on that listing take 26-106s (the rebuild has no function boundary to stop at), so the CLI reported "no response ... server busy or the op is hung" for edits that had already been applied. Believing a successful edit failed is the worse error -- the driver redoes it, or "fixes" what was never broken. Default is now 300s; IDATUI_RPC_TIMEOUT still overrides.
* rpc: rename_many drops the Hex-Rays cache toouser7 days1-1/+9
| | | | | | | | | | Hex-Rays caches per function and does not notice that a *callee* was renamed; worse, that cache is persisted in the .i64, so a bulk import left pseudocode calling sub_98C0 forever while the listing and every readback said memset -- the exact readback disagreement a driver cannot detect. Batch now calls force_recompile before bumping the local caches. Test extended: decompile, rename via rename_many, read the pseudocode back.
* rpc: make a raw firmware image drivable (load options, define, bulk symbols)user7 days3-1/+180
| | | | | | | | | | | | | | | | | | | | | | | | Opening a headerless blob was the one workflow that fell out of the driving surface entirely, and each gap hid the next: - `pane spawn` couldn't pass --processor/--base/--ida-args, so the pane came up "ready" with zero functions (x86 at 0) and the only way through was to hand-write a project file. It now forwards them to idatui.launch. - c/p/t/T (code, function, ARM<->Thumb, vector scan) existed as listing bindings with no verb, so a driver had to guess raw keys -- and raw keys are swallowed by whatever modal happens to be up. `define {kind,target?}` goes through the app's own edit worker and reports what IDA actually did. - every name went through the typed rename prompt: a navigation (listing page + decompile) plus two prompt round-trips each. A 427-symbol map took tens of minutes of driving. `rename_many {items|file}` hands IDA's rename tool the whole list in one call (371 symbols in 3s) and refreshes the caches and the function table once. drive gains `define <kind> [target...]` and `syms <file.json>`. Verified live against a real pane (tests/test_rawimage_rpc.py, 13 checks: spawn load options, define thumb/func + unknown-kind rejection, rename_many from a file and inline, with resolve/functions readback).
* rpc: comments are instant and newlines no longer vanishuser7 days1-2/+8
| | | | | | | | | | | | | | | | | | | | | | | | Two fixes to the comment verb: Drop the per-character typing delay. Rename/retype/goto use a 35 ms delay for the visual effect (the agent's keystrokes appear one by one on the livestream), but comments can be long — a 200-char annotation blocked the driver for 7 s of pure animation. Comments now type instantly (delay=0); the aesthetic delay is kept for rename, retype and goto where values are short. Escape literal newlines before injecting into the prompt. The Input widget is single-line, so a real 0x0a sent as a keystroke was silently swallowed. The app's _do_comment already converts the two-char sequence '\\n' into a real newline for IDA, so the RPC layer now does text.replace('\\n', '\\\\n') before typing — both literal newlines from the caller and explicit \\n in the text reach IDA as multi-line comments (each line gets its own // prefix in the decompiler). Verified on a live pane: a comment with an embedded newline now renders as two // lines in the pseudocode, and long comments appear without the multi-second typing pause. tests/test_scenarios.py: 212 passed, 0 failed.
* rpc: a navigation that timed out reported success and corrupted the next edituser9 days1-8/+34
| | | | | | | | | | | | | | | | | | | | | | | goto/open ran `settle(app, pred, timeout)` and threw the result away. On a large database the listing build routinely outruns the default 20 s, so the verb returned a normal snapshot while the view had not moved. Every subsequent rename/comment then applied to wherever the caller *used* to be. Reproduced on a live pane against a 4 MB Go binary: `goto 0x1002019b0` returned ok with the view still at 0x100001000, and the following `rename main_inflate_zlib` renamed internal/abi.BoundsDecode instead — then the rename's own snapshot showed `main_inflate @ 0x1002019b0`, because by the time it was taken the goto had finally landed. Success reported, right-looking readback, wrong function edited, and it survived a save. This is what made an agent session stamp net_writeFull onto main_usage and conclude the tooling was flaky. goto/open now raise TimeoutError naming the target and where we actually are, suggesting a larger timeout=. _press() does the same for follow/toggle_view/ hex/xrefs/structs, which had the identical "predicate ignored" shape. tests/test_scenarios.py: 212 passed, 0 failed.
* rpc: stop lying to the driver about renames, modals and teardownuser9 days3-8/+140
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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 backblasty9 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 hexblasty10 days2-6/+196
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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 twoblasty11 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.