| Commit message (Collapse) | Author | Age | Files | Lines |
| | |
|
| | |
|
| | |
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
0.5.x turned ida_codemode.__all__ into a real public API and hid the rest:
client.py -> handle.py, registry.py -> _registry.py + instances.py,
resolver.py -> _resolver.py. Every import we had was from a module that no
longer exists, so the TUI could not attach at all.
- handle.entry -> handle.instance (RegistryEntry -> DatabaseInstance)
- 30 keyword-only options -> DatabaseOpenOptions(...) passed as options=
- IdbBusy -> DatabaseBusyError
- InstanceDisconnected/ClientError -> DatabaseDisconnected/CodeModeConnectionError
- our scan_instances()+idb_key() ownership walks -> find_database_owner()
- our FileLock poking (_wait_for_entry_release) -> wait_database_released()
- registry.discover_instances() -> discover_databases() + InstanceState
_database_exists() is deleted with it: upstream now drops the loader switches
itself when reopening an existing IDB (_resolver._build_worker_command), which
is the same fix we had client-side. See docs/CODEMODE_UPSTREAM.md section 4 for
the one invocation that still slips through.
The offline contract suite has to keep running under a stdlib-only python3,
where every Code Mode name is bound to None -- so it now injects a strict fake
DatabaseOpenOptions and a real DatabaseBusyError exception alongside the fake
handle. Without the latter, `except DatabaseBusyError` is `except None`, and
the TypeError it raises masks whatever actually failed inside the try. The
loader-option names moved inside the options dataclass, so the guard that
caught `loading_address` vs `image_base` moved with them
(_option_fields_are_real).
uv.lock pins 0.6.1; ~/ida-venv and .venv are on 0.6.1 with the ida-domain
0.5.1 / zeromcp 1.8.0 floors it requires. Full gate: 1065 passed.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Reported as "decompiler -> listing feels slow", and it was: ~900ms per Tab.
The listing view re-primes every time it is shown, and priming now builds the
whole row index. build_from_index() had no idempotence, so each switch back
re-ran segment_index over the entire segment. Measured on bash, going back to
the listing away from the primed viewport:
press Tab: 904ms -> 9.7ms, and 5 backend calls -> 0
The index is a pure function of the database and the model is thrown away and
rebuilt whenever anything moves the walk (stale_structure), so a model that is
already indexed can return immediately.
Nothing caught this because the listing was CORRECT the whole time -- only
slow. Every structural assertion passed, boot still measured fast, and the
suite has no notion of "how many calls did that keystroke cost". The new
scenario counts backend calls across three view switches and asserts
segment_index is not among them; with the guard removed again it fails.
Latent and NOT fixed here: materialising a page the viewport reaches for the
first time still happens inside render_line, i.e. an RPC (~20ms) on the UI
loop. That predates this change -- it is how skeleton pages have always
worked -- and is small enough not to read as a stall, but it is the same shape
of bug and wants prefetching onto the worker that already exists for pages.
Full gate: 1064 passed.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The listing used to learn its shape by fetching it. Even after skeleton pages
that was 457 round trips and 227k rows for a 1.2MB bash, to end up knowing
how many rows there are and where each one is.
segment_index(detail=True) now returns exactly that -- every row's address,
kind and size as packed arrays, plus the page boundaries -- from one walk that
builds no rows and renders no text. ListingModel.build_from_index() decodes it
straight into _heads/_head_eas/_row_at/_by_ea/_page_*, marks every row
_SKELETON_GEN, and declares itself complete. _grow has nothing left to stream.
Nothing else in the model changed, because a row without text is a state it
already had: the FIRST read of a page materialises it through the same
_ensure_text/_ensure_page path a rename uses. That is why this is a ~90 line
change to a core view rather than a rewrite.
bash boot: 911 calls / 9.26s -> 4 calls / 1.25s 7.4x
whole census (boot + 9 UI actions): 933 calls -> 30
Two things had to be exactly right, and both are tested rather than argued:
* the ROW COUNT, or the scrollbar lies. Verified equal to a fully streamed
model, and every row's ea/kind/size equal too, 228,659 of them, zero
mismatches.
* the PAGE BOUNDARIES, or _ensure_page refetches a page that does not line
up, fails its structure check and triggers a full rebuild. heads() pages on
PHYSICAL rows; anchoring every N LOGICAL rows looks identical (the two only
diverge once a segment holds an undefined run) and would have been a
lurking bug on .bss. Anchors now carry [logical_row, ea, head_index] taken
at the real boundary, and are asserted equal to the streamer's own.
Transport note: the packed arrays are base64, not raw bytes. _PACK_EPILOGUE
serialises with json.dumps(default=str), which turns bytes into their repr --
2.97MB arrived as 11.26MB of unparseable text before that was spotted.
The new test builds both models back to back and compares every internal
array. An earlier version compared against the app's long-lived model and was
off by one row, because scenarios before it rename and define things: that
model describes the database at boot, not now.
Full gate: 1063 passed, twice.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The listing streams a whole segment for one reason -- to know how many rows
it has, so the scrollbar and paging are right. Even as skeletons that is 458
round trips and 227k rows for a 1.2MB bash, none of which is displayed.
segment_index walks the same items and counts what heads() WOULD emit,
building none of them, and returns the total plus [row, ea] anchors every 500
rows. Measured on bash, same process and database:
segment_index : 228,659 rows, 1 call, 501ms
streaming : 228,659 rows, 458 calls, 1836ms 3.7x
Exactness is the whole point, so it mirrors _rows_for's arithmetic rather
than approximating it: 3 banner rows at a function start, a label row for a
named code head that is not one, the head row, struct members for data, 2
footer rows at a function end, and an undefined run counted as its byte
length because the client presents one collapsed row as that many logical
rows. A count that is off by a handful means the scrollbar lies and a seek
lands on the wrong row, so the test compares against a fully streamed model
in the same process rather than against a tolerance, and checks that every
anchor names the address of the row it claims.
Getting that right took a false alarm worth recording: the count first looked
35 rows short of a model built by the pilot, which turned out to be a
DIFFERENT DATABASE (tests run on a pristine scratch copy). Against the same
database it matches exactly, head for head, with zero differing addresses.
Nothing consumes this yet. Spending it means teaching ListingModel to hold
sparse pages seeked through the anchors instead of one dense array grown from
the segment start, which is a real change to the core view and wants its own
run at it.
Full gate: 1054 passed.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
ListingView._grow streams the entire segment in the background for one
reason: to learn how many rows it has, so the scrollbar and paging are
right. It did that by rendering every row in full -- 227,500 rows of a
1.2MB bash, 911 backend calls, 9.3 seconds -- essentially none of which is
ever looked at.
generate_disasm_line is 22x the cost of the walk around it, so heads() gains
text=False: a SKELETON page with the same rows at the same addresses with the
same kinds and sizes, and no rendered text. Measured identical structurally
(rows, addresses, kinds, sizes and cursor all match a real page) which is
what makes one swappable for the other later. It also skips the digest
(nothing to go stale) and lets the client skip the bulk opcode read, so a
page costs ONE round trip instead of two.
Client side is deliberately tiny, because the machinery already existed: a
skeleton page is just a page whose text is stale. It is marked with a
sentinel generation no _text_gen can equal, and the FIRST read of it goes
through the same _ensure_text/_ensure_page path a rename uses -- which
already refetches a page by address, verifies the structure still lines up
and splices it in. Two staleness gates learn to fire for _skeleton as well
as _renamed; that is the whole integration.
bash boot: 911 calls / 9.26s -> 456 calls / 3.12s, 3.0x. The trade is that a
page you actually display is fetched twice (3.3ms + 9.4ms vs 9.4ms), paid
only for what is shown. _prime still loads real pages, so the viewport you
land on is never a skeleton.
The failure mode is BLANK ROWS, not an exception, and nothing in the suite
scrolled far enough to see one: _prime renders the first ~1000 rows for real,
so a test that pages down a few screens passes against a completely broken
implementation. The new scenario reads deep rows through both the model and
the render path, and asserts materialising changes neither the row count nor
the walk. Verified by reverting the two gates: it fails with text=''.
Full gate: 1050 passed.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The help screen has always advertised 'PgDn / PgUp - page down / up', but
only the four code views implemented it. In the palettes the keys did
nothing at all: those screens focus a filter Input, so the OptionList's own
pageup/pagedown bindings never fire -- every key goes to the Input, and an
unhandled one is silently dropped.
Adds OptionListNav, a mixin carrying the forwarding actions, and puts the
six Input+OptionList overlays on it: symbols, search, strings, registers,
load options, projects. They already held six BYTE-IDENTICAL copies of
action_cursor_down/up, so this removes more than it adds.
Paging delegates to the widget's own action_page_up/down instead of moving
by a guessed N: those know the live viewport height, skip disabled options
and clamp at both ends -- and it keeps the forwarded panes behaving exactly
like the ones that page natively.
Two panes deliberately stay off the mixin:
* XrefsScreen focuses its list, so Textual already pages it. Now covered
by a test so nobody 'fixes' it into double-stepping.
* StructEditor binds ctrl+n to 'new type', so it cannot take NAV_BINDINGS;
it gets page actions through its existing filter-focused guard instead.
BINDINGS do not merge from a plain mixin (Textual only merges them from
DOMNode subclasses), so every screen splats *NAV_BINDINGS explicitly -- the
same trap SearchMixin documents.
Tests gate on scrollable_content_region.height >= 1 first: paging is
geometry, and before layout the page size is 0, so every check would pass
against a no-op. Verified by removing the bindings again -- 3 checks fail
with highlighted=0, which is the exact silent failure being fixed.
Full gate: 1040 passed, 0 failed.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
"EMPTY BL" in the status bar is triskel's own bracket-list assertion from
its SESE pass, and it turned out to be the mild version of the problem.
Triskel's graph root is whichever node was created FIRST, and every
analysis walks out from it. Anything unreachable from that node is
undefined behaviour. We were:
- creating nodes in id order, so the root was the lowest-numbered
block rather than the entry, and
- splitting only WEAKLY connected components, which says nothing about
reachability.
A 7-block CFG whose entry has no successors -- IDA hands those out for
thunks and for dead code it could not resolve -- SEGFAULTS the
interpreter. That is unsurvivable: it takes the session down and there
is no exception to fall back from.
Now the entry is created first, orphan blocks are attached to it with
phantom edges that steer placement but are never drawn (one edge usually
adopts a whole orphan subgraph, attached at a node no other orphan
reaches), and reachability is asserted in python BEFORE crossing into
C++. This replaces the component splitting entirely: one layout instead
of N stacked side by side, and triskel gets to place the orphans.
The reproducer is now a test (t_unreachable_entry). Remaining fallbacks
on the ls corpus are 8/1200 layouts, all the upstream box-overlap
defect, all but one on 300-500 block functions.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
"engine triskel -> native+triskel-failed" was unreadable and, worse,
gave no reason: the cause went to a logger a TUI user never sees.
Dumped all 400 functions of bin/ls (echo's 128 were not enough) and swept
them at three zoom levels: 6 of 1200 layouts fell back, all of them my
own _verify tripping over a detour that could not be placed.
- the detour jumped to the nearest side of the FIRST box in the way,
which in a dense layout is usually inside the next box along. It now
collects every box the run passes and picks the nearest genuinely
free line.
- it skipped the first and last segments because they carry the port
and the arrowhead. But that is exactly where the failures were:
triskel is happy to park a block directly above its successor and
drive the final approach straight through it. Those segments may now
move ALONG their own box's border, which is free almost every time.
- repairs are swept to a fixed point: moving one segment stretches its
neighbours, which can push those into a box.
0/1200 fallbacks after that. Then the bigger corpus turned up a second,
genuinely upstream defect: superimposing SESE regions can leave two
blocks a couple of columns into each other (2 of ls's 400 functions,
in float space, before rounding). Cosmetic in a PNG; here the boxes are
made of text, so one block's disassembly overwrites another's. _verify
now checks it and falls back, which is the right trade.
Reporting, so this is never mute again:
- stats["engine_error"] carries the reason, the status line shows it,
and the label is "native (triskel failed)".
- the corpus test asserts fallbacks are rare AND explained, rather
than asserting they never happen.
Also lowered AUTO_TRISKEL_MAX_BLOCKS 250 -> 180. Layout runs on every
zoom keypress and triskel knees hard past ~175 blocks (174: 66ms,
233: 489ms, 329: 555ms). The old cap allowed a 489ms stall. The corpus
timing check now measures only sizes `auto` can actually reach, plus a
5s ceiling so nothing blows up quadratically when forced.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`e` in graph mode cycles auto -> native -> triskel, and `auto` prefers
triskel where it is installed and the function is at most 250 blocks.
Why: our layered engine draws wide-and-short pictures with a lot of
crossings on anything branchy. Triskel splits the CFG into Single-Entry
Single-Exit regions first and lays each out on its own, which on the
128-function corpus means fewer crossings on 12 functions, equal on 9,
worse on 3 -- and the wins are the hairballs (sub_5CA0 41 -> 6,
sub_2C90 32 -> 7, sub_2C00 12 -> 0). It also routes loop edges around
the side of the graph the way IDA does, which was a known gap here.
It is not free: ~2x slower at 87 blocks, 10x at 424, hence the cap.
The library needed a fork (~/dev/triskel, branch idatui) before it could
be used from Python at all -- its get_waypoints() threw on every
published version, an empty graph segfaulted the interpreter, and its
spacing constants were pixels baked in at compile time. Making those
settable is what makes this integration cheap: we hand it CELLS, so
its output is integral and two edge lanes can never round onto the same
row. The feared quantisation problem measured out backwards -- cells
claimed by more than one edge: native 131, triskel 35.
Not trusted with degenerate input, all handled before the call:
self-loops and disconnected components make it throw, and one corpus
edge comes back routed through a block, which we detour and re-verify.
A triskel failure is never fatal; it falls back to native.
Two things the second engine flushed out of the existing code:
- the canvas was sized from boxes alone, which is exact only because
native's dummy nodes reserve the space. Triskel routes outside that
bounding box and the edges were being clipped.
- arrowhead placement read e.back, conflating "this is a loop edge"
(style) with "this polyline runs against control flow" (geometry).
Now Edge.flipped, which is also a latent fix for residual-cycle edges
whose succ/pred were being reported backwards.
tests/test_graph.py runs its whole suite once per available engine
(943 checks); new graph_engine scenario covers the live toggle.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Reported as "the splash logo stopped rendering". It had not stopped: the
splash asks for the artwork's NATURAL size and shows nothing when that
does not fit, and the artwork needs 31 rows plus 10 of box chrome. A pane
in a split zellij window is 31 rows — one row short of the 41 it wanted —
so the logo silently disappeared. Traced with $IDATUI_KITTY_LOG in the
real session:
compose: supported=True app.size=Size(width=159, height=31)
cells=60x23 fits=False
The terminal scales an image into whatever cell box it is placed in
(`c=`/`r=` on the placement), so there was never a reason for
all-or-nothing. `logo_cells(max_rows)` now fits the art to the room left
after the box's furniture, and the same number reserves the cells and
sizes the placement, so a resize needs no relayout. In that same 31-row
pane it now draws 55x21 instead of nothing.
Two things fixed on the way:
* The chrome constant was one row optimistic (`rows + 9` where the box
measures 10: border 2, padding 2, art margin 1, title 1, note 1+1,
help 1+1). At exactly the old threshold the help line was clipped off
the bottom.
* `_fits` conflated "is the terminal big enough" with "is the artwork the
right size", which is what made the image path inherit the block art's
all-or-nothing behaviour. The block art genuinely cannot scale (it is
half-block cells, 26 rows) and still falls back to the text splash;
the image no longer does.
`splash_scaling` pins it at 31, 30 and 44 rows: the logo is drawn, it is
scaled to the room, the box is never clipped, and a big pane still gets
the natural size.
905 passed, 0 failed.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
SearchPalette opened pinned to the top of the screen: the CSS named the
screens that centre (`SymbolPalette, StringsPalette, ProjectPalette, …`)
and a new dialog is not on a list nobody remembers to edit. The comment
sitting above that rule — "every #pal-box palette centres, not just the
symbol one" — was the *first* time this happened.
`ModalScreen { align: center middle; }` matches subclasses, so every
dialog inherits it and the next one is centred for free; the eight
per-screen rules that only repeated it are gone. Textual's own Ctrl+P
CommandPalette is a ModalScreen too and wants its stock top alignment, so
it opts out in one visible line rather than by omission.
The `modal_centering` scenario checks both halves: that centring is
expressed as a rule, and that it actually reaches a dialog's laid-out
region (above/below and left/right within a cell).
894 passed, 0 failed.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`/` only ever searched the lines of the view you were in. This adds the
search you actually need on a binary: over the entire database, either
through the rendered disassembly or through the image.
* **text** matches the line as displayed, whitespace-normalised, so
`call cs:` finds `call cs:getenv_ptr` (IDA's column padding is not
something anyone types). Smartcase; `regex` available over RPC.
* **bytes** is IDA's own `find_bytes`, so the pattern language people
already know works unchanged: hex pairs, `?` wildcards for a whole byte
or one nibble (`48 8? ?? 24`), quoted literals (`"Hello", 0`). Commas,
no separators (`488B05C3`) and ragged spacing all normalise.
**Which mode you meant is guessed, and the guess is biased on purpose.**
`dead`, `add`, `cafe` and `ff` are valid hex AND ordinary things to search
for, so a bare hex-looking word stays TEXT; nobody types `48 8b ?? c3`
meaning prose. `hex:`/`text:` prefixes and F2 override it.
The subtle case is a *typo* in a byte pattern. `48 zz c3` first fell
through to a text search and reported "no match" — indistinguishable from
"those bytes are not in this binary", which is the most misleading answer
a search can give. Now any query whose tokens are all byte-sized is
treated as bytes, and a bad token is refused BY NAME. IDA does the same
thing quietly (find_bytes answers a malformed pattern with zero hits and
no error), so the validation lives in Program.search, not just in the UI.
Enter searches, then Enter opens the highlighted hit; the title says which
it will do, because a database-wide scan is far too slow to run on every
keystroke like the other palettes. Navigation goes to the item head — a
byte match can start mid-instruction — and the status names the exact
address.
Also: the `find` RPC verb and `drive find`, which is the one an agent
wants (`drive find '48 8b ?? c3'`).
idatui/search.py holds the classification and is pure, so the whole
question of "what did they mean" is tested offline: tests/test_search.py,
35 checks, 0.1s. Pilot scenario db_search covers the UI end to end.
Full suite: 890 passed, 0 failed, 51.3s.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The output of an RE session is what you worked out, and it was locked in a
.i64 that only IDA can read. Ctrl+E (or `drive export`, or the `export` RPC
verb) writes it out: your comments grouped by function with the line each
annotates, the names and prototypes you set, the types you declared.
**The hard part was provenance, and it needed a mechanism, not a filter.**
A database does not record WHO wrote a comment or a name. IDA's analyzer
sets `; switch 73 cases` and `; s1` with the same `set_cmt` a person uses,
and the ELF loader sets `elf_gnu_hash_nbuckets` and `File class: 64-bit`
the same way. Four probes, all negative: the FF_COMM flag is identical,
`get_cmt` returns them all, `generate_disasm_line` tags every one of them
COLOR_REGCMT (not COLOR_AUTOCMT), and they survive with auto-comments
switched off. A first cut filtered by shape and produced a report whose
first screen was ELF header trivia and `; jumptable ... case 99`.
So idatui journals its own edits (idatui/journal.py) into a netnode in the
database: it rides along in the .i64, it is still there next session, and
the report is then exactly what was done here -- 2 findings out of a
database carrying 693 other annotations. Recorded at the choke points in
edit_ctl (rename, name-address, comment, retype) and in the struct editor;
flushed on save, on export and on quit, so no edit pays a round trip.
Without a journal (a database worked on in the IDA GUI, or predating this)
the report falls back to filtering by shape -- dummy names, imports, loader
segments, the analyzer's stereotyped switch/jumptable strings -- and says
so in the document rather than claiming authorship it cannot prove.
idatui/findings.py splits gather (needs IDA) from render (does not), so the
formatting, grouping, sorting, escaping and the empty cases are tested
offline: tests/test_findings.py, 32 checks, no worker, 0.1s. The pilot
scenario covers the round trip that matters -- edit through the UI, export,
find it in the file, and reload the journal from the .i64.
Full suite: 842 passed, 0 failed, 51.2s.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The suite spent its time in two kinds of guess.
**Flat pauses.** ~140 `pause(d)` calls were 20.4s of the pilot's 62s, and
`test_trace_ui` was 13.5s of `pilot.pause(1.0)` out of 19.6s. `Ctx.pause` is
now `settle` (`d` is the upper bound, not the cost) and the other suites'
sleeps became gates on the thing the check is about. `Ctx.sleep` stays for
what a timer really drives.
**Textual's keypress path.** `Pilot.press` calls `wait_for_idle` twice per
key, which sleeps in 20ms granules until process time stops advancing --
84ms per keypress here, 23s of the pilot's 43s. `_fixtures.fast_keys()`
replaces it with the gate the suites already use: send the keys, then
settle. Deleting the heuristic *without* that broke nine checks, so it was
doing a job, badly.
Four checks turned out to be riding on those sleeps: they read geometry or
a repaint (`si.region`, `gv._minimap_rect()`, glyphs off `gv.render_line`,
a repaint trace), and a settled app has not necessarily been laid out or
painted. They now wait for the frame. The debounced function filter
(`set_timer(0.08)`) likewise waits for its effect.
Also fixed two waits on signals that never arrive: the comment wait in
`rename` carried a `dec.loaded_ea == app._cur.ea` conjunct that cost 9s of
timeout and then let the check pass vacuously, and `listing_view` -- the
one entry under "Known-flaky" -- waited on `lst.total`, which is true
before a single row exists.
`--profile` reports, per scenario, seconds settling / waiting / pressing,
and names any wait that expired with its line number. It is how the above
was found and how the next 20s should be.
Verified: 4 full `tests/run.py` runs, 800 passed each, 49.0-49.2s
(was 117.4s); 4 consecutive pilot runs, 313 passed each, 21.2s (was 63.7s).
|
| | |
|
| | |
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
"the code view follows the trace" failed on the port (None vs 0x2ae4) and
passed on master, but it was not a trace regression: the test pressed "]",
waited for `app._t == 1`, and then read the listing cursor. `app._t` is
assigned the moment the key is handled -- the navigation it starts runs in a
worker -- so the wait was satisfied before the view had moved, and the check
read a cursor that had no address yet.
Master won that race because its backend answers in single-digit milliseconds.
The Code Mode backend is slower, so the race became a reliable failure. The
gate is now the condition the check is about (the cursor is on the trace's ip),
for both the forward and the backward step.
39 passed, 0 failed -- the same tally as master.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
It was not slow because ARM analysis is slow. It was slow because it fought
Code Mode's ownership model and then waited out timeouts for the result.
The suite deleted <fixture>.i64 and REOPENED THE SAME PATH before each of its
four phases. That was safe when the TUI owned a private worker that died with
it. Under Code Mode the database is leased, and the previous phase's managed
worker can still hold it through its lease grace -- so the delete raced a live
owner, the next open never produced a listing, and the suite died on
`lst.model.index_of_ea(0)` with model=None after burning minutes in waits whose
results were never checked. Each phase now gets its own temp copy: separate
paths cannot collide and nothing waits for anyone to let go. This is the same
hazard docs/CODEMODE_PORT.md flags -- sweeping files that another client may
own -- showing up in the test suite rather than in the app.
Also replaced four `wait(lambda: lst.model is not <old>, ..., 60)` gates. An
item edit now keeps the listing's walk instead of rebuilding it, so the model
object is never replaced and each of those sat out its full 60 seconds while
the suite still reported success. They now settle() on the signal the checks
actually read: the status line announcing Thumb/ARM/64-bit, and the function
appearing in the index.
20 passed, 0 failed (it never reached a tally before).
Verified separately that the four ARM operations the port carried over do work
against a live database: set_thumb, thumb_scan, define_code_run, define_func_run.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Three separate wastes, all of the same family: waiting on a guess instead of a
signal, and paying for work that never had to be repeated.
1. The 64KB blob was built with os.urandom into a fresh TemporaryDirectory on
every run. New bytes at a new path means the pristine-database cache can
never apply, so full auto-analysis of 64KB of AArch64-decoded noise was paid
every single run. It is now built from a seeded PRNG at a stable path
(tests/.synthetic/, gitignored) and staged through the existing cache.
Determinism is also a correctness fix: whether 64KB of chance bytes contains
something IDA reads as a function is luck, and this suite asserts "and really
has no functions".
2. `wait(lambda: lst.model is not old, ..., 30)` after commenting. The perf work
made an item edit KEEP the listing's walk and re-render in place, so the
model object is never replaced and this waited out its full 30s timeout on
every run -- and then "commenting leaves the view where it was" passed
vacuously, because nothing had happened at all. A test that burns 30s to
check nothing is worse than no test.
3. Two `pause(2.0)`/`pause(2.5)` after a carve, replaced with settle() on a real
condition. The second one deliberately has NO predicate: that spot is random
data, so the carve may legitimately produce nothing, and "the row became
code" would never hold -- gating on it cost another 30s timeout. What that
check is about is the VIEW not moving, so the gate is "the app finished
reacting".
Fixing (1) exposed a real bug in the client, fixed here too: reopening a
database that already exists while passing loader switches is FATAL in IDA --
FATAL ERROR: Switch '-b400' can be used only when loading a new file
which kills the worker before it can report anything. Loader switches describe
an IMPORT and are recorded in the database they produce, so they are now sent
only when there is an import to describe. This was never reachable from the old
suite (a fresh random blob never had a database to reopen), but it is reachable
by any user who opens a raw blob with --ida-args twice.
30 passed, 0 failed.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
signature
ida-codemode is now cloned at ../ida-codemode (0.3.1) and installed into
~/ida-venv, so the adapter can be checked against the library instead of
against assumptions.
First thing it found: connect() passed loading_address=, which
DatabaseHandle.open() does not have. The real parameter is image_base, and it
already wants the natural 16-byte-aligned address we compute, so this is a
rename. Every connect would have died with TypeError on the first call.
The port's own contract test could not catch it: its fake handle takes
**kwargs, so any keyword at all looks accepted. The test now also validates
the keywords we send against inspect.signature(DatabaseHandle.open) when the
library is importable, and skips that one check when it is not.
Offline suite: 302 passed with the library installed, 302 without it.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Mechanical part of the port: the 27-file patch was cut against a base ~148
commits behind us, so it did not apply. Resolved 11 conflicts (all of them
diff drift, not semantic clashes) and the three file deletions:
- app.py: the patch re-inserted _do_rename/_do_name_addr/_seek_split etc. as
"theirs" because our tree moved them to edit_ctl.py/trace_ctl.py. Kept ours
and applied the real intent (WorkerClient->CodeModeClient, .call->.invoke,
_open_worker_client->_open_database_client) at their current homes.
- domain.py: kept Head as a NamedTuple -- the patch reverted it to a frozen
dataclass, which the perf work measured at 2.9us vs 1.9us per row on a
quarter-million-row walk. Dropped _fetch_output (no download_url under Code
Mode) and its now-dead urllib/json imports.
- pane.py: the patch's deletion swallowed our zellij support along with the
worker-reaping block it meant to remove. Kept zellij, removed the reaping.
- test_scenarios.py: the idb_save->save_database teardown hunk belongs to
tests/_fixtures.py in our tree; applied it there and kept our pc_num_format
scenario that the drift landed on.
Three defects in the patch itself, fixed here:
- It made "import idatui" hard-require ida_codemode, so every offline suite
died at import -- including the pure ones (graph/index/trace) that are the
house rule for "tests/run.py --fast". The import is now deferred and gated
on the binding, which is also what lets the port's own contract tests
inject a fake DatabaseHandle.
- project.stage() inlined an ida_codemode.registry import and treated "library
not installed" as "someone owns this database", which broke IDA-free project
staging. Ownership lookup moved to codemode_client.database_owner().
- tests/test_codemode_client.py had no NEEDS_IDA marker, which tests/run.py
rejects outright.
Offline suite: 301 passed, 0 failed. Against master's 344 the whole delta is
accounted for: -40 worker_client (module deleted), -18 launch sweep checks
(behaviour deliberately removed) +3 guarding that it stays removed, +2 pool
(GUI-save semantics), +13 new codemode_client contract tests.
NOT yet done, and the port is not functional without it: the adapter is
missing five operations our tree grew since the patch's base (flowchart,
op_format, pc_nums, pc_num_format, survey_binary) and its "heads" predates
back-walking and digest/expect.
|
| |
|
|
|
|
| |
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}
|
| |
|
|
|
|
| |
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}
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
_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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
_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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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_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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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).
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|