<feed xmlns='http://www.w3.org/2005/Atom'>
<title>ida-tui.git/idatui/project.py, branch main</title>
<subtitle>tui for headless ida</subtitle>
<id>https://git.sl0p.foo/ida-tui.git/atom/idatui/project.py?h=main</id>
<link rel='self' href='https://git.sl0p.foo/ida-tui.git/atom/idatui/project.py?h=main'/>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/ida-tui.git/'/>
<updated>2026-07-25T22:25:35Z</updated>
<entry>
<title>load dialog: reachable address field, project mode, and a way back from a bad answer</title>
<updated>2026-07-25T22:25:35Z</updated>
<author>
<name>blasty</name>
<email>blasty@local</email>
</author>
<published>2026-07-25T22:25:35Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/ida-tui.git/commit/?id=1e246a1136c5b5d4d84f6a796391007fa7ad0abc'/>
<id>urn:sha1:1e246a1136c5b5d4d84f6a796391007fa7ad0abc</id>
<content type='text'>
Three bugs, reported together, with one shared root: you couldn't get to the
address field, so the address went into the processor filter, so IDA got a
nonsense processor name and refused to open — and the app dead-ended with a
misleading error.

**Tab never reached any modal.** Binding("tab,shift+tab", "toggle_view",
priority=True) is an APP binding, and priority bindings run before the focus
chain. Nothing in any dialog in this app could ever be tabbed to; the load
dialog is just where it finally mattered. action_toggle_view now hands the key
back when a modal is up, which fixes it everywhere.

**...and DOM order was the wrong tab order anyway.** focus_next() stopped at the
processor list, which is arrow-driven and has nothing to type. LoadOptionsScreen
overrides it to cycle the two fields you actually type into.

**...and the dialog outgrew the terminal.** With the palette's default
max-height the 21-row list pushed the address field and help line off the bottom
of the screen. Nothing errors — the field simply isn't there, which reads as
"Tab does nothing". Capped per-dialog.

**Project mode never asked.** _should_ask_load_options bailed on
`self._project is not None` with the comment "project mode carries per-binary
options already" — true only if someone had already filled them in. A raw blob
added to a project got the silent x86-at-0 treatment the dialog exists to
prevent. Now asked at boot AND on switching to an undescribed binary, and the
answer is written back to the project entry (Project.set_load), so it is asked
once per binary, not once per run.

**A rejected answer dead-ended.** Getting a processor wrong is an ordinary
mistake; it left an empty app with "connect failed: worker exited (code 1)" and
a message blaming a locked .i64. The worker now names the real suspect when load
switches were in play, and the app re-opens the dialog instead of giving up.

Verified with real keys in a tmux pane, which is the only way any of this shows
up: Tab -&gt; address field -&gt; 0x8000000 -&gt; Enter -&gt; 35 functions at 0x80039AC; a
bogus processor -&gt; "those load options were rejected — try again" with the
dialog back; project mode -&gt; asks, loads at the right base, and the answer is in
the project file.

tests: +3 scenarios (Tab moves focus under a modal, lands on the address field,
cycles back). 202/0 scenarios, 39/0 project, 32/0 formats, 30/0 project UI.
docs/TEXTUAL_NOTES.md gets the priority-binding and clipped-modal traps.
</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>projects: don't duplicate a binary that's already in the project</title>
<updated>2026-07-25T14:26:40Z</updated>
<author>
<name>blasty</name>
<email>blasty@local</email>
</author>
<published>2026-07-25T14:26:40Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/ida-tui.git/commit/?id=391422bc805fb8620a77cc3f82787da989f1b2cf'/>
<id>urn:sha1:391422bc805fb8620a77cc3f82787da989f1b2cf</id>
<content type='text'>
`--project fw.json a.elf b.elf` where those are already listed appended them
again, so the project grew a second copy on every launch — each duplicate then
staged its own file, opened its own worker and got its own index entries.

Dedupe on the RESOLVED SOURCE PATH, which is the only identity that's actually
correct here: two different foo.elf from different directories are different
binaries and must both be accepted (the label disambiguator already gives them
foo.elf and foo.elf_2), while ./a.elf, /abs/a.elf and a symlink to it are all the
same file and must collapse to one entry.

* Project.by_source(path) — lookup by realpath.
* Project.add() returns the existing entry instead of appending a duplicate.
* Project.create() drops repeats on one command line too.
* launch.py reports what it did: "added N binary(ies)" / "N already in the
  project (matched by path) — left alone", and only rewrites the file when
  something actually changed.

Not deduped in _build_refs on load: remove() maps refs to entries by index, so
collapsing there would desync them, and a hand-edited duplicate still works
(labels disambiguate).

tests/test_project.py +8 checks: re-add is a no-op, so are a relative spelling,
a messy ../ path and a symlink; a same-named file from another directory IS
added and gets a distinct label; create() drops repeats. 33/33. Verified on the
real CLI: re-running the reported command leaves the project at 3 entries.
</content>
</entry>
<entry>
<title>projects: project model + binary staging (phase 1a)</title>
<updated>2026-07-25T09:26:11Z</updated>
<author>
<name>blasty</name>
<email>blasty@local</email>
</author>
<published>2026-07-25T09:26:11Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/ida-tui.git/commit/?id=54e5aab945d6c27edcaf7900c5d328384143fab2'/>
<id>urn:sha1:54e5aab945d6c27edcaf7900c5d328384143fab2</id>
<content type='text'>
First slice of multi-binary projects (docs/PROJECTS.md): the on-disk model, with
no runtime wiring yet.

idatui/project.py (stdlib-only, like domain/worker):
* Project.load/create/save — an explicit JSON project file listing binaries;
  paths resolve relative to it, labels default to the basename and are
  disambiguated on collision (they name files).
* A sidecar dir beside the project file (&lt;stem&gt;.idatui.d/) holds bin/ (staged
  binaries), their .i64 + scratch, and idx/ for phase 2. IDA opens the STAGED
  file, so nothing lands in the source tree — today targets/ carries ~244MB of
  IDA litter around ~13MB of binaries, much of it stale wedge files.
* stage() copies rather than hardlinks. A hardlink is free but makes source and
  staged one inode, so an in-place rebuild (cp over the path truncates instead of
  replacing) would silently swap the bytes under an analysed DB with nothing to
  detect it. The unit test caught exactly that. A copy also leaves the sidecar
  self-contained once the sources are gone.
* Re-staging a changed source drops its now-stale DB; sweep_scratch() clears the
  unpacked working files a hard-killed worker leaves behind (never the .i64).

tests/test_project.py: 27 checks, pure stdlib (no IDA/textual/worker), &lt;1s.

docs/PROJECTS.md: the full design — the one-worker-per-DB constraint with
measured costs (bash worker = 126MB RSS/117MB PSS; libcrypto's DB is 72MB, so
residency is budgeted by MEMORY, not a worker count), the switch between
"switching needs a live worker" and "searching doesn't (cached index)", and
phases 1-4.
</content>
</entry>
</feed>
