<feed xmlns='http://www.w3.org/2005/Atom'>
<title>ida-tui.git/docs/PROJECTS.md, branch main</title>
<subtitle>tui for headless ida</subtitle>
<id>https://git.sl0p.foo/ida-tui.git/atom/docs/PROJECTS.md?h=main</id>
<link rel='self' href='https://git.sl0p.foo/ida-tui.git/atom/docs/PROJECTS.md?h=main'/>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/ida-tui.git/'/>
<updated>2026-07-26T13:04:51Z</updated>
<entry>
<title>arm: offer 32-bit ARM at load, and fix `p` on carved code</title>
<updated>2026-07-26T13:04:51Z</updated>
<author>
<name>blasty</name>
<email>blasty@local</email>
</author>
<published>2026-07-26T13:04:51Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/ida-tui.git/commit/?id=2c2124aff2f4ed6df23512603b6e1ecdcd4b699b'/>
<id>urn:sha1:2c2124aff2f4ed6df23512603b6e1ecdcd4b699b</id>
<content type='text'>
Reported as "after c a few times and p at the entry point, Tab just flashes and
nothing decompiles". Three separate things, found by following it down:

**1. `p` failed on hand-carved code.** ida_funcs.add_func(ea) asks IDA to find
the function's end and on carved code it often can't — a run ending in a tail
call, or whose last instruction isn't recognised as a return, fails with no
reason given. add_func(ea, end) with an explicit end succeeds. define_func_run
tries IDA's way first, then falls back to the end of the contiguous instruction
run, and says which it used.

**2. The database was 64-bit, so Hex-Rays refused it regardless.** Bare `-parm`
gives an AArch64 database. Ask Hex-Rays for the failure object rather than
reading None as "dunno" and it says exactly what's wrong: "only 64-bit functions
can be decompiled in the current database". So the disassembly looked right and
F5 could never work.

That is decided at LOAD and cannot be corrected — inf_set_app_bitness(32)
afterwards makes the decompiler INTERR 50735. The fix is at the load dialog:
arm:ARMv7-A (most firmware), arm:ARMv7-M / arm:ARMv6-M (Cortex-M, Thumb only)
and arm:ARMv5TE now sit alongside 64-bit `arm`, labelled with their bitness.
With arm:ARMv7-A, experiments/fibonacci.bin decompiles:

  void __fastcall __noreturn sub_0(int a1) { int v2; v2 = sub_E3C(a1, 0); ... }

— and IDA's own auto-analysis finds 54 Thumb functions on load, versus none as
plain `arm`.

**3. `t` was silently building an undecompilable state.** It forced the SEGMENT
to 32-bit in a 64-bit database, which produces correct-looking disassembly that
F5 will never touch. It now says so and names the fix (Ctrl+L, arm:ARMv7-A)
rather than leaving you to discover it.

tools/verify_procs.py now reports each processor's resulting bitness, since that
is the reason the variants exist — and it compares against the base module name,
because a variant reports "ARM".

tests: test_thumb_ui.py +5 (13 total) — a 64-bit database warns and names the
fix, a 32-bit one finds functions by itself, Tab decompiles a Thumb function and
the result reads like C. test_formats.py +2 (34) pinning that a 32-bit variant is
offered and the ARM labels state their bitness. 209/0 scenarios, 26/0 blob, 30/0
project UI.
</content>
</entry>
<entry>
<title>arm: switch ARM/Thumb decoding with `t`</title>
<updated>2026-07-26T12:51:07Z</updated>
<author>
<name>blasty</name>
<email>blasty@local</email>
</author>
<published>2026-07-26T12:51:07Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/ida-tui.git/commit/?id=7bdf3741c91f76bfff3f3e054a5cf41b7f476d0b'/>
<id>urn:sha1:7bdf3741c91f76bfff3f3e054a5cf41b7f476d0b</id>
<content type='text'>
`c` could not carve Thumb code. Thumb isn't a property of the bytes — it's a
mode the CPU is in — so a raw image gives IDA nothing to detect: at a Thumb entry
point it decodes 16-bit instructions as 32-bit ARM and produces confident
nonsense. experiments/fibonacci.bin starts with `08 b5` = push {r3,lr}, which
IDA reads as SVCLT 0xBF00.

`t` on the listing switches the mode at the cursor and disassembles in it:

  Thumb @ 0x0 (segment set to 32-bit; Thumb needs ARM32) — 10 instructions
  0x0 PUSH {R3,LR}   0x2 MOVS R1, #0   0x4 MOV R4, R0   0x6 BL unk_E3C

Setting the T segment register is only half of it. Thumb does not exist in
AArch64, and a headerless blob loaded with -parm comes up 64-bit, so T alone
changes nothing and looks broken — I watched exactly that happen while probing
the API. Asking for Thumb IS asking for ARM32, so set_thumb forces the segment
to 32-bit and says so rather than doing it silently.

It also has to del_items over the range first: the bytes are currently decoded
in the old mode, and leaving that item defined pins the wrong instruction length
so the new mode has nothing to apply to.

Implemented as a `thumb` kind in the existing edit-item flow, so it inherits the
shared reload — same cache bump, same ViewAnchor restore, same status flash. It
switches AND disassembles, because flipping T and leaving the bytes undefined
shows you nothing and reading the code was the point.

tests: new tests/test_thumb_ui.py (8) driving the real Thumb binary — `c` alone
does NOT produce the prologue, `t` does, the instructions are 16-bit wide (in ARM
mode those three rows would be one 4-byte instruction), the run continues, the
status explains the 32-bit forcing, and `t` toggles back. Deletes the .i64 first,
because T and the segment's addressing mode are saved in it and a stale database
would answer the question for us.

209/0 scenarios, 26/0 blob, 8/0 thumb.
</content>
</entry>
<entry>
<title>loading: a blob that analyses to nothing is still openable</title>
<updated>2026-07-26T06:58:31Z</updated>
<author>
<name>blasty</name>
<email>blasty@local</email>
</author>
<published>2026-07-26T06:58:31Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/ida-tui.git/commit/?id=a0cb1a1a6f8aca7a64c102dba388e899267d5bc1'/>
<id>urn:sha1:a0cb1a1a6f8aca7a64c102dba388e899267d5bc1</id>
<content type='text'>
Pick a random .bin, say ARM at 0x4000, and you got two empty panes and
"functions still loading…" — which was a lie; loading had finished. _auto_land
falls back to the symbol picker when there's no entry function, and the picker
answers an empty index with that message. Nothing ever opened.

Zero functions is not a corner case. It's exactly what a real firmware image
looks like when it's described wrongly, and IDA has no complaint of its own to
make about it, so this was the last silent-wrong-answer in the blob path.

Now:

* Land in the listing at the start of the image. The bytes exist even when no
  code was recognised, so there is always something to show.
* Say so, in the status bar, for as long as it stays true — an image with no
  functions is a property of the database, not an event, and writing it once
  meant the next status write erased it (the same clobber that bit the split
  view's "decompiling…").
* Ctrl+L re-asks. The .i64 has the old processor and base baked in and takes
  precedence over any switches, so reloading means deleting it; the confirmation
  says what that costs, and when there are no functions it says nothing is lost.

Verified with real keys on a random 64K blob: ARM @ 0x4000 lands showing
"db 65536 dup(?)" with the hint in the status; Ctrl+L -&gt; confirm -&gt; dialog -&gt;
metapc reloads at 0. The hint survives scrolling.

tests: new tests/test_blob_ui.py (11) driving a real random blob end to end —
lands, has rows, right base, honest status, hint survives navigation, Ctrl+L
offers the reload and declining leaves the binary open. 202/0 scenarios, 30/0
project UI.

(Harness note for future me: tmux send-keys reads "0x4000" as a hex KEY CODE and
sends U+4000. Use send-keys -l for literal text.)
</content>
</entry>
<entry>
<title>formats: verify every offered processor name against a real IDA</title>
<updated>2026-07-25T22:04:38Z</updated>
<author>
<name>blasty</name>
<email>blasty@local</email>
</author>
<published>2026-07-25T22:04:38Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/ida-tui.git/commit/?id=cf8b4645e2744fff455fc09167f91d011a3e620a'/>
<id>urn:sha1:cf8b4645e2744fff455fc09167f91d011a3e620a</id>
<content type='text'>
The curated list was written from the procs/ directory listing. Two of the
twenty names were wrong, and wrong here is not a soft failure: IDA REFUSES to
open the database (rc=4) with nothing useful said. The load dialog would have
handed people a dead end from inside the UI that exists to rescue them — the
same silent-failure class the dialog was built to kill.

  h8    -&gt; h8300     (h8.so is the module FILENAME, not a processor name)
  sparc -&gt; sparcb / sparcl   (and SPARC has endianness variants, like MIPS/PPC)

Also probed the aliases people reach for first: arm64, aarch64, mips, m68k are
all invalid. 'arm' covers AArch64 (verified: an AArch64 blob analyses to 35
functions under -parm), so those names now live in the human labels, where the
filter still finds them — typing "arm64" finds ARM, "m68k" finds 68k, "mips"
finds both endiannesses.

tools/verify_procs.py does the check: open a scratch blob with -p&lt;name&gt;, read
back inf_get_procname(), compare. Fresh temp dir per name, because once a
database exists IDA ignores the load switches and every name after the first
would "pass". 21/21 verified.

tests: +11 formats, including the verified-set guard (adding a processor without
re-running the script fails on purpose) and checks that the rejected aliases are
NOT offered but ARE still findable by typing them. 32/0 formats, 199/0 scenarios.
</content>
</entry>
<entry>
<title>loading: ask how to load an unrecognised file, like IDA does</title>
<updated>2026-07-25T22:00:19Z</updated>
<author>
<name>blasty</name>
<email>blasty@local</email>
</author>
<published>2026-07-25T22:00:19Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/ida-tui.git/commit/?id=ba0b26ebb0ce16632e8a2e67675dd79a11e26551'/>
<id>urn:sha1:ba0b26ebb0ce16632e8a2e67675dd79a11e26551</id>
<content type='text'>
Last commit let you SAY how to load a blob. This one notices when you should
have. Interactive IDA pops a dialog when no loader matches; we silently loaded
as x86 at 0 and analysed to nothing, so the flag only helped people who already
knew they needed it — which is exactly the people who don't need help.

formats.sniff() recognises the formats IDA definitely handles (ELF, PE, Mach-O,
dex, wasm, ar, COFF, Intel HEX, S-records). Anything else gets
LoadOptionsScreen: a filterable processor list with human labels, a load-address
field, Enter to accept, Esc to load it the way IDA would have anyway.

Deliberate asymmetry: the sniff only claims formats it is sure about. A false
"unknown" costs one dismissible dialog; a false "known" is the silent wrong
answer this exists to kill. Esc is always an escape hatch.

The list offers 20 processors, not IDA's 73 — most of the rest are museum
pieces, and a name typed into the filter that matches nothing is taken literally
so nothing is actually unreachable. Endianness is spelled out (arm vs armb)
because getting it backwards is the most common route to zero functions.

Asked only when nobody has answered yet: not with --processor, not in project
mode (entries carry their own), and not when a database exists — the .i64
already records how the image was loaded.

Textual trap worth recording: the screen stored the file size in self._size,
which is Widget's own backing field for outer_size. Assigning an int to it
crashes layout with "'int' object has no attribute 'region'" from deep inside
_set_dirty, nowhere near the cause. Same family as the _render collision.

The paragraph conversion now lives in exactly one place (formats.load_args);
BinaryRef and launch both call it.

Verified on a real AArch64 blob: dialog appears, filtering to "arm" leaves two
entries, base 0x8000000 accepted -&gt; "-parm -b800000" -&gt; 35 functions at
0x8002440. An ELF never asks, and neither does a blob that already has a .i64.

tests: new tests/test_formats.py (21) and a load_options scenario asserting the
dialog stays out of the way for a recognised binary. 199/0 scenarios, 39/0
project, 30/0 project UI, 36/0 index, 27/0 pool, 21/0 formats.
</content>
</entry>
<entry>
<title>loading: say how to read a headerless blob (processor, base)</title>
<updated>2026-07-25T21:49:14Z</updated>
<author>
<name>blasty</name>
<email>blasty@local</email>
</author>
<published>2026-07-25T21:49:14Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/ida-tui.git/commit/?id=57b09047881d4bb42b2b98e4bb0739d5bc5e5af8'/>
<id>urn:sha1:57b09047881d4bb42b2b98e4bb0739d5bc5e5af8</id>
<content type='text'>
A raw firmware dump has no format to detect, so IDA fell back to x86 at address
0. It doesn't fail — it opens, analyses, and finds nothing. An AArch64 image
loaded this way gave 0 functions; told the truth it gives 35.

    ida-tui fw.bin --processor arm --base 0x8000000

and per binary in a project, which is what a multi-image firmware actually
needs:

    {"path": "app.bin", "processor": "arm", "base": "0x8000000"}

idapro.open_database() already accepted IDA command-line switches; nothing was
passing any. Plumbed BinaryRef -&gt; WorkerPool -&gt; WorkerClient -&gt; worker argv, plus
a load_args for the single-binary path that has no project ref.

base is written the way people say it (0x8000000, int or string, any base).
IDA's -b is in PARAGRAPHS — -b1000 loads at 0x10000 — so BinaryRef.load_args
converts, and a base that isn't 16-byte aligned is refused rather than silently
landing 16x off. ida_args passes anything else through.

Two bugs found by testing the whole path rather than the happy one:

* Project.load() whitelisted path/label when normalising entries, so the load
  options were dropped the first time a project was reopened — set a processor,
  come back tomorrow, it's gone.
* Re-passing the switches to an EXISTING database makes IDA refuse the open
  (rc != 0, no functions). The .i64 already records how the image was loaded, so
  the worker skips them once a database exists. My first guard checked
  splitext(path) + ".i64" and never fired, because IDA names it "&lt;file&gt;.i64" —
  keeping the extension. It checks both spellings now.

Verified on a real AArch64 blob: fresh load 35 functions based at 0x8002440,
reopen 35 again, and the CLI rejects an unaligned or non-numeric --base.

tests: +6 project (options recorded, paragraph conversion, file round-trip,
add() takes them, an ELF passes nothing, hex-string base). 39/0 project, 195/0
scenarios, 30/0 project UI, 36/0 index, 27/0 pool.
</content>
</entry>
<entry>
<title>xrefs: show project binaries that import this export</title>
<updated>2026-07-25T21:23:14Z</updated>
<author>
<name>blasty</name>
<email>blasty@local</email>
</author>
<published>2026-07-25T21:23:14Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/ida-tui.git/commit/?id=0a6fc6c215cc64b5da160ee755f7609913754c10'/>
<id>urn:sha1:0a6fc6c215cc64b5da160ee755f7609913754c10</id>
<content type='text'>
xrefs_to only ever sees the current database, so an exported function looks
unused from the inside even when the rest of the project calls it. The import
side of the phase-3 linkage index already knew better; nothing surfaced it.

_foreign_importers appends those callers to the xrefs dialog. From libc's
strrchr, with echo in the project:

    0000C318  import  [echo] strrchr

Read from the on-disk index, so a caller appears whether or not its worker is
resident. Choosing one carries a (binary, addr) payload instead of a bare
address; _on_xref_chosen routes that through _switch_then_goto — the same path a
project search hit takes — so it records a hop and Esc comes back.

Only fires for a symbol this binary actually EXPORTS. A local name that happens
to collide with some other binary's import is not a caller of ours, and without
that check every common name (main, read, error) would sprout fictional callers.
Names are compared after link_name(), so ELF versioning doesn't hide the match.

Verified on a real echo+libc project: the dialog lists echo's call site, and
selecting it switches to echo, lands on 0xc318 and leaves hops=['libc.so.6'].

tests: +3 project UI — a symbol we don't export gets no cross-binary callers,
the (binary, addr) payload jumps to the other binary, and it records the hop.
195/0 scenarios, 30/0 project UI.
</content>
</entry>
<entry>
<title>projects phase 4: cross-binary back, linkage-guided pre-warm, project verbs</title>
<updated>2026-07-25T21:03:10Z</updated>
<author>
<name>blasty</name>
<email>blasty@local</email>
</author>
<published>2026-07-25T21:03:10Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/ida-tui.git/commit/?id=b8d9aa4fce78f4523cffaba8b46095d2dfb66310'/>
<id>urn:sha1:b8d9aa4fce78f4523cffaba8b46095d2dfb66310</id>
<content type='text'>
Three things, all following from phase 3 making cross-binary jumps ordinary.

**A cross-binary jump was a one-way door.** Nav history is per-binary, so
arriving in another binary — a project search hit, or now following an import
into the library that implements it — landed you in an empty history with nothing
to take you back. _switch_then_goto records the binary it came FROM, and
action_back falls through to that hop once local history is spent: Esc walks back
through the function you were in, then the binary you were in. Manual Ctrl+O
switching records nothing, because that isn't navigation.

**Pre-warm follows the linkage graph, not list order.** _prewarm_provider warms
the binary providing the most of this one's imports — where a follow is most
likely to go, so its startup is paid before you ask for it. "Next in the list"
would have been arbitrary; phase 3 gave us something better to ask.

pool.prewarm() refuses rather than making room. Evicting a binary the user
visited to speculatively load one they haven't is a straight downgrade, and it
throws away that binary's caches as well; at a tight budget pre-warm just does
nothing. The cost of a worker that doesn't exist yet can only be estimated, so it
uses the largest resident one (same program, different database) — and if that
estimate proves wrong, the speculative worker is the one evicted, never a chosen
one.

**Driving a project.** pane spawn --project FILE [--open BIN]; `binaries` lists
the inventory (active / resident / indexed / where Esc returns to) and `switch
{binary,addr?}` makes another active — with an address it takes the search-hit
path, so it records a hop. state gains `binary` and `hops`, which it should have
had the moment project mode existed.

Verified on real sessions: drive binaries/switch against an echo+cat project
pane; Esc crossing back from a switch; and prewarm on echo+libc picking libc
(provider of echo's imports) and warming it after an evict.

tests: +5 pool (prewarm warms, no-ops when resident, refuses at budget, evicts
nothing when refusing, ignores unknown labels) and +4 project UI (jump records
the hop, Esc crosses back, hop consumed). Confirmed the Esc-back checks fail with
the branch removed. 195/0 scenarios, 27/0 project UI, 36/0 index, 27/0 pool,
33/0 project.

Left open: project-level persistence across sessions.
</content>
</entry>
<entry>
<title>projects phase 3: follow an import into the binary that implements it</title>
<updated>2026-07-25T20:33:16Z</updated>
<author>
<name>blasty</name>
<email>blasty@local</email>
</author>
<published>2026-07-25T20:33:16Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/ida-tui.git/commit/?id=dcb76f92e8851f747e17cae848e800a394a073e1'/>
<id>urn:sha1:dcb76f92e8851f747e17cae848e800a394a073e1</id>
<content type='text'>
Following a call to strcmp reached the PLT/extern entry and stopped there —
Hex-Rays has nothing to decompile, because the code lives in a library this
binary only references. With the other binary open in the same project we
already had everything needed to cross that gap; we just weren't indexing it.

Index each binary's imports and exports (KIND_IMPORT / KIND_EXPORT) alongside its
functions and strings. On a follow, _import_stub asks whether the target address
is one of this binary's import stubs; if so _cross_binary_impl asks the index who
exports that name, and we switch there instead of landing on the thunk.

Verified end to end on a real echo + libc project: Enter on `strrchr(a1, 47)` in
echo's pseudocode switches to libc.so.6 and lands on strrchr at 0xaf960.

Three things it turns on:

* ELF symbol versioning. The importer sees strrchr@@GLIBC_2.2.5 while the
  provider may export any of three spellings, so raw names resolve almost
  nothing. domain.link_name() cuts at the first '@'; Linkage.raw keeps what IDA
  reported, which is what the listing shows.
* Exact match, not substring — ProjectIndex.exact(), so `read` doesn't bind to
  pread/read_line/thread_start. It also answers below the 3-char trigram floor,
  and plenty of real exports are that short.
* Resolution reads the on-disk index, so a provider resolves while its worker is
  evicted. That's what the index was for.

When nothing in the project provides the symbol _follow_import declines and the
normal navigation runs: landing on the stub is still the honest answer, and a
single-binary session is unchanged. The PLT-stub PRESENTATION item stays open —
an unprovided import should say "imported, provider not in project" rather than
show a decompiler error.

server/patch_server.py gains list_linkage (idautils.Entries + enum_import_names);
a worker without it degrades to no linkage rather than failing.

tests: index join +8 (exact vs substring, short names, exclude-self, reverse
join, kind isolation, forget unresolves) and link_name +4. 36/0 index, 195/0
scenarios, 23/0 project UI, 33/0 project, 22/0 pool.
</content>
</entry>
<entry>
<title>docs: refresh PROJECTS.md phase 2/3 to match what shipped</title>
<updated>2026-07-25T13:59:10Z</updated>
<author>
<name>blasty</name>
<email>blasty@local</email>
</author>
<published>2026-07-25T13:59:10Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/ida-tui.git/commit/?id=f4bc9c0dcb95fd1b0a55fce1b3f298f5aa9d6134'/>
<id>urn:sha1:f4bc9c0dcb95fd1b0a55fce1b3f298f5aa9d6134</id>
<content type='text'>
* Phase 2: strings has the F2 toggle now, so drop the "still needs it" note.
  Record the 60-row project cap and the reason for it, and the ranking rule —
  including why the (binary, addr) tiebreak is load-bearing (a name shared by two
  binaries ties on every other field and Python then compares hit objects,
  raising TypeError; that shipped as a crash and was fixed).
* Phase 3: spell out precisely how it relates to the PLT/import-stub backlog
  item. An export index turns "follow strcmp -&gt; extrn strcmp:near, dead end" into
  a real jump into libc WHEN that library is in the project. It doesn't retire
  the item: a single-binary session, or a provider outside the project, still
  lands on a stub and still wants it recognised and presented as an import rather
  than a decompiler error.
</content>
</entry>
</feed>
