summaryrefslogtreecommitdiffstats
Commit message (Collapse)AuthorAgeFilesLines
...
* diag: somewhere for swallowed errors to goblasty2 days8-7/+343
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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 openblasty2 days2-4/+195
| | | | | | | | | | | | | | | | | | | | | | | | _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 workerblasty2 days2-1/+466
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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 goneblasty2 days6-89/+202
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | _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.
* tests: a guard that actually regresses on the resync stormblasty2 days2-3/+34
| | | | | | | | | | | | | | | | | The split resync loop (f898350) had no test. Two attempts at one were worthless and are not in this commit: a scroll-based guard passed with the bug reintroduced, and a constructed anchor -- inside the loaded function, outside its mapped span -- skipped, because on this target the map covers the whole function. The real trigger is the race window while the decomp map lags the decompiler re-pointing, which is tedious to force but wide open in split_view's own flow. So split_view counts lookup_funcs across its body and bounds it. Verified both ways, which is the only reason it's worth having: 29,227 calls with the bug put back, under 500 with the fix. The bound is loose because the bug was three orders of magnitude out, not a near miss. 733 checks, 139s.
* tests: deterministic fixtures, and a correctionblasty2 days1-2/+16
| | | | | | | | | | | | | | | | | | | | | | all_funcs() forced a full load of the function index only when it was EMPTY, so a partially streamed index -- non-empty but incomplete, which is exactly the state during boot and after any bump_items() -- came back truncated. Every fixture picked through find_func/biggest therefore depended on how far streaming had got by the time a scenario asked. That is the graph_minimap flake: on an unlucky run find_func(size > 0x300) picked a much larger function than usual, whose graph never finished inside the scenario's own 60s wait. Three failures and 65 seconds, one run in several, with no code change to blame. Three consecutive clean runs at 1.7s since. CORRECTION to f898350, which said a range cache for function_of 'broke graph_minimap'. It did not. The failure happened in the run after I added the cache and I attributed it without checking; it recurred with the cache long gone. The cache is still not here, but for the honest reason: with the resync loop fixed, function_of is down to 340 calls and 1.4s across the whole suite, so caching it is not worth the invalidation surface. Suite 195.7s -> 138.4s, 732 checks.
* tests: wait for the thing, don't sleep and hopeblasty2 days4-46/+140
| | | | | | | | | | | | | | | | | | | | | | test_trace_ui spent 18.8 of its 35.2 seconds in flat pilot.pause() calls placed to let an async seek land. Two loops were most of it: 6 iterations at 0.5s and 28 at 0.3s, 11.4s of sleeping to check that a step moves the cursor. They are condition waits now. The questions are unchanged -- does the listing cursor reach the pc, does the pseudocode cursor follow -- but they cost what they cost instead of a fixed budget. The second loop settles on something that does NOT presuppose the answer (the listing cursor arriving, and the trail map belonging to the loaded function): waiting on 'is this pc mapped' would have burned the timeout on every unmapped instruction, about half of them, and come out slower than the sleep it replaced. 35.2s -> 20.9s, 39 checks, stable over repeated runs. tests/_fixtures.py collects the staging both this suite and test_scenarios need -- scratch copy, seeded from a golden .i64 nothing writes back to -- which was private to test_scenarios. Worth saying plainly: on targets/echo the seeding is worth 0.19s, not the analysis time I assumed when I went looking. It is shared for the deduplication and for whatever gets pointed at a bigger binary.
* split: stop the resync loop that spun the worker foreverblasty2 days1-6/+20
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | _split_range is the min/max of the decomp_map's addresses, which does not cover every address in the function -- Hex-Rays doesn't attribute them all. An anchor inside the loaded function but outside that span therefore asked _sync_split for a resync, _apply_resync found the function already decompiled, called _sync_split again, and it asked again. One thread worker and one lookup_funcs round trip per iteration, for as long as the cursor sat there. Measured in the pilot: 23,888 function_of calls in one scenario across FOUR distinct addresses, 21,156 of them for 0x2060 alone. In the live app that is an idle split view pegging the worker. _sync_split grows a resync flag; the one caller that is itself the resync passes resync=False, so the branch can be entered at most once per chain. While measuring, three scenarios waited on "fail" appearing in the status -- the app says "cannot decompile". decomp_fallback burned its full 25s timeout and then passed a check on _active == "listing", which was already true before Tab was pressed: it asserted nothing, slowly. Now waits for the real text and checks that the fallback actually said something. scenarios 115.8s -> 74.9s, suite 195.7s -> 153.3s, 732 checks green. Not included: a range cache for function_of. It broke graph_minimap (the graph stopped loading at all -- the 65s was that scenario's own 60s wait timing out) and with the loop gone it buys little. Left out rather than shipped half-understood.
* app: lift database edits out of IdaTui, and one prompt abstractionblasty2 days3-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.
* tests: one front doorblasty2 days16-4/+327
| | | | | | | | | | | | | | | | Fourteen test files, each its own __main__, and no way to run them but from memory -- so in practice you ran the one you were working on and hoped. Worse, nothing said which files need a licensed IDA and a real worker (minutes) and which are pure stdlib (milliseconds), so the cheap ones nobody ran either. tests/run.py runs the lot and prints one table. --fast selects only the suites that need nothing, which is 257 checks in half a second under any python3 -- that's the one you run between edits. The classification lives in the test files, not in a table here that would rot the first time someone adds a test: each declares NEEDS_IDA at module scope and run.py reads it with ast (it can't import them -- they run their suite at import). A file without the marker is a hard error rather than a silent guess.
* splash: blasty's transparent logo, and stop guessing the cell aspectblasty2 days6-44/+169
| | | | | | | | | | | | | | | | | | | | | | | | | | | | 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 days2-5/+19
| | | | | | | | | | | | | 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 days3-32/+132
| | | | | | | | | | | | | | | | | | | 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 days3-8/+144
| | | | | | | | | | | | | | | | 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 days4-10/+349
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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.
* add the logo source imageblasty3 days1-0/+0
| | | | The PNG logo.ans was rendered from.
* graph: docs, and the offline layout toolsblasty3 days6-0/+600
| | | | | | | | cfg_dump freezes real CFGs to JSON; graph_spike renders one or --stats a whole corpus through the shipping engine; graph_smoke is the end-to-end tool->domain->layout check; graph_shot renders the real view headless at a chosen size, which is the only sane way to judge it (a tiled pane is far too narrow and the minimap sits on top of the graph).
* tests: graph scenariosblasty3 days1-4/+261
| | | | | | | | Opening, navigation and edge-following, the three zoom levels, the drawing actually reaching the screen (a layout that is right but paints nothing looks fine from the outside), clicking a block, renaming from inside one, and the mode surviving a navigation. The help test now derives its group list from _HELP instead of hardcoding it, so adding a card isn't a failure.
* rpc: a graph verbblasty3 days2-2/+186
| | | | | | | | 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 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.
* domain: the control-flow graph, with each block's rowsblasty3 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.
* server: a flowchart toolblasty3 days1-0/+62
| | | | | | | Blocks and typed edges for the function at an address -- not their text. The block body is an address range the client already knows how to render with heads, and keeping text out is what lets the graph reuse the listing rows instead of growing a second disassembly renderer.
* graph: a layered CFG layout engineblasty3 days2-0/+949
| | | | | | | | | | | | | | | | | | | | | | | | 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.
* README/TODO: literal formatsblasty3 days2-135/+52
|
* tests: cover the literal formatsblasty3 days3-0/+900
| | | | | | | | | | | | | | | Pilot scenarios for the listing and the pseudocode, for the mark moving between operands, for a refusal not being swallowed by the previous success, and for the cursor staying on its literal across a reflow. Plus experiments/opfmt_tools.py, which runs the real injected tool sources against a live database with the decorators stubbed -- faster than the pilot and the right place for the IDA-side edge cases. Also fixes two pre-existing bugs the work surfaced, both of which made edits happen off screen: cursor_on searched from row 0 of the whole segment and never scrolled, so a driver's word= edit landed in an unrelated function while reporting success; and the cursor verb didn't scroll either. Both now go through rpc.place_cursor.
* rpc: an opfmt verb, and 'drive fmt'blasty3 days3-15/+77
| | | | | | | | 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 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.
* domain: operand extents, and the formats behind themblasty3 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.
* server: tools for how a number is displayedblasty3 days1-9/+770
| | | | | | | | | | op_format reads and sets an operand's display format through IDA's own op_hex/op_dec/... nibbles, and pc_nums/pc_num_format do the same for Hex-Rays' number formats, which are a SEPARATE set the listing's format never reaches. The candidate stops are filtered to ones that change what you see: no char unless the value prints as one, no offset unless the target is already named. Operand extents come from IDA's own COLOR_OPND markers on the generated line, so they agree with print_operand exactly.
* pane: drive panes under zellij as well as tmuxblasty3 days3-47/+308
| | | | | | | | | 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 days2-1/+26
| | | | | | | | | | 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 days5-1/+368
| | | | | | | | | | | | | | | | | | | | | | | | 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).
* tests: trace integration over the RPC socket (45 checks)user7 days1-0/+369
| | | | | | | | | | | | | | | | | | | | | | | | | The existing trace suites (test_trace.py, test_trace_ui.py, test_trace_vs_tenet.py) cover the model layer, the UI via the Textual pilot, and differential correctness against Tenet's reference. None of them exercise the path an agent actually takes: the trace RPC verb driven over the unix socket through rpcclient. This one spawns a real tmux pane with --trace, records a trace with the QEMU tracer (falling back to /tmp/echotrace.0.log if the tracer isn't built), and drives every trace operation through the RPC socket, validating the JSON responses: seek (absolute, percentage, string, edge-clamping) step (forward, backward, multi-step, clamp at bounds) step over (finds a call via SP drop, verifies it lands after the return) goto (by name, by hex address, error on unexecuted) changed registers in the response cursor tracking (ea follows the trace pc) response shape (trace key is a superset of snapshot) interaction with non-trace verbs (pseudocode, state, view, goto) trace position independence from navigation All 45 checks pass against the echo binary with a 226-instruction trace.
* rpc: comments are instant and newlines no longer vanishuser8 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 backblasty10 days3-24/+63
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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 days3-3/+288
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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 days6-8/+299
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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 days2-21/+34
| | | | | | | | | | | | | | | | | | | | | | | | | | | | 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 days3-28/+63
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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 days3-7/+133
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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 days2-1/+20
| | | | | | | | | | | | | | | | | | | | 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 days2-6/+43
| | | | | | | | | | | | | | | | | | 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.
* rpc: a `trace` verb, and --trace for pane spawnblasty12 days2-0/+37
| | | | | | | | | | | | | | | | | | Driving the trace viewer needed the same treatment as everything else: seeking by hand through a few hundred keypresses to reach an interesting timestamp is not a way to test it. trace {seek: 120} absolute timestamp trace {seek: "!50"} halfway through, like Tenet's timestamp shell trace {goto: "main"} first execution of a name or 0xADDR trace {step: 20} relative, negative goes back trace {step: 5, over: true} Returns the usual state snapshot plus {idx, length, pc, changed} so a driver can see where it landed and what that instruction wrote. pane spawn --trace FILE passes it through to the launcher, so a trace pane is one command.
* trace: paint the execution trail — including on the decompilerblasty12 days4-1/+184
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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 days4-2/+383
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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.
* trace: read Tenet execution traces (model layer)blasty12 days3-0/+675
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | First slice of the execution-trace viewer: parse, index and query a Tenet trace. No UI yet — this is the layer everything else stands on, and its shape decides whether decompiler painting is cheap later. A Tenet trace is a line-per-instruction delta log: registers that changed, the PC every line, and each memory access WITH its bytes. That's enough to reconstruct any register or address at any point in time, in either direction. Our own reader, not a port. The reference (tenet-original) packs traces into segments with compressed address/mask tables, which earns its keep for its Qt timeline; we need different queries and would rather own ~400 lines than inherit 3700. Indexed around the query the UI actually asks, which the reference answers one address at a time: WHICH TIMESTAMPS EXECUTED THIS SET OF ADDRESSES. A listing row is one address, but a pseudocode line covers many (we already have decomp_map for that), so by_ip maps address -> timestamps and hits() takes a set. Painting a pseudocode line will be one call, not one per instruction. Registers are stored as per-register change points, so a value at time t is a bisect, and "which instruction set this register?" (last_write) is the same lookup — that being the question a trace explorer exists to answer. Rebasing is not optional: our echo trace runs at 0x7ffff6faa000 while the database has that code at 0x2490. Page offsets survive relocation, so the low 12 bits of an instruction address are invariant; bucket the database's addresses by those bits and take the slide the most trace addresses agree on. Verified against a real IDB: slide -0x7ffff6fa8000, and it picks out the 12 functions the trace actually entered (main, start, ...) from 128. Performance: 176k instructions parse in 350ms (~500k lines/s), so a 10M-line trace is ~20s and wants a progress callback, which load() takes. FOUND A BUG IN THE REFERENCE while building the differential test. A register written on the LAST line of a 65535-line segment is missing from the next segment's base state, so Tenet returns a stale value until that register is written again — measured: wrong for all 179 timestamps of one such window. It survives in the reference because it only shows when the register isn't rewritten immediately. That changed how the test works. Rather than "must agree with the reference", it ARBITRATES with the raw text when they differ: if the text backs us it's reported and allowed, if the text backs them it fails. Blanket agreement would have made us copy their bug to stay green. tests: test_trace.py (27, pure stdlib) covers reconstruction, the set queries, rebasing (including that a lone agreeing address is not enough, and that matching is on page offsets rather than addresses looking plausible) and malformed input; test_trace_vs_tenet.py (10) diffs against the reference on real traces.