<feed xmlns='http://www.w3.org/2005/Atom'>
<title>ida-tui.git/idatui/launch.py, branch pre-codemode</title>
<subtitle>tui for headless ida</subtitle>
<id>https://git.sl0p.foo/ida-tui.git/atom/idatui/launch.py?h=pre-codemode</id>
<link rel='self' href='https://git.sl0p.foo/ida-tui.git/atom/idatui/launch.py?h=pre-codemode'/>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/ida-tui.git/'/>
<updated>2026-08-06T22:18:55Z</updated>
<entry>
<title>launch: the sweep could delete the file it was asked to open</title>
<updated>2026-08-06T22:18:55Z</updated>
<author>
<name>blasty</name>
<email>blasty@local</email>
</author>
<published>2026-08-06T22:18:55Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/ida-tui.git/commit/?id=c70acf6881c83d6de1f33022dc6210b93ed86f2e'/>
<id>urn:sha1:c70acf6881c83d6de1f33022dc6210b93ed86f2e</id>
<content type='text'>
_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.
</content>
</entry>
<entry>
<title>splash: draw the real logo on terminals that can</title>
<updated>2026-08-06T14:12:13Z</updated>
<author>
<name>blasty</name>
<email>blasty@local</email>
</author>
<published>2026-08-06T14:01:57Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/ida-tui.git/commit/?id=6978c38b8bcc2dc7fb631a2223475a3164add22b'/>
<id>urn:sha1:6978c38b8bcc2dc7fb631a2223475a3164add22b</id>
<content type='text'>
logo.ans is 60x33 cells of half-blocks -- a 60x66 pixel image. logo.png is
474x516. On a terminal that speaks the kitty graphics protocol we now send the
real thing, in the same cell footprint (fit() lands on exactly 60x33, so the
layout is unchanged), and fall back to the block art everywhere else.

Three findings, each of which cost a round of "it renders nothing":

Support cannot be sniffed from the environment. Under a multiplexer that passes
the protocol through, TERM is xterm-256color and KITTY_WINDOW_ID, TERM_PROGRAM
and COLORTERM are all empty while the protocol answers OK -- detection by
terminal name would disable graphics on exactly the terminal that supports them.
So we ask: a 1x1 graphics query plus a Primary Device Attributes request, with
DA1 as the sync point.

Unicode placeholders are not usable. The tidy way to put an image in a TUI is a
virtual placement plus U+10EEEE cells that the compositor clips and moves like
text -- and it is what every Textual image library builds on -- but this
terminal answers ENOTSUPPORTED for placeholders while supporting everything
else. So the image is placed directly, anchored to screen cells Textual knows
nothing about. The splash therefore owns its lifetime: place after layout,
re-anchor when the note repaints (throttled; a placement is one short escape
with no image data), delete on unmount, or a leftover would sit on top of the
disassembly forever.

The query and the upload go on OPPOSITE sides of the alternate screen. The query
must run before Textual starts, which reads stdin on its own thread and would
eat the reply. The image must be uploaded after Textual has switched to the
alternate screen: an image uploaded to the primary screen cannot be placed from
the alternate one, and the placement reports success while drawing nothing. That
silent failure is why detection lives in launch.py and upload lives in the
splash's on_mount.

$IDATUI_KITTY_LOG traces the decisions, since none of this is visible to a test
-- correct escape sequences and visible pixels are not the same thing here.
Off-tty (the pilot suite, a pipe) detection returns False and the block art is
used, so the tests are unaffected.
</content>
</entry>
<entry>
<title>trace: docked registers + timeline, and stepping through time</title>
<updated>2026-07-27T19:43:57Z</updated>
<author>
<name>blasty</name>
<email>blasty@local</email>
</author>
<published>2026-07-27T19:43:57Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/ida-tui.git/commit/?id=fe717b7ea9bce9abc816c7b35a4c23bccf0d7f74'/>
<id>urn:sha1:fe717b7ea9bce9abc816c7b35a4c23bccf0d7f74</id>
<content type='text'>
M0 of the trace viewer. --trace FILE loads a Tenet trace beside the binary and
docks a pane on the right: where you are in time, the register state there, and
a timeline.

Docked rather than modal on purpose. A trace turns every other view into "state
at time T", so time and registers are context you read WHILE looking at code,
not something you open and dismiss.

The registers the current instruction WROTE are highlighted. That difference is
the entire reason a delta trace is readable, and it's free — the trace already
says which registers each line changed.

] / [ step one instruction. } / { step over, by following the stack pointer: a
call pushes, so the callee runs with SP below where we started, and stepping
until SP comes back up lands after the return. That's cheaper and more portable
than recognising call instructions per architecture, and it degrades correctly —
on an instruction that calls nothing, SP is already &gt;= the start and it's one
step. Verified on a real call: t=13 -&gt; 18, past 5 instructions, where a plain
step gives 14.

The load waits for the function index because rebasing needs the database's
addresses: our echo trace runs at 0x7ffff6faa000 and the same code sits at
0x2000 in the database. Rebased -0x7ffff6fa8000, 12 functions touched.

Register values stay as the trace recorded them (they're machine state) while
everything else on screen is in database addresses, so the header shows both —
"pc 0x2aed (trace 0x7ffff6faaaed)" — rather than leaving the two to be puzzled
over side by side.

tests: test_trace_ui.py (13) records its own trace with the QEMU tracer and
drives the real UI — loads, rebases onto real functions, the dock renders, ] and
[ step and the code view follows, and } steps OVER a call found in that trace
rather than at a hardcoded index. Skips with a message if the tracer isn't
built. 209/0 scenarios.

One thing worth recording: my first attempt to add the key bindings SILENTLY did
nothing — the pattern contained a literal \\u2026 where the file has a real
ellipsis, so the replace matched nothing and the bindings never appeared. The
action worked when called directly, which made it look like a key-routing
problem. Assert on the replacement, not on the diff looking plausible.
</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: switch between a project's binaries in the TUI (phase 1c)</title>
<updated>2026-07-25T09:51:38Z</updated>
<author>
<name>blasty</name>
<email>blasty@local</email>
</author>
<published>2026-07-25T09:51:38Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/ida-tui.git/commit/?id=f4cb6eba274c411c503cbc8b4ed10fdfc6b70dcb'/>
<id>urn:sha1:f4cb6eba274c411c503cbc8b4ed10fdfc6b70dcb</id>
<content type='text'>
Wires the project model + worker pool into the app. Project mode is ADDITIVE —
without --project the app is exactly the single-binary tool it was, which is what
keeps the 167-check pilot meaningful.

* IdaTui(project=...) builds a WorkerPool and opens the project's first binary;
  _open_worker_client asks the pool instead of spawning directly.
* BinaryState snapshots what a switch leaves behind (program, func_index, nav,
  cur, view prefs, filter). Switching reuses the _after_reconnect shape: swap
  client+program, rebuild the index, reopen the entry. A still-resident binary
  restores instantly (Program + index are in memory); an evicted one gets a fresh
  worker but keeps its nav history, which is just addresses.
* ProjectPalette (Ctrl+O, + a "Switch binary…" palette command): the project's
  binaries with resident/analysed/pinned/active state and memory, filterable.
* launch.py --project FILE, creating the project when binaries are also given;
  stages everything up front so the source tree is never written to.

Two bugs found while testing:
* _did_auto_land is app-wide, but landing is per-binary: after the first binary
  landed, a cold switch never landed at all AND left the switch overlay up
  forever. Reset it per switch.
* PRE-EXISTING: the status Static had Textual markup enabled, so a single-word
  bracket marker parses as a style tag and is silently eaten — [listing] and
  [pseudocode] have never actually rendered (only [split · listing] survived,
  because the · makes it an invalid tag). Status is plain text with brackets and
  symbol names, so markup=False.

tests/test_project_ui.py: end-to-end pilot on two real binaries (18 checks) —
boot, switcher contents, switch, per-binary index, both workers resident,
switch back with state intact, return-to-where-you-were, and the promise that
the source tree stays pristine while every artifact lands in the sidecar.
Full single-binary suite unchanged at 167/2 (the standing flakes).
</content>
</entry>
<entry>
<title>mcp: collapse app + launcher to worker-only</title>
<updated>2026-07-24T12:45:28Z</updated>
<author>
<name>blasty</name>
<email>blasty@local</email>
</author>
<published>2026-07-24T12:45:28Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/ida-tui.git/commit/?id=720f9e8283257e21ed183d8dff84c7ab6777314d'/>
<id>urn:sha1:720f9e8283257e21ed183d8dff84c7ab6777314d</id>
<content type='text'>
The idalib worker is now the sole backend for opening a binary, so remove the
ida-pro-mcp code paths from the hot path:

* app.py: IdaTui.__init__ drops url/db/ensure_server/backend (now just
  open_path/keepalive/rpc_path/ttl); _connect calls _open_worker_client directly;
  _open_mcp_client deleted; _reconnect respawns the worker only; self.client and
  _after_reconnect typed WorkerClient. No more IDAClient import.
* launch.py: rewritten worker-only -- validate the binary, sweep stale locks,
  spawn the TUI (which starts the private worker behind its overlay). The whole
  supervisor dance (_ensure_server/_start_supervisor/_open_binary/
  _existing_session) is gone; `ida-tui foo.elf` is the one usage.
* tests/test_scenarios.py: pilot is worker-only (run(binary); binary via
  positional/--worker/--binary, defaults to targets/echo).

Verified: app + launch + pilot import and construct; pilot lists 31 scenarios.
The mcp modules (client.py/pane.py/tui.py/spawn.sh) still exist as dead code and
are deleted in the next commit. Worker pilot (134/2-known-flakes) still the gate.
</content>
</entry>
<entry>
<title>deprecate: make the idalib worker the default backend; mark mcp path for removal</title>
<updated>2026-07-23T23:56:47Z</updated>
<author>
<name>blasty</name>
<email>blasty@local</email>
</author>
<published>2026-07-23T23:56:47Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/ida-tui.git/commit/?id=03564655fda1682e61bbf7055c3ef310c448cf28'/>
<id>urn:sha1:03564655fda1682e61bbf7055c3ef310c448cf28</id>
<content type='text'>
Opening a binary now defaults to our own idalib worker; the ida-pro-mcp HTTP
supervisor path is deprecated (kept only for --db/attach and --backend mcp).

* launch.py: --backend default resolves to worker for a fresh binary open, mcp
  for the attach modes (--db / bare `ida-tui`, which have no worker equivalent);
  explicit --backend or IDATUI_BACKEND still wins. Logs a deprecation notice when
  the mcp path is used.
* Deprecation markers on client.py and server/patch_server.py; the ida-tui shell
  header and README now describe the worker as primary and note
  $IDATUI_WORKER_PYTHON. TODO tracks the removal checklist.

No code deleted yet — the mcp fallback stays until the worker is proven on a box
where idalib can spawn (pilot against --backend worker is the gate). Backend
resolution matrix verified: `ida-tui bash`-&gt;worker, bare/`--db`-&gt;mcp, explicit
flag/env honored.
</content>
</entry>
<entry>
<title>app: --backend {mcp,worker} — run the TUI on our idalib worker (migration step 3)</title>
<updated>2026-07-23T23:37:48Z</updated>
<author>
<name>blasty</name>
<email>blasty@local</email>
</author>
<published>2026-07-23T23:37:48Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/ida-tui.git/commit/?id=f925701e003f05160d46cc3efd8eacf3ea256aeb'/>
<id>urn:sha1:f925701e003f05160d46cc3efd8eacf3ea256aeb</id>
<content type='text'>
Wires WorkerClient into the app as a selectable backend, so you can launch:

    ida-tui --backend worker /path/to/binary        # or IDATUI_BACKEND=worker

* IdaTui gains a `backend` param. _connect is refactored into _open_mcp_client()
  (the existing ida-pro-mcp path, unchanged) and _open_worker_client() (spawns a
  private idalib worker via WorkerClient that opens+analyzes THIS binary and
  streams progress into the loading overlay). The common tail (health, keepalive,
  Program, load_functions) is shared, so domain.py and every view are untouched.
* _reconnect branches the same way: a dropped worker (segfault → closed socket)
  respawns a fresh WorkerClient — the connection-loss recovery already built
  works verbatim for the worker.
* launch.py adds --backend (env IDATUI_BACKEND); the worker path skips all the
  supervisor/server plumbing (it owns a private worker) and just sweeps stale
  locks + requires a binary. keepalive is a no-op for the worker (it never idles
  out). mcp remains the default — nothing changes unless you opt in.

Verified without idalib: all four files parse; --backend is in --help; IdaTui
constructs for both backends with both openers present; WorkerClient covers the
full client surface. The idalib E2E (experiments/worker_smoke.py, and actually
launching --backend worker) still can't run in this sandbox — it now reaps every
idalib spawn before a byte is written — but the mcp default is untouched and the
worker path reuses proven pieces (the unix protocol benched at ~50us/call; the
worker dispatches the same tool functions the HTTP path does).

To validate on a real box: ida-tui --backend worker targets/echo   (or run
experiments/worker_smoke.py for the headless read-path check).
</content>
</entry>
</feed>
