aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CONTRIBUTING.md8
-rw-r--r--README.md25
-rw-r--r--TODO4
-rw-r--r--docs/GRAPH_VIEW.md2
-rw-r--r--docs/NEXUS_UPSTREAM.md (renamed from docs/CODEMODE_UPSTREAM.md)167
-rw-r--r--docs/PAGING_FINDINGS.md14
-rw-r--r--docs/PROJECTS.md10
-rw-r--r--docs/SPLIT_VIEW.md8
-rw-r--r--experiments/bench_ops.py45
-rw-r--r--experiments/bench_pack_trace.py100
-rw-r--r--experiments/call_census.py10
-rw-r--r--experiments/profile_client.py19
-rw-r--r--experiments/profile_remote.py111
-rw-r--r--experiments/worker_smoke.py10
-rwxr-xr-xida-tui2
-rw-r--r--idatui/__init__.py6
-rw-r--r--idatui/app.py391
-rw-r--r--idatui/codemode_client.py1494
-rw-r--r--idatui/domain.py695
-rw-r--r--idatui/edit_ctl.py207
-rw-r--r--idatui/errors.py2
-rw-r--r--idatui/kittygfx.py18
-rw-r--r--idatui/launch.py16
-rw-r--r--idatui/nexus_client.py551
-rw-r--r--idatui/pane.py18
-rw-r--r--idatui/pool.py36
-rw-r--r--idatui/project.py14
-rw-r--r--idatui/remote_ops.py1683
-rw-r--r--idatui/remote_tools.py622
-rw-r--r--pyproject.toml7
-rw-r--r--tests/_fixtures.py2
-rwxr-xr-xtests/run.py4
-rw-r--r--tests/test_codemode_client.py212
-rw-r--r--tests/test_kittygfx.py59
-rw-r--r--tests/test_launch.py6
-rw-r--r--tests/test_nexus_client.py440
-rw-r--r--tests/test_pool.py43
-rw-r--r--tests/test_project.py2
-rw-r--r--tests/test_scenarios.py3320
-rw-r--r--tests/test_thumb_ui.py2
-rw-r--r--tests/test_trace_ui.py2
-rw-r--r--uv.lock28
42 files changed, 6770 insertions, 3645 deletions
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 8efdfe7..b66df1c 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -18,12 +18,12 @@ without a licence.
uv sync
```
-That pulls [ida-codemode](https://github.com/HexRaysSA/ida-codemode) from PyPI,
+That pulls [ida-nexus](https://github.com/HexRaysSA/ida-nexus) from PyPI,
which is how ida-tui talks to IDA. To also attach to databases open in the IDA
GUI:
```sh
-uvx ida-hcli plugin install ida-codemode
+uvx ida-hcli plugin install ida-nexus
```
## Running the tests
@@ -39,7 +39,7 @@ python3 tests/run.py # everything (needs IDA)
```
The IDA-backed suites need an interpreter that has `textual`, `idapro` and
-`ida_codemode` on it:
+`ida_nexus` on it:
```sh
<ida-python> tests/test_scenarios.py /path/to/binary --only rename
@@ -47,7 +47,7 @@ The IDA-backed suites need an interpreter that has `textual`, `idapro` and
```
**House rule:** a suite marked `pure` must keep running under a plain system
-`python3`. This is why `idatui/codemode_client.py` defers its `ida_codemode`
+`python3`. This is why `idatui/nexus_client.py` defers its `ida_nexus`
import instead of doing it at module top. Please don't break that — it's what
keeps the fast gate fast and lets people without IDA contribute at all.
diff --git a/README.md b/README.md
index 3eb6c60..9a751a1 100644
--- a/README.md
+++ b/README.md
@@ -27,18 +27,18 @@ Needs **Python ≥ 3.11** and **IDA Pro 9.4+ with idalib**.
uv sync
```
-That pulls [ida-codemode](https://github.com/HexRaysSA/ida-codemode) from PyPI,
+That pulls [ida-nexus](https://github.com/HexRaysSA/ida-nexus) from PyPI,
which is how ida-tui talks to IDA. To also attach to databases you have open in
the IDA GUI, install its plugin:
```sh
-uvx ida-hcli plugin install ida-codemode
+uvx ida-hcli plugin install ida-nexus
```
-Hacking on ida-codemode itself? Point at a checkout instead:
+Hacking on ida-nexus itself? Point at a checkout instead:
```sh
-uv add --editable ../ida-codemode
+uv add --editable ../ida-nexus
```
## Run
@@ -49,8 +49,21 @@ uv add --editable ../ida-codemode
```
ida-tui never owns an IDA process — it takes a **lease**. A matching database open
-in the IDA GUI is reused, otherwise Code Mode starts or shares a managed idalib
+in the IDA GUI is reused, otherwise IDA Nexus starts or shares a managed idalib
worker. Quitting drops the lease and leaves everyone else alone.
+Changes made in the GUI or another client arrive over IDA Nexus's IDB event
+stream; ida-tui debounces bursts and refreshes its cached views automatically.
+On quit with unsaved changes, a final managed-worker lease can discard the
+session without saving; GUI-backed or still-shared sessions leave that final
+decision with their owner or remaining clients.
+If the owning GUI or worker closes, ida-tui never replaces it by spawning a
+headless worker implicitly. It keeps the cached view disconnected until a
+matching owner is reopened and an attach-only rediscovery succeeds.
+Remote operations are typed, source-backed Python functions. ida-nexus installs
+their content-addressed modules once per IDA Python interpreter, so ida-tui keeps
+normal refactorable source without paying to resend hot listing/decompiler code.
+Operation attribution is also a per-call provider rather than a fixed string, so
+it can evolve from `IDA TUI` to labels such as `IDA TUI: alice`.
Headerless blobs need a hint, or IDA assumes x86 at address 0 and analyses nothing:
@@ -76,7 +89,7 @@ Thumb entry points.
| `o` `O` `B` | cycle this literal's format · reverse · opcode bytes |
| `\` `"` `ctrl+t` | hex · strings · structs |
| `ctrl+n` `ctrl+p` | symbol palette · command palette |
-| `ctrl+s` `ctrl+l` `q` | save · reload as… · quit |
+| `ctrl+r` `ctrl+s` `ctrl+l` `q` | refresh view · save · reload as… · quit |
| `F1` | all of them |
## What's in it
diff --git a/TODO b/TODO
index 726e2b1..b497c89 100644
--- a/TODO
+++ b/TODO
@@ -4,12 +4,12 @@ TODO:
[x] PORT TO ida-codemode-mcp as a library dependency
[x] DatabaseHandle discovery prefers registered GUI sessions
[x] shared managed idalib workers + SSE lease lifecycle
- [x] domain operations execute against ida-domain through Code Mode
+ [x] domain operations execute against ida-domain through IDA Nexus
[x] delete the private pickle worker and ida-pro-mcp patch injection
[x] stop sweeping/reaping resources that may belong to another client
[ ] run the full live Pilot suite against both GUI and managed backends
[ ] add database revision/change notifications for cross-client cache invalidation
- [ ] decide how "discard changes" should work (Code Mode final workers save)
+ [ ] decide how "discard changes" should work (IDA Nexus final workers save)
- [x] add support for toggling literal types, ala `o` in IDA. (decimal to hex to
reference etc.)
diff --git a/docs/GRAPH_VIEW.md b/docs/GRAPH_VIEW.md
index 3aced1f..0b24aa5 100644
--- a/docs/GRAPH_VIEW.md
+++ b/docs/GRAPH_VIEW.md
@@ -54,7 +54,7 @@ Growing a second disassembly renderer for graph mode would have been the real
cost.
The backend adds exactly one operation, `flowchart(addr)` in
-`idatui/codemode_client.py`, which returns block ranges and typed edges — **not**
+`idatui/nexus_client.py`, which returns block ranges and typed edges — **not**
text.
## Two layout engines
diff --git a/docs/CODEMODE_UPSTREAM.md b/docs/NEXUS_UPSTREAM.md
index 9d3e09f..f4dcf91 100644
--- a/docs/CODEMODE_UPSTREAM.md
+++ b/docs/NEXUS_UPSTREAM.md
@@ -1,29 +1,30 @@
-# Findings from porting a real client to IDA Code Mode
+# Findings from porting a real client to IDA Nexus
-Notes for the `ida-codemode` maintainers, gathered while porting **ida-tui** (a
+Notes for the `ida-nexus` maintainers, gathered while porting **ida-tui** (a
Textual TUI frontend for IDA) from a private idalib worker to
-`ida_codemode.DatabaseHandle`.
+`ida_nexus.DatabaseHandle`.
Everything below is measured, not inferred. Where we worked around something, the
workaround is named so you can judge whether the library should make it
unnecessary.
-**Environment:** ida-codemode 0.3.1, IDA 9.4 (idalib), Linux, single managed
+**Environment:** ida-nexus 0.3.1, IDA 9.4 (idalib), Linux, single managed
worker backend, quiet box. Target for timings: `targets/echo` unless stated.
-> **Status against 0.6.1 (upstream `439289f`) — every item re-checked.**
+> **Status against the protocol-6 event-stream development tree, based on 0.6.1
+> (upstream `439289f`) — every item re-checked.**
>
> | item | verdict |
> |---|---|
> | 1 `timeout_trace` line tracing | ✅ **fixed in 0.3.2** — no `settrace` in the runtime at all |
> | 2 `to_jsonable` on large results | ✅ **fixed in 0.3.2** — `dumps_json` C fast path |
> | 3 2 ms `execute_sync` floor | ✅ **fixed in 0.3.2, 7.0x** — 2.055 ms → 0.294 ms |
-> | 4 loader switches fatal on reopen | ✅ **fixed in 0.5.x** — see the caveat in §4 |
-> | 5 IDB replaced under a live lease | ❌ open |
-> | 6 no close-without-save | ❌ open |
-> | 7 no change notification | ⚠️ partial — `DatabaseEventCallback` exists on `DatabaseManager`, but there is still no revision counter for an *external* caching client |
+> | 4 loader switches fatal on reopen | **partial** — normal reopen fixed in 0.5.x; direct `.i64` paths remain [issue #36](https://github.com/HexRaysSA/ida-nexus/issues/36) |
+> | 5 IDB replaced under a live lease | **stale** — out-of-band replacement is outside the supported lifecycle, as it is for the IDA GUI |
+> | 6 close without save | **fixed in protocol 6** — the final managed-worker lease can choose `shutdown_database(save=False)` |
+> | 7 no change notification | ✅ **fixed in protocol 6** — `DatabaseHandle.subscribe_idb_events()` streams revisioned, operation-attributed IDB changes |
> | 8 package exports | ✅ **fixed in 0.5.x** — a real `__all__` on the package root |
-> | 9 no `py.typed` / handle Protocol | ✅ **fixed in 0.5.x** — `ida_codemode/py.typed` ships |
+> | 9 no `py.typed` / handle Protocol | ✅ **fixed in 0.5.x** — `ida_nexus/py.typed` ships |
>
> **0.5.x restructured the package**, which is why the old "these files are
> byte-identical" re-check recipe no longer works: `client.py` → `handle.py`,
@@ -31,16 +32,17 @@ worker backend, quiet box. Target for timings: `targets/echo` unless stated.
> and the loader options moved into a frozen `DatabaseOpenOptions` dataclass.
> Everything private is now underscore-prefixed, so the cheap re-check after an
> upstream pull is simply: does anything we import still appear in
-> `ida_codemode.__all__`?
+> `ida_nexus.__all__`?
>
> 0.5.3 → 0.6.1 changed **nothing** we depend on: `__init__.py`, `handle.py`,
> `instances.py`, `options.py`, `errors.py` and `models.py` are byte-identical
> between those two releases. 0.6.1 only collapses the six console scripts into a
-> single `ida-codemode` command.
+> single `ida-nexus` command.
>
-> Both of our client-side workarounds re-measured at **0.99x and 0.97x** on 0.3.2
-> — i.e. nothing. The settrace strip has been deleted; `_PACK_EPILOGUE` is kept
-> only for encoder determinism. Harness: `experiments/bench_pack_trace.py`.
+> Both client-side workarounds re-measured at **0.99x and 0.97x** on 0.3.2 —
+> i.e. nothing — and are deleted. Remote code is now ordinary typed Python,
+> installed as content-addressed modules by ida-nexus. Harness:
+> `experiments/bench_pack_trace.py`.
**What the client does**, for scale: it renders a continuous disassembly listing,
pseudocode, a CFG graph view and a hex view, paging over the database as the user
@@ -108,10 +110,10 @@ and do the same, which is an argument for fixing it in the runtime.
## 2. `to_jsonable` dominates any large result
-✅ **FIXED in 0.3.2**, via the first suggested fix below: `serialization.dumps_json`
-calls `json.dumps(value, default=to_jsonable)`, so a JSON-safe result never enters
-the Python walker. Our packing workaround now measures 0.97x and is retained only
-to pin encoder settings, not for speed.
+**FIXED in 0.3.2**, via the first suggested fix below:
+`serialization.dumps_json` calls `json.dumps(value, default=to_jsonable)`, so a
+JSON-safe result never enters the Python walker. Our packing workaround measured
+0.97x and has been deleted.
`execute_python` runs `to_jsonable()` over whatever the snippet returns. Our
answers are already JSON-safe and they are big — a 200-row listing page is
@@ -133,10 +135,10 @@ That is 114x, and it was 72% of the page's total cost before we changed it.
envelope such as `{"__json__": "<...>"}`, or simply passing `str`/`bytes`
through untouched.
-**Our workaround:** snippets `json.dumps` inside the database process and return
-one string, which the client parses. `to_jsonable` then walks a single scalar.
-Cost went 66.2 ms → ~0.6 ms. It works, but every client with a large result set
-has to discover and re-implement it.
+**Retired workaround:** snippets used to `json.dumps` inside the database process
+and return one string, which the client parsed. The typed remote API now owns
+strict argument/result encoding, and ida-tui contains no generated script
+strings or packing envelope.
---
@@ -181,7 +183,7 @@ would let chatty clients amortise it without redesigning around it.
---
-## 4. Loader switches on an existing database are a FATAL, not an error
+## 4. Loader switches on an existing database are a FATAL, not an error — one edge remains
Opening a target that already has an `.i64`, while passing spawn-only options,
kills the worker:
@@ -226,63 +228,80 @@ Our workaround is therefore deleted. **One narrow case remains**: the strip need
`input_path != source`, so passing an `.i64` path *directly* together with load
options (`ida-tui foo.i64 --processor arm`) still forwards the switches and still
fatals. Our old guard keyed on "the target IDB exists" and so covered it. It is a
-nonsense invocation and no idatui code path generates it — the project layer
+nonsense invocation and no ida-tui code path generates it — the project layer
always passes `output_database`, and `_needs_load_options` bails when an `.i64`
-exists — but if this ever resurfaces as "worker exited with status 1", that is
-where it comes from.
+exists — but the library boundary should still reject or normalize it rather
+than launch a known-fatal IDA command. Tracked upstream as
+[issue #36](https://github.com/HexRaysSA/ida-nexus/issues/36).
---
-## 5. Deleting or replacing an IDB under a live lease fails silently
+## 5. Deleting or replacing an IDB under a live lease — STALE
-A suite that did "delete the `.i64`, reopen the same path" (safe when it owned a
-private worker) now races the previous worker's lease grace. The reopen produced
-a handle that never became usable, with no error — just a database with no
-listing, and every wait timing out.
+The original suite deleted an `.i64` while a private worker still had it open,
+then immediately reopened the same path. That ownership model no longer applies:
+IDA Nexus databases are shared resources, and the IDA GUI itself does not survive
+out-of-band replacement of its open database. Detecting arbitrary filesystem
+replacement is therefore not part of the supported lifecycle.
-**Suggested fixes**
+The actionable lifecycle gaps that originally forced private-registry access are
+fixed. `find_database_owner()` and `wait_database_released()` are public exports;
+`DatabaseHandle.close(wait_for_database=True)` can wait for a final managed close;
+a draining owner remains registered until the IDB is actually closed; and
+`new_database=True` refuses to replace a live owner.
-- Detect that the IDB backing a registered instance has been removed or replaced
- and fail loudly (the registry already holds `idb_key`).
-- Expose a **public** "wait until this database is released" primitive. We needed
- one and ended up reaching into `registry.REGISTRY_DIR` and `FileLock` to build
- it, which is not an API we should be depending on.
-- Document the lease-grace window as part of the lifecycle contract.
+ida-tui now uses the public owner/release API while recreating a database and no
+longer reaches into registry locks. Owner loss is attach-only: ida-tui will
+rediscover a replacement GUI or worker, but will never turn a
+user-closing-the-GUI action into an implicit headless reopen. There is no
+remaining upstream request in this section.
---
-## 6. No close-without-save, and no rollback
+## 6. Close without save — FIXED in protocol 6
+
+`DatabaseHandle.shutdown_database(save=False)` can discard a managed idalib
+worker when the requesting handle is its only active lease and no other operation
+is running. The server rejects GUI databases and shared workers.
-A managed worker saves when its final lease closes. A GUI handle leaves GUI state
-as-is. Neither gives a client a way to say "discard what I did".
+The coherent ownership model is the **final lease**, not necessarily the lease
+that spawned the worker. Releasing a non-final lease makes no whole-database save
+decision; responsibility transfers to the leases that remain. The final client
+can save or discard the shared session. A client that needs its work to survive
+regardless of that later decision must call `save_database()` before releasing
+its lease.
-ida-tui had a "discard & quit" that we could not port; it is now "leave as-is &
-quit", and we cannot honestly promise the user their edits are not persisted.
+This does not claim to provide per-client rollback. Discard applies to all
+changes since the last database save, and attempting it while another lease is
+active is correctly rejected. That is the same ref-counted lifetime model used
+by other shared resources and requires no separate starter capability.
-**Suggested fixes:** a close policy on a lease the client created
-(`close(save=False)`), or a transaction/rollback API, or a documented
-disposable-copy pattern that clients can follow.
+The upstream gap is therefore closed. ida-tui now routes its discard action
+through `shutdown_database(save=False)`: a final managed-worker lease discards,
+while GUI-backed and still-shared sessions transfer finalization to their owner
+or remaining leases.
---
-## 7. No change notification for shared databases
+## 7. No change notification for shared databases — FIXED in protocol 6
-The lease reports liveness, not mutations. If a GUI user or another Code Mode
-client renames or retypes while we are attached, our materialised caches (name
-generation, decompilation, listing pages) are silently stale. Our own edits
-invalidate correctly; someone else's cannot.
+`DatabaseHandle.subscribe_idb_events()` now returns a closeable iterator over
+structured IDB changes. Each event carries a monotonic revision plus
+`operation_id`/`operation_label` attribution and an opaque `origin_id`.
+`DatabaseHandle.owns_event()` compares that origin with the handle's lease, so a
+caching client does not need to generate, retain, or race operation IDs itself.
-**Suggested fix — cheap and sufficient:** a monotonic database revision counter,
-bumped on any mutating operation and exposed on `/health` (and ideally on the
-lease event stream). Clients can then invalidate by comparing one integer. A full
-change feed would be better but is much more work; the counter alone would make
-shared editing safe for every caching client.
+ida-tui keeps one subscription for its active database, asks the handle to drop
+its own events, and batches peer events behind a 200 ms quiet period. One batch
+invalidates the function, listing, decompiler, graph, strings, linkage, segment
+and byte caches, then reloads the visible view in place. Closing or switching
+databases closes the subscription, so the blocking event reader does not leak.
---
## 8. Package exports and API surface stability — FIXED in 0.5.x
-`ida_codemode/__init__.py` used to export nothing, so a library consumer had to
+`ida_nexus/__init__.py` used to export nothing, so a library consumer had to
import from submodules, including things that were clearly internals (`FileLock`,
`REGISTRY_DIR`, `canonical_path`, `idb_key`, `scan_instances`) that we only
touched because no public equivalent existed.
@@ -294,9 +313,9 @@ the package root, and mark the intended-public registry helpers explicitly.
the internals moved behind an underscore:
```python
-from ida_codemode import DatabaseHandle, DatabaseOpenOptions, DatabaseInstance
-from ida_codemode import RemoteError, DatabaseBusyError, DatabaseDisconnectedError
-from ida_codemode import discover_databases, find_database_owner, wait_database_released
+from ida_nexus import DatabaseHandle, DatabaseOpenOptions, DatabaseInstance
+from ida_nexus import RemoteError, DatabaseBusyError, DatabaseDisconnectedError
+from ida_nexus import discover_databases, find_database_owner, wait_database_released
```
The two lock-poking helpers we had reimplemented client-side
@@ -323,13 +342,13 @@ can be checked against the real signature and a typo is caught statically. (We
added a test asserting our kwargs are a subset of
`inspect.signature(DatabaseHandle.open).parameters`, which is a poor substitute.)
-**0.5.x ships `ida_codemode/py.typed`**, and the 30 keyword-only options became a
+**0.5.x ships `ida_nexus/py.typed`**, and the 30 keyword-only options became a
frozen `DatabaseOpenOptions` dataclass — which is strictly better, because an
invented option name is now a `TypeError` at construction rather than something a
`**kwargs` fake swallows. Our subset test survives in two halves
(`_open_kwargs_are_real` for `open()`, `_option_fields_are_real` for the
dataclass fields), because the offline contract suite must keep running with no
-`ida_codemode` installed at all and therefore still fakes both.
+`ida_nexus` installed at all and therefore still fakes both.
---
@@ -340,10 +359,10 @@ dataclass fields), because the offline contract suite must keep running with no
| ~~1~~ | ~~`timeout_trace` line tracing~~ | ~~52x on IDA calls~~ | ✅ fixed in 0.3.2 |
| ~~2~~ | ~~`to_jsonable` on large results~~ | ~~114x on serialisation~~ | ✅ fixed in 0.3.2 |
| ~~3~~ | ~~2 ms `execute_sync` floor~~ | ~~shapes client design~~ | ✅ fixed in 0.3.2, 7.0x |
-| 7 | no change/revision counter | correctness for shared editing | yes, cheap |
-| ~~4~~ | ~~loader switches fatal on reopen~~ | ~~crashes, hard to diagnose~~ | ✅ fixed in 0.5.x (one edge, §4) |
-| 5 | replaced/deleted IDB under lease | silent hang | yes |
-| 6 | no close-without-save | a feature we had to drop | design question |
+| ~~7~~ | ~~no change/revision counter~~ | ~~correctness for shared editing~~ | ✅ fixed in protocol 6 |
+| 4 | direct `.i64` forwards loader-only options | fatal worker startup | [issue #36](https://github.com/HexRaysSA/ida-nexus/issues/36) |
+| ~~5~~ | ~~replaced/deleted IDB under lease~~ | ~~out-of-contract filesystem mutation~~ | **stale** |
+| ~~6~~ | ~~no close without save~~ | ~~could not discard a managed session~~ | **fixed in protocol 6: final lease decides** |
| ~~8~~ | ~~package exports~~ | ~~forces internal imports~~ | ✅ fixed in 0.5.x |
| ~~9~~ | ~~typed handle for fakes~~ | ~~catches a whole bug class~~ | ✅ fixed in 0.5.x (`py.typed` + options dataclass) |
@@ -356,13 +375,11 @@ worth fixing centrally rather than leaving each client to rediscover.
fixed upstream, and both client-side workarounds could be measured at parity and
retired. That is the outcome this document was written for.
-**What is left is entirely non-performance**: 0.5.x then fixed the API-surface
-items (4, 8, 9) — the package root is a real public API, `py.typed` ships, and
-the loader-switch fatal is handled in the resolver. What remains open is
-lifecycle: **5** (replacing an IDB under a live lease), **6** (close without
-save) and **7** — a monotonic revision counter on `/health`, still the cheapest
-large win for any caching client, and still the one thing an *external* client
-cannot build for itself.
+**What is left is entirely non-performance.** Items 6 through 9 are fixed, and
+item 5 is stale because out-of-band replacement is not a supported lifecycle for
+either IDA Nexus or the IDA GUI. One narrow piece remains: **4**, normalize or
+reject loader-only options when the source is itself an existing `.i64`
+([issue #36](https://github.com/HexRaysSA/ida-nexus/issues/36)).
Happy to supply the benchmark harness (it is backend-agnostic and runs against
-both our old worker and Code Mode), or to test a patch.
+both our old worker and IDA Nexus), or to test a patch.
diff --git a/docs/PAGING_FINDINGS.md b/docs/PAGING_FINDINGS.md
index bd6c38f..f343973 100644
--- a/docs/PAGING_FINDINGS.md
+++ b/docs/PAGING_FINDINGS.md
@@ -3,9 +3,9 @@
Measured against a real target: `libcrypto.so.3` (5.7 MB, **10,092 functions**,
biggest function **52,120 instructions**). These constraints drive the domain /
paging layer. The measurements below came from the former ida-pro-mcp tool
-backend. The Code Mode port preserves the adapter response shapes and conservative
+backend. The IDA Nexus port preserves the adapter response shapes and conservative
page sizes, but executes enumeration through ida-domain; old server caps and RTT
-numbers are historical rather than Code Mode constraints.
+numbers are historical rather than IDA Nexus constraints.
## Response shape (list_* / *_query tools)
@@ -93,17 +93,17 @@ disasm totals are **top-level** fields, not under `asm`:
(correct). The pseudocode view must handle "decompilation failed" gracefully —
fall back to the disassembly view or show an error panel.
-Code Mode returns the complete execution result directly; ida-tui no longer
+IDA Nexus returns the complete execution result directly; ida-tui no longer
needs MCP structured-content/download-URL recovery for large pseudocode bodies.
-## Code Mode lifecycle
+## IDA Nexus lifecycle
-`CodeModeClient` owns an authenticated SSE lease on a registered database:
+`NexusClient` owns an authenticated SSE lease on a registered database:
* A matching GUI is preferred and remains open when the TUI exits.
-* Otherwise Code Mode reuses or starts a shared managed idalib worker.
+* Otherwise IDA Nexus reuses or starts a shared managed idalib worker.
* Releasing one lease never terminates another client's session. A managed
- worker saves and exits after its final lease under Code Mode's grace policy.
+ worker saves and exits after its final lease under IDA Nexus's grace policy.
* Lease loss surfaces as `IDAConnectionError`; reconnect performs discovery
again and may bind a newly-created instance. It does not silently swap the
handle underneath an operation.
diff --git a/docs/PROJECTS.md b/docs/PROJECTS.md
index 0efee31..52dbf83 100644
--- a/docs/PROJECTS.md
+++ b/docs/PROJECTS.md
@@ -7,7 +7,7 @@ search across all of them, and (later) follow calls from one into another.
## The constraint that shapes everything
-IDA still exposes one active database per GUI/idalib process. Code Mode makes
+IDA still exposes one active database per GUI/idalib process. IDA Nexus makes
those instances discoverable and shareable: each project entry retains one
`DatabaseHandle` lease, which may target a registered GUI or a managed idalib
worker. N resident project databases can therefore mean up to N processes, but
@@ -32,13 +32,13 @@ crypto library.
Two capabilities that feel like one, but aren't:
-1. **Switching** to a binary needs a *live Code Mode lease*.
+1. **Switching** to a binary needs a *live IDA Nexus lease*.
2. **Searching across** binaries does *not* — if a per-binary index (functions,
strings, imports/exports) is cached on disk.
That split is the unlock: project-wide search stays instant across every binary,
including ones never opened this session, and only *jumping* to a hit costs a
-Code Mode attach/open.
+IDA Nexus attach/open.
## Layout
@@ -85,11 +85,11 @@ basename and must be unique (it names the staged file).
## Runtime
-- **`DatabasePool`** — one `CodeModeClient` lease per resident binary, attached
+- **`DatabasePool`** — one `NexusClient` lease per resident binary, attached
lazily on first switch and LRU-released when the advisory memory budget is
exceeded. Eviction explicitly saves managed IDBs but never implicitly saves a
GUI. Closing a lease never kills a GUI or another client's managed worker;
- Code Mode owns final worker shutdown.
+ IDA Nexus owns final worker shutdown.
- **`BinaryState`** — per binary: `client, program, nav, cur, func_index,
pref/active/split, filter`. Switching snapshots the current state and restores
the target's. `_after_reconnect` provides the client/program swap seam.
diff --git a/docs/SPLIT_VIEW.md b/docs/SPLIT_VIEW.md
index c421656..6e9b938 100644
--- a/docs/SPLIT_VIEW.md
+++ b/docs/SPLIT_VIEW.md
@@ -32,7 +32,7 @@ known technique:
The old ida-pro-mcp backend derived the per-line marker via
`cfunc.get_line_item(line, col=0, …).get_ea()`. To get the **full set**, sweep
every column of the line (`get_line_item(line, x, …).get_ea()` for `x` in
-`0..len`) and collect distinct non-`BADADDR` EAs. The Code Mode adapter's
+`0..len`) and collect distinct non-`BADADDR` EAs. The IDA Nexus adapter's
`decomp_map(ea)` operation returns
`[{line, primary_ea, eas:[…]}, …]`; invert for `ea → line`.
@@ -78,14 +78,14 @@ decomp→listing uses `ListingModel.ensure_ea`. Tab re-links from the new driver
Still single-ea per line (one instruction highlighted); the region comes in
phase 3.
-**Phase 3 — rich highlight. DONE.** The Code Mode `decomp_map` operation
-(`idatui/codemode_client.py`) sweeps `cfunc.get_line_item` across every column of
+**Phase 3 — rich highlight. DONE.** The IDA Nexus `decomp_map` operation
+(`idatui/nexus_client.py`) sweeps `cfunc.get_line_item` across every column of
each pseudocode line and collects the EAs from each item's `dstr()` (`'EA: desc'`
— the same source as the `/*ea*/` marker, so it aligns). `Program.decomp_map(ea)`
returns the per-line ea lists (cached by name-gen); the app loads it async into
`_split_eamap` / `_split_ea2line` and `_sync_split` bands the **whole** instruction
region of a C line (and uses the exact ea→line inverse for the reverse). Falls
-back to the single marker until the map lands. Verified on a real Code Mode database
+back to the single marker until the map lands. Verified on a real IDA Nexus database
(alignment + multi-instruction region band).
**Phase 4 — polish. DONE.**
diff --git a/experiments/bench_ops.py b/experiments/bench_ops.py
index 178b4bc..1c0ad69 100644
--- a/experiments/bench_ops.py
+++ b/experiments/bench_ops.py
@@ -1,4 +1,4 @@
-"""Time a realistic idatui operation mix against whatever ida-codemode is installed.
+"""Time a realistic idatui operation mix against whatever ida-nexus is installed.
The companion to `bench_pack_trace.py`: that one isolates a single workaround,
this one answers "how much faster is the whole client, on real operations".
@@ -14,19 +14,20 @@ replace or delete it::
PYTHONPATH=. ~/ida-venv/bin/python /tmp/bench_ops.py
# B: current client against the OLD library (shows what the workarounds were for)
- git -C ~/dev/ida-codemode checkout 4195f21
+ git -C ~/dev/ida-nexus checkout 4195f21
PYTHONPATH=. ~/ida-venv/bin/python /tmp/bench_ops.py
# A: the client as it SHIPPED on the old library, workarounds and all
git checkout 8550474 # the commit before the workaround removal
PYTHONPATH=. ~/ida-venv/bin/python /tmp/bench_ops.py
- git checkout main && git -C ~/dev/ida-codemode checkout main # ALWAYS restore
+ git checkout main && git -C ~/dev/ida-nexus checkout main # ALWAYS restore
-ida-codemode is installed **editable** into both venvs, so checking that repo out
+ida-nexus is installed **editable** into both venvs, so checking that repo out
swaps the backend under the TUI with no reinstall -- which is what makes this A/B
cheap.
"""
+
from __future__ import annotations
import argparse
@@ -34,7 +35,8 @@ import os
import statistics
import time
-from idatui.codemode_client import CodeModeClient
+from idatui import remote_ops
+from idatui.nexus_client import NexusClient
def bench(fn, reps: int) -> tuple[float, float]:
@@ -53,13 +55,13 @@ def main() -> int:
ap.add_argument("--reps", type=int, default=20)
args = ap.parse_args()
- client = CodeModeClient(os.path.abspath(args.target))
+ client = NexusClient(os.path.abspath(args.target))
client.connect()
handle = client._handle
# Work on the biggest function we can find, so the payload-heavy operations
# are actually payload-heavy.
- index = client.invoke("list_funcs", queries=[{"offset": 0, "count": 60}])
+ index = client.call(remote_ops.list_funcs, queries=[{"offset": 0, "count": 60}])
funcs = (index.get("result") or [{}])[0].get("data") or []
if not funcs:
print("VERDICT: FAIL - no functions")
@@ -71,19 +73,30 @@ def main() -> int:
# Synthetic: isolates the per-operation floor (execute_sync marshalling).
("empty round trip", lambda: handle.execute_python("result = 1")),
# Payload-dominated: what _PACK_EPILOGUE was written for.
- ("list_funcs 500", lambda: client.invoke(
- "list_funcs", queries=[{"offset": 0, "count": 500}])),
- ("heads 200 (listing page)", lambda: client.invoke(
- "heads", addr=ea, count=200, annotate=True)),
+ (
+ "list_funcs 500",
+ lambda: client.call(
+ remote_ops.list_funcs, queries=[{"offset": 0, "count": 500}]
+ ),
+ ),
+ (
+ "heads 200 (listing page)",
+ lambda: client.call(remote_ops.heads, addr=ea, count=200, annotate=True),
+ ),
# IDA-work-dominated: Hex-Rays, nothing upstream can move.
- ("decompile (warm)", lambda: client.invoke("decompile", addr=ea)),
- ("flowchart (graph)", lambda: client.invoke("flowchart", addr=ea)),
+ ("decompile (warm)", lambda: client.call(remote_ops.decompile, addr=ea)),
+ ("flowchart (graph)", lambda: client.call(remote_ops.flowchart, addr=ea)),
# Round-trip-dominated: small payload, so only the floor matters.
- ("xrefs_to", lambda: client.invoke("xref_query", direction="to", addr=ea)),
+ (
+ "xrefs_to",
+ lambda: client.call(remote_ops.xref_query, direction="to", addr=ea),
+ ),
]
- print(f"# target={os.path.basename(args.target)} func={ea} reps={args.reps} "
- f"backend={client.backend}")
+ print(
+ f"# target={os.path.basename(args.target)} func={ea} reps={args.reps} "
+ f"backend={client.backend}"
+ )
results = {}
for name, fn in ops:
try:
diff --git a/experiments/bench_pack_trace.py b/experiments/bench_pack_trace.py
index c6ca980..2b53afd 100644
--- a/experiments/bench_pack_trace.py
+++ b/experiments/bench_pack_trace.py
@@ -1,59 +1,49 @@
-"""Measure ``_PACK_EPILOGUE`` against the live ida-codemode runtime.
+"""Measure cold installation versus warm calls for typed remote modules.
-Our snippets return one pre-serialised JSON STRING instead of a structure, to
-dodge to_jsonable()'s Python-level walk of the result (a 200-row listing page
-is ~10k small objects). ida-codemode 0.3.2 gave that path a C fast path --
-``serialization.dumps_json`` hands the structure straight to ``json.dumps`` and
-only falls back to the walker for values the encoder rejects -- so the packing
-now costs a double encode (escaping the whole payload as a string literal) to
-avoid a walk that may no longer happen.
-
-This script answers whether packing still pays. Its sibling question, the
-``sys.settrace`` strip, is settled: 0.3.2 deleted the trace hook, the workaround
-measured 0.99x, and it has been removed.
+Historical note: this file used to benchmark ``_PACK_EPILOGUE``. Application
+scripts are no longer strings and packing is gone; the relevant design cost is
+now the one-time content-addressed module installation versus steady-state calls.
Usage::
- PYTHONPATH=. ~/ida-venv/bin/python experiments/bench_pack_trace.py [FILE]
+ PYTHONPATH=. python experiments/bench_pack_trace.py [FILE]
"""
+
from __future__ import annotations
import argparse
-import json
import os
import statistics
import time
-from idatui import codemode_client as cc
-from idatui.codemode_client import CodeModeClient
+from idatui import remote_ops
+from idatui.nexus_client import NexusClient
-def _time(fn, reps: int) -> tuple[float, float]:
- """Best-of and median wall time in ms; best-of resists co-tenant noise."""
+def timed(function, reps: int = 1) -> tuple[object, float]:
samples = []
+ result = None
for _ in range(reps):
started = time.perf_counter()
- fn()
+ result = function()
samples.append((time.perf_counter() - started) * 1000.0)
- return min(samples), statistics.median(samples)
+ return result, statistics.median(samples)
def main() -> int:
- ap = argparse.ArgumentParser()
- ap.add_argument("target", nargs="?", default="targets/bash")
- ap.add_argument("--reps", type=int, default=25)
- ap.add_argument("--rows", type=int, default=200)
- args = ap.parse_args()
+ parser = argparse.ArgumentParser()
+ parser.add_argument("target", nargs="?", default="targets/bash")
+ parser.add_argument("--reps", type=int, default=25)
+ parser.add_argument("--rows", type=int, default=200)
+ args = parser.parse_args()
- target = os.path.abspath(args.target)
- client = CodeModeClient(target)
- client.connect()
+ client = NexusClient(os.path.abspath(args.target)).connect()
- # A real listing page: the flow the workaround was tuned for.
- # list_funcs answers {"result": [{"data": [...], "total": N}]}.
- index = client.invoke("list_funcs", queries=[{"offset": 0, "count": 40}])
+ index, operations_cold = timed(
+ lambda: client.call(remote_ops.list_funcs, queries=[{"offset": 0, "count": 40}])
+ )
funcs = (index.get("result") or [{}])[0].get("data") or []
- biggest = max(funcs, key=lambda f: f.get("size") or 0, default=None)
+ biggest = max(funcs, key=lambda function: function.get("size") or 0, default=None)
if not biggest:
print("VERDICT: FAIL - no functions")
return 1
@@ -61,34 +51,26 @@ def main() -> int:
if isinstance(addr, int):
addr = hex(addr)
- page = lambda: client.invoke( # noqa: E731
- "heads", addr=addr, count=args.rows, annotate=True)
-
- # _script() reads _PACK_EPILOGUE at CALL time, so both variants share one
- # process -- one lease, one warm database, one fair baseline. _unpack()
- # passes an unpacked answer through untouched, so plain `result` works.
- packed_epilogue = cc._PACK_EPILOGUE
- results = {}
- for packing in (True, False):
- cc._PACK_EPILOGUE = packed_epilogue if packing else "\nresult\n"
- payload = page()
- rows = len(payload.get("heads", []))
- for _ in range(3): # warm caches; the first sample is always an outlier
- page()
- results[packing] = (rows, *_time(page, args.reps))
- cc._PACK_EPILOGUE = packed_epilogue
+ _, operations_warm = timed(
+ lambda: client.call(
+ remote_ops.list_funcs, queries=[{"offset": 0, "count": 40}]
+ ),
+ args.reps,
+ )
+ page = lambda: client.call( # noqa: E731
+ remote_ops.heads, addr=addr, count=args.rows, annotate=True
+ )
+ payload, tools_cold = timed(page)
+ _, tools_warm = timed(page, args.reps)
- size = len(json.dumps(payload, separators=(",", ":"), default=str)) / 1024
- print(f"target {os.path.basename(target)} backend={client.backend}")
- print(f"listing page func {addr}, {results[True][0]} rows, "
- f"{size:.1f} KiB of JSON, {args.reps} reps")
- for packing, label in ((True, "packed string (current)"),
- (False, "plain structure")):
- rows, best, med = results[packing]
- print(f" {label:<26} best {best:7.2f}ms median {med:7.2f}ms"
- f" rows={rows}")
- print(f" packing buys "
- f"{results[False][1] / results[True][1]:.2f}x")
+ print(f"target {os.path.basename(args.target)} backend={client.backend}")
+ print(f"function {addr}")
+ print(f"listing rows {len(payload.get('heads', []))}")
+ print(
+ f"operations.py cold {operations_cold:8.3f}ms warm {operations_warm:8.3f}ms"
+ )
+ print(f"remote_tools.py cold {tools_cold:8.3f}ms warm {tools_warm:8.3f}ms")
+ print(f"tools install overhead {tools_cold / max(tools_warm, 0.001):.2f}x one time")
client.close()
return 0
diff --git a/experiments/call_census.py b/experiments/call_census.py
index 1680691..17a5f94 100644
--- a/experiments/call_census.py
+++ b/experiments/call_census.py
@@ -1,7 +1,7 @@
"""Count backend round-trips per user action.
Answers "are we batching, or paying a round-trip per item?" with numbers rather
-than intent. Wraps ``CodeModeClient.invoke`` on the live app, drives a headless
+than intent. Wraps ``NexusClient.invoke`` on the live app, drives a headless
Pilot through realistic actions, and reports calls + wall time + which
operations were used for each.
@@ -25,7 +25,7 @@ from _fixtures import fast_keys, staged # noqa: E402
fast_keys()
from idatui.app import IdaTui, ListingView # noqa: E402
-from idatui.codemode_client import CodeModeClient # noqa: E402
+from idatui.nexus_client import NexusClient # noqa: E402
class Census:
@@ -34,18 +34,18 @@ class Census:
def __init__(self) -> None:
self.ops: collections.Counter = collections.Counter()
self.n = 0
- original = CodeModeClient.invoke
+ original = NexusClient.invoke
def counting(client, operation, *a, **kw):
self.n += 1
self.ops[operation] += 1
return original(client, operation, *a, **kw)
- CodeModeClient.invoke = counting
+ NexusClient.invoke = counting
self._original = original
def restore(self) -> None:
- CodeModeClient.invoke = self._original
+ NexusClient.invoke = self._original
def span(self, label: str):
return _Span(self, label)
diff --git a/experiments/profile_client.py b/experiments/profile_client.py
index 77f16e7..84abd8a 100644
--- a/experiments/profile_client.py
+++ b/experiments/profile_client.py
@@ -10,6 +10,7 @@ nothing else here can see it.
Time spent in `invoke` is the backend + transport; everything below it in the
`tottime` list is ours and is what this file is for.
"""
+
from __future__ import annotations
import argparse
@@ -19,7 +20,8 @@ import os
import pstats
import time
-from idatui.codemode_client import CodeModeClient
+from idatui import remote_ops
+from idatui.nexus_client import NexusClient
from idatui.domain import Program
@@ -28,14 +30,15 @@ def main() -> int:
ap.add_argument("binary", nargs="?", default="targets/bash")
ap.add_argument("--pages", type=int, default=60)
ap.add_argument("--lines", type=int, default=16)
- ap.add_argument("--text", action="store_true",
- help="load full pages instead of skeletons")
+ ap.add_argument(
+ "--text", action="store_true", help="load full pages instead of skeletons"
+ )
args = ap.parse_args()
- client = CodeModeClient(os.path.abspath(args.binary))
+ client = NexusClient(os.path.abspath(args.binary))
client.connect()
program = Program(client)
- regions = client.invoke("file_regions")
+ regions = client.call(remote_ops.file_regions)
rows = regions.get("regions") or regions.get("result") or []
text_seg = next((r for r in rows if ".text" in str(r.get("name", ""))), rows[0])
model = program.listing(int(str(text_seg["start"]), 16))
@@ -57,8 +60,10 @@ def main() -> int:
pr.disable()
wall = (time.perf_counter() - started) * 1000
- print(f"# {os.path.basename(args.binary)} pages={loaded} "
- f"text={want_text} {wall:.0f}ms ({wall/max(loaded,1):.2f}ms/page)")
+ print(
+ f"# {os.path.basename(args.binary)} pages={loaded} "
+ f"text={want_text} {wall:.0f}ms ({wall / max(loaded, 1):.2f}ms/page)"
+ )
buf = io.StringIO()
pstats.Stats(pr, stream=buf).sort_stats("tottime").print_stats(args.lines)
print(buf.getvalue())
diff --git a/experiments/profile_remote.py b/experiments/profile_remote.py
index ca673db..fbf17dc 100644
--- a/experiments/profile_remote.py
+++ b/experiments/profile_remote.py
@@ -1,94 +1,71 @@
-"""Profile an operation INSIDE the database process.
+"""Profile persistent ida-tui operations inside the IDA process.
-`bench_ops.py` says how long an operation takes; this says where that time
-goes. The snippet ships cProfile into the Code Mode sandbox, runs the real
-remote-library function there in a loop, and returns the stats as text -- so
-the split between IDA's own calls and OUR python in `remote_tools.py` is
-visible, which no client-side timer can see.
+The profiler itself is a typed ``RemoteModule`` function in ``remote_tools.py``;
+this file contains no generated Python source or knowledge of remote module names.
- PYTHONPATH=. ~/ida-venv/bin/python experiments/profile_remote.py [BINARY]
- PYTHONPATH=. ~/ida-venv/bin/python experiments/profile_remote.py --op decompile
+Usage::
-Read the `tottime` column: time in that function excluding subcalls. IDA
-builtins (generate_disasm_line, get_flags, next_head...) are the floor; a
-python frame from ida_tui_remote near the top is ours, and ours is fixable.
+ PYTHONPATH=. python experiments/profile_remote.py [BINARY]
+ PYTHONPATH=. python experiments/profile_remote.py --op decompile
"""
+
from __future__ import annotations
import argparse
import os
-import sys
-
-from idatui.codemode_client import CodeModeClient, _REMOTE_MODULE, _script
-# Runs in the database process. `a` is the bound argument dict.
-PROFILE = '''
-import cProfile, pstats, io, sys
-_m = sys.modules.get(%(mod)r)
-if _m is None:
- result = {"error": "remote lib not installed yet"}
-else:
- call = a["call"]
- reps = int(a["reps"])
- ns = {"_m": _m, "a": a}
- src = "for _ in range(%%d):\\n _m.%%s" %% (reps, call)
- code = compile(src, "<profile>", "exec")
- pr = cProfile.Profile()
- pr.enable()
- exec(code, ns)
- pr.disable()
- buf = io.StringIO()
- st = pstats.Stats(pr, stream=buf).sort_stats("tottime")
- st.print_stats(int(a["lines"]))
- result = {"stats": buf.getvalue(), "total": st.total_tt, "reps": reps}
-''' % {"mod": _REMOTE_MODULE}
+from idatui import remote_ops
+from idatui.nexus_client import NexusClient
CALLS = {
- # One full listing page, exactly as the background grower asks for it.
- "heads": 'heads(addr=a["addr"], count=500, annotate=True)',
- "heads_plain": 'heads(addr=a["addr"], count=500, annotate=False)',
- "heads_skeleton": 'heads(addr=a["addr"], count=500, annotate=True, text=False)',
- "decompile": 'decompile(a["addr"])',
- "disasm": 'disasm(a["addr"], 500)',
+ "heads": ("heads", {"count": 500, "annotate": True}),
+ "heads_plain": ("heads", {"count": 500, "annotate": False}),
+ "heads_skeleton": (
+ "heads",
+ {"count": 500, "annotate": True, "text": False},
+ ),
+ "decompile": ("decompile", {}),
}
def main() -> int:
- ap = argparse.ArgumentParser()
- ap.add_argument("binary", nargs="?", default="targets/bash")
- ap.add_argument("--op", default="heads", choices=sorted(CALLS))
- ap.add_argument("--reps", type=int, default=20)
- ap.add_argument("--lines", type=int, default=18)
- ap.add_argument("--addr", default=None, help="default: the .text start")
- args = ap.parse_args()
-
- client = CodeModeClient(os.path.abspath(args.binary))
- client.connect()
+ parser = argparse.ArgumentParser()
+ parser.add_argument("binary", nargs="?", default="targets/bash")
+ parser.add_argument("--op", default="heads", choices=sorted(CALLS))
+ parser.add_argument("--reps", type=int, default=20)
+ parser.add_argument("--addr", default=None, help="default: the .text start")
+ args = parser.parse_args()
+ client = NexusClient(os.path.abspath(args.binary)).connect()
addr = args.addr
if addr is None:
- regions = client.invoke("file_regions")
+ regions = client.call(remote_ops.file_regions)
rows = regions.get("regions") or regions.get("result") or []
- text = next((r for r in rows if ".text" in str(r.get("name", ""))), None)
+ text = next(
+ (row for row in rows if ".text" in str(row.get("name", ""))),
+ None,
+ )
addr = (text or rows[0])["start"] if rows else "0x0"
- print(f"# {os.path.basename(args.binary)} op={args.op} addr={addr} reps={args.reps}")
- # Prime: the remote lib installs lazily, and its lru_caches must be warm or
- # the profile measures cache misses that the real workload never pays.
- client.invoke("heads", addr=addr, count=500, annotate=True)
+ operation, call_args = CALLS[args.op]
+ call_args = {"addr": addr, **call_args}
+ print(
+ f"# {os.path.basename(args.binary)} op={args.op} "
+ f"addr={addr} reps={args.reps}"
+ )
- # _script binds the args as JSON and adds the pack epilogue, exactly as a
- # real operation is shipped -- so this measures the same path, not a
- # special one.
- out = client._unpack(client.execute_python(_script(
- {"call": CALLS[args.op], "addr": addr,
- "reps": args.reps, "lines": args.lines}, PROFILE), timeout=600))
- if "error" in out:
- print("FAILED:", out["error"])
- return 1
+ # Install the persistent tool module and warm its caches before profiling.
+ client.call(remote_ops.heads, addr=addr, count=500, annotate=True)
+ out = client.call(
+ remote_ops.profile_remote,
+ operation=operation,
+ args=call_args,
+ reps=args.reps,
+ )
per = out["total"] / out["reps"] * 1000
- print(f"# {out['total']*1000:.0f}ms total, {per:.1f}ms per call\n")
+ print(f"# {out['total'] * 1000:.0f}ms total, {per:.1f}ms per call\n")
print(out["stats"])
+ client.close()
return 0
diff --git a/experiments/worker_smoke.py b/experiments/worker_smoke.py
index 9b55138..1c2125f 100644
--- a/experiments/worker_smoke.py
+++ b/experiments/worker_smoke.py
@@ -1,6 +1,6 @@
-"""Exercise the real domain.Program through an IDA Code Mode lease.
+"""Exercise the real domain.Program through an IDA Nexus lease.
-A matching registered GUI is reused; otherwise Code Mode starts a managed
+A matching registered GUI is reused; otherwise IDA Nexus starts a managed
idalib worker. Usage: ``uv run python experiments/worker_smoke.py FILE``.
"""
from __future__ import annotations
@@ -9,15 +9,15 @@ import os
import sys
import time
-from idatui.codemode_client import CodeModeClient
+from idatui.nexus_client import NexusClient
from idatui.domain import Program
def main() -> int:
target = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else "experiments/fibonacci.elf")
- print(f"attaching Code Mode to {target}…", flush=True)
+ print(f"attaching IDA Nexus to {target}…", flush=True)
started = time.time()
- client = CodeModeClient(target)
+ client = NexusClient(target)
client.connect(progress=lambda message: print(f" {message}", flush=True))
print(
f" ready in {time.time() - started:.2f}s; backend={client.backend}; "
diff --git a/ida-tui b/ida-tui
index a488cac..de60a45 100755
--- a/ida-tui
+++ b/ida-tui
@@ -4,7 +4,7 @@
# ./ida-tui foo.elf # open a binary and drive it — that's it
#
# The launcher leases a registered IDA GUI or shared managed idalib worker
-# through ida_codemode. The selected Python must have ida-tui's dependencies;
+# through ida_nexus. The selected Python must have ida-tui's dependencies;
# override it with $IDATUI_PYTHON.
set -eu
diff --git a/idatui/__init__.py b/idatui/__init__.py
index 7da28b3..e89d8f6 100644
--- a/idatui/__init__.py
+++ b/idatui/__init__.py
@@ -1,4 +1,4 @@
-"""idatui — a keyboard-first TUI using shared IDA Code Mode databases."""
+"""idatui — a keyboard-first TUI using shared IDA Nexus databases."""
from .errors import (
IDAError,
@@ -10,7 +10,7 @@ from .errors import (
IDASessionError,
Session,
)
-from .codemode_client import CodeModeClient
+from .nexus_client import NexusClient
from .domain import (
Program,
FunctionIndex,
@@ -25,7 +25,7 @@ from .domain import (
)
__all__ = [
- "CodeModeClient",
+ "NexusClient",
"Program",
"FunctionIndex",
"DisasmModel",
diff --git a/idatui/app.py b/idatui/app.py
index d0bf403..1ee7459 100644
--- a/idatui/app.py
+++ b/idatui/app.py
@@ -10,7 +10,7 @@ Design notes:
without ever materializing 52k lines in a widget.
* All network/domain work runs in Textual worker threads; the UI never blocks.
* An address-history stack backs Enter (follow) / Esc (back), IDA-style.
-* Database lifecycle is lease-based through ida_codemode: matching GUI sessions
+* Database lifecycle is lease-based through ida_nexus: matching GUI sessions
are reused, otherwise a shared managed idalib worker is opened on demand.
"""
@@ -46,8 +46,7 @@ from textual.widgets import (
)
from textual.widgets.option_list import Option
-from . import graph
-from . import kittygfx
+from . import diag, graph, kittygfx
from .edit_ctl import EditController
from .prompt import PromptBar
from .trace_ctl import TraceController
@@ -55,8 +54,8 @@ from . import findings, search
from .highlight import CTextArea, highlight_c
from .journal import Journal
-from .errors import IDAToolError, IDAConnectionError
-from .codemode_client import CodeModeClient, registered_database
+from .errors import IDAConnectionError
+from .nexus_client import NexusClient, registered_database
from .domain import Func, Head, ListingModel, Program, Struct
# Styles for the disassembly listing.
@@ -1088,7 +1087,7 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru
def _span_segments(h: Head, fallback: Style):
"""Segments for a row's disassembly text.
- Uses IDA's own token classification when Code Mode supplies it; falls
+ Uses IDA's own token classification when IDA Nexus supplies it; falls
back to the mnemonic/rest split when spans are absent or disagree with
the plain text.
"""
@@ -3695,6 +3694,7 @@ _HELP = (
("Ctrl+B", "show/hide the names pane"),
("Ctrl+T", "structs / types editor"),
("Ctrl+F", "search the database: text or bytes"),
+ ("Ctrl+R", "refresh the current view in place"),
("Ctrl+E", "export findings as markdown"),
("Ctrl+P", "command palette"),
)),
@@ -3748,14 +3748,14 @@ _HELP = (
class QuitScreen(ModalScreen):
"""Asked before exiting with unsaved database changes.
- Code Mode clients cannot roll a shared database back. The ``d`` choice means
- "do not explicitly save": a GUI keeps the changes dirty, while a managed
- idalib worker may persist them when its final lease closes.
+ A final managed-worker lease can discard the whole session. Shared workers
+ and GUI databases keep their state: releasing this lease transfers the final
+ save/discard decision to the remaining client or GUI owner.
"""
BINDINGS = [
Binding("s", "save", "Save & quit"),
- Binding("d", "discard", "Leave & quit"),
+ Binding("d", "discard", "Discard / leave"),
Binding("escape,c", "cancel", "Cancel"),
]
@@ -3771,8 +3771,11 @@ class QuitScreen(ModalScreen):
body = Text()
for label in self._labels:
body.append(f" \u2022 {label}\n", _S_LABEL)
+ body.append(
+ "\nFinal managed leases discard; shared/GUI sessions stay open.",
+ _S_DIM)
yield Static(body, id="quit-list")
- yield Static("s save & quit d leave as-is & quit Esc cancel",
+ yield Static("s save & quit d discard / leave & quit Esc cancel",
id="quit-help")
def action_save(self) -> None:
@@ -5174,6 +5177,7 @@ class IdaTui(App):
Binding("ctrl+n", "symbols", "Symbols"),
Binding("ctrl+t", "structs", "Structs"),
Binding("ctrl+f", "find", "Find"),
+ Binding("ctrl+r", "refresh_view", "Refresh", show=False),
Binding("ctrl+e", "export", "Export", show=False),
Binding("backslash", "hex", "Hex"),
Binding("s", "toggle_split", "Split", show=False),
@@ -5244,7 +5248,7 @@ class IdaTui(App):
self._open_path = open_path
self._ttl = ttl
self._load_args = load_args or "" # first-open options for a headerless blob
- self._new_database = False # Ctrl+L asks Code Mode for a fresh IDB
+ self._new_database = False # Ctrl+L asks IDA Nexus for a fresh IDB
self._title = (os.path.basename(open_path) if open_path else "")
#: Where we are in the execution trace, and everything that moves us.
#: Owns the trace state; the _trace/_t/_trail_* properties below
@@ -5253,7 +5257,7 @@ class IdaTui(App):
self._do_keepalive = keepalive
self._rpc_path = rpc_path
self._rpc = None
- self.client: CodeModeClient | None = None
+ self.client: NexusClient | None = None
self.program: Program | None = None
self._loading_screen: LoadingScreen | None = None
self._ka = None
@@ -5294,6 +5298,11 @@ class IdaTui(App):
self.journal = Journal()
self._xref_focus_name: str | None = None
self._dirty = False
+ # One subscription for the active database. NexusClient debounces
+ # bursts off the Textual worker pool; the callback re-enters here on the
+ # UI thread to invalidate and reload the visible models.
+ self._idb_event_watch = None
+ self._idb_refresh_seq = 0
# -- layout ------------------------------------------------------------ #
def compose(self) -> ComposeResult:
@@ -5363,7 +5372,7 @@ class IdaTui(App):
if self._rpc_path:
self._start_rpc()
# A file no loader recognises has to be described before it can be
- # opened, so ask BEFORE Code Mode creates it — once IDA has made a database
+ # opened, so ask BEFORE IDA Nexus creates it — once IDA has made a database
# the answer is baked in and changing it requires a fresh-IDB reopen.
if self._project is not None:
ref = self._pending_load_ref()
@@ -5436,7 +5445,7 @@ class IdaTui(App):
ref = self._project.by_label(self._binary)
if ref is not None:
path, label = ref.source, ref.label
- # Release our lease first. Code Mode waits for a managed worker's final
+ # Release our lease first. IDA Nexus waits for a managed worker's final
# lease grace, then creates the replacement IDB atomically. A GUI-backed
# database is rejected by _can_reload(): the TUI must never close it.
self._release_database()
@@ -5455,6 +5464,7 @@ class IdaTui(App):
self._ask_load_options(path, label=label)
def _release_database(self) -> None:
+ self._stop_idb_event_watch()
if self.program is not None:
self.program.close()
if self._pool is not None and self._binary is not None:
@@ -5633,9 +5643,142 @@ class IdaTui(App):
pass
return len(text)
+ # -- live refresh from shared IDB changes ----------------------------- #
+ def _start_idb_event_watch(self, client: NexusClient) -> None:
+ self._stop_idb_event_watch()
+ watch = getattr(client, "watch_idb_events", None)
+ if watch is None: # IDA-free test doubles and pre-event adapters
+ return
+
+ def changed(events) -> None: # listener thread
+ try:
+ self.call_from_thread(self._refresh_idb_events, client, events)
+ except Exception: # noqa: BLE001 -- app teardown can win this race
+ pass
+
+ def failed(error: BaseException) -> None: # listener thread
+ try:
+ self.call_from_thread(self._idb_event_watch_failed, client, error)
+ except Exception: # noqa: BLE001 -- app teardown can win this race
+ pass
+
+ self._idb_event_watch = watch(
+ changed, on_error=failed, debounce=0.2)
+
+ def _stop_idb_event_watch(self) -> None:
+ watcher, self._idb_event_watch = self._idb_event_watch, None
+ if watcher is not None:
+ watcher.close()
+
+ def _idb_event_watch_failed(
+ self, client: NexusClient, error: BaseException
+ ) -> None:
+ if client is not self.client:
+ return
+ if isinstance(error, IDAConnectionError):
+ self._on_connection_lost()
+ else:
+ self._status(f"live database refresh stopped: {error}")
+
+ def _listing_event_anchor(self) -> ViewAnchor:
+ """Capture the listing position even when the split's decompiler has focus."""
+ anchor = ViewAnchor(view=self._active)
+ listing = self.query_one(ListingView)
+ model = listing.model
+ if model is None:
+ return anchor
+ anchor.cursor_x = listing.cursor_x
+ anchor.ea = listing._cursor_ea()
+ top = round(listing.scroll_offset.y)
+ head = model.cached_line(top) or model.get(top)
+ anchor.top_ea = getattr(head, "ea", None)
+ return anchor
+
+ def _refresh_idb_events(
+ self, client: NexusClient, events: tuple[dict, ...]
+ ) -> None:
+ """Invalidate once per external edit burst and reload the active surface."""
+ program = self.program
+ if not events or client is not self.client or program is None \
+ or program.client is not client:
+ return
+ self._idb_refresh_seq += 1
+ seq = self._idb_refresh_seq
+ entry = self._cur
+ anchor = self._listing_event_anchor()
+ hex_ea = self.query_one(HexView).cursor_va() if self.is_hex else None
+ graph_ea = self.query_one(GraphView)._cursor_ea() if self.is_graph else None
+ decomp = self.query_one(DecompView)
+ if entry is not None and decomp.loaded_ea == entry.ea:
+ entry.dec_cursor = decomp.cursor
+ entry.dec_cursor_x = decomp.cursor_x
+ entry.dec_scroll_y = round(decomp.scroll_offset.y)
+ entry.dec_scroll_x = round(decomp.scroll_offset.x)
+
+ program.invalidate_external()
+ self._status(
+ f"{len(events)} external database "
+ f"change{'s' if len(events) != 1 else ''} — refreshing…")
+ self._reindex_functions()
+
+ if self.is_hex:
+ self.query_one(HexView).model = None
+ self._load_hex_model(hex_ea)
+ return
+ if self.is_graph and entry is not None:
+ self._load_graph(entry.ea, graph_ea or entry.ea)
+ return
+ if entry is None:
+ return
+
+ # A split needs both halves rebuilt; a decompiler-only view still keeps
+ # the hidden listing fresh so Tab does not reveal pre-event rows.
+ decomp.loaded_ea = None
+ self._reload_idb_listing(program, seq, entry, anchor)
+ if self.is_decomp or self._split:
+ self._show_active()
+
+ @work(thread=True, exclusive=True, group="idb-refresh")
+ def _reload_idb_listing(
+ self, program: Program, seq: int, entry: NavEntry, anchor: ViewAnchor
+ ) -> None:
+ target = anchor.ea if anchor.ea is not None else entry.ea
+ model = program.listing(target)
+ cursor = top = -1
+ if model is not None:
+ model.ensure_ea(target)
+ cursor, top = self._anchor_rows(anchor, model, target)
+ fn = program.function_of(entry.ea)
+ name = fn.name if fn is not None else program.region_label(entry.ea)
+ self.app.call_from_thread(
+ self._apply_idb_listing, program, seq, entry, model,
+ cursor, top, anchor.cursor_x, name, fn is None)
+
+ def _apply_idb_listing(
+ self, program: Program, seq: int, entry: NavEntry, model,
+ cursor: int, top: int, cursor_x: int, name: str, is_region: bool,
+ ) -> None:
+ if program is not self.program or seq != self._idb_refresh_seq \
+ or entry is not self._cur:
+ return
+ if model is None:
+ self._status(f"{entry.ea:#x} is no longer in a loaded segment")
+ return
+ entry.name = name
+ entry.is_region = is_region
+ entry.cursor = max(cursor, 0)
+ entry.cursor_x = cursor_x
+ if top >= 0:
+ entry.scroll_y = top
+ self.query_one(ListingView).load(
+ model, name, cursor=entry.cursor, cursor_x=entry.cursor_x,
+ scroll_y=top if top >= 0 else None)
+ if self.is_listing:
+ self._show_active()
+
# -- connection loss / recovery --------------------------------------- #
def _handle_exception(self, error: BaseException) -> None:
- """Intercept a lost Code Mode lease so the app can rediscover the DB.
+ """Intercept a lost IDA Nexus lease so the app can rediscover the DB.
Everything unrelated to database connectivity crashes as usual.
"""
@@ -5670,15 +5813,22 @@ class IdaTui(App):
@work(thread=True, exclusive=True, group="reconnect")
def _reconnect(self) -> None:
- # The registered instance disappeared. Rediscover it; Code Mode may find
- # a GUI/replacement worker, then we rebuild caches against the new handle.
+ # Rediscovery is attach-only. If a GUI owner closes its database, a TUI
+ # must not silently reopen it by spawning a headless worker.
try:
if self._open_path is None:
self.app.call_from_thread(self._reconnect_failed,
"no binary to reopen")
return
- client = CodeModeClient(self._open_path, ttl=self._ttl,
- load_args=self._load_args)
+ if self._project is not None and self._binary is not None:
+ ref = self._project.by_label(self._binary)
+ client = NexusClient(
+ ref.staged, ttl=self._ttl, load_args=ref.load_args,
+ output_database=ref.db, spawn=False)
+ else:
+ client = NexusClient(
+ self._open_path, ttl=self._ttl,
+ load_args=self._load_args, spawn=False)
client.connect(progress=lambda m: self.app.call_from_thread(
self._conn_note, m))
except Exception as e: # noqa: BLE001
@@ -5686,21 +5836,33 @@ class IdaTui(App):
return
self.app.call_from_thread(self._after_reconnect, client, Program(client))
- def _after_reconnect(self, client: "CodeModeClient", program: "Program") -> None:
+ def _after_reconnect(self, client: "NexusClient", program: "Program") -> None:
+ old_client, old_program = self.client, self.program
+ self._stop_idb_event_watch()
+ if old_program is not None:
+ old_program.close()
+ if self._pool is not None and self._binary is not None:
+ self._pool.replace_client(self._binary, old_client, client)
+ if old_client is not None and old_client is not client:
+ old_client.close()
self.client = client
self.program = program
+ self._start_idb_event_watch(client)
self._reconnecting = False
+ self._dirty = False
self._dismiss_conn()
- self._status("reconnected \u2014 reloading\u2026")
- self._load_functions() # rebuild the function index against the new client
+ self._status("reattached — reloading persisted state…")
+ self._load_functions()
cur = self._cur
- if cur is not None: # refresh the current view with the new program
+ if cur is not None:
self._open_entry(cur, push=False)
def _reconnect_failed(self, why: str) -> None:
self._reconnecting = False
- self._conn_note(f"reconnect failed: {why} \u2014 retry on next action, or 'q'")
- self._status(f"reconnect failed: {why}")
+ note = (f"database owner closed: {why} — reopen it in IDA, then "
+ "Esc and retry an action; or q to quit")
+ self._conn_note(note)
+ self._status(note)
# -- connection + initial load ---------------------------------------- #
@work(thread=True, exclusive=True, group="connect")
@@ -5727,13 +5889,14 @@ class IdaTui(App):
return
self.client = client
self.program = program
+ self._start_idb_event_watch(client)
self._new_database = False
self.app.call_from_thread(
self._status, f"{module} [{client.backend}] — loading functions…")
self._load_functions()
def _open_database_client(self): # type: ignore[no-untyped-def]
- """Attach through Code Mode, reusing a GUI or managed idalib database."""
+ """Attach through IDA Nexus, reusing a GUI or managed idalib database."""
if self._pool is not None: # project mode: the pool owns the leases
label = self._binary or self._project.refs[0].label
client = self._pool.get(label, progress=lambda m:
@@ -5745,13 +5908,13 @@ class IdaTui(App):
return client
if not self._open_path:
self.app.call_from_thread(
- self._status, "Code Mode needs a database or executable path")
+ self._status, "IDA Nexus needs a database or executable path")
self.app.call_from_thread(self._dismiss_loading)
return None
base = os.path.basename(self._open_path)
self.app.call_from_thread(
- self._status, f"discovering Code Mode database for {base}…")
- client = CodeModeClient(self._open_path, ttl=self._ttl,
+ self._status, f"discovering IDA Nexus database for {base}…")
+ client = NexusClient(self._open_path, ttl=self._ttl,
load_args=self._load_args,
new_database=self._new_database)
client.connect(progress=lambda m: self.app.call_from_thread(
@@ -5825,7 +5988,7 @@ class IdaTui(App):
@work(thread=True, exclusive=True, group="index")
def _index_binary(self) -> None:
"""Fold this binary's symbols + strings into the project index, so it can
- be searched later even when its Code Mode lease is gone."""
+ be searched later even when its IDA Nexus lease is gone."""
if self._index is None or self._project is None or self._binary is None:
return
ref = self._project.by_label(self._binary)
@@ -5928,7 +6091,7 @@ class IdaTui(App):
cursor=0, push=True, is_region=True)
def _can_reload(self) -> bool:
- """Whether Code Mode can replace this IDB with different options.
+ """Whether IDA Nexus can replace this IDB with different options.
A GUI database is owned by the user and has no remote close/rollback
route. Managed idalib databases can be released and reopened fresh.
@@ -6123,11 +6286,13 @@ class IdaTui(App):
def _on_quit_choice(self, choice: str | None) -> None:
if choice == "discard":
- # Code Mode has no rollback/close-without-save operation. For GUI
- # sessions this leaves changes dirty in IDA; a managed worker owns
- # its final save policy and may persist them on final lease release.
- self._save_on_exit = False
- self.exit()
+ # Whole-session discard is legal only for the final managed lease.
+ # GUI/shared sessions retain state and inherit finalization.
+ dirty = self._dirty_labels()
+ self._loading_screen = LoadingScreen(
+ "discarding", note="finalizing database leases…")
+ self.push_screen(self._loading_screen)
+ self._discard_then_exit(dirty)
elif choice == "save":
# Save with the overlay up: writing a big .i64 takes seconds, and
# doing it during teardown would look like a hang with no UI left.
@@ -6137,6 +6302,31 @@ class IdaTui(App):
# None: cancel, stay put
@work(thread=True, exclusive=True, group="save-exit")
+ def _discard_then_exit(self, dirty: list[str]) -> None:
+ try:
+ if self._pool is not None:
+ transferred = self._pool.discard_changes(dirty)
+ elif self.client is not None:
+ transferred = [] if self.client.discard_database() else dirty
+ else:
+ transferred = dirty
+ except Exception as exc: # noqa: BLE001 -- keep the app open on failure
+ self.app.call_from_thread(self._discard_failed, str(exc))
+ return
+ self.app.call_from_thread(self._finish_discard, transferred)
+
+ def _discard_failed(self, why: str) -> None:
+ self._dismiss_loading()
+ self._status(f"discard failed: {why}", priority=True)
+
+ def _finish_discard(self, transferred: list[str]) -> None:
+ if transferred and self._loading_screen is not None:
+ labels = ", ".join(transferred)
+ self._loading_screen.update_note(
+ f"finalization transferred: {labels}")
+ self._finish_exit()
+
+ @work(thread=True, exclusive=True, group="save-exit")
def _save_then_exit(self) -> None:
try:
if self._pool is not None:
@@ -6148,7 +6338,9 @@ class IdaTui(App):
self.app.call_from_thread(self._finish_exit)
def _finish_exit(self) -> None:
- self._save_on_exit = False # already written above
+ # Teardown must not save again: save, discard, or ownership transfer was
+ # already decided by the quit path.
+ self._save_on_exit = False
self._dirty = False
self.exit()
@@ -6215,6 +6407,7 @@ class IdaTui(App):
def _after_switch(self, label, client, program, st, reuse) -> None: # type: ignore[no-untyped-def]
self.client = client
self.program = program
+ self._start_idb_event_watch(client)
self._binary = label
self._pool.set_active(label)
self._open_path = self._project.by_label(label).staged
@@ -6327,6 +6520,127 @@ class IdaTui(App):
return
self._goto_ea(addr, push=True) # land on the literal in the listing
+ def action_refresh_view(self) -> None:
+ """Ctrl+R: discard cached data and reload the visible view in place."""
+ if self.program is None or self._cur is None:
+ self._status("nothing to refresh")
+ return
+ if self._prompt_active() or self.screen is not self.screen_stack[0]:
+ return
+
+ if self.is_hex:
+ hx = self.query_one(HexView)
+ if hx.model is None:
+ self._status("hex: nothing to refresh")
+ return
+ self._status("hex — refreshing…")
+ hx.model.invalidate()
+ # Re-read the visible blocks off the UI thread. ``center=False``
+ # preserves both the byte cursor and the viewport.
+ hx._prime(center=False)
+ return
+
+ cur = self._cur
+ mode = self._active
+ split = self._split
+ listing_anchor = None
+ if self.is_listing or split:
+ # _anchor() follows the active pane. In split mode pseudocode may be
+ # active, but the listing must still round-trip through addresses:
+ # row indices do not survive an external structure change.
+ lst = self.query_one(ListingView)
+ listing_anchor = ViewAnchor(view=ViewMode.LISTING,
+ cursor_x=lst.cursor_x)
+ model = lst.model
+ if model is not None:
+ listing_anchor.ea = lst._cursor_ea()
+ top = round(lst.scroll_offset.y)
+ h = model.cached_line(top) or model.get(top)
+ listing_anchor.top_ea = getattr(h, "ea", None)
+
+ refresh_decomp = self.is_decomp or split
+ if refresh_decomp:
+ # Keep the live pseudocode position; NavEntry is only updated when
+ # navigating away and may lag behind the widget.
+ dec = self.query_one(DecompView)
+ if dec.loaded_ea == cur.ea:
+ cur.dec_cursor = dec.cursor
+ cur.dec_cursor_x = dec.cursor_x
+ cur.dec_scroll_y = round(dec.scroll_offset.y)
+ cur.dec_scroll_x = round(dec.scroll_offset.x)
+ dec.loading = True
+
+ want_ea = (self.query_one(GraphView)._cursor_ea()
+ if self.is_graph else None)
+ if self.is_graph:
+ self._graph_sticky = True
+ self._status(f"{cur.name} — refreshing graph…")
+ else:
+ self._status(f"{cur.name} — refreshing…")
+ self._refresh_view(cur, mode, split, listing_anchor,
+ refresh_decomp, want_ea, self.program)
+
+ @work(thread=True, exclusive=True, group="refresh-view")
+ def _refresh_view(self, cur: NavEntry, mode: ViewMode, split: bool,
+ anchor: ViewAnchor | None, refresh_decomp: bool,
+ want_ea: int | None, program) -> None: # type: ignore[no-untyped-def]
+ """Invalidate and rebuild without blocking Textual's event loop."""
+ try:
+ program.bump_items()
+ if refresh_decomp:
+ program.force_recompile(cur.ea)
+
+ model = None
+ cursor = top = -1
+ if anchor is not None:
+ target = anchor.ea if anchor.ea is not None else cur.ea
+ model = program.listing(target)
+ if model is not None:
+ model.ensure_ea(target)
+ cursor, top = self._anchor_rows(anchor, model, target)
+ except Exception as exc: # noqa: BLE001 -- a refresh is recoverable
+ diag.note("refresh_view", exc)
+ self.app.call_from_thread(
+ self._view_refresh_failed, cur, program, str(exc))
+ return
+ self.app.call_from_thread(
+ self._apply_view_refresh, cur, mode, split, anchor,
+ refresh_decomp, want_ea, program, model, cursor, top)
+
+ def _view_refresh_failed(self, cur: NavEntry, program, error: str) -> None: # type: ignore[no-untyped-def]
+ if self.program is program and self._cur is cur:
+ self.query_one(DecompView).loading = False
+ self._status(f"refresh failed: {error}", priority=True)
+
+ def _apply_view_refresh(self, cur: NavEntry, mode: ViewMode, split: bool,
+ anchor: ViewAnchor | None, refresh_decomp: bool,
+ want_ea: int | None, program, model, cursor: int,
+ top: int) -> None: # type: ignore[no-untyped-def]
+ # A binary switch or navigation completed while the refresh was in
+ # flight. Its newer view wins; never drag the user back.
+ if self.program is not program or self._cur is not cur:
+ return
+ self._active = mode
+ self._split = split
+
+ if self.is_graph:
+ self._load_graph(cur.ea, want_ea)
+ return
+
+ if anchor is not None and model is not None:
+ lst = self.query_one(ListingView)
+ cur.cursor = max(cursor, 0)
+ cur.cursor_x = anchor.cursor_x
+ cur.scroll_y = top
+ lst.load(model, cur.name, cursor=cur.cursor,
+ cursor_x=cur.cursor_x,
+ scroll_y=top if top >= 0 else None)
+ if refresh_decomp:
+ self.query_one(DecompView).loaded_ea = None
+ self._show_active()
+ if not refresh_decomp:
+ self._status(f"{cur.name} — refreshed", priority=True)
+
def action_toggle_view(self) -> None:
"""Tab: switch the code pane between disassembly and pseudocode (or leave
the hex view back to the preferred code view)."""
@@ -8261,6 +8575,7 @@ class IdaTui(App):
# -- teardown ---------------------------------------------------------- #
async def on_unmount(self) -> None:
+ self._stop_idb_event_watch()
if self._rpc is not None:
await self._rpc.stop()
if self._ka is not None:
diff --git a/idatui/codemode_client.py b/idatui/codemode_client.py
deleted file mode 100644
index 8eb66b5..0000000
--- a/idatui/codemode_client.py
+++ /dev/null
@@ -1,1494 +0,0 @@
-"""Client adapter from ida-tui's domain operations to IDA Code Mode.
-
-``DatabaseHandle`` is the lifecycle boundary: it discovers an already-registered
-GUI database, reuses a shared managed idalib worker, or starts one when needed.
-The TUI never owns or terminates an IDA process. Closing this client releases
-only its lease.
-
-The Code Mode transport intentionally exposes one broad operation,
-``execute_python``. ``CodeModeClient.invoke`` turns the small, address-centric
-operations needed by the paging layer into self-contained snippets. The
-snippets prefer the public ``ida-domain`` ``db`` object. A handful of features
-that ida-domain does not currently expose (IDA-coloured listing rows, creating
-instructions, ARM T-state, and detailed Hex-Rays line maps/failures) use the
-IDAPython modules that Code Mode deliberately makes importable.
-"""
-from __future__ import annotations
-
-import hashlib
-import json
-import os
-import shlex
-import threading
-import time
-from pathlib import Path
-from textwrap import dedent
-from typing import Any
-
-from .errors import IDAConnectionError, IDATimeoutError, IDAToolError, Session
-
-# ida_codemode is imported EAGERLY-IF-PRESENT but never at hard import cost.
-#
-# The paging/graph/trace layers and their offline test suites must keep importing
-# `idatui` on a machine with no IDA and no Code Mode installed -- that is the
-# house rule the stdlib-only worker client used to satisfy for free, and
-# `tests/run.py --fast` (380 checks, any python3) depends on it. A hard top-level
-# import here makes the whole package unimportable, so the failure is deferred to
-# the first operation that genuinely needs the library.
-_CODEMODE_ERROR: Exception | None = None
-try:
- from ida_codemode import (
- CodeModeConnectionError,
- DatabaseBusyError,
- DatabaseDisconnectedError,
- DatabaseHandle,
- DatabaseInstance,
- DatabaseOpenOptions,
- RemoteError,
- find_database_owner,
- wait_database_released,
- )
-except ImportError as _exc: # library absent: usable only for offline layers
- _CODEMODE_ERROR = _exc
- # Bound to None rather than left undefined so the names stay patchable: the
- # offline contract tests inject a fake DatabaseHandle here.
- CodeModeConnectionError = DatabaseDisconnectedError = RemoteError = None # type: ignore[assignment,misc]
- DatabaseBusyError = DatabaseHandle = DatabaseInstance = None # type: ignore[assignment,misc]
- DatabaseOpenOptions = find_database_owner = wait_database_released = None # type: ignore[assignment]
-
-
-def _require_codemode() -> None:
- """Raise an actionable error when the Code Mode library is missing.
-
- Gated on the binding, not on the original import result, so a test that
- injects a fake ``DatabaseHandle`` exercises the real adapter logic.
- """
- if DatabaseHandle is None:
- raise IDAConnectionError(
- "ida-codemode is not installed in this environment "
- f"({_CODEMODE_ERROR}). Install it (e.g. `uv sync`, or "
- "`pip install ida-codemode`) so ida-tui can lease a "
- "database.") from _CODEMODE_ERROR
-
-
-def database_owner(idb_path: str, staged_path: str | None = None):
- """The Code Mode instance that owns ``idb_path``/``staged_path``, else None.
-
- Returns None when the Code Mode library is absent: with no library there is
- no client in this environment that could be holding the database, and the
- IDA-free layers (project staging) must keep working. Discovery errors with
- the library installed still propagate because unknown ownership is unsafe.
- """
- if DatabaseHandle is None:
- return None
- if staged_path:
- owner = find_database_owner(
- staged_path,
- output_database=idb_path,
- timeout=0.5,
- )
- return owner or find_database_owner(staged_path, timeout=0.5)
- return find_database_owner(idb_path, timeout=0.5)
-
-
-def registered_database(path: str, output_database: str | None = None) -> bool:
- """Whether a live/lock-held Code Mode instance owns this target."""
- _require_codemode()
- return (
- find_database_owner(
- path,
- output_database=output_database,
- timeout=0.5,
- )
- is not None
- )
-
-
-class _NoopKeepAlive:
- """Compatibility shim: the DatabaseHandle's SSE lease is the heartbeat."""
-
- def __init__(self) -> None:
- self.beats = self.failures = 0
-
- def start(self) -> "_NoopKeepAlive":
- return self
-
- def stop(self) -> None:
- pass
-
-
-def _parse_load_args(value: str) -> tuple[str | None, int | None, str | None]:
- """Translate ida-tui's legacy first-open switches to Code Mode options.
-
- Code Mode has typed options for processor, natural loading address and file
- type. It deliberately has no arbitrary command-line escape hatch; reject
- switches we cannot represent instead of silently loading a blob wrongly.
- """
- processor: str | None = None
- loading_address: int | None = None
- file_type: str | None = None
- unsupported: list[str] = []
- try:
- words = shlex.split(value or "", posix=os.name != "nt")
- except ValueError as exc:
- raise ValueError(f"invalid IDA load options: {exc}") from exc
- for word in words:
- if word.startswith("-p") and len(word) > 2:
- processor = word[2:]
- elif word.startswith("-b") and len(word) > 2:
- try:
- # IDA's -b is in 16-byte paragraphs. DatabaseHandle expects the
- # natural address, which is the safer public API.
- loading_address = int(word[2:], 16) << 4
- except ValueError as exc:
- raise ValueError(f"invalid IDA loading address: {word!r}") from exc
- elif word.startswith("-T") and len(word) > 2:
- file_type = word[2:]
- else:
- unsupported.append(word)
- if unsupported:
- joined = " ".join(unsupported)
- raise ValueError(
- "ida-codemode cannot represent arbitrary IDA load options: "
- f"{joined!r}; use processor/base/file type options instead"
- )
- return processor, loading_address, file_type
-
-
-#: Key of the pre-serialised payload envelope. See _script().
-_PACKED = "__idatui_json__"
-
-#: Serialise the answer INSIDE the database process and hand back one string.
-#:
-#: Written when Code Mode ran to_jsonable() over every snippet result, walking
-#: the whole structure in Python to make it JSON-safe: a 200-row listing page is
-#: ~10k small objects, which cost 66ms to walk -- 72% of the page's total cost,
-#: and 114x what json.dumps of the same data cost (0.58ms).
-#:
-#: ida-codemode 0.3.2 removed that reason: serialization.dumps_json now hands
-#: the structure straight to the C encoder and only falls back to the walker for
-#: values json.dumps rejects. Re-measured against 0.3.2, packing buys 0.97x on
-#: that same page (experiments/bench_pack_trace.py) -- i.e. nothing, because the
-#: dodged walk is replaced by a double encode.
-#:
-#: It is kept anyway, on correctness rather than speed: packing pins OUR encoder
-#: settings (compact separators, default=str) inside the database process, so an
-#: un-encodable IDA object degrades to repr() at a point we control instead of
-#: depending on the runtime's fallback. Delete it if that stops being worth a
-#: protocol step -- it is no longer load-bearing for performance.
-_PACK_EPILOGUE = (
- '\n{"' + _PACKED + '": json.dumps(result, separators=(",", ":"), default=str)}\n'
-)
-
-
-def _script(args: dict[str, Any], body: str) -> str:
- """Bind JSON arguments without interpolating user text into Python code.
-
- This used to also run the body with Code Mode's trace hook detached
- (sys.settrace(None) + restore), because the runtime wrapped every
- execute_python in a trace function that returned ITSELF -- enabling line
- tracing in every frame it saw, so every line of every function we called
- paid a Python-level callback (ida_bytes.get_flags: 0.106us -> 5.49us, 52x).
-
- ida-codemode 0.3.2 deleted that hook; cancellation is now a C-level thread
- interrupt (runtime._interrupt_thread) that costs nothing while idle. The
- workaround measured 0.99x on a 200-row listing page against 0.3.2 -- pure
- noise -- so it is gone, and with it the caveat that a pure-Python loop in a
- snippet escaped its deadline. See experiments/bench_pack_trace.py.
- """
- encoded = json.dumps(args, ensure_ascii=False, separators=(",", ":"))
- head = f"import json\na = json.loads({encoded!r})\n"
- return f"{head}{dedent(body).strip()}\n{_PACK_EPILOGUE}"
-
-
-_OPERATIONS: dict[str, str] = {
- "list_funcs": r'''
-import fnmatch
-queries = a.get("queries") or [{}]
-q = queries[0]
-offset, count = max(0, int(q.get("offset", 0))), max(1, int(q.get("count", 500)))
-pattern = str(q.get("filter") or "").lower()
-if pattern and not any(ch in pattern for ch in "*?["): pattern = "*" + pattern + "*"
-rows = []
-for fn in db.functions.get_all():
- name = db.functions.get_name(fn) or f"sub_{int(fn.start_ea):X}"
- if pattern and not fnmatch.fnmatchcase(name.lower(), pattern): continue
- rows.append({"addr": hex(int(fn.start_ea)), "name": name,
- "size": int(fn.end_ea) - int(fn.start_ea)})
-page = rows[offset:offset + count]
-result = {"result": [{"data": page, "next_offset": offset + len(page), "total": len(rows)}]}
-result
-''',
- "disasm": r'''
-ea = int(str(a["addr"]), 16)
-fn = db.functions.get_at(ea)
-if fn is None:
- result = {"instructions": [], "total_instructions": 0, "instruction_count": 0}
-else:
- instructions = list(db.functions.get_instructions(fn))
- limit = max(1, int(a.get("max_instructions", len(instructions) or 1)))
- rows = [{"addr": hex(int(insn.ea)), "instruction": db.instructions.get_disassembly(insn)}
- for insn in instructions[:limit]]
- result = {"instructions": rows, "total_instructions": len(instructions),
- "instruction_count": len(instructions)}
-result
-''',
- "file_regions": r'''
-import idaapi
-rows = []
-for seg in db.segments.get_all():
- try: file_off = int(idaapi.get_fileregion_offset(seg.start_ea))
- except Exception: file_off = -1
- if file_off < 0 or file_off >= (1 << 48): file_off = -1
- rows.append({"start": hex(int(seg.start_ea)), "end": hex(int(seg.end_ea)),
- "file_off": file_off, "name": db.segments.get_name(seg) or ""})
-result = {"regions": rows}
-result
-''',
- "read_raw": r'''
-import ida_bytes
-ea, size = int(str(a["addr"]), 16), max(0, int(a["size"]))
-raw = ida_bytes.get_bytes(ea, size) or b""
-raw = raw[:size] + b"\xff" * max(0, size - len(raw))
-data = bytearray(raw)
-for index, value in enumerate(data):
- if value == 0xFF and not ida_bytes.is_loaded(ea + index): data[index] = 0
-result = {"addr": a["addr"], "hex": bytes(data).hex(), "n": len(data)}
-result
-''',
- "get_bytes": r'''
-rows = []
-for region in a.get("regions", []):
- ea, size = int(str(region["addr"]), 16), int(region["size"])
- raw = db.bytes.get_bytes_at(ea, size) or b""
- rows.append({"addr": region["addr"], "data": " ".join(f"{b:02x}" for b in raw)})
-result = {"result": rows}
-result
-''',
- "search_structs": r'''
-needle = str(a.get("filter") or "").lower()
-rows = []
-for tif in db.types.get_all():
- name = tif.get_type_name() or ""
- if not name or needle not in name.lower() or not tif.is_udt(): continue
- members = list(db.types.get_udt_members(tif))
- rows.append({"name": name, "size": int(tif.get_size()), "is_union": bool(tif.is_union()),
- "cardinality": len(members), "ordinal": int(tif.get_ordinal())})
-result = {"result": rows}
-result
-''',
- "type_inspect": r'''
-rows = []
-for query in a.get("queries", []):
- name = str(query.get("name") or "")
- tif = db.types.get_by_name(name)
- if tif is None:
- rows.append({"name": name, "error": "type not found"}); continue
- members = [{"name": m.name, "type": m.type.dstr() or str(m.type),
- "offset": int(m.offset), "size": int(m.size)}
- for m in db.types.get_udt_members(tif)] if tif.is_udt() else []
- rows.append({"name": name, "size": int(tif.get_size()), "is_union": bool(tif.is_union()),
- "members": members})
-result = {"result": rows}
-result
-''',
- "declare_type": r'''
-import ida_typeinf
-decls = a.get("decls", "")
-if isinstance(decls, str): decls = [decls]
-rows = []
-for declaration in decls:
- try:
- errors = int(db.types.parse_declarations(ida_typeinf.get_idati(), declaration))
- rows.append({"ok": errors == 0, **({} if errors == 0 else {"error": f"{errors} parse error(s)"})})
- except Exception as exc:
- rows.append({"ok": False, "error": str(exc)})
-result = {"result": rows}
-result
-''',
- "del_type": r'''
-import ida_typeinf
-name = str(a["name"])
-ok = bool(ida_typeinf.del_named_type(ida_typeinf.get_idati(), name, ida_typeinf.NTF_TYPE))
-result = {"name": name, "deleted": ok, **({} if ok else {"error": f"Type {name!r} not found or could not be deleted"})}
-result
-''',
- "func_types": r'''
-import ida_typeinf
-ea = int(str(a["addr"]), 16)
-fn = db.functions.get_at(ea)
-if fn is None:
- result = {"addr": a["addr"], "error": "no function at address"}
-else:
- pseudo = db.pseudocode.decompile(fn)
- name = db.functions.get_name(fn) or ""
- tif = pseudo.get_func_type()
- try: prototype = ida_typeinf.print_tinfo("", 0, 0, ida_typeinf.PRTYPE_1LINE, tif, name, "") if tif else ""
- except Exception: prototype = tif.dstr() if tif else ""
- lvars = [{"name": var.name, "type": var.type_info.dstr() if var.type_info else "",
- "is_arg": bool(var.is_arg)} for var in pseudo.local_variables]
- result = {"addr": hex(int(fn.start_ea)), "name": name,
- "prototype": (prototype or "").strip(), "lvars": lvars}
-result
-''',
- "set_lvar_type": r'''
-import ida_typeinf
-ea, variable, declaration = int(str(a["addr"]), 16), str(a["variable"]), str(a["type"])
-fn = db.functions.get_at(ea)
-if fn is None:
- result = {"error": "no function at address"}
-else:
- pseudo = db.pseudocode.decompile(fn)
- var = pseudo.find_local_variable(variable)
- if var is None:
- result = {"error": f"local variable {variable!r} not found"}
- else:
- try:
- tif = db.types.parse_one_declaration(ida_typeinf.get_idati(), declaration)
- accepted = bool(var.set_type(tif))
- saved = bool(pseudo.save_local_variable_info(var, save_type=True)) if accepted else False
- result = {"addr": hex(int(fn.start_ea)), "variable": variable,
- "type": declaration, "ok": accepted and saved}
- except Exception as exc:
- result = {"error": f"bad type {declaration!r}: {exc}"}
-result
-''',
- "set_type": r'''
-from ida_domain.types import TypeApplyFlags
-rows = []
-for edit in a.get("edits", []):
- ea = int(str(edit["addr"]), 16)
- declaration = str(edit.get("signature") or edit.get("type") or "")
- try:
- ok = bool(db.types.apply_declaration_at(ea, declaration, TypeApplyFlags.DEFINITE))
- rows.append({"addr": hex(ea), "ok": ok, **({} if ok else {"error": "IDA rejected the type"})})
- except Exception as exc:
- rows.append({"addr": hex(ea), "ok": False, "error": str(exc)})
-result = {"result": rows}
-result
-''',
- "data_type": r'''
-ea = int(str(a["addr"]), 16)
-try:
- tif = db.types.get_at(ea)
- fn = db.functions.get_at(ea)
- result = {"addr": hex(ea), "name": db.names.get_at(ea) or "",
- "type": tif.dstr() if tif else "", "size": int(db.heads.size(ea)) if db.heads.is_head(ea) else 0,
- "is_func": bool(fn)}
-except Exception as exc:
- result = {"addr": hex(ea), "error": str(exc)}
-result
-''',
- "force_recompile": r'''
-import ida_hexrays
-rows = []
-for item in a.get("items", []):
- ea = int(str(item["addr"]), 16)
- ida_hexrays.mark_cfunc_dirty(ea, False)
- rows.append({"addr": hex(ea), "ok": True})
-result = {"result": rows}
-result
-''',
- "undefine": r'''
-import ida_bytes
-rows = []
-for item in a.get("items", []):
- ea = int(str(item["addr"]), 16)
- size = max(1, int(item.get("size") or ida_bytes.get_item_size(ea) or 1))
- ok = bool(ida_bytes.del_items(ea, ida_bytes.DELIT_SIMPLE, size))
- rows.append({"addr": hex(ea), "ok": ok, **({} if ok else {"error": "delete items failed"})})
-result = {"result": rows}
-result
-''',
- "define_code": r'''
-import ida_ua
-rows = []
-for item in a.get("items", []):
- ea = int(str(item["addr"]), 16); size = int(ida_ua.create_insn(ea))
- rows.append({"addr": hex(ea), "ok": size > 0, "size": size,
- **({} if size > 0 else {"error": "instruction did not decode"})})
-result = {"result": rows}
-result
-''',
- "define_func": r'''
-rows = []
-for item in a.get("items", []):
- ea = int(str(item["addr"]), 16); ok = bool(db.functions.create(ea))
- rows.append({"addr": hex(ea), "ok": ok, **({} if ok else {"error": "IDA refused the function"})})
-result = {"result": rows}
-result
-''',
- "make_data": r'''
-import ida_bytes, ida_idaapi, ida_typeinf
-from ida_domain.types import TypeApplyFlags
-rows = []
-for item in a.get("items", []):
- ea, declaration = int(str(item["addr"]), 16), str(item["type"])
- try:
- tif = db.types.parse_one_declaration(ida_typeinf.get_idati(), declaration)
- size = max(1, int(tif.get_size()))
- saved_names = [(addr, name) for addr, name in db.names.get_all()
- if ea <= int(addr) < ea + size]
- ida_bytes.del_items(ea, ida_bytes.DELIT_EXPAND | ida_bytes.DELIT_DELNAMES,
- max(size, int(ida_bytes.get_item_size(ea) or 1)))
- created = bool(ida_bytes.create_data(ea, ida_bytes.FF_BYTE, size, ida_idaapi.BADADDR))
- ok = created and bool(db.types.apply_at(tif, ea, TypeApplyFlags.DEFINITE))
- for address, name in saved_names:
- db.names.set_name(int(address), name)
- if ok and item.get("name"): ok = bool(db.names.set_name(ea, str(item["name"])))
- rows.append({"addr": hex(ea), "ok": ok, "size": size,
- **({} if ok else {"error": "IDA rejected the data type"})})
- except Exception as exc:
- rows.append({"addr": hex(ea), "ok": False, "error": str(exc)})
-result = {"result": rows}
-result
-''',
- "make_string": r'''
-from ida_domain.strings import StringType
-ea, length = int(str(a["addr"]), 16), max(0, int(a.get("length", 0)))
-kind = {"c": StringType.C, "c16": StringType.C_16, "c32": StringType.C_32,
- "pascal": StringType.PASCAL}.get(str(a.get("kind", "c")).lower(), StringType.C)
-import ida_bytes
-try:
- ida_bytes.del_items(ea, ida_bytes.DELIT_SIMPLE, length if length > 0 else 1)
-except Exception:
- pass
-try:
- ok = bool(db.bytes.create_string_at(ea, length or None, kind))
- text = db.bytes.get_string_at(ea) or "" if ok else ""
- result = {"addr": hex(ea), "ok": ok, "size": int(db.heads.size(ea)) if ok else 0, "text": text}
-except Exception as exc:
- result = {"addr": hex(ea), "ok": False, "error": str(exc)}
-result
-''',
- "list_strings": r'''
-from ida_domain.strings import StringListConfig
-offset, count, min_len = max(0, int(a.get("offset", 0))), max(1, int(a.get("count", 2000))), max(1, int(a.get("min_len", 4)))
-if offset == 0 or a.get("refresh"):
- from ida_domain.strings import StringType
- db.strings.rebuild(StringListConfig(string_types=list(StringType), min_len=min_len,
- only_ascii_7bit=False))
-items = list(db.strings.get_all())
-page = items[offset:offset + count]
-rows = []
-for item in page:
- try: text = str(item)
- except Exception: text = item.contents.decode("utf-8", "replace") if item.contents else ""
- rows.append({"addr": hex(int(item.address)), "text": text, "len": int(item.length), "type": item.type.name})
-result = {"strings": rows, "total": len(items), "next_offset": offset + len(rows)}
-result
-''',
- # Everything a person ADDED to the database: comments, non-dummy names, and
- # the prototypes they set.
- #
- # Names come from IDA's name list, which is already an index -- no scan at
- # all. Comments have no index, so they need a walk, and the walk is over
- # HEADS: `next_that`'s predicate is a *Python* callback (SWIG calls it with
- # one argument, so `f_has_cmt` does not even fit), which would be one call
- # per BYTE -- 400 million of them on a big image. `max_scan` bounds it and
- # reports `truncated` rather than sitting there.
- "list_annotations": r'''
-import ida_bytes, ida_funcs, ida_lines, ida_nalt, ida_name
-import ida_segment, ida_typeinf, idautils
-limit = max(1, int(a.get("limit", 4000)))
-max_scan = max(1000, int(a.get("max_scan", 2000000)))
-comments, names = [], []
-scanned = 0
-
-def _line(ea):
- try:
- txt = ida_lines.generate_disasm_line(ea, ida_lines.GENDSM_REMOVE_TAGS)
- except Exception:
- txt = ""
- return " ".join((txt or "").split())
-
-for ea, nm in idautils.Names():
- if len(names) >= limit:
- break
- if not nm or not ida_bytes.has_user_name(ida_bytes.get_flags(ea)):
- continue
- fn = ida_funcs.get_func(ea)
- is_fn = fn is not None and int(fn.start_ea) == int(ea)
- proto = None
- if is_fn:
- try:
- ti = ida_typeinf.tinfo_t()
- if ida_nalt.get_tinfo(ti, ea):
- proto = str(ti)
- except Exception:
- proto = None
- seg = ida_segment.getseg(ea)
- names.append({"addr": hex(int(ea)), "name": nm, "func": is_fn,
- "size": (int(fn.end_ea - fn.start_ea) if is_fn else 0),
- "proto": proto,
- "seg": (ida_segment.get_segm_name(seg) if seg else "")})
-
-for i in range(ida_segment.get_segm_qty()):
- seg = ida_segment.getnseg(i)
- if seg is None or len(comments) >= limit or scanned >= max_scan:
- continue
- for ea in idautils.Heads(seg.start_ea, seg.end_ea):
- scanned += 1
- if len(comments) >= limit or scanned >= max_scan:
- break
- if not ida_bytes.has_cmt(ida_bytes.get_flags(ea)):
- continue
- for rep in (False, True):
- text = ida_bytes.get_cmt(ea, rep)
- if text:
- fn = ida_funcs.get_func(ea)
- comments.append({
- "addr": hex(int(ea)), "text": text, "repeatable": rep,
- "line": _line(ea), "seg": ida_segment.get_segm_name(seg),
- "func": (ida_funcs.get_func_name(fn.start_ea) if fn else None),
- "func_addr": (hex(int(fn.start_ea)) if fn else None)})
-
-# Whole-function comments are not on the byte flags, so the scan cannot see them.
-for fn_ea in idautils.Functions():
- fn = ida_funcs.get_func(fn_ea)
- if fn is None or len(comments) >= limit:
- continue
- for rep in (False, True):
- text = ida_funcs.get_func_cmt(fn, rep)
- if text:
- seg = ida_segment.getseg(fn_ea)
- comments.append({"addr": hex(int(fn_ea)), "text": text,
- "repeatable": rep, "line": "", "whole_func": True,
- "seg": (ida_segment.get_segm_name(seg) if seg else ""),
- "func": ida_funcs.get_func_name(fn_ea),
- "func_addr": hex(int(fn_ea))})
-result = {"comments": comments, "names": names, "scanned": scanned,
- "truncated": (len(comments) >= limit or len(names) >= limit
- or scanned >= max_scan)}
-result
-''',
- # The findings journal (idatui/journal.py). A netnode blob rides along in
- # the .i64, so "what did I work out here" survives closing the database.
- "journal_get": r'''
-import ida_netnode
-n = ida_netnode.netnode(a.get("node", "$ idatui.journal"))
-blob = n.getblob(0, "I") if ida_netnode.exist(n) else None
-result = {"data": blob.decode("utf-8", "replace") if blob else ""}
-result
-''',
- "journal_put": r'''
-import ida_netnode
-n = ida_netnode.netnode(a.get("node", "$ idatui.journal"), 0, True)
-payload = (a.get("data") or "").encode("utf-8")
-n.setblob(payload, 0, "I")
-result = {"ok": True, "bytes": len(payload)}
-result
-''',
- # Database-wide search (Ctrl+F), two kinds.
- #
- # BYTES uses IDA's own `find_bytes`, which already understands the pattern
- # language people expect -- "B8 ? ? ? ? 90", nibble wildcards ("48 8? ??")
- # and quoted literals -- so we neither parse nor match anything ourselves.
- # Iterating is match+1, per its documented contract.
- "search_bytes": r'''
-import ida_bytes, ida_funcs, ida_idaapi, ida_lines, ida_segment
-pat = str(a.get("pattern", "")).strip()
-limit = max(1, int(a.get("limit", 500)))
-lo = int(a.get("start", 0))
-hi = int(a.get("end", 0)) or ida_idaapi.BADADDR
-flags = ida_bytes.BIN_SEARCH_FORWARD | ida_bytes.BIN_SEARCH_NOSHOW
-if a.get("case"):
- flags |= ida_bytes.BIN_SEARCH_CASE
-rows, err, ea = [], None, lo
-while len(rows) < limit:
- try:
- hit = ida_bytes.find_bytes(pat, range_start=ea, range_end=hi, flags=flags)
- except Exception as exc:
- err = str(exc) or exc.__class__.__name__
- break
- if hit is None or hit == ida_idaapi.BADADDR:
- break
- head = ida_bytes.get_item_head(hit)
- fn = ida_funcs.get_func(hit)
- seg = ida_segment.getseg(hit)
- try:
- line = ida_lines.generate_disasm_line(head, ida_lines.GENDSM_REMOVE_TAGS) or ""
- except Exception:
- line = ""
- rows.append({"addr": hex(int(hit)), "head": hex(int(head)),
- "line": " ".join(line.split()),
- "func": (ida_funcs.get_func_name(fn.start_ea) if fn else None),
- "func_addr": (hex(int(fn.start_ea)) if fn else None),
- "seg": (ida_segment.get_segm_name(seg) if seg else "")})
- ea = int(hit) + 1
-result = {"hits": rows, "error": err, "truncated": len(rows) >= limit}
-result
-''',
- # TEXT walks the listing the way a person reads it: every head's rendered
- # disassembly line, which is why it finds "call cs:__isoc99_scanf" and
- # "0deadbeefh" alike. Bounded by max_scan, so a 400MB image reports partial
- # results instead of stalling.
- "search_text": r'''
-import ida_lines, ida_funcs, ida_segment, idautils
-import re as _re
-q = str(a.get("query", ""))
-limit = max(1, int(a.get("limit", 500)))
-max_scan = max(1000, int(a.get("max_scan", 3000000)))
-ci = (not a.get("case")) and q.islower() # smartcase, like the in-view search
-rx, err = None, None
-if a.get("regex"):
- try:
- rx = _re.compile(q, _re.I if ci else 0)
- except Exception as exc:
- err = "bad regex: " + str(exc)
-needle = q.lower() if ci else q
-rows, scanned = [], 0
-if err is None and q:
- for i in range(ida_segment.get_segm_qty()):
- seg = ida_segment.getnseg(i)
- if seg is None or len(rows) >= limit or scanned >= max_scan:
- continue
- for ea in idautils.Heads(seg.start_ea, seg.end_ea):
- scanned += 1
- if len(rows) >= limit or scanned >= max_scan:
- break
- try:
- line = ida_lines.generate_disasm_line(ea, ida_lines.GENDSM_REMOVE_TAGS) or ""
- except Exception:
- continue
- # Match what the user SEES, not IDA's column padding: nobody types
- # "call" + four spaces + "cs:getenv_ptr".
- line = " ".join(line.split())
- hay = line.lower() if ci else line
- if (rx.search(line) if rx is not None else (needle in hay)):
- fn = ida_funcs.get_func(ea)
- rows.append({"addr": hex(int(ea)), "head": hex(int(ea)),
- "line": line,
- "func": (ida_funcs.get_func_name(fn.start_ea) if fn else None),
- "func_addr": (hex(int(fn.start_ea)) if fn else None),
- "seg": ida_segment.get_segm_name(seg)})
-result = {"hits": rows, "error": err, "scanned": scanned,
- "truncated": len(rows) >= limit or scanned >= max_scan}
-result
-''',
- "list_linkage": r'''
-imports = [{"addr": hex(int(item.address)), "name": item.name, "module": item.module_name}
- for item in db.imports.get_all_imports() if item.name]
-exports = [{"addr": hex(int(item.address)), "name": item.name, "ordinal": int(item.ordinal)}
- for item in db.entries.get_all() if item.name]
-result = {"imports": imports, "exports": exports,
- "n_imports": len(imports), "n_exports": len(exports)}
-result
-''',
- "lookup_funcs": r'''
-rows = []
-for query in a.get("queries", []):
- raw = str(query)
- try: ea = int(raw, 16)
- except ValueError:
- fn = db.functions.get_by_name(raw); ea = int(fn.start_ea) if fn else None
- else: fn = db.functions.get_at(ea)
- if fn is None:
- rows.append({"query": raw, "fn": None})
- else:
- rows.append({"query": raw, "fn": {"addr": hex(int(fn.start_ea)),
- "name": db.functions.get_name(fn) or f"sub_{int(fn.start_ea):X}",
- "size": int(fn.end_ea) - int(fn.start_ea)}})
-result = {"result": rows}
-result
-''',
- "resolve_names": r'''
-import ida_idaapi, ida_name
-rows = []
-for query in a.get("queries", []):
- name = str(query).strip(); ea = ida_name.get_name_ea(ida_idaapi.BADADDR, name)
- rows.append({"query": name, "ea": hex(int(ea)) if ea != ida_idaapi.BADADDR else None})
-result = {"result": rows}
-result
-''',
- # Ours: the coarse code/data type plus a fine `kind` (call/jump/flow,
- # read/write/offset/text/info) that the xref dialog draws its badges from.
- # Deliberately NOT sorted -- the dialog lists xrefs in IDA's own order.
- "xref_types": r'''
-import idaapi, idautils, ida_bytes, ida_funcs, ida_xref
-code_kind = {ida_xref.fl_CF: "call", ida_xref.fl_CN: "call", ida_xref.fl_JF: "jump",
- ida_xref.fl_JN: "jump", ida_xref.fl_F: "flow"}
-data_kind = {ida_xref.dr_O: "offset", ida_xref.dr_W: "write", ida_xref.dr_R: "read",
- ida_xref.dr_T: "text", ida_xref.dr_I: "info"}
-def _kind(xr):
- return (code_kind if xr.iscode else data_kind).get(xr.type, "code" if xr.iscode else "data")
-def _fn(ea):
- f = ida_funcs.get_func(ea)
- return {"addr": hex(int(f.start_ea)), "name": ida_funcs.get_func_name(f.start_ea) or ""} if f else None
-queries = a.get("queries") or []
-all_results = []
-for query in queries:
- query = query if isinstance(query, dict) else {"addr": query}
- raw = str(query.get("addr", "")).strip()
- direction = str(query.get("direction", "to") or "to").lower()
- include_fn = bool(query.get("include_fn", True))
- dedup = bool(query.get("dedup", True))
- try: count = int(query.get("count", 2000) or 2000)
- except (TypeError, ValueError): count = 2000
- try: target = int(raw, 16)
- except ValueError: target = idaapi.get_name_ea(idaapi.BADADDR, raw)
- rows = []
- if target is not None and target != idaapi.BADADDR and ida_bytes.is_mapped(target):
- if direction in ("to", "both"):
- for xr in idautils.XrefsTo(target, 0):
- row = {"direction": "to", "addr": hex(int(xr.frm)), "from": hex(int(xr.frm)),
- "to": hex(int(target)), "type": "code" if xr.iscode else "data", "kind": _kind(xr)}
- if include_fn: row["fn"] = _fn(xr.frm)
- rows.append(row)
- if direction in ("from", "both"):
- for xr in idautils.XrefsFrom(target, 0):
- row = {"direction": "from", "addr": hex(int(xr.to)), "from": hex(int(target)),
- "to": hex(int(xr.to)), "type": "code" if xr.iscode else "data", "kind": _kind(xr)}
- if include_fn: row["fn"] = _fn(xr.to)
- rows.append(row)
- if dedup:
- seen, deduped = set(), []
- for r in rows:
- k = (r["direction"], r["from"], r["to"], r["kind"])
- if k in seen: continue
- seen.add(k); deduped.append(r)
- rows = deduped
- rows = rows[:count]
- all_results.append({"query": raw, "data": rows, "next_offset": None})
-result = {"result": all_results}
-result
-''',
- # Mirrors the tool ida-tui was written against, ORDER INCLUDED. The rows are
- # sorted by the far-end address and deduped by default, and the pseudocode
- # follow's address fallback silently depends on it: at a call site the raw
- # IDA order yields the ordinary-flow xref (the next instruction) first, so an
- # unsorted result makes "follow the call" land on the following line instead.
- "xref_query": r'''
-import idaapi, idautils, ida_bytes, ida_funcs
-def _fn(ea):
- f = ida_funcs.get_func(ea)
- return {"addr": hex(int(f.start_ea)), "name": ida_funcs.get_func_name(f.start_ea) or ""} if f else None
-queries = a.get("queries") or []
-all_results = []
-for query in queries:
- raw = str(query.get("addr", "")).strip()
- direction = str(query.get("direction", "both") or "both").lower()
- if direction not in ("to", "from", "both"): direction = "both"
- xref_type = str(query.get("xref_type", "any") or "any").lower()
- if xref_type not in ("any", "code", "data"): xref_type = "any"
- include_fn = bool(query.get("include_fn", True))
- dedup = bool(query.get("dedup", True))
- sort_by = str(query.get("sort_by", "addr") or "addr")
- descending = bool(query.get("descending", False))
- try: offset = max(0, int(query.get("offset", 0) or 0))
- except (TypeError, ValueError): offset = 0
- try: count = max(0, min(int(query.get("count", 200) or 200), 5000))
- except (TypeError, ValueError): count = 200
- try:
- try: target = int(raw, 16)
- except ValueError:
- target = idaapi.get_name_ea(idaapi.BADADDR, raw)
- if target == idaapi.BADADDR: raise ValueError(f"Failed to resolve address/name: {raw}")
- if not ida_bytes.is_mapped(target): raise ValueError(f"Address not mapped: {raw}")
- rows = []
- if direction in ("to", "both"):
- for xr in idautils.XrefsTo(target, 0):
- kind = "code" if xr.iscode else "data"
- if xref_type != "any" and kind != xref_type: continue
- row = {"direction": "to", "addr": hex(int(xr.frm)), "from": hex(int(xr.frm)),
- "to": hex(int(target)), "type": kind}
- if include_fn: row["fn"] = _fn(xr.frm)
- rows.append(row)
- if direction in ("from", "both"):
- for xr in idautils.XrefsFrom(target, 0):
- kind = "code" if xr.iscode else "data"
- if xref_type != "any" and kind != xref_type: continue
- row = {"direction": "from", "addr": hex(int(xr.to)), "from": hex(int(target)),
- "to": hex(int(xr.to)), "type": kind}
- if include_fn: row["fn"] = _fn(xr.to)
- rows.append(row)
- if dedup:
- seen, deduped = set(), []
- for row in rows:
- key = (row["direction"], row["from"], row["to"], row["type"])
- if key in seen: continue
- seen.add(key); deduped.append(row)
- rows = deduped
- if sort_by == "type":
- rows.sort(key=lambda r: (str(r.get("type", "")), int(str(r["addr"]), 16)), reverse=descending)
- else:
- rows.sort(key=lambda r: int(str(r["addr"]), 16), reverse=descending)
- page = rows[offset:offset + count] if count else rows[offset:]
- nxt = offset + len(page)
- all_results.append({"target": raw, "resolved_addr": hex(int(target)), "direction": direction,
- "xref_type": xref_type, "data": page,
- "next_offset": nxt if nxt < len(rows) else None,
- "total": len(rows), "error": None})
- except Exception as exc:
- all_results.append({"target": raw, "resolved_addr": None, "direction": direction,
- "xref_type": xref_type, "data": [], "next_offset": None,
- "total": 0, "error": str(exc)})
-result = {"result": all_results}
-result
-''',
- # A comment must land in BOTH views, and the pseudocode half is not a
- # simple set: db.comments.set_at() alone leaves the pseudocode unchanged.
- # Hex-Rays comments are anchored to a ctree location (treeloc_t), and an
- # anchor the ctree does not actually own is dropped as an "orphan" -- so the
- # itp slot has to be searched until one sticks, exactly as IDA's own UI does.
- # Without it a comment silently never appears in the decompilation.
- "set_comments": r'''
-import idaapi, idc, ida_hexrays
-rows = []
-for item in a.get("items", []):
- addr_s = str(item.get("addr", ""))
- text = str(item.get("comment") or "")
- try:
- ea = int(addr_s, 16)
- if not idaapi.set_cmt(ea, text, False):
- rows.append({"addr": addr_s,
- "error": f"Failed to set disassembly comment at {hex(ea)}"})
- continue
- if not ida_hexrays.init_hexrays_plugin():
- rows.append({"addr": addr_s}); continue
- try:
- cfunc = ida_hexrays.decompile(ea)
- except Exception:
- cfunc = None
- if cfunc is None:
- rows.append({"addr": addr_s}); continue
- if ea == cfunc.entry_ea:
- # The signature line carries no ctree item: it is a function comment.
- idc.set_func_cmt(ea, text, True)
- cfunc.refresh_func_ctext()
- rows.append({"addr": addr_s}); continue
- eamap = cfunc.get_eamap()
- if ea not in eamap:
- rows.append({"addr": addr_s,
- "error": f"Failed to set decompiler comment at {hex(ea)}"})
- continue
- nearest_ea = eamap[ea][0].ea
- if cfunc.has_orphan_cmts():
- cfunc.del_orphan_cmts(); cfunc.save_user_cmts()
- tl = idaapi.treeloc_t(); tl.ea = nearest_ea
- placed = False
- for itp in range(idaapi.ITP_SEMI, idaapi.ITP_COLON):
- tl.itp = itp
- cfunc.set_user_cmt(tl, text)
- cfunc.save_user_cmts()
- cfunc.refresh_func_ctext()
- if not cfunc.has_orphan_cmts():
- placed = True; break
- cfunc.del_orphan_cmts(); cfunc.save_user_cmts()
- rows.append({"addr": addr_s} if placed else
- {"addr": addr_s,
- "error": f"Failed to set decompiler comment at {hex(ea)}"})
- except Exception as exc:
- rows.append({"addr": addr_s, "error": str(exc)})
-result = {"result": rows}
-result
-''',
- # Every category takes EITHER one edit or a LIST of them, and the answer is
- # one row per edit. The port accepted only a single dict, so any batch path
- # (rpc rename_many applying a whole symbol file, which is the entire point of
- # that verb) died with "list indices must be integers or slices, not str" and
- # reported the failure against addr=null. Mirrors the real tool: conflict
- # detection before the write, dry_run/allow_overwrite/stop_on_error, per-row
- # addr/old/name, and a summary counting EDITS rather than categories.
- "rename": r'''
-import idaapi, ida_hexrays, ida_name
-batch = a.get("batch") or {}
-dry_run = bool(batch.get("dry_run", False))
-allow_overwrite = bool(batch.get("allow_overwrite", False))
-stop_on_error = bool(batch.get("stop_on_error", False))
-
-def _items(value):
- if value is None: return []
- if isinstance(value, dict): return [value]
- if isinstance(value, list): return [i for i in value if isinstance(i, dict)]
- return []
-
-def _set_name_checked(ea, new):
- conflict = idaapi.get_name_ea(idaapi.BADADDR, new)
- if conflict != idaapi.BADADDR and conflict != ea and not allow_overwrite:
- return False, f"can't rename at {hex(ea)} as {new!r}: name already used at {hex(conflict)}"
- if dry_run:
- return True, None
- flags = idaapi.SN_CHECK
- if allow_overwrite: flags |= int(getattr(idaapi, "SN_FORCE", 0))
- if not idaapi.set_name(ea, new, flags):
- return False, (f"Rename failed at {hex(ea)}: IDA rejected name {new!r} "
- "(invalid identifier or internal conflict)")
- return True, None
-
-def _refresh_ctext(fn_addr):
- # A renamed function must invalidate Hex-Rays' cache, which is per function
- # and persisted in the .i64: without this the pseudocode keeps calling the
- # old name forever while every other readback reports the new one.
- if not ida_hexrays.init_hexrays_plugin(): return
- failure = ida_hexrays.hexrays_failure_t()
- cfunc = ida_hexrays.decompile_func(fn_addr, failure, ida_hexrays.DECOMP_WARNINGS)
- if cfunc: cfunc.refresh_func_ctext()
-
-out = {}; ok_count = failed = 0; halted = False
-for category in ("func", "data", "local", "stack"):
- if category not in batch: continue
- rows = []
- for edit in _items(batch.get(category)):
- try:
- if category == "func":
- addr_text = edit.get("addr") or edit.get("func_addr") or edit.get("func")
- new = edit.get("name") or edit.get("new") or edit.get("new_name")
- if not addr_text or not new:
- row = {"addr": addr_text, "name": new,
- "error": "Function rename requires addr + name"}
- else:
- ea = int(str(addr_text), 16)
- fn = idaapi.get_func(ea)
- if fn is None:
- row = {"addr": addr_text, "name": new, "error": "Function not found"}
- else:
- old = idaapi.get_name(fn.start_ea) or None
- ok, err = _set_name_checked(fn.start_ea, str(new))
- row = {"addr": addr_text, "old": old, "name": str(new)}
- if err: row["error"] = err
- if dry_run: row["dry_run"] = True
- if ok and not dry_run: _refresh_ctext(fn.start_ea)
- elif category == "data":
- addr_text = edit.get("addr")
- old = edit.get("old") or edit.get("old_name")
- new = edit.get("new") or edit.get("new_name") or edit.get("name")
- if not new and new != "":
- row = {"old": old, "new": None,
- "error": "Global rename requires target and new name"}
- else:
- if addr_text is not None:
- ea = int(str(addr_text), 16)
- old = old or (idaapi.get_name(ea) or None)
- else:
- ea = idaapi.get_name_ea(idaapi.BADADDR, str(old or ""))
- if ea == idaapi.BADADDR:
- row = {"old": old, "new": str(new), "error": f"Global {old!r} not found"}
- else:
- # An empty new name CLEARS the label; that is a real
- # request (tests revert with it), not a missing argument.
- if str(new) == "":
- ok = bool(ida_name.set_name(ea, "", idaapi.SN_CHECK))
- err = None if ok else f"Failed to clear the name at {hex(ea)}"
- else:
- ok, err = _set_name_checked(ea, str(new))
- row = {"addr": hex(ea), "old": old, "new": str(new)}
- if err: row["error"] = err
- if dry_run: row["dry_run"] = True
- else:
- fa, old, new = edit.get("func_addr"), edit.get("old"), edit.get("new")
- if not fa or not old or not new:
- row = {"old": old, "new": new,
- "error": f"{category} rename requires func_addr + old + new"}
- else:
- ea = int(str(fa), 16)
- pseudo = db.pseudocode.decompile(ea)
- var = pseudo.find_local_variable(str(old))
- if var is None:
- row = {"func_addr": fa, "old": old, "new": new,
- "error": f"no local {old!r} in that function"}
- elif dry_run:
- row = {"func_addr": fa, "old": old, "new": new, "dry_run": True}
- else:
- var.set_user_name(str(new))
- ok = bool(pseudo.save_local_variable_info(var, save_name=True))
- row = {"func_addr": fa, "old": old, "new": new}
- if not ok: row["error"] = "IDA rejected the local variable name"
- except Exception as exc:
- row = {"addr": edit.get("addr"), "error": str(exc)}
- rows.append(row)
- if row.get("error"): failed += 1
- else: ok_count += 1
- if row.get("error") and stop_on_error:
- halted = True; break
- out[category] = rows
- if halted: break
-out["summary"] = {"ok": ok_count, "failed": failed}
-if dry_run: out["summary"]["dry_run"] = True
-if halted: out["summary"]["halted"] = True
-result = out
-result
-''',
-}
-
-
-_OPERATIONS["define_code_run"] = r'''
-import ida_bytes, ida_idp, ida_segment, ida_ua, idaapi
-ea, limit = int(str(a["addr"]), 16), max(1, min(int(a.get("limit", 20000)), 200000))
-seg = ida_segment.getseg(ea)
-if seg is None:
- result = {"addr": a["addr"], "error": "no segment", "count": 0}
-else:
- start, count, stopped, hi = ea, 0, "limit", int(seg.end_ea)
- while count < limit:
- if ea >= hi: stopped = "segment"; break
- flags = ida_bytes.get_flags(ea)
- if ida_bytes.is_code(flags) or ida_bytes.is_data(flags): stopped = "defined"; break
- size = int(ida_ua.create_insn(ea))
- if size <= 0: stopped = "undecodable"; break
- count += 1
- insn = ida_ua.insn_t()
- if ida_ua.decode_insn(insn, ea) > 0:
- try: is_ret = bool(ida_idp.is_ret_insn(insn))
- except Exception: is_ret = False
- if is_ret or (insn.get_canon_feature() & idaapi.CF_STOP):
- ea += size; stopped = "flow"; break
- ea += size
- result = {"start": hex(start), "end": hex(ea), "count": count, "stopped": stopped}
-result
-'''
-
-
-_OPERATIONS["define_func_run"] = r'''
-import ida_bytes, ida_funcs, ida_segment
-ea = int(str(a["addr"]), 16)
-fn = db.functions.get_at(ea)
-if fn is not None and int(fn.start_ea) == ea:
- result = {"addr": hex(ea), "ok": True, "start": hex(ea), "end": hex(int(fn.end_ea)), "how": "existed"}
-else:
- automatic = bool(db.functions.create(ea))
- if not automatic:
- seg = db.segments.get_at(ea); end = ea; hi = int(seg.end_ea) if seg else ea
- while end < hi and ida_bytes.is_code(ida_bytes.get_flags(end)):
- nxt = int(ida_bytes.get_item_end(end))
- if nxt <= end: break
- end = nxt
- ok = bool(end > ea and ida_funcs.add_func(ea, end))
- else: ok = True
- fn = db.functions.get_at(ea)
- result = ({"addr": hex(ea), "ok": True, "start": hex(int(fn.start_ea)),
- "end": hex(int(fn.end_ea)), "how": "auto" if automatic else "explicit-end"}
- if ok and fn is not None else
- {"addr": hex(ea), "ok": False, "error": f"IDA refused a function at {ea:#x}"})
-result
-'''
-
-
-_OPERATIONS["set_thumb"] = r'''
-import ida_bytes, ida_ida, ida_idp, ida_segment, ida_segregs
-ea = int(str(a["addr"]), 16); treg = ida_idp.str2reg("T")
-seg = ida_segment.getseg(ea)
-if treg is None or treg < 0:
- result = {"addr": hex(ea), "error": "no T register (not an ARM database)"}
-elif seg is None:
- result = {"addr": hex(ea), "error": "no segment"}
-else:
- current = ida_segregs.get_sreg(ea, treg)
- current = 0 if current in (None, 0xFFFFFFFF, -1) else int(current)
- want = {"on": 1, "off": 0}.get(str(a.get("mode", "toggle")).lower(), 0 if current else 1)
- changed = False
- if want and seg.bitness != 1:
- ida_segment.set_segm_addressing(seg, 1); changed = True
- size = max(int(ida_bytes.get_item_size(ea)), 2)
- ida_bytes.del_items(ea, 0, size)
- ok = bool(ida_segregs.split_sreg_range(ea, treg, want, ida_segregs.SR_user))
- now = ida_segregs.get_sreg(ea, treg)
- result = {"addr": hex(ea), "thumb": bool(now), "was": bool(current), "ok": ok,
- "bitness": ida_segment.getseg(ea).bitness, "forced_32bit": changed,
- "db_64bit": bool(ida_ida.inf_get_app_bitness() == 64 and want)}
-result
-'''
-
-
-_OPERATIONS["thumb_scan"] = r'''
-import ida_bytes, ida_funcs, ida_idp, ida_segment, ida_segregs, ida_ua
-lo, hi = int(str(a["start"]), 16), int(str(a["end"]), 16)
-apply, limit = bool(a.get("apply", True)), int(a.get("limit", 512))
-treg = ida_idp.str2reg("T"); found = []; applied = 0; cursor = lo
-while cursor + 4 <= hi and len(found) < limit:
- at = cursor; value = int(ida_bytes.get_dword(cursor)); cursor += 4
- if not value & 1: continue
- target = value & ~1; seg = ida_segment.getseg(target)
- if seg is None or not (seg.perm & ida_segment.SEGPERM_EXEC or seg.perm == 0): continue
- flags = ida_bytes.get_flags(target)
- if ida_bytes.is_data(flags): continue
- item = {"at": hex(at), "value": hex(value), "target": hex(target),
- "was_code": bool(ida_bytes.is_code(flags))}; found.append(item)
- if not apply: continue
- if treg is not None and treg >= 0: ida_segregs.split_sreg_range(target, treg, 1, ida_segregs.SR_user)
- if not ida_bytes.is_code(ida_bytes.get_flags(target)):
- ida_bytes.del_items(target, 0, 2)
- if ida_ua.create_insn(target) <= 0: item["decoded"] = False; continue
- item["decoded"] = True; item["function"] = bool(db.functions.get_at(target) or db.functions.create(target)); applied += 1
-result = {"start": hex(lo), "end": hex(hi), "found": found, "applied": applied, "n": len(found)}
-result
-'''
-
-
-_OPERATIONS["decomp_error"] = r'''
-import ida_hexrays, ida_ida
-ea = int(str(a["addr"]), 16); fn = db.functions.get_at(ea)
-result = {"addr": hex(ea), "bitness": ida_ida.inf_get_app_bitness()}
-if fn is None:
- result["reason"] = "no function here"
-else:
- try:
- failure = ida_hexrays.hexrays_failure_t(); cfunc = ida_hexrays.decompile_func(fn, failure)
- if cfunc is not None: result["reason"] = ""
- else:
- result.update({"reason": failure.desc() or f"error {failure.code}",
- "code": int(failure.code), "errea": hex(int(failure.errea))})
- except Exception as exc: result["reason"] = f"{type(exc).__name__}: {exc}"
-result
-'''
-
-# `heads` and the operand-format tools are the port's IDAPython island: the
-# continuous listing's presentation model (undefined runs, colour spans, operand
-# extents, banners, struct members, the digest protocol) and IDA/Hex-Rays number
-# formats have no ida-domain surface. Rather than paraphrase ~1100 lines of
-# performance-tuned, behaviour-sensitive code into string literals, they stay
-# real, diffable source in idatui/remote_tools.py and are shipped to the database
-# process as text. Read once at import; the file ships beside this module.
-_REMOTE_LIB = (Path(__file__).with_name("remote_tools.py")).read_text(encoding="utf-8")
-
-#: Versioned by content, so editing remote_tools.py re-installs it instead of
-#: silently running the copy a long-lived worker already has.
-_REMOTE_MODULE = "_idatui_remote_" + hashlib.sha1(
- _REMOTE_LIB.encode("utf-8")).hexdigest()[:12]
-
-#: Sent back when the database process has not got the library yet; the client
-#: installs it and retries once. Amortised, a worker receives it exactly once.
-_NEED_LIB = "__idatui_needs_remote_lib__"
-
-#: Installs the library as a real module in the database process. Persisting it
-#: in sys.modules is what makes the module-level caches (the tag maps, and the
-#: line-render lru_cache the listing's throughput depends on) survive between
-#: calls -- execute_python builds a fresh namespace every time, so a library
-#: exec'd inline is rebuilt, and its caches thrown away, on every single call.
-_INSTALL_LIB = f'''
-import sys, types
-_m = types.ModuleType({_REMOTE_MODULE!r})
-exec(compile(a["source"], {_REMOTE_MODULE!r}, "exec"), _m.__dict__)
-sys.modules[{_REMOTE_MODULE!r}] = _m
-result = True
-result
-'''
-
-
-def _remote_op(call: str) -> str:
- """A snippet that calls one of the carried-over tools by its real signature.
-
- Costs one short request: the library is imported from the database process's
- own sys.modules, not shipped again.
- """
- return (f"import sys\n"
- f"_m = sys.modules.get({_REMOTE_MODULE!r})\n"
- f"result = {{{_NEED_LIB!r}: True}} if _m is None else _m.{call}\n"
- f"result\n")
-
-
-_OPERATIONS["op_format"] = _remote_op(
- 'op_format(addr=a["addr"], mode=a.get("mode", "cycle"),'
- ' col=int(a.get("col", -1)), n=int(a.get("n", -1)))')
-_OPERATIONS["pc_nums"] = _remote_op('pc_nums(addr=a["addr"])')
-_OPERATIONS["decompile"] = _remote_op(
- 'decompile(addr=a["addr"],'
- ' include_addresses=bool(a.get("include_addresses", True)))')
-_OPERATIONS["decomp_map"] = _remote_op('decomp_map(addr=a["addr"])')
-_OPERATIONS["pc_num_format"] = _remote_op(
- 'pc_num_format(addr=a["addr"], mode=a.get("mode", "cycle"),'
- ' line=int(a.get("line", -1)), col=int(a.get("col", -1)),'
- ' ea=a.get("ea", ""), opnum=int(a.get("opnum", -1)))')
-
-# The listing walker itself. Replaces the port's re-implementation, which
-# rendered no per-operand extents (so no keypress could say which literal it
-# would reformat) and had no digest/expect support (so every page was re-sent
-# after any edit), and whose span walk was the per-character loop our own
-# version had already been rewritten to avoid.
-#: Row count + seek anchors for a whole segment, in ONE call. See
-#: remote_tools.segment_index: the alternative is fetching every row.
-_OPERATIONS["segment_index"] = _remote_op(
- 'segment_index(addr=a["addr"], end=a.get("end", ""),'
- ' page_rows=int(a.get("page_rows", 500)), detail=bool(a.get("detail", False)))')
-
-_HEADS = _remote_op(
- 'heads(addr=a["addr"], count=int(a.get("count", 200)),'
- ' offset=int(a.get("offset", 0)), end=a.get("end", ""),'
- ' back=bool(a.get("back", False)), annotate=bool(a.get("annotate", False)),'
- ' expect=a.get("expect", ""), text=bool(a.get("text", True)))')
-
-
-# The graph view's only backend call. Blocks are address RANGES, never text:
-# the client re-renders them with `heads`, so boxes reuse the exact listing rows
-# (colours, operand marks, trail painting) instead of growing a second renderer.
-#
-# ida-domain exposes no basic-block/edge-kind surface, so this stays on ida_gdl.
-_OPERATIONS["flowchart"] = r'''
-import ida_funcs, ida_gdl
-ea = int(str(a["addr"]), 16)
-fn = ida_funcs.get_func(ea)
-if fn is None:
- result = {"addr": hex(ea), "error": "no function at that address", "blocks": []}
-else:
- fc = ida_gdl.FlowChart(fn, flags=ida_gdl.FC_PREDS)
- index, order = {}, []
- for bb in fc:
- index[bb.start_ea] = len(order)
- order.append(bb)
- blocks = []
- for bb in order:
- sl = [s for s in bb.succs() if s.start_ea in index]
- succs = []
- for s in sl:
- # Edge kind is what the graph view colours by: an n-way dispatch is
- # "switch", a successor that is literally the next address falls
- # through, anything else is a taken branch.
- if len(sl) > 2: kind = "switch"
- elif s.start_ea == bb.end_ea: kind = "fall"
- else: kind = "jump"
- succs.append([index[s.start_ea], kind])
- blocks.append({"id": index[bb.start_ea], "start": hex(int(bb.start_ea)),
- "end": hex(int(bb.end_ea)), "succs": succs})
- result = {"addr": hex(ea),
- "func": {"addr": hex(int(fn.start_ea)), "end": hex(int(fn.end_ea)),
- "name": ida_funcs.get_func_name(fn.start_ea) or ""},
- "entry": index.get(fn.start_ea, 0), "blocks": blocks}
-result
-'''
-
-# Only ever reached as domain.py's fallback when file_regions yields nothing.
-_OPERATIONS["survey_binary"] = r'''
-segments = []
-for seg in db.segments.get_all():
- segments.append({"start": hex(int(seg.start_ea)), "end": hex(int(seg.end_ea)),
- "name": db.segments.get_name(seg) or ""})
-result = {"segments": segments}
-result
-'''
-
-
-class CodeModeClient:
- """A leased GUI/idalib database accessed through ``ida_codemode``."""
-
- def __init__(
- self,
- binary_path: str,
- *,
- ttl: int = 0,
- load_args: str = "",
- processor: str | None = None,
- loading_address: int | None = None,
- file_type: str | None = None,
- output_database: str | None = None,
- spawn: bool = True,
- new_database: bool = False,
- ) -> None:
- del ttl # managed-worker lifetime is lease-based, not idle-TTL based
- self._path = os.path.abspath(os.path.expanduser(binary_path))
- parsed_processor, parsed_address, parsed_file_type = _parse_load_args(load_args)
- self._processor = processor or parsed_processor
- self._loading_address = loading_address if loading_address is not None else parsed_address
- self._file_type = file_type or parsed_file_type
- self._output_database = output_database
- self._spawn = spawn
- self._new_database = new_database
- self._handle: DatabaseHandle | None = None
- self._last_instance: DatabaseInstance | None = None
- self._connect_lock = threading.Lock()
-
- def connect(self, timeout: float = 1800.0, progress=None) -> "CodeModeClient":
- _require_codemode()
- with self._connect_lock:
- if self._handle is not None and self._handle.connected:
- return self
- if progress:
- progress(f"discovering Code Mode database for {os.path.basename(self._path)}…")
- try:
- # A Ctrl+L reload releases its current managed-worker lease, but
- # that worker remains registered during Code Mode's final-lease
- # grace period. Retry only that known handoff window. A GUI or
- # another long-lived client remains busy and yields a clear
- # failure rather than being modified underneath its owner.
- deadline = time.monotonic() + min(timeout, 60.0)
- while True:
- try:
- handle = DatabaseHandle.open(
- self._path,
- options=DatabaseOpenOptions(
- spawn=self._spawn,
- startup_timeout=max(0.1, timeout),
- output_database=self._output_database,
- processor=self._processor,
- # The natural byte address is converted to IDA's
- # paragraph-based -b value by Code Mode.
- image_base=self._loading_address,
- file_type=self._file_type,
- new_database=self._new_database,
- ),
- )
- break
- except DatabaseBusyError:
- if not self._new_database or time.monotonic() >= deadline:
- raise
- if progress:
- progress("waiting for the previous Code Mode lease to close…")
- owner = find_database_owner(
- self._path,
- output_database=self._output_database,
- timeout=0.5,
- )
- if owner is not None:
- wait_database_released(
- owner,
- max(0.0, deadline - time.monotonic()),
- )
- else:
- time.sleep(0.2)
- if progress:
- backend = handle.instance.backend
- progress(f"attached to {backend} database; waiting for auto-analysis…")
- handle.wait_autoanalysis(timeout=timeout)
- except Exception as exc: # normalize the dependency's transport errors
- raise self._connection_error(exc) from exc
- self._handle = handle
- self._last_instance = handle.instance
- return self
-
- @staticmethod
- def _connection_error(exc: BaseException) -> IDAConnectionError:
- return IDAConnectionError(str(exc) or type(exc).__name__)
-
- @property
- def connected(self) -> bool:
- return self._handle is not None and self._handle.connected
-
- @property
- def pid(self) -> int | None:
- return self._handle.instance.pid if self._handle is not None else None
-
- @property
- def backend(self) -> str | None:
- return self._handle.instance.backend if self._handle is not None else None
-
- def execute_python(self, code: str, *, timeout: float | None = None) -> Any:
- if not self.connected:
- self.connect()
- handle = self._handle
- if handle is None:
- raise IDAConnectionError("Code Mode database is not connected")
- try:
- response = handle.execute_python(code, timeout=timeout)
- except RemoteError as exc:
- details = exc.details or {}
- message = str(exc)
- if details.get("traceback"):
- message += f"\n{details['traceback']}"
- if exc.code == "operation_timeout":
- raise IDATimeoutError(message) from exc
- raise IDAToolError("execute_python", message) from exc
- except (DatabaseDisconnectedError, CodeModeConnectionError) as exc:
- raise self._connection_error(exc) from exc
- if not isinstance(response, dict) or "result" not in response:
- raise IDAToolError("execute_python", "Code Mode returned an invalid execution result")
- return response["result"]
-
- @staticmethod
- def _unpack(answer: Any) -> Any:
- """Undo _PACK_EPILOGUE. Anything else passes through untouched."""
- if isinstance(answer, dict) and _PACKED in answer:
- return json.loads(answer[_PACKED])
- return answer
-
- def invoke(self, operation: str, *, timeout: float | None = None, **args) -> Any:
- """Execute one TUI domain operation through Code Mode."""
- if operation in ("idb_save", "save"):
- return self.save_database()
- if operation in ("server_health", "ping", "health", "state"):
- return self.health()
- body = _HEADS if operation == "heads" else _OPERATIONS.get(operation)
- if body is None:
- raise IDAToolError(operation, f"unknown ida-tui Code Mode operation: {operation}")
- try:
- answer = self._unpack(self.execute_python(_script(args, body), timeout=timeout))
- if isinstance(answer, dict) and answer.get(_NEED_LIB):
- # First call against this database process (or a restarted one).
- self.execute_python(_script({"source": _REMOTE_LIB}, _INSTALL_LIB),
- timeout=timeout)
- answer = self._unpack(
- self.execute_python(_script(args, body), timeout=timeout))
- return answer
- except IDAToolError as exc:
- if exc.tool == "execute_python":
- raise IDAToolError(operation, exc.message) from exc
- raise
-
- # Temporary source compatibility for external drivers/tests that used the
- # old WorkerClient. Application code uses the accurately named invoke().
- call = invoke
-
- def save_database(self) -> dict[str, Any]:
- if not self.connected:
- self.connect()
- handle = self._handle
- if handle is None:
- raise IDAConnectionError("Code Mode database is not connected")
- try:
- return handle.save_database()
- except RemoteError as exc:
- raise IDAToolError("save_database", str(exc)) from exc
- except (DatabaseDisconnectedError, CodeModeConnectionError) as exc:
- raise self._connection_error(exc) from exc
-
- def health(self) -> dict[str, Any]:
- if not self.connected:
- self.connect()
- assert self._handle is not None
- entry = self._handle.instance
- module = os.path.basename(entry.exe_path or entry.idb_path or self._path)
- return {
- "ok": self._handle.connected,
- "module": module,
- "backend": entry.backend,
- "record_id": entry.record_id,
- "input_path": entry.exe_path,
- "idb_path": entry.idb_path,
- }
-
- def keepalive(self, interval: float = 120.0) -> _NoopKeepAlive:
- del interval
- return _NoopKeepAlive()
-
- def resolve_db(self) -> str:
- if not self.connected:
- self.connect()
- assert self._handle is not None
- return self._handle.instance.record_id
-
- def set_db(self, db: str | None) -> None:
- del db # one handle is permanently bound to one registered database
-
- def list_sessions(self) -> list[Session]:
- if not self.connected:
- self.connect()
- assert self._handle is not None
- entry = self._handle.instance
- path = entry.exe_path or entry.idb_path or self._path
- return [Session(session_id=entry.record_id, filename=os.path.basename(path),
- input_path=path, is_active=True)]
-
- def close(self, grace: float = 0.0) -> None:
- del grace
- with self._connect_lock:
- handle, self._handle = self._handle, None
- if handle is not None:
- self._last_instance = handle.instance
- handle.close() # release our lease; never close a GUI/other client's DB
-
- def wait_released(self, timeout: float = 45.0) -> bool:
- """Wait until a managed instance releases its lifetime lock.
-
- Normal application shutdown must not wait: another client may retain the
- worker. This is an explicit test/maintenance helper for deleting a
- temporary IDB safely after this client closes. GUI instances return
- ``False`` immediately because clients never own their lifetime.
- """
- instance = self._last_instance
- if instance is None or instance.backend != "idalib":
- return False
- return wait_database_released(instance, timeout)
-
- def __enter__(self) -> "CodeModeClient":
- return self.connect()
-
- def __exit__(self, *exc) -> None:
- self.close()
diff --git a/idatui/domain.py b/idatui/domain.py
index 9690a65..1e5a863 100644
--- a/idatui/domain.py
+++ b/idatui/domain.py
@@ -1,4 +1,4 @@
-"""Domain / paging layer: address-centric models over IDA Code Mode.
+"""Domain / paging layer: address-centric models over IDA Nexus.
This is where the "millions of lines" problem is solved, so the TUI widgets only
ever see a viewport-sized slice. Every hard-won constraint from
@@ -7,7 +7,7 @@ ever see a viewport-sized slice. Every hard-won constraint from
* Page sizes remain bounded so remote execution returns viewport-scale JSON.
* Pagination advances by the number of rows actually returned.
* Deep head walks are block-cached (revisits are free) and neighboring blocks
- prefetch through the thread-safe Code Mode client.
+ prefetch through the thread-safe IDA Nexus client.
* Expensive function totals are fetched once and cached.
* Decompilation failures are surfaced as data, not application crashes.
@@ -22,23 +22,21 @@ import bisect
import re
import threading
from base64 import b64decode
+from collections.abc import Callable, Sequence
from concurrent.futures import ThreadPoolExecutor
-from collections.abc import Sequence
from dataclasses import dataclass, field, replace
-from typing import NamedTuple
-from typing import Callable, TYPE_CHECKING
+from typing import TYPE_CHECKING, NamedTuple
-from . import diag
+from . import remote_ops
from .errors import IDAToolError
if TYPE_CHECKING: # type hint only
- from .codemode_client import CodeModeClient
+ from .nexus_client import NexusClient
# Clamps derived from measured caps (list ~700, disasm ~500). Margin included.
LIST_PAGE = 500
DISASM_BLOCK = 256 # instructions per cached/fetched block (<= disasm cap)
-HEX_BLOCK = 16384 # bytes per cached/fetched hex block (compact read_raw -> cheap)
-DECOMPILE_TIMEOUT = 15.0 # s; cap per decompile so a failing one can't hang the CLI
+HEX_BLOCK = 16384 # bytes per cached/fetched hex block (compact read_raw -> cheap)
_TRUNC_RE = re.compile(r"\[(\d+) chars total\]\s*$")
@@ -92,7 +90,7 @@ class Line:
class Head(NamedTuple):
- """One flat-listing item (from the Code Mode ``heads`` operation): a code
+ """One flat-listing item (from the IDA Nexus ``heads`` operation): a code
instruction, a data item, or an undefined byte run.
A ``NamedTuple`` rather than a dataclass because this is by far the
@@ -107,13 +105,13 @@ class Head(NamedTuple):
"""
ea: int
- kind: str # 'code' | 'data' | 'unknown' | 'member'
+ kind: str # 'code' | 'data' | 'unknown' | 'member'
size: int
text: str
name: str | None = None
raw: bytes | None = None # opcode/item bytes (filled in for code by the model)
#: [(kind, text)] from IDA's own colour tags — mnem/reg/num/name/str/punct/…
- #: None when Code Mode didn't provide them (or the spans
+ #: None when IDA Nexus didn't provide them (or the spans
#: disagreed with the plain text, in which case the text wins).
#:
#: Held exactly as it came off the wire, and **read-only**. The worker
@@ -199,10 +197,10 @@ class Ref:
@dataclass
class Xref:
- frm: int # the referencing address
- to: int | None # the referenced address
- type: str # coarse: "code" | "data"
- fn_name: str | None # function containing `frm`
+ frm: int # the referencing address
+ to: int | None # the referenced address
+ type: str # coarse: "code" | "data"
+ fn_name: str | None # function containing `frm`
fn_addr: int | None
kind: str | None = None # fine: call/jump/flow/read/write/offset/text/info
@@ -218,7 +216,7 @@ class LVar:
class FuncTypes:
addr: int
name: str
- prototype: str # e.g. 'int __fastcall foo(int a, char *b)'
+ prototype: str # e.g. 'int __fastcall foo(int a, char *b)'
lvars: list[LVar]
@@ -227,7 +225,7 @@ class Struct:
name: str
size: int
is_union: bool
- members: int # field count
+ members: int # field count
ordinal: int
@classmethod
@@ -244,6 +242,7 @@ class Struct:
@dataclass(frozen=True)
class StrLit:
"""A string literal IDA found in the binary (the Shift+F12 list)."""
+
addr: int
text: str
length: int
@@ -271,6 +270,7 @@ class SearchHit:
an instruction, so ``head`` is the item to navigate to and ``line`` is what
that item renders as.
"""
+
addr: int
head: int
line: str = ""
@@ -287,6 +287,7 @@ class Comment:
report can show what was being commented ON without a second round trip.
``whole_func`` marks a function comment rather than an instruction one.
"""
+
addr: int
text: str
repeatable: bool = False
@@ -302,6 +303,7 @@ class NamedItem:
"""An address carrying a real name -- one you typed, or one the file's own
symbols supplied. IDA records both as "user" names and does not remember
which was which, so a report must say so rather than claim authorship."""
+
addr: int
name: str
is_func: bool = False
@@ -319,6 +321,7 @@ class Linkage:
``name`` is the joinable name; ``raw`` keeps the spelling IDA reported, which
is what the user sees in the listing.
"""
+
addr: int
name: str
module: str = ""
@@ -374,7 +377,9 @@ class FunctionIndex:
query: dict = {"offset": offset, "count": LIST_PAGE}
if self.filter:
query["filter"] = self.filter
- data = _query_data(self._prog.client.invoke("list_funcs", queries=[query]))
+ data = _query_data(
+ self._prog.client.call(remote_ops.list_funcs, queries=[query])
+ )
added = 0
with self._lock:
for d in data:
@@ -468,7 +473,7 @@ class DisasmModel:
self._blocks: dict[int, list[Line]] = {}
self._total: int | None = None
self._ea_list: list[int] | None = None
- self._max_raw = 0 # widest opcode length seen (bytes)
+ self._max_raw = 0 # widest opcode length seen (bytes)
self._func_end: int | None = None
self._func_end_done = False
self._lock = threading.Lock()
@@ -484,8 +489,8 @@ class DisasmModel:
code function this equals the heads row count that backs the lines."""
if self._total is not None:
return self._total
- payload = self._prog.client.invoke(
- "disasm", addr=hex(self.ea), max_instructions=1, include_total=True
+ payload = self._prog.client.call(
+ remote_ops.disasm, addr=hex(self.ea), max_instructions=1, include_total=True
)
total = payload.get("total_instructions")
if total is None:
@@ -527,7 +532,7 @@ class DisasmModel:
nxt = lines[i + 1].ea if i + 1 < len(lines) else last_end
length = max(nxt - ln.ea, 0)
off = ln.ea - start
- b = bytes(data[off:off + length])
+ b = bytes(data[off : off + length])
biggest = max(biggest, len(b))
out.append(replace(ln, raw=b))
with self._lock:
@@ -538,20 +543,22 @@ class DisasmModel:
@staticmethod
def _line_from_head(r: dict) -> Line:
"""Adapt a ``heads`` row to a disasm Line (label = the head's name)."""
- return Line(ea=_as_int(r["ea"]), text=r.get("text", ""),
- label=r.get("name"))
+ return Line(ea=_as_int(r["ea"]), text=r.get("text", ""), label=r.get("name"))
def _fetch_block(self, b: int) -> list[Line]:
# The function disasm view is a listing filtered to the function: fetch a
# block of heads (one per instruction for code). Over-fetch one row so
# the block knows where its last instruction ends (opcode-byte sizing).
- payload = self._prog.client.invoke(
- "heads", addr=hex(self.ea), offset=b * self.BLOCK,
- count=self.BLOCK + 1, **self._end_kw(),
+ payload = self._prog.client.call(
+ remote_ops.heads,
+ addr=hex(self.ea),
+ offset=b * self.BLOCK,
+ count=self.BLOCK + 1,
+ **self._end_kw(),
)
rows = payload.get("heads", []) if isinstance(payload, dict) else []
fetched = [self._line_from_head(r) for r in rows]
- lines = fetched[:self.BLOCK]
+ lines = fetched[: self.BLOCK]
if len(fetched) > self.BLOCK:
end_ea: int | None = fetched[self.BLOCK].ea
else: # this block ends the function
@@ -607,7 +614,7 @@ class DisasmModel:
block = self._get_block(b)
lo = start - b * self.BLOCK if b == b0 else 0
hi = end - b * self.BLOCK if b == b1 else self.BLOCK
- out.extend(block[max(lo, 0):hi])
+ out.extend(block[max(lo, 0) : hi])
if prefetch:
self._prefetch_block(b1 + 1) # forward scroll
self._prefetch_block(b0 - 1) # backward scroll
@@ -686,7 +693,7 @@ class ListingModel:
"""A flat, IDA-style disassembly *listing* over one segment: code, data and
undefined heads interleaved, unlike ``DisasmModel`` (one function, code only).
- Backed by the Code Mode adapter's ``heads`` operation, which walks item heads
+ Backed by the IDA Nexus adapter's ``heads`` operation, which walks item heads
and renders each via ``generate_disasm_line``. The segment is walked lazily in
forward pages (``FunctionIndex`` style); line index == position in the walked
head list. Random access to an address is O(distance-from-seg-start) the
@@ -694,13 +701,14 @@ class ListingModel:
on demand as the viewport scrolls. Synchronous + thread-safe.
"""
- PAGE = 500 # viewport-scale heads per Code Mode execution
+ PAGE = 500 # viewport-scale heads per IDA Nexus execution
#: Generation marker for a skeleton (text-less) page. Never equals a real
#: _text_gen, which counts up from 0, so such a page always reads as stale.
_SKELETON_GEN = -1
- def __init__(self, program: "Program", seg_start: int, seg_end: int,
- name: str | None = None):
+ def __init__(
+ self, program: "Program", seg_start: int, seg_end: int, name: str | None = None
+ ):
self._prog = program
self.seg_start = seg_start
self.seg_end = seg_end
@@ -714,7 +722,7 @@ class ListingModel:
# N bytes PRESENTS as N rows and the text for each is synthesised on
# demand. _row_at[i] is the logical row where physical head i starts.
self._row_at: list[int] = []
- self._head_eas: list[int] = [] # parallel to _heads, for bisect
+ self._head_eas: list[int] = [] # parallel to _heads, for bisect
#: Which name generation each head's TEXT was rendered at, parallel to
#: _heads. A rename bumps :attr:`_text_gen`; the rows themselves stay
#: (their addresses and row numbers are unchanged) and are re-rendered a
@@ -740,7 +748,7 @@ class ListingModel:
#: means something DID move the walk. Program.listing() throws the model
#: away when it sees this, so the next read rebuilds from scratch.
self.stale_structure = False
- self._rows = 0 # total logical rows loaded
+ self._rows = 0 # total logical rows loaded
self._ubytes: dict[int, bytes] = {} # lazily-read bytes for those rows
self._next: int | None = seg_start # next address to fetch from
self._done = False
@@ -788,7 +796,7 @@ class ListingModel:
size = int(r.get("size") or 0)
if size > 0:
off = _as_int(r["ea"]) - lo
- raw = bytes(data[off:off + size])
+ raw = bytes(data[off : off + size])
if len(raw) > biggest:
biggest = len(raw)
try:
@@ -827,19 +835,26 @@ class ListingModel:
"""
with self._lock:
if self._done and self._heads:
- return True # already indexed; re-priming is a no-op
+ return True # already indexed; re-priming is a no-op
try:
- idx = self._prog.client.invoke(
- "segment_index", addr=hex(self.seg_start), end=hex(self.seg_end),
- page_rows=self.PAGE, detail=True)
+ idx = self._prog.client.call(
+ remote_ops.segment_index,
+ addr=hex(self.seg_start),
+ end=hex(self.seg_end),
+ page_rows=self.PAGE,
+ detail=True,
+ )
except Exception: # noqa: BLE001 -- fall back to streaming
return False
if not isinstance(idx, dict) or idx.get("error") or "eas" not in idx:
return False
try:
- eas = array.array("Q"); eas.frombytes(b64decode(idx["eas"]))
- kinds = array.array("B"); kinds.frombytes(b64decode(idx["kinds"]))
- sizes = array.array("I"); sizes.frombytes(b64decode(idx["sizes"]))
+ eas = array.array("Q")
+ eas.frombytes(b64decode(idx["eas"]))
+ kinds = array.array("B")
+ kinds.frombytes(b64decode(idx["kinds"]))
+ sizes = array.array("I")
+ sizes.frombytes(b64decode(idx["sizes"]))
except Exception: # noqa: BLE001
return False
names = idx.get("kind_names") or []
@@ -881,7 +896,8 @@ class ListingModel:
self._page_digest = [None] * len(anchors)
self._page_rows = [
(anchors[k + 1][2] if k + 1 < len(anchors) else n) - anchors[k][2]
- for k in range(len(anchors))]
+ for k in range(len(anchors))
+ ]
self._skeleton = True
self._done = True
self._next = None
@@ -912,8 +928,9 @@ class ListingModel:
if self._done or self._next is None:
return 0
frm = self._next
- payload = self._prog.client.invoke(
- "heads", addr=hex(frm), count=self.PAGE, annotate=True, text=text)
+ payload = self._prog.client.call(
+ remote_ops.heads, addr=hex(frm), count=self.PAGE, annotate=True, text=text
+ )
rows = payload.get("heads", []) if isinstance(payload, dict) else []
cur = payload.get("cursor", {}) if isinstance(payload, dict) else {}
page = self._build_page(rows, raw=text)
@@ -925,8 +942,9 @@ class ListingModel:
self._skeleton = True
self._page_head.append(len(self._heads))
self._page_addr.append(frm)
- self._page_digest.append(payload.get("digest")
- if isinstance(payload, dict) else None)
+ self._page_digest.append(
+ payload.get("digest") if isinstance(payload, dict) else None
+ )
self._page_rows.append(len(rows))
for h in page:
# Banner/label rows (function headers, separators, code labels)
@@ -983,7 +1001,7 @@ class ListingModel:
self._ubytes[b0] = blk
off = a - b0
take = min(BLK - off, n - len(out))
- chunk = blk[off:off + take] if blk else b""
+ chunk = blk[off : off + take] if blk else b""
if not chunk:
break
out += chunk
@@ -1004,8 +1022,9 @@ class ListingModel:
ea = h.ea + off
b = self._unknown_bytes(ea, 1)
text = f"db {b[0]:02X}h" if b else "db ?"
- return Head(ea=ea, kind="unknown", size=1, text=text,
- name=h.name if off == 0 else None)
+ return Head(
+ ea=ea, kind="unknown", size=1, text=text, name=h.name if off == 0 else None
+ )
def ensure(self, n: int) -> None:
"""Ensure at least ``n`` logical rows are loaded (or all, if fewer)."""
@@ -1022,8 +1041,11 @@ class ListingModel:
return idx
with self._lock:
have = self._rows
- last_ea = (self._heads[-1].ea + max(self._heads[-1].size, 1) - 1
- if self._heads else -1)
+ last_ea = (
+ self._heads[-1].ea + max(self._heads[-1].size, 1) - 1
+ if self._heads
+ else -1
+ )
done = self._done
if done or (have and last_ea >= ea):
# Loaded past ea without an exact head hit: return the first head
@@ -1086,13 +1108,13 @@ class ListingModel:
"""
with self._lock:
if not (self.seg_start <= ea < self.seg_end):
- return True # another segment; nothing moved here
+ return True # another segment; nothing moved here
if len(self._page_head) < 3:
- return False # barely walked; a rebuild is cheaper
+ return False # barely walked; a rebuild is cheaper
p = bisect.bisect_right(self._page_addr, ea) - 1
p = max(p - 1, 0)
if p <= 0:
- return False # the edit is in the first pages
+ return False # the edit is in the first pages
keep = self._page_head[p]
if keep <= 0:
return False
@@ -1110,7 +1132,7 @@ class ListingModel:
last = self._heads[-1]
self._rows = self._row_at[-1] + self._span(last)
self._done = False
- self._ubytes.clear() # undefined-run bytes behind the drop point
+ self._ubytes.clear() # undefined-run bytes behind the drop point
return True
def invalidate_text(self) -> None:
@@ -1156,8 +1178,9 @@ class ListingModel:
def _page_bounds(self, p: int) -> tuple[int, int]:
"""[first, last) head index of page ``p`` (caller holds the lock)."""
lo = self._page_head[p]
- hi = (self._page_head[p + 1] if p + 1 < len(self._page_head)
- else len(self._heads))
+ hi = (
+ self._page_head[p + 1] if p + 1 < len(self._page_head) else len(self._heads)
+ )
return lo, hi
def _ensure_page(self, p: int) -> int:
@@ -1181,13 +1204,20 @@ class ListingModel:
# the expectation rather than asking first means a page that HAS changed
# still costs one round trip.
try:
- payload = self._prog.client.invoke(
- "heads", addr=hex(addr), count=self.PAGE, annotate=True,
- expect="" if want_digest is None else str(want_digest))
+ payload = self._prog.client.call(
+ remote_ops.heads,
+ addr=hex(addr),
+ count=self.PAGE,
+ annotate=True,
+ expect="" if want_digest is None else str(want_digest),
+ )
except Exception: # noqa: BLE001 -- keep the old text rather than blank
return p + 1
- if (isinstance(payload, dict) and "heads" not in payload
- and payload.get("count") == want_rows):
+ if (
+ isinstance(payload, dict)
+ and "heads" not in payload
+ and payload.get("count") == want_rows
+ ):
with self._lock:
if self._text_gen == gen and len(self._heads) >= hi:
for k in range(lo, hi):
@@ -1212,8 +1242,9 @@ class ListingModel:
# what it once loaded. Leaving it stale is how a literal cycling
# hex -> dec -> hex ends up declared "unchanged" while the row still
# shows the decimal it was refetched with in between.
- self._page_digest[p] = (payload.get("digest")
- if isinstance(payload, dict) else None)
+ self._page_digest[p] = (
+ payload.get("digest") if isinstance(payload, dict) else None
+ )
for k in range(lo, hi):
self._head_gen[k] = gen
return p + 1
@@ -1225,8 +1256,9 @@ class ListingModel:
j, off = self._phys(i)
if j < 0:
return None
- stale = ((self._renamed or self._skeleton)
- and self._head_gen[j] != self._text_gen)
+ stale = (self._renamed or self._skeleton) and self._head_gen[
+ j
+ ] != self._text_gen
if not stale:
span = self._span(self._heads[j])
h = self._heads[j]
@@ -1265,8 +1297,9 @@ class ListingModel:
spans = [self._phys(i) for i in range(max(start, 0), max(rows, 0))]
heads = self._heads
plain = [(j, off, heads[j]) for j, off in spans if j >= 0]
- return [self._row_head(j, off) if self._span(h) > 1 else h
- for j, off, h in plain]
+ return [
+ self._row_head(j, off) if self._span(h) > 1 else h for j, off, h in plain
+ ]
def index_of_ea(self, ea: int) -> int:
with self._lock:
@@ -1361,7 +1394,7 @@ class HexModel:
if block is None:
return (va, None)
bo = off - b * self.BLOCK
- return (va, block[bo:bo + 16])
+ return (va, block[bo : bo + 16])
def ensure(self, r0: int, count: int) -> None:
"""Blocking: fetch the blocks covering rows [r0, r0+count) if missing."""
@@ -1389,6 +1422,11 @@ class HexModel:
for b in range(b0 - 1, b1 + 2):
self._prefetch(b)
+ def invalidate(self) -> None:
+ """Drop cached bytes so the next viewport read reaches the database."""
+ with self._lock:
+ self._blocks.clear()
+
# --------------------------------------------------------------------------- #
# Program: top-level handle, model registry, prefetch pool
@@ -1396,7 +1434,7 @@ class HexModel:
class Program:
"""The bound analysis session: models, caches, and a small prefetch pool."""
- def __init__(self, client: "CodeModeClient", prefetch_workers: int = 2):
+ def __init__(self, client: "NexusClient", prefetch_workers: int = 2):
self.client = client
self._pool = ThreadPoolExecutor(
max_workers=prefetch_workers, thread_name_prefix="idatui-prefetch"
@@ -1447,27 +1485,39 @@ class Program:
"""Sorted raw segment map [(start, end, file_off, name)] — the single
source for sections()/file_regions()/image_range. Cached.
- Uses the Code Mode adapter's ``file_regions`` operation (a plain segment
+ Uses the IDA Nexus adapter's ``file_regions`` operation (a plain segment
walk, ~ms), avoiding broad binary surveys on the hex-pane open path.
"""
if self._segments_cache is not None:
return self._segments_cache
segs: list[tuple[int, int, int, str]] = []
try:
- r = self.client.invoke("file_regions")
- for d in (r.get("regions", []) if isinstance(r, dict) else []):
+ r = self.client.call(remote_ops.file_regions)
+ for d in r.get("regions", []) if isinstance(r, dict) else []:
if isinstance(d, dict) and "start" in d:
- segs.append((_as_int(d["start"]), _as_int(d["end"]),
- int(d.get("file_off", -1)), d.get("name", "") or ""))
+ segs.append(
+ (
+ _as_int(d["start"]),
+ _as_int(d["end"]),
+ int(d.get("file_off", -1)),
+ d.get("name", "") or "",
+ )
+ )
except IDAToolError:
segs = []
if not segs: # older server without file_regions -> survey_binary (slow)
try:
- sb = self.client.invoke("survey_binary")
- for s in (sb.get("segments", []) if isinstance(sb, dict) else []):
+ sb = self.client.call(remote_ops.survey_binary)
+ for s in sb.get("segments", []) if isinstance(sb, dict) else []:
try:
- segs.append((_as_int(s["start"]), _as_int(s["end"]), -1,
- s.get("name", "") or ""))
+ segs.append(
+ (
+ _as_int(s["start"]),
+ _as_int(s["end"]),
+ -1,
+ s.get("name", "") or "",
+ )
+ )
except (KeyError, ValueError, TypeError):
continue
except Exception: # noqa: BLE001 -- best-effort; callers handle empty
@@ -1523,28 +1573,34 @@ class Program:
def read_bytes(self, ea: int, n: int) -> bytes:
"""Raw bytes [ea, ea+n) from IDA (gaps read as zero).
- The Code Mode adapter returns one contiguous hex string (C-speed in IDA).
+ The IDA Nexus adapter returns one contiguous hex string (C-speed in IDA).
A legacy ``get_bytes`` decoding fallback remains for alternate clients.
"""
if n <= 0:
return b""
if not self._no_read_raw:
try:
- r = self.client.invoke("read_raw", addr=hex(ea), size=int(n))
+ r = self.client.call(remote_ops.read_raw, addr=hex(ea), size=int(n))
h = r.get("hex") if isinstance(r, dict) else None
if isinstance(h, str):
out = bytes.fromhex(h)
return out[:n] if len(out) >= n else out + b"\x00" * (n - len(out))
except IDAToolError as e:
# Tool missing on this server: stop trying it, use get_bytes.
- if "read_raw" in str(e) or "Unknown tool" in str(e) or "not found" in str(e):
+ if (
+ "read_raw" in str(e)
+ or "Unknown tool" in str(e)
+ or "not found" in str(e)
+ ):
self._no_read_raw = True
else:
return b"\x00" * n
except (ValueError, KeyError):
pass # malformed hex -> fall through to the legacy decoder
try:
- r = self.client.invoke("get_bytes", regions=[{"addr": hex(ea), "size": int(n)}])
+ r = self.client.call(
+ remote_ops.get_bytes, regions=[{"addr": hex(ea), "size": int(n)}]
+ )
except IDAToolError:
return b"\x00" * n
res = r.get("result", []) if isinstance(r, dict) else []
@@ -1585,7 +1641,7 @@ class Program:
with self._lock:
m = self._listings.get(start)
if m is not None and m.stale_structure:
- m = None # a refresh found the walk had moved; start over
+ m = None # a refresh found the walk had moved; start over
if m is None:
m = ListingModel(self, start, end, name)
self._listings[start] = m
@@ -1595,11 +1651,15 @@ class Program:
def list_structs(self, filter: str = "") -> list[Struct]:
"""All local structs/unions (optionally name-substring filtered), sorted
by name."""
- payload = self.client.invoke("search_structs", filter=filter)
+ payload = self.client.call(remote_ops.search_structs, filter=filter)
res = payload.get("result", []) if isinstance(payload, dict) else []
- out = [Struct.from_raw(d) for d in res
- if isinstance(d, dict) and d.get("name")
- and not str(d["name"]).startswith("$")] # skip anonymous UDTs
+ out = [
+ Struct.from_raw(d)
+ for d in res
+ if isinstance(d, dict)
+ and d.get("name")
+ and not str(d["name"]).startswith("$")
+ ] # skip anonymous UDTs
out.sort(key=lambda s: s.name.lower())
return out
@@ -1607,8 +1667,9 @@ class Program:
"""A C definition for ``name`` reconstructed from its member layout
(the remote operation exposes members, not printable source). Faithful to IDA's
field names/types; array dims are moved after the field name."""
- payload = self.client.invoke(
- "type_inspect", queries=[{"name": name, "include_members": True}])
+ payload = self.client.call(
+ remote_ops.type_inspect, queries=[{"name": name, "include_members": True}]
+ )
res = payload.get("result", []) if isinstance(payload, dict) else []
info = res[0] if res and isinstance(res[0], dict) else {}
kw = "union" if info.get("is_union") else "struct"
@@ -1630,7 +1691,7 @@ class Program:
def declare_type(self, decl: str) -> str | None:
"""Create or update a C type. Returns None on success, else the parse
error. (Re-declaring a name updates it in place.)"""
- payload = self.client.invoke("declare_type", decls=decl)
+ payload = self.client.call(remote_ops.declare_type, decls=decl)
res = payload.get("result", []) if isinstance(payload, dict) else []
if res and isinstance(res[0], dict):
return res[0].get("error")
@@ -1641,20 +1702,32 @@ class Program:
"""Structured decompiler types for the function at ``ea`` (prototype +
local variables). None if ``ea`` isn't a decompilable function."""
try:
- r = self.client.invoke("func_types", addr=hex(ea))
+ r = self.client.call(remote_ops.func_types, addr=hex(ea))
except IDAToolError:
return None
if not isinstance(r, dict) or r.get("error"):
return None
- lvars = [LVar(name=lv.get("name", ""), type=lv.get("type", ""),
- is_arg=bool(lv.get("is_arg")))
- for lv in r.get("lvars", []) if isinstance(lv, dict)]
- return FuncTypes(addr=_as_int(r.get("addr", hex(ea))), name=r.get("name", ""),
- prototype=r.get("prototype", ""), lvars=lvars)
+ lvars = [
+ LVar(
+ name=lv.get("name", ""),
+ type=lv.get("type", ""),
+ is_arg=bool(lv.get("is_arg")),
+ )
+ for lv in r.get("lvars", [])
+ if isinstance(lv, dict)
+ ]
+ return FuncTypes(
+ addr=_as_int(r.get("addr", hex(ea))),
+ name=r.get("name", ""),
+ prototype=r.get("prototype", ""),
+ lvars=lvars,
+ )
def set_function_type(self, ea: int, signature: str) -> str | None:
"""Set a function's prototype. None on success, else an error string."""
- r = self.client.invoke("set_type", edits=[{"addr": hex(ea), "signature": signature}])
+ r = self.client.call(
+ remote_ops.set_type, edits=[{"addr": hex(ea), "signature": signature}]
+ )
res = r.get("result", []) if isinstance(r, dict) else []
row = res[0] if res and isinstance(res[0], dict) else {}
if row.get("ok"):
@@ -1665,7 +1738,7 @@ class Program:
"""Current type info for a data item/global: {addr,name,type,size,is_func}.
None if the operation fails or the address isn't mapped."""
try:
- r = self.client.invoke("data_type", addr=hex(ea))
+ r = self.client.call(remote_ops.data_type, addr=hex(ea))
except IDAToolError:
return None
if not isinstance(r, dict) or r.get("error"):
@@ -1674,8 +1747,10 @@ class Program:
def set_data_type(self, ea: int, decl: str) -> str | None:
"""Set a global/data item's type. None on success, else an error string."""
- r = self.client.invoke(
- "set_type", edits=[{"kind": "global", "addr": hex(ea), "type": decl}])
+ r = self.client.call(
+ remote_ops.set_type,
+ edits=[{"kind": "global", "addr": hex(ea), "type": decl}],
+ )
res = r.get("result", []) if isinstance(r, dict) else []
row = res[0] if res and isinstance(res[0], dict) else {}
if row.get("ok"):
@@ -1685,7 +1760,9 @@ class Program:
def set_lvar_type(self, fn_ea: int, var: str, ty: str) -> str | None:
"""Set a decompiler local variable's type through ida-domain pseudocode.
None on success, else an error string."""
- r = self.client.invoke("set_lvar_type", addr=hex(fn_ea), variable=var, type=ty)
+ r = self.client.call(
+ remote_ops.set_lvar_type, addr=hex(fn_ea), variable=var, type=ty
+ )
if isinstance(r, dict) and r.get("error"):
return r["error"]
if isinstance(r, dict) and not r.get("ok"):
@@ -1696,12 +1773,12 @@ class Program:
"""Delete a named type. Returns None on success, else an error string.
Returns a clear error instead of raising when the runtime cannot do it."""
try:
- self.client.invoke("del_type", name=name)
+ self.client.call(remote_ops.del_type, name=name)
return None
except IDAToolError as e:
msg = e.message
if "not found" in msg.lower() and "del_type" in msg:
- return "the connected Code Mode runtime cannot delete local types"
+ return "the connected IDA Nexus runtime cannot delete local types"
return msg
# -- disassembly ------------------------------------------------------- #
@@ -1714,8 +1791,25 @@ class Program:
return m
# -- decompilation ----------------------------------------------------- #
+ def force_recompile(self, ea: int) -> None:
+ """Drop local and Hex-Rays caches before an explicit view refresh.
+
+ Normal edit paths use generation-based invalidation. Ctrl+R is also for
+ changes made by another IDA Nexus/IDA client, for which this Program has
+ seen no generation bump, so it must explicitly ask Hex-Rays to discard
+ its cached cfunc.
+ """
+ with self._lock:
+ self._decomp.pop(ea, None)
+ self._pc_nums.pop(ea, None)
+ self._decomp_maps.pop(ea, None)
+ try:
+ self.client.call(remote_ops.force_recompile, items=[{"addr": hex(ea)}])
+ except Exception: # noqa: BLE001 -- refresh still refetches best-effort
+ pass
+
def decompile(self, ea: int, refresh: bool = False) -> Decompilation:
- """Full pseudocode for a function, returned directly by Code Mode."""
+ """Full pseudocode for a function, returned directly by IDA Nexus."""
if not refresh:
with self._lock:
hit = self._decomp.get(ea)
@@ -1727,22 +1821,15 @@ class Program:
# Cached before a rename: names may be stale. Drop Hex-Rays'
# cache so the refetch reflects the new names.
try:
- self.client.invoke("force_recompile", items=[{"addr": hex(ea)}])
+ self.client.call(
+ remote_ops.force_recompile, items=[{"addr": hex(ea)}]
+ )
except Exception: # noqa: BLE001
pass
- # Bound the decompile: a function Hex-Rays can't handle tends to stall
- # near the client's default 30s timeout, and the transport retries a
- # dropped connection up to max_retries+1 times, re-running the failing
- # decompile each time. Cap it so the worst case stays well under the
- # rpcclient socket timeout, and cache the failure below so a re-request
- # returns instantly instead of re-grinding.
+ # The typed remote declaration carries a 15-second transport timeout,
+ # so a function Hex-Rays cannot handle does not stall the UI.
try:
- # Code Mode returns the complete JSON result directly; unlike the
- # old MCP tool transport there is no structured-content envelope or
- # out-of-band download URL to unwrap.
- payload = self.client.invoke(
- "decompile", addr=hex(ea), timeout=DECOMPILE_TIMEOUT
- )
+ payload = self.client.call(remote_ops.decompile, addr=hex(ea))
except Exception as e: # noqa: BLE001 -- surface as a failed decompile
dec = Decompilation(ea, None, True, f"decompile error: {e}", False, None)
with self._lock:
@@ -1768,7 +1855,7 @@ class Program:
self._name_gen += 1
models = list(self._disasm.values())
listings = list(self._listings.values())
- self._pc_nums.clear() # a reformat moves every literal on its line
+ self._pc_nums.clear() # a reformat moves every literal on its line
for m in models:
m.invalidate()
for lm in listings:
@@ -1810,6 +1897,34 @@ class Program:
if self._listings.get(start) is lm:
del self._listings[start]
+ def invalidate_external(self) -> None:
+ """Drop every cached view of an IDB changed by another client.
+
+ An event may describe a rename, a byte patch, a new function, or a
+ segment move. Treating an unknown event as text-only risks displaying a
+ structurally impossible mix of old rows and new metadata, so the
+ external boundary deliberately invalidates all derived state. The app
+ debounces event bursts before reaching this method.
+ """
+ with self._lock:
+ self._name_gen += 1
+ models = list(self._disasm.values())
+ self._indices.clear()
+ self._disasm.clear()
+ self._listings.clear()
+ self._decomp.clear()
+ self._pc_nums.clear()
+ self._decomp_maps.clear()
+ self._flowcharts.clear()
+ self._strings = None
+ self._linkage = None
+ self._segments_cache = None
+ self._sections = None
+ self._fileregions = None
+ self._hexmodel = None
+ for model in models:
+ model.invalidate()
+
# -- item / function structure edits (IDA c/d/u/p) --------------------- #
@staticmethod
def _first_result(payload) -> dict:
@@ -1825,18 +1940,19 @@ class Program:
Undefine first so it works even when the bytes are currently part of a
data/align item — ``create_insn`` refuses to carve into a live item."""
try:
- self.client.invoke("undefine", items=[{"addr": hex(ea)}])
+ self.client.call(remote_ops.undefine, items=[{"addr": hex(ea)}])
except IDAToolError:
pass # nothing defined here yet -> just try to create the insn
res = self._first_result(
- self.client.invoke("define_code", items=[{"addr": hex(ea)}]))
+ self.client.call(remote_ops.define_code, items=[{"addr": hex(ea)}])
+ )
if res.get("error"):
raise IDAToolError("define_code", f"@ {ea:#x}: {res['error']}")
def decomp_error(self, ea: int) -> str:
"""Hex-Rays' own reason for refusing ``ea``, or "" if it won't say."""
try:
- r = self.client.invoke("decomp_error", addr=hex(ea))
+ r = self.client.call(remote_ops.decomp_error, addr=hex(ea))
except IDAToolError:
return ""
if not isinstance(r, dict):
@@ -1855,19 +1971,22 @@ class Program:
def thumb_scan(self, start: int, end: int, apply: bool = True) -> dict:
"""Find Thumb entry points from odd pointers in ``[start, end)``."""
- r = self.client.invoke("thumb_scan", start=hex(start), end=hex(end),
- apply=bool(apply))
+ r = self.client.call(
+ remote_ops.thumb_scan, start=hex(start), end=hex(end), apply=bool(apply)
+ )
if not isinstance(r, dict) or r.get("error"):
- raise IDAToolError("thumb_scan",
- f"@ {start:#x}: {(r or {}).get('error', 'failed')}")
+ raise IDAToolError(
+ "thumb_scan", f"@ {start:#x}: {(r or {}).get('error', 'failed')}"
+ )
return r
def set_thumb(self, ea: int, mode: str = "toggle") -> dict:
"""Switch ARM/Thumb decoding at ``ea``. Returns the resulting state."""
- r = self.client.invoke("set_thumb", addr=hex(ea), mode=mode)
+ r = self.client.call(remote_ops.set_thumb, addr=hex(ea), mode=mode)
if not isinstance(r, dict) or r.get("error"):
- raise IDAToolError("set_thumb",
- f"@ {ea:#x}: {(r or {}).get('error', 'failed')}")
+ raise IDAToolError(
+ "set_thumb", f"@ {ea:#x}: {(r or {}).get('error', 'failed')}"
+ )
return r
def define_code_run(self, ea: int, limit: int = 20000) -> dict:
@@ -1877,32 +1996,37 @@ class Program:
provide the run operation.
"""
try:
- r = self.client.invoke("define_code_run", addr=hex(ea), limit=int(limit))
+ r = self.client.call(
+ remote_ops.define_code_run, addr=hex(ea), limit=int(limit)
+ )
except IDAToolError:
self.define_code(ea)
return {"count": 1, "stopped": "single", "end": hex(ea)}
if not isinstance(r, dict) or r.get("error"):
- raise IDAToolError("define_code_run",
- f"@ {ea:#x}: {(r or {}).get('error', 'failed')}")
+ raise IDAToolError(
+ "define_code_run", f"@ {ea:#x}: {(r or {}).get('error', 'failed')}"
+ )
return r
def define_func(self, ea: int) -> dict:
"""Create a function starting at ``ea`` (IDA's 'p').
- Prefers the Code Mode operation, which works out the end when IDA can't;
+ Prefers the IDA Nexus operation, which works out the end when IDA can't;
falls back to a plain create for alternate clients.
"""
try:
- r = self.client.invoke("define_func_run", addr=hex(ea))
+ r = self.client.call(remote_ops.define_func_run, addr=hex(ea))
except IDAToolError:
res = self._first_result(
- self.client.invoke("define_func", items=[{"addr": hex(ea)}]))
+ self.client.call(remote_ops.define_func, items=[{"addr": hex(ea)}])
+ )
if res.get("error"):
raise IDAToolError("define_func", f"@ {ea:#x}: {res['error']}")
return {"ok": True, "how": "legacy"}
if not isinstance(r, dict) or not r.get("ok"):
- raise IDAToolError("define_func",
- f"@ {ea:#x}: {(r or {}).get('error', 'failed')}")
+ raise IDAToolError(
+ "define_func", f"@ {ea:#x}: {(r or {}).get('error', 'failed')}"
+ )
return r
def undefine(self, ea: int, size: int | None = None) -> None:
@@ -1910,7 +2034,7 @@ class Program:
item: dict = {"addr": hex(ea)}
if size:
item["size"] = int(size)
- res = self._first_result(self.client.invoke("undefine", items=[item]))
+ res = self._first_result(self.client.call(remote_ops.undefine, items=[item]))
if res.get("error"):
raise IDAToolError("undefine", f"@ {ea:#x}: {res['error']}")
@@ -1920,24 +2044,29 @@ class Program:
item: dict = {"addr": hex(ea), "type": type_decl}
if name:
item["name"] = name
- res = self._first_result(self.client.invoke("make_data", items=[item]))
+ res = self._first_result(self.client.call(remote_ops.make_data, items=[item]))
if res.get("ok") is False or res.get("error"):
raise IDAToolError(
- "make_data", f"@ {ea:#x}: {res.get('error') or 'rejected'}")
+ "make_data", f"@ {ea:#x}: {res.get('error') or 'rejected'}"
+ )
def make_string(self, ea: int, length: int = 0, kind: str = "c") -> str:
"""Create a string literal at ``ea`` (IDA's 'A'); auto-length when 0.
Returns the decoded contents."""
- r = self.client.invoke("make_string", addr=hex(ea), length=int(length), kind=kind)
+ r = self.client.call(
+ remote_ops.make_string, addr=hex(ea), length=int(length), kind=kind
+ )
res = r if isinstance(r, dict) else {}
if not res.get("ok"):
raise IDAToolError(
- "make_string", f"@ {ea:#x}: {res.get('error') or 'rejected'}")
+ "make_string", f"@ {ea:#x}: {res.get('error') or 'rejected'}"
+ )
return res.get("text", "")
# -- literal display formats (IDA's 'o': hex / dec / char / offset) ---- #
- def op_format(self, ea: int, mode: str = "cycle", col: int = -1,
- n: int = -1) -> dict:
+ def op_format(
+ self, ea: int, mode: str = "cycle", col: int = -1, n: int = -1
+ ) -> dict:
"""Change how the literal at ``ea`` is DISPLAYED in the listing.
``col`` is a column inside the rendered line, which is how the cursor
@@ -1945,8 +2074,9 @@ class Program:
``cycle``/``back`` (step the stops that make sense for this value) or a
format by name. ``show`` reports without changing anything.
"""
- r = self.client.invoke("op_format", addr=hex(ea), mode=str(mode),
- col=int(col), n=int(n))
+ r = self.client.call(
+ remote_ops.op_format, addr=hex(ea), mode=str(mode), col=int(col), n=int(n)
+ )
res = r if isinstance(r, dict) else {}
if res.get("error"):
raise IDAToolError("op_format", f"@ {ea:#x}: {res['error']}")
@@ -1969,31 +2099,43 @@ class Program:
if hit is not None and hit[1] == gen:
return hit[0]
try:
- r = self.client.invoke("pc_nums", addr=hex(fn_ea))
+ r = self.client.call(remote_ops.pc_nums, addr=hex(fn_ea))
except Exception: # noqa: BLE001 -- an older worker hasn't got the tool
r = {}
out: dict[int, list[tuple[int, int, str, int, int]]] = {}
for rec in (r or {}).get("nums", []):
try:
out.setdefault(int(rec["line"]), []).append(
- (int(rec["x0"]), int(rec["x1"]), str(rec.get("value", "")),
- _as_int(rec["ea"]), int(rec.get("opnum", 0))))
+ (
+ int(rec["x0"]),
+ int(rec["x1"]),
+ str(rec.get("value", "")),
+ _as_int(rec["ea"]),
+ int(rec.get("opnum", 0)),
+ )
+ )
except Exception: # noqa: BLE001 -- skip a malformed row
continue
with self._lock:
self._pc_nums[fn_ea] = (out, gen)
return out
- def pc_num_format(self, fn_ea: int, mode: str = "cycle", line: int = -1,
- col: int = -1) -> dict:
+ def pc_num_format(
+ self, fn_ea: int, mode: str = "cycle", line: int = -1, col: int = -1
+ ) -> dict:
"""The same, for a number in the DECOMPILATION of ``fn_ea``.
Hex-Rays keeps number formats of its own, per (address, operand) — the
listing's format doesn't reach the pseudocode and vice versa, so this is
a separate call rather than a flag on ``op_format``.
"""
- r = self.client.invoke("pc_num_format", addr=hex(fn_ea), mode=str(mode),
- line=int(line), col=int(col))
+ r = self.client.call(
+ remote_ops.pc_num_format,
+ addr=hex(fn_ea),
+ mode=str(mode),
+ line=int(line),
+ col=int(col),
+ )
res = r if isinstance(r, dict) else {}
if res.get("error"):
raise IDAToolError("pc_num_format", f"@ {fn_ea:#x}: {res['error']}")
@@ -2021,22 +2163,30 @@ class Program:
offset, page = 0, 2000
while True:
try:
- payload = self.client.invoke(
- "list_strings", offset=offset, count=page, min_len=min_len,
- refresh=(refresh and offset == 0))
+ payload = self.client.call(
+ remote_ops.list_strings,
+ offset=offset,
+ count=page,
+ min_len=min_len,
+ refresh=(refresh and offset == 0),
+ )
except IDAToolError:
return []
rows = payload.get("strings", []) if isinstance(payload, dict) else []
for r in rows:
if not isinstance(r, dict):
continue
- out.append(StrLit(
- addr=_as_int(r.get("addr", 0)),
- text=r.get("text", ""),
- length=int(r.get("len", 0) or 0),
- type=r.get("type", "") or "",
- ))
- total = int(payload.get("total", 0) or 0) if isinstance(payload, dict) else 0
+ out.append(
+ StrLit(
+ addr=_as_int(r.get("addr", 0)),
+ text=r.get("text", ""),
+ length=int(r.get("len", 0) or 0),
+ type=r.get("type", "") or "",
+ )
+ )
+ total = (
+ int(payload.get("total", 0) or 0) if isinstance(payload, dict) else 0
+ )
if len(rows) < page or len(out) >= total:
break
offset += len(rows)
@@ -2052,27 +2202,39 @@ class Program:
if hit is not None:
return hit
try:
- payload = self.client.invoke("list_linkage", kind="both")
+ payload = self.client.call(remote_ops.list_linkage, kind="both")
except IDAToolError:
return ([], [])
if not isinstance(payload, dict):
return ([], [])
- imps = [Linkage(addr=_as_int(r.get("addr", 0)),
- name=link_name(r.get("name", "")),
- module=r.get("module", "") or "",
- raw=r.get("name", "") or "")
- for r in payload.get("imports", []) if isinstance(r, dict)]
- exps = [Linkage(addr=_as_int(r.get("addr", 0)),
- name=link_name(r.get("name", "")),
- ordinal=int(r.get("ordinal", 0) or 0),
- raw=r.get("name", "") or "")
- for r in payload.get("exports", []) if isinstance(r, dict)]
+ imps = [
+ Linkage(
+ addr=_as_int(r.get("addr", 0)),
+ name=link_name(r.get("name", "")),
+ module=r.get("module", "") or "",
+ raw=r.get("name", "") or "",
+ )
+ for r in payload.get("imports", [])
+ if isinstance(r, dict)
+ ]
+ exps = [
+ Linkage(
+ addr=_as_int(r.get("addr", 0)),
+ name=link_name(r.get("name", "")),
+ ordinal=int(r.get("ordinal", 0) or 0),
+ raw=r.get("name", "") or "",
+ )
+ for r in payload.get("exports", [])
+ if isinstance(r, dict)
+ ]
out = ([i for i in imps if i.name], [e for e in exps if e.name])
with self._lock:
self._linkage = out
return out
- def annotations(self, limit: int = 4000) -> tuple[list["Comment"], list["NamedItem"]]:
+ def annotations(
+ self, limit: int = 4000
+ ) -> tuple[list["Comment"], list["NamedItem"]]:
"""``(comments, names)`` -- everything a person added to this database.
Not cached: it is the *current* state of your work, and the one caller
@@ -2080,44 +2242,64 @@ class Program:
no such operation, so an alternate client degrades instead of breaking.
"""
try:
- payload = self.client.invoke("list_annotations", limit=int(limit))
+ payload = self.client.call(remote_ops.list_annotations, limit=int(limit))
except IDAToolError:
return ([], [])
if not isinstance(payload, dict):
return ([], [])
comments = [
- Comment(addr=_as_int(r.get("addr", 0)), text=str(r.get("text", "")),
- repeatable=bool(r.get("repeatable")),
- whole_func=bool(r.get("whole_func")),
- line=str(r.get("line", "") or ""),
- seg=str(r.get("seg", "") or ""),
- func=(r.get("func") or None),
- func_addr=(_as_int(r["func_addr"]) if r.get("func_addr") else None))
- for r in payload.get("comments", []) if isinstance(r, dict) and r.get("text")]
+ Comment(
+ addr=_as_int(r.get("addr", 0)),
+ text=str(r.get("text", "")),
+ repeatable=bool(r.get("repeatable")),
+ whole_func=bool(r.get("whole_func")),
+ line=str(r.get("line", "") or ""),
+ seg=str(r.get("seg", "") or ""),
+ func=(r.get("func") or None),
+ func_addr=(_as_int(r["func_addr"]) if r.get("func_addr") else None),
+ )
+ for r in payload.get("comments", [])
+ if isinstance(r, dict) and r.get("text")
+ ]
names = [
- NamedItem(addr=_as_int(r.get("addr", 0)), name=str(r.get("name", "")),
- is_func=bool(r.get("func")), size=int(r.get("size", 0) or 0),
- proto=(r.get("proto") or None), seg=str(r.get("seg", "") or ""))
- for r in payload.get("names", []) if isinstance(r, dict) and r.get("name")]
+ NamedItem(
+ addr=_as_int(r.get("addr", 0)),
+ name=str(r.get("name", "")),
+ is_func=bool(r.get("func")),
+ size=int(r.get("size", 0) or 0),
+ proto=(r.get("proto") or None),
+ seg=str(r.get("seg", "") or ""),
+ )
+ for r in payload.get("names", [])
+ if isinstance(r, dict) and r.get("name")
+ ]
return (comments, names)
-
- def search(self, query: str, mode: str = "text", *, limit: int = 500,
- regex: bool = False, case: bool = False,
- ) -> tuple[list["SearchHit"], str | None, bool]:
+ def search(
+ self,
+ query: str,
+ mode: str = "text",
+ *,
+ limit: int = 500,
+ regex: bool = False,
+ case: bool = False,
+ ) -> tuple[list["SearchHit"], str | None, bool]:
"""Search the whole database. Returns ``(hits, error, truncated)``.
A failed search is DATA (a message to show), not an exception: a bad
regex or an unparsable byte pattern is something the user typed, and
the palette wants to say so without unwinding.
"""
- op = "search_bytes" if mode == "bytes" else "search_text"
+ operation = (
+ remote_ops.search_bytes if mode == "bytes" else remote_ops.search_text
+ )
args: dict = {"limit": int(limit), "case": bool(case)}
if mode == "bytes":
# Validate HERE, not just in the UI: IDA's find_bytes answers a
# malformed pattern with zero hits and no error, which reads as
# "not present" -- the most misleading answer a search can give.
from .search import normalise_pattern, pattern_problem
+
problem = pattern_problem(query)
if problem:
return ([], problem, False)
@@ -2126,29 +2308,32 @@ class Program:
args["query"] = query
args["regex"] = bool(regex)
try:
- payload = self.client.invoke(op, **args)
+ payload = self.client.call(operation, **args)
except IDAToolError as e:
return ([], str(e), False)
if not isinstance(payload, dict):
return ([], "the backend returned nothing searchable", False)
hits = [
- SearchHit(addr=_as_int(r.get("addr", 0)),
- head=_as_int(r.get("head", r.get("addr", 0))),
- line=str(r.get("line", "") or ""),
- func=(r.get("func") or None),
- func_addr=(_as_int(r["func_addr"]) if r.get("func_addr")
- else None),
- seg=str(r.get("seg", "") or ""))
- for r in payload.get("hits", []) if isinstance(r, dict)]
+ SearchHit(
+ addr=_as_int(r.get("addr", 0)),
+ head=_as_int(r.get("head", r.get("addr", 0))),
+ line=str(r.get("line", "") or ""),
+ func=(r.get("func") or None),
+ func_addr=(_as_int(r["func_addr"]) if r.get("func_addr") else None),
+ seg=str(r.get("seg", "") or ""),
+ )
+ for r in payload.get("hits", [])
+ if isinstance(r, dict)
+ ]
return (hits, payload.get("error") or None, bool(payload.get("truncated")))
def journal_get(self) -> str:
"""The findings journal blob stored in this database ('' if none)."""
- payload = self.client.invoke("journal_get")
+ payload = self.client.call(remote_ops.journal_get)
return str(payload.get("data", "")) if isinstance(payload, dict) else ""
def journal_put(self, data: str) -> None:
- self.client.invoke("journal_put", data=str(data))
+ self.client.call(remote_ops.journal_put, data=str(data))
def decomp_map(self, ea: int) -> list[list[int]]:
"""Per-pseudocode-line instruction coverage for the split-view region
@@ -2161,12 +2346,15 @@ class Program:
if hit is not None and hit[1] == gen:
return hit[0]
try:
- payload = self.client.invoke("decomp_map", addr=hex(ea))
+ payload = self.client.call(remote_ops.decomp_map, addr=hex(ea))
except IDAToolError:
return []
lines = payload.get("lines", []) if isinstance(payload, dict) else []
- out = [[_as_int(e) for e in (ln.get("eas") or [])]
- for ln in lines if isinstance(ln, dict)]
+ out = [
+ [_as_int(e) for e in (ln.get("eas") or [])]
+ for ln in lines
+ if isinstance(ln, dict)
+ ]
with self._lock:
self._decomp_maps[ea] = (out, gen)
return out
@@ -2191,7 +2379,7 @@ class Program:
if hit is not None and hit[1] == gen:
return hit[0]
try:
- payload = self.client.invoke("flowchart", addr=hex(ea))
+ payload = self.client.call(remote_ops.flowchart, addr=hex(ea))
except IDAToolError:
return None
if not isinstance(payload, dict) or payload.get("error"):
@@ -2202,10 +2390,14 @@ class Program:
blocks = []
for b in raw:
try:
- blocks.append(BasicBlock(
- id=int(b["id"]), start=_as_int(b["start"]),
- end=_as_int(b["end"]),
- succs=[(int(d), str(k)) for d, k in (b.get("succs") or [])]))
+ blocks.append(
+ BasicBlock(
+ id=int(b["id"]),
+ start=_as_int(b["start"]),
+ end=_as_int(b["end"]),
+ succs=[(int(d), str(k)) for d, k in (b.get("succs") or [])],
+ )
+ )
except (KeyError, ValueError, TypeError):
continue
if not blocks:
@@ -2217,8 +2409,9 @@ class Program:
for b in blocks:
# bisect, not a scan per block: a 400-block function against a few
# thousand rows is a million comparisons done for nothing.
- b.rows = rows[bisect.bisect_left(eas, b.start):
- bisect.bisect_left(eas, b.end)]
+ b.rows = rows[
+ bisect.bisect_left(eas, b.start) : bisect.bisect_left(eas, b.end)
+ ]
fcv = Flowchart(
func_ea=_as_int(f.get("addr", lo)),
name=str(f.get("name") or f"sub_{lo:X}"),
@@ -2261,11 +2454,12 @@ class Program:
operand marks for free."""
out: list[Head] = []
addr = lo
- for _ in range(64): # bounded: ~128k heads
+ for _ in range(64): # bounded: ~128k heads
if addr >= hi:
break
- payload = self.client.invoke("heads", addr=hex(addr), end=hex(hi),
- count=2000)
+ payload = self.client.call(
+ remote_ops.heads, addr=hex(addr), end=hex(hi), count=2000
+ )
rows = payload.get("heads", []) if isinstance(payload, dict) else []
if not rows:
break
@@ -2293,27 +2487,34 @@ class Program:
# -- cross-references & containing function --------------------------- #
def function_of(self, ea: int) -> Func | None:
"""Return the function containing ``ea`` (resolves mid-function addrs)."""
- payload = self.client.invoke("lookup_funcs", queries=[hex(ea)])
+ payload = self.client.call(remote_ops.lookup_funcs, queries=[hex(ea)])
res = payload.get("result", []) if isinstance(payload, dict) else []
fn = res[0].get("fn") if res and isinstance(res[0], dict) else None
return Func.from_raw(fn) if fn else None
def xrefs_from(self, ea: int) -> list[Xref]:
- payload = self.client.invoke(
- "xref_query",
+ payload = self.client.call(
+ remote_ops.xref_query,
queries=[{"addr": hex(ea), "direction": "from", "include_fn": True}],
)
return _parse_xrefs(payload)
def xrefs_to(self, ea: int, limit: int = 2000) -> list[Xref]:
- q = [{"addr": hex(ea), "direction": "to", "include_fn": True,
- "dedup": True, "count": limit}]
+ q = [
+ {
+ "addr": hex(ea),
+ "direction": "to",
+ "include_fn": True,
+ "dedup": True,
+ "count": limit,
+ }
+ ]
try:
# xref_types adds a fine-grained `kind` (call/read/write/...) for the
# xref dialog; fall back to xref_query (code/data only) if absent.
- payload = self.client.invoke("xref_types", queries=q)
+ payload = self.client.call(remote_ops.xref_types, queries=q)
except IDAToolError:
- payload = self.client.invoke("xref_query", queries=q)
+ payload = self.client.call(remote_ops.xref_query, queries=q)
return _parse_xrefs(payload)
# -- address resolution ------------------------------------------------ #
@@ -2331,7 +2532,7 @@ class Program:
# (loc_/locret_): lookup_funcs would map a label to its *containing*
# function's entry, so double-clicking a label jumped to the wrong place.
try:
- payload = self.client.invoke("resolve_names", queries=[s])
+ payload = self.client.call(remote_ops.resolve_names, queries=[s])
res = payload.get("result", []) if isinstance(payload, dict) else []
ea = res[0].get("ea") if res and isinstance(res[0], dict) else None
if ea:
@@ -2341,7 +2542,7 @@ class Program:
# Fall back to function-name resolution (also drives the 'did you mean'
# suggestion when the name is unknown).
try:
- payload = self.client.invoke("lookup_funcs", queries=[s])
+ payload = self.client.call(remote_ops.lookup_funcs, queries=[s])
except IDAToolError as e:
raise KeyError(f"cannot resolve {target!r}: {e}") from e
res = payload.get("result", []) if isinstance(payload, dict) else []
@@ -2367,8 +2568,10 @@ class Program:
except Exception: # noqa: BLE001 -- suggestions are strictly optional
return ""
if not cands:
- return (" (no function name contains it; it may be a data symbol or "
- "not a function — pass an address like 0x1234)")
+ return (
+ " (no function name contains it; it may be a data symbol or "
+ "not a function — pass an address like 0x1234)"
+ )
shown = cands[:5]
names = ", ".join(f"{c.name} @ {c.addr:#x}" for c in shown)
more = " …" if len(cands) > len(shown) else ""
@@ -2379,7 +2582,9 @@ class Program:
"""Set (empty text clears) the comment at ``ea``; affects both the disasm
and decompiler views. Returns the raw payload so the caller can surface a
soft per-item error. The caller must invalidate/recompile to see it."""
- return self.client.invoke("set_comments", items=[{"addr": hex(ea), "comment": text}])
+ return self.client.call(
+ remote_ops.set_comments, items=[{"addr": hex(ea), "comment": text}]
+ )
# -- invalidation (after edits) --------------------------------------- #
def invalidate(self, ea: int) -> None:
@@ -2408,14 +2613,16 @@ def _parse_xrefs(payload) -> list[Xref]:
fn = d.get("fn") or {}
frm = d.get("from", d.get("addr"))
to = d.get("to")
- out.append(Xref(
- frm=_as_int(frm) if frm is not None else 0,
- to=_as_int(to) if to is not None else None,
- type=d.get("type", "?"),
- fn_name=fn.get("name"),
- fn_addr=_as_int(fn["addr"]) if fn.get("addr") else None,
- kind=d.get("kind"),
- ))
+ out.append(
+ Xref(
+ frm=_as_int(frm) if frm is not None else 0,
+ to=_as_int(to) if to is not None else None,
+ type=d.get("type", "?"),
+ fn_name=fn.get("name"),
+ fn_addr=_as_int(fn["addr"]) if fn.get("addr") else None,
+ kind=d.get("kind"),
+ )
+ )
return out
@@ -2425,13 +2632,15 @@ def _parse_decompilation(ea: int, payload) -> Decompilation:
code = payload.get("code")
error = payload.get("error")
if not code:
- return Decompilation(ea, None, True, error or "decompilation failed",
- False, None)
+ return Decompilation(
+ ea, None, True, error or "decompilation failed", False, None
+ )
m = _TRUNC_RE.search(code)
truncated = m is not None
total_chars = int(m.group(1)) if m else len(code)
refs = [
Ref(addr=_as_int(r["addr"]), name=r.get("name", ""), string=r.get("string"))
- for r in payload.get("refs", []) if isinstance(r, dict) and "addr" in r
+ for r in payload.get("refs", [])
+ if isinstance(r, dict) and "addr" in r
]
return Decompilation(ea, code, False, error, truncated, total_chars, refs)
diff --git a/idatui/edit_ctl.py b/idatui/edit_ctl.py
index 52566ca..89dfc69 100644
--- a/idatui/edit_ctl.py
+++ b/idatui/edit_ctl.py
@@ -19,6 +19,7 @@ The message handlers and the ``@work`` entry points stay on ``IdaTui``: Textual
dispatches ``on_<message>`` by name on the DOMNode, and its worker machinery
wants a DOMNode host. They are one-line delegates into here.
"""
+
from __future__ import annotations
import re
@@ -26,10 +27,10 @@ from typing import TYPE_CHECKING
from textual.widgets import DataTable
-from . import diag
+from . import diag, remote_ops
from .errors import IDAToolError
-if TYPE_CHECKING: # pragma: no cover
+if TYPE_CHECKING: # pragma: no cover
from .app import IdaTui
_app_mod = None
@@ -40,13 +41,18 @@ def _M():
global _app_mod
if _app_mod is None:
from . import app as _m
+
_app_mod = _m
return _app_mod
#: A C type wide enough for N bytes, for prefilling a retype/define prompt.
-_BY_SIZE = {1: "unsigned __int8", 2: "unsigned __int16",
- 4: "unsigned __int32", 8: "unsigned __int64"}
+_BY_SIZE = {
+ 1: "unsigned __int8",
+ 2: "unsigned __int16",
+ 4: "unsigned __int32",
+ 8: "unsigned __int64",
+}
class EditController:
@@ -169,16 +175,24 @@ class EditController:
# If the cursor is on a symbol token (a call/branch target, a data
# reference, or this head's own label) rename THAT symbol; otherwise
# create/rename a label at the head's address (bare/undefined bytes).
- if (word and app._looks_like_symbol(word) and word != mnem
- and word.lower() not in M._ASM_KEYWORDS):
+ if (
+ word
+ and app._looks_like_symbol(word)
+ and word != mnem
+ and word.lower() not in M._ASM_KEYWORDS
+ ):
app.prompts.rename.show(
f"rename '{word}' — Enter=apply Esc=cancel",
- word, ctx=(msg.view, word, None))
+ word,
+ ctx=(msg.view, word, None),
+ )
else:
cur = head.name if (head is not None and head.name) else ""
app.prompts.rename.show(
f"name @ {ea:#x} — Enter=apply Esc=cancel",
- cur, ctx=(msg.view, cur, ea))
+ cur,
+ ctx=(msg.view, cur, ea),
+ )
return
if not msg.name:
app._status("nothing to rename under the cursor")
@@ -186,11 +200,14 @@ class EditController:
if self.is_pseudocode_label(msg.view, msg.name):
app._status(
f"can't rename pseudocode label '{msg.name}' "
- "(Hex-Rays goto labels aren't renamable via the API)")
+ "(Hex-Rays goto labels aren't renamable via the API)"
+ )
return
app.prompts.rename.show(
f"rename '{msg.name}' — Enter=apply Esc=cancel",
- msg.name, ctx=(msg.view, msg.name, None))
+ msg.name,
+ ctx=(msg.view, msg.name, None),
+ )
def submit_rename(self, ctx, value: str) -> None: # type: ignore[no-untyped-def]
view, old, addr = ctx
@@ -242,7 +259,7 @@ class EditController:
kind = "stack"
batch = {"stack": {"func_addr": hex(cur.ea), "old": old, "new": new}}
try:
- res = prog.client.invoke("rename", batch=batch)
+ res = prog.client.call(remote_ops.rename, batch=batch)
except IDAToolError as e:
app.call_from_thread(app._status, f"rename failed: {e.message}")
return
@@ -275,8 +292,9 @@ class EditController:
app = self.app
assert app.program is not None
try:
- res = app.program.client.invoke(
- "rename", batch={"data": {"addr": hex(addr), "new": name}})
+ res = app.program.client.call(
+ remote_ops.rename, batch={"data": {"addr": hex(addr), "new": name}}
+ )
except IDAToolError as e:
app.call_from_thread(app._status, f"name failed: {e.message}")
return
@@ -307,11 +325,11 @@ class EditController:
lm = app.program.listing(addr)
label = name if is_func_start else app.program.region_label(addr)
idx = max(lm.ensure_ea(addr), 0) if lm is not None else 0
- app.call_from_thread(self.open_at_named, label, addr, idx, name,
- is_func_start)
+ app.call_from_thread(self.open_at_named, label, addr, idx, name, is_func_start)
- def open_at_named(self, label: str, addr: int, idx: int, name: str,
- is_func_start: bool = False) -> None:
+ def open_at_named(
+ self, label: str, addr: int, idx: int, name: str, is_func_start: bool = False
+ ) -> None:
app = self.app
if is_func_start:
app.program.bump_names()
@@ -328,10 +346,11 @@ class EditController:
pseudocode a comment is `// text` before the trailing /*0xEA*/ markers;
C has no `//` operator, so the last `//` is unambiguously the comment."""
if isinstance(view, _M().DecompView) and 0 <= view.cursor < len(view._texts):
- s = re.sub(r"(?:/\*\s*0x[0-9A-Fa-f]+\s*\*/\s*)+$", "",
- view._texts[view.cursor])
+ s = re.sub(
+ r"(?:/\*\s*0x[0-9A-Fa-f]+\s*\*/\s*)+$", "", view._texts[view.cursor]
+ )
i = s.rfind("//")
- return s[i + 2:].strip() if i >= 0 else ""
+ return s[i + 2 :].strip() if i >= 0 else ""
return ""
def request_comment(self, msg) -> None: # type: ignore[no-untyped-def]
@@ -349,7 +368,9 @@ class EditController:
what = "function comment" if func_level else "comment"
app.prompts.comment.show(
f"{what} @ {ea:#x} — Enter=apply (empty=clear) Esc=cancel",
- existing, ctx=(msg.view, ea, existing))
+ existing,
+ ctx=(msg.view, ea, existing),
+ )
def submit_comment(self, ctx, value: str) -> None: # type: ignore[no-untyped-def]
view, ea, existing = ctx
@@ -369,10 +390,13 @@ class EditController:
app.call_from_thread(app._status, f"comment failed: {e.message}")
return
data = res.get("result") if isinstance(res, dict) else None
- if (isinstance(data, list) and data and isinstance(data[0], dict)
- and data[0].get("error")):
- app.call_from_thread(app._status,
- f"comment failed: {data[0]['error']}")
+ if (
+ isinstance(data, list)
+ and data
+ and isinstance(data[0], dict)
+ and data[0].get("error")
+ ):
+ app.call_from_thread(app._status, f"comment failed: {data[0]['error']}")
return
app.call_from_thread(self.after_comment, ea, text)
@@ -429,30 +453,34 @@ class EditController:
if dt is not None and not dt.get("is_func"):
kind, subject = "data", tgt
prefill = dt.get("type") or self.guess_data_type(
- dt.get("size") or 0)
+ dt.get("size") or 0
+ )
# 3) fall back to the current function itself
if kind is None and ft is not None:
kind, subject, prefill = "func", app._cur.ea, ft.prototype
if kind is None:
- app.call_from_thread(app._status,
- "nothing to retype under the cursor")
+ app.call_from_thread(app._status, "nothing to retype under the cursor")
return
- app.call_from_thread(self.open_retype, view, kind, subject,
- word or "", prefill)
+ app.call_from_thread(self.open_retype, view, kind, subject, word or "", prefill)
- def open_retype(self, view, kind: str, subject: int, word: str,
- prefill: str) -> None: # type: ignore[no-untyped-def]
+ def open_retype(
+ self, view, kind: str, subject: int, word: str, prefill: str
+ ) -> None: # type: ignore[no-untyped-def]
label = "prototype" if kind == "func" else f"type for '{word}'"
- self.app.prompts.retype.show(f"{label} — Enter=apply Esc=cancel",
- prefill, ctx=(view, kind, subject, word))
+ self.app.prompts.retype.show(
+ f"{label} — Enter=apply Esc=cancel",
+ prefill,
+ ctx=(view, kind, subject, word),
+ )
def submit_retype(self, ctx, value: str) -> None: # type: ignore[no-untyped-def]
view, kind, subject, word = ctx
if view is not None and value:
self.app._do_retype(kind, subject, word, value)
- def do_retype(self, kind: str, subject: int, word: str,
- new: str) -> None: # worker context
+ def do_retype(
+ self, kind: str, subject: int, word: str, new: str
+ ) -> None: # worker context
app = self.app
assert app.program is not None
if kind == "func":
@@ -473,8 +501,9 @@ class EditController:
app.program.bump_names()
self.reload_active_code()
app._dirty = True
- app.journal.record("retype", getattr(app._cur, "ea", None), word,
- {"kind": kind})
+ app.journal.record(
+ "retype", getattr(app._cur, "ea", None), word, {"kind": kind}
+ )
what = "prototype" if kind == "func" else f"'{word}'"
app._status(f"retyped {what} (Ctrl+S to save)")
@@ -498,15 +527,17 @@ class EditController:
f"data type @ {ea:#x} (e.g. int, char[16], my_struct)"
" — Enter=apply Esc=cancel",
self.default_data_type(head) if head is not None else "int",
- ctx=(view, ea))
+ ctx=(view, ea),
+ )
def submit_make_data(self, ctx, value: str) -> None: # type: ignore[no-untyped-def]
view, ea = ctx
if view is not None and value:
self.app._do_make_data(ea, value, self.app._anchor())
- def do_make_data(self, ea: int, type_decl: str,
- anchor=None) -> None: # worker context
+ def do_make_data(
+ self, ea: int, type_decl: str, anchor=None
+ ) -> None: # worker context
app = self.app
assert app.program is not None
try:
@@ -522,8 +553,7 @@ class EditController:
lm = app.program.listing(ea)
idx = max(lm.ensure_ea(ea), 0) if lm is not None else 0
_cur, top = app._anchor_rows(anchor, lm, ea)
- app.call_from_thread(
- app._open_at, ea, name, idx, False, -1, 0, True, None, top)
+ app.call_from_thread(app._open_at, ea, name, idx, False, -1, 0, True, None, top)
app.call_from_thread(self.edit_done, anchor)
# -- literal display formats (IDA 'o') --------------------------------- #
@@ -554,8 +584,9 @@ class EditController:
fn = view.loaded_ea if view.loaded_ea is not None else app._cur.ea
app._do_op_format(msg.mode, "decomp", fn, view.cursor_x, view.cursor)
- def do_op_format(self, mode: str, where: str, ea: int, col: int,
- line: int = -1) -> None: # worker context
+ def do_op_format(
+ self, mode: str, where: str, ea: int, col: int, line: int = -1
+ ) -> None: # worker context
app = self.app
assert app.program is not None
try:
@@ -584,7 +615,9 @@ class EditController:
app.call_from_thread(
app._status,
f"{what}{fmt} {r.get('value') or ''}"
- f" [{', '.join(r.get('choices', []))}]", True)
+ f" [{', '.join(r.get('choices', []))}]",
+ True,
+ )
return
step = f"{prev} \u2192 {fmt}" if prev and prev != fmt else fmt
desc = f"{what}{step}: {text[:96]}"
@@ -629,13 +662,17 @@ class EditController:
return
app._do_edit_item(msg.kind, ea, app._anchor())
- def do_edit_item(self, kind: str, ea: int,
- anchor=None) -> None: # worker context
+ def do_edit_item(self, kind: str, ea: int, anchor=None) -> None: # worker context
app = self.app
assert app.program is not None
- verb = {"code": "defined code", "func": "created function",
- "undef": "undefined", "string": "made string",
- "thumb": "switched decoding", "thumbscan": "scanned"}[kind]
+ verb = {
+ "code": "defined code",
+ "func": "created function",
+ "undef": "undefined",
+ "string": "made string",
+ "thumb": "switched decoding",
+ "thumbscan": "scanned",
+ }[kind]
try:
if kind == "code":
# Keep going until something stops it: one instruction is rarely
@@ -646,20 +683,24 @@ class EditController:
if n == 0 and why == "defined":
# Already code/data here — a no-op, not a failure. Saying
# "failed to create instruction" for it would be a lie.
- app.call_from_thread(
- app._status, f"already defined @ {ea:#x}")
+ app.call_from_thread(app._status, f"already defined @ {ea:#x}")
return
if n == 0:
- raise IDAToolError("define_code",
- f"@ {ea:#x}: Failed to create instruction")
+ raise IDAToolError(
+ "define_code", f"@ {ea:#x}: Failed to create instruction"
+ )
end = int(str(r.get("end", hex(ea))), 0)
- reason = {"undecodable": "hit bytes that don't decode",
- "flow": "control flow ends here",
- "defined": "ran into existing code/data",
- "segment": "end of segment",
- "limit": "instruction limit"}.get(why, why)
- verb = (f"defined {n} instruction{'s' if n != 1 else ''} "
- f"({ea:#x}\u2013{end:#x}) \u2014 {reason}")
+ reason = {
+ "undecodable": "hit bytes that don't decode",
+ "flow": "control flow ends here",
+ "defined": "ran into existing code/data",
+ "segment": "end of segment",
+ "limit": "instruction limit",
+ }.get(why, why)
+ verb = (
+ f"defined {n} instruction{'s' if n != 1 else ''} "
+ f"({ea:#x}\u2013{end:#x}) \u2014 {reason}"
+ )
elif kind == "thumbscan":
# A vector table is a list of Thumb entry points that IDA won't
# follow on a headerless image, because nothing tells it those
@@ -668,11 +709,15 @@ class EditController:
r = app.program.thumb_scan(ea, ea + 0x400)
n, applied = int(r.get("n", 0)), int(r.get("applied", 0))
if not n:
- verb = (f"no Thumb entry pointers in {ea:#x}\u2013{ea+0x400:#x}"
- " (odd words pointing into the image)")
+ verb = (
+ f"no Thumb entry pointers in {ea:#x}\u2013{ea + 0x400:#x}"
+ " (odd words pointing into the image)"
+ )
else:
- verb = (f"{n} Thumb entr{'y' if n == 1 else 'ies'} found, "
- f"{applied} disassembled")
+ verb = (
+ f"{n} Thumb entr{'y' if n == 1 else 'ies'} found, "
+ f"{applied} disassembled"
+ )
elif kind == "thumb":
# Switch the mode, then disassemble in it: flipping T and
# leaving the bytes undefined shows nothing, and the reason you
@@ -686,11 +731,16 @@ class EditController:
verb += " (segment set to 32-bit; Thumb needs ARM32)"
if r.get("db_64bit"):
# Disassembly will look right and F5 will never work.
- verb += (" \u26a0 this database is 64-bit, so Hex-Rays "
- "won't decompile it \u2014 Ctrl+L and pick "
- "arm:ARMv7-A")
- verb += (f" \u2014 {n} instruction{'s' if n != 1 else ''}"
- if n else " \u2014 still doesn't decode")
+ verb += (
+ " \u26a0 this database is 64-bit, so Hex-Rays "
+ "won't decompile it \u2014 Ctrl+L and pick "
+ "arm:ARMv7-A"
+ )
+ verb += (
+ f" \u2014 {n} instruction{'s' if n != 1 else ''}"
+ if n
+ else " \u2014 still doesn't decode"
+ )
# falls through to the shared reload: same cache bump, same
# anchor restore, same flash. That is the whole point of having
# one path.
@@ -698,9 +748,11 @@ class EditController:
anchor.refresh_functions = True
r = app.program.define_func(ea)
if r.get("start") and r.get("end"):
- verb = (f"created function {r['start']}\u2013{r['end']}"
- + (" (end worked out from the code)"
- if r.get("how") == "explicit-end" else ""))
+ verb = f"created function {r['start']}\u2013{r['end']}" + (
+ " (end worked out from the code)"
+ if r.get("how") == "explicit-end"
+ else ""
+ )
elif kind == "string":
s = app.program.make_string(ea)
verb = f"made string ({s[:24]!r})" if s else verb
@@ -726,13 +778,14 @@ class EditController:
idx = 0 if ea == fn.addr else model.index_of_ea(ea)
_cur, top = app._anchor_rows(anchor, model, ea)
app.call_from_thread(
- app._open_at, fn.addr, fn.name, idx, False, -1, 0, False,
- None, top)
+ app._open_at, fn.addr, fn.name, idx, False, -1, 0, False, None, top
+ )
else:
name = app.program.region_label(ea)
lm = app.program.listing(ea)
idx = max(lm.ensure_ea(ea), 0) if lm is not None else 0
_cur, top = app._anchor_rows(anchor, lm, ea)
app.call_from_thread(
- app._open_at, ea, name, idx, False, -1, 0, True, None, top)
+ app._open_at, ea, name, idx, False, -1, 0, True, None, top
+ )
app.call_from_thread(self.edit_done, anchor)
diff --git a/idatui/errors.py b/idatui/errors.py
index 7632ade..ab1365a 100644
--- a/idatui/errors.py
+++ b/idatui/errors.py
@@ -1,6 +1,6 @@
"""TUI-facing error hierarchy and lightweight database session model.
-The Code Mode adapter normalizes ``ida_codemode`` transport and execution
+The IDA Nexus adapter normalizes ``ida_nexus`` transport and execution
errors into these types so the domain and Textual layers do not depend on HTTP or
registry implementation details.
"""
diff --git a/idatui/kittygfx.py b/idatui/kittygfx.py
index 2f6766d..62fd6a6 100644
--- a/idatui/kittygfx.py
+++ b/idatui/kittygfx.py
@@ -79,8 +79,16 @@ def log(msg: str) -> None:
# Detection
# --------------------------------------------------------------------------- #
def _query_tty(timeout: float = 2.0) -> bool:
- import termios
- import tty as ttymod
+ # ``termios`` and ``/dev/tty`` are POSIX-only. Native Windows terminals
+ # generally don't expose the synchronous reply channel this probe needs;
+ # use the ANSI-art splash there instead of making graphics fatal to the
+ # whole application. IDATUI_KITTY=1 still permits an explicit override.
+ try:
+ import termios
+ import tty as ttymod
+ except ImportError:
+ log("supported: tty queries are unavailable on this platform")
+ return False
try:
fd = os.open("/dev/tty", os.O_RDWR | os.O_NOCTTY)
@@ -144,7 +152,11 @@ def supported() -> bool:
_supported = False # pilot tests, pipes, redirected output
log("supported: stdout is not a tty")
else:
- _supported = _query_tty()
+ try:
+ _supported = _query_tty()
+ except Exception as exc: # graphics are optional on every platform
+ log(f"supported: terminal query failed ({type(exc).__name__}: {exc})")
+ _supported = False
log(f"supported() -> {_supported}")
return _supported
diff --git a/idatui/launch.py b/idatui/launch.py
index e3cb091..a0201a7 100644
--- a/idatui/launch.py
+++ b/idatui/launch.py
@@ -1,6 +1,6 @@
-"""One-shot launcher for the IDA Code Mode-backed TUI.
+"""One-shot launcher for the IDA Nexus-backed TUI.
-A path first resolves to a registered GUI database; when none matches, Code Mode
+A path first resolves to a registered GUI database; when none matches, IDA Nexus
reuses or starts a managed idalib worker. With no path, a single registered
database is selected automatically.
@@ -34,9 +34,9 @@ def _log(msg: str) -> None:
def _registered_databases() -> tuple[list[dict], list[dict]]:
- """Ready and blocked Code Mode registrations, with normalized errors."""
+ """Ready and blocked IDA Nexus registrations, with normalized errors."""
try:
- from ida_codemode import InstanceState, discover_databases
+ from ida_nexus import InstanceState, discover_databases
ready: list[dict] = []
blocked: list[dict] = []
@@ -70,7 +70,7 @@ def main(argv: list[str] | None = None) -> int:
help="open a multi-binary project (created from the given "
"binaries if FILE doesn't exist)")
p.add_argument("--ttl", type=int, default=1800,
- help="deprecated compatibility option (Code Mode uses leases)")
+ help="deprecated compatibility option (IDA Nexus uses leases)")
p.add_argument("--no-keepalive", action="store_true",
help="deprecated compatibility option (the lease is the heartbeat)")
p.add_argument("--rpc", metavar="PATH",
@@ -87,7 +87,7 @@ def main(argv: list[str] | None = None) -> int:
g.add_argument("--base", metavar="ADDR",
help="load address, e.g. 0x8000000 (any base; NOT paragraphs)")
g.add_argument("--ida-args", metavar="STR", dest="ida_args",
- help="legacy switches; only Code Mode-representable -p/-b/-T are accepted")
+ help="legacy switches; only IDA Nexus-representable -p/-b/-T are accepted")
args = p.parse_args(argv)
load: dict = {}
@@ -166,10 +166,10 @@ def main(argv: list[str] | None = None) -> int:
_log(f"attaching to registered {item.get('backend')} database: {binary}")
elif not ready:
detail = f" ({blocked[0].get('error')})" if blocked else ""
- _log(f"no registered Code Mode database; pass a binary path{detail}")
+ _log(f"no registered IDA Nexus database; pass a binary path{detail}")
return 2
else:
- _log("several Code Mode databases are registered; pass one of these paths:")
+ _log("several IDA Nexus databases are registered; pass one of these paths:")
for item in ready:
_log(f" {item.get('exe_path') or item.get('idb_path')} "
f"[{item.get('backend')}, {item.get('record_id')}]")
diff --git a/idatui/nexus_client.py b/idatui/nexus_client.py
new file mode 100644
index 0000000..44eb1b1
--- /dev/null
+++ b/idatui/nexus_client.py
@@ -0,0 +1,551 @@
+"""Client adapter from ida-tui's domain operations to IDA Nexus.
+
+``DatabaseHandle`` is the lifecycle boundary: it discovers an already-registered
+GUI database, reuses a shared managed idalib worker, or starts one when needed.
+The TUI never owns or terminates an IDA process. Closing this client releases
+only its lease.
+
+Remote operations are ordinary typed Python functions declared in
+``idatui.remote_ops``. IDA Nexus installs their content-addressed modules once
+per IDA Python interpreter; subsequent calls send only encoded arguments. The
+optimized IDAPython listing/decompiler implementation remains real source in
+``idatui.remote_tools`` and is installed through the same module interface.
+"""
+
+from __future__ import annotations
+
+import os
+import shlex
+import threading
+import time
+from collections.abc import Callable
+from typing import Any
+
+from .errors import IDAConnectionError, IDATimeoutError, IDAToolError, Session
+
+# ida_nexus is imported EAGERLY-IF-PRESENT but never at hard import cost.
+#
+# The paging/graph/trace layers and their offline test suites must keep importing
+# `idatui` on a machine with no IDA and no IDA Nexus installed -- that is the
+# house rule the stdlib-only worker client used to satisfy for free, and
+# `tests/run.py --fast` (380 checks, any python3) depends on it. A hard top-level
+# import here makes the whole package unimportable, so the failure is deferred to
+# the first operation that genuinely needs the library.
+_NEXUS_ERROR: Exception | None = None
+try:
+ from ida_nexus import (
+ DatabaseBusyError,
+ DatabaseDisconnectedError,
+ DatabaseHandle,
+ DatabaseInstance,
+ DatabaseOpenOptions,
+ NexusConnectionError,
+ RemoteError,
+ find_database_owner,
+ wait_database_released,
+ )
+except ImportError as _exc: # library absent: usable only for offline layers
+ _NEXUS_ERROR = _exc
+ # Bound to None rather than left undefined so the names stay patchable: the
+ # offline contract tests inject a fake DatabaseHandle here.
+ NexusConnectionError = DatabaseDisconnectedError = RemoteError = None # type: ignore[assignment,misc]
+ DatabaseBusyError = DatabaseHandle = DatabaseInstance = None # type: ignore[assignment,misc]
+ DatabaseOpenOptions = find_database_owner = wait_database_released = None # type: ignore[assignment]
+
+
+def _require_nexus() -> None:
+ """Raise an actionable error when the IDA Nexus library is missing.
+
+ Gated on the binding, not on the original import result, so a test that
+ injects a fake ``DatabaseHandle`` exercises the real adapter logic.
+ """
+ if DatabaseHandle is None:
+ raise IDAConnectionError(
+ "ida-nexus is not installed in this environment "
+ f"({_NEXUS_ERROR}). Install it (e.g. `uv sync`, or "
+ "`pip install ida-nexus`) so ida-tui can lease a "
+ "database."
+ ) from _NEXUS_ERROR
+
+
+def database_owner(idb_path: str, staged_path: str | None = None):
+ """The IDA Nexus instance that owns ``idb_path``/``staged_path``, else None.
+
+ Returns None when the IDA Nexus library is absent: with no library there is
+ no client in this environment that could be holding the database, and the
+ IDA-free layers (project staging) must keep working. Discovery errors with
+ the library installed still propagate because unknown ownership is unsafe.
+ """
+ if DatabaseHandle is None:
+ return None
+ if staged_path:
+ owner = find_database_owner(
+ staged_path,
+ output_database=idb_path,
+ timeout=0.5,
+ )
+ return owner or find_database_owner(staged_path, timeout=0.5)
+ return find_database_owner(idb_path, timeout=0.5)
+
+
+def registered_database(path: str, output_database: str | None = None) -> bool:
+ """Whether a live/lock-held IDA Nexus instance owns this target."""
+ _require_nexus()
+ return (
+ find_database_owner(
+ path,
+ output_database=output_database,
+ timeout=0.5,
+ )
+ is not None
+ )
+
+
+class _NoopKeepAlive:
+ """Compatibility shim: the DatabaseHandle's SSE lease is the heartbeat."""
+
+ def __init__(self) -> None:
+ self.beats = self.failures = 0
+
+ def start(self) -> "_NoopKeepAlive":
+ return self
+
+ def stop(self) -> None:
+ pass
+
+
+def _parse_load_args(value: str) -> tuple[str | None, int | None, str | None]:
+ """Translate ida-tui's legacy first-open switches to IDA Nexus options.
+
+ IDA Nexus has typed options for processor, natural loading address and file
+ type. It deliberately has no arbitrary command-line escape hatch; reject
+ switches we cannot represent instead of silently loading a blob wrongly.
+ """
+ processor: str | None = None
+ loading_address: int | None = None
+ file_type: str | None = None
+ unsupported: list[str] = []
+ try:
+ words = shlex.split(value or "", posix=os.name != "nt")
+ except ValueError as exc:
+ raise ValueError(f"invalid IDA load options: {exc}") from exc
+ for word in words:
+ if word.startswith("-p") and len(word) > 2:
+ processor = word[2:]
+ elif word.startswith("-b") and len(word) > 2:
+ try:
+ # IDA's -b is in 16-byte paragraphs. DatabaseHandle expects the
+ # natural address, which is the safer public API.
+ loading_address = int(word[2:], 16) << 4
+ except ValueError as exc:
+ raise ValueError(f"invalid IDA loading address: {word!r}") from exc
+ elif word.startswith("-T") and len(word) > 2:
+ file_type = word[2:]
+ else:
+ unsupported.append(word)
+ if unsupported:
+ joined = " ".join(unsupported)
+ raise ValueError(
+ "ida-nexus cannot represent arbitrary IDA load options: "
+ f"{joined!r}; use processor/base/file type options instead"
+ )
+ return processor, loading_address, file_type
+
+
+class IDBEventListener:
+ """Debounced, closeable delivery of another client's IDB changes.
+
+ IDA Nexus's subscription is a blocking iterator, so one daemon thread reads
+ it and a second waits for a quiet period before handing a batch to the UI.
+ Keeping the debounce here avoids a permanent Textual worker (which would
+ make the app's worker-idle contract impossible) and bounds refresh work to
+ one pass per edit burst.
+ """
+
+ def __init__(
+ self,
+ client: "NexusClient",
+ callback: Callable[[tuple[dict[str, Any], ...]], None],
+ *,
+ on_error: Callable[[BaseException], None] | None = None,
+ debounce: float = 0.2,
+ ) -> None:
+ self._client = client
+ self._callback = callback
+ self._on_error = on_error
+ self._debounce = max(float(debounce), 0.0)
+ self._condition = threading.Condition()
+ self._closed = False
+ self._subscription = None
+ self._pending: list[dict[str, Any]] = []
+ self._deadline = 0.0
+ self._reader = threading.Thread(
+ target=self._read, name="idatui-idb-events", daemon=True
+ )
+ self._deliverer = threading.Thread(
+ target=self._deliver, name="idatui-idb-refresh", daemon=True
+ )
+ self._deliverer.start()
+ self._reader.start()
+
+ def _report(self, error: BaseException) -> None:
+ disconnected = DatabaseDisconnectedError
+ if isinstance(disconnected, type) and isinstance(error, disconnected):
+ error = self._client._connection_error(error)
+ with self._condition:
+ closed = self._closed
+ if not closed and self._on_error is not None:
+ self._on_error(error)
+
+ def _read(self) -> None:
+ try:
+ subscription = self._client.subscribe_idb_events()
+ except Exception as exc: # noqa: BLE001 -- surfaced through on_error
+ self._report(exc)
+ with self._condition:
+ self._closed = True
+ self._pending.clear()
+ self._condition.notify_all()
+ return
+ with self._condition:
+ if self._closed:
+ subscription.close()
+ return
+ self._subscription = subscription
+ try:
+ for event in subscription:
+ with self._condition:
+ if self._closed:
+ break
+ if self._client.owns_event(event):
+ continue
+ with self._condition:
+ if self._closed:
+ break
+ self._pending.append(event)
+ self._deadline = time.monotonic() + self._debounce
+ self._condition.notify_all()
+ except Exception as exc: # noqa: BLE001 -- stream failures are recoverable
+ self._report(exc)
+ finally:
+ subscription.close()
+ with self._condition:
+ if self._subscription is subscription:
+ self._subscription = None
+ self._closed = True
+ self._pending.clear()
+ self._condition.notify_all()
+
+ def _deliver(self) -> None:
+ while True:
+ with self._condition:
+ while not self._closed and not self._pending:
+ self._condition.wait()
+ if self._closed:
+ return
+ remaining = self._deadline - time.monotonic()
+ if remaining > 0:
+ self._condition.wait(remaining)
+ continue
+ batch = tuple(self._pending)
+ self._pending.clear()
+ try:
+ self._callback(batch)
+ except Exception as exc: # noqa: BLE001 -- keep the stream alive
+ self._report(exc)
+
+ def close(self) -> None:
+ """Stop delivery and unblock the subscription reader."""
+ with self._condition:
+ if self._closed:
+ return
+ self._closed = True
+ self._pending.clear()
+ subscription = self._subscription
+ self._condition.notify_all()
+ if subscription is not None:
+ subscription.close()
+
+
+class NexusClient:
+ """A leased GUI/idalib database accessed through ``ida_nexus``."""
+
+ def __init__(
+ self,
+ binary_path: str,
+ *,
+ ttl: int = 0,
+ load_args: str = "",
+ processor: str | None = None,
+ loading_address: int | None = None,
+ file_type: str | None = None,
+ output_database: str | None = None,
+ spawn: bool = True,
+ new_database: bool = False,
+ ) -> None:
+ del ttl # managed-worker lifetime is lease-based, not idle-TTL based
+ self._path = os.path.abspath(os.path.expanduser(binary_path))
+ parsed_processor, parsed_address, parsed_file_type = _parse_load_args(load_args)
+ self._processor = processor or parsed_processor
+ self._loading_address = (
+ loading_address if loading_address is not None else parsed_address
+ )
+ self._file_type = file_type or parsed_file_type
+ self._output_database = output_database
+ self._spawn = spawn
+ self._new_database = new_database
+ self._handle: DatabaseHandle | None = None
+ self._last_instance: DatabaseInstance | None = None
+ self._connect_lock = threading.Lock()
+
+ def connect(self, timeout: float = 1800.0, progress=None) -> "NexusClient":
+ _require_nexus()
+ with self._connect_lock:
+ handle = self._handle
+ if handle is not None:
+ if handle.connected:
+ return self
+ raise IDAConnectionError(
+ "IDA Nexus database disconnected; explicit rediscovery required"
+ )
+ if progress:
+ progress(
+ f"discovering IDA Nexus database for {os.path.basename(self._path)}…"
+ )
+ try:
+ # A Ctrl+L reload releases its current managed-worker lease, but
+ # that worker remains registered during IDA Nexus's final-lease
+ # grace period. Retry only that known handoff window. A GUI or
+ # another long-lived client remains busy and yields a clear
+ # failure rather than being modified underneath its owner.
+ deadline = time.monotonic() + min(timeout, 60.0)
+ while True:
+ try:
+ handle = DatabaseHandle.open(
+ self._path,
+ options=DatabaseOpenOptions(
+ spawn=self._spawn,
+ startup_timeout=max(0.1, timeout),
+ output_database=self._output_database,
+ processor=self._processor,
+ # The natural byte address is converted to IDA's
+ # paragraph-based -b value by IDA Nexus.
+ image_base=self._loading_address,
+ file_type=self._file_type,
+ new_database=self._new_database,
+ ),
+ )
+ break
+ except DatabaseBusyError:
+ if not self._new_database or time.monotonic() >= deadline:
+ raise
+ if progress:
+ progress(
+ "waiting for the previous IDA Nexus lease to close…"
+ )
+ owner = find_database_owner(
+ self._path,
+ output_database=self._output_database,
+ timeout=0.5,
+ )
+ if owner is not None:
+ wait_database_released(
+ owner,
+ max(0.0, deadline - time.monotonic()),
+ )
+ else:
+ time.sleep(0.2)
+ if progress:
+ backend = handle.instance.backend
+ progress(
+ f"attached to {backend} database; waiting for auto-analysis…"
+ )
+ handle.wait_autoanalysis(timeout=timeout)
+ except Exception as exc: # normalize the dependency's transport errors
+ raise self._connection_error(exc) from exc
+ self._handle = handle
+ self._last_instance = handle.instance
+ return self
+
+ @staticmethod
+ def _connection_error(exc: BaseException) -> IDAConnectionError:
+ return IDAConnectionError(str(exc) or type(exc).__name__)
+
+ @property
+ def connected(self) -> bool:
+ return self._handle is not None and self._handle.connected
+
+ @property
+ def pid(self) -> int | None:
+ return self._handle.instance.pid if self._handle is not None else None
+
+ @property
+ def backend(self) -> str | None:
+ return self._handle.instance.backend if self._handle is not None else None
+
+ def owns_event(self, event: dict[str, Any]) -> bool:
+ """Whether ``event`` was produced through this client's handle."""
+ handle = self._handle
+ return handle is not None and handle.owns_event(event)
+
+ def subscribe_idb_events(self):
+ """Open IDA Nexus's closeable IDB-change iterator."""
+ if not self.connected:
+ self.connect()
+ handle = self._handle
+ if handle is None:
+ raise IDAConnectionError("IDA Nexus database is not connected")
+ try:
+ return handle.subscribe_idb_events()
+ except (DatabaseDisconnectedError, NexusConnectionError) as exc:
+ raise self._connection_error(exc) from exc
+
+ def watch_idb_events(
+ self,
+ callback: Callable[[tuple[dict[str, Any], ...]], None],
+ *,
+ on_error: Callable[[BaseException], None] | None = None,
+ debounce: float = 0.2,
+ ) -> IDBEventListener:
+ """Deliver external IDB changes in debounced batches."""
+ return IDBEventListener(self, callback, on_error=on_error, debounce=debounce)
+
+ def call(self, operation: Callable[..., Any], /, **args) -> Any:
+ """Execute one source-backed remote declaration through this client."""
+ name = getattr(operation, "__name__", "remote operation")
+ try:
+ from .remote_ops import bind
+
+ remote = bind(operation)
+ except KeyError as exc:
+ raise IDAToolError(
+ name, f"remote operation {name!r} is not registered"
+ ) from exc
+ if not self.connected:
+ self.connect()
+ handle = self._handle
+ if handle is None:
+ raise IDAConnectionError("IDA Nexus database is not connected")
+ try:
+ return remote(handle, **args)
+ except RemoteError as exc:
+ message = str(exc)
+ if exc.details.get("traceback"):
+ message += f"\n{exc.details['traceback']}"
+ if exc.code == "operation_timeout":
+ raise IDATimeoutError(message) from exc
+ raise IDAToolError(name, message) from exc
+ except (DatabaseDisconnectedError, NexusConnectionError) as exc:
+ raise self._connection_error(exc) from exc
+
+ def save_database(self) -> dict[str, Any]:
+ if not self.connected:
+ self.connect()
+ handle = self._handle
+ if handle is None:
+ raise IDAConnectionError("IDA Nexus database is not connected")
+ try:
+ return handle.save_database()
+ except RemoteError as exc:
+ raise IDAToolError("save_database", str(exc)) from exc
+ except (DatabaseDisconnectedError, NexusConnectionError) as exc:
+ raise self._connection_error(exc) from exc
+
+ def discard_database(self, timeout: float = 5.0) -> bool:
+ """Discard a final managed-worker lease; otherwise transfer finalization.
+
+ ``False`` is an expected ownership result: a GUI owns its session, or
+ another lease still shares the managed worker. A busy final worker is
+ retried briefly so background reads finishing during quit do not turn a
+ real discard into an implicit save.
+ """
+ handle = self._handle
+ if handle is None or not handle.connected:
+ return False
+ entry = handle.instance
+ if entry.backend != "idalib" or not getattr(entry, "managed", False):
+ return False
+ deadline = time.monotonic() + max(float(timeout), 0.0)
+ while True:
+ try:
+ handle.shutdown_database(save=False)
+ return True
+ except RemoteError as exc:
+ if exc.code in ("instance_shared", "shutdown_not_supported"):
+ return False
+ if exc.code == "instance_busy" and time.monotonic() < deadline:
+ time.sleep(0.05)
+ continue
+ raise IDAToolError("shutdown_database", str(exc)) from exc
+ except (DatabaseDisconnectedError, NexusConnectionError) as exc:
+ raise self._connection_error(exc) from exc
+
+ def health(self) -> dict[str, Any]:
+ if not self.connected:
+ self.connect()
+ assert self._handle is not None
+ entry = self._handle.instance
+ module = os.path.basename(entry.exe_path or entry.idb_path or self._path)
+ return {
+ "ok": self._handle.connected,
+ "module": module,
+ "backend": entry.backend,
+ "record_id": entry.record_id,
+ "input_path": entry.exe_path,
+ "idb_path": entry.idb_path,
+ }
+
+ def keepalive(self, interval: float = 120.0) -> _NoopKeepAlive:
+ del interval
+ return _NoopKeepAlive()
+
+ def resolve_db(self) -> str:
+ if not self.connected:
+ self.connect()
+ assert self._handle is not None
+ return self._handle.instance.record_id
+
+ def set_db(self, db: str | None) -> None:
+ del db # one handle is permanently bound to one registered database
+
+ def list_sessions(self) -> list[Session]:
+ if not self.connected:
+ self.connect()
+ assert self._handle is not None
+ entry = self._handle.instance
+ path = entry.exe_path or entry.idb_path or self._path
+ return [
+ Session(
+ session_id=entry.record_id,
+ filename=os.path.basename(path),
+ input_path=path,
+ is_active=True,
+ )
+ ]
+
+ def close(self, grace: float = 0.0) -> None:
+ del grace
+ with self._connect_lock:
+ handle, self._handle = self._handle, None
+ if handle is not None:
+ self._last_instance = handle.instance
+ handle.close() # release our lease; never close a GUI/other client's DB
+
+ def wait_released(self, timeout: float = 45.0) -> bool:
+ """Wait until a managed instance releases its lifetime lock.
+
+ Normal application shutdown must not wait: another client may retain the
+ worker. This is an explicit test/maintenance helper for deleting a
+ temporary IDB safely after this client closes. GUI instances return
+ ``False`` immediately because clients never own their lifetime.
+ """
+ instance = self._last_instance
+ if instance is None or instance.backend != "idalib":
+ return False
+ return wait_database_released(instance, timeout)
+
+ def __enter__(self) -> "NexusClient":
+ return self.connect()
+
+ def __exit__(self, *exc) -> None:
+ self.close()
diff --git a/idatui/pane.py b/idatui/pane.py
index c31a93c..1eee847 100644
--- a/idatui/pane.py
+++ b/idatui/pane.py
@@ -24,7 +24,7 @@ per pane in the registry, so stop/list/capture/keys keep working across both
python -m idatui.pane keys --pane <pane> Escape
Requires: running inside tmux or zellij. Each pane leases a registered GUI or
-shared managed idalib database through Code Mode. Uses ~/ida-venv/bin/python for
+shared managed idalib database through IDA Nexus. Uses ~/ida-venv/bin/python for
the TUI (needs textual) unless --python / IDATUI_PYTHON says otherwise.
"""
from __future__ import annotations
@@ -250,8 +250,8 @@ def _pane_keys(pane: str, keys: list[str], mux: str | None = None) -> None:
subprocess.run(["tmux", "send-keys", "-t", pane, *keys], check=True)
-# Code Mode owns database process lifetime: a closed pane drops its lease at the
-# socket/kernel boundary and Code Mode decides whether a managed worker still
+# IDA Nexus owns database process lifetime: a closed pane drops its lease at the
+# socket/kernel boundary and IDA Nexus decides whether a managed worker still
# has clients. There is nothing for the pane layer to reap.
@@ -261,7 +261,7 @@ def _count_live_panes() -> int:
def _reap_orphan_workers(force: bool = False) -> int:
- """Compatibility no-op: Code Mode workers are shared and lease-managed."""
+ """Compatibility no-op: IDA Nexus workers are shared and lease-managed."""
del force
return 0
@@ -294,7 +294,7 @@ def spawn(args) -> int:
print(f"error: no such project: {project}", file=sys.stderr)
return 2
- # The pane owns only the TUI. Code Mode's lease cleanup handles crashes;
+ # The pane owns only the TUI. IDA Nexus's lease cleanup handles crashes;
# kill-pane must never reap a shared GUI/idalib database.
if project is not None:
# launch takes: --project FILE [binaries...]; extra binaries are added to
@@ -347,7 +347,7 @@ def _wait_ready(sock: str, timeout: float, pane: str,
stuck_after: float = 45.0, mux: str | None = None) -> dict[str, Any]:
"""Poll the socket + ping until the TUI reports ready (or timeout).
- Emits a one-time hint if Code Mode discovery/opening is still not ready after
+ Emits a one-time hint if IDA Nexus discovery/opening is still not ready after
``stuck_after`` seconds.
"""
start = time.time()
@@ -370,7 +370,7 @@ def _wait_ready(sock: str, timeout: float, pane: str,
why = ("RPC socket not created yet" if not os.path.exists(sock)
else "TUI up but analysis not ready")
print(f"still waiting ({int(time.time() - start)}s): {why}. "
- f"Check Code Mode registrations and worker logs.", file=sys.stderr)
+ f"Check IDA Nexus registrations and worker logs.", file=sys.stderr)
time.sleep(0.4)
last = dict(last)
last["ready"] = False
@@ -467,7 +467,7 @@ def list_panes(args) -> int:
def reap(args) -> int:
- """Deprecated no-op; shared Code Mode workers are managed by leases."""
+ """Deprecated no-op; shared IDA Nexus workers are managed by leases."""
print(json.dumps({"reaped_workers": 0, "live_panes": _count_live_panes(),
"forced": args.force, "deprecated": True}))
return 0
@@ -572,7 +572,7 @@ def main(argv: list[str]) -> int:
ls.add_argument("--prune", action="store_true", help="drop dead panes (and their sockets)")
ls.set_defaults(fn=list_panes)
- rp = sub.add_parser("reap", help="deprecated no-op (Code Mode uses shared leases)")
+ rp = sub.add_parser("reap", help="deprecated no-op (IDA Nexus uses shared leases)")
rp.add_argument("--force", action="store_true", help=argparse.SUPPRESS)
rp.set_defaults(fn=reap)
diff --git a/idatui/pool.py b/idatui/pool.py
index 465dff2..2407b44 100644
--- a/idatui/pool.py
+++ b/idatui/pool.py
@@ -1,6 +1,6 @@
-"""DatabasePool — LRU leases on Code Mode databases for a project.
+"""DatabasePool — LRU leases on IDA Nexus databases for a project.
-Code Mode may bind a lease to an existing IDA GUI or to a shared managed idalib
+IDA Nexus may bind a lease to an existing IDA GUI or to a shared managed idalib
worker. The pool therefore owns *client interest*, never an IDA process. Releasing
an LRU entry persists managed IDBs but does not implicitly save a GUI, then closes
only this TUI's lease; other clients and GUI sessions remain alive. Managed workers exit themselves after their final lease.
@@ -48,8 +48,8 @@ def _pss_mb(pid: int | None) -> int:
def _default_spawn(ref: BinaryRef, ttl: int, *, new_database: bool = False): # pragma: no cover - needs IDA
- from .codemode_client import CodeModeClient
- return CodeModeClient(
+ from .nexus_client import NexusClient
+ return NexusClient(
ref.staged,
ttl=ttl,
load_args=ref.load_args,
@@ -59,7 +59,7 @@ def _default_spawn(ref: BinaryRef, ttl: int, *, new_database: bool = False): #
class DatabasePool:
- """Live Code Mode database leases, keyed by project label."""
+ """Live IDA Nexus database leases, keyed by project label."""
def __init__(self, project: Project, *, budget_mb: int | None = None,
ttl: int = 1800, spawn=None, mem_fn=None) -> None:
@@ -101,7 +101,7 @@ class DatabasePool:
"""A live client for ``label``, attaching or spawning as needed.
Do not sweep IDA scratch files here: a registered GUI or another Code
- Mode client may own the database. Code Mode's registry locks and health
+ Mode client may own the database. IDA Nexus's registry locks and health
probes are the authority for safe discovery and stale-record cleanup.
"""
client = self._clients.get(label)
@@ -225,6 +225,28 @@ class DatabasePool:
self.evict(label, save=save, save_gui=save)
self.active = None
+ def discard_changes(self, labels: list[str]) -> list[str]:
+ """Discard final managed sessions; return labels whose owner remains.
+
+ A returned label is not an error: its client is attached to a GUI or a
+ still-shared worker, so releasing our lease transfers finalization to
+ that session's owner or remaining clients.
+ """
+ transferred: list[str] = []
+ for label in labels:
+ client = self._clients.get(label)
+ if client is not None and not client.discard_database():
+ transferred.append(label)
+ return transferred
+
+ def replace_client(self, label: str, old, new) -> bool:
+ """Replace one disconnected lease without changing residency policy."""
+ if self._clients.get(label) is not old:
+ return False
+ self._clients[label] = new
+ self._touch(label)
+ return True
+
# -- introspection ------------------------------------------------------ #
def status(self) -> list[dict]:
"""Per-binary residency for the switcher UI."""
@@ -247,5 +269,3 @@ class DatabasePool:
f"{self.memory_mb()}/{self.budget_mb}MB active={self.active}>")
-# Source compatibility for callers that imported the pre-Code-Mode name.
-WorkerPool = DatabasePool
diff --git a/idatui/project.py b/idatui/project.py
index 53f3b0a..e681dfc 100644
--- a/idatui/project.py
+++ b/idatui/project.py
@@ -23,7 +23,7 @@ firmware image, a cleaned build tree).
A source whose size/mtime no longer matches the staged copy is re-staged, and its
now-stale database is dropped (the DB describes the old bytes).
-The model has no IDA imports. Staging consults ida_codemode's registry before
+The model has no IDA imports. Staging consults ida_nexus's registry before
replacing files so it never mutates a database owned by a GUI/shared worker.
"""
from __future__ import annotations
@@ -57,7 +57,7 @@ class BinaryRef:
#: it is, a raw firmware image doesn't, and IDA defaults to metapc at 0.
processor: str = "" # IDA processor name: arm, armb, mipsb, metapc, …
base: int = 0 # load address (natural, e.g. 0x8000000)
- ida_args: str = "" # legacy -p/-b/-T switches accepted by Code Mode adapter
+ ida_args: str = "" # legacy -p/-b/-T switches accepted by IDA Nexus adapter
@property
def db(self) -> str:
@@ -311,7 +311,7 @@ class Project:
"""Ensure ``ref`` is staged in the sidecar; returns the staged path.
Re-staging a changed source drops its database: the DB describes the old
- bytes. Refuse while Code Mode reports a GUI/idalib owner; replacing a
+ bytes. Refuse while IDA Nexus reports a GUI/idalib owner; replacing a
staged executable or IDB underneath a shared live instance is corruption.
"""
if not os.path.isfile(ref.source):
@@ -319,15 +319,15 @@ class Project:
if not self.is_stale(ref):
return ref.staged
try:
- from .codemode_client import database_owner
+ from .nexus_client import database_owner
owner = database_owner(ref.db, ref.staged)
except Exception as exc:
raise ProjectError(
- f"cannot verify Code Mode ownership before staging {ref.label}: {exc}"
+ f"cannot verify IDA Nexus ownership before staging {ref.label}: {exc}"
) from exc
if owner is not None:
raise ProjectError(
- f"cannot restage {ref.label}: Code Mode instance {owner.record_id} "
+ f"cannot restage {ref.label}: IDA Nexus instance {owner.record_id} "
f"still owns {owner.idb_path}; close/release it first"
)
os.makedirs(self.bin_dir, exist_ok=True)
@@ -353,7 +353,7 @@ class Project:
def sweep_scratch(self, ref: BinaryRef) -> int:
"""Delete unpacked working files (never the ``.i64``) for maintenance.
- Runtime paths no longer call this: Code Mode instances are shared, so a
+ Runtime paths no longer call this: IDA Nexus instances are shared, so a
registry owner may still be using these files. Callers must independently
prove that no GUI/idalib instance owns the database.
"""
diff --git a/idatui/remote_ops.py b/idatui/remote_ops.py
new file mode 100644
index 0000000..a67b2b7
--- /dev/null
+++ b/idatui/remote_ops.py
@@ -0,0 +1,1683 @@
+"""Typed remote operations executed through ida-nexus."""
+
+from __future__ import annotations
+# ruff: noqa
+
+import threading
+from collections.abc import Callable
+from pathlib import Path
+from typing import TYPE_CHECKING, Any
+
+if TYPE_CHECKING:
+ from ida_domain import Database
+
+
+def operation_label() -> str:
+ """Display attribution for the current call; ready for per-user context."""
+ return "IDA TUI"
+
+
+def data_type(db: Database, **a: Any) -> Any:
+ ea = int(str(a["addr"]), 16)
+ try:
+ tif = db.types.get_at(ea)
+ fn = db.functions.get_at(ea)
+ result = {
+ "addr": hex(ea),
+ "name": db.names.get_at(ea) or "",
+ "type": tif.dstr() if tif else "",
+ "size": int(db.heads.size(ea)) if db.heads.is_head(ea) else 0,
+ "is_func": bool(fn),
+ }
+ except Exception as exc:
+ result = {"addr": hex(ea), "error": str(exc)}
+ return result
+
+
+def declare_type(db: Database, **a: Any) -> Any:
+ import ida_typeinf
+
+ decls = a.get("decls", "")
+ if isinstance(decls, str):
+ decls = [decls]
+ rows = []
+ for declaration in decls:
+ try:
+ errors = int(
+ db.types.parse_declarations(ida_typeinf.get_idati(), declaration)
+ )
+ rows.append(
+ {
+ "ok": errors == 0,
+ **({} if errors == 0 else {"error": f"{errors} parse error(s)"}),
+ }
+ )
+ except Exception as exc:
+ rows.append({"ok": False, "error": str(exc)})
+ result = {"result": rows}
+ return result
+
+
+def decomp_error(db: Database, **a: Any) -> Any:
+ import ida_hexrays, ida_ida
+
+ ea = int(str(a["addr"]), 16)
+ fn = db.functions.get_at(ea)
+ result = {"addr": hex(ea), "bitness": ida_ida.inf_get_app_bitness()}
+ if fn is None:
+ result["reason"] = "no function here"
+ else:
+ try:
+ failure = ida_hexrays.hexrays_failure_t()
+ cfunc = ida_hexrays.decompile_func(fn, failure)
+ if cfunc is not None:
+ result["reason"] = ""
+ else:
+ result.update(
+ {
+ "reason": failure.desc() or f"error {failure.code}",
+ "code": int(failure.code),
+ "errea": hex(int(failure.errea)),
+ }
+ )
+ except Exception as exc:
+ result["reason"] = f"{type(exc).__name__}: {exc}"
+ return result
+
+
+def define_code(db: Database, **a: Any) -> Any:
+ import ida_ua
+
+ rows = []
+ for item in a.get("items", []):
+ ea = int(str(item["addr"]), 16)
+ size = int(ida_ua.create_insn(ea))
+ rows.append(
+ {
+ "addr": hex(ea),
+ "ok": size > 0,
+ "size": size,
+ **({} if size > 0 else {"error": "instruction did not decode"}),
+ }
+ )
+ result = {"result": rows}
+ return result
+
+
+def define_code_run(db: Database, **a: Any) -> Any:
+ import ida_bytes, ida_idp, ida_segment, ida_ua, idaapi
+
+ ea, limit = int(str(a["addr"]), 16), max(1, min(int(a.get("limit", 20000)), 200000))
+ seg = ida_segment.getseg(ea)
+ if seg is None:
+ result = {"addr": a["addr"], "error": "no segment", "count": 0}
+ else:
+ start, count, stopped, hi = ea, 0, "limit", int(seg.end_ea)
+ while count < limit:
+ if ea >= hi:
+ stopped = "segment"
+ break
+ flags = ida_bytes.get_flags(ea)
+ if ida_bytes.is_code(flags) or ida_bytes.is_data(flags):
+ stopped = "defined"
+ break
+ size = int(ida_ua.create_insn(ea))
+ if size <= 0:
+ stopped = "undecodable"
+ break
+ count += 1
+ insn = ida_ua.insn_t()
+ if ida_ua.decode_insn(insn, ea) > 0:
+ try:
+ is_ret = bool(ida_idp.is_ret_insn(insn))
+ except Exception:
+ is_ret = False
+ if is_ret or (insn.get_canon_feature() & idaapi.CF_STOP):
+ ea += size
+ stopped = "flow"
+ break
+ ea += size
+ result = {
+ "start": hex(start),
+ "end": hex(ea),
+ "count": count,
+ "stopped": stopped,
+ }
+ return result
+
+
+def define_func(db: Database, **a: Any) -> Any:
+ rows = []
+ for item in a.get("items", []):
+ ea = int(str(item["addr"]), 16)
+ ok = bool(db.functions.create(ea))
+ rows.append(
+ {
+ "addr": hex(ea),
+ "ok": ok,
+ **({} if ok else {"error": "IDA refused the function"}),
+ }
+ )
+ result = {"result": rows}
+ return result
+
+
+def define_func_run(db: Database, **a: Any) -> Any:
+ import ida_bytes, ida_funcs, ida_segment
+
+ ea = int(str(a["addr"]), 16)
+ fn = db.functions.get_at(ea)
+ if fn is not None and int(fn.start_ea) == ea:
+ result = {
+ "addr": hex(ea),
+ "ok": True,
+ "start": hex(ea),
+ "end": hex(int(fn.end_ea)),
+ "how": "existed",
+ }
+ else:
+ automatic = bool(db.functions.create(ea))
+ if not automatic:
+ seg = db.segments.get_at(ea)
+ end = ea
+ hi = int(seg.end_ea) if seg else ea
+ while end < hi and ida_bytes.is_code(ida_bytes.get_flags(end)):
+ nxt = int(ida_bytes.get_item_end(end))
+ if nxt <= end:
+ break
+ end = nxt
+ ok = bool(end > ea and ida_funcs.add_func(ea, end))
+ else:
+ ok = True
+ fn = db.functions.get_at(ea)
+ result = (
+ {
+ "addr": hex(ea),
+ "ok": True,
+ "start": hex(int(fn.start_ea)),
+ "end": hex(int(fn.end_ea)),
+ "how": "auto" if automatic else "explicit-end",
+ }
+ if ok and fn is not None
+ else {
+ "addr": hex(ea),
+ "ok": False,
+ "error": f"IDA refused a function at {ea:#x}",
+ }
+ )
+ return result
+
+
+def del_type(db: Database, **a: Any) -> Any:
+ import ida_typeinf
+
+ name = str(a["name"])
+ ok = bool(
+ ida_typeinf.del_named_type(ida_typeinf.get_idati(), name, ida_typeinf.NTF_TYPE)
+ )
+ result = {
+ "name": name,
+ "deleted": ok,
+ **({} if ok else {"error": f"Type {name!r} not found or could not be deleted"}),
+ }
+ return result
+
+
+def disasm(db: Database, **a: Any) -> Any:
+ ea = int(str(a["addr"]), 16)
+ fn = db.functions.get_at(ea)
+ if fn is None:
+ result = {"instructions": [], "total_instructions": 0, "instruction_count": 0}
+ else:
+ instructions = list(db.functions.get_instructions(fn))
+ limit = max(1, int(a.get("max_instructions", len(instructions) or 1)))
+ rows = [
+ {
+ "addr": hex(int(insn.ea)),
+ "instruction": db.instructions.get_disassembly(insn),
+ }
+ for insn in instructions[:limit]
+ ]
+ result = {
+ "instructions": rows,
+ "total_instructions": len(instructions),
+ "instruction_count": len(instructions),
+ }
+ return result
+
+
+def file_regions(db: Database, **a: Any) -> Any:
+ import idaapi
+
+ rows = []
+ for seg in db.segments.get_all():
+ try:
+ file_off = int(idaapi.get_fileregion_offset(seg.start_ea))
+ except Exception:
+ file_off = -1
+ if file_off < 0 or file_off >= (1 << 48):
+ file_off = -1
+ rows.append(
+ {
+ "start": hex(int(seg.start_ea)),
+ "end": hex(int(seg.end_ea)),
+ "file_off": file_off,
+ "name": db.segments.get_name(seg) or "",
+ }
+ )
+ result = {"regions": rows}
+ return result
+
+
+def flowchart(db: Database, **a: Any) -> Any:
+ import ida_funcs, ida_gdl
+
+ ea = int(str(a["addr"]), 16)
+ fn = ida_funcs.get_func(ea)
+ if fn is None:
+ result = {"addr": hex(ea), "error": "no function at that address", "blocks": []}
+ else:
+ fc = ida_gdl.FlowChart(fn, flags=ida_gdl.FC_PREDS)
+ index, order = {}, []
+ for bb in fc:
+ index[bb.start_ea] = len(order)
+ order.append(bb)
+ blocks = []
+ for bb in order:
+ sl = [s for s in bb.succs() if s.start_ea in index]
+ succs = []
+ for s in sl:
+ # Edge kind is what the graph view colours by: an n-way dispatch is
+ # "switch", a successor that is literally the next address falls
+ # through, anything else is a taken branch.
+ if len(sl) > 2:
+ kind = "switch"
+ elif s.start_ea == bb.end_ea:
+ kind = "fall"
+ else:
+ kind = "jump"
+ succs.append([index[s.start_ea], kind])
+ blocks.append(
+ {
+ "id": index[bb.start_ea],
+ "start": hex(int(bb.start_ea)),
+ "end": hex(int(bb.end_ea)),
+ "succs": succs,
+ }
+ )
+ result = {
+ "addr": hex(ea),
+ "func": {
+ "addr": hex(int(fn.start_ea)),
+ "end": hex(int(fn.end_ea)),
+ "name": ida_funcs.get_func_name(fn.start_ea) or "",
+ },
+ "entry": index.get(fn.start_ea, 0),
+ "blocks": blocks,
+ }
+ return result
+
+
+def force_recompile(db: Database, **a: Any) -> Any:
+ import ida_hexrays
+
+ rows = []
+ for item in a.get("items", []):
+ ea = int(str(item["addr"]), 16)
+ ida_hexrays.mark_cfunc_dirty(ea, False)
+ rows.append({"addr": hex(ea), "ok": True})
+ result = {"result": rows}
+ return result
+
+
+def func_types(db: Database, **a: Any) -> Any:
+ import ida_typeinf
+
+ ea = int(str(a["addr"]), 16)
+ fn = db.functions.get_at(ea)
+ if fn is None:
+ result = {"addr": a["addr"], "error": "no function at address"}
+ else:
+ pseudo = db.pseudocode.decompile(fn)
+ name = db.functions.get_name(fn) or ""
+ tif = pseudo.get_func_type()
+ try:
+ prototype = (
+ ida_typeinf.print_tinfo(
+ "", 0, 0, ida_typeinf.PRTYPE_1LINE, tif, name, ""
+ )
+ if tif
+ else ""
+ )
+ except Exception:
+ prototype = tif.dstr() if tif else ""
+ lvars = [
+ {
+ "name": var.name,
+ "type": var.type_info.dstr() if var.type_info else "",
+ "is_arg": bool(var.is_arg),
+ }
+ for var in pseudo.local_variables
+ ]
+ result = {
+ "addr": hex(int(fn.start_ea)),
+ "name": name,
+ "prototype": (prototype or "").strip(),
+ "lvars": lvars,
+ }
+ return result
+
+
+def get_bytes(db: Database, **a: Any) -> Any:
+ rows = []
+ for region in a.get("regions", []):
+ ea, size = int(str(region["addr"]), 16), int(region["size"])
+ raw = db.bytes.get_bytes_at(ea, size) or b""
+ rows.append({"addr": region["addr"], "data": " ".join(f"{b:02x}" for b in raw)})
+ result = {"result": rows}
+ return result
+
+
+def journal_get(db: Database, **a: Any) -> Any:
+ import ida_netnode
+
+ n = ida_netnode.netnode(a.get("node", "$ idatui.journal"))
+ blob = n.getblob(0, "I") if ida_netnode.exist(n) else None
+ result = {"data": blob.decode("utf-8", "replace") if blob else ""}
+ return result
+
+
+def journal_put(db: Database, **a: Any) -> Any:
+ import ida_netnode
+
+ n = ida_netnode.netnode(a.get("node", "$ idatui.journal"), 0, True)
+ payload = (a.get("data") or "").encode("utf-8")
+ n.setblob(payload, 0, "I")
+ result = {"ok": True, "bytes": len(payload)}
+ return result
+
+
+def list_annotations(db: Database, **a: Any) -> Any:
+ import ida_bytes, ida_funcs, ida_lines, ida_nalt, ida_name
+ import ida_segment, ida_typeinf, idautils
+
+ limit = max(1, int(a.get("limit", 4000)))
+ max_scan = max(1000, int(a.get("max_scan", 2000000)))
+ comments, names = [], []
+ scanned = 0
+
+ def _line(ea):
+ try:
+ txt = ida_lines.generate_disasm_line(ea, ida_lines.GENDSM_REMOVE_TAGS)
+ except Exception:
+ txt = ""
+ return " ".join((txt or "").split())
+
+ for ea, nm in idautils.Names():
+ if len(names) >= limit:
+ break
+ if not nm or not ida_bytes.has_user_name(ida_bytes.get_flags(ea)):
+ continue
+ fn = ida_funcs.get_func(ea)
+ is_fn = fn is not None and int(fn.start_ea) == int(ea)
+ proto = None
+ if is_fn:
+ try:
+ ti = ida_typeinf.tinfo_t()
+ if ida_nalt.get_tinfo(ti, ea):
+ proto = str(ti)
+ except Exception:
+ proto = None
+ seg = ida_segment.getseg(ea)
+ names.append(
+ {
+ "addr": hex(int(ea)),
+ "name": nm,
+ "func": is_fn,
+ "size": (int(fn.end_ea - fn.start_ea) if is_fn else 0),
+ "proto": proto,
+ "seg": (ida_segment.get_segm_name(seg) if seg else ""),
+ }
+ )
+
+ for i in range(ida_segment.get_segm_qty()):
+ seg = ida_segment.getnseg(i)
+ if seg is None or len(comments) >= limit or scanned >= max_scan:
+ continue
+ for ea in idautils.Heads(seg.start_ea, seg.end_ea):
+ scanned += 1
+ if len(comments) >= limit or scanned >= max_scan:
+ break
+ if not ida_bytes.has_cmt(ida_bytes.get_flags(ea)):
+ continue
+ for rep in (False, True):
+ text = ida_bytes.get_cmt(ea, rep)
+ if text:
+ fn = ida_funcs.get_func(ea)
+ comments.append(
+ {
+ "addr": hex(int(ea)),
+ "text": text,
+ "repeatable": rep,
+ "line": _line(ea),
+ "seg": ida_segment.get_segm_name(seg),
+ "func": (
+ ida_funcs.get_func_name(fn.start_ea) if fn else None
+ ),
+ "func_addr": (hex(int(fn.start_ea)) if fn else None),
+ }
+ )
+
+ # Whole-function comments are not on the byte flags, so the scan cannot see them.
+ for fn_ea in idautils.Functions():
+ fn = ida_funcs.get_func(fn_ea)
+ if fn is None or len(comments) >= limit:
+ continue
+ for rep in (False, True):
+ text = ida_funcs.get_func_cmt(fn, rep)
+ if text:
+ seg = ida_segment.getseg(fn_ea)
+ comments.append(
+ {
+ "addr": hex(int(fn_ea)),
+ "text": text,
+ "repeatable": rep,
+ "line": "",
+ "whole_func": True,
+ "seg": (ida_segment.get_segm_name(seg) if seg else ""),
+ "func": ida_funcs.get_func_name(fn_ea),
+ "func_addr": hex(int(fn_ea)),
+ }
+ )
+ result = {
+ "comments": comments,
+ "names": names,
+ "scanned": scanned,
+ "truncated": (
+ len(comments) >= limit or len(names) >= limit or scanned >= max_scan
+ ),
+ }
+ return result
+
+
+def list_funcs(db: Database, **a: Any) -> Any:
+ import fnmatch
+
+ queries = a.get("queries") or [{}]
+ q = queries[0]
+ offset, count = max(0, int(q.get("offset", 0))), max(1, int(q.get("count", 500)))
+ pattern = str(q.get("filter") or "").lower()
+ if pattern and not any(ch in pattern for ch in "*?["):
+ pattern = "*" + pattern + "*"
+ rows = []
+ for fn in db.functions.get_all():
+ name = db.functions.get_name(fn) or f"sub_{int(fn.start_ea):X}"
+ if pattern and not fnmatch.fnmatchcase(name.lower(), pattern):
+ continue
+ rows.append(
+ {
+ "addr": hex(int(fn.start_ea)),
+ "name": name,
+ "size": int(fn.end_ea) - int(fn.start_ea),
+ }
+ )
+ page = rows[offset : offset + count]
+ result = {
+ "result": [
+ {"data": page, "next_offset": offset + len(page), "total": len(rows)}
+ ]
+ }
+ return result
+
+
+def list_linkage(db: Database, **a: Any) -> Any:
+ imports = [
+ {"addr": hex(int(item.address)), "name": item.name, "module": item.module_name}
+ for item in db.imports.get_all_imports()
+ if item.name
+ ]
+ exports = [
+ {
+ "addr": hex(int(item.address)),
+ "name": item.name,
+ "ordinal": int(item.ordinal),
+ }
+ for item in db.entries.get_all()
+ if item.name
+ ]
+ result = {
+ "imports": imports,
+ "exports": exports,
+ "n_imports": len(imports),
+ "n_exports": len(exports),
+ }
+ return result
+
+
+def list_strings(db: Database, **a: Any) -> Any:
+ from ida_domain.strings import StringListConfig
+
+ offset, count, min_len = (
+ max(0, int(a.get("offset", 0))),
+ max(1, int(a.get("count", 2000))),
+ max(1, int(a.get("min_len", 4))),
+ )
+ if offset == 0 or a.get("refresh"):
+ from ida_domain.strings import StringType
+
+ db.strings.rebuild(
+ StringListConfig(
+ string_types=list(StringType), min_len=min_len, only_ascii_7bit=False
+ )
+ )
+ items = list(db.strings.get_all())
+ page = items[offset : offset + count]
+ rows = []
+ for item in page:
+ try:
+ text = str(item)
+ except Exception:
+ text = item.contents.decode("utf-8", "replace") if item.contents else ""
+ rows.append(
+ {
+ "addr": hex(int(item.address)),
+ "text": text,
+ "len": int(item.length),
+ "type": item.type.name,
+ }
+ )
+ result = {"strings": rows, "total": len(items), "next_offset": offset + len(rows)}
+ return result
+
+
+def lookup_funcs(db: Database, **a: Any) -> Any:
+ rows = []
+ for query in a.get("queries", []):
+ raw = str(query)
+ try:
+ ea = int(raw, 16)
+ except ValueError:
+ fn = db.functions.get_by_name(raw)
+ ea = int(fn.start_ea) if fn else None
+ else:
+ fn = db.functions.get_at(ea)
+ if fn is None:
+ rows.append({"query": raw, "fn": None})
+ else:
+ rows.append(
+ {
+ "query": raw,
+ "fn": {
+ "addr": hex(int(fn.start_ea)),
+ "name": db.functions.get_name(fn)
+ or f"sub_{int(fn.start_ea):X}",
+ "size": int(fn.end_ea) - int(fn.start_ea),
+ },
+ }
+ )
+ result = {"result": rows}
+ return result
+
+
+def make_data(db: Database, **a: Any) -> Any:
+ import ida_bytes, ida_idaapi, ida_typeinf
+ from ida_domain.types import TypeApplyFlags
+
+ rows = []
+ for item in a.get("items", []):
+ ea, declaration = int(str(item["addr"]), 16), str(item["type"])
+ try:
+ tif = db.types.parse_one_declaration(ida_typeinf.get_idati(), declaration)
+ size = max(1, int(tif.get_size()))
+ saved_names = [
+ (addr, name)
+ for addr, name in db.names.get_all()
+ if ea <= int(addr) < ea + size
+ ]
+ ida_bytes.del_items(
+ ea,
+ ida_bytes.DELIT_EXPAND | ida_bytes.DELIT_DELNAMES,
+ max(size, int(ida_bytes.get_item_size(ea) or 1)),
+ )
+ created = bool(
+ ida_bytes.create_data(ea, ida_bytes.FF_BYTE, size, ida_idaapi.BADADDR)
+ )
+ ok = created and bool(db.types.apply_at(tif, ea, TypeApplyFlags.DEFINITE))
+ for address, name in saved_names:
+ db.names.set_name(int(address), name)
+ if ok and item.get("name"):
+ ok = bool(db.names.set_name(ea, str(item["name"])))
+ rows.append(
+ {
+ "addr": hex(ea),
+ "ok": ok,
+ "size": size,
+ **({} if ok else {"error": "IDA rejected the data type"}),
+ }
+ )
+ except Exception as exc:
+ rows.append({"addr": hex(ea), "ok": False, "error": str(exc)})
+ result = {"result": rows}
+ return result
+
+
+def make_string(db: Database, **a: Any) -> Any:
+ from ida_domain.strings import StringType
+
+ ea, length = int(str(a["addr"]), 16), max(0, int(a.get("length", 0)))
+ kind = {
+ "c": StringType.C,
+ "c16": StringType.C_16,
+ "c32": StringType.C_32,
+ "pascal": StringType.PASCAL,
+ }.get(str(a.get("kind", "c")).lower(), StringType.C)
+ import ida_bytes
+
+ try:
+ ida_bytes.del_items(ea, ida_bytes.DELIT_SIMPLE, length if length > 0 else 1)
+ except Exception:
+ pass
+ try:
+ ok = bool(db.bytes.create_string_at(ea, length or None, kind))
+ text = db.bytes.get_string_at(ea) or "" if ok else ""
+ result = {
+ "addr": hex(ea),
+ "ok": ok,
+ "size": int(db.heads.size(ea)) if ok else 0,
+ "text": text,
+ }
+ except Exception as exc:
+ result = {"addr": hex(ea), "ok": False, "error": str(exc)}
+ return result
+
+
+def read_raw(db: Database, **a: Any) -> Any:
+ import ida_bytes
+
+ ea, size = int(str(a["addr"]), 16), max(0, int(a["size"]))
+ raw = ida_bytes.get_bytes(ea, size) or b""
+ raw = raw[:size] + b"\xff" * max(0, size - len(raw))
+ data = bytearray(raw)
+ for index, value in enumerate(data):
+ if value == 0xFF and not ida_bytes.is_loaded(ea + index):
+ data[index] = 0
+ result = {"addr": a["addr"], "hex": bytes(data).hex(), "n": len(data)}
+ return result
+
+
+def rename(db: Database, **a: Any) -> Any:
+ import idaapi, ida_hexrays, ida_name
+
+ batch = a.get("batch") or {}
+ dry_run = bool(batch.get("dry_run", False))
+ allow_overwrite = bool(batch.get("allow_overwrite", False))
+ stop_on_error = bool(batch.get("stop_on_error", False))
+
+ def _items(value):
+ if value is None:
+ return []
+ if isinstance(value, dict):
+ return [value]
+ if isinstance(value, list):
+ return [i for i in value if isinstance(i, dict)]
+ return []
+
+ def _set_name_checked(ea, new):
+ conflict = idaapi.get_name_ea(idaapi.BADADDR, new)
+ if conflict != idaapi.BADADDR and conflict != ea and not allow_overwrite:
+ return (
+ False,
+ f"can't rename at {hex(ea)} as {new!r}: name already used at {hex(conflict)}",
+ )
+ if dry_run:
+ return True, None
+ flags = idaapi.SN_CHECK
+ if allow_overwrite:
+ flags |= int(getattr(idaapi, "SN_FORCE", 0))
+ if not idaapi.set_name(ea, new, flags):
+ return False, (
+ f"Rename failed at {hex(ea)}: IDA rejected name {new!r} "
+ "(invalid identifier or internal conflict)"
+ )
+ return True, None
+
+ def _refresh_ctext(fn_addr):
+ # A renamed function must invalidate Hex-Rays' cache, which is per function
+ # and persisted in the .i64: without this the pseudocode keeps calling the
+ # old name forever while every other readback reports the new one.
+ if not ida_hexrays.init_hexrays_plugin():
+ return
+ failure = ida_hexrays.hexrays_failure_t()
+ cfunc = ida_hexrays.decompile_func(
+ fn_addr, failure, ida_hexrays.DECOMP_WARNINGS
+ )
+ if cfunc:
+ cfunc.refresh_func_ctext()
+
+ out = {}
+ ok_count = failed = 0
+ halted = False
+ for category in ("func", "data", "local", "stack"):
+ if category not in batch:
+ continue
+ rows = []
+ for edit in _items(batch.get(category)):
+ try:
+ if category == "func":
+ addr_text = (
+ edit.get("addr") or edit.get("func_addr") or edit.get("func")
+ )
+ new = edit.get("name") or edit.get("new") or edit.get("new_name")
+ if not addr_text or not new:
+ row = {
+ "addr": addr_text,
+ "name": new,
+ "error": "Function rename requires addr + name",
+ }
+ else:
+ ea = int(str(addr_text), 16)
+ fn = idaapi.get_func(ea)
+ if fn is None:
+ row = {
+ "addr": addr_text,
+ "name": new,
+ "error": "Function not found",
+ }
+ else:
+ old = idaapi.get_name(fn.start_ea) or None
+ ok, err = _set_name_checked(fn.start_ea, str(new))
+ row = {"addr": addr_text, "old": old, "name": str(new)}
+ if err:
+ row["error"] = err
+ if dry_run:
+ row["dry_run"] = True
+ if ok and not dry_run:
+ _refresh_ctext(fn.start_ea)
+ elif category == "data":
+ addr_text = edit.get("addr")
+ old = edit.get("old") or edit.get("old_name")
+ new = edit.get("new") or edit.get("new_name") or edit.get("name")
+ if not new and new != "":
+ row = {
+ "old": old,
+ "new": None,
+ "error": "Global rename requires target and new name",
+ }
+ else:
+ if addr_text is not None:
+ ea = int(str(addr_text), 16)
+ old = old or (idaapi.get_name(ea) or None)
+ else:
+ ea = idaapi.get_name_ea(idaapi.BADADDR, str(old or ""))
+ if ea == idaapi.BADADDR:
+ row = {
+ "old": old,
+ "new": str(new),
+ "error": f"Global {old!r} not found",
+ }
+ else:
+ # An empty new name CLEARS the label; that is a real
+ # request (tests revert with it), not a missing argument.
+ if str(new) == "":
+ ok = bool(ida_name.set_name(ea, "", idaapi.SN_CHECK))
+ err = (
+ None
+ if ok
+ else f"Failed to clear the name at {hex(ea)}"
+ )
+ else:
+ ok, err = _set_name_checked(ea, str(new))
+ row = {"addr": hex(ea), "old": old, "new": str(new)}
+ if err:
+ row["error"] = err
+ if dry_run:
+ row["dry_run"] = True
+ else:
+ fa, old, new = (
+ edit.get("func_addr"),
+ edit.get("old"),
+ edit.get("new"),
+ )
+ if not fa or not old or not new:
+ row = {
+ "old": old,
+ "new": new,
+ "error": f"{category} rename requires func_addr + old + new",
+ }
+ else:
+ ea = int(str(fa), 16)
+ pseudo = db.pseudocode.decompile(ea)
+ var = pseudo.find_local_variable(str(old))
+ if var is None:
+ row = {
+ "func_addr": fa,
+ "old": old,
+ "new": new,
+ "error": f"no local {old!r} in that function",
+ }
+ elif dry_run:
+ row = {
+ "func_addr": fa,
+ "old": old,
+ "new": new,
+ "dry_run": True,
+ }
+ else:
+ var.set_user_name(str(new))
+ ok = bool(
+ pseudo.save_local_variable_info(var, save_name=True)
+ )
+ row = {"func_addr": fa, "old": old, "new": new}
+ if not ok:
+ row["error"] = "IDA rejected the local variable name"
+ except Exception as exc:
+ row = {"addr": edit.get("addr"), "error": str(exc)}
+ rows.append(row)
+ if row.get("error"):
+ failed += 1
+ else:
+ ok_count += 1
+ if row.get("error") and stop_on_error:
+ halted = True
+ break
+ out[category] = rows
+ if halted:
+ break
+ out["summary"] = {"ok": ok_count, "failed": failed}
+ if dry_run:
+ out["summary"]["dry_run"] = True
+ if halted:
+ out["summary"]["halted"] = True
+ result = out
+ return result
+
+
+def resolve_names(db: Database, **a: Any) -> Any:
+ import ida_idaapi, ida_name
+
+ rows = []
+ for query in a.get("queries", []):
+ name = str(query).strip()
+ ea = ida_name.get_name_ea(ida_idaapi.BADADDR, name)
+ rows.append(
+ {"query": name, "ea": hex(int(ea)) if ea != ida_idaapi.BADADDR else None}
+ )
+ result = {"result": rows}
+ return result
+
+
+def search_bytes(db: Database, **a: Any) -> Any:
+ import ida_bytes, ida_funcs, ida_idaapi, ida_lines, ida_segment
+
+ pat = str(a.get("pattern", "")).strip()
+ limit = max(1, int(a.get("limit", 500)))
+ lo = int(a.get("start", 0))
+ hi = int(a.get("end", 0)) or ida_idaapi.BADADDR
+ flags = ida_bytes.BIN_SEARCH_FORWARD | ida_bytes.BIN_SEARCH_NOSHOW
+ if a.get("case"):
+ flags |= ida_bytes.BIN_SEARCH_CASE
+ rows, err, ea = [], None, lo
+ while len(rows) < limit:
+ try:
+ hit = ida_bytes.find_bytes(pat, range_start=ea, range_end=hi, flags=flags)
+ except Exception as exc:
+ err = str(exc) or exc.__class__.__name__
+ break
+ if hit is None or hit == ida_idaapi.BADADDR:
+ break
+ head = ida_bytes.get_item_head(hit)
+ fn = ida_funcs.get_func(hit)
+ seg = ida_segment.getseg(hit)
+ try:
+ line = (
+ ida_lines.generate_disasm_line(head, ida_lines.GENDSM_REMOVE_TAGS) or ""
+ )
+ except Exception:
+ line = ""
+ rows.append(
+ {
+ "addr": hex(int(hit)),
+ "head": hex(int(head)),
+ "line": " ".join(line.split()),
+ "func": (ida_funcs.get_func_name(fn.start_ea) if fn else None),
+ "func_addr": (hex(int(fn.start_ea)) if fn else None),
+ "seg": (ida_segment.get_segm_name(seg) if seg else ""),
+ }
+ )
+ ea = int(hit) + 1
+ result = {"hits": rows, "error": err, "truncated": len(rows) >= limit}
+ return result
+
+
+def search_structs(db: Database, **a: Any) -> Any:
+ needle = str(a.get("filter") or "").lower()
+ rows = []
+ for tif in db.types.get_all():
+ name = tif.get_type_name() or ""
+ if not name or needle not in name.lower() or not tif.is_udt():
+ continue
+ members = list(db.types.get_udt_members(tif))
+ rows.append(
+ {
+ "name": name,
+ "size": int(tif.get_size()),
+ "is_union": bool(tif.is_union()),
+ "cardinality": len(members),
+ "ordinal": int(tif.get_ordinal()),
+ }
+ )
+ result = {"result": rows}
+ return result
+
+
+def search_text(db: Database, **a: Any) -> Any:
+ import ida_lines, ida_funcs, ida_segment, idautils
+ import re as _re
+
+ q = str(a.get("query", ""))
+ limit = max(1, int(a.get("limit", 500)))
+ max_scan = max(1000, int(a.get("max_scan", 3000000)))
+ ci = (not a.get("case")) and q.islower() # smartcase, like the in-view search
+ rx, err = None, None
+ if a.get("regex"):
+ try:
+ rx = _re.compile(q, _re.I if ci else 0)
+ except Exception as exc:
+ err = "bad regex: " + str(exc)
+ needle = q.lower() if ci else q
+ rows, scanned = [], 0
+ if err is None and q:
+ for i in range(ida_segment.get_segm_qty()):
+ seg = ida_segment.getnseg(i)
+ if seg is None or len(rows) >= limit or scanned >= max_scan:
+ continue
+ for ea in idautils.Heads(seg.start_ea, seg.end_ea):
+ scanned += 1
+ if len(rows) >= limit or scanned >= max_scan:
+ break
+ try:
+ line = (
+ ida_lines.generate_disasm_line(ea, ida_lines.GENDSM_REMOVE_TAGS)
+ or ""
+ )
+ except Exception:
+ continue
+ # Match what the user SEES, not IDA's column padding: nobody types
+ # "call" + four spaces + "cs:getenv_ptr".
+ line = " ".join(line.split())
+ hay = line.lower() if ci else line
+ if rx.search(line) if rx is not None else (needle in hay):
+ fn = ida_funcs.get_func(ea)
+ rows.append(
+ {
+ "addr": hex(int(ea)),
+ "head": hex(int(ea)),
+ "line": line,
+ "func": (
+ ida_funcs.get_func_name(fn.start_ea) if fn else None
+ ),
+ "func_addr": (hex(int(fn.start_ea)) if fn else None),
+ "seg": ida_segment.get_segm_name(seg),
+ }
+ )
+ result = {
+ "hits": rows,
+ "error": err,
+ "scanned": scanned,
+ "truncated": len(rows) >= limit or scanned >= max_scan,
+ }
+ return result
+
+
+def set_comments(db: Database, **a: Any) -> Any:
+ import idaapi, idc, ida_hexrays
+
+ rows = []
+ for item in a.get("items", []):
+ addr_s = str(item.get("addr", ""))
+ text = str(item.get("comment") or "")
+ try:
+ ea = int(addr_s, 16)
+ if not idaapi.set_cmt(ea, text, False):
+ rows.append(
+ {
+ "addr": addr_s,
+ "error": f"Failed to set disassembly comment at {hex(ea)}",
+ }
+ )
+ continue
+ if not ida_hexrays.init_hexrays_plugin():
+ rows.append({"addr": addr_s})
+ continue
+ try:
+ cfunc = ida_hexrays.decompile(ea)
+ except Exception:
+ cfunc = None
+ if cfunc is None:
+ rows.append({"addr": addr_s})
+ continue
+ if ea == cfunc.entry_ea:
+ # The signature line carries no ctree item: it is a function comment.
+ idc.set_func_cmt(ea, text, True)
+ cfunc.refresh_func_ctext()
+ rows.append({"addr": addr_s})
+ continue
+ eamap = cfunc.get_eamap()
+ if ea not in eamap:
+ rows.append(
+ {
+ "addr": addr_s,
+ "error": f"Failed to set decompiler comment at {hex(ea)}",
+ }
+ )
+ continue
+ nearest_ea = eamap[ea][0].ea
+ if cfunc.has_orphan_cmts():
+ cfunc.del_orphan_cmts()
+ cfunc.save_user_cmts()
+ tl = idaapi.treeloc_t()
+ tl.ea = nearest_ea
+ placed = False
+ for itp in range(idaapi.ITP_SEMI, idaapi.ITP_COLON):
+ tl.itp = itp
+ cfunc.set_user_cmt(tl, text)
+ cfunc.save_user_cmts()
+ cfunc.refresh_func_ctext()
+ if not cfunc.has_orphan_cmts():
+ placed = True
+ break
+ cfunc.del_orphan_cmts()
+ cfunc.save_user_cmts()
+ rows.append(
+ {"addr": addr_s}
+ if placed
+ else {
+ "addr": addr_s,
+ "error": f"Failed to set decompiler comment at {hex(ea)}",
+ }
+ )
+ except Exception as exc:
+ rows.append({"addr": addr_s, "error": str(exc)})
+ result = {"result": rows}
+ return result
+
+
+def set_lvar_type(db: Database, **a: Any) -> Any:
+ import ida_typeinf
+
+ ea, variable, declaration = (
+ int(str(a["addr"]), 16),
+ str(a["variable"]),
+ str(a["type"]),
+ )
+ fn = db.functions.get_at(ea)
+ if fn is None:
+ result = {"error": "no function at address"}
+ else:
+ pseudo = db.pseudocode.decompile(fn)
+ var = pseudo.find_local_variable(variable)
+ if var is None:
+ result = {"error": f"local variable {variable!r} not found"}
+ else:
+ try:
+ tif = db.types.parse_one_declaration(
+ ida_typeinf.get_idati(), declaration
+ )
+ accepted = bool(var.set_type(tif))
+ saved = (
+ bool(pseudo.save_local_variable_info(var, save_type=True))
+ if accepted
+ else False
+ )
+ result = {
+ "addr": hex(int(fn.start_ea)),
+ "variable": variable,
+ "type": declaration,
+ "ok": accepted and saved,
+ }
+ except Exception as exc:
+ result = {"error": f"bad type {declaration!r}: {exc}"}
+ return result
+
+
+def set_thumb(db: Database, **a: Any) -> Any:
+ import ida_bytes, ida_ida, ida_idp, ida_segment, ida_segregs
+
+ ea = int(str(a["addr"]), 16)
+ treg = ida_idp.str2reg("T")
+ seg = ida_segment.getseg(ea)
+ if treg is None or treg < 0:
+ result = {"addr": hex(ea), "error": "no T register (not an ARM database)"}
+ elif seg is None:
+ result = {"addr": hex(ea), "error": "no segment"}
+ else:
+ current = ida_segregs.get_sreg(ea, treg)
+ current = 0 if current in (None, 0xFFFFFFFF, -1) else int(current)
+ want = {"on": 1, "off": 0}.get(
+ str(a.get("mode", "toggle")).lower(), 0 if current else 1
+ )
+ changed = False
+ if want and seg.bitness != 1:
+ ida_segment.set_segm_addressing(seg, 1)
+ changed = True
+ size = max(int(ida_bytes.get_item_size(ea)), 2)
+ ida_bytes.del_items(ea, 0, size)
+ ok = bool(ida_segregs.split_sreg_range(ea, treg, want, ida_segregs.SR_user))
+ now = ida_segregs.get_sreg(ea, treg)
+ result = {
+ "addr": hex(ea),
+ "thumb": bool(now),
+ "was": bool(current),
+ "ok": ok,
+ "bitness": ida_segment.getseg(ea).bitness,
+ "forced_32bit": changed,
+ "db_64bit": bool(ida_ida.inf_get_app_bitness() == 64 and want),
+ }
+ return result
+
+
+def set_type(db: Database, **a: Any) -> Any:
+ from ida_domain.types import TypeApplyFlags
+
+ rows = []
+ for edit in a.get("edits", []):
+ ea = int(str(edit["addr"]), 16)
+ declaration = str(edit.get("signature") or edit.get("type") or "")
+ try:
+ ok = bool(
+ db.types.apply_declaration_at(ea, declaration, TypeApplyFlags.DEFINITE)
+ )
+ rows.append(
+ {
+ "addr": hex(ea),
+ "ok": ok,
+ **({} if ok else {"error": "IDA rejected the type"}),
+ }
+ )
+ except Exception as exc:
+ rows.append({"addr": hex(ea), "ok": False, "error": str(exc)})
+ result = {"result": rows}
+ return result
+
+
+def survey_binary(db: Database, **a: Any) -> Any:
+ segments = []
+ for seg in db.segments.get_all():
+ segments.append(
+ {
+ "start": hex(int(seg.start_ea)),
+ "end": hex(int(seg.end_ea)),
+ "name": db.segments.get_name(seg) or "",
+ }
+ )
+ result = {"segments": segments}
+ return result
+
+
+def thumb_scan(db: Database, **a: Any) -> Any:
+ import ida_bytes, ida_funcs, ida_idp, ida_segment, ida_segregs, ida_ua
+
+ lo, hi = int(str(a["start"]), 16), int(str(a["end"]), 16)
+ apply, limit = bool(a.get("apply", True)), int(a.get("limit", 512))
+ treg = ida_idp.str2reg("T")
+ found = []
+ applied = 0
+ cursor = lo
+ while cursor + 4 <= hi and len(found) < limit:
+ at = cursor
+ value = int(ida_bytes.get_dword(cursor))
+ cursor += 4
+ if not value & 1:
+ continue
+ target = value & ~1
+ seg = ida_segment.getseg(target)
+ if seg is None or not (seg.perm & ida_segment.SEGPERM_EXEC or seg.perm == 0):
+ continue
+ flags = ida_bytes.get_flags(target)
+ if ida_bytes.is_data(flags):
+ continue
+ item = {
+ "at": hex(at),
+ "value": hex(value),
+ "target": hex(target),
+ "was_code": bool(ida_bytes.is_code(flags)),
+ }
+ found.append(item)
+ if not apply:
+ continue
+ if treg is not None and treg >= 0:
+ ida_segregs.split_sreg_range(target, treg, 1, ida_segregs.SR_user)
+ if not ida_bytes.is_code(ida_bytes.get_flags(target)):
+ ida_bytes.del_items(target, 0, 2)
+ if ida_ua.create_insn(target) <= 0:
+ item["decoded"] = False
+ continue
+ item["decoded"] = True
+ item["function"] = bool(
+ db.functions.get_at(target) or db.functions.create(target)
+ )
+ applied += 1
+ result = {
+ "start": hex(lo),
+ "end": hex(hi),
+ "found": found,
+ "applied": applied,
+ "n": len(found),
+ }
+ return result
+
+
+def type_inspect(db: Database, **a: Any) -> Any:
+ rows = []
+ for query in a.get("queries", []):
+ name = str(query.get("name") or "")
+ tif = db.types.get_by_name(name)
+ if tif is None:
+ rows.append({"name": name, "error": "type not found"})
+ continue
+ members = (
+ [
+ {
+ "name": m.name,
+ "type": m.type.dstr() or str(m.type),
+ "offset": int(m.offset),
+ "size": int(m.size),
+ }
+ for m in db.types.get_udt_members(tif)
+ ]
+ if tif.is_udt()
+ else []
+ )
+ rows.append(
+ {
+ "name": name,
+ "size": int(tif.get_size()),
+ "is_union": bool(tif.is_union()),
+ "members": members,
+ }
+ )
+ result = {"result": rows}
+ return result
+
+
+def undefine(db: Database, **a: Any) -> Any:
+ import ida_bytes
+
+ rows = []
+ for item in a.get("items", []):
+ ea = int(str(item["addr"]), 16)
+ size = max(1, int(item.get("size") or ida_bytes.get_item_size(ea) or 1))
+ ok = bool(ida_bytes.del_items(ea, ida_bytes.DELIT_SIMPLE, size))
+ rows.append(
+ {
+ "addr": hex(ea),
+ "ok": ok,
+ **({} if ok else {"error": "delete items failed"}),
+ }
+ )
+ result = {"result": rows}
+ return result
+
+
+def xref_query(db: Database, **a: Any) -> Any:
+ import idaapi, idautils, ida_bytes, ida_funcs
+
+ def _fn(ea):
+ f = ida_funcs.get_func(ea)
+ return (
+ {
+ "addr": hex(int(f.start_ea)),
+ "name": ida_funcs.get_func_name(f.start_ea) or "",
+ }
+ if f
+ else None
+ )
+
+ queries = a.get("queries") or []
+ all_results = []
+ for query in queries:
+ raw = str(query.get("addr", "")).strip()
+ direction = str(query.get("direction", "both") or "both").lower()
+ if direction not in ("to", "from", "both"):
+ direction = "both"
+ xref_type = str(query.get("xref_type", "any") or "any").lower()
+ if xref_type not in ("any", "code", "data"):
+ xref_type = "any"
+ include_fn = bool(query.get("include_fn", True))
+ dedup = bool(query.get("dedup", True))
+ sort_by = str(query.get("sort_by", "addr") or "addr")
+ descending = bool(query.get("descending", False))
+ try:
+ offset = max(0, int(query.get("offset", 0) or 0))
+ except (TypeError, ValueError):
+ offset = 0
+ try:
+ count = max(0, min(int(query.get("count", 200) or 200), 5000))
+ except (TypeError, ValueError):
+ count = 200
+ try:
+ try:
+ target = int(raw, 16)
+ except ValueError:
+ target = idaapi.get_name_ea(idaapi.BADADDR, raw)
+ if target == idaapi.BADADDR:
+ raise ValueError(f"Failed to resolve address/name: {raw}")
+ if not ida_bytes.is_mapped(target):
+ raise ValueError(f"Address not mapped: {raw}")
+ rows = []
+ if direction in ("to", "both"):
+ for xr in idautils.XrefsTo(target, 0):
+ kind = "code" if xr.iscode else "data"
+ if xref_type != "any" and kind != xref_type:
+ continue
+ row = {
+ "direction": "to",
+ "addr": hex(int(xr.frm)),
+ "from": hex(int(xr.frm)),
+ "to": hex(int(target)),
+ "type": kind,
+ }
+ if include_fn:
+ row["fn"] = _fn(xr.frm)
+ rows.append(row)
+ if direction in ("from", "both"):
+ for xr in idautils.XrefsFrom(target, 0):
+ kind = "code" if xr.iscode else "data"
+ if xref_type != "any" and kind != xref_type:
+ continue
+ row = {
+ "direction": "from",
+ "addr": hex(int(xr.to)),
+ "from": hex(int(target)),
+ "to": hex(int(xr.to)),
+ "type": kind,
+ }
+ if include_fn:
+ row["fn"] = _fn(xr.to)
+ rows.append(row)
+ if dedup:
+ seen, deduped = set(), []
+ for row in rows:
+ key = (row["direction"], row["from"], row["to"], row["type"])
+ if key in seen:
+ continue
+ seen.add(key)
+ deduped.append(row)
+ rows = deduped
+ if sort_by == "type":
+ rows.sort(
+ key=lambda r: (str(r.get("type", "")), int(str(r["addr"]), 16)),
+ reverse=descending,
+ )
+ else:
+ rows.sort(key=lambda r: int(str(r["addr"]), 16), reverse=descending)
+ page = rows[offset : offset + count] if count else rows[offset:]
+ nxt = offset + len(page)
+ all_results.append(
+ {
+ "target": raw,
+ "resolved_addr": hex(int(target)),
+ "direction": direction,
+ "xref_type": xref_type,
+ "data": page,
+ "next_offset": nxt if nxt < len(rows) else None,
+ "total": len(rows),
+ "error": None,
+ }
+ )
+ except Exception as exc:
+ all_results.append(
+ {
+ "target": raw,
+ "resolved_addr": None,
+ "direction": direction,
+ "xref_type": xref_type,
+ "data": [],
+ "next_offset": None,
+ "total": 0,
+ "error": str(exc),
+ }
+ )
+ result = {"result": all_results}
+ return result
+
+
+def xref_types(db: Database, **a: Any) -> Any:
+ import idaapi, idautils, ida_bytes, ida_funcs, ida_xref
+
+ code_kind = {
+ ida_xref.fl_CF: "call",
+ ida_xref.fl_CN: "call",
+ ida_xref.fl_JF: "jump",
+ ida_xref.fl_JN: "jump",
+ ida_xref.fl_F: "flow",
+ }
+ data_kind = {
+ ida_xref.dr_O: "offset",
+ ida_xref.dr_W: "write",
+ ida_xref.dr_R: "read",
+ ida_xref.dr_T: "text",
+ ida_xref.dr_I: "info",
+ }
+
+ def _kind(xr):
+ return (code_kind if xr.iscode else data_kind).get(
+ xr.type, "code" if xr.iscode else "data"
+ )
+
+ def _fn(ea):
+ f = ida_funcs.get_func(ea)
+ return (
+ {
+ "addr": hex(int(f.start_ea)),
+ "name": ida_funcs.get_func_name(f.start_ea) or "",
+ }
+ if f
+ else None
+ )
+
+ queries = a.get("queries") or []
+ all_results = []
+ for query in queries:
+ query = query if isinstance(query, dict) else {"addr": query}
+ raw = str(query.get("addr", "")).strip()
+ direction = str(query.get("direction", "to") or "to").lower()
+ include_fn = bool(query.get("include_fn", True))
+ dedup = bool(query.get("dedup", True))
+ try:
+ count = int(query.get("count", 2000) or 2000)
+ except (TypeError, ValueError):
+ count = 2000
+ try:
+ target = int(raw, 16)
+ except ValueError:
+ target = idaapi.get_name_ea(idaapi.BADADDR, raw)
+ rows = []
+ if (
+ target is not None
+ and target != idaapi.BADADDR
+ and ida_bytes.is_mapped(target)
+ ):
+ if direction in ("to", "both"):
+ for xr in idautils.XrefsTo(target, 0):
+ row = {
+ "direction": "to",
+ "addr": hex(int(xr.frm)),
+ "from": hex(int(xr.frm)),
+ "to": hex(int(target)),
+ "type": "code" if xr.iscode else "data",
+ "kind": _kind(xr),
+ }
+ if include_fn:
+ row["fn"] = _fn(xr.frm)
+ rows.append(row)
+ if direction in ("from", "both"):
+ for xr in idautils.XrefsFrom(target, 0):
+ row = {
+ "direction": "from",
+ "addr": hex(int(xr.to)),
+ "from": hex(int(target)),
+ "to": hex(int(xr.to)),
+ "type": "code" if xr.iscode else "data",
+ "kind": _kind(xr),
+ }
+ if include_fn:
+ row["fn"] = _fn(xr.to)
+ rows.append(row)
+ if dedup:
+ seen, deduped = set(), []
+ for r in rows:
+ k = (r["direction"], r["from"], r["to"], r["kind"])
+ if k in seen:
+ continue
+ seen.add(k)
+ deduped.append(r)
+ rows = deduped
+ rows = rows[:count]
+ all_results.append({"query": raw, "data": rows, "next_offset": None})
+ result = {"result": all_results}
+ return result
+
+
+def op_format(addr: str, mode: str = "cycle", col: int = -1, n: int = -1) -> dict: ...
+
+
+def pc_nums(addr: str) -> dict: ...
+
+
+def decompile(addr, include_addresses=True) -> dict: ...
+
+
+def decomp_map(addr: str) -> dict: ...
+
+
+def pc_num_format(
+ addr: str,
+ mode: str = "cycle",
+ line: int = -1,
+ col: int = -1,
+ ea: str = "",
+ opnum: int = -1,
+) -> dict: ...
+
+
+def segment_index(
+ addr: str,
+ end: str = "",
+ page_rows: int = 500,
+ detail: bool = False,
+) -> dict: ...
+
+
+def heads(
+ addr: str,
+ count: int = 200,
+ offset: int = 0,
+ end: str = "",
+ back: bool = False,
+ annotate: bool = False,
+ expect: str = "",
+ text: bool = True,
+) -> dict: ...
+
+
+def profile_remote(operation: str, args: dict[str, Any], reps: int = 5) -> dict: ...
+
+
+OPERATIONS: dict[str, Callable[..., Any]] = {
+ "data_type": data_type,
+ "declare_type": declare_type,
+ "decomp_error": decomp_error,
+ "define_code": define_code,
+ "define_code_run": define_code_run,
+ "define_func": define_func,
+ "define_func_run": define_func_run,
+ "del_type": del_type,
+ "disasm": disasm,
+ "file_regions": file_regions,
+ "flowchart": flowchart,
+ "force_recompile": force_recompile,
+ "func_types": func_types,
+ "get_bytes": get_bytes,
+ "heads": heads,
+ "journal_get": journal_get,
+ "journal_put": journal_put,
+ "list_annotations": list_annotations,
+ "list_funcs": list_funcs,
+ "list_linkage": list_linkage,
+ "list_strings": list_strings,
+ "lookup_funcs": lookup_funcs,
+ "make_data": make_data,
+ "make_string": make_string,
+ "op_format": op_format,
+ "pc_nums": pc_nums,
+ "decompile": decompile,
+ "decomp_map": decomp_map,
+ "pc_num_format": pc_num_format,
+ "profile_remote": profile_remote,
+ "read_raw": read_raw,
+ "rename": rename,
+ "resolve_names": resolve_names,
+ "search_bytes": search_bytes,
+ "search_structs": search_structs,
+ "search_text": search_text,
+ "segment_index": segment_index,
+ "set_comments": set_comments,
+ "set_lvar_type": set_lvar_type,
+ "set_thumb": set_thumb,
+ "set_type": set_type,
+ "survey_binary": survey_binary,
+ "thumb_scan": thumb_scan,
+ "type_inspect": type_inspect,
+ "undefine": undefine,
+ "xref_query": xref_query,
+ "xref_types": xref_types,
+}
+
+_MODULE_DECLARATIONS = frozenset(
+ (
+ heads,
+ segment_index,
+ op_format,
+ pc_nums,
+ decompile,
+ decomp_map,
+ pc_num_format,
+ profile_remote,
+ )
+)
+_BOUND: dict[Callable[..., Any], Any] | None = None
+_BIND_LOCK = threading.Lock()
+
+
+def _bindings() -> dict[Callable[..., Any], Any]:
+ global _BOUND
+ with _BIND_LOCK:
+ if _BOUND is not None:
+ return _BOUND
+ from ida_nexus import RemoteModule
+
+ operations_module = RemoteModule(
+ Path(__file__), operation_label=operation_label, codec="json"
+ )
+ tools_module = RemoteModule(
+ Path(__file__).with_name("remote_tools.py"),
+ operation_label=operation_label,
+ codec="json",
+ )
+ bound: dict[Callable[..., Any], Any] = {}
+ for declaration in OPERATIONS.values():
+ if declaration in _MODULE_DECLARATIONS:
+ bound[declaration] = tools_module.function(
+ declaration,
+ timeout=15.0 if declaration is decompile else None,
+ )
+ else:
+ bound[declaration] = operations_module.function(declaration)
+ _BOUND = bound
+ return bound
+
+
+def bind(function: Callable[..., Any]) -> Any:
+ """Return the lazily constructed remote callable for one declaration."""
+ return _bindings()[function]
diff --git a/idatui/remote_tools.py b/idatui/remote_tools.py
index 0e26965..6681379 100644
--- a/idatui/remote_tools.py
+++ b/idatui/remote_tools.py
@@ -1,8 +1,8 @@
-"""The IDAPython ida-tui runs inside the Code Mode sandbox.
+"""The IDAPython ida-tui runs inside the IDA Nexus sandbox.
Two features have no ida-domain surface at all and are carried over VERBATIM
from the tools ida-tui was developed against (`server/patch_server.py`'s
-injected BODY, which the Code Mode port deletes):
+injected BODY, which the IDA Nexus port deletes):
* `heads` -- the continuous listing. ida-domain enumerates defined heads and
renders plain disassembly; the listing also needs coalesced undefined runs,
@@ -20,15 +20,16 @@ what you see). A re-implementation drifts from it silently.
This file is SOURCE SHIPPED AS TEXT to the database process; it is never
imported here, because the ida_* modules do not exist in the TUI's interpreter.
-`codemode_client` reads it and prepends it to the relevant snippets. Keep it
-self-contained: no relative imports, nothing beyond what Code Mode provides.
+`nexus_client` reads it and prepends it to the relevant snippets. Keep it
+self-contained: no relative imports, nothing beyond what IDA Nexus provides.
"""
+
# ruff: noqa
import re as _re
# IDAPython, imported ONCE at module scope.
#
-# This file is never imported by the client -- codemode_client reads it as
+# This file is never imported by the client -- nexus_client reads it as
# TEXT and installs it as a module inside the database process -- so the
# no-IDA house rule that keeps idatui importable without IDA does not apply
# here, and these need not be function-local.
@@ -134,7 +135,7 @@ def _idatui_head_row(ea, flags=None, text=True):
"""
f = ida_bytes.get_flags(ea) if flags is None else flags
- cls = f & _MS_CLS # == is_code(f) / is_data(f), without the calls
+ cls = f & _MS_CLS # == is_code(f) / is_data(f), without the calls
if cls == _FF_CODE:
kind = "code"
elif cls == _FF_DATA:
@@ -211,10 +212,20 @@ _IDATUI_SPAN_KINDS = {
# NB the real constant names: DATNAME/CODNAME, not "DNAME". Guessing here
# fails silently — an unmapped tag renders as plain body text, so symbols
# just quietly aren't blue and nothing tells you why.
- "name": ("SCOLOR_DATNAME", "SCOLOR_CODNAME", "SCOLOR_LOCNAME",
- "SCOLOR_IMPNAME", "SCOLOR_DEMNAME", "SCOLOR_LIBNAME",
- "SCOLOR_CNAME", "SCOLOR_DNAME",
- "SCOLOR_CREF", "SCOLOR_DREF", "SCOLOR_CREFTAIL", "SCOLOR_DREFTAIL"),
+ "name": (
+ "SCOLOR_DATNAME",
+ "SCOLOR_CODNAME",
+ "SCOLOR_LOCNAME",
+ "SCOLOR_IMPNAME",
+ "SCOLOR_DEMNAME",
+ "SCOLOR_LIBNAME",
+ "SCOLOR_CNAME",
+ "SCOLOR_DNAME",
+ "SCOLOR_CREF",
+ "SCOLOR_DREF",
+ "SCOLOR_CREFTAIL",
+ "SCOLOR_DREFTAIL",
+ ),
"seg": ("SCOLOR_SEGNAME",),
"cmt": ("SCOLOR_AUTOCMT", "SCOLOR_REGCMT", "SCOLOR_RPTCMT", "SCOLOR_VOIDOP"),
"punct": ("SCOLOR_SYMBOL", "SCOLOR_ALTOP", "SCOLOR_HIDNAME"),
@@ -241,7 +252,7 @@ _IDATUI_TAGS = None
_IDATUI_OPND_TAGS = None
-_IDATUI_CTL = None # re: a tag = one of three control chars plus its argument
+_IDATUI_CTL = None # re: a tag = one of three control chars plus its argument
_IDATUI_TAGINFO = None
@@ -280,6 +291,7 @@ def _idatui_spans(line):
_IDATUI_OPND_TAGS = _idatui_opnd_tag_map()
if _IDATUI_CTL is None:
import re as _re
+
# One capturing split gives [text, tag, text, tag, ..., text] in a
# single C pass. A per-character python loop over the line used to be
# the most expensive thing the `heads` tool did, and a line is ~54
@@ -289,17 +301,18 @@ def _idatui_spans(line):
if _IDATUI_TAGINFO is None:
_IDATUI_TAGINFO = {
tag: (_IDATUI_TAGS.get(tag, "text"), _IDATUI_OPND_TAGS.get(tag))
- for tag in set(_IDATUI_TAGS) | set(_IDATUI_OPND_TAGS)}
+ for tag in set(_IDATUI_TAGS) | set(_IDATUI_OPND_TAGS)
+ }
taginfo = _IDATUI_TAGINFO
plain_tag = ("text", None)
on, off, esc = "\x01", "\x02", "\x03"
addr_tag = chr(getattr(ida_lines, "COLOR_ADDR", 0x28))
addr_len = int(getattr(ida_lines, "COLOR_ADDR_SIZE", 16))
parts = _IDATUI_CTL.split(line)
- spans, stack = [], [] # stack entries: (kind, operand index|None)
- kind, opnd = "text", None # state the current run of text belongs to
+ spans, stack = [], [] # stack entries: (kind, operand index|None)
+ kind, opnd = "text", None # state the current run of text belongs to
pend = ""
- skip = 0 # characters of an address payload still due
+ skip = 0 # characters of an address payload still due
i, n = 0, len(parts)
while i < n:
txt = parts[i]
@@ -317,11 +330,11 @@ def _idatui_spans(line):
break
pair = parts[i]
i += 1
- if skip: # a tag INSIDE an address payload: 2 chars
+ if skip: # a tag INSIDE an address payload: 2 chars
skip = skip - 2 if skip > 2 else 0
continue
ch = pair[0]
- if ch == esc: # escaped literal: keep the char it guards
+ if ch == esc: # escaped literal: keep the char it guards
pend += pair[1]
continue
tag = pair[1]
@@ -337,7 +350,7 @@ def _idatui_spans(line):
stack.append((kind, opnd))
kind, o = taginfo.get(tag, plain_tag)
if o is not None:
- opnd = o # operands nest: an inner colour keeps the operand
+ opnd = o # operands nest: an inner colour keeps the operand
elif stack:
kind, opnd = stack.pop()
else:
@@ -359,7 +372,7 @@ def _idatui_spans(line):
prev_space = False
out.append([kind, txt, opnd])
continue
- if not core: # the span is nothing but padding
+ if not core: # the span is nothing but padding
if not prev_space:
prev_space = True
out.append([kind, " ", opnd])
@@ -396,7 +409,7 @@ def _idatui_spans(line):
ops.append([start, pos, cur])
text = "".join(t for _k, t, _o in out)
trimmed = []
- for lo, hi, k in ops: # don't let a range own trailing space
+ for lo, hi, k in ops: # don't let a range own trailing space
while hi > lo and text[hi - 1].isspace():
hi -= 1
while lo < hi and text[lo].isspace():
@@ -435,8 +448,17 @@ def _idatui_rows_digest(rows):
sh = seen.get(key)
if sh is None:
sh = seen[key] = hash(tuple(map(tuple, sp)))
- acc = hash((acc, r.get("ea"), r.get("kind"), r.get("size"),
- r.get("text"), r.get("name"), sh))
+ acc = hash(
+ (
+ acc,
+ r.get("ea"),
+ r.get("kind"),
+ r.get("size"),
+ r.get("text"),
+ r.get("name"),
+ sh,
+ )
+ )
return acc
@@ -448,8 +470,12 @@ def _idatui_unknown_row(ea, size):
if size <= 1:
return _idatui_head_row(ea)
- row = {"ea": hex(ea), "kind": "unknown", "size": int(size),
- "text": f"db {size} dup(?)"}
+ row = {
+ "ea": hex(ea),
+ "kind": "unknown",
+ "size": int(size),
+ "text": f"db {size} dup(?)",
+ }
nm = ida_name.get_ea_name(ea)
if nm:
row["name"] = nm
@@ -481,8 +507,7 @@ def _idatui_struct_member_rows(ea):
sz = 0
name = m.name or ""
text = f"+{off:X} {name}" + (f" {mtype}" if mtype else "")
- rows.append({"ea": hex(ea + off), "kind": "member", "size": sz,
- "text": text})
+ rows.append({"ea": hex(ea + off), "kind": "member", "size": sz, "text": text})
return rows
@@ -494,8 +519,13 @@ def _idatui_func_header_rows(ea):
return [
{"ea": hex(ea), "kind": "sep", "size": 0, "text": ""},
{"ea": hex(ea), "kind": "sep", "size": 0, "text": "; " + bar},
- {"ea": hex(ea), "kind": "funchdr", "size": 0,
- "text": name + " proc", "name": name},
+ {
+ "ea": hex(ea),
+ "kind": "funchdr",
+ "size": 0,
+ "text": name + " proc",
+ "name": name,
+ },
]
@@ -504,8 +534,13 @@ def _idatui_func_footer_rows(ea, func):
name = ida_funcs.get_func_name(func.start_ea) or "sub_%X" % func.start_ea
return [
- {"ea": hex(ea), "kind": "funchdr", "size": 0,
- "text": name + " endp", "name": name},
+ {
+ "ea": hex(ea),
+ "kind": "funchdr",
+ "size": 0,
+ "text": name + " endp",
+ "name": name,
+ },
{"ea": hex(ea), "kind": "sep", "size": 0, "text": "; " + "-" * 60},
]
@@ -543,9 +578,12 @@ def _idatui_segment_detail(addr, end, page_rows):
except Exception:
pass
- K_CODE = _IDATUI_KIND_ID["code"]; K_DATA = _IDATUI_KIND_ID["data"]
- K_UNK = _IDATUI_KIND_ID["unknown"]; K_SEP = _IDATUI_KIND_ID["sep"]
- K_FUNC = _IDATUI_KIND_ID["funchdr"]; K_LABEL = _IDATUI_KIND_ID["label"]
+ K_CODE = _IDATUI_KIND_ID["code"]
+ K_DATA = _IDATUI_KIND_ID["data"]
+ K_UNK = _IDATUI_KIND_ID["unknown"]
+ K_SEP = _IDATUI_KIND_ID["sep"]
+ K_FUNC = _IDATUI_KIND_ID["funchdr"]
+ K_LABEL = _IDATUI_KIND_ID["label"]
K_MEMBER = _IDATUI_KIND_ID["member"]
eas = array.array("Q")
@@ -569,8 +607,8 @@ def _idatui_segment_detail(addr, end, page_rows):
# are the same number until a segment contains an undefined run) and then
# silently yields pages that do not line up with a refetch.
anchors = []
- page_phys = 0 # physical rows emitted into the page being filled
- rows = 0 # logical rows so far (what the scrollbar counts)
+ page_phys = 0 # physical rows emitted into the page being filled
+ rows = 0 # logical rows so far (what the scrollbar counts)
fn = None
ea = ida_bytes.get_item_head(lo)
while ea != BAD and ea < hi:
@@ -584,7 +622,9 @@ def _idatui_segment_detail(addr, end, page_rows):
nh = next_head(ea, hi)
stop = nh if (nh != BAD and ea < nh <= hi) else hi
run = stop - ea
- ea_ap(ea); kind_ap(K_UNK); size_ap(run)
+ ea_ap(ea)
+ kind_ap(K_UNK)
+ size_ap(run)
rows += run if run > 1 else 1
page_phys += len(eas) - before
ea = stop
@@ -593,23 +633,32 @@ def _idatui_segment_detail(addr, end, page_rows):
fn = get_func(ea)
at_start = fn is not None and fn.start_ea == ea
if at_start:
- for k in (K_SEP, K_SEP, K_FUNC): # blank, banner, `name proc`
- ea_ap(ea); kind_ap(k); size_ap(0)
+ for k in (K_SEP, K_SEP, K_FUNC): # blank, banner, `name proc`
+ ea_ap(ea)
+ kind_ap(k)
+ size_ap(0)
rows += 3
elif cls == _FF_CODE and get_ea_name(ea):
- ea_ap(ea); kind_ap(K_LABEL); size_ap(0)
+ ea_ap(ea)
+ kind_ap(K_LABEL)
+ size_ap(0)
rows += 1
- ea_ap(ea); kind_ap(K_CODE if cls == _FF_CODE else K_DATA)
- size_ap(int(get_item_size(ea))); rows += 1
+ ea_ap(ea)
+ kind_ap(K_CODE if cls == _FF_CODE else K_DATA)
+ size_ap(int(get_item_size(ea)))
+ rows += 1
if cls == _FF_DATA:
for m in _idatui_struct_member_rows(ea):
ea_ap(int(m["ea"], 16) if isinstance(m["ea"], str) else m["ea"])
kind_ap(_IDATUI_KIND_ID.get(m.get("kind", "member"), K_MEMBER))
- size_ap(int(m.get("size", 0) or 0)); rows += 1
+ size_ap(int(m.get("size", 0) or 0))
+ rows += 1
item_end = get_item_end(ea)
if fn is not None and item_end >= fn.end_ea:
- for k in (K_FUNC, K_SEP): # `name endp`, separator
- ea_ap(ea); kind_ap(k); size_ap(0)
+ for k in (K_FUNC, K_SEP): # `name endp`, separator
+ ea_ap(ea)
+ kind_ap(k)
+ size_ap(0)
rows += 2
page_phys += len(eas) - before
ea = item_end if item_end > ea else ea + 1
@@ -618,19 +667,28 @@ def _idatui_segment_detail(addr, end, page_rows):
# which turns a bytes object into its repr -- 4 characters per byte and
# unparseable at the other end. Learned by watching 2.97MB arrive as 11.26MB.
import base64
+
b64 = base64.b64encode
- return {"addr": hex(lo), "end": hex(hi), "rows": rows, "heads": len(eas),
- "anchors": anchors, "kind_names": list(_IDATUI_KINDS),
- "eas": b64(eas.tobytes()).decode(),
- "kinds": b64(kinds.tobytes()).decode(),
- "sizes": b64(sizes.tobytes()).decode()}
+ return {
+ "addr": hex(lo),
+ "end": hex(hi),
+ "rows": rows,
+ "heads": len(eas),
+ "anchors": anchors,
+ "kind_names": list(_IDATUI_KINDS),
+ "eas": b64(eas.tobytes()).decode(),
+ "kinds": b64(kinds.tobytes()).decode(),
+ "sizes": b64(sizes.tobytes()).decode(),
+ }
def segment_index(
addr: Annotated[str, "Any address in the segment to index"],
end: Annotated[str, "Optional exclusive end address; default = segment end"] = "",
page_rows: Annotated[int, "Rows between anchors (default 500)"] = 500,
- detail: Annotated[bool, "Also return every row's ea/kind/size as packed arrays"] = False,
+ detail: Annotated[
+ bool, "Also return every row's ea/kind/size as packed arrays"
+ ] = False,
) -> dict:
"""How many listing rows a segment has, and where to seek into it.
@@ -662,6 +720,7 @@ def segment_index(
if detail:
return _idatui_segment_detail(addr, end, count)
import ida_segment
+
seg = ida_segment.getseg(start)
if not seg:
return {"addr": str(addr), "error": "no segment", "rows": 0, "anchors": []}
@@ -703,19 +762,24 @@ def segment_index(
fn = get_func(ea)
n = 1
if fn is not None and fn.start_ea == ea:
- n += 3 # blank, banner, `proc`
+ n += 3 # blank, banner, `proc`
elif cls == _FF_CODE and get_ea_name(ea):
- n += 1 # loc_XXX label on its own row
+ n += 1 # loc_XXX label on its own row
if cls == _FF_DATA:
n += len(_idatui_struct_member_rows(ea))
item_end = get_item_end(ea)
if fn is not None and item_end >= fn.end_ea:
- n += 2 # `endp` + separator
+ n += 2 # `endp` + separator
rows += n
n_heads += 1
ea = item_end if item_end > ea else ea + 1
- return {"addr": hex(lo), "end": hex(hi), "rows": rows,
- "heads": n_heads, "anchors": anchors}
+ return {
+ "addr": hex(lo),
+ "end": hex(hi),
+ "rows": rows,
+ "heads": n_heads,
+ "anchors": anchors,
+ }
def heads(
@@ -723,10 +787,21 @@ def heads(
count: Annotated[int, "Max heads to return (default 200, max 2000)"] = 200,
offset: Annotated[int, "Skip first N heads from addr (default 0)"] = 0,
end: Annotated[str, "Optional exclusive end address; default = segment end"] = "",
- back: Annotated[bool, "Walk backwards: return the count heads ENDING just before addr, in forward order"] = False,
- annotate: Annotated[bool, "Emit IDA-style function boundary banner rows (kind sep/funchdr)"] = False,
- expect: Annotated[str, "Digest a caller already holds: the rows are omitted when they still hash to it"] = "",
- text: Annotated[bool, "Render each row's disassembly text (default true). False = a skeleton page: same rows, same addresses, no text"] = True,
+ back: Annotated[
+ bool,
+ "Walk backwards: return the count heads ENDING just before addr, in forward order",
+ ] = False,
+ annotate: Annotated[
+ bool, "Emit IDA-style function boundary banner rows (kind sep/funchdr)"
+ ] = False,
+ expect: Annotated[
+ str,
+ "Digest a caller already holds: the rows are omitted when they still hash to it",
+ ] = "",
+ text: Annotated[
+ bool,
+ "Render each row's disassembly text (default true). False = a skeleton page: same rows, same addresses, no text",
+ ] = True,
) -> dict:
"""Walk item heads from ``addr`` as a flat listing: every head is rendered
(code OR data OR undefined) via generate_disasm_line and stepped with
@@ -740,10 +815,20 @@ def heads(
try:
start = parse_address(addr)
except Exception as e:
- return {"addr": str(addr), "error": str(e), "heads": [], "cursor": {"done": True}}
+ return {
+ "addr": str(addr),
+ "error": str(e),
+ "heads": [],
+ "cursor": {"done": True},
+ }
seg = ida_segment.getseg(start)
if not seg:
- return {"addr": str(addr), "error": "no segment", "heads": [], "cursor": {"done": True}}
+ return {
+ "addr": str(addr),
+ "error": "no segment",
+ "heads": [],
+ "cursor": {"done": True},
+ }
lo, hi = seg.start_ea, seg.end_ea
if end:
try:
@@ -766,7 +851,9 @@ def heads(
rows = [_idatui_head_row(e) for e in chosen]
first = chosen[0] if chosen else start
pea = ida_bytes.prev_head(first, lo)
- cursor = {"done": True} if pea == idaapi.BADADDR or pea < lo else {"prev": hex(pea)}
+ cursor = (
+ {"done": True} if pea == idaapi.BADADDR or pea < lo else {"prev": hex(pea)}
+ )
return {"addr": str(addr), "heads": rows, "cursor": cursor}
# Walk by item END (not next_head): next_head SKIPS undefined bytes, but a
@@ -823,8 +910,9 @@ def heads(
# A code label (loc_XXX/jump target) gets its OWN line at depth 0,
# like IDA; strip it from the instruction row below.
nm = row["name"]
- out.append({"ea": hex(e), "kind": "label", "size": 0,
- "text": nm + ":", "name": nm})
+ out.append(
+ {"ea": hex(e), "kind": "label", "size": 0, "text": nm + ":", "name": nm}
+ )
row = dict(row)
row["name"] = None
out.append(row)
@@ -845,7 +933,7 @@ def heads(
if len(rows) >= count:
more = True
break
- f = get_flags(ea) # once per head, not once per consumer
+ f = get_flags(ea) # once per head, not once per consumer
rows.extend(_rows_for(ea, f)) # a struct head expands into member rows
ea = _advance(ea, f)
cursor = {"next": hex(ea)} if more else {"done": True}
@@ -871,8 +959,18 @@ def heads(
_IDATUI_FMT_CYCLE = ("hex", "dec", "bin", "char", "offset", "default")
-_IDATUI_FMT_SETTABLE = ("hex", "dec", "oct", "bin", "char", "offset", "seg",
- "float", "stack", "default")
+_IDATUI_FMT_SETTABLE = (
+ "hex",
+ "dec",
+ "oct",
+ "bin",
+ "char",
+ "offset",
+ "seg",
+ "float",
+ "stack",
+ "default",
+)
def _idatui_fmt_nibbles():
@@ -880,13 +978,20 @@ def _idatui_fmt_nibbles():
this module is injected into a file that is imported before a database is
open."""
return {
- "default": ida_bytes.FF_N_VOID, "hex": ida_bytes.FF_N_NUMH,
- "dec": ida_bytes.FF_N_NUMD, "char": ida_bytes.FF_N_CHAR,
- "seg": ida_bytes.FF_N_SEG, "offset": ida_bytes.FF_N_OFF,
- "bin": ida_bytes.FF_N_NUMB, "oct": ida_bytes.FF_N_NUMO,
- "enum": ida_bytes.FF_N_ENUM, "forced": ida_bytes.FF_N_FOP,
- "stroff": ida_bytes.FF_N_STRO, "stack": ida_bytes.FF_N_STK,
- "float": ida_bytes.FF_N_FLT, "custom": ida_bytes.FF_N_CUST,
+ "default": ida_bytes.FF_N_VOID,
+ "hex": ida_bytes.FF_N_NUMH,
+ "dec": ida_bytes.FF_N_NUMD,
+ "char": ida_bytes.FF_N_CHAR,
+ "seg": ida_bytes.FF_N_SEG,
+ "offset": ida_bytes.FF_N_OFF,
+ "bin": ida_bytes.FF_N_NUMB,
+ "oct": ida_bytes.FF_N_NUMO,
+ "enum": ida_bytes.FF_N_ENUM,
+ "forced": ida_bytes.FF_N_FOP,
+ "stroff": ida_bytes.FF_N_STRO,
+ "stack": ida_bytes.FF_N_STK,
+ "float": ida_bytes.FF_N_FLT,
+ "custom": ida_bytes.FF_N_CUST,
}
@@ -932,8 +1037,12 @@ def _idatui_op_value(ea, n):
size = 0
return int(v), size
size = int(ida_bytes.get_item_size(ea))
- read = {1: ida_bytes.get_byte, 2: ida_bytes.get_word,
- 4: ida_bytes.get_dword, 8: ida_bytes.get_qword}.get(size)
+ read = {
+ 1: ida_bytes.get_byte,
+ 2: ida_bytes.get_word,
+ 4: ida_bytes.get_dword,
+ 8: ida_bytes.get_qword,
+ }.get(size)
if read is None:
return None, size
try:
@@ -991,9 +1100,9 @@ def _idatui_op_candidates(ea):
F = ida_bytes.get_flags(ea)
if ida_bytes.is_data(F):
- return [0] # a data item's value is operand 0
+ return [0] # a data item's value is operand 0
if not ida_bytes.is_code(F):
- return [] # undefined bytes: IDA refuses a format outright
+ return [] # undefined bytes: IDA refuses a format outright
insn = ida_ua.insn_t()
if ida_ua.decode_insn(insn, ea) <= 0:
return []
@@ -1037,7 +1146,7 @@ def _idatui_op_spans(ea, text):
if not op:
continue
i = text.find(op, pos)
- if i < 0: # duplicated operand text (mov eax, eax)
+ if i < 0: # duplicated operand text (mov eax, eax)
i = text.find(op)
if i < 0:
continue
@@ -1070,21 +1179,34 @@ def _idatui_apply_fmt(ea, n, fmt):
if base in (idaapi.BADADDR, None) or base < 0:
base = 0
return bool(ida_offset.op_plain_offset(ea, n, base)), ""
- fn = {"hex": ida_bytes.op_hex, "dec": ida_bytes.op_dec,
- "oct": ida_bytes.op_oct, "bin": ida_bytes.op_bin,
- "char": ida_bytes.op_chr, "seg": ida_bytes.op_seg,
- "float": ida_bytes.op_flt, "stack": ida_bytes.op_stkvar}.get(fmt)
+ fn = {
+ "hex": ida_bytes.op_hex,
+ "dec": ida_bytes.op_dec,
+ "oct": ida_bytes.op_oct,
+ "bin": ida_bytes.op_bin,
+ "char": ida_bytes.op_chr,
+ "seg": ida_bytes.op_seg,
+ "float": ida_bytes.op_flt,
+ "stack": ida_bytes.op_stkvar,
+ }.get(fmt)
if fn is None:
- return False, (f"can't set {fmt!r} from a name alone"
- if fmt in _idatui_fmt_nibbles() else
- f"unknown format {fmt!r}")
+ return False, (
+ f"can't set {fmt!r} from a name alone"
+ if fmt in _idatui_fmt_nibbles()
+ else f"unknown format {fmt!r}"
+ )
return bool(fn(ea, n)), ""
def op_format(
addr: Annotated[str, "Address of the instruction or data item"],
- mode: Annotated[str, "cycle | back | show | hex | dec | oct | bin | char | offset | stack | default"] = "cycle",
- col: Annotated[int, "Cursor column inside the rendered line (-1: first literal)"] = -1,
+ mode: Annotated[
+ str,
+ "cycle | back | show | hex | dec | oct | bin | char | offset | stack | default",
+ ] = "cycle",
+ col: Annotated[
+ int, "Cursor column inside the rendered line (-1: first literal)"
+ ] = -1,
n: Annotated[int, "Operand index; -1 derives it from ``col``"] = -1,
) -> dict:
"""Change how a literal is DISPLAYED (IDA's 'o' family): hex, decimal,
@@ -1126,19 +1248,27 @@ def op_format(
# a different operand would make that highlight a lie -- say
# which one can be changed instead.
where = before[lo:hi].strip()
- alt = (f"; the literal on this line is operand {cands[0]} "
- f"({_idatui_op_text(ea, before, cands[0])})"
- if cands else "")
- return {"addr": hex(ea), "n": i, "text": before,
- "error": f"operand {i} ({where}) has no format to "
- f"change{alt}"}
+ alt = (
+ f"; the literal on this line is operand {cands[0]} "
+ f"({_idatui_op_text(ea, before, cands[0])})"
+ if cands
+ else ""
+ )
+ return {
+ "addr": hex(ea),
+ "n": i,
+ "text": before,
+ "error": f"operand {i} ({where}) has no format to change{alt}",
+ }
if n < 0:
if not cands:
F = ida_bytes.get_flags(ea)
- why = ("no literal on this line to reformat"
- if ida_bytes.is_code(F) or ida_bytes.is_data(F) else
- "undefined bytes have no format to change -- define "
- "them first ('d' makes data, 'c' makes code)")
+ why = (
+ "no literal on this line to reformat"
+ if ida_bytes.is_code(F) or ida_bytes.is_data(F)
+ else "undefined bytes have no format to change -- define "
+ "them first ('d' makes data, 'c' makes code)"
+ )
return {"addr": hex(ea), "text": before, "error": why}
n = cands[0]
@@ -1148,9 +1278,12 @@ def op_format(
# The ring is a property of the OPERAND, not of what you last pressed: every
# stop is one that changes what you see for this value, and it is the same
# ring at every step, so a lap always comes home.
- choices = [f for f in _IDATUI_FMT_CYCLE
- if (f != "char" or _idatui_printable(value))
- and (f != "offset" or _idatui_offset_worth(value))]
+ choices = [
+ f
+ for f in _IDATUI_FMT_CYCLE
+ if (f != "char" or _idatui_printable(value))
+ and (f != "offset" or _idatui_offset_worth(value))
+ ]
# A stack variable is deliberately NOT a stop: ``[rbp+var_40]`` is a frame
# member, not a way of writing a number, and IDA's own "is this a stack
# variable" test isn't exposed to Python here (calc_stkvar_struc_offset
@@ -1160,10 +1293,18 @@ def op_format(
mode = str(mode or "cycle").lower()
if mode == "show":
- return {"addr": hex(ea), "n": n, "format": cur, "prev": cur,
- "choices": choices, "text": before, "before": before,
- "value": None if value is None else hex(value),
- "width": width, "applied": False}
+ return {
+ "addr": hex(ea),
+ "n": n,
+ "format": cur,
+ "prev": cur,
+ "choices": choices,
+ "text": before,
+ "before": before,
+ "value": None if value is None else hex(value),
+ "width": width,
+ "applied": False,
+ }
if mode in ("cycle", "back"):
step = 1 if mode == "cycle" else -1
if cur in choices:
@@ -1176,31 +1317,51 @@ def op_format(
else:
want = mode
if want not in _idatui_fmt_nibbles():
- return {"addr": hex(ea), "n": n, "text": before,
- "error": f"unknown format {mode!r}; one of "
- + ", ".join(_IDATUI_FMT_SETTABLE)}
+ return {
+ "addr": hex(ea),
+ "n": n,
+ "text": before,
+ "error": f"unknown format {mode!r}; one of "
+ + ", ".join(_IDATUI_FMT_SETTABLE),
+ }
if want == "offset" and not mapped:
- return {"addr": hex(ea), "n": n, "text": before, "format": cur,
- "error": (f"{'0x%x' % value if value is not None else 'this operand'}"
- " isn't a mapped address -- an offset to it would"
- " invent a name for nothing")}
+ return {
+ "addr": hex(ea),
+ "n": n,
+ "text": before,
+ "format": cur,
+ "error": (
+ f"{'0x%x' % value if value is not None else 'this operand'}"
+ " isn't a mapped address -- an offset to it would"
+ " invent a name for nothing"
+ ),
+ }
ok, err = _idatui_apply_fmt(ea, n, want)
if err:
- return {"addr": hex(ea), "n": n, "text": before, "format": cur,
- "error": err}
+ return {"addr": hex(ea), "n": n, "text": before, "format": cur, "error": err}
got = _idatui_op_fmt(ea, n)
- out = {"addr": hex(ea), "n": n, "prev": cur, "format": got,
- "requested": want, "applied": bool(ok), "choices": choices,
- "before": before, "text": _idatui_line_text(ea),
- "value": None if value is None else hex(value), "width": width}
+ out = {
+ "addr": hex(ea),
+ "n": n,
+ "prev": cur,
+ "format": got,
+ "requested": want,
+ "applied": bool(ok),
+ "choices": choices,
+ "before": before,
+ "text": _idatui_line_text(ea),
+ "value": None if value is None else hex(value),
+ "width": width,
+ }
if not ok:
out["error"] = f"IDA refused {want} on operand {n}"
elif lossy:
- out["warn"] = (
- f"operand {n} was {cur} and the ring has no stop there -- "
- + (f"'{cur}' sets it again" if cur in _IDATUI_FMT_SETTABLE else
- f"{cur} names a type this can't put back, reassign it by hand"))
+ out["warn"] = f"operand {n} was {cur} and the ring has no stop there -- " + (
+ f"'{cur}' sets it again"
+ if cur in _IDATUI_FMT_SETTABLE
+ else f"{cur} names a type this can't put back, reassign it by hand"
+ )
return out
@@ -1267,13 +1428,18 @@ def _idatui_lit_extent(plain, x):
around the column, which cannot reach a ``)`` or a space."""
if x >= len(plain):
return None
- if plain[x] == "'": # a character constant: '-'
+ if plain[x] == "'": # a character constant: '-'
end = plain.find("'", x + 1)
return (x, end + 1) if end > x else None
lo = plain.rfind("'", 0, x)
- if lo >= 0 and plain.find("'", x) > x and "'" in plain[lo:x] and \
- plain[lo:x].count("'") == 1 and " " not in plain[lo:x]:
- return (lo, plain.find("'", x) + 1) # inside 'c'
+ if (
+ lo >= 0
+ and plain.find("'", x) > x
+ and "'" in plain[lo:x]
+ and plain[lo:x].count("'") == 1
+ and " " not in plain[lo:x]
+ ):
+ return (lo, plain.find("'", x) + 1) # inside 'c'
if plain[x] not in _IDATUI_LIT_CHARS:
return None
lo = x
@@ -1282,7 +1448,7 @@ def _idatui_lit_extent(plain, x):
hi = x
while hi < len(plain) and plain[hi] in _IDATUI_LIT_CHARS:
hi += 1
- if lo > 0 and plain[lo - 1] == "-": # a unary minus is part of it
+ if lo > 0 and plain[lo - 1] == "-": # a unary minus is part of it
lo -= 1
return (lo, hi)
@@ -1321,26 +1487,36 @@ def _idatui_pc_nums(cf, sl):
continue
nf = e.n.nf
opnum = ord(nf.opnum) if isinstance(nf.opnum, str) else int(nf.opnum)
- nbytes = (ord(nf.org_nbytes) if isinstance(nf.org_nbytes, str)
- else int(nf.org_nbytes))
+ nbytes = (
+ ord(nf.org_nbytes) if isinstance(nf.org_nbytes, str) else int(nf.org_nbytes)
+ )
ea = int(e.ea)
if ea == idaapi.BADADDR:
x = extent[1]
- continue # synthesised: nothing to key on
+ continue # synthesised: nothing to key on
nib = (nf.flags >> ida_bytes.get_operand_type_shift(opnum)) & 0xF
# Whether this format is the USER's or Hex-Rays' own guess. The nibble
# can't say: an untouched number reads back as whatever it happens to
# be printed as, and cycling from there would skip that stop forever
# (default already looks like it) and never come back to it.
loc = ida_hexrays.operand_locator_t(ea, opnum)
- user = (ida_hexrays.user_numforms_find(cf.numforms, loc)
- != ida_hexrays.user_numforms_end(cf.numforms))
- out.append({"x0": extent[0], "x1": extent[1], "ea": ea,
- "opnum": opnum, "value": int(e.n._value),
- "nbytes": nbytes, "user": user,
- "fmt": _idatui_fmt_name(nib) if user else "default",
- "shown": _idatui_fmt_name(nib)})
- x = extent[1] # past this literal, not into it
+ user = ida_hexrays.user_numforms_find(
+ cf.numforms, loc
+ ) != ida_hexrays.user_numforms_end(cf.numforms)
+ out.append(
+ {
+ "x0": extent[0],
+ "x1": extent[1],
+ "ea": ea,
+ "opnum": opnum,
+ "value": int(e.n._value),
+ "nbytes": nbytes,
+ "user": user,
+ "fmt": _idatui_fmt_name(nib) if user else "default",
+ "shown": _idatui_fmt_name(nib),
+ }
+ )
+ x = extent[1] # past this literal, not into it
return out
@@ -1367,31 +1543,36 @@ def pc_nums(
try:
cf = ida_hexrays.decompile(f.start_ea)
except Exception as e:
- return {"addr": hex(f.start_ea), "error": f"decompile failed: {e}",
- "nums": []}
+ return {"addr": hex(f.start_ea), "error": f"decompile failed: {e}", "nums": []}
if cf is None:
- return {"addr": hex(f.start_ea), "error": "decompilation failed",
- "nums": []}
+ return {"addr": hex(f.start_ea), "error": "decompilation failed", "nums": []}
sv = cf.get_pseudocode()
out = []
for i in range(len(sv)):
plain = ida_lines.tag_remove(sv[i].line)
compact = _idatui_compact(plain)
for rec in _idatui_pc_nums(cf, sv[i]):
- out.append({
- "line": i,
- "x0": _idatui_compact_col(plain, compact, rec["x0"]),
- "x1": _idatui_compact_col(plain, compact, rec["x1"]),
- "ea": hex(rec["ea"]), "opnum": rec["opnum"],
- "value": hex(rec["value"]), "fmt": rec["fmt"],
- "shown": rec["shown"], "user": bool(rec["user"]),
- })
+ out.append(
+ {
+ "line": i,
+ "x0": _idatui_compact_col(plain, compact, rec["x0"]),
+ "x1": _idatui_compact_col(plain, compact, rec["x1"]),
+ "ea": hex(rec["ea"]),
+ "opnum": rec["opnum"],
+ "value": hex(rec["value"]),
+ "fmt": rec["fmt"],
+ "shown": rec["shown"],
+ "user": bool(rec["user"]),
+ }
+ )
return {"addr": hex(f.start_ea), "nums": out, "lines": len(sv)}
def pc_num_format(
addr: Annotated[str, "Function address (or any address inside it)"],
- mode: Annotated[str, "cycle | back | show | hex | dec | oct | char | default"] = "cycle",
+ mode: Annotated[
+ str, "cycle | back | show | hex | dec | oct | char | default"
+ ] = "cycle",
line: Annotated[int, "0-based pseudocode line index"] = -1,
col: Annotated[int, "Cursor column in the DISPLAYED line (-1: first literal)"] = -1,
ea: Annotated[str, "Address of the number instead of line/col"] = "",
@@ -1431,8 +1612,9 @@ def pc_num_format(
return {"addr": hex(f.start_ea), "error": str(e)}
for i in range(len(sv)):
for rec in _idatui_pc_nums(cf, sv[i]):
- if rec["ea"] == want_ea and (int(opnum) < 0
- or rec["opnum"] == int(opnum)):
+ if rec["ea"] == want_ea and (
+ int(opnum) < 0 or rec["opnum"] == int(opnum)
+ ):
target, line = rec, i
break
if target:
@@ -1446,26 +1628,42 @@ def pc_num_format(
target = next((r for r in nums if r["x0"] <= x < r["x1"]), None)
target = target or nums[0]
else:
- return {"addr": hex(f.start_ea),
- "error": f"line {line} is outside the {len(sv)}-line decompilation"}
+ return {
+ "addr": hex(f.start_ea),
+ "error": f"line {line} is outside the {len(sv)}-line decompilation",
+ }
if target is None:
- return {"addr": hex(f.start_ea), "line": line,
- "text": (ida_lines.tag_remove(sv[line].line).strip()
- if 0 <= line < len(sv) else ""),
- "error": "no number literal on this line"}
+ return {
+ "addr": hex(f.start_ea),
+ "line": line,
+ "text": (
+ ida_lines.tag_remove(sv[line].line).strip()
+ if 0 <= line < len(sv)
+ else ""
+ ),
+ "error": "no number literal on this line",
+ }
cur, value = target["fmt"], target["value"]
- choices = [c for c in _IDATUI_PC_FMT_CYCLE
- if c != "char" or _idatui_printable(value)]
+ choices = [
+ c for c in _IDATUI_PC_FMT_CYCLE if c != "char" or _idatui_printable(value)
+ ]
# Same rule as the listing: one ring per literal, every step. A format the
# ring can't hold (an enum set in the GUI) is reported on the way out
# instead of being kept for one lap and then lost.
lossy = cur not in choices and cur != "default"
- out = {"addr": hex(f.start_ea), "ea": hex(target["ea"]),
- "opnum": target["opnum"], "line": line, "prev": cur,
- "format": cur, "shown": target["shown"], "choices": choices,
- "value": hex(value),
- "before": ida_lines.tag_remove(sv[line].line).strip()}
+ out = {
+ "addr": hex(f.start_ea),
+ "ea": hex(target["ea"]),
+ "opnum": target["opnum"],
+ "line": line,
+ "prev": cur,
+ "format": cur,
+ "shown": target["shown"],
+ "choices": choices,
+ "value": hex(value),
+ "before": ida_lines.tag_remove(sv[line].line).strip(),
+ }
mode = str(mode or "cycle").lower()
if mode == "show":
@@ -1481,13 +1679,16 @@ def pc_num_format(
else:
want = mode
if want in ("bin", "offset", "stack", "seg", "float"):
- out["error"] = (f"Hex-Rays has no {want} format for a number "
- f"-- set it on the listing instead")
+ out["error"] = (
+ f"Hex-Rays has no {want} format for a number "
+ f"-- set it on the listing instead"
+ )
out["text"] = out["before"]
return out
if want not in ("hex", "dec", "oct", "char", "default"):
- out["error"] = (f"unknown format {mode!r}; one of hex, dec, oct, "
- f"char, default")
+ out["error"] = (
+ f"unknown format {mode!r}; one of hex, dec, oct, char, default"
+ )
out["text"] = out["before"]
return out
@@ -1499,8 +1700,9 @@ def pc_num_format(
ida_hexrays.user_numforms_erase(cf.numforms, it)
if want != "default":
nf = ida_hexrays.number_format_t(target["opnum"])
- nf.flags = ida_bytes.get_operand_flag(_idatui_fmt_nibbles()[want],
- target["opnum"])
+ nf.flags = ida_bytes.get_operand_flag(
+ _idatui_fmt_nibbles()[want], target["opnum"]
+ )
try:
nf.org_nbytes = target["nbytes"]
except Exception:
@@ -1515,14 +1717,18 @@ def pc_num_format(
out["format"] = want
out["applied"] = True
if lossy:
- out["warn"] = (f"this number was {cur}, which names a type a radix "
- f"can't put back -- reassign it in IDA")
+ out["warn"] = (
+ f"this number was {cur}, which names a type a radix "
+ f"can't put back -- reassign it in IDA"
+ )
try:
- cf2 = ida_hexrays.decompile(f.start_ea,
- flags=ida_hexrays.DECOMP_NO_CACHE)
+ cf2 = ida_hexrays.decompile(f.start_ea, flags=ida_hexrays.DECOMP_NO_CACHE)
sv2 = cf2.get_pseudocode() if cf2 is not None else None
- out["text"] = (ida_lines.tag_remove(sv2[line].line).strip()
- if sv2 is not None and line < len(sv2) else out["before"])
+ out["text"] = (
+ ida_lines.tag_remove(sv2[line].line).strip()
+ if sv2 is not None and line < len(sv2)
+ else out["before"]
+ )
except Exception as e:
out["text"] = out["before"]
out["warn"] = f"re-render failed: {e}"
@@ -1534,7 +1740,7 @@ def decompile(addr, include_addresses=True):
Faithful to the tool ida-tui was written against, and in particular to its
COST: the per-line address anchor comes from ONE ``get_line_item`` at column
- 0 per line. The Code Mode port asked for the full per-column line map (what
+ 0 per line. The IDA Nexus port asked for the full per-column line map (what
``decomp_map`` is for) purely to fill in that anchor, which is thousands of
``get_line_item``+``dstr()`` calls per function instead of one per line, and
made every pseudocode open cost the same as opening the split view.
@@ -1557,11 +1763,17 @@ def decompile(addr, include_addresses=True):
try:
cfunc = ida_hexrays.decompile_func(fn, failure)
except Exception as e:
- return {"addr": hex(int(fn.start_ea)), "code": None,
- "error": f"Decompilation failed at {ea:#x}: {e}"}
+ return {
+ "addr": hex(int(fn.start_ea)),
+ "code": None,
+ "error": f"Decompilation failed at {ea:#x}: {e}",
+ }
if cfunc is None:
- return {"addr": hex(int(fn.start_ea)), "code": None,
- "error": failure.desc() or f"Decompilation failed at {ea:#x}"}
+ return {
+ "addr": hex(int(fn.start_ea)),
+ "code": None,
+ "error": failure.desc() or f"Decompilation failed at {ea:#x}",
+ }
lines = []
for sl in cfunc.get_pseudocode():
@@ -1569,7 +1781,9 @@ def decompile(addr, include_addresses=True):
item = ida_hexrays.ctree_item_t()
tail = ida_hexrays.ctree_item_t()
line_ea = None
- if include_addresses and cfunc.get_line_item(sl.line, 0, False, head, item, tail):
+ if include_addresses and cfunc.get_line_item(
+ sl.line, 0, False, head, item, tail
+ ):
parts = (item.dstr() or "").split(": ")
if len(parts) == 2:
try:
@@ -1595,9 +1809,13 @@ def decompile(addr, include_addresses=True):
text = raw.decode("utf-8", "replace") if raw else None
except Exception:
text = None
- refs.append({"addr": hex(target),
- "name": ida_name.get_name(target) or "",
- "string": text})
+ refs.append(
+ {
+ "addr": hex(target),
+ "name": ida_name.get_name(target) or "",
+ "string": text,
+ }
+ )
return 0
try:
@@ -1615,6 +1833,7 @@ def decomp_map(
swept across the line's columns via get_line_item. Shape:
{addr, lines:[{ea: primary|None, eas:[hex,...]}, ...]}."""
import ida_hexrays
+
try:
ea = int(str(addr), 16)
except ValueError:
@@ -1691,3 +1910,32 @@ def decomp_map(
eas.append(hex(e))
lines.append({"ea": eas[0] if eas else None, "eas": eas})
return {"addr": hex(func.start_ea), "lines": lines}
+
+
+def profile_remote(operation, args, reps=5):
+ """Profile one persistent remote tool entirely inside IDA."""
+ import cProfile
+ import io
+ import pstats
+
+ functions = {
+ "heads": heads,
+ "segment_index": segment_index,
+ "op_format": op_format,
+ "pc_nums": pc_nums,
+ "decompile": decompile,
+ "decomp_map": decomp_map,
+ "pc_num_format": pc_num_format,
+ }
+ function = functions.get(str(operation))
+ if function is None:
+ raise ValueError(f"unknown profile operation: {operation!r}")
+ profiler = cProfile.Profile()
+ for _ in range(max(1, int(reps))):
+ profiler.runcall(function, **dict(args))
+ stats = pstats.Stats(profiler)
+ output = io.StringIO()
+ stats.stream = output
+ stats.sort_stats("tottime")
+ stats.print_stats(80)
+ return {"stats": output.getvalue(), "total": stats.total_tt, "reps": reps}
diff --git a/pyproject.toml b/pyproject.toml
index a46dd45..dc68d12 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,12 +1,12 @@
[project]
name = "idatui"
version = "0.0.1"
-description = "A keyboard-first TUI frontend for shared IDA Code Mode databases."
+description = "A keyboard-first TUI frontend for shared IDA Nexus databases."
requires-python = ">=3.11"
-# ida-codemode supplies GUI discovery, shared idalib workers, leases, and the
+# ida-nexus supplies GUI discovery, shared idalib workers, leases, and the
# execute_python/ida-domain database surface.
dependencies = [
- "ida-codemode>=0.5.3",
+ "ida-nexus>=0.7.0",
"textual>=8",
"pygments>=2", # Used directly for pseudocode highlighting.
]
@@ -29,3 +29,4 @@ build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["idatui"]
+
diff --git a/tests/_fixtures.py b/tests/_fixtures.py
index 16192fa..bc10235 100644
--- a/tests/_fixtures.py
+++ b/tests/_fixtures.py
@@ -133,7 +133,7 @@ async def build_pristine(binary: str, cache: str, app_factory) -> None:
break
app.program.client.save_database()
# Textual's headless run_test context does not reliably emit App.Unmount on
- # every platform/version; release the Code Mode lease explicitly.
+ # every platform/version; release the IDA Nexus lease explicitly.
if app.program is not None:
app.program.close()
if app.client is not None:
diff --git a/tests/run.py b/tests/run.py
index b38a707..5d552bb 100755
--- a/tests/run.py
+++ b/tests/run.py
@@ -51,8 +51,8 @@ import time
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TESTS = os.path.join(ROOT, "tests")
-#: The IDA-capable interpreter. The pilot tests need textual AND the Code Mode
-#: library in one python; the database process is Code Mode's to place.
+#: The IDA-capable interpreter. The pilot tests need textual AND the IDA Nexus
+#: library in one python; the database process is IDA Nexus's to place.
DEFAULT_PY = os.path.expanduser("~/ida-venv/bin/python")
#: Both shapes the suites print: "N passed, M failed" and "N checks, M failed".
diff --git a/tests/test_codemode_client.py b/tests/test_codemode_client.py
deleted file mode 100644
index 5ab95e2..0000000
--- a/tests/test_codemode_client.py
+++ /dev/null
@@ -1,212 +0,0 @@
-"""IDA-free contract tests for the Code Mode client adapter."""
-from __future__ import annotations
-
-import os
-import sys
-import tempfile
-from dataclasses import dataclass
-
-sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
-
-import idatui.codemode_client as module # noqa: E402
-from idatui.codemode_client import CodeModeClient, _parse_load_args # noqa: E402
-from idatui.errors import IDAToolError # noqa: E402
-
-#: Pure: fakes the DatabaseHandle, never touches IDA or the Code Mode library.
-NEEDS_IDA = False
-
-PASS = FAIL = 0
-
-
-def check(name: str, condition: bool, detail="") -> None:
- global PASS, FAIL
- if condition:
- PASS += 1
- print(f" ok {name}")
- else:
- FAIL += 1
- print(f" FAIL {name} {detail}")
-
-
-@dataclass(frozen=True)
-class FakeEntry:
- pid: int = 123
- backend: str = "gui"
- record_id: str = "123-abcdef"
- exe_path: str = ""
- idb_path: str = ""
-
-
-class FakeHandle:
- def __init__(self, path: str) -> None:
- self.connected = True
- self.instance = FakeEntry(exe_path=path, idb_path=path + ".i64")
- self.waited = None
- self.saved = 0
- self.closed = False
- self.code = ""
- self.code_timeout = None
-
- def wait_autoanalysis(self, timeout=None):
- self.waited = timeout
- return {"complete": True, "status": "complete"}
-
- def execute_python(self, code, timeout=None):
- self.code = code
- self.code_timeout = timeout
- return {"result": {"sentinel": 7}, "stdout": "", "stderr": ""}
-
- def save_database(self):
- self.saved += 1
- return {"saved": True, "idb_path": self.instance.idb_path}
-
- def close(self):
- self.connected = False
- self.closed = True
-
-
-class FakeDatabaseHandle:
- opened = None
- kwargs = None
-
- @classmethod
- def open(cls, path, **kwargs):
- cls.opened = path
- cls.kwargs = kwargs
- return FakeHandle(path)
-
-
-@dataclass(frozen=True)
-class FakeOpenOptions:
- """Stand-in for DatabaseOpenOptions when the library is not installed.
-
- Deliberately STRICT (no **kwargs): an option the adapter invents would
- raise here, and `_option_fields_are_real` checks the surviving names
- against the real dataclass wherever it is importable.
- """
-
- spawn: bool = True
- startup_timeout: float = 120.0
- output_database: str | None = None
- processor: str | None = None
- image_base: int | None = None
- file_type: str | None = None
- new_database: bool = False
-
-
-class FakeBusy(Exception):
- """Stand-in for DatabaseBusyError: `except None` is a TypeError."""
-
-
-def _open_kwargs_are_real(sent: dict):
- """(ok, detail) for the kwargs the adapter passes to DatabaseHandle.open.
-
- Skips (passes) when ida_codemode is not installed, so the file stays pure.
- """
- try:
- import inspect
- from ida_codemode import DatabaseHandle as Real
- except ImportError:
- return True, "ida_codemode not installed - signature not checked"
- accepted = set(inspect.signature(Real.open).parameters)
- unknown = sorted(set(sent) - accepted)
- return not unknown, f"open() rejects {unknown}"
-
-
-def _option_fields_are_real(options):
- """(ok, detail) for the option names the adapter fills in.
-
- The open() signature no longer names the loader options -- they moved
- inside DatabaseOpenOptions -- so the `loading_address` class of bug now
- hides there instead. Check it in the same way.
- """
- try:
- import dataclasses
- from ida_codemode import DatabaseOpenOptions as Real
- except ImportError:
- return True, "ida_codemode not installed - fields not checked"
- accepted = {field.name for field in dataclasses.fields(Real)}
- unknown = sorted({f.name for f in dataclasses.fields(options)} - accepted)
- return not unknown, f"DatabaseOpenOptions rejects {unknown}"
-
-
-def main() -> int:
- proc, base, file_type = _parse_load_args("-parm:ARMv7-M -b800000 -TRaw")
- check("legacy switches map to typed Code Mode options",
- (proc, base, file_type) == ("arm:ARMv7-M", 0x8000000, "Raw"),
- (proc, base, file_type))
- try:
- _parse_load_args("-parm -zcustom")
- except ValueError as exc:
- check("arbitrary IDA switches fail loudly", "cannot represent" in str(exc), exc)
- else:
- check("arbitrary IDA switches fail loudly", False)
-
- original = module.DatabaseHandle
- module.DatabaseHandle = FakeDatabaseHandle
- # The library's own names when it is installed; strict fakes when it is not
- # (this file must keep running under a stdlib-only python3).
- original_options = module.DatabaseOpenOptions
- original_busy = module.DatabaseBusyError
- module.DatabaseOpenOptions = original_options or FakeOpenOptions
- module.DatabaseBusyError = original_busy or FakeBusy
- try:
- with tempfile.TemporaryDirectory() as tmp:
- path = os.path.join(tmp, "sample.bin")
- with open(path, "wb") as file:
- file.write(b"sample")
- client = CodeModeClient(path, load_args="-parm:ARMv7-A -b100")
- notes = []
- client.connect(timeout=42, progress=notes.append)
- handle = client._handle
- check("connect delegates database discovery to DatabaseHandle.open",
- FakeDatabaseHandle.opened == path and handle is not None)
- options = FakeDatabaseHandle.kwargs["options"]
- check("typed loader options cross the dependency boundary",
- options.processor == "arm:ARMv7-A"
- and options.image_base == 0x1000,
- options)
- check("every open option exists in the real library",
- *_option_fields_are_real(options))
- # A fake that swallows **kwargs cannot catch a keyword the real
- # library does not have -- which is exactly how this port shipped
- # `loading_address` (the real name is `image_base`) and would have
- # raised TypeError on the very first connect. Check the names we
- # send against the real signature whenever it is importable.
- check("every open() keyword exists in the real library",
- *_open_kwargs_are_real(FakeDatabaseHandle.kwargs))
- check("connect waits for Code Mode autoanalysis",
- handle.waited == 42, getattr(handle, "waited", None))
- check("progress distinguishes discovery and backend attachment",
- len(notes) == 2 and "gui" in notes[-1], notes)
- result = client.invoke("list_funcs", queries=[{"offset": 0, "count": 2}])
- check("invoke returns execute_python's result", result == {"sentinel": 7}, result)
- check("operation scripts use the preloaded ida-domain database",
- "db.functions.get_all()" in handle.code, handle.code[:200])
- check("health exposes registry identity",
- client.health()["record_id"] == "123-abcdef")
- client.save_database()
- check("save uses the public Code Mode save route", handle.saved == 1)
- client.close()
- check("close releases only the handle lease", handle.closed)
- check("GUI lifetime is never claimed by the client",
- client.wait_released(0) is False)
- finally:
- module.DatabaseHandle = original
- module.DatabaseOpenOptions = original_options
- module.DatabaseBusyError = original_busy
-
- client = CodeModeClient(__file__)
- try:
- client.invoke("not-an-operation")
- except IDAToolError as exc:
- check("unknown adapter operations are explicit", exc.tool == "not-an-operation")
- else:
- check("unknown adapter operations are explicit", False)
-
- print(f"\n{PASS} passed, {FAIL} failed")
- return 1 if FAIL else 0
-
-
-if __name__ == "__main__":
- raise SystemExit(main())
diff --git a/tests/test_kittygfx.py b/tests/test_kittygfx.py
index b12817d..38d361c 100644
--- a/tests/test_kittygfx.py
+++ b/tests/test_kittygfx.py
@@ -4,11 +4,16 @@
The splash re-anchors itself on every progress note, so the escape it sends has
to be a REPLACEMENT, not another copy. That is one key (``p``) and it is
invisible in every screenshot, which is exactly why it needs a test.
+
+Also the cross-platform contract: graphics are optional everywhere, so a
+missing ``termios`` (native Windows) or a failing terminal probe must disable
+the splash, never prevent TUI startup.
"""
#: pure stdlib escape-construction checks; no IDA, no Textual.
#: Read by tests/run.py (--fast skips every NEEDS_IDA file).
NEEDS_IDA = False
+import builtins
import os
import re
import sys
@@ -58,7 +63,61 @@ def keys(cmd):
return dict(kv.split("=", 1) for kv in cmd.split(",") if "=" in kv)
+def t_no_termios_falls_back():
+ """Native Windows has no termios; the splash must simply use ANSI art."""
+ original_import = builtins.__import__
+
+ def without_termios(name, *args, **kwargs):
+ if name == "termios":
+ raise ModuleNotFoundError("No module named 'termios'")
+ return original_import(name, *args, **kwargs)
+
+ builtins.__import__ = without_termios
+ try:
+ check("missing termios disables graphics", kittygfx._query_tty(0) is False)
+ except Exception as exc: # the original Windows startup crash
+ check("missing termios does not escape", False,
+ f"{type(exc).__name__}: {exc}")
+ finally:
+ builtins.__import__ = original_import
+
+
+def t_probe_failure_is_never_fatal():
+ """Even an unexpected platform/probe error cannot prevent TUI startup."""
+ original_query = kittygfx._query_tty
+ original_stdout = sys.__stdout__
+ original_supported = kittygfx._supported
+ old_env = os.environ.pop("IDATUI_KITTY", None)
+
+ class FakeTty:
+ def isatty(self):
+ return True
+
+ def broken_query():
+ raise RuntimeError("terminal API failed")
+
+ try:
+ sys.__stdout__ = FakeTty()
+ kittygfx._query_tty = broken_query
+ kittygfx._supported = None
+ check("probe exception disables graphics", kittygfx.supported() is False)
+ check("failed result is cached", kittygfx.supported() is False)
+ except Exception as exc:
+ check("probe exception does not escape", False,
+ f"{type(exc).__name__}: {exc}")
+ finally:
+ kittygfx._query_tty = original_query
+ kittygfx._supported = original_supported
+ sys.__stdout__ = original_stdout
+ if old_env is not None:
+ os.environ["IDATUI_KITTY"] = old_env
+
+
def main() -> int:
+ # -- graphics stay optional on every platform ---------------------------- #
+ t_no_termios_falls_back()
+ t_probe_failure_is_never_fatal()
+
kittygfx._uploaded[kittygfx.LOGO_ID] = (768, 801) # pretend it's uploaded
# -- the bug: anonymous placements STACK ------------------------------- #
diff --git a/tests/test_launch.py b/tests/test_launch.py
index d57ee5f..35a0b8d 100644
--- a/tests/test_launch.py
+++ b/tests/test_launch.py
@@ -3,12 +3,12 @@
The old `_sweep_locks` deleted `.id0/.id1/.id2/.nam/.til` next to the user's
binary when a database failed to open. That was only defensible while the TUI
-exclusively owned a private worker; under Code Mode a GUI or another client may
+exclusively owned a private worker; under IDA Nexus a GUI or another client may
own the database, so the sweep is gone. Its tests are replaced by one that keeps
it gone -- deleting a shared database's working files is unrecoverable, and this
is the cheapest guard against someone reintroducing the "helpful" cleanup.
-Pure: no IDA, no Code Mode library, no Textual.
+Pure: no IDA, no IDA Nexus library, no Textual.
"""
from __future__ import annotations
@@ -47,7 +47,7 @@ def touch(*paths):
def t_no_lock_sweeping():
"""The launcher must not delete database working files any more.
- Code Mode's registry locks, health probes and IDA itself arbitrate database
+ IDA Nexus's registry locks, health probes and IDA itself arbitrate database
ownership now. A sweep here would delete files out from under a live GUI.
"""
check("_sweep_locks is gone", not hasattr(launch, "_sweep_locks"))
diff --git a/tests/test_nexus_client.py b/tests/test_nexus_client.py
new file mode 100644
index 0000000..4ebf753
--- /dev/null
+++ b/tests/test_nexus_client.py
@@ -0,0 +1,440 @@
+"""IDA-free contract tests for the IDA Nexus client adapter."""
+
+from __future__ import annotations
+
+import os
+import queue
+import sys
+import threading
+import time
+import tempfile
+from dataclasses import dataclass
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+from idatui.errors import IDAConnectionError, IDAToolError # noqa: E402
+from idatui import remote_ops # noqa: E402
+import idatui.nexus_client as module # noqa: E402
+from idatui.nexus_client import NexusClient, _parse_load_args # noqa: E402
+
+#: Pure: fakes the DatabaseHandle, never touches IDA or the IDA Nexus library.
+NEEDS_IDA = False
+
+PASS = FAIL = 0
+
+
+def check(name: str, condition: bool, detail="") -> None:
+ global PASS, FAIL
+ if condition:
+ PASS += 1
+ print(f" ok {name}")
+ else:
+ FAIL += 1
+ print(f" FAIL {name} {detail}")
+
+
+@dataclass(frozen=True)
+class FakeEntry:
+ pid: int = 123
+ backend: str = "gui"
+ record_id: str = "123-abcdef"
+ exe_path: str = ""
+ idb_path: str = ""
+ managed: bool = False
+
+
+_CLOSED = object()
+
+
+class FakeSubscription:
+ def __init__(self) -> None:
+ self._queue: queue.Queue = queue.Queue()
+ self.closed = False
+
+ def __iter__(self):
+ return self
+
+ def __next__(self):
+ item = self._queue.get()
+ if isinstance(item, BaseException):
+ raise item
+ if item is _CLOSED:
+ raise StopIteration
+ return item
+
+ def emit(self, event: dict) -> None:
+ self._queue.put(event)
+
+ def close(self) -> None:
+ if not self.closed:
+ self.closed = True
+ self._queue.put(_CLOSED)
+
+
+class FakeHandle:
+ def __init__(self, path: str) -> None:
+ self.connected = True
+ self.instance = FakeEntry(exe_path=path, idb_path=path + ".i64")
+ self.waited = None
+ self.saved = 0
+ self.closed = False
+ self.code = ""
+ self.codes = []
+ self.code_timeout = None
+ self.operation_label = None
+ self.event_origin_id = "fake-handle-origin"
+ self.owns_checks = 0
+ self.subscription = FakeSubscription()
+ self.shutdown_calls = []
+ self.shutdown_error = None
+
+ def wait_autoanalysis(self, timeout=None):
+ self.waited = timeout
+ return {"complete": True, "status": "complete"}
+
+ def execute_python(
+ self,
+ code,
+ timeout=None,
+ *,
+ operation_id=None,
+ operation_label=None,
+ persist_globals=False,
+ filename=None,
+ ):
+ self.code = code
+ self.codes.append(code)
+ self.code_timeout = timeout
+ self.operation_label = operation_label
+ result = (
+ {
+ "__remote_ida_status__": "ok",
+ "__remote_ida_value__": {"sentinel": 7},
+ }
+ if ".modules.get(" in code
+ else True
+ )
+ return {"result": result, "stdout": "", "stderr": ""}
+
+ def subscribe_idb_events(self):
+ return self.subscription
+
+ def owns_event(self, event):
+ self.owns_checks += 1
+ return event.get("origin_id") == self.event_origin_id
+
+ def save_database(self):
+ self.saved += 1
+ return {"saved": True, "idb_path": self.instance.idb_path}
+
+ def shutdown_database(self, *, save=True):
+ self.shutdown_calls.append(save)
+ if self.shutdown_error is not None:
+ raise module.RemoteError(self.shutdown_error, self.shutdown_error, 409)
+ return {"shutting_down": True, "save": save}
+
+ def close(self):
+ self.subscription.close()
+ self.connected = False
+ self.closed = True
+
+
+class FakeDatabaseHandle:
+ opened = None
+ opens = 0
+ kwargs = None
+
+ @classmethod
+ def open(cls, path, **kwargs):
+ cls.opens += 1
+ cls.opened = path
+ cls.kwargs = kwargs
+ return FakeHandle(path)
+
+
+@dataclass(frozen=True)
+class FakeOpenOptions:
+ """Stand-in for DatabaseOpenOptions when the library is not installed.
+
+ Deliberately STRICT (no **kwargs): an option the adapter invents would
+ raise here, and `_option_fields_are_real` checks the surviving names
+ against the real dataclass wherever it is importable.
+ """
+
+ spawn: bool = True
+ startup_timeout: float = 120.0
+ output_database: str | None = None
+ processor: str | None = None
+ image_base: int | None = None
+ file_type: str | None = None
+ new_database: bool = False
+
+
+class FakeBusy(Exception):
+ """Stand-in for DatabaseBusyError: `except None` is a TypeError."""
+
+
+class FakeDisconnected(Exception):
+ """Stand-in for DatabaseDisconnectedError in stdlib-only runs."""
+
+
+def _open_kwargs_are_real(sent: dict):
+ """(ok, detail) for the kwargs the adapter passes to DatabaseHandle.open.
+
+ Skips (passes) when ida_nexus is not installed, so the file stays pure.
+ """
+ try:
+ import inspect
+ from ida_nexus import DatabaseHandle as Real
+ except ImportError:
+ return True, "ida_nexus not installed - signature not checked"
+ accepted = set(inspect.signature(Real.open).parameters)
+ unknown = sorted(set(sent) - accepted)
+ return not unknown, f"open() rejects {unknown}"
+
+
+def _option_fields_are_real(options):
+ """(ok, detail) for the option names the adapter fills in.
+
+ The open() signature no longer names the loader options -- they moved
+ inside DatabaseOpenOptions -- so the `loading_address` class of bug now
+ hides there instead. Check it in the same way.
+ """
+ try:
+ import dataclasses
+ from ida_nexus import DatabaseOpenOptions as Real
+ except ImportError:
+ return True, "ida_nexus not installed - fields not checked"
+ accepted = {field.name for field in dataclasses.fields(Real)}
+ unknown = sorted({f.name for f in dataclasses.fields(options)} - accepted)
+ return not unknown, f"DatabaseOpenOptions rejects {unknown}"
+
+
+def main() -> int:
+ proc, base, file_type = _parse_load_args("-parm:ARMv7-M -b800000 -TRaw")
+ check(
+ "legacy switches map to typed IDA Nexus options",
+ (proc, base, file_type) == ("arm:ARMv7-M", 0x8000000, "Raw"),
+ (proc, base, file_type),
+ )
+ try:
+ _parse_load_args("-parm -zcustom")
+ except ValueError as exc:
+ check("arbitrary IDA switches fail loudly", "cannot represent" in str(exc), exc)
+ else:
+ check("arbitrary IDA switches fail loudly", False)
+
+ original = module.DatabaseHandle
+ module.DatabaseHandle = FakeDatabaseHandle
+ # The library's own names when it is installed; strict fakes when it is not
+ # (this file must keep running under a stdlib-only python3).
+ original_options = module.DatabaseOpenOptions
+ original_busy = module.DatabaseBusyError
+ original_disconnected = module.DatabaseDisconnectedError
+ module.DatabaseOpenOptions = original_options or FakeOpenOptions
+ module.DatabaseBusyError = original_busy or FakeBusy
+ module.DatabaseDisconnectedError = original_disconnected or FakeDisconnected
+ try:
+ with tempfile.TemporaryDirectory() as tmp:
+ path = os.path.join(tmp, "sample.bin")
+ with open(path, "wb") as file:
+ file.write(b"sample")
+ client = NexusClient(path, load_args="-parm:ARMv7-A -b100")
+ notes = []
+ client.connect(timeout=42, progress=notes.append)
+ handle = client._handle
+ check(
+ "connect delegates database discovery to DatabaseHandle.open",
+ FakeDatabaseHandle.opened == path and handle is not None,
+ )
+ options = FakeDatabaseHandle.kwargs["options"]
+ check(
+ "typed loader options cross the dependency boundary",
+ options.processor == "arm:ARMv7-A" and options.image_base == 0x1000,
+ options,
+ )
+ check(
+ "every open option exists in the real library",
+ *_option_fields_are_real(options),
+ )
+ # A fake that swallows **kwargs cannot catch a keyword the real
+ # library does not have -- which is exactly how this port shipped
+ # `loading_address` (the real name is `image_base`) and would have
+ # raised TypeError on the very first connect. Check the names we
+ # send against the real signature whenever it is importable.
+ check(
+ "every open() keyword exists in the real library",
+ *_open_kwargs_are_real(FakeDatabaseHandle.kwargs),
+ )
+ check(
+ "connect waits for IDA Nexus autoanalysis",
+ handle.waited == 42,
+ getattr(handle, "waited", None),
+ )
+ check(
+ "progress distinguishes discovery and backend attachment",
+ len(notes) == 2 and "gui" in notes[-1],
+ notes,
+ )
+ result = client.call(
+ remote_ops.list_funcs, queries=[{"offset": 0, "count": 2}]
+ )
+ check(
+ "remote operation returns its JSON result",
+ result == {"sentinel": 7},
+ result,
+ )
+ check(
+ "operation source is real Python installed through ida-domain",
+ any("db.functions.get_all()" in code for code in handle.codes),
+ handle.codes[0][:200],
+ )
+ check(
+ "remote operations attribute IDB events to IDA TUI",
+ handle.operation_label == "IDA TUI",
+ handle.operation_label,
+ )
+ batches = []
+ delivered = threading.Event()
+
+ def changed(batch):
+ batches.append(batch)
+ delivered.set()
+
+ watcher = client.watch_idb_events(changed, debounce=0.05)
+ handle.subscription.emit({"event_name": "renamed", "origin_id": "peer-1"})
+ handle.subscription.emit(
+ {"event_name": "cmt_changed", "origin_id": "peer-2"}
+ )
+ check(
+ "event bursts produce one debounced refresh",
+ delivered.wait(1) and len(batches) == 1 and len(batches[0]) == 2,
+ batches,
+ )
+ delivered.clear()
+ handle.subscription.emit(
+ {"event_name": "renamed", "origin_id": handle.event_origin_id}
+ )
+ time.sleep(0.1)
+ check(
+ "the listener uses handle ownership to ignore its own events",
+ not delivered.is_set()
+ and len(batches) == 1
+ and handle.owns_checks >= 3,
+ (batches, handle.owns_checks),
+ )
+ handle.subscription.emit(
+ {"event_name": "byte_patched", "origin_id": "peer-3"}
+ )
+ watcher.close()
+ time.sleep(0.1)
+ check(
+ "closing drops a pending debounced refresh",
+ not delivered.is_set() and len(batches) == 1,
+ batches,
+ )
+ check(
+ "health exposes registry identity",
+ client.health()["record_id"] == "123-abcdef",
+ )
+ client.save_database()
+ check("save uses the public IDA Nexus save route", handle.saved == 1)
+ check(
+ "GUI leases transfer rather than claiming discard",
+ client.discard_database() is False and handle.shutdown_calls == [],
+ handle.shutdown_calls,
+ )
+ handle.instance = FakeEntry(
+ backend="idalib", managed=True, exe_path=path, idb_path=path + ".i64"
+ )
+ check(
+ "a final managed lease discards without saving",
+ client.discard_database() is True and handle.shutdown_calls == [False],
+ handle.shutdown_calls,
+ )
+ handle.shutdown_error = "instance_shared"
+ check(
+ "a shared managed lease transfers finalization",
+ client.discard_database() is False,
+ handle.shutdown_calls,
+ )
+ handle.shutdown_error = "instance_busy"
+ try:
+ client.discard_database(timeout=0)
+ except IDAToolError as exc:
+ check(
+ "a busy final lease never silently saves",
+ exc.tool == "shutdown_database",
+ exc,
+ )
+ else:
+ check("a busy final lease never silently saves", False)
+ handle.shutdown_error = None
+ handle.instance = FakeEntry(exe_path=path, idb_path=path + ".i64")
+ opens = FakeDatabaseHandle.opens
+ handle.connected = False
+ try:
+ client.health()
+ except IDAConnectionError as exc:
+ check(
+ "a disconnected handle requires explicit rediscovery",
+ "explicit rediscovery" in str(exc)
+ and FakeDatabaseHandle.opens == opens,
+ (exc, FakeDatabaseHandle.opens, opens),
+ )
+ else:
+ check("a disconnected handle requires explicit rediscovery", False)
+ client.close()
+ check("close releases only the handle lease", handle.closed)
+ check(
+ "GUI lifetime is never claimed by the client",
+ client.wait_released(0) is False,
+ )
+ disconnected = NexusClient(path).connect()
+ stream_errors = []
+ stream_failed = threading.Event()
+
+ def failed(error):
+ stream_errors.append(error)
+ stream_failed.set()
+
+ stream_watch = disconnected.watch_idb_events(
+ lambda _batch: None, on_error=failed, debounce=0
+ )
+ disconnected._handle.subscription.emit(
+ module.DatabaseDisconnectedError("GUI database closed")
+ )
+ check(
+ "stream disconnects become application connection errors",
+ stream_failed.wait(1)
+ and isinstance(stream_errors[0], IDAConnectionError),
+ stream_errors,
+ )
+ stream_watch.close()
+ disconnected.close()
+ finally:
+ module.DatabaseHandle = original
+ module.DatabaseOpenOptions = original_options
+ module.DatabaseBusyError = original_busy
+ module.DatabaseDisconnectedError = original_disconnected
+
+ client = NexusClient(__file__)
+
+ def unknown_operation():
+ pass
+
+ try:
+ client.call(unknown_operation)
+ except IDAToolError as exc:
+ check(
+ "unknown adapter operations are explicit",
+ exc.tool == "unknown_operation",
+ )
+ else:
+ check("unknown adapter operations are explicit", False)
+
+ print(f"\n{PASS} passed, {FAIL} failed")
+ return 1 if FAIL else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/test_pool.py b/tests/test_pool.py
index 9fc1446..37058bf 100644
--- a/tests/test_pool.py
+++ b/tests/test_pool.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-"""Unit tests for idatui.pool (Code Mode lease residency and LRU budget).
+"""Unit tests for idatui.pool (IDA Nexus lease residency and LRU budget).
A fake client keeps the policy testable without IDA or Textual.
@@ -31,13 +31,16 @@ def check(name, cond, detail=""):
class FakeClient:
- """Stands in for a CodeModeClient lease and records saves/closes."""
+ """Stands in for a NexusClient lease and records saves/closes."""
- def __init__(self, ref, mem=100, backend="idalib"):
+ def __init__(self, ref, mem=100, backend="idalib",
+ discardable=True):
self.ref = ref
self.mem = mem
self.backend = backend
self.saved = 0
+ self.discarded = 0
+ self.discardable = discardable
self.closed = False
self.connected = False
@@ -49,6 +52,10 @@ class FakeClient:
self.saved += 1
return {"saved": True}
+ def discard_database(self):
+ self.discarded += 1
+ return self.discardable
+
def close(self, grace=None):
self.closed = True
@@ -153,6 +160,36 @@ def main() -> int:
except KeyError:
check("an unknown label raises KeyError", True)
+ # -- discard delegates shared/GUI finalization ------------------------ #
+ discard_made = {}
+
+ def spawn_discard(ref, ttl):
+ client = FakeClient(
+ ref, discardable=ref.label != "bin1")
+ discard_made[ref.label] = client
+ return client
+
+ discard_pool = DatabasePool(
+ proj, spawn=spawn_discard, mem_fn=lambda c: c.mem)
+ discard_pool.get("bin0")
+ discard_pool.get("bin1")
+ delegated = discard_pool.discard_changes(["bin0", "bin1"])
+ check("discard asks every dirty resident database",
+ discard_made["bin0"].discarded == 1
+ and discard_made["bin1"].discarded == 1,
+ {k: c.discarded for k, c in discard_made.items()})
+ check("discard reports leases whose finalization transferred",
+ delegated == ["bin1"], delegated)
+ old = discard_made["bin0"]
+ replacement = FakeClient(proj.by_label("bin0"))
+ check("replace_client refuses a stale lease generation",
+ discard_pool.replace_client("bin0", object(), replacement) is False
+ and discard_pool.get("bin0") is old)
+ check("replace_client installs the reattached lease",
+ discard_pool.replace_client("bin0", old, replacement) is True
+ and discard_pool.get("bin0") is replacement)
+ discard_pool.close_all(save=False)
+
# -- default budget comes from the project's memory_pct ------------------- #
pool3 = DatabasePool(proj, spawn=spawn, mem_fn=lambda c: c.mem)
check("default budget is derived, not a fixed lease count",
diff --git a/tests/test_project.py b/tests/test_project.py
index 690f360..bae0802 100644
--- a/tests/test_project.py
+++ b/tests/test_project.py
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""Unit tests for idatui.project (the multi-binary project model + staging).
-IDA-free: exercises staging plus Code Mode ownership checks without opening a database.
+IDA-free: exercises staging plus IDA Nexus ownership checks without opening a database.
python tests/test_project.py
"""
diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py
index c08dc96..190777c 100644
--- a/tests/test_scenarios.py
+++ b/tests/test_scenarios.py
@@ -31,16 +31,34 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _fixtures import fast_keys, staged # noqa: E402
-fast_keys() # ~85ms -> ~2ms per keypress; see _fixtures.fast_keys
+fast_keys() # ~85ms -> ~2ms per keypress; see _fixtures.fast_keys
+from idatui import remote_ops # noqa: E402
from idatui.app import ( # noqa: E402
- ConfirmScreen, DecompView, FunctionsPanel, GraphView, HexView, IdaTui,
- HelpScreen, ListingView, QuitScreen, SearchPalette, StringsPalette,
- StructEditor, SymbolPalette, XrefsScreen, _HELP, _str_display,
+ ConfirmScreen,
+ DecompView,
+ FunctionsPanel,
+ GraphView,
+ HexView,
+ IdaTui,
+ HelpScreen,
+ ListingView,
+ QuitScreen,
+ SearchPalette,
+ StringsPalette,
+ StructEditor,
+ SymbolPalette,
+ XrefsScreen,
+ _HELP,
+ _str_display,
_word_occurrences,
)
from idatui.errors import IDAToolError # noqa: E402
from textual.widgets import ( # noqa: E402
- DataTable, Input, OptionList, Static, TextArea,
+ DataTable,
+ Input,
+ OptionList,
+ Static,
+ TextArea,
)
from rich.text import Text # noqa: E402
from idatui._sync import settle, wait_for # noqa: E402
@@ -86,8 +104,10 @@ class _Profile:
return
tp, tw = sum(self.paused.values()), sum(self.waited.values())
tk = sum(self.pressed.values())
- print(f"\nprofile: {tp:.1f}s settling, {tw:.1f}s in waits, "
- f"{tk:.1f}s in keystrokes")
+ print(
+ f"\nprofile: {tp:.1f}s settling, {tw:.1f}s in waits, "
+ f"{tk:.1f}s in keystrokes"
+ )
rows = sorted(self.paused.items(), key=lambda kv: -kv[1])[:8]
for name, secs in rows:
print(f" pause {secs:5.2f}s {name}")
@@ -98,8 +118,10 @@ class _Profile:
for name, secs in rows:
print(f" keys {secs:5.2f}s {name}")
for name, secs in self.expired:
- print(f" EXPIRED wait {secs:5.2f}s in {name} "
- f"(the check after it may have passed vacuously)")
+ print(
+ f" EXPIRED wait {secs:5.2f}s in {name} "
+ f"(the check after it may have passed vacuously)"
+ )
PROFILE = _Profile()
@@ -113,6 +135,7 @@ def scenario(name):
def deco(fn):
SCENARIOS.append((name, fn))
return fn
+
return deco
@@ -149,8 +172,12 @@ class Ctx:
async def wait(self, pred, t=20.0, step=0.02):
t0 = asyncio.get_event_loop().time()
ok = await wait_for(pred, self.pilot.pause, t, step)
- PROFILE.wait(self.scenario, asyncio.get_event_loop().time() - t0, ok,
- sys._getframe(1).f_lineno)
+ PROFILE.wait(
+ self.scenario,
+ asyncio.get_event_loop().time() - t0,
+ ok,
+ sys._getframe(1).f_lineno,
+ )
return ok
async def press(self, *keys):
@@ -293,8 +320,9 @@ class Ctx:
# _cur.ea to match: re-opening a function we already navigated to (its
# _cur.ea is stale-true) schedules an async re-navigation, and proceeding
# before it lands would leave the cursor parked wherever we last were.
- await self.wait(lambda: self.lst.total > 0
- and self.lst._cursor_ea() == fn.addr, t)
+ await self.wait(
+ lambda: self.lst.total > 0 and self.lst._cursor_ea() == fn.addr, t
+ )
if view == "decomp":
# F5/Tab only decompiles from a focused code pane, and the listing
# may still be settling from the open above — a swallowed Tab used to
@@ -304,8 +332,12 @@ class Ctx:
self.lst.focus()
await self.pause(0.05)
await self.press("tab")
- if await self.wait(lambda: self.app._active == "decomp"
- and self.dec.loaded_ea == fn.addr, max(t / 3, 5)):
+ if await self.wait(
+ lambda: (
+ self.app._active == "decomp" and self.dec.loaded_ea == fn.addr
+ ),
+ max(t / 3, 5),
+ ):
break
return fn
@@ -332,7 +364,8 @@ class Ctx:
# Load is done when the index is complete (robust vs the status line,
# which startup auto-land immediately overwrites with the landed fn).
await self.wait(
- lambda: app._func_index is not None and app._func_index.complete, 60)
+ lambda: app._func_index is not None and app._func_index.complete, 60
+ )
if app._func_index is not None and not app._func_index.complete:
app._func_index.load_all()
@@ -359,7 +392,7 @@ class Ctx:
app._pref = "decomp"
if app._active in ("hex", "graph"):
app._active = "decomp"
- app._graph_sticky = False # else every later scenario rebuilds a graph
+ app._graph_sticky = False # else every later scenario rebuilds a graph
app._split = False
await self.pause(0.02)
@@ -369,15 +402,26 @@ class Ctx:
# --------------------------------------------------------------------------- #
@scenario("startup")
async def s_startup(c: Ctx):
- c.check("function list populated", c.table.row_count > 0, f"rows={c.table.row_count}")
+ c.check(
+ "function list populated", c.table.row_count > 0, f"rows={c.table.row_count}"
+ )
left = c.app.query_one("#left")
- c.check("names pane starts hidden (overlay-first)", not left.display,
- f"display={left.display}")
+ c.check(
+ "names pane starts hidden (overlay-first)",
+ not left.display,
+ f"display={left.display}",
+ )
await c.reveal_pane()
- c.check("function pane width is capped (doesn't eat the screen)",
- left.size.width <= 44, f"width={left.size.width}")
- c.check("function load completed (index complete)",
- c.app._func_index is not None and c.app._func_index.complete, c.status())
+ c.check(
+ "function pane width is capped (doesn't eat the screen)",
+ left.size.width <= 44,
+ f"width={left.size.width}",
+ )
+ c.check(
+ "function load completed (index complete)",
+ c.app._func_index is not None and c.app._func_index.complete,
+ c.status(),
+ )
print(f" {c.table.row_count} functions loaded")
@@ -398,13 +442,18 @@ async def s_auto_land(c: Ctx):
await c.pause(0.2)
if fn is not None:
await c.wait(lambda: app._cur is not None and app._cur.ea == fn.addr, 20)
- c.check("auto-land jumps to the entry function (main) when present",
- app._cur is not None and app._cur.ea == fn.addr,
- f"entry={fn.name}@{fn.addr:#x} cur={app._cur}")
+ c.check(
+ "auto-land jumps to the entry function (main) when present",
+ app._cur is not None and app._cur.ea == fn.addr,
+ f"entry={fn.name}@{fn.addr:#x} cur={app._cur}",
+ )
else:
await c.wait(lambda: isinstance(app.screen, SymbolPalette), 10)
- c.check("auto-land pops the symbol picker when there's no entry fn",
- isinstance(app.screen, SymbolPalette), f"screen={app.screen}")
+ c.check(
+ "auto-land pops the symbol picker when there's no entry fn",
+ isinstance(app.screen, SymbolPalette),
+ f"screen={app.screen}",
+ )
app.pop_screen()
# guard fires once: a second call is a no-op
prev = app._cur
@@ -437,17 +486,23 @@ async def s_segment_index(c: Ctx):
c.check("segment streamed for comparison", False)
return
- idx = app.program.client.invoke("segment_index", addr=hex(model.seg_start))
- c.check("segment_index counts exactly what streaming produced",
- idx.get("rows") == len(model),
- f"index={idx.get('rows')} streamed={len(model)}")
- c.check("it reports the same segment",
- int(str(idx.get("addr")), 16) == model.seg_start,
- f"{idx.get('addr')} vs {model.seg_start:#x}")
+ idx = app.program.client.call(remote_ops.segment_index, addr=hex(model.seg_start))
+ c.check(
+ "segment_index counts exactly what streaming produced",
+ idx.get("rows") == len(model),
+ f"index={idx.get('rows')} streamed={len(model)}",
+ )
+ c.check(
+ "it reports the same segment",
+ int(str(idx.get("addr")), 16) == model.seg_start,
+ f"{idx.get('addr')} vs {model.seg_start:#x}",
+ )
anchors = idx.get("anchors") or []
- c.check("anchors cover the segment",
- len(anchors) >= max(1, len(model) // 500),
- f"{len(anchors)} anchors for {len(model)} rows")
+ c.check(
+ "anchors cover the segment",
+ len(anchors) >= max(1, len(model) // 500),
+ f"{len(anchors)} anchors for {len(model)} rows",
+ )
# Every anchor must name the address of the row it claims, or seeking to it
# would land somewhere else entirely.
@@ -456,8 +511,11 @@ async def s_segment_index(c: Ctx):
h = model.get(row)
if h is None or h.ea != int(str(ea), 16):
bad.append((row, ea, hex(h.ea) if h else None))
- c.check("every anchor points at the row it claims", not bad,
- f"{len(bad)} wrong, first={bad[:2]}")
+ c.check(
+ "every anchor points at the row it claims",
+ not bad,
+ f"{len(bad)} wrong, first={bad[:2]}",
+ )
# A model built from the index must be INDISTINGUISHABLE from a streamed
# one. That is the invariant the whole optimisation rests on: _prime builds
@@ -471,6 +529,7 @@ async def s_segment_index(c: Ctx):
# rename and define things, so that model describes the database as it was
# at boot, not as it is now.
from idatui.domain import ListingModel # noqa: PLC0415
+
args = (app.program, model.seg_start, model.seg_end, model.name)
idx_model, streamed = ListingModel(*args), ListingModel(*args)
if not idx_model.build_from_index():
@@ -480,16 +539,30 @@ async def s_segment_index(c: Ctx):
if streamed.load_next_page(text=False) == 0:
break
c.check("an index-built model is complete immediately", idx_model.complete)
- c.check("index-built model has the streamed row count",
- len(idx_model) == len(streamed), f"{len(idx_model)} vs {len(streamed)}")
- for field in ("_row_at", "_head_eas", "_by_ea", "_page_head",
- "_page_addr", "_page_rows"):
+ c.check(
+ "index-built model has the streamed row count",
+ len(idx_model) == len(streamed),
+ f"{len(idx_model)} vs {len(streamed)}",
+ )
+ for field in (
+ "_row_at",
+ "_head_eas",
+ "_by_ea",
+ "_page_head",
+ "_page_addr",
+ "_page_rows",
+ ):
a, b = getattr(idx_model, field), getattr(streamed, field)
- c.check(f"index-built {field} matches streaming", a == b,
- f"len {len(a)} vs {len(b)}")
- c.check("index-built rows carry the same ea/kind/size",
- [(h.ea, h.kind, h.size) for h in idx_model._heads]
- == [(h.ea, h.kind, h.size) for h in streamed._heads])
+ c.check(
+ f"index-built {field} matches streaming",
+ a == b,
+ f"len {len(a)} vs {len(b)}",
+ )
+ c.check(
+ "index-built rows carry the same ea/kind/size",
+ [(h.ea, h.kind, h.size) for h in idx_model._heads]
+ == [(h.ea, h.kind, h.size) for h in streamed._heads],
+ )
@scenario("reprime_is_free")
@@ -521,7 +594,7 @@ async def s_reprime_is_free(c: Ctx):
type(client).invoke = counting
try:
- for _ in range(3): # decomp and back, three times
+ for _ in range(3): # decomp and back, three times
await c.press("tab")
await c.pause(0.05)
await c.press("tab")
@@ -530,8 +603,11 @@ async def s_reprime_is_free(c: Ctx):
type(client).invoke = original
rebuilds = seen.count("segment_index")
- c.check("switching views never rebuilds the segment index", rebuilds == 0,
- f"segment_index called {rebuilds}x during 3 view switches: {seen}")
+ c.check(
+ "switching views never rebuilds the segment index",
+ rebuilds == 0,
+ f"segment_index called {rebuilds}x during 3 view switches: {seen}",
+ )
@scenario("skeleton_pages")
@@ -561,8 +637,11 @@ async def s_skeleton_pages(c: Ctx):
if not model.complete or len(model) < 1200:
# A target smaller than _prime's horizon has no skeleton pages at all,
# so there is nothing to check rather than something broken.
- c.check("segment is big enough to have skeleton pages", True,
- f"skipped: only {len(model)} rows, _prime renders ~1000")
+ c.check(
+ "segment is big enough to have skeleton pages",
+ True,
+ f"skipped: only {len(model)} rows, _prime renders ~1000",
+ )
return
c.check("pages were loaded as skeletons", model._skeleton is True)
@@ -570,24 +649,32 @@ async def s_skeleton_pages(c: Ctx):
deep = max(1200, len(model) - 40)
for row in (1200, len(model) // 2, deep):
h = model.get(row)
- c.check(f"row {row} of a skeleton page has real text",
- h is not None and bool((h.text or "").strip()),
- f"ea={getattr(h, 'ea', None)} text={getattr(h, 'text', None)!r}")
+ c.check(
+ f"row {row} of a skeleton page has real text",
+ h is not None and bool((h.text or "").strip()),
+ f"ea={getattr(h, 'ea', None)} text={getattr(h, 'text', None)!r}",
+ )
# And through the render path the user actually sees, not just the model.
lv.cursor = deep
lv.refresh()
await c.pause(0.1)
painted = lv._line_plain(deep)
- c.check("a deep row RENDERS with text",
- bool(painted and painted.strip()), f"painted={painted!r}")
+ c.check(
+ "a deep row RENDERS with text",
+ bool(painted and painted.strip()),
+ f"painted={painted!r}",
+ )
# Materialising must not change the row count or move any address: the
# skeleton's structure is what the scrollbar was sized from.
before = len(model)
model.get(deep)
- c.check("materialising a page does not change the row count",
- len(model) == before, f"{before} -> {len(model)}")
+ c.check(
+ "materialising a page does not change the row count",
+ len(model) == before,
+ f"{before} -> {len(model)}",
+ )
c.check("the walk was not disturbed", not model.stale_structure)
@@ -615,41 +702,58 @@ async def s_palette_paging(c: Ctx):
# below would pass vacuously against a zero-height page.
await c.wait(lambda: ol.scrollable_content_region.height >= 1, 10)
page = ol.scrollable_content_region.height
- c.check("the palette list has a real viewport to page by", page >= 1,
- f"height={page}")
+ c.check(
+ "the palette list has a real viewport to page by", page >= 1, f"height={page}"
+ )
if ol.option_count <= 2:
c.check("enough symbols to page through", False, f"n={ol.option_count}")
return
- c.check("the filter Input holds focus (so the list never sees the key)",
- pal.focused is inp, f"focused={type(pal.focused).__name__}")
+ c.check(
+ "the filter Input holds focus (so the list never sees the key)",
+ pal.focused is inp,
+ f"focused={type(pal.focused).__name__}",
+ )
ol.highlighted = 0
await c.press("pagedown")
down = ol.highlighted or 0
# A page, not a line: the bug this guards against is PgDn falling through to
# the Input and moving nothing, or degrading to a single-step cursor move.
- c.check("PgDn moves the symbol list by more than one row", down > 1,
- f"highlighted={down} page={page} n={ol.option_count}")
- c.check("PgDn moves by about a viewport (or lands on the last row)",
- down >= min(page, ol.option_count - 1) - 1,
- f"highlighted={down} page={page} n={ol.option_count}")
+ c.check(
+ "PgDn moves the symbol list by more than one row",
+ down > 1,
+ f"highlighted={down} page={page} n={ol.option_count}",
+ )
+ c.check(
+ "PgDn moves by about a viewport (or lands on the last row)",
+ down >= min(page, ol.option_count - 1) - 1,
+ f"highlighted={down} page={page} n={ol.option_count}",
+ )
await c.press("pageup")
- c.check("PgUp comes back to the top", (ol.highlighted or 0) == 0,
- f"highlighted={ol.highlighted}")
+ c.check(
+ "PgUp comes back to the top",
+ (ol.highlighted or 0) == 0,
+ f"highlighted={ol.highlighted}",
+ )
# Clamping: hammering past the end must settle on the last row, not wrap or
# raise. 12 pages clears any list this palette will show.
for _ in range(12):
await c.press("pagedown")
- c.check("PgDn clamps at the last row",
- ol.highlighted == ol.option_count - 1,
- f"highlighted={ol.highlighted} n={ol.option_count}")
+ c.check(
+ "PgDn clamps at the last row",
+ ol.highlighted == ol.option_count - 1,
+ f"highlighted={ol.highlighted} n={ol.option_count}",
+ )
for _ in range(12):
await c.press("pageup")
- c.check("PgUp clamps at the first row", ol.highlighted == 0,
- f"highlighted={ol.highlighted}")
+ c.check(
+ "PgUp clamps at the first row",
+ ol.highlighted == 0,
+ f"highlighted={ol.highlighted}",
+ )
await c.press("escape")
await c.wait(lambda: not isinstance(app.screen, SymbolPalette), 10)
@@ -666,19 +770,28 @@ async def s_xrefs_paging(c: Ctx):
await c.open_biggest("listing")
await c.press("x")
if not await c.wait(lambda: isinstance(app.screen, XrefsScreen), 10):
- c.check("xrefs popup opens for the paging test", True,
- "skipped: no xrefs at this cursor")
+ c.check(
+ "xrefs popup opens for the paging test",
+ True,
+ "skipped: no xrefs at this cursor",
+ )
return
scr = app.screen
ol = scr.query_one(OptionList)
await c.wait(lambda: ol.scrollable_content_region.height >= 1, 10)
- c.check("the xrefs list itself has focus", scr.focused is ol,
- f"focused={type(scr.focused).__name__}")
+ c.check(
+ "the xrefs list itself has focus",
+ scr.focused is ol,
+ f"focused={type(scr.focused).__name__}",
+ )
if ol.option_count > 2:
ol.highlighted = 0
await c.press("pagedown")
- c.check("PgDn pages the xrefs list natively", (ol.highlighted or 0) > 1,
- f"highlighted={ol.highlighted} n={ol.option_count}")
+ c.check(
+ "PgDn pages the xrefs list natively",
+ (ol.highlighted or 0) > 1,
+ f"highlighted={ol.highlighted} n={ol.option_count}",
+ )
await c.press("escape")
await c.wait(lambda: not isinstance(app.screen, XrefsScreen), 10)
@@ -688,40 +801,51 @@ async def s_palette(c: Ctx):
app, pilot = c.app, c.pilot
await c.press("ctrl+n")
pal_open = await c.wait(lambda: isinstance(app.screen, SymbolPalette), 10)
- c.check("Ctrl+N opens the symbol palette", pal_open,
- f"screen={type(app.screen).__name__}")
+ c.check(
+ "Ctrl+N opens the symbol palette",
+ pal_open,
+ f"screen={type(app.screen).__name__}",
+ )
if not pal_open:
return
pal = app.screen
pinp = pal.query_one(Input)
pinp.value = "main"
await c.wait(lambda: pal._results and pal._results[0][2] == "main", 10)
- c.check("palette fuzzy-finds (top result matches the query)",
- bool(pal._results) and pal._results[0][2] == "main",
- f"top={pal._results[0][2] if pal._results else None}")
+ c.check(
+ "palette fuzzy-finds (top result matches the query)",
+ bool(pal._results) and pal._results[0][2] == "main",
+ f"top={pal._results[0][2] if pal._results else None}",
+ )
pinp.value = "eror" # scattered subsequence of 'error'
await c.wait(lambda: any(n == "error" for _, _, n in pal._results), 10)
- c.check("palette matches a fuzzy subsequence",
- any(n == "error" for _, _, n in pal._results),
- f"results={[n for _, _, n in pal._results[:4]]}")
+ c.check(
+ "palette matches a fuzzy subsequence",
+ any(n == "error" for _, _, n in pal._results),
+ f"results={[n for _, _, n in pal._results[:4]]}",
+ )
# Every other query here is lowercase, which is how a case bug hid for so
# long: the name was lowered but the query wasn't, so ONE capital matched
# nothing. Invisible on lowercase C symbols, fatal on a library that
# capitalises (PEM_read_bio found 0 of 10093 functions in libcrypto).
pinp.value = "MAIN"
await c.wait(lambda: any(n == "main" for _, _, n in pal._results), 10)
- c.check("palette matching is case-insensitive in BOTH directions",
- any(n == "main" for _, _, n in pal._results),
- f"results={[n for _, _, n in pal._results[:4]]}")
+ c.check(
+ "palette matching is case-insensitive in BOTH directions",
+ any(n == "main" for _, _, n in pal._results),
+ f"results={[n for _, _, n in pal._results[:4]]}",
+ )
pinp.value = "main"
await c.wait(lambda: pal._results and pal._results[0][2] == "main", 10)
want = pal._results[0][1]
await c.press("enter")
await c.wait(lambda: not isinstance(app.screen, SymbolPalette), 10)
await c.wait(lambda: app._cur and app._cur.ea == want, 20)
- c.check("selecting a palette entry opens that function",
- bool(app._cur) and app._cur.ea == want,
- f"cur={app._cur.ea if app._cur else None}")
+ c.check(
+ "selecting a palette entry opens that function",
+ bool(app._cur) and app._cur.ea == want,
+ f"cur={app._cur.ea if app._cur else None}",
+ )
# Re-open the function we are ALREADY standing on. That used to append an
# identical nav entry, and the extra Esc it bought popped the stack without
# changing anything on screen — a dead keypress, which is precisely what
@@ -735,8 +859,11 @@ async def s_palette(c: Ctx):
await c.press("enter")
await c.wait(lambda: not isinstance(app.screen, SymbolPalette), 10)
await c.pause(0.4)
- c.check("re-opening the current function doesn't stack a duplicate",
- len(app._nav) == depth, f"nav {depth} -> {len(app._nav)}")
+ c.check(
+ "re-opening the current function doesn't stack a duplicate",
+ len(app._nav) == depth,
+ f"nav {depth} -> {len(app._nav)}",
+ )
await c.press("ctrl+n")
await c.wait(lambda: isinstance(app.screen, SymbolPalette), 10)
await c.press("escape")
@@ -749,43 +876,57 @@ async def s_load_options(c: Ctx):
"""The dialog must never appear for a file IDA can load itself — the whole
suite runs on an ELF, so a false positive here would block every run."""
from idatui.app import LoadOptionsScreen
+
app = c.app
- c.check("no load dialog for a recognised binary",
- not isinstance(app.screen, LoadOptionsScreen),
- f"screen={type(app.screen).__name__}")
- c.check("and the app agrees it shouldn't ask",
- not app._should_ask_load_options())
+ c.check(
+ "no load dialog for a recognised binary",
+ not isinstance(app.screen, LoadOptionsScreen),
+ f"screen={type(app.screen).__name__}",
+ )
+ c.check("and the app agrees it shouldn't ask", not app._should_ask_load_options())
# The dialog itself, driven directly: it has to come back with switches the
# worker can use, and -b has to be paragraphs.
from idatui.formats import load_args, needs_load_options, sniff
- c.check("the running target sniffs as a real format",
- sniff(app._open_path) is not None and not needs_load_options(app._open_path),
- f"{sniff(app._open_path)}")
- c.check("dialog output converts a base to paragraphs",
- load_args("arm", 0x8000000) == "-parm -b800000")
+
+ c.check(
+ "the running target sniffs as a real format",
+ sniff(app._open_path) is not None and not needs_load_options(app._open_path),
+ f"{sniff(app._open_path)}",
+ )
+ c.check(
+ "dialog output converts a base to paragraphs",
+ load_args("arm", 0x8000000) == "-parm -b800000",
+ )
# Tab is a PRIORITY app binding (disasm<->pseudocode), so it fired even with
# a modal up and nothing in a dialog could be tabbed to. That is why the load
# dialog's address field was unreachable — and it was broken in every other
# modal too.
from idatui.app import LoadOptionsScreen
+
app.push_screen(LoadOptionsScreen("/tmp/probe.bin", 1234))
await c.wait(lambda: isinstance(app.screen, LoadOptionsScreen), 10)
sc = app.screen
first = app.focused
await c.press("tab")
await c.pause(0.2)
- c.check("Tab moves focus inside a modal instead of toggling the view",
- app.focused is not first and isinstance(app.screen, LoadOptionsScreen),
- f"focus={getattr(app.focused, 'id', None)}")
- c.check("Tab in the load dialog lands on the address field",
- getattr(app.focused, "id", None) == "load-base",
- f"focus={getattr(app.focused, 'id', None)}")
+ c.check(
+ "Tab moves focus inside a modal instead of toggling the view",
+ app.focused is not first and isinstance(app.screen, LoadOptionsScreen),
+ f"focus={getattr(app.focused, 'id', None)}",
+ )
+ c.check(
+ "Tab in the load dialog lands on the address field",
+ getattr(app.focused, "id", None) == "load-base",
+ f"focus={getattr(app.focused, 'id', None)}",
+ )
await c.press("tab")
await c.pause(0.2)
- c.check("Tab again returns to the processor filter",
- getattr(app.focused, "id", None) == "pal-input",
- f"focus={getattr(app.focused, 'id', None)}")
+ c.check(
+ "Tab again returns to the processor filter",
+ getattr(app.focused, "id", None) == "pal-input",
+ f"focus={getattr(app.focused, 'id', None)}",
+ )
await c.press("escape")
await c.wait(lambda: not isinstance(app.screen, LoadOptionsScreen), 10)
@@ -800,6 +941,7 @@ async def s_asm_highlight(c: Ctx):
with the plain text, carried on the Head, and mapped to a style.
"""
from idatui.app import _S_SPAN
+
app = c.app
await c.open_biggest("listing")
lst = c.lst
@@ -811,30 +953,40 @@ async def s_asm_highlight(c: Ctx):
rows = [lst.model.get(i) for i in range(min(lst.model.loaded(), 400))]
rows = [h for h in rows if h is not None]
code = [h for h in rows if h.kind == "code"]
- c.check("code rows carry IDA's token spans",
- code and sum(1 for h in code if h.spans) > len(code) * 0.9,
- f"{sum(1 for h in code if h.spans)}/{len(code)} have spans")
+ c.check(
+ "code rows carry IDA's token spans",
+ code and sum(1 for h in code if h.spans) > len(code) * 0.9,
+ f"{sum(1 for h in code if h.spans)}/{len(code)} have spans",
+ )
kinds = {k for h in rows for k, _ in (h.spans or ())}
# If a tag isn't mapped it renders as body text and nothing says why, so the
# ones that carry real meaning are worth asserting explicitly.
for want in ("insn", "reg", "punct"):
c.check(f"the palette sees {want} tokens", want in kinds, f"{sorted(kinds)}")
- c.check("every span kind has a style",
- all(k in _S_SPAN for k in kinds), f"unstyled: {sorted(kinds - set(_S_SPAN))}")
+ c.check(
+ "every span kind has a style",
+ all(k in _S_SPAN for k in kinds),
+ f"unstyled: {sorted(kinds - set(_S_SPAN))}",
+ )
# Spans must describe the SAME text the row shows, or the row renders
# different characters than search/width calculations think it has.
- bad = [h for h in rows if h.spans
- and "".join(t for _k, t in h.spans) != h.text]
- c.check("spans reconstruct the row text exactly", not bad,
- f"{[(hex(h.ea), h.text) for h in bad[:2]]}")
+ bad = [h for h in rows if h.spans and "".join(t for _k, t in h.spans) != h.text]
+ c.check(
+ "spans reconstruct the row text exactly",
+ not bad,
+ f"{[(hex(h.ea), h.text) for h in bad[:2]]}",
+ )
# And the mnemonic must be the loudest thing on the line (the column you
# scan), not just any styled token.
mn = next((h for h in code if h.spans and h.spans[0][0] == "insn"), None)
- c.check("the mnemonic is the first span",
- mn is not None, f"{code[0].spans if code else None}")
+ c.check(
+ "the mnemonic is the first span",
+ mn is not None,
+ f"{code[0].spans if code else None}",
+ )
@scenario("status_names_the_file")
@@ -846,26 +998,30 @@ async def s_status_names_the_file(c: Ctx):
it belongs to.
"""
from textual.widgets import Static
+
app = c.app
await c.open_biggest("listing")
await c.pause(0.3)
name = os.path.basename(app._open_path)
status = str(app.query_one("#status", Static).render())
- c.check("the status bar names the open file",
- status.startswith(f"[{name}]"), f"{status[:60]!r} (want [{name}])")
+ c.check(
+ "the status bar names the open file",
+ status.startswith(f"[{name}]"),
+ f"{status[:60]!r} (want [{name}])",
+ )
# It must survive the messages that WRITE the status, not just the idle one.
c.lst.focus()
await c.press("down")
await c.pause(0.3)
status = str(app.query_one("#status", Static).render())
- c.check("and keeps naming it as you move",
- status.startswith(f"[{name}]"), status[:60])
+ c.check(
+ "and keeps naming it as you move", status.startswith(f"[{name}]"), status[:60]
+ )
# The function-count message used to include the module name itself, which
# would now read "[echo] echo — 128 functions".
- c.check("without saying the name twice",
- status.count(name) == 1, status[:70])
+ c.check("without saying the name twice", status.count(name) == 1, status[:70])
@scenario("command_palette")
@@ -873,10 +1029,12 @@ async def s_command_palette(c: Ctx):
app = c.app
await c.open_biggest("listing")
await c.press("ctrl+p")
- opened = await c.wait(
- lambda: type(app.screen).__name__ == "CommandPalette", 10)
- c.check("Ctrl+P opens the command palette", opened,
- f"screen={type(app.screen).__name__}")
+ opened = await c.wait(lambda: type(app.screen).__name__ == "CommandPalette", 10)
+ c.check(
+ "Ctrl+P opens the command palette",
+ opened,
+ f"screen={type(app.screen).__name__}",
+ )
if not opened:
return
inp = app.screen.query_one(Input)
@@ -884,8 +1042,9 @@ async def s_command_palette(c: Ctx):
await c.pause(0.5) # let the async search + option list settle
await c.press("enter")
landed = await c.wait(lambda: app._active == "hex", 10)
- c.check("a palette command executes (Hex view opens)", landed,
- f"active={app._active}")
+ c.check(
+ "a palette command executes (Hex view opens)", landed, f"active={app._active}"
+ )
if landed:
await c.press("backslash") # leave hex
await c.wait(lambda: app._active != "hex", 5)
@@ -894,21 +1053,32 @@ async def s_command_palette(c: Ctx):
@scenario("quit_guard")
async def s_quit_guard(c: Ctx):
app = c.app
- c.check("a clean database reports nothing unsaved", app._dirty_labels() == [],
- f"{app._dirty_labels()}")
+ c.check(
+ "a clean database reports nothing unsaved",
+ app._dirty_labels() == [],
+ f"{app._dirty_labels()}",
+ )
app._dirty = True # as an edit would
- c.check("an edited database is reported unsaved",
- len(app._dirty_labels()) == 1, f"{app._dirty_labels()}")
+ c.check(
+ "an edited database is reported unsaved",
+ len(app._dirty_labels()) == 1,
+ f"{app._dirty_labels()}",
+ )
await c.press("q")
asked = await c.wait(lambda: isinstance(app.screen, QuitScreen), 10)
- c.check("quitting with unsaved changes asks first", asked and app.is_running,
- f"screen={type(app.screen).__name__} running={app.is_running}")
+ c.check(
+ "quitting with unsaved changes asks first",
+ asked and app.is_running,
+ f"screen={type(app.screen).__name__} running={app.is_running}",
+ )
if not asked:
return
await c.press("escape")
await c.wait(lambda: not isinstance(app.screen, QuitScreen), 10)
- c.check("Esc cancels the quit and stays put",
- app.is_running and not isinstance(app.screen, QuitScreen))
+ c.check(
+ "Esc cancels the quit and stays put",
+ app.is_running and not isinstance(app.screen, QuitScreen),
+ )
# leave it clean so the rest of the suite (and teardown) isn't affected
app._dirty = False
app._save_on_exit = False
@@ -918,13 +1088,16 @@ async def s_quit_guard(c: Ctx):
async def s_help(c: Ctx):
app = c.app
st = app.query_one("#status", Static)
- c.check("the status line owns the bottom row (no footer cheatsheet)",
- st.region.y + st.region.height == app.size.height,
- f"status={st.region} screen={app.size}")
+ c.check(
+ "the status line owns the bottom row (no footer cheatsheet)",
+ st.region.y + st.region.height == app.size.height,
+ f"status={st.region} screen={app.size}",
+ )
await c.press("f1")
opened = await c.wait(lambda: isinstance(app.screen, HelpScreen), 10)
- c.check("F1 opens the key cheatsheet", opened,
- f"screen={type(app.screen).__name__}")
+ c.check(
+ "F1 opens the key cheatsheet", opened, f"screen={type(app.screen).__name__}"
+ )
if not opened:
return
cards = app.screen.query(".help-card")
@@ -932,16 +1105,26 @@ async def s_help(c: Ctx):
txt = " ".join(str(w.render()) for w in cards)
# Derived from _HELP, not hardcoded: adding a group is a normal change and
# shouldn't fail a test that only meant 'every group is rendered'.
- c.check("each key group gets its own card",
- titles == {t for t, _ in _HELP}, f"{titles}")
- c.check("it documents real bindings",
- "set type" in txt and "split view" in txt and "cross-references" in txt)
- c.check("the graph keys are documented",
- "control-flow graph" in txt and "minimap" in txt)
+ c.check(
+ "each key group gets its own card", titles == {t for t, _ in _HELP}, f"{titles}"
+ )
+ c.check(
+ "it documents real bindings",
+ "set type" in txt
+ and "split view" in txt
+ and "cross-references" in txt
+ and "refresh the current view" in txt,
+ )
+ c.check(
+ "the graph keys are documented",
+ "control-flow graph" in txt and "minimap" in txt,
+ )
body = app.screen.query_one("#help-body")
- c.check("the cards fit without a scrollbar at a normal size",
- body.virtual_size.height <= body.size.height,
- f"content={body.virtual_size.height} view={body.size.height}")
+ c.check(
+ "the cards fit without a scrollbar at a normal size",
+ body.virtual_size.height <= body.size.height,
+ f"content={body.virtual_size.height} view={body.size.height}",
+ )
await c.press("escape")
await c.wait(lambda: not isinstance(app.screen, HelpScreen), 10)
c.check("Esc closes it", not isinstance(app.screen, HelpScreen))
@@ -949,8 +1132,9 @@ async def s_help(c: Ctx):
# cheatsheet must not be reachable ONLY through F1.
await c.press("H")
opened_h = await c.wait(lambda: isinstance(app.screen, HelpScreen), 10)
- c.check("H opens the cheatsheet too", opened_h,
- f"screen={type(app.screen).__name__}")
+ c.check(
+ "H opens the cheatsheet too", opened_h, f"screen={type(app.screen).__name__}"
+ )
if opened_h:
await c.press("H")
await c.wait(lambda: not isinstance(app.screen, HelpScreen), 10)
@@ -962,39 +1146,54 @@ async def s_strings(c: Ctx):
app = c.app
await c.open_biggest("listing")
items = app.program.strings()
- c.check("program.strings() lists the binary's literals", len(items) > 3,
- f"n={len(items)}")
+ c.check(
+ "program.strings() lists the binary's literals",
+ len(items) > 3,
+ f"n={len(items)}",
+ )
if not items:
return
- c.check("strings carry addr/text/length",
- all(s.addr > 0 and s.text and s.length > 0 for s in items[:5]),
- f"first={items[0]}")
+ c.check(
+ "strings carry addr/text/length",
+ all(s.addr > 0 and s.text and s.length > 0 for s in items[:5]),
+ f"first={items[0]}",
+ )
await c.press("quotation_mark")
opened = await c.wait(lambda: isinstance(app.screen, StringsPalette), 25)
- c.check('\'"\' opens the strings browser', opened,
- f"screen={type(app.screen).__name__}")
+ c.check(
+ "'\"' opens the strings browser", opened, f"screen={type(app.screen).__name__}"
+ )
if not opened:
return
pal = app.screen
- c.check("the browser lists strings", len(pal._results) > 0,
- f"results={len(pal._results)}")
+ c.check(
+ "the browser lists strings",
+ len(pal._results) > 0,
+ f"results={len(pal._results)}",
+ )
# filter on a fragment of a real (unescaped) literal
- target = next((s for s in items
- if len(s.text) >= 6 and _str_display(s.text) == s.text), None)
+ target = next(
+ (s for s in items if len(s.text) >= 6 and _str_display(s.text) == s.text), None
+ )
if target is not None:
frag = target.text[:6]
pal.query_one(Input).value = frag
await c.pause(0.2)
- ok = (pal._results
- and all(frag.lower() in t.lower() for _, _, t in pal._results))
- c.check("filtering narrows to matching strings", bool(ok),
- f"frag={frag!r} n={len(pal._results)}")
+ ok = pal._results and all(frag.lower() in t.lower() for _, _, t in pal._results)
+ c.check(
+ "filtering narrows to matching strings",
+ bool(ok),
+ f"frag={frag!r} n={len(pal._results)}",
+ )
want = pal._results[0][1]
await c.press("enter")
await c.wait(lambda: not isinstance(app.screen, StringsPalette), 10)
landed = await c.wait(lambda: c.lst._cursor_ea() == want, 20)
- c.check("Enter jumps to the string in the unified listing", landed,
- f"cursor={c.lst._cursor_ea()} want={want:#x}")
+ c.check(
+ "Enter jumps to the string in the unified listing",
+ landed,
+ f"cursor={c.lst._cursor_ea()} want={want:#x}",
+ )
else:
await c.press("escape")
@@ -1013,31 +1212,42 @@ async def s_view_modes_all_handled(c: Ctx):
the next mode that forgets to appear somewhere.
"""
from idatui.app import ViewMode
+
app = c.app
fn = await c.open_biggest("listing")
try:
for mode in ViewMode:
app._active = mode
- app._show_active() # must not raise for any member
+ app._show_active() # must not raise for any member
await c.pause(0.05)
view = app._active_code_view()
if mode in ViewMode.code_modes():
- c.check(f"{mode.value}: _active_code_view resolves a widget",
- view is not None, f"{mode.value} -> None")
- c.check(f"{mode.value}: exactly one predicate is true",
- sum((app.is_listing, app.is_decomp,
- app.is_hex, app.is_graph)) == 1,
- f"{mode.value}: listing={app.is_listing} "
- f"decomp={app.is_decomp} hex={app.is_hex} graph={app.is_graph}")
- c.check(f"{mode.value}: in_code agrees with code_modes()",
- app.in_code == (mode in ViewMode.code_modes()),
- f"in_code={app.in_code} for {mode.value}")
- c.check("every mode is a plain string over the wire",
- all(isinstance(m, str) and m == m.value for m in ViewMode),
- str([repr(m) for m in ViewMode]))
- c.check("'disasm' is not a mode any more",
- "disasm" not in {m.value for m in ViewMode},
- str([m.value for m in ViewMode]))
+ c.check(
+ f"{mode.value}: _active_code_view resolves a widget",
+ view is not None,
+ f"{mode.value} -> None",
+ )
+ c.check(
+ f"{mode.value}: exactly one predicate is true",
+ sum((app.is_listing, app.is_decomp, app.is_hex, app.is_graph)) == 1,
+ f"{mode.value}: listing={app.is_listing} "
+ f"decomp={app.is_decomp} hex={app.is_hex} graph={app.is_graph}",
+ )
+ c.check(
+ f"{mode.value}: in_code agrees with code_modes()",
+ app.in_code == (mode in ViewMode.code_modes()),
+ f"in_code={app.in_code} for {mode.value}",
+ )
+ c.check(
+ "every mode is a plain string over the wire",
+ all(isinstance(m, str) and m == m.value for m in ViewMode),
+ str([repr(m) for m in ViewMode]),
+ )
+ c.check(
+ "'disasm' is not a mode any more",
+ "disasm" not in {m.value for m in ViewMode},
+ str([m.value for m in ViewMode]),
+ )
finally:
# Restore through a real navigation, not by poking _active back.
# _show_active() tears down split state and re-points the panes as a
@@ -1048,6 +1258,56 @@ async def s_view_modes_all_handled(c: Ctx):
await c.open(fn.addr, "listing")
+@scenario("refresh_view")
+async def s_refresh_view(c: Ctx):
+ """Ctrl+R replaces stale backing data without moving the listing."""
+ fn = await c.open_biggest("listing")
+ await c.press("down", "down", "down")
+ lst = c.lst
+ old_model = lst.model
+ old_ea = lst._cursor_ea()
+ old_top = round(lst.scroll_offset.y)
+ old_head = old_model.get(old_top) if old_model is not None else None
+ old_top_ea = getattr(old_head, "ea", None)
+
+ await c.press("ctrl+r")
+ landed = await c.wait(
+ lambda: lst.model is not old_model and lst._cursor_ea() == old_ea, 25
+ )
+ c.check("Ctrl+R rebuilds the listing model", lst.model is not old_model)
+ c.check(
+ "Ctrl+R preserves the cursor address",
+ landed,
+ f"got={lst._cursor_ea()} want={old_ea}",
+ )
+ new_top = round(lst.scroll_offset.y)
+ new_head = lst.model.get(new_top) if lst.model is not None else None
+ c.check(
+ "Ctrl+R preserves the viewport by address",
+ getattr(new_head, "ea", None) == old_top_ea,
+ f"got={getattr(new_head, 'ea', None)} want={old_top_ea}",
+ )
+
+ await c.open(fn.addr, "decomp")
+ dec = c.dec
+ dec.cursor = min(3, max(len(dec._texts) - 1, 0))
+ old_dec_cursor = dec.cursor
+ await c.press("ctrl+r")
+ refreshed = await c.wait(
+ lambda: c.app.is_decomp and dec.loaded_ea == fn.addr and not dec.loading, 25
+ )
+ c.check(
+ "Ctrl+R reloads pseudocode without changing views",
+ refreshed,
+ f"active={c.app._active} loaded={dec.loaded_ea} want={fn.addr:#x}",
+ )
+ c.check(
+ "Ctrl+R preserves the pseudocode cursor",
+ dec.cursor == old_dec_cursor,
+ f"got={dec.cursor} want={old_dec_cursor}",
+ )
+
+
@scenario("split_view")
async def s_split_view(c: Ctx):
app, lst, dec = c.app, c.lst, c.dec
@@ -1073,51 +1333,73 @@ async def s_split_view(c: Ctx):
await _split_view_body(c, app, lst, dec)
finally:
c.prog.client.invoke = _orig_call
- c.check("split view doesn't storm the worker with function lookups",
- _lookups["n"] < 500, f"{_lookups['n']} lookup_funcs calls")
+ c.check(
+ "split view doesn't storm the worker with function lookups",
+ _lookups["n"] < 500,
+ f"{_lookups['n']} lookup_funcs calls",
+ )
async def _split_view_body(c: Ctx, app, lst, dec):
await c.open_biggest("listing")
await c.press("s")
shown = await c.wait(lambda: app._split and lst.display and dec.display, 20)
- c.check("'s' enters split view (both panes shown)", shown,
- f"split={app._split} lst={lst.display} dec={dec.display}")
+ c.check(
+ "'s' enters split view (both panes shown)",
+ shown,
+ f"split={app._split} lst={lst.display} dec={dec.display}",
+ )
loaded = await c.wait(lambda: dec.loaded_ea == app._cur.ea, 25)
- c.check("split loads the pseudocode alongside the listing", loaded,
- f"loaded={dec.loaded_ea} cur={app._cur.ea if app._cur else None}")
+ c.check(
+ "split loads the pseudocode alongside the listing",
+ loaded,
+ f"loaded={dec.loaded_ea} cur={app._cur.ea if app._cur else None}",
+ )
await c.wait(lambda: "[split" in c.status(), 5)
- c.check("split view shows a split-aware status", "[split" in c.status(),
- f"status={c.status()!r}")
+ c.check(
+ "split view shows a split-aware status",
+ "[split" in c.status(),
+ f"status={c.status()!r}",
+ )
# phase 3: the rich per-line instruction map (decomp_map tool, run on the
# pilot's real worker) — verify it returns, aligns with the markers, and
# bands a whole region for a multi-instruction C line.
m = app.program.decomp_map(app._cur.ea)
c.check("decomp_map returns per-line ea sets", len(m) > 5, f"lines={len(m)}")
- aligned = sum(1 for i in range(min(len(m), len(dec._line_eas)))
- if m[i] and dec._line_eas[i] is not None
- and dec._line_eas[i] in m[i])
- c.check("decomp_map aligns with the pseudocode markers", aligned >= 3,
- f"aligned={aligned}/{len(dec._line_eas)}")
+ aligned = sum(
+ 1
+ for i in range(min(len(m), len(dec._line_eas)))
+ if m[i] and dec._line_eas[i] is not None and dec._line_eas[i] in m[i]
+ )
+ c.check(
+ "decomp_map aligns with the pseudocode markers",
+ aligned >= 3,
+ f"aligned={aligned}/{len(dec._line_eas)}",
+ )
multi = next((i for i, eas in enumerate(m) if len(eas) > 1), None)
if multi is not None:
app._split_eamap = m
- dec.focus() # the decomp must BE the driver for a decomp-driven
+ dec.focus() # the decomp must BE the driver for a decomp-driven
app._active = "decomp" # sync (else its align() re-syncs listing-driven)
dec.cursor = multi
dec._scroll_cursor_into_view() # key-nav always does; the anchor needs it
await c.pause(0.1)
app._sync_split("decomp")
await c.pause(0.1)
- c.check("a multi-instruction C line bands a region (>1 listing row)",
- len(lst._link_rows) > 1,
- f"line={multi} eas={len(m[multi])} rows={sorted(lst._link_rows)[:8]}")
+ c.check(
+ "a multi-instruction C line bands a region (>1 listing row)",
+ len(lst._link_rows) > 1,
+ f"line={multi} eas={len(m[multi])} rows={sorted(lst._link_rows)[:8]}",
+ )
lst.focus()
app._active = "listing"
await c.pause(0.05)
else:
- c.check("a multi-instruction C line bands a region (>1 listing row)",
- True, "no multi-instruction line in this function (skipped)")
+ c.check(
+ "a multi-instruction C line bands a region (>1 listing row)",
+ True,
+ "no multi-instruction line in this function (skipped)",
+ )
# listing drives: move it, the decomp band must track the covering C line
lst.focus()
for _ in range(6):
@@ -1125,48 +1407,60 @@ async def _split_view_body(c: Ctx, app, lst, dec):
await c.pause(0.2)
lea = lst._cursor_ea()
dl = dec._link_line
- c.check("listing cursor links the covering pseudocode line",
- dl is not None and lea is not None and dec._line_eas[dl] is not None
- and dec._line_eas[dl] <= lea,
- f"link_line={dl} lea={hex(lea) if lea else None}")
+ c.check(
+ "listing cursor links the covering pseudocode line",
+ dl is not None
+ and lea is not None
+ and dec._line_eas[dl] is not None
+ and dec._line_eas[dl] <= lea,
+ f"link_line={dl} lea={hex(lea) if lea else None}",
+ )
# the companion pane sits LEVEL with the driver's cursor (visual coherence):
# the linked row lands at the same viewport offset, not merely on-screen.
deep = [i for i, eas in enumerate(m) if eas][10:]
row = lst.model.ensure_ea(m[deep[0]][0]) if (deep and lst.model) else None
if row is not None and row > 12:
lst.scroll_to(y=row - 10, animate=False)
- await c.pause(0.2) # let the deferred scroll land
- lst.cursor = row # driver cursor now at viewport offset 10
+ await c.pause(0.2) # let the deferred scroll land
+ lst.cursor = row # driver cursor now at viewport offset 10
app._sync_split("listing")
- await c.pause(0.2) # let the companion's scroll land
+ await c.pause(0.2) # let the companion's scroll land
drv = lst.cursor - round(lst.scroll_offset.y)
link, top = dec._link_line, round(dec.scroll_offset.y)
# exact, modulo the unavoidable clamps (can't scroll above line 0, nor
# past the end when the pseudocode is shorter than the viewport)
- want = min(max(0, (link or 0) - drv),
- max(0, dec.total - dec._visible_height()))
- c.check("the companion pane sits level with the driver's cursor",
- link is not None and top == want,
- f"driver_row={drv} link={link} dec_top={top} want={want}")
+ want = min(max(0, (link or 0) - drv), max(0, dec.total - dec._visible_height()))
+ c.check(
+ "the companion pane sits level with the driver's cursor",
+ link is not None and top == want,
+ f"driver_row={drv} link={link} dec_top={top} want={want}",
+ )
# a PURE scroll (wheel/scrollbar) moves no cursor — it must still drag
# the companion along (anchors on the viewport once the cursor is gone)
before_cur, before_dec = lst.cursor, round(dec.scroll_offset.y)
lst.scroll_to(y=round(lst.scroll_offset.y) + 30, animate=False)
await c.pause(0.35)
- c.check("a pure scroll in the driver drags the companion along",
- lst.cursor == before_cur
- and round(dec.scroll_offset.y) != before_dec,
- f"cursor {before_cur}->{lst.cursor} "
- f"dec_top {before_dec}->{round(dec.scroll_offset.y)}")
+ c.check(
+ "a pure scroll in the driver drags the companion along",
+ lst.cursor == before_cur and round(dec.scroll_offset.y) != before_dec,
+ f"cursor {before_cur}->{lst.cursor} "
+ f"dec_top {before_dec}->{round(dec.scroll_offset.y)}",
+ )
await c.press("tab")
await c.pause(0.1)
- c.check("Tab in split focuses the pseudocode pane", app._active == "decomp",
- f"active={app._active}")
+ c.check(
+ "Tab in split focuses the pseudocode pane",
+ app._active == "decomp",
+ f"active={app._active}",
+ )
# decomp drives: put the cursor on an addressed pseudocode line (past the
# variable decls); the listing band must track the covering instruction row.
target = next((i for i, e in enumerate(dec._line_eas) if e is not None), None)
- c.check("pseudocode has addressed lines", target is not None,
- "no /*0xEA*/ markers in the pseudocode")
+ c.check(
+ "pseudocode has addressed lines",
+ target is not None,
+ "no /*0xEA*/ markers in the pseudocode",
+ )
if target is not None:
dec.cursor = target
dec._scroll_cursor_into_view()
@@ -1174,27 +1468,39 @@ async def _split_view_body(c: Ctx, app, lst, dec):
app._sync_split("decomp")
await c.pause(0.1)
want = lst.model.ensure_ea(dec._line_eas[target])
- c.check("decomp cursor links the instruction row in the listing",
- want in lst._link_rows,
- f"link_rows={sorted(lst._link_rows)[:6]} want={want}")
+ c.check(
+ "decomp cursor links the instruction row in the listing",
+ want in lst._link_rows,
+ f"link_rows={sorted(lst._link_rows)[:6]} want={want}",
+ )
# and that linked row actually paints a background band (base rows have
# no bg; `want` is a deep code row, never the listing's own cursor row)
lst.reveal(want)
await c.pause(0.05)
y = want - round(lst.scroll_offset.y)
- banded = (0 <= y < lst.size.height and any(
- s.style and s.style.bgcolor is not None for s in lst.render_line(y)))
- c.check("the linked instruction row renders a highlight band", banded,
- f"y={y} cursor_row={lst.cursor}")
+ banded = 0 <= y < lst.size.height and any(
+ s.style and s.style.bgcolor is not None for s in lst.render_line(y)
+ )
+ c.check(
+ "the linked instruction row renders a highlight band",
+ banded,
+ f"y={y} cursor_row={lst.cursor}",
+ )
await c.press("tab")
await c.pause(0.1)
- c.check("Tab again focuses the listing pane", app._active == "listing",
- f"active={app._active}")
+ c.check(
+ "Tab again focuses the listing pane",
+ app._active == "listing",
+ f"active={app._active}",
+ )
# a mouse click on the other pane also makes it the driver (not just Tab)
await c.pilot.click(DecompView, offset=(10, 5))
await c.pause(0.15)
- c.check("clicking the pseudocode pane makes it the driver",
- app._active == "decomp", f"active={app._active}")
+ c.check(
+ "clicking the pseudocode pane makes it the driver",
+ app._active == "decomp",
+ f"active={app._active}",
+ )
await c.press("tab") # restore listing as the driver
await c.pause(0.1)
# cross-function follow: the listing cursor leaving the decompiled function
@@ -1210,8 +1516,11 @@ async def _split_view_body(c: Ctx, app, lst, dec):
await c.pause(0.1)
app._sync_split("listing") # cursor now outside the decompiled fn
followed = await c.wait(lambda: dec.loaded_ea == other.addr, 25)
- c.check("listing cursor crossing into another function re-syncs the decomp",
- followed, f"dec={dec.loaded_ea} want={other.addr}")
+ c.check(
+ "listing cursor crossing into another function re-syncs the decomp",
+ followed,
+ f"dec={dec.loaded_ea} want={other.addr}",
+ )
# navigation in split keeps BOTH panes on the (new) function
nf = c.find_func(lambda f: f.addr != app._cur.ea and f.size > 80)
if nf is not None:
@@ -1219,27 +1528,42 @@ async def _split_view_body(c: Ctx, app, lst, dec):
await c.type(hex(nf.addr))
await c.press("enter")
nav = await c.wait(lambda: app._cur and app._cur.ea == nf.addr, 15)
- c.check("goto in split navigates", nav,
- f"cur={app._cur.ea if app._cur else None} want={nf.addr}")
- both = await c.wait(lambda: dec.loaded_ea == nf.addr and app._split
- and lst.display and dec.display, 25)
- c.check("split reloads both panes on navigation", both,
- f"dec={dec.loaded_ea} split={app._split}")
+ c.check(
+ "goto in split navigates",
+ nav,
+ f"cur={app._cur.ea if app._cur else None} want={nf.addr}",
+ )
+ both = await c.wait(
+ lambda: (
+ dec.loaded_ea == nf.addr and app._split and lst.display and dec.display
+ ),
+ 25,
+ )
+ c.check(
+ "split reloads both panes on navigation",
+ both,
+ f"dec={dec.loaded_ea} split={app._split}",
+ )
await c.press("s")
- gone = await c.wait(lambda: not app._split and lst.display
- and not dec.display, 10)
- c.check("'s' exits split back to a single view", gone,
- f"split={app._split} lst={lst.display} dec={dec.display}")
- c.check("exiting split clears the link bands",
- not lst._link_rows and dec._link_line is None,
- f"rows={lst._link_rows} line={dec._link_line}")
+ gone = await c.wait(lambda: not app._split and lst.display and not dec.display, 10)
+ c.check(
+ "'s' exits split back to a single view",
+ gone,
+ f"split={app._split} lst={lst.display} dec={dec.display}",
+ )
+ c.check(
+ "exiting split clears the link bands",
+ not lst._link_rows and dec._link_line is None,
+ f"rows={lst._link_rows} line={dec._link_line}",
+ )
@scenario("decomp_fallback")
async def s_fallback(c: Ctx):
app = c.app
- failing = next((f for f in reversed(c.all_funcs())
- if c.prog.decompile(f.addr).failed), None)
+ failing = next(
+ (f for f in reversed(c.all_funcs()) if c.prog.decompile(f.addr).failed), None
+ )
if failing is None:
c.check("found a decompile-failing function", False)
return
@@ -1254,15 +1578,23 @@ async def s_fallback(c: Ctx):
# was pressed, since the function was opened in the listing. It asserted
# nothing, slowly.
landed = await c.wait(lambda: _CANNOT_DECOMP in c.status().lower(), 25)
- c.check("F5/Tab on an undecompilable function says so", landed,
- f"active={app._active} status={c.status()!r}")
- c.check("F5/Tab on an undecompilable function falls back to a code view",
- app.is_listing and c.dis.display,
- f"active={app._active} status={c.status()!r}")
+ c.check(
+ "F5/Tab on an undecompilable function says so",
+ landed,
+ f"active={app._active} status={c.status()!r}",
+ )
+ c.check(
+ "F5/Tab on an undecompilable function falls back to a code view",
+ app.is_listing and c.dis.display,
+ f"active={app._active} status={c.status()!r}",
+ )
# a decompilable function F5s into pseudocode
await c.open("main", "decomp")
- c.check("a decompilable function F5s into pseudocode",
- app._active == "decomp" and c.dec.display, f"active={app._active}")
+ c.check(
+ "a decompilable function F5s into pseudocode",
+ app._active == "decomp" and c.dec.display,
+ f"active={app._active}",
+ )
@scenario("structs")
@@ -1270,95 +1602,144 @@ async def s_structs(c: Ctx):
app = c.app
await c.press("ctrl+t")
se_open = await c.wait(lambda: isinstance(app.screen, StructEditor), 10)
- c.check("Ctrl+T opens the struct editor", se_open,
- f"screen={type(app.screen).__name__}")
+ c.check(
+ "Ctrl+T opens the struct editor", se_open, f"screen={type(app.screen).__name__}"
+ )
if not se_open:
return
se = app.screen
await c.wait(lambda: bool(se._structs), 15)
- c.check("struct editor lists existing structs", len(se._structs) > 0,
- f"n={len(se._structs)}")
+ c.check(
+ "struct editor lists existing structs",
+ len(se._structs) > 0,
+ f"n={len(se._structs)}",
+ )
ta = se.query_one(TextArea)
- tname = next((s.name for s in se._structs if s.name == "timespec"),
- se._structs[0].name)
+ tname = next(
+ (s.name for s in se._structs if s.name == "timespec"), se._structs[0].name
+ )
idx = next(i for i, s in enumerate(se._structs) if s.name == tname)
se.query_one(OptionList).highlighted = idx
se.on_option_list_option_selected(type("E", (), {"option_index": idx})())
await c.wait(lambda: tname in ta.text and "{" in ta.text, 15)
- c.check("selecting a struct shows its C definition",
- tname in ta.text and "{" in ta.text, f"text={ta.text[:40]!r}")
+ c.check(
+ "selecting a struct shows its C definition",
+ tname in ta.text and "{" in ta.text,
+ f"text={ta.text[:40]!r}",
+ )
# The definition is C, so it must be coloured as C (no tree-sitter grammar
# for it: idatui.highlight fills TextArea's highlight map from Pygments).
names = {n for spans in ta._highlights.values() for _, _, n in spans}
- c.check("the C definition is syntax-highlighted",
- {"keyword", "name"} <= names, f"names={sorted(names)}")
- styled = {s.style.color.name for s in ta.render_line(0)
- if s.style and s.style.color}
- c.check("highlight styles reach the rendered line", len(styled) > 1,
- f"colors={sorted(styled)}")
+ c.check(
+ "the C definition is syntax-highlighted",
+ {"keyword", "name"} <= names,
+ f"names={sorted(names)}",
+ )
+ styled = {
+ s.style.color.name for s in ta.render_line(0) if s.style and s.style.color
+ }
+ c.check(
+ "highlight styles reach the rendered line",
+ len(styled) > 1,
+ f"colors={sorted(styled)}",
+ )
app._clipboard = ""
se.query_one(TextArea).focus()
await c.press("ctrl+y")
await c.wait(lambda: app._clipboard == ta.text, 10)
- c.check("Ctrl+Y copies the struct definition to the clipboard",
- bool(app._clipboard) and app._clipboard == ta.text,
- f"clip_len={len(app._clipboard)}")
+ c.check(
+ "Ctrl+Y copies the struct definition to the clipboard",
+ bool(app._clipboard) and app._clipboard == ta.text,
+ f"clip_len={len(app._clipboard)}",
+ )
sname = "TuiEdTest"
await c.press("ctrl+n")
await c.pause(0.05)
ta.text = f"struct {sname} {{ int a; char b[8]; }};"
await c.press("ctrl+s")
await c.wait(lambda: any(s.name == sname for s in se._structs), 15)
- c.check("Ctrl+S declares a new struct",
- any(s.name == sname for s in se._structs), "not created")
+ c.check(
+ "Ctrl+S declares a new struct",
+ any(s.name == sname for s in se._structs),
+ "not created",
+ )
await c.wait(lambda: "\n" in ta.text, 10)
- c.check("editing re-highlights the definition",
- any(n == "keyword" for spans in ta._highlights.values()
- for _, _, n in spans),
- f"rows={len(ta._highlights)}")
- c.check("save auto-formats the definition in the editor",
- ta.text.count("\n") >= 3 and f"struct {sname}" in ta.text
- and not se._is_dirty(), f"text={ta.text[:50]!r}")
+ c.check(
+ "editing re-highlights the definition",
+ any(n == "keyword" for spans in ta._highlights.values() for _, _, n in spans),
+ f"rows={len(ta._highlights)}",
+ )
+ c.check(
+ "save auto-formats the definition in the editor",
+ ta.text.count("\n") >= 3
+ and f"struct {sname}" in ta.text
+ and not se._is_dirty(),
+ f"text={ta.text[:50]!r}",
+ )
idx = next(i for i, s in enumerate(se._structs) if s.name == sname)
se.on_option_list_option_selected(type("E", (), {"option_index": idx})())
await c.wait(lambda: sname in ta.text, 10)
ta.text = f"struct {sname} {{ int a; char b[8]; long c; }};"
await c.press("ctrl+s")
- await c.wait(lambda: next((s.members for s in se._structs if s.name == sname), 0) == 3, 15)
- c.check("Ctrl+S updates an existing struct in place",
- next((s.members for s in se._structs if s.name == sname), 0) == 3,
- "member count not 3")
+ await c.wait(
+ lambda: next((s.members for s in se._structs if s.name == sname), 0) == 3, 15
+ )
+ c.check(
+ "Ctrl+S updates an existing struct in place",
+ next((s.members for s in se._structs if s.name == sname), 0) == 3,
+ "member count not 3",
+ )
ta.text = f"struct {sname} {{ int a; char b[8]; long c; int __unused; }};"
await c.press("ctrl+s")
await c.wait(lambda: "save failed" in str(se.query_one("#se-status").render()), 15)
st = str(se.query_one("#se-status").render())
- c.check("a rejected save fails loudly, naming the reserved field",
- "save failed" in st and "__unused" in st, f"status={st!r}")
- c.check("a rejected save leaves the struct unchanged",
- next((s.members for s in se._structs if s.name == sname), 0) == 3, "changed")
+ c.check(
+ "a rejected save fails loudly, naming the reserved field",
+ "save failed" in st and "__unused" in st,
+ f"status={st!r}",
+ )
+ c.check(
+ "a rejected save leaves the struct unchanged",
+ next((s.members for s in se._structs if s.name == sname), 0) == 3,
+ "changed",
+ )
c.check("a rejected save keeps your edited text", "__unused" in ta.text)
other = next(i for i, s in enumerate(se._structs) if s.name != sname)
se.on_option_list_option_selected(type("E", (), {"option_index": other})())
guard = await c.wait(lambda: isinstance(app.screen, ConfirmScreen), 10)
- c.check("unsaved edits prompt before switching structs", guard,
- f"screen={type(app.screen).__name__}")
+ c.check(
+ "unsaved edits prompt before switching structs",
+ guard,
+ f"screen={type(app.screen).__name__}",
+ )
await c.press("enter")
- await c.wait(lambda: isinstance(app.screen, StructEditor) and not se._is_dirty(), 15)
+ await c.wait(
+ lambda: isinstance(app.screen, StructEditor) and not se._is_dirty(), 15
+ )
idx = next(i for i, s in enumerate(se._structs) if s.name == sname)
se.query_one(OptionList).focus()
se.query_one(OptionList).highlighted = idx
await c.press("d")
confirmed = await c.wait(lambda: isinstance(app.screen, ConfirmScreen), 10)
- c.check("delete asks for confirmation", confirmed,
- f"screen={type(app.screen).__name__}")
+ c.check(
+ "delete asks for confirmation", confirmed, f"screen={type(app.screen).__name__}"
+ )
await c.press("enter")
await c.wait(lambda: isinstance(app.screen, StructEditor), 10)
- await c.wait(lambda: not any(s.name == sname for s in se._structs)
- or "del_type" in str(se.query_one("#se-status").render()), 15)
+ await c.wait(
+ lambda: (
+ not any(s.name == sname for s in se._structs)
+ or "del_type" in str(se.query_one("#se-status").render())
+ ),
+ 15,
+ )
st = str(se.query_one("#se-status").render())
gone = not any(s.name == sname for s in se._structs)
- c.check("confirming delete removes the struct (or reports missing tool)",
- gone or "del_type" in st, f"gone={gone} status={st!r}")
+ c.check(
+ "confirming delete removes the struct (or reports missing tool)",
+ gone or "del_type" in st,
+ f"gone={gone} status={st!r}",
+ )
se.query_one(OptionList).focus()
await c.press("escape")
await c.wait(lambda: not isinstance(app.screen, StructEditor), 10)
@@ -1376,13 +1757,15 @@ async def s_splash_scaling(c: Ctx):
it is placed in, so there was never a reason for all-or-nothing.
"""
from idatui import kittygfx
- from idatui.app import (LOGO_CHROME_ROWS, LOGO_MIN_ROWS, LoadingScreen,
- logo_cells)
+ from idatui.app import LOGO_CHROME_ROWS, LOGO_MIN_ROWS, LoadingScreen, logo_cells
app = c.app
placed: list[tuple] = []
real_supported, real_upload, real_place = (
- kittygfx.supported, kittygfx.upload, kittygfx.place)
+ kittygfx.supported,
+ kittygfx.upload,
+ kittygfx.place,
+ )
kittygfx.supported = lambda: True
kittygfx.upload = lambda *a, **k: True
kittygfx.place = lambda *a, **k: (placed.append(a), True)[1]
@@ -1397,28 +1780,41 @@ async def s_splash_scaling(c: Ctx):
await c.wait(lambda: scr._cells is not None, 5)
room = height - LOGO_CHROME_ROWS
has_image = bool(scr.query("#loading-image"))
- c.check(f"{width}x{height}: the logo is drawn, not dropped",
- has_image and room >= LOGO_MIN_ROWS,
- f"image={has_image} room={room}")
+ c.check(
+ f"{width}x{height}: the logo is drawn, not dropped",
+ has_image and room >= LOGO_MIN_ROWS,
+ f"image={has_image} room={room}",
+ )
if has_image:
cols, rows = scr._cells
- c.check(f"{width}x{height}: scaled to the room available",
- rows <= room and rows == min(room, logo_cells()[1]),
- f"cells={scr._cells} room={room} natural={logo_cells()}")
+ c.check(
+ f"{width}x{height}: scaled to the room available",
+ rows <= room and rows == min(room, logo_cells()[1]),
+ f"cells={scr._cells} room={room} natural={logo_cells()}",
+ )
await c.wait(lambda: scr.query_one("#loading-box").region.height > 0, 5)
box = scr.query_one("#loading-box").region
- c.check(f"{width}x{height}: the box is not clipped",
- box.y >= 0 and box.y + box.height <= height,
- f"box={box} screen={height}")
+ c.check(
+ f"{width}x{height}: the box is not clipped",
+ box.y >= 0 and box.y + box.height <= height,
+ f"box={box} screen={height}",
+ )
app.pop_screen()
await c.pause(0.05)
- c.check("a full-size pane still gets the artwork's natural size",
- logo_cells(999) == logo_cells(), f"{logo_cells(999)}")
- c.check("and the image was actually placed each time", len(placed) >= 3,
- f"{placed}")
+ c.check(
+ "a full-size pane still gets the artwork's natural size",
+ logo_cells(999) == logo_cells(),
+ f"{logo_cells(999)}",
+ )
+ c.check(
+ "and the image was actually placed each time", len(placed) >= 3, f"{placed}"
+ )
finally:
kittygfx.supported, kittygfx.upload, kittygfx.place = (
- real_supported, real_upload, real_place)
+ real_supported,
+ real_upload,
+ real_place,
+ )
# Every later scenario assumes the suite's own geometry.
await c.pilot.resize_terminal(140, 44)
await c.pause(0.05)
@@ -1438,33 +1834,45 @@ async def s_modal_centering(c: Ctx):
import idatui.app as A
ours = sorted(
- (n for n, v in vars(A).items()
- if isinstance(v, type) and issubclass(v, ModalScreen)
- and v is not ModalScreen and v.__module__ == A.__name__),
- key=str)
+ (
+ n
+ for n, v in vars(A).items()
+ if isinstance(v, type)
+ and issubclass(v, ModalScreen)
+ and v is not ModalScreen
+ and v.__module__ == A.__name__
+ ),
+ key=str,
+ )
c.check("found the app's modal screens", len(ours) >= 8, f"{ours}")
styles = A.IdaTui.CSS
- c.check("centring is a rule about modals, not a list of them",
- "ModalScreen { align: center middle; }" in styles,
- "the ModalScreen rule is gone")
+ c.check(
+ "centring is a rule about modals, not a list of them",
+ "ModalScreen { align: center middle; }" in styles,
+ "the ModalScreen rule is gone",
+ )
# And prove it REACHES a dialog, rather than just being present in the text.
await c.press("ctrl+f")
opened = await c.wait(lambda: isinstance(c.app.screen, A.SearchPalette), 10)
if not opened:
- c.check("the search palette opened", False,
- f"screen={type(c.app.screen).__name__}")
+ c.check(
+ "the search palette opened", False, f"screen={type(c.app.screen).__name__}"
+ )
return
scr = c.app.screen
await c.wait(lambda: scr.query_one("#pal-box").region.height > 0, 5)
box = scr.query_one("#pal-box").region
above, below = box.y, c.app.size.height - (box.y + box.height)
- c.check("the search palette is vertically centred",
- box.height > 0 and abs(above - below) <= 1,
- f"box={box} screen={c.app.size} above={above} below={below}")
+ c.check(
+ "the search palette is vertically centred",
+ box.height > 0 and abs(above - below) <= 1,
+ f"box={box} screen={c.app.size} above={above} below={below}",
+ )
left = box.x
right = c.app.size.width - (box.x + box.width)
- c.check("and horizontally centred", abs(left - right) <= 1,
- f"left={left} right={right}")
+ c.check(
+ "and horizontally centred", abs(left - right) <= 1, f"left={left} right={right}"
+ )
await c.press("escape")
await c.wait(lambda: not isinstance(c.app.screen, A.SearchPalette), 5)
@@ -1476,8 +1884,9 @@ async def s_db_search(c: Ctx):
await c.open("main", "listing")
await c.press("ctrl+f")
opened = await c.wait(lambda: isinstance(app.screen, SearchPalette), 10)
- c.check("Ctrl+F opens the search palette", opened,
- f"screen={type(app.screen).__name__}")
+ c.check(
+ "Ctrl+F opens the search palette", opened, f"screen={type(app.screen).__name__}"
+ )
if not opened:
return
pal = app.screen
@@ -1487,29 +1896,40 @@ async def s_db_search(c: Ctx):
inp.value = "endbr64"
await c.press("enter")
await c.wait(lambda: bool(pal._hits), 30)
- c.check("a text search finds instructions", len(pal._hits) > 1,
- f"n={len(pal._hits)}")
- c.check("and it was classified as text",
- pal._searched and pal._searched[0] == "text", f"{pal._searched}")
- c.check("hits carry the line they matched",
- all("endbr64" in h.line for h in pal._hits[:5]),
- [h.line for h in pal._hits[:3]])
+ c.check(
+ "a text search finds instructions", len(pal._hits) > 1, f"n={len(pal._hits)}"
+ )
+ c.check(
+ "and it was classified as text",
+ pal._searched and pal._searched[0] == "text",
+ f"{pal._searched}",
+ )
+ c.check(
+ "hits carry the line they matched",
+ all("endbr64" in h.line for h in pal._hits[:5]),
+ [h.line for h in pal._hits[:3]],
+ )
# -- text with padding: match what is SEEN, not IDA's column spacing ----- #
inp.value = "call cs:"
await c.press("enter")
found = await c.wait(lambda: pal._searched == ("text", "call cs:"), 30)
- c.check("a query spanning IDA's column padding still matches",
- found and len(pal._hits) > 0, f"n={len(pal._hits)}")
+ c.check(
+ "a query spanning IDA's column padding still matches",
+ found and len(pal._hits) > 0,
+ f"n={len(pal._hits)}",
+ )
# -- bytes: the same endbr64, as a pattern ------------------------------- #
inp.value = "f3 0f 1e fa"
await c.press("enter")
await c.wait(lambda: pal._searched and pal._searched[0] == "bytes", 30)
- c.check("a hex query is classified as bytes",
- pal._searched and pal._searched[0] == "bytes", f"{pal._searched}")
- c.check("and finds the same instruction", len(pal._hits) > 1,
- f"n={len(pal._hits)}")
+ c.check(
+ "a hex query is classified as bytes",
+ pal._searched and pal._searched[0] == "bytes",
+ f"{pal._searched}",
+ )
+ c.check("and finds the same instruction", len(pal._hits) > 1, f"n={len(pal._hits)}")
# -- wildcards ----------------------------------------------------------- #
inp.value = "f3 0f ?? fa"
@@ -1522,34 +1942,49 @@ async def s_db_search(c: Ctx):
await c.press("enter")
await c.pause(0.1)
title = str(app.screen.query_one("#pal-box").border_title)
- c.check("a malformed byte pattern is refused with a reason",
- "not a byte" in title, f"title={title!r}")
+ c.check(
+ "a malformed byte pattern is refused with a reason",
+ "not a byte" in title,
+ f"title={title!r}",
+ )
# -- F2 pins the mode against the guess ---------------------------------- #
inp.value = "dead"
await c.pause(0.05)
- c.check("a hex-looking WORD still searches text",
- pal._mode_query()[0] == "text", f"{pal._mode_query()}")
+ c.check(
+ "a hex-looking WORD still searches text",
+ pal._mode_query()[0] == "text",
+ f"{pal._mode_query()}",
+ )
await c.press("f2")
- c.check("F2 forces it to bytes", pal._mode_query()[0] == "bytes",
- f"{pal._mode_query()}")
+ c.check(
+ "F2 forces it to bytes", pal._mode_query()[0] == "bytes", f"{pal._mode_query()}"
+ )
# -- Enter on a result navigates ----------------------------------------- #
inp.value = "endbr64"
- await c.press("f2") # back to text
+ await c.press("f2") # back to text
await c.press("enter")
- await c.wait(lambda: bool(pal._hits) and pal._searched
- and pal._searched[0] == "text", 30)
+ await c.wait(
+ lambda: bool(pal._hits) and pal._searched and pal._searched[0] == "text", 30
+ )
target = pal._hits[1] if len(pal._hits) > 1 else pal._hits[0]
pal.query_one(OptionList).highlighted = 1 if len(pal._hits) > 1 else 0
await c.press("enter")
closed = await c.wait(lambda: not isinstance(app.screen, SearchPalette), 10)
- c.check("Enter on a hit closes the palette", closed,
- f"screen={type(app.screen).__name__}")
+ c.check(
+ "Enter on a hit closes the palette",
+ closed,
+ f"screen={type(app.screen).__name__}",
+ )
landed = await c.wait(
- lambda: app._cur is not None and c.lst._cursor_ea() == target.head, 30)
- c.check("and lands the cursor on it", landed,
- f"cursor={c.lst._cursor_ea()} want={target.head:#x}")
+ lambda: app._cur is not None and c.lst._cursor_ea() == target.head, 30
+ )
+ c.check(
+ "and lands the cursor on it",
+ landed,
+ f"cursor={c.lst._cursor_ea()} want={target.head:#x}",
+ )
@scenario("export_findings")
@@ -1572,8 +2007,9 @@ async def s_export_findings(c: Ctx):
await c.open(fn.addr, "listing")
# Make something to find: a rename and a comment, through the real paths.
- app.program.client.invoke(
- "rename", batch={"func": {"addr": hex(fn.addr), "name": newname}})
+ app.program.client.call(
+ remote_ops.rename, batch={"func": {"addr": hex(fn.addr), "name": newname}}
+ )
app.program.bump_names()
app.program.set_comment(fn.addr, note)
app.program.invalidate(fn.addr)
@@ -1589,8 +2025,11 @@ async def s_export_findings(c: Ctx):
inp = app.query_one("#export", Input)
opened = await c.wait(lambda: inp.display, 5)
c.check("Ctrl+E opens the export prompt", opened, f"display={inp.display}")
- c.check("the prompt is prefilled with a path beside the binary",
- inp.value == default_path(app._open_path), f"value={inp.value!r}")
+ c.check(
+ "the prompt is prefilled with a path beside the binary",
+ inp.value == default_path(app._open_path),
+ f"value={inp.value!r}",
+ )
inp.value = out
await c.press("enter")
written = await c.wait(lambda: os.path.exists(out), 30)
@@ -1598,21 +2037,26 @@ async def s_export_findings(c: Ctx):
if not written:
return
doc = open(out, encoding="utf-8").read()
- c.check("the report is markdown with the expected sections",
- doc.startswith("# Findings") and "## Comments" in doc
- and "## Named functions" in doc, doc[:60])
- c.check("a comment written this session is in it", note in doc,
- doc[:200])
- c.check("and the function it belongs to is named", newname in doc,
- doc[:200])
- c.check("the report is sourced from the journal, not a scan",
- "idatui's edit journal" in doc,
- [l for l in doc.splitlines() if "**source**" in l])
- c.check("the analyzer's own comments stay out of it",
- "switch jump" not in doc and "jumptable" not in doc,
- [l for l in doc.splitlines() if "switch" in l][:2])
- c.check("the status line says where it went",
- out in c.status(), c.status())
+ c.check(
+ "the report is markdown with the expected sections",
+ doc.startswith("# Findings")
+ and "## Comments" in doc
+ and "## Named functions" in doc,
+ doc[:60],
+ )
+ c.check("a comment written this session is in it", note in doc, doc[:200])
+ c.check("and the function it belongs to is named", newname in doc, doc[:200])
+ c.check(
+ "the report is sourced from the journal, not a scan",
+ "idatui's edit journal" in doc,
+ [l for l in doc.splitlines() if "**source**" in l],
+ )
+ c.check(
+ "the analyzer's own comments stay out of it",
+ "switch jump" not in doc and "jumptable" not in doc,
+ [l for l in doc.splitlines() if "switch" in l][:2],
+ )
+ c.check("the status line says where it went", out in c.status(), c.status())
# The journal has to survive the database, or a report is only ever
# about the session that happened to be open.
from idatui.journal import Journal
@@ -1620,14 +2064,17 @@ async def s_export_findings(c: Ctx):
app.journal.flush(app.program)
reloaded = Journal()
reloaded.load(app.program)
- c.check("the journal round-trips through the .i64",
- fn.addr in reloaded.addresses(),
- f"{len(reloaded)} entries, {sorted(reloaded.addresses())[:3]}")
+ c.check(
+ "the journal round-trips through the .i64",
+ fn.addr in reloaded.addresses(),
+ f"{len(reloaded)} entries, {sorted(reloaded.addresses())[:3]}",
+ )
finally:
# Idempotent: hand the database back exactly as we found it.
app.program.set_comment(fn.addr, "")
- app.program.client.invoke(
- "rename", batch={"func": {"addr": hex(fn.addr), "name": old}})
+ app.program.client.call(
+ remote_ops.rename, batch={"func": {"addr": hex(fn.addr), "name": old}}
+ )
app.program.bump_names()
app.program.invalidate(fn.addr)
if os.path.exists(out):
@@ -1639,8 +2086,11 @@ async def s_struct_filter(c: Ctx):
app = c.app
await c.press("ctrl+t")
if not await c.wait(lambda: isinstance(app.screen, StructEditor), 10):
- c.check("Ctrl+T opens the struct editor", False,
- f"screen={type(app.screen).__name__}")
+ c.check(
+ "Ctrl+T opens the struct editor",
+ False,
+ f"screen={type(app.screen).__name__}",
+ )
return
se = app.screen
await c.wait(lambda: bool(se._structs), 15)
@@ -1655,8 +2105,11 @@ async def s_struct_filter(c: Ctx):
ol.focus()
await c.press("slash")
opened = await c.wait(lambda: inp.display and app.focused is inp, 5)
- c.check("'/' from the list opens the struct filter", opened,
- f"display={inp.display} focus={getattr(app.focused, 'id', None)}")
+ c.check(
+ "'/' from the list opens the struct filter",
+ opened,
+ f"display={inp.display} focus={getattr(app.focused, 'id', None)}",
+ )
# PgDn from the FILTER: this screen can't use OptionListNav (ctrl+n is "new
# type" here), so it forwards through its own guarded _page(). Checked while
@@ -1665,31 +2118,42 @@ async def s_struct_filter(c: Ctx):
if ol.option_count > 2:
ol.highlighted = 0
await c.press("pagedown")
- c.check("PgDn pages the struct list from the filter prompt",
- (ol.highlighted or 0) > 1,
- f"highlighted={ol.highlighted} n={ol.option_count}")
+ c.check(
+ "PgDn pages the struct list from the filter prompt",
+ (ol.highlighted or 0) > 1,
+ f"highlighted={ol.highlighted} n={ol.option_count}",
+ )
await c.press("pageup")
- c.check("PgUp returns to the first struct", (ol.highlighted or 0) == 0,
- f"highlighted={ol.highlighted}")
+ c.check(
+ "PgUp returns to the first struct",
+ (ol.highlighted or 0) == 0,
+ f"highlighted={ol.highlighted}",
+ )
for ch in q:
await c.press(ch)
await c.wait(lambda: len(se._structs) < total, 5)
- c.check("typing fuzzy-filters the struct list",
- 0 < len(se._structs) < total and
- any(s.name == target for s in se._structs),
- f"q={q!r} {len(se._structs)}/{total}")
+ c.check(
+ "typing fuzzy-filters the struct list",
+ 0 < len(se._structs) < total and any(s.name == target for s in se._structs),
+ f"q={q!r} {len(se._structs)}/{total}",
+ )
cap = str(se.query_one("#se-title", Static).render())
- c.check("the caption counts what the filter kept",
- f"{len(se._structs)}/{total}" in cap, f"caption={cap!r}")
+ c.check(
+ "the caption counts what the filter kept",
+ f"{len(se._structs)}/{total}" in cap,
+ f"caption={cap!r}",
+ )
# 'd' is the delete binding on this screen: in the prompt it must be a
# character, not a destructive verb aimed at the highlighted struct.
await c.press("d")
await c.pause(0.05)
- c.check("'d' in the filter types instead of deleting",
- isinstance(app.screen, StructEditor) and inp.value == q + "d",
- f"screen={type(app.screen).__name__} value={inp.value!r}")
+ c.check(
+ "'d' in the filter types instead of deleting",
+ isinstance(app.screen, StructEditor) and inp.value == q + "d",
+ f"screen={type(app.screen).__name__} value={inp.value!r}",
+ )
await c.press("backspace")
await c.wait(lambda: inp.value == q, 5)
@@ -1697,34 +2161,45 @@ async def s_struct_filter(c: Ctx):
before = ol.highlighted
await c.press("down")
await c.pause(0.05)
- c.check("arrows move the list while the filter has focus",
- app.focused is inp and (ol.highlighted != before
- or ol.option_count == 1),
- f"{before} -> {ol.highlighted} of {ol.option_count}")
+ c.check(
+ "arrows move the list while the filter has focus",
+ app.focused is inp and (ol.highlighted != before or ol.option_count == 1),
+ f"{before} -> {ol.highlighted} of {ol.option_count}",
+ )
sel = se._structs[ol.highlighted or 0].name
ta = se.query_one(TextArea)
ta.text = ""
await c.press("enter")
loaded = await c.wait(lambda: sel in ta.text, 15)
- c.check("Enter in the filter loads the highlighted struct", loaded,
- f"want {sel!r} in {ta.text[:40]!r}")
+ c.check(
+ "Enter in the filter loads the highlighted struct",
+ loaded,
+ f"want {sel!r} in {ta.text[:40]!r}",
+ )
# Esc backs out one level at a time: definition -> filter -> dialog.
await c.press("escape")
await c.wait(lambda: app.focused is ol, 5)
- c.check("Esc leaves the definition for the list", app.focused is ol,
- f"focus={getattr(app.focused, 'id', None)}")
+ c.check(
+ "Esc leaves the definition for the list",
+ app.focused is ol,
+ f"focus={getattr(app.focused, 'id', None)}",
+ )
await c.press("escape")
cleared = await c.wait(lambda: len(se._structs) == total, 5)
- c.check("Esc clears the filter instead of closing",
- cleared and not inp.display and isinstance(app.screen, StructEditor),
- f"n={len(se._structs)}/{total} display={inp.display}")
+ c.check(
+ "Esc clears the filter instead of closing",
+ cleared and not inp.display and isinstance(app.screen, StructEditor),
+ f"n={len(se._structs)}/{total} display={inp.display}",
+ )
await c.press("escape")
await c.wait(lambda: not isinstance(app.screen, StructEditor), 10)
- c.check("a third Esc closes the editor",
- not isinstance(app.screen, StructEditor),
- f"screen={type(app.screen).__name__}")
+ c.check(
+ "a third Esc closes the editor",
+ not isinstance(app.screen, StructEditor),
+ f"screen={type(app.screen).__name__}",
+ )
@scenario("open_default_view")
@@ -1734,9 +2209,11 @@ async def s_open(c: Ctx):
app._open_function(fn.addr, fn.name)
await c.wait(lambda: app._cur and app._cur.ea == fn.addr, 20)
await c.wait(lambda: c.lst.total > 0, 30)
- c.check("opening a function shows the linear listing by default",
- app._active == "listing" and c.lst.display and c.lst.total > 0,
- f"active={app._active} total={c.lst.total}")
+ c.check(
+ "opening a function shows the linear listing by default",
+ app._active == "listing" and c.lst.display and c.lst.total > 0,
+ f"active={app._active} total={c.lst.total}",
+ )
print(f" biggest = {fn.name} ({c.lst.total} listing rows)")
@@ -1745,15 +2222,19 @@ async def s_disasm_nav(c: Ctx):
app, view = c.app, c.dis
await c.open_biggest("listing")
view.focus()
- c.check("first instruction cached",
- view.model is not None and view.model.cached_line(0) is not None)
+ c.check(
+ "first instruction cached",
+ view.model is not None and view.model.cached_line(0) is not None,
+ )
for _ in range(5):
await c.press("pagedown")
await c.pause(0.025)
c.check("pagedown moved the cursor", view.cursor > 0, f"cursor={view.cursor}")
await c.wait(lambda: view.model.cached_line(view.cursor) is not None, 15)
- c.check("cursor line eventually cached (bg fetch)",
- view.model.cached_line(view.cursor) is not None)
+ c.check(
+ "cursor line eventually cached (bg fetch)",
+ view.model.cached_line(view.cursor) is not None,
+ )
c.check("status shows an address", "@ 0x" in c.status(), c.status())
# Ctrl+Y copies the current code line.
view.focus()
@@ -1761,14 +2242,20 @@ async def s_disasm_nav(c: Ctx):
app._clipboard = ""
await c.press("ctrl+y")
await c.wait(lambda: app._clipboard == cur_line, 10)
- c.check("Ctrl+Y copies the current code line to the clipboard",
- bool(cur_line) and app._clipboard == cur_line, f"clip={app._clipboard!r}")
+ c.check(
+ "Ctrl+Y copies the current code line to the clipboard",
+ bool(cur_line) and app._clipboard == cur_line,
+ f"clip={app._clipboard!r}",
+ )
# goto-bottom must not hang on a huge function (ctrl+end; plain 'end' now
# moves the cursor to end-of-line).
await c.press("ctrl+end")
await c.pause(0.05)
- c.check("goto-bottom lands near end", view.cursor >= view.total - 1,
- f"cursor={view.cursor}/{view.total}")
+ c.check(
+ "goto-bottom lands near end",
+ view.cursor >= view.total - 1,
+ f"cursor={view.cursor}/{view.total}",
+ )
@scenario("hex")
@@ -1782,23 +2269,52 @@ async def s_hex(c: Ctx):
await c.press("backslash")
await c.wait(lambda: app._active == "hex", 10)
hx = c.hex
- await c.wait(lambda: hx.model is not None
- and hx.model.row(hx.cursor // 16)[1] is not None, 20)
- c.check("backslash opens the hex view synced to the code cursor",
- app._active == "hex" and code_ea is not None and hx.cursor_va() == code_ea,
- f"active={app._active} hexva={hx.cursor_va():#x} ea={code_ea}")
+ await c.wait(
+ lambda: hx.model is not None and hx.model.row(hx.cursor // 16)[1] is not None,
+ 20,
+ )
+ c.check(
+ "backslash opens the hex view synced to the code cursor",
+ app._active == "hex" and code_ea is not None and hx.cursor_va() == code_ea,
+ f"active={app._active} hexva={hx.cursor_va():#x} ea={code_ea}",
+ )
want = app.program.read_bytes(code_ea, 1)
_, rb = hx.model.row(hx.cursor // 16)
- c.check("hex shows the actual byte at that address",
- rb is not None and rb[hx.cursor % 16] == want[0],
- f"got={rb[hx.cursor % 16] if rb else None} want={want[0]}")
+ c.check(
+ "hex shows the actual byte at that address",
+ rb is not None and rb[hx.cursor % 16] == want[0],
+ f"got={rb[hx.cursor % 16] if rb else None} want={want[0]}",
+ )
+ old_va = hx.cursor_va()
+ block = (old_va - hx.model.start) // hx.model.BLOCK
+ old_bytes = hx.model._blocks.get(block)
+ await c.press("ctrl+r")
+ reloaded = await c.wait(
+ lambda: (
+ hx.model._blocks.get(block) is not None
+ and hx.model._blocks.get(block) is not old_bytes
+ ),
+ 20,
+ )
+ c.check("Ctrl+R refetches the visible hex block", reloaded)
+ c.check(
+ "Ctrl+R preserves the hex cursor",
+ hx.cursor_va() == old_va,
+ f"got={hx.cursor_va():#x} want={old_va:#x}",
+ )
await c.press("l")
await c.pause(0.1)
- c.check("hex cursor steps one byte", hx.cursor_va() == code_ea + 1,
- f"va={hx.cursor_va():#x}")
+ c.check(
+ "hex cursor steps one byte",
+ hx.cursor_va() == code_ea + 1,
+ f"va={hx.cursor_va():#x}",
+ )
fo = app.program.file_offset(hx.cursor_va())
- c.check("hex carries a file offset for a mapped (.text) address",
- fo is not None and hx.model.file_offset(hx.cursor_va()) == fo, f"fo={fo}")
+ c.check(
+ "hex carries a file offset for a mapped (.text) address",
+ fo is not None and hx.model.file_offset(hx.cursor_va()) == fo,
+ f"fo={fo}",
+ )
rng = app.program.image_range()
target_va = rng[0] + (rng[1] - rng[0]) // 2
await c.press("g")
@@ -1806,14 +2322,17 @@ async def s_hex(c: Ctx):
await c.type(hex(target_va))
await c.press("enter")
await c.wait(lambda: hx.cursor_va() == target_va, 15)
- c.check("'g' in the hex view jumps the cursor to an address",
- hx.cursor_va() == target_va, f"va={hx.cursor_va():#x} want={target_va:#x}")
+ c.check(
+ "'g' in the hex view jumps the cursor to an address",
+ hx.cursor_va() == target_va,
+ f"va={hx.cursor_va():#x} want={target_va:#x}",
+ )
# -- a user scroll freezes the cursor's screen row (points at a new byte) --
await c.press("g")
await c.type(hex(rng[0]))
await c.press("enter")
await c.wait(lambda: hx.cursor_va() == rng[0], 10)
- for _ in range(8): # cursor to viewport row 8 (top still 0)
+ for _ in range(8): # cursor to viewport row 8 (top still 0)
await c.press("j")
await c.pause(0.1)
top0 = round(hx.scroll_offset.y)
@@ -1822,24 +2341,33 @@ async def s_hex(c: Ctx):
await c.pause(0.15)
top1 = round(hx.scroll_offset.y)
c.check("hex viewport scrolled", top1 >= top0 + 20, f"top0={top0} top1={top1}")
- c.check("hex cursor's screen row stays frozen on scroll",
- hx.cursor // 16 - top1 == screen_row,
- f"screen_row={screen_row} now={hx.cursor // 16 - top1} top1={top1}")
+ c.check(
+ "hex cursor's screen row stays frozen on scroll",
+ hx.cursor // 16 - top1 == screen_row,
+ f"screen_row={screen_row} now={hx.cursor // 16 - top1} top1={top1}",
+ )
PAD = 1 # HexView { padding: 0 1 } -> content is inset one col
await c.pilot.click(HexView, offset=(PAD + 19 + 3 * 3, 5)) # hex byte 3, row 5
await c.pause(0.1)
- c.check("clicking the hex pane moves the cursor to the clicked byte",
- hx.cursor == (top1 + 5) * 16 + 3,
- f"cursor={hx.cursor} want={(top1 + 5) * 16 + 3} top1={top1}")
+ c.check(
+ "clicking the hex pane moves the cursor to the clicked byte",
+ hx.cursor == (top1 + 5) * 16 + 3,
+ f"cursor={hx.cursor} want={(top1 + 5) * 16 + 3} top1={top1}",
+ )
await c.pilot.click(HexView, offset=(PAD + 70 + 10, 7)) # ascii byte 10, row 7
await c.pause(0.1)
- c.check("clicking the ascii pane maps to the right byte",
- hx.cursor == (top1 + 7) * 16 + 10,
- f"cursor={hx.cursor} want={(top1 + 7) * 16 + 10}")
+ c.check(
+ "clicking the ascii pane maps to the right byte",
+ hx.cursor == (top1 + 7) * 16 + 10,
+ f"cursor={hx.cursor} want={(top1 + 7) * 16 + 10}",
+ )
await c.press("backslash")
await c.wait(lambda: app._active != "hex", 10)
- c.check("backslash returns from hex to the code view",
- app._active == "listing", f"active={app._active}")
+ c.check(
+ "backslash returns from hex to the code view",
+ app._active == "listing",
+ f"active={app._active}",
+ )
@scenario("filter")
@@ -1849,24 +2377,34 @@ async def s_filter(c: Ctx):
nfuncs = table.row_count
# row selection opens a function
fn = c.biggest()
- ridx = next((i for i in range(nfuncs)
- if int(str(table.get_row_at(i)[0]), 16) == fn.addr), 0)
+ ridx = next(
+ (i for i in range(nfuncs) if int(str(table.get_row_at(i)[0]), 16) == fn.addr), 0
+ )
table.move_cursor(row=ridx)
table.focus()
await c.press("enter")
await c.wait(lambda: app._cur and app._cur.ea == fn.addr, 20)
- c.check("selecting a table row opens that function",
- bool(app._cur) and app._cur.ea == fn.addr, f"cur={app._cur.ea if app._cur else None}")
+ c.check(
+ "selecting a table row opens that function",
+ bool(app._cur) and app._cur.ea == fn.addr,
+ f"cur={app._cur.ea if app._cur else None}",
+ )
# filter round-trip. Derive the glob from real names: this used to hardcode
# 'sub_1*', which matches NOTHING in a binary whose code never reaches
# 0x1xxx (echo's functions are sub_2xxx..sub_7xxx) — a deterministic failure
# that looked like a flake, and left the table empty for the next scenario.
subs = sorted(f.name for f in c.all_funcs() if f.name.startswith("sub_"))
term = (subs[0][:5] + "*") if subs else ""
- want = sum(1 for f in c.all_funcs()
- if fnmatch.fnmatch(f.name.lower(), term.lower())) if term else 0
- c.check("picked a glob that actually matches (test self-check)",
- 0 < want < nfuncs, f"term={term!r} want={want} of {nfuncs}")
+ want = (
+ sum(1 for f in c.all_funcs() if fnmatch.fnmatch(f.name.lower(), term.lower()))
+ if term
+ else 0
+ )
+ c.check(
+ "picked a glob that actually matches (test self-check)",
+ 0 < want < nfuncs,
+ f"term={term!r} want={want} of {nfuncs}",
+ )
table.focus()
await c.press("slash")
await c.pause(0.05)
@@ -1874,32 +2412,44 @@ async def s_filter(c: Ctx):
await c.press(ch if ch != "*" else "asterisk")
await c.press("enter")
filtered = await c.wait(lambda: table.row_count == want, 15)
- c.check("filter narrowed the list to exactly the matches", filtered,
- f"term={term!r} rows={table.row_count} want={want} of {nfuncs}")
+ c.check(
+ "filter narrowed the list to exactly the matches",
+ filtered,
+ f"term={term!r} rows={table.row_count} want={want} of {nfuncs}",
+ )
# pane toggle
left = app.query_one("#left", FunctionsPanel)
await c.press("ctrl+b")
await c.pause(0.05)
- c.check("ctrl+b hides functions pane + focuses the code view",
- not left.display and isinstance(app.focused, (ListingView, DecompView)),
- f"display={left.display} focus={type(app.focused).__name__}")
+ c.check(
+ "ctrl+b hides functions pane + focuses the code view",
+ not left.display and isinstance(app.focused, (ListingView, DecompView)),
+ f"display={left.display} focus={type(app.focused).__name__}",
+ )
await c.press("ctrl+b")
await c.pause(0.05)
- c.check("ctrl+b again restores pane + focuses table",
- left.display and isinstance(app.focused, DataTable),
- f"display={left.display} focus={type(app.focused).__name__}")
+ c.check(
+ "ctrl+b again restores pane + focuses table",
+ left.display and isinstance(app.focused, DataTable),
+ f"display={left.display} focus={type(app.focused).__name__}",
+ )
@scenario("view_toggle")
async def s_view_toggle(c: Ctx):
app, dis, dec = c.app, c.dis, c.dec
await c.open_biggest("decomp")
- pc = await c.wait(lambda: dec.display and dec.loaded_ea is not None
- and app._active == "decomp", 25)
+ pc = await c.wait(
+ lambda: dec.display and dec.loaded_ea is not None and app._active == "decomp",
+ 25,
+ )
c.check("pseudocode view shows", pc, f"active={app._active}")
c.check("pseudocode has many lines", dec.total > 20, f"lines={dec.total}")
- styled = any(seg.style is not None and seg.style.color is not None
- for strip in dec._strips[:min(dec.total, 200)] for seg in strip)
+ styled = any(
+ seg.style is not None and seg.style.color is not None
+ for strip in dec._strips[: min(dec.total, 200)]
+ for seg in strip
+ )
c.check("pseudocode is syntax-highlighted", styled)
# Cancelling a prompt must hand focus back to the pane you were READING.
# _code_view() used to choose on _pref, which was only ever "listing", so it
@@ -1915,21 +2465,27 @@ async def s_view_toggle(c: Ctx):
await c.press("down")
await c.press("down")
await c.pause(0.2)
- c.check("cancelling goto leaves focus in the pseudocode (arrows still work)",
- dec.cursor > line0,
- f"cursor {line0} -> {dec.cursor} focus={type(app.focused).__name__}")
+ c.check(
+ "cancelling goto leaves focus in the pseudocode (arrows still work)",
+ dec.cursor > line0,
+ f"cursor {line0} -> {dec.cursor} focus={type(app.focused).__name__}",
+ )
dec.focus() # Tab only toggles the view from a code pane; elsewhere it's
await c.pause(0.05) # focus-next, which would silently leave us in decomp
await c.press("tab")
# decomp -> listing runs through _toggle_to_listing, a background worker, so
# _active only flips once the listing model has loaded. A fixed pause held in
# a short run and lost the race in a full one.
- switched = await c.wait(lambda: app._active == "listing" and dis.display
- and not dec.display, 20)
- c.check("tab switches to disassembly", switched,
- f"active={app._active} split={app._split} "
- f"focus={type(app.focused).__name__} "
- f"lst={dis.display} dec={dec.display}")
+ switched = await c.wait(
+ lambda: app._active == "listing" and dis.display and not dec.display, 20
+ )
+ c.check(
+ "tab switches to disassembly",
+ switched,
+ f"active={app._active} split={app._split} "
+ f"focus={type(app.focused).__name__} "
+ f"lst={dis.display} dec={dec.display}",
+ )
# _toggle_to_listing repositions asynchronously; F5 below reads the listing
# cursor's ea and no-ops if it isn't on an addressed row yet.
await c.wait(lambda: c.lst._cursor_ea() is not None, 10)
@@ -1939,34 +2495,54 @@ async def s_view_toggle(c: Ctx):
dec.loaded_ea = None
app.action_toggle_view()
cover = dec._cover_widget
- c.check("F5 from the listing raises the 'decompiling…' overlay",
- dec.loading and cover is not None and "decomp-loading" in cover.classes
- and "decompiling" in str(cover.render()),
- f"loading={dec.loading} cover={cover!r}")
- await c.wait(lambda: app._active == "decomp" and not dec.loading
- and dec._cover_widget is None, 25)
- c.check("overlay clears when the decompile finishes",
- not dec.loading and dec._cover_widget is None)
+ c.check(
+ "F5 from the listing raises the 'decompiling…' overlay",
+ dec.loading
+ and cover is not None
+ and "decomp-loading" in cover.classes
+ and "decompiling" in str(cover.render()),
+ f"loading={dec.loading} cover={cover!r}",
+ )
+ await c.wait(
+ lambda: (
+ app._active == "decomp" and not dec.loading and dec._cover_widget is None
+ ),
+ 25,
+ )
+ c.check(
+ "overlay clears when the decompile finishes",
+ not dec.loading and dec._cover_widget is None,
+ )
# F5 on an ALREADY-loaded function must still clear the overlay (regression:
# the F5-raised overlay had nothing to clear it in the 'already loaded' branch
# -> spinner stuck forever).
await c.press("tab") # -> listing
await c.wait(lambda: app._active == "listing", 10)
app.action_toggle_view() # F5 the same, cached function again
- cleared = await c.wait(lambda: app._active == "decomp" and not dec.loading
- and dec._cover_widget is None, 15)
- c.check("re-decompiling an already-loaded function clears the overlay", cleared,
- f"loading={dec.loading} cover={dec._cover_widget!r}")
+ cleared = await c.wait(
+ lambda: (
+ app._active == "decomp" and not dec.loading and dec._cover_widget is None
+ ),
+ 15,
+ )
+ c.check(
+ "re-decompiling an already-loaded function clears the overlay",
+ cleared,
+ f"loading={dec.loading} cover={dec._cover_widget!r}",
+ )
# line-number gutter
dec.scroll_to(0, 0, animate=False)
await c.pause(0.025)
row0 = "".join(seg.text for seg in dec.render_line(0))
- c.check("pseudocode has a numbered gutter (line 1 first)",
- dec._gutter > 0 and row0[:dec._gutter].strip() == "1",
- f"gutter={dec._gutter} row0={row0[:10]!r}")
+ c.check(
+ "pseudocode has a numbered gutter (line 1 first)",
+ dec._gutter > 0 and row0[: dec._gutter].strip() == "1",
+ f"gutter={dec._gutter} row0={row0[:10]!r}",
+ )
# Home/End move along the line here too (they used to scroll to top/bottom).
- line = next((i for i, t in enumerate(dec._texts)
- if t.startswith(" ") and t.strip()), None)
+ line = next(
+ (i for i, t in enumerate(dec._texts) if t.startswith(" ") and t.strip()), None
+ )
if line is not None:
text = dec._texts[line]
dec.focus()
@@ -1976,28 +2552,37 @@ async def s_view_toggle(c: Ctx):
top = round(dec.scroll_offset.y)
await c.press("end")
await c.pause(0.05)
- c.check("<end> in pseudocode goes to end-of-line, not the bottom",
- dec.cursor == line and dec.cursor_x == max(len(text) - 1, 0)
- and round(dec.scroll_offset.y) == top,
- f"line={dec.cursor} col={dec.cursor_x} len={len(text)}")
+ c.check(
+ "<end> in pseudocode goes to end-of-line, not the bottom",
+ dec.cursor == line
+ and dec.cursor_x == max(len(text) - 1, 0)
+ and round(dec.scroll_offset.y) == top,
+ f"line={dec.cursor} col={dec.cursor_x} len={len(text)}",
+ )
await c.press("home")
await c.pause(0.05)
- c.check("<home> in pseudocode goes to start-of-line",
- dec.cursor == line and dec.cursor_x == 0,
- f"line={dec.cursor} col={dec.cursor_x}")
+ c.check(
+ "<home> in pseudocode goes to start-of-line",
+ dec.cursor == line and dec.cursor_x == 0,
+ f"line={dec.cursor} col={dec.cursor_x}",
+ )
await c.press("shift+home")
await c.pause(0.05)
- c.check("<shift+home> skips the indentation",
- dec.cursor_x == len(text) - len(text.lstrip()),
- f"col={dec.cursor_x} indent={len(text) - len(text.lstrip())}")
+ c.check(
+ "<shift+home> skips the indentation",
+ dec.cursor_x == len(text) - len(text.lstrip()),
+ f"col={dec.cursor_x} indent={len(text) - len(text.lstrip())}",
+ )
await c.press("ctrl+end")
await c.pause(0.1)
- c.check("<ctrl+end> still goes to the bottom",
- dec.cursor >= dec.total - 1, f"{dec.cursor}/{dec.total}")
+ c.check(
+ "<ctrl+end> still goes to the bottom",
+ dec.cursor >= dec.total - 1,
+ f"{dec.cursor}/{dec.total}",
+ )
await c.press("ctrl+home")
await c.pause(0.1)
- c.check("<ctrl+home> still goes to the top", dec.cursor == 0,
- f"{dec.cursor}")
+ c.check("<ctrl+home> still goes to the top", dec.cursor == 0, f"{dec.cursor}")
@scenario("search")
@@ -2017,26 +2602,39 @@ async def s_search(c: Ctx):
# the unified listing searches the whole segment (load_all) -> allow time
await c.wait(lambda: bool(dis._matches), 45)
c.check("search finds matches", len(dis._matches) > 0, f"term={term!r}")
- c.check("cursor sits on a match", dis.cursor in dis._matches, f"cursor={dis.cursor}")
- c.check("match substring highlighted",
- bool(dis._ranges.get(dis.cursor)), str(dis._ranges.get(dis.cursor)))
- c.check("search cursor lands on the match's starting column",
- bool(dis._ranges.get(dis.cursor))
- and dis.cursor_x == dis._ranges[dis.cursor][0][0],
- f"cursor_x={dis.cursor_x} ranges={dis._ranges.get(dis.cursor)}")
+ c.check(
+ "cursor sits on a match", dis.cursor in dis._matches, f"cursor={dis.cursor}"
+ )
+ c.check(
+ "match substring highlighted",
+ bool(dis._ranges.get(dis.cursor)),
+ str(dis._ranges.get(dis.cursor)),
+ )
+ c.check(
+ "search cursor lands on the match's starting column",
+ bool(dis._ranges.get(dis.cursor))
+ and dis.cursor_x == dis._ranges[dis.cursor][0][0],
+ f"cursor_x={dis.cursor_x} ranges={dis._ranges.get(dis.cursor)}",
+ )
prev = dis.cursor
await c.press("slash")
await c.pause(0.05)
await c.press("enter")
await c.pause(0.05)
- c.check("'/' repeats to next match",
- dis.cursor != prev and dis.cursor in dis._matches, f"cursor={dis.cursor}")
+ c.check(
+ "'/' repeats to next match",
+ dis.cursor != prev and dis.cursor in dis._matches,
+ f"cursor={dis.cursor}",
+ )
await c.press("question_mark")
await c.pause(0.05)
await c.press("enter")
await c.pause(0.05)
- c.check("'?' repeats to previous match", dis.cursor in dis._matches,
- f"cursor={dis.cursor}")
+ c.check(
+ "'?' repeats to previous match",
+ dis.cursor in dis._matches,
+ f"cursor={dis.cursor}",
+ )
# incremental preview + visible bar + Esc cancel
si = app.query_one("#search", Input)
status = app.query_one("#status", Static)
@@ -2044,22 +2642,31 @@ async def s_search(c: Ctx):
# display flips synchronously; the REGION only exists once Textual has laid
# the prompt out, which is a frame, not a worker.
await c.wait(lambda: si.display and si.region.height >= 1, 5)
- c.check("search bar visible, status hidden (no overlap)",
- si.display and not status.display, f"si={si.display} status={status.display}")
- c.check("search input owns the bottom row (nothing overlaps it)",
- si.region.height >= 1
- and si.region.y + si.region.height == app.size.height,
- f"search={si.region} screen={app.size}")
+ c.check(
+ "search bar visible, status hidden (no overlap)",
+ si.display and not status.display,
+ f"si={si.display} status={status.display}",
+ )
+ c.check(
+ "search input owns the bottom row (nothing overlaps it)",
+ si.region.height >= 1 and si.region.y + si.region.height == app.size.height,
+ f"search={si.region} screen={app.size}",
+ )
for ch in term:
await c.press(ch)
await c.pause(0.05)
- c.check("matches highlight incrementally (before Enter)",
- len(dis._matches) > 0 and si.value == term, f"val={si.value!r}")
+ c.check(
+ "matches highlight incrementally (before Enter)",
+ len(dis._matches) > 0 and si.value == term,
+ f"val={si.value!r}",
+ )
await c.press("escape")
await c.pause(0.1)
- c.check("Esc cancels: status restored, matches cleared",
- status.display and not si.display and not dis._matches,
- f"status={status.display} si={si.display} m={len(dis._matches)}")
+ c.check(
+ "Esc cancels: status restored, matches cleared",
+ status.display and not si.display and not dis._matches,
+ f"status={status.display} si={si.display} m={len(dis._matches)}",
+ )
@scenario("incr_filter")
@@ -2075,19 +2682,30 @@ async def s_incr_filter(c: Ctx):
# settling can't see it. Wait for the effect instead of guessing at the
# debounce: it returns the moment the rows are rebuilt.
await c.wait(lambda: 0 < table.row_count < full, 5)
- c.check("filter narrows incrementally as you type",
- 0 < table.row_count < full, f"{table.row_count}/{full}")
+ c.check(
+ "filter narrows incrementally as you type",
+ 0 < table.row_count < full,
+ f"{table.row_count}/{full}",
+ )
cell = table.get_row_at(0)[1]
- c.check("filter highlights matched substring in name",
- isinstance(cell, Text) and any(s.style for s in cell.spans), repr(str(cell)))
+ c.check(
+ "filter highlights matched substring in name",
+ isinstance(cell, Text) and any(s.style for s in cell.spans),
+ repr(str(cell)),
+ )
await c.press("enter")
await c.wait(lambda: isinstance(app.focused, DataTable), 5)
- c.check("Enter keeps filter + focuses table",
- isinstance(app.focused, DataTable) and table.row_count < full)
+ c.check(
+ "Enter keeps filter + focuses table",
+ isinstance(app.focused, DataTable) and table.row_count < full,
+ )
await c.press("escape")
await c.wait(lambda: table.row_count == full, 5)
- c.check("Esc on the list clears the filter", table.row_count == full,
- f"{table.row_count}/{full}")
+ c.check(
+ "Esc on the list clears the filter",
+ table.row_count == full,
+ f"{table.row_count}/{full}",
+ )
@scenario("follow_xrefs")
@@ -2096,10 +2714,13 @@ async def s_follow_xrefs(c: Ctx):
await c.open_biggest("listing")
dis.focus()
lines = dis.model.lines(0, 400, prefetch=False)
- call_idx = next((i for i, ln in enumerate(lines)
- if ln.text.startswith("call ")), None)
+ call_idx = next(
+ (i for i, ln in enumerate(lines) if ln.text.startswith("call ")), None
+ )
if call_idx is None:
- c.check("found a call line to exercise follow/xrefs", False, "no call in first 400")
+ c.check(
+ "found a call line to exercise follow/xrefs", False, "no call in first 400"
+ )
return
dis.cursor = call_idx
dis.refresh()
@@ -2113,8 +2734,11 @@ async def s_follow_xrefs(c: Ctx):
# failed about one run in ten with cur == orig, at full speed, looking like
# a code regression.
await c.wait(lambda: len(app._nav) > depth and app._cur.ea != orig, 25)
- c.check("Enter follows the call into another function",
- app._cur.ea != orig and len(app._nav) > depth, f"cur={app._cur.ea:#x}")
+ c.check(
+ "Enter follows the call into another function",
+ app._cur.ea != orig and len(app._nav) > depth,
+ f"cur={app._cur.ea:#x}",
+ )
await c.press("escape")
await c.wait(lambda: app._cur.ea == orig, 15)
c.check("Esc returns from the follow", app._cur.ea == orig, f"cur={app._cur.ea:#x}")
@@ -2124,15 +2748,19 @@ async def s_follow_xrefs(c: Ctx):
opened = await c.wait(lambda: isinstance(app.screen, XrefsScreen), 25)
c.check("'x' opens the xrefs popup", opened, f"screen={type(app.screen).__name__}")
if opened:
- c.check("xrefs popup has entries",
- app.screen.query_one(OptionList).option_count >= 1)
+ c.check(
+ "xrefs popup has entries",
+ app.screen.query_one(OptionList).option_count >= 1,
+ )
await c.press("escape")
await c.pause(0.1)
c.check("Esc closes the xrefs popup", not isinstance(app.screen, XrefsScreen))
xf = None
for cand in c.all_funcs()[:600]:
- codex = [x for x in app.program.xrefs_to(cand.addr) if x.type == "code" and x.frm]
+ codex = [
+ x for x in app.program.xrefs_to(cand.addr) if x.type == "code" and x.frm
+ ]
if codex:
xf = (cand, codex[0])
break
@@ -2150,19 +2778,27 @@ async def s_follow_xrefs(c: Ctx):
await c.wait(lambda: not isinstance(app.screen, XrefsScreen), 25)
# xref-select lands the listing cursor on the referencing SITE (frm)
await c.wait(lambda: c.lst._cursor_ea() == xref.frm, 25)
- c.check("xref-select lands the cursor on the referencing site",
- c.lst._cursor_ea() == xref.frm,
- f"cur_ea={c.lst._cursor_ea()} want={xref.frm:#x}")
+ c.check(
+ "xref-select lands the cursor on the referencing site",
+ c.lst._cursor_ea() == xref.frm,
+ f"cur_ea={c.lst._cursor_ea()} want={xref.frm:#x}",
+ )
# F5 at the site decompiles the referencing function
c.lst.focus()
await c.press("tab")
landed = await c.wait(
- lambda: (app._active == "decomp" and dec.loaded_ea == xref.fn_addr)
- or (app.is_listing
- and _CANNOT_DECOMP in c.status().lower()), 25)
+ lambda: (
+ (app._active == "decomp" and dec.loaded_ea == xref.fn_addr)
+ or (app.is_listing and _CANNOT_DECOMP in c.status().lower())
+ ),
+ 25,
+ )
if app._active == "decomp":
- c.check("F5 at the xref site decompiles the referencing function",
- dec.loaded_ea == xref.fn_addr, f"loaded={dec.loaded_ea}")
+ c.check(
+ "F5 at the xref site decompiles the referencing function",
+ dec.loaded_ea == xref.fn_addr,
+ f"loaded={dec.loaded_ea}",
+ )
await c.press("tab")
await c.wait(lambda: app._active == "listing", 20)
@@ -2175,9 +2811,13 @@ async def s_follow_xrefs(c: Ctx):
dis.refresh()
await c.pause(0.025)
await c.press("h")
- c.check("h moves the column cursor left", dis.cursor_x == 4, f"x={dis.cursor_x}")
+ c.check(
+ "h moves the column cursor left", dis.cursor_x == 4, f"x={dis.cursor_x}"
+ )
await c.press("l", "l")
- c.check("l moves the column cursor right", dis.cursor_x == 6, f"x={dis.cursor_x}")
+ c.check(
+ "l moves the column cursor right", dis.cursor_x == 6, f"x={dis.cursor_x}"
+ )
plain = dis._line_plain(call_idx) or ""
m = re.search(r"\b(sub_[0-9A-Fa-f]+)", plain)
if m:
@@ -2185,24 +2825,32 @@ async def s_follow_xrefs(c: Ctx):
dis.cursor_x = m.start(1) + 1
dis.refresh()
await c.pause(0.05)
- c.check("word-under-cursor is the operand symbol",
- dis.word_under_cursor() == m.group(1),
- f"{dis.word_under_cursor()!r} vs {m.group(1)!r}")
+ c.check(
+ "word-under-cursor is the operand symbol",
+ dis.word_under_cursor() == m.group(1),
+ f"{dis.word_under_cursor()!r} vs {m.group(1)!r}",
+ )
depth = len(app._nav)
want = app.program.resolve(m.group(1))
await c.press("enter")
await c.wait(lambda: len(app._nav) > depth, 25)
- c.check("follows the symbol under the cursor",
- app._cur.ea == want, f"cur={app._cur.ea:#x} want={want:#x}")
+ c.check(
+ "follows the symbol under the cursor",
+ app._cur.ea == want,
+ f"cur={app._cur.ea:#x} want={want:#x}",
+ )
@scenario("xref_labels")
async def s_xref_labels(c: Ctx):
from collections import Counter, defaultdict
+
app, dis, dec = c.app, c.dis, c.dec
multi = None
for cand in c.all_funcs()[:200]:
- callers = Counter(x.fn_addr for x in app.program.xrefs_to(cand.addr) if x.fn_name)
+ callers = Counter(
+ x.fn_addr for x in app.program.xrefs_to(cand.addr) if x.fn_name
+ )
if any(n >= 2 for n in callers.values()):
multi = cand
break
@@ -2223,9 +2871,14 @@ async def s_xref_labels(c: Ctx):
if "+0x" in x:
nm, off = x.split("+0x", 1)
byfn[nm].add(off)
- c.check("xref labels distinguish multiple sites in a function by offset",
- any(len(offs) >= 2 for offs in byfn.values()), f"locs={locs[:8]}")
- c.check("no xref label is a bare '?'", all(x != "?" for x in locs), f"locs={locs[:8]}")
+ c.check(
+ "xref labels distinguish multiple sites in a function by offset",
+ any(len(offs) >= 2 for offs in byfn.values()),
+ f"locs={locs[:8]}",
+ )
+ c.check(
+ "no xref label is a bare '?'", all(x != "?" for x in locs), f"locs={locs[:8]}"
+ )
await c.press("escape")
await c.wait(lambda: not isinstance(app.screen, XrefsScreen), 25)
# pre-selection: 'x' at a call site highlights that site in the dialog
@@ -2233,8 +2886,9 @@ async def s_xref_labels(c: Ctx):
for x in app.program.xrefs_to(multi.addr):
if x.fn_name and x.type == "code":
bycaller[x.fn_addr].append(x)
- csites = next((sorted(v, key=lambda x: x.frm)
- for v in bycaller.values() if len(v) >= 2), None)
+ csites = next(
+ (sorted(v, key=lambda x: x.frm) for v in bycaller.values() if len(v) >= 2), None
+ )
if not csites:
c.check("found a caller with multiple sites for preselect", False)
return
@@ -2253,9 +2907,11 @@ async def s_xref_labels(c: Ctx):
await c.wait(lambda: isinstance(app.screen, XrefsScreen), 25)
hl = app.screen.query_one(OptionList).highlighted
it = app.screen._items
- c.check("xref dialog pre-selects the site it was invoked from",
- hl is not None and it[hl][0] == site.frm,
- f"hl={hl} frm={hex(it[hl][0]) if hl is not None else None} want={hex(site.frm)}")
+ c.check(
+ "xref dialog pre-selects the site it was invoked from",
+ hl is not None and it[hl][0] == site.frm,
+ f"hl={hl} frm={hex(it[hl][0]) if hl is not None else None} want={hex(site.frm)}",
+ )
await c.press("escape")
await c.wait(lambda: not isinstance(app.screen, XrefsScreen), 25)
@@ -2286,21 +2942,28 @@ async def s_mouse(c: Ctx):
return
await c.pilot.click(dis, offset=(mcol + 1, mrow))
await c.pause(0.05)
- c.check("single click places the cursor on the clicked token",
- dis.cursor == mline and dis.word_under_cursor() == msym,
- f"cursor={dis.cursor} (want {mline}) word={dis.word_under_cursor()!r}")
+ c.check(
+ "single click places the cursor on the clicked token",
+ dis.cursor == mline and dis.word_under_cursor() == msym,
+ f"cursor={dis.cursor} (want {mline}) word={dis.word_under_cursor()!r}",
+ )
depth = len(app._nav)
want = app.program.resolve(msym)
await c.pilot.click(dis, offset=(mcol + 1, mrow), times=2)
await c.wait(lambda: len(app._nav) > depth, 25)
- c.check("double-click follows the symbol", app._cur.ea == want,
- f"cur={app._cur.ea:#x} want={want:#x}")
+ c.check(
+ "double-click follows the symbol",
+ app._cur.ea == want,
+ f"cur={app._cur.ea:#x} want={want:#x}",
+ )
await c.press("escape")
await c.wait(lambda: app._cur.ea != want, 20)
await c.wait(lambda: dis.total > 0 and dis.cursor == mline, 20)
- c.check("back restores the exact line + column",
- dis.cursor == mline and dis.word_under_cursor() == msym,
- f"cursor={dis.cursor} (want {mline}) word={dis.word_under_cursor()!r}")
+ c.check(
+ "back restores the exact line + column",
+ dis.cursor == mline and dis.word_under_cursor() == msym,
+ f"cursor={dis.cursor} (want {mline}) word={dis.word_under_cursor()!r}",
+ )
@scenario("decomp_nav")
@@ -2322,23 +2985,32 @@ async def s_decomp_nav(c: Ctx):
await c.press("escape")
await c.wait(lambda: dec.loaded_ea == fn.addr, 25)
await c.pause(0.1)
- c.check("pseudocode-view position restored after jump+back",
- dec.cursor == drow and dec.word_under_cursor() == dsym,
- f"cursor={dec.cursor} (want {drow}) word={dec.word_under_cursor()!r}")
+ c.check(
+ "pseudocode-view position restored after jump+back",
+ dec.cursor == drow and dec.word_under_cursor() == dsym,
+ f"cursor={dec.cursor} (want {drow}) word={dec.word_under_cursor()!r}",
+ )
# follow works with a STALE name (post-rename): ea-marker fallback
dstale = app.program.resolve(dsym)
old_line = dec._texts[drow] if drow < len(dec._texts) else ""
old_ea = dec._line_ea(drow)
if old_ea is not None and dsym in old_line:
tmp = f"stale_{os.getpid()}"
- app.program.client.invoke("rename", batch={"func": {"addr": hex(dstale), "name": tmp}})
+ app.program.client.call(
+ remote_ops.rename, batch={"func": {"addr": hex(dstale), "name": tmp}}
+ )
app.program.bump_names()
d2 = len(app._nav)
app._follow_decomp(old_line, dsym, old_ea)
await c.wait(lambda: len(app._nav) > d2, 25)
- c.check("decomp follow works with a stale name (ea-marker fallback)",
- app._cur.ea == dstale, f"cur={app._cur.ea:#x} want={dstale:#x}")
- app.program.client.invoke("rename", batch={"func": {"addr": hex(dstale), "name": dsym}})
+ c.check(
+ "decomp follow works with a stale name (ea-marker fallback)",
+ app._cur.ea == dstale,
+ f"cur={app._cur.ea:#x} want={dstale:#x}",
+ )
+ app.program.client.call(
+ remote_ops.rename, batch={"func": {"addr": hex(dstale), "name": dsym}}
+ )
app.program.bump_names()
@@ -2365,9 +3037,11 @@ async def s_decomp_follow_self(c: Ctx):
depth = len(app._nav)
await c.press("enter")
moved = await c.wait(lambda: len(app._nav) > depth, 25)
- c.check("decompiler follows a name not in refs (resolve fallback)",
- moved and app._cur.ea == fn.addr,
- f"moved={moved} cur={hex(app._cur.ea)} want={hex(fn.addr)}")
+ c.check(
+ "decompiler follows a name not in refs (resolve fallback)",
+ moved and app._cur.ea == fn.addr,
+ f"moved={moved} cur={hex(app._cur.ea)} want={hex(fn.addr)}",
+ )
@scenario("sort")
@@ -2377,19 +3051,28 @@ async def s_sort(c: Ctx):
await c.pilot.click(table, offset=(15, 0)) # Function header
await c.pause(0.15)
snames = [str(table.get_row_at(i)[1]) for i in range(min(20, table.row_count))]
- c.check("click Function header sorts by name",
- app._sort_col == 1 and snames == sorted(snames, key=str.lower),
- f"sort_col={app._sort_col}")
+ c.check(
+ "click Function header sorts by name",
+ app._sort_col == 1 and snames == sorted(snames, key=str.lower),
+ f"sort_col={app._sort_col}",
+ )
first_asc = str(table.get_row_at(0)[1])
await c.pilot.click(table, offset=(15, 0)) # reverse
await c.pause(0.15)
- c.check("click again reverses the sort",
- app._sort_reverse and str(table.get_row_at(0)[1]) != first_asc)
+ c.check(
+ "click again reverses the sort",
+ app._sort_reverse and str(table.get_row_at(0)[1]) != first_asc,
+ )
await c.pilot.click(table, offset=(3, 0)) # Address header
await c.pause(0.15)
- saddrs = [int(str(table.get_row_at(i)[0]), 16) for i in range(min(20, table.row_count))]
- c.check("click Address header sorts by address",
- app._sort_col == 0 and saddrs == sorted(saddrs), f"sort_col={app._sort_col}")
+ saddrs = [
+ int(str(table.get_row_at(i)[0]), 16) for i in range(min(20, table.row_count))
+ ]
+ c.check(
+ "click Address header sorts by address",
+ app._sort_col == 0 and saddrs == sorted(saddrs),
+ f"sort_col={app._sort_col}",
+ )
@scenario("rename")
@@ -2410,18 +3093,34 @@ async def s_rename(c: Ctx):
await c.press("n")
await c.pause(0.1)
ri = app.query_one("#rename", Input)
- c.check("'n' opens the rename prompt prefilled with the symbol",
- ri.display and ri.value == dsym, f"val={ri.value!r}")
+ c.check(
+ "'n' opens the rename prompt prefilled with the symbol",
+ ri.display and ri.value == dsym,
+ f"val={ri.value!r}",
+ )
ri.value = newname
await c.press("enter")
- await c.wait(lambda: app._func_index.by_addr(dtarget)
- and app._func_index.by_addr(dtarget).name == newname, 25)
- c.check("rename updates the function name",
- app._func_index.by_addr(dtarget).name == newname,
- app._func_index.by_addr(dtarget).name)
- rr = app.program.client.invoke("rename", batch={"func": {"addr": hex(dtarget), "name": dsym}})
- c.check("rename reverted cleanly",
- rr.get("summary", {}).get("ok", 0) == 1, str(rr.get("summary")))
+ await c.wait(
+ lambda: (
+ app._func_index.by_addr(dtarget)
+ and app._func_index.by_addr(dtarget).name == newname
+ ),
+ 25,
+ )
+ c.check(
+ "rename updates the function name",
+ app._func_index.by_addr(dtarget).name == newname,
+ app._func_index.by_addr(dtarget).name,
+ )
+ rr = app.program.client.call(
+ remote_ops.rename, batch={"func": {"addr": hex(dtarget), "name": dsym}}
+ )
+ c.check(
+ "rename reverted cleanly",
+ rr.get("summary", {}).get("ok", 0) == 1,
+ str(rr.get("summary")),
+ )
+
# goto label refuse
def _find_label():
for i, t in enumerate(dec._texts):
@@ -2429,6 +3128,7 @@ async def s_rename(c: Ctx):
if mm:
return i, mm.start(), mm.group(0)
return None
+
lab = _find_label()
if lab is None:
await c.open("main", "decomp")
@@ -2442,15 +3142,25 @@ async def s_rename(c: Ctx):
await c.press("n")
await c.pause(0.1)
ri2 = app.query_one("#rename", Input)
- c.check("renaming a pseudocode label is refused with a clear message",
- (not ri2.display) and "label" in c.status().lower(),
- f"display={ri2.display} status={c.status()!r}")
+ c.check(
+ "renaming a pseudocode label is refused with a clear message",
+ (not ri2.display) and "label" in c.status().lower(),
+ f"display={ri2.display} status={c.status()!r}",
+ )
else:
c.check("found a pseudocode label to test", False, "no LABEL_ found")
# comment via ';'
- cline = next((i for i in range(len(dec._texts))
- if i > 5 and dec._line_ea(i) is not None
- and dec._texts[i].strip() and "//" not in dec._texts[i]), None)
+ cline = next(
+ (
+ i
+ for i in range(len(dec._texts))
+ if i > 5
+ and dec._line_ea(i) is not None
+ and dec._texts[i].strip()
+ and "//" not in dec._texts[i]
+ ),
+ None,
+ )
if cline is not None:
cea = dec._line_ea(cline)
dec.focus()
@@ -2461,8 +3171,11 @@ async def s_rename(c: Ctx):
await c.pause(0.1)
ci = app.query_one("#comment", Input)
cnote = f"note_{os.getpid()}"
- c.check("';' opens the comment prompt on the current line", ci.display,
- f"display={ci.display}")
+ c.check(
+ "';' opens the comment prompt on the current line",
+ ci.display,
+ f"display={ci.display}",
+ )
ci.value = cnote
await c.press("enter")
# Gate on the comment showing up, and ONLY that: the extra
@@ -2471,9 +3184,14 @@ async def s_rename(c: Ctx):
# out its full 25s (9s of wall clock) and then the check below passed
# vacuously anyway.
await c.wait(lambda: any(cnote in t for t in dec._texts), 25)
- c.check("comment appears in the pseudocode after ';'",
- any(cnote in t for t in dec._texts), "comment not shown")
- app.program.client.invoke("set_comments", items=[{"addr": hex(cea), "comment": ""}])
+ c.check(
+ "comment appears in the pseudocode after ';'",
+ any(cnote in t for t in dec._texts),
+ "comment not shown",
+ )
+ app.program.client.call(
+ remote_ops.set_comments, items=[{"addr": hex(cea), "comment": ""}]
+ )
else:
c.check("found a pseudocode line to comment", False, "no marker line")
@@ -2493,29 +3211,42 @@ async def s_comment_func(c: Ctx):
dec.cursor, dec.cursor_x = 0, 2 # the signature line
dec.refresh()
await c.pause(0.05)
- c.check("signature line has no address of its own", dec._line_ea(0) is None,
- f"ea={dec._line_ea(0)}")
+ c.check(
+ "signature line has no address of its own",
+ dec._line_ea(0) is None,
+ f"ea={dec._line_ea(0)}",
+ )
await c.press("semicolon")
await c.pause(0.1)
ci = app.query_one("#comment", Input)
note = f"fn_note_{os.getpid()}"
- c.check("';' on the signature line opens a function-comment prompt",
- ci.display and "function comment" in str(ci.placeholder).lower(),
- f"display={ci.display} ph={ci.placeholder!r}")
+ c.check(
+ "';' on the signature line opens a function-comment prompt",
+ ci.display and "function comment" in str(ci.placeholder).lower(),
+ f"display={ci.display} ph={ci.placeholder!r}",
+ )
# literal '\n' in the comment becomes a real newline -> multi-line render
a, b = f"{note}_A", f"{note}_B"
ci.value = f"{a}\\n{b}"
await c.press("enter")
- await c.wait(lambda: dec.loaded_ea == fn.addr
- and any(a in t for t in dec._texts)
- and any(b in t for t in dec._texts), 25)
+ await c.wait(
+ lambda: (
+ dec.loaded_ea == fn.addr
+ and any(a in t for t in dec._texts)
+ and any(b in t for t in dec._texts)
+ ),
+ 25,
+ )
la = next((i for i, t in enumerate(dec._texts) if a in t), None)
lb = next((i for i, t in enumerate(dec._texts) if b in t), None)
- c.check("multi-line function comment renders on separate lines",
- la is not None and lb is not None and lb > la
- and a not in dec._texts[lb],
- f"la={la} lb={lb}")
- app.program.client.invoke("set_comments", items=[{"addr": hex(fn.addr), "comment": ""}])
+ c.check(
+ "multi-line function comment renders on separate lines",
+ la is not None and lb is not None and lb > la and a not in dec._texts[lb],
+ f"la={la} lb={lb}",
+ )
+ app.program.client.call(
+ remote_ops.set_comments, items=[{"addr": hex(fn.addr), "comment": ""}]
+ )
@scenario("retype")
@@ -2535,30 +3266,41 @@ async def s_retype(c: Ctx):
await c.press("y")
await c.wait(lambda: app.query_one("#retype", Input).display, 10)
ri = app.query_one("#retype", Input)
- c.check("'y' on a function prefills its prototype",
- ri.display and ri.value == old_proto, f"val={ri.value!r} want={old_proto!r}")
+ c.check(
+ "'y' on a function prefills its prototype",
+ ri.display and ri.value == old_proto,
+ f"val={ri.value!r} want={old_proto!r}",
+ )
ri.value = f"void __fastcall {cf.name}(int zz_retype_arg)"
await c.press("enter")
- await c.wait(lambda: (lambda f: bool(f) and "zz_retype_arg" in f.prototype)(
- app.program.func_types(cf.addr)), 20)
+ await c.wait(
+ lambda: (lambda f: bool(f) and "zz_retype_arg" in f.prototype)(
+ app.program.func_types(cf.addr)
+ ),
+ 20,
+ )
after = app.program.func_types(cf.addr)
- c.check("applying a retype changes the function prototype",
- after is not None and "zz_retype_arg" in after.prototype,
- f"proto={after.prototype if after else None!r}")
+ c.check(
+ "applying a retype changes the function prototype",
+ after is not None and "zz_retype_arg" in after.prototype,
+ f"proto={after.prototype if after else None!r}",
+ )
app.program.set_function_type(cf.addr, old_proto) # restore
# the retype above kicked off a recompile+reload; let it land before we start
# placing the cursor, or the reload resets it under us.
app.program.bump_names()
- await c.wait(lambda: not dec.loading and dec.loaded_ea == cf.addr
- and bool(dec._texts), 25)
+ await c.wait(
+ lambda: not dec.loading and dec.loaded_ea == cf.addr and bool(dec._texts), 25
+ )
await c.pause(0.3)
# -- 'y' on a LOCAL variable retypes that variable, not the prototype --- #
fts = app.program.func_types(cf.addr)
lv = next((v for v in (fts.lvars if fts else []) if not v.is_arg), None)
if lv is not None:
- line = next((i for i, t in enumerate(dec._texts)
- if _word_occurrences(t, lv.name)), None)
+ line = next(
+ (i for i, t in enumerate(dec._texts) if _word_occurrences(t, lv.name)), None
+ )
if line is not None:
col = _word_occurrences(dec._texts[line], lv.name)[0][0]
dec.focus()
@@ -2568,26 +3310,43 @@ async def s_retype(c: Ctx):
await c.press("y")
await c.wait(lambda: app.query_one("#retype", Input).display, 10)
ri = app.query_one("#retype", Input)
- c.check("'y' on a local variable prefills that variable's type",
- ri.value == lv.type and lv.name in str(ri.placeholder),
- f"val={ri.value!r} want={lv.type!r} ph={ri.placeholder!r}")
+ c.check(
+ "'y' on a local variable prefills that variable's type",
+ ri.value == lv.type and lv.name in str(ri.placeholder),
+ f"val={ri.value!r} want={lv.type!r} ph={ri.placeholder!r}",
+ )
ri.value = "unsigned __int64"
await c.press("enter")
- changed = await c.wait(lambda: (lambda f: bool(f) and any(
- v.name == lv.name and v.type == "unsigned __int64"
- for v in f.lvars))(app.program.func_types(cf.addr)), 25)
- c.check("applying it retypes the local variable", changed,
- f"{lv.name}: wanted unsigned __int64")
+ changed = await c.wait(
+ lambda: (
+ lambda f: (
+ bool(f)
+ and any(
+ v.name == lv.name and v.type == "unsigned __int64"
+ for v in f.lvars
+ )
+ )
+ )(app.program.func_types(cf.addr)),
+ 25,
+ )
+ c.check(
+ "applying it retypes the local variable",
+ changed,
+ f"{lv.name}: wanted unsigned __int64",
+ )
after = app.program.func_types(cf.addr)
- c.check("retyping a local leaves the prototype alone",
- after is not None and after.prototype == old_proto,
- f"proto={after.prototype if after else None!r}")
+ c.check(
+ "retyping a local leaves the prototype alone",
+ after is not None and after.prototype == old_proto,
+ f"proto={after.prototype if after else None!r}",
+ )
# -- 'y' on a GLOBAL retypes the global, not the enclosing function ----- #
# The lvar retype above recompiled too — settle again, or the scan below
# indexes into pseudocode that's about to be replaced.
- await c.wait(lambda: not dec.loading and dec.loaded_ea == cf.addr
- and bool(dec._texts), 25)
+ await c.wait(
+ lambda: not dec.loading and dec.loaded_ea == cf.addr and bool(dec._texts), 25
+ )
await c.pause(0.3)
# Pick a global that actually appears as a word in the pseudocode — a symbol
@@ -2605,8 +3364,11 @@ async def s_retype(c: Ctx):
if app.program.func_types(a) is not None:
continue
d = app.program.data_type(a) or {}
- if (d.get("name") and not d.get("is_func")
- and "(" not in (d.get("type") or "")):
+ if (
+ d.get("name")
+ and not d.get("is_func")
+ and "(" not in (d.get("type") or "")
+ ):
glob = (w, a, d, i)
break
if glob:
@@ -2620,27 +3382,40 @@ async def s_retype(c: Ctx):
dec.cursor, dec.cursor_x = line, col + 1 # inside the word
dec.refresh()
await c.pause(0.05)
- c.check("the cursor sits on the global",
- dec.word_under_cursor() == gname,
- f"word={dec.word_under_cursor()!r} want={gname!r} "
- f"line={line} col={col} text={dec._texts[line][:60]!r}")
+ c.check(
+ "the cursor sits on the global",
+ dec.word_under_cursor() == gname,
+ f"word={dec.word_under_cursor()!r} want={gname!r} "
+ f"line={line} col={col} text={dec._texts[line][:60]!r}",
+ )
await c.press("y")
await c.wait(lambda: app.query_one("#retype", Input).display, 10)
ri = app.query_one("#retype", Input)
- c.check("'y' on a global prefills the global's type (not the proto)",
- ri.value != old_proto and gname in str(ri.placeholder),
- f"val={ri.value!r} ph={ri.placeholder!r}")
+ c.check(
+ "'y' on a global prefills the global's type (not the proto)",
+ ri.value != old_proto and gname in str(ri.placeholder),
+ f"val={ri.value!r} ph={ri.placeholder!r}",
+ )
ri.value = "unsigned __int64"
await c.press("enter")
retyped = await c.wait(
- lambda: (app.program.data_type(glob.addr) or {}).get("type")
- == "unsigned __int64", 25)
- c.check("applying it retypes the global", retyped,
- f"type={(app.program.data_type(glob.addr) or {}).get('type')!r}")
+ lambda: (
+ (app.program.data_type(glob.addr) or {}).get("type")
+ == "unsigned __int64"
+ ),
+ 25,
+ )
+ c.check(
+ "applying it retypes the global",
+ retyped,
+ f"type={(app.program.data_type(glob.addr) or {}).get('type')!r}",
+ )
after = app.program.func_types(cf.addr)
- c.check("retyping a global leaves the prototype alone",
- after is not None and after.prototype == old_proto,
- f"proto={after.prototype if after else None!r}")
+ c.check(
+ "retyping a global leaves the prototype alone",
+ after is not None and after.prototype == old_proto,
+ f"proto={after.prototype if after else None!r}",
+ )
if dt.get("type"): # restore
app.program.set_data_type(glob.addr, dt["type"])
@@ -2671,10 +3446,12 @@ async def s_scroll_restore(c: Ctx):
await c.wait(lambda: app._cur.ea == fb.addr, 20)
renders: list[int] = []
_orig_rl = dis.render_line
+
def _traced(y, _o=_orig_rl):
if y == 0:
renders.append(round(dis.scroll_offset.y))
return _o(y)
+
dis.render_line = _traced
await c.press("escape")
await c.wait(lambda: app._cur.ea == fa.addr, 20)
@@ -2683,13 +3460,19 @@ async def s_scroll_restore(c: Ctx):
# one happened. Wait for the paint we are actually asserting about (each
# poll ticks the screen, so this is ~one frame, not a quarter second).
await c.wait(lambda: bool(renders) and renders[-1] == want_sy, 5)
- c.check("disasm scroll + cursor restored on back (mid-viewport)",
- round(dis.scroll_offset.y) == want_sy and dis.cursor == want_cur and want_rel > 0,
- f"scroll={round(dis.scroll_offset.y)} (want {want_sy}) "
- f"cursor={dis.cursor} (want {want_cur}) rel_before={want_rel}")
- c.check("pane is repainted at the restored scroll (no stale top frame)",
- bool(renders) and renders[-1] == want_sy,
- f"last repaint scroll={renders[-1] if renders else None} (want {want_sy})")
+ c.check(
+ "disasm scroll + cursor restored on back (mid-viewport)",
+ round(dis.scroll_offset.y) == want_sy
+ and dis.cursor == want_cur
+ and want_rel > 0,
+ f"scroll={round(dis.scroll_offset.y)} (want {want_sy}) "
+ f"cursor={dis.cursor} (want {want_cur}) rel_before={want_rel}",
+ )
+ c.check(
+ "pane is repainted at the restored scroll (no stale top frame)",
+ bool(renders) and renders[-1] == want_sy,
+ f"last repaint scroll={renders[-1] if renders else None} (want {want_sy})",
+ )
dis.render_line = _orig_rl
@@ -2709,14 +3492,18 @@ async def s_paging(c: Ctx):
rel = dis.cursor - round(dis.scroll_offset.y)
await c.press("pagedown")
await c.pause(0.05)
- c.check("PageDown preserves the viewport-relative row",
- dis.cursor - round(dis.scroll_offset.y) == rel,
- f"rel={dis.cursor - round(dis.scroll_offset.y)} want={rel}")
+ c.check(
+ "PageDown preserves the viewport-relative row",
+ dis.cursor - round(dis.scroll_offset.y) == rel,
+ f"rel={dis.cursor - round(dis.scroll_offset.y)} want={rel}",
+ )
await c.press("pageup")
await c.pause(0.05)
- c.check("PageUp preserves the viewport-relative row",
- dis.cursor - round(dis.scroll_offset.y) == rel,
- f"rel={dis.cursor - round(dis.scroll_offset.y)} want={rel}")
+ c.check(
+ "PageUp preserves the viewport-relative row",
+ dis.cursor - round(dis.scroll_offset.y) == rel,
+ f"rel={dis.cursor - round(dis.scroll_offset.y)} want={rel}",
+ )
@scenario("rename_history")
@@ -2766,8 +3553,13 @@ async def s_rename_history(c: Ctx):
await c.pause(0.1)
app.query_one("#rename", Input).value = hnew
await c.press("enter")
- await c.wait(lambda: app._func_index.by_addr(htarget)
- and app._func_index.by_addr(htarget).name == hnew, 25)
+ await c.wait(
+ lambda: (
+ app._func_index.by_addr(htarget)
+ and app._func_index.by_addr(htarget).name == hnew
+ ),
+ 25,
+ )
if app._active != "listing":
await c.press("tab")
await c.press("escape")
@@ -2776,14 +3568,22 @@ async def s_rename_history(c: Ctx):
dis.model.lines(hrow, 4, prefetch=False)
await c.pause(0.1)
hline = dis._line_plain(hrow)
- c.check("caller disasm shows renamed callee after 'back'",
- hline is not None and hnew in hline, f"line={hline!r}")
+ c.check(
+ "caller disasm shows renamed callee after 'back'",
+ hline is not None and hnew in hline,
+ f"line={hline!r}",
+ )
await c.press("tab")
await c.wait(lambda: dec.loaded_ea == bea, 25)
await c.pause(0.15)
- c.check("caller pseudocode shows renamed callee after 'back'",
- any(hnew in tx for tx in dec._texts), "pseudocode still stale")
- app.program.client.invoke("rename", batch={"func": {"addr": hex(htarget), "name": hsym}})
+ c.check(
+ "caller pseudocode shows renamed callee after 'back'",
+ any(hnew in tx for tx in dec._texts),
+ "pseudocode still stale",
+ )
+ app.program.client.call(
+ remote_ops.rename, batch={"func": {"addr": hex(htarget), "name": hsym}}
+ )
@scenario("region_define")
@@ -2800,37 +3600,59 @@ async def s_region_define(c: Ctx):
# setup: undefine the whole function so [addr, addr+size) is a bare region
c.prog.undefine(addr, size=size)
c.prog.bump_items()
- c.check("function removed by undefine",
- c.prog.function_of(addr) is None, "still a function")
+ c.check(
+ "function removed by undefine",
+ c.prog.function_of(addr) is None,
+ "still a function",
+ )
# navigate there via the real 'g' prompt -> opens the flat LISTING view
# (a non-function region), not refused
await c.goto_ui(hex(addr))
await c.wait(lambda: app._cur is not None and app._cur.ea == addr, 25)
- c.check("goto to a non-function address opens the listing view (not refused)",
- app._cur is not None and app._cur.is_region
- and app._active == "listing" and c.lst.display,
- f"cur={app._cur} active={app._active} status={c.status()!r}")
+ c.check(
+ "goto to a non-function address opens the listing view (not refused)",
+ app._cur is not None
+ and app._cur.is_region
+ and app._active == "listing"
+ and c.lst.display,
+ f"cur={app._cur} active={app._active} status={c.status()!r}",
+ )
await c.wait(lambda: c.lst.total > 0 and c.lst._cursor_ea() is not None, 25)
- c.check("listing renders heads and the cursor sits on the target address",
- c.lst.total > 0 and c.lst._cursor_ea() == addr,
- f"total={c.lst.total} cur_ea={c.lst._cursor_ea()}")
+ c.check(
+ "listing renders heads and the cursor sits on the target address",
+ c.lst.total > 0 and c.lst._cursor_ea() == addr,
+ f"total={c.lst.total} cur_ea={c.lst._cursor_ea()}",
+ )
# the flat listing spans the whole segment, not just this function
seg = c.prog.segment_bounds(addr)
- c.check("listing spans the whole segment (more heads than one function)",
- seg is not None and c.lst.total > 1, f"total={c.lst.total} seg={seg}")
+ c.check(
+ "listing spans the whole segment (more heads than one function)",
+ seg is not None and c.lst.total > 1,
+ f"total={c.lst.total} seg={seg}",
+ )
# 'p' on the entry head (re)creates the function
c.lst.focus()
c.lst.cursor, c.lst.cursor_x = c.lst.model.index_of_ea(addr), 0
await c.pause(0.05)
await c.press("p")
- await c.wait(lambda: c.prog.function_of(addr) is not None
- and app._cur is not None and not app._cur.is_region, 25)
- c.check("'p' creates a function and upgrades the listing to a function view",
- c.prog.function_of(addr) is not None and not app._cur.is_region
- and app._cur.ea == addr and app._active in ("listing", "decomp"),
- f"fn={c.prog.function_of(addr)} cur={app._cur} active={app._active}")
+ await c.wait(
+ lambda: (
+ c.prog.function_of(addr) is not None
+ and app._cur is not None
+ and not app._cur.is_region
+ ),
+ 25,
+ )
+ c.check(
+ "'p' creates a function and upgrades the listing to a function view",
+ c.prog.function_of(addr) is not None
+ and not app._cur.is_region
+ and app._cur.ea == addr
+ and app._active in ("listing", "decomp"),
+ f"fn={c.prog.function_of(addr)} cur={app._cur} active={app._active}",
+ )
finally:
# idempotency: guarantee the function is back even if a check failed
if c.prog.function_of(addr) is None:
@@ -2866,46 +3688,83 @@ async def s_listing_view(c: Ctx):
# `total > 0` is set from the segment's size before a single page has
# materialised, so waiting on it and then reading rows was the suite's
# one known flake (it failed roughly one run in three). Wait for a ROW.
- await c.wait(lambda: app._cur is not None and app._active == "listing"
- and c.lst.total > 0 and c.lst.model is not None
- and any(h.kind == "data" for h in c.lst.model.window(0, 40)), 25)
- c.check("navigating to a data segment opens the listing view",
- app._active == "listing" and c.lst.display and c.lst.total > 0,
- f"active={app._active} total={c.lst.total}")
+ await c.wait(
+ lambda: (
+ app._cur is not None
+ and app._active == "listing"
+ and c.lst.total > 0
+ and c.lst.model is not None
+ and any(h.kind == "data" for h in c.lst.model.window(0, 40))
+ ),
+ 25,
+ )
+ c.check(
+ "navigating to a data segment opens the listing view",
+ app._active == "listing" and c.lst.display and c.lst.total > 0,
+ f"active={app._active} total={c.lst.total}",
+ )
kinds = {h.kind for h in c.lst.model.window(0, 40)}
c.check("listing shows data heads (not just code)", "data" in kinds, str(kinds))
# a rendered data line carries the item text (e.g. db/dd/string)
c.lst.focus()
- first_data = next((i for i in range(min(c.lst.total, 60))
- if c.lst.model.get(i) and c.lst.model.get(i).kind == "data"), None)
- c.check("a data head exists in the first screenful", first_data is not None,
- f"total={c.lst.total}")
+ first_data = next(
+ (
+ i
+ for i in range(min(c.lst.total, 60))
+ if c.lst.model.get(i) and c.lst.model.get(i).kind == "data"
+ ),
+ None,
+ )
+ c.check(
+ "a data head exists in the first screenful",
+ first_data is not None,
+ f"total={c.lst.total}",
+ )
if first_data is not None:
c.lst.cursor = first_data
await c.pause(0.05)
plain = c.lst._line_plain(first_data)
- c.check("data line renders its item text", bool(plain and plain.strip()),
- f"plain={plain!r}")
- c.check("listing cursor reports the head address",
- c.lst._cursor_ea() == c.lst.model.get(first_data).ea, str(c.lst._cursor_ea()))
+ c.check(
+ "data line renders its item text",
+ bool(plain and plain.strip()),
+ f"plain={plain!r}",
+ )
+ c.check(
+ "listing cursor reports the head address",
+ c.lst._cursor_ea() == c.lst.model.get(first_data).ea,
+ str(c.lst._cursor_ea()),
+ )
# backslash from the listing opens hex at the cursor address; and back
cur_ea = c.lst._cursor_ea()
await c.press("backslash")
await c.wait(lambda: app._active == "hex" and c.hex.display, 15)
- c.check("backslash from the listing opens the hex view", app._active == "hex",
- f"active={app._active}")
+ c.check(
+ "backslash from the listing opens the hex view",
+ app._active == "hex",
+ f"active={app._active}",
+ )
await c.press("backslash")
await c.wait(lambda: app._active == "listing", 15)
- c.check("returning from hex lands back on the listing (not a func view)",
- app._active == "listing" and c.lst.display, f"active={app._active}")
+ c.check(
+ "returning from hex lands back on the listing (not a func view)",
+ app._active == "listing" and c.lst.display,
+ f"active={app._active}",
+ )
# 'd' defines typed data over an undefined run. Synthesize the run
# deterministically: undefine a data head, then re-type it via the prompt.
- dhead = next((c.lst.model.get(i) for i in range(min(c.lst.total, 200))
- if c.lst.model.get(i) and c.lst.model.get(i).kind == "data"
- and (c.lst.model.get(i).size or 0) >= 4), None)
+ dhead = next(
+ (
+ c.lst.model.get(i)
+ for i in range(min(c.lst.total, 200))
+ if c.lst.model.get(i)
+ and c.lst.model.get(i).kind == "data"
+ and (c.lst.model.get(i).size or 0) >= 4
+ ),
+ None,
+ )
if dhead is None:
c.check("found a data head to re-type", False)
return
@@ -2921,31 +3780,49 @@ async def s_listing_view(c: Ctx):
# win the race, and it started failing the moment page loads got bigger.
stale = c.lst.model
await c.goto_ui(hex(dea))
- await c.wait(lambda: app._active == "listing" and c.lst.total > 0
- and c.lst.model is not stale
- and c.lst.model.index_of_ea(dea) >= 0, 25)
+ await c.wait(
+ lambda: (
+ app._active == "listing"
+ and c.lst.total > 0
+ and c.lst.model is not stale
+ and c.lst.model.index_of_ea(dea) >= 0
+ ),
+ 25,
+ )
ui = c.lst.model.index_of_ea(dea)
- c.check("undefining a data head yields an unknown run in the listing",
- ui >= 0 and c.lst.model.get(ui).kind == "unknown",
- f"kind={c.lst.model.get(ui).kind if ui>=0 else None}")
+ c.check(
+ "undefining a data head yields an unknown run in the listing",
+ ui >= 0 and c.lst.model.get(ui).kind == "unknown",
+ f"kind={c.lst.model.get(ui).kind if ui >= 0 else None}",
+ )
c.lst.focus()
c.lst.cursor = ui
await c.pause(0.05)
await c.press("d")
await c.pause(0.1)
mdi = app.query_one("#makedata", Input)
- c.check("'d' opens the make-data prompt prefilled with a type",
- mdi.display and bool(mdi.value), f"display={mdi.display} val={mdi.value!r}")
+ c.check(
+ "'d' opens the make-data prompt prefilled with a type",
+ mdi.display and bool(mdi.value),
+ f"display={mdi.display} val={mdi.value!r}",
+ )
mdi.value = "char[4]"
await c.press("enter")
- await c.wait(lambda: app._active == "listing"
- and c.lst.model.index_of_ea(dea) >= 0
- and c.lst.model.get(c.lst.model.index_of_ea(dea)) is not None
- and c.lst.model.get(c.lst.model.index_of_ea(dea)).kind == "data", 25)
+ await c.wait(
+ lambda: (
+ app._active == "listing"
+ and c.lst.model.index_of_ea(dea) >= 0
+ and c.lst.model.get(c.lst.model.index_of_ea(dea)) is not None
+ and c.lst.model.get(c.lst.model.index_of_ea(dea)).kind == "data"
+ ),
+ 25,
+ )
di = c.lst.model.index_of_ea(dea)
- c.check("'d' turns the undefined run into a typed data item",
- di >= 0 and c.lst.model.get(di).kind == "data",
- f"kind={c.lst.model.get(di).kind if di>=0 else None}")
+ c.check(
+ "'d' turns the undefined run into a typed data item",
+ di >= 0 and c.lst.model.get(di).kind == "data",
+ f"kind={c.lst.model.get(di).kind if di >= 0 else None}",
+ )
finally:
c.prog.bump_items()
@@ -2971,8 +3848,11 @@ async def s_listing_name_addr(c: Ctx):
if data_ea is None:
c.check("found a data segment with a >=2-byte item", False)
return
- dh = next(h for h in c.prog.listing(data_ea).window(0, 60)
- if h.kind == "data" and (h.size or 0) >= 2)
+ dh = next(
+ h
+ for h in c.prog.listing(data_ea).window(0, 60)
+ if h.kind == "data" and (h.size or 0) >= 2
+ )
A = dh.ea
newname = f"after_{os.getpid()}"
try:
@@ -2980,33 +3860,53 @@ async def s_listing_name_addr(c: Ctx):
c.prog.make_data(A, "unsigned __int8")
c.prog.bump_items()
await c.goto_ui(hex(A + 1))
- await c.wait(lambda: app._active == "listing" and app._cur is not None
- and app._cur.ea == A + 1, 25)
+ await c.wait(
+ lambda: (
+ app._active == "listing"
+ and app._cur is not None
+ and app._cur.ea == A + 1
+ ),
+ 25,
+ )
head = c.lst.cur_head()
- c.check("cursor lands on the now-undefined byte at addr+1",
- head is not None and head.ea == A + 1 and head.kind == "unknown",
- f"head={head}")
+ c.check(
+ "cursor lands on the now-undefined byte at addr+1",
+ head is not None and head.ea == A + 1 and head.kind == "unknown",
+ f"head={head}",
+ )
# 'n' opens the address-name prompt (even though there's no symbol)
c.lst.focus()
await c.press("n")
await c.pause(0.1)
ri = app.query_one("#rename", Input)
- c.check("'n' opens the name prompt on an unnamed byte",
- ri.display, f"display={ri.display}")
+ c.check(
+ "'n' opens the name prompt on an unnamed byte",
+ ri.display,
+ f"display={ri.display}",
+ )
ri.value = newname
await c.press("enter")
- await c.wait(lambda: app._active == "listing"
- and c.lst.model.index_of_ea(A + 1) >= 0
- and c.lst.model.get(c.lst.model.index_of_ea(A + 1)) is not None
- and c.lst.model.get(c.lst.model.index_of_ea(A + 1)).name == newname, 25)
+ await c.wait(
+ lambda: (
+ app._active == "listing"
+ and c.lst.model.index_of_ea(A + 1) >= 0
+ and c.lst.model.get(c.lst.model.index_of_ea(A + 1)) is not None
+ and c.lst.model.get(c.lst.model.index_of_ea(A + 1)).name == newname
+ ),
+ 25,
+ )
hi = c.lst.model.index_of_ea(A + 1)
- c.check("naming a bare byte at addr+1 sticks",
- hi >= 0 and c.lst.model.get(hi).name == newname,
- f"name={c.lst.model.get(hi).name if hi>=0 else None}")
+ c.check(
+ "naming a bare byte at addr+1 sticks",
+ hi >= 0 and c.lst.model.get(hi).name == newname,
+ f"name={c.lst.model.get(hi).name if hi >= 0 else None}",
+ )
finally:
# revert: drop the label and restore raw bytes at A
try:
- c.prog.client.invoke("rename", batch={"data": {"addr": hex(A + 1), "new": ""}})
+ c.prog.client.call(
+ remote_ops.rename, batch={"data": {"addr": hex(A + 1), "new": ""}}
+ )
except Exception: # noqa: BLE001
pass
c.prog.undefine(A, size=8)
@@ -3025,8 +3925,9 @@ async def s_listing_make_string(c: Ctx):
if lm is None:
continue
lm.ensure(80)
- h = next((h for h in lm.window(0, 80)
- if h.kind == "data" and "'" in h.text), None)
+ h = next(
+ (h for h in lm.window(0, 80) if h.kind == "data" and "'" in h.text), None
+ )
if h is not None:
target = h.ea
break
@@ -3038,22 +3939,36 @@ async def s_listing_make_string(c: Ctx):
c.prog.undefine(A, size=8)
c.prog.bump_items()
await c.goto_ui(hex(A))
- await c.wait(lambda: app._active == "listing" and app._cur is not None
- and app._cur.ea == A, 25)
- c.check("target is undefined before 'a'",
- c.lst.cur_head() is not None and c.lst.cur_head().kind == "unknown",
- f"head={c.lst.cur_head()}")
+ await c.wait(
+ lambda: (
+ app._active == "listing" and app._cur is not None and app._cur.ea == A
+ ),
+ 25,
+ )
+ c.check(
+ "target is undefined before 'a'",
+ c.lst.cur_head() is not None and c.lst.cur_head().kind == "unknown",
+ f"head={c.lst.cur_head()}",
+ )
c.lst.focus()
await c.press("a")
- await c.wait(lambda: c.lst.model.index_of_ea(A) >= 0
- and c.lst.model.get(c.lst.model.index_of_ea(A)) is not None
- and c.lst.model.get(c.lst.model.index_of_ea(A)).kind == "data"
- and "'" in c.lst.model.get(c.lst.model.index_of_ea(A)).text, 25)
+ await c.wait(
+ lambda: (
+ c.lst.model.index_of_ea(A) >= 0
+ and c.lst.model.get(c.lst.model.index_of_ea(A)) is not None
+ and c.lst.model.get(c.lst.model.index_of_ea(A)).kind == "data"
+ and "'" in c.lst.model.get(c.lst.model.index_of_ea(A)).text
+ ),
+ 25,
+ )
hi = c.lst.model.index_of_ea(A)
- c.check("'a' creates a string literal at the cursor",
- hi >= 0 and c.lst.model.get(hi).kind == "data"
- and "'" in c.lst.model.get(hi).text,
- f"head={c.lst.model.get(hi) if hi >= 0 else None}")
+ c.check(
+ "'a' creates a string literal at the cursor",
+ hi >= 0
+ and c.lst.model.get(hi).kind == "data"
+ and "'" in c.lst.model.get(hi).text,
+ f"head={c.lst.model.get(hi) if hi >= 0 else None}",
+ )
finally:
try:
c.prog.make_string(A) # restore the original string
@@ -3083,25 +3998,38 @@ async def s_listing_struct_expand(c: Ctx):
c.check("found a data address for the struct test", False)
return
try:
- c.prog.client.invoke(
- "declare_type",
- decls=["struct TuiExpandS { int a; char b[4]; short c; };"])
+ c.prog.client.call(
+ remote_ops.declare_type,
+ decls=["struct TuiExpandS { int a; char b[4]; short c; };"],
+ )
c.prog.make_data(A, "TuiExpandS")
c.prog.bump_items()
await c.goto_ui(hex(A))
- await c.wait(lambda: app._active == "listing" and app._cur is not None
- and app._cur.ea == A and c.lst.total > 0, 25)
+ await c.wait(
+ lambda: (
+ app._active == "listing"
+ and app._cur is not None
+ and app._cur.ea == A
+ and c.lst.total > 0
+ ),
+ 25,
+ )
# the summary head, then member rows for a/b/c
si = c.lst.model.index_of_ea(A)
members = [c.lst.model.get(si + 1 + k) for k in range(3)]
names = [m.text for m in members if m is not None]
- c.check("struct global expands into member rows",
- all(m is not None and m.kind == "member" for m in members)
- and any("a" in t for t in names) and any("b" in t for t in names),
- f"members={names}")
- c.check("member rows carry field addresses",
- members[1] is not None and members[1].ea == A + 4,
- f"ea={members[1].ea if members[1] else None:#x} want={A+4:#x}")
+ c.check(
+ "struct global expands into member rows",
+ all(m is not None and m.kind == "member" for m in members)
+ and any("a" in t for t in names)
+ and any("b" in t for t in names),
+ f"members={names}",
+ )
+ c.check(
+ "member rows carry field addresses",
+ members[1] is not None and members[1].ea == A + 4,
+ f"ea={members[1].ea if members[1] else None:#x} want={A + 4:#x}",
+ )
finally:
try:
c.prog.undefine(A, size=16)
@@ -3119,45 +4047,66 @@ async def s_continuous_view(c: Ctx):
fn = await c.open_biggest("listing")
fn_ea = fn.addr
await c.wait(lambda: app._active == "listing" and c.lst.display, 10)
- c.check("a function opens in the continuous listing by default",
- app._active == "listing" and c.lst.display
- and c.lst._cursor_ea() == fn_ea,
- f"active={app._active} disp={c.lst.display} cur_ea={c.lst._cursor_ea()}")
+ c.check(
+ "a function opens in the continuous listing by default",
+ app._active == "listing" and c.lst.display and c.lst._cursor_ea() == fn_ea,
+ f"active={app._active} disp={c.lst.display} cur_ea={c.lst._cursor_ea()}",
+ )
# the listing spans the whole segment, not just the function
c.lst.model.load_all()
seg = c.prog.segment_bounds(fn_ea)
seg_rows = len(c.lst.model)
# a function's own instruction count is far smaller than the segment
fdis = c.prog.disasm(fn_ea, fn.name)
- c.check("the continuous listing extends past the function's bounds",
- seg_rows > fdis.total(), f"listing={seg_rows} func={fdis.total()}")
+ c.check(
+ "the continuous listing extends past the function's bounds",
+ seg_rows > fdis.total(),
+ f"listing={seg_rows} func={fdis.total()}",
+ )
kinds = {c.lst.model.get(i).kind for i in range(seg_rows)}
- c.check("continuous listing interleaves code with data/undefined",
- "code" in kinds and ("data" in kinds or "unknown" in kinds), str(kinds))
+ c.check(
+ "continuous listing interleaves code with data/undefined",
+ "code" in kinds and ("data" in kinds or "unknown" in kinds),
+ str(kinds),
+ )
# rendering parity with disasm: code lines carry opcode bytes
- cidx = next((i for i in range(seg_rows)
- if c.lst.model.get(i).kind == "code"), None)
- c.check("continuous listing renders opcode bytes (parity with disasm)",
- cidx is not None and c.lst.model.get(cidx).raw
- and c.lst._op_field(c.lst.model.get(cidx)).strip() != "",
- f"raw={c.lst.model.get(cidx).raw if cidx is not None else None!r}")
+ cidx = next((i for i in range(seg_rows) if c.lst.model.get(i).kind == "code"), None)
+ c.check(
+ "continuous listing renders opcode bytes (parity with disasm)",
+ cidx is not None
+ and c.lst.model.get(cidx).raw
+ and c.lst._op_field(c.lst.model.get(cidx)).strip() != "",
+ f"raw={c.lst.model.get(cidx).raw if cidx is not None else None!r}",
+ )
# F5/Tab at the function -> decompiler, and back to the same spot
c.lst.focus()
await c.press("tab")
- await c.wait(lambda: (app._active == "decomp" and c.dec.loaded_ea == fn_ea)
- or (app.is_listing
- and _CANNOT_DECOMP in c.status().lower()), 25)
+ await c.wait(
+ lambda: (
+ (app._active == "decomp" and c.dec.loaded_ea == fn_ea)
+ or (app.is_listing and _CANNOT_DECOMP in c.status().lower())
+ ),
+ 25,
+ )
if app._active == "decomp":
- c.check("F5/Tab decompiles the function under the cursor",
- c.dec.loaded_ea == fn_ea, f"loaded={c.dec.loaded_ea}")
+ c.check(
+ "F5/Tab decompiles the function under the cursor",
+ c.dec.loaded_ea == fn_ea,
+ f"loaded={c.dec.loaded_ea}",
+ )
await c.press("tab")
await c.wait(lambda: app._active == "listing", 25)
- c.check("F5/Tab in the decompiler returns to the listing at the same ea",
- app._active == "listing" and c.lst._cursor_ea() == fn_ea,
- f"active={app._active} cur_ea={c.lst._cursor_ea()}")
+ c.check(
+ "F5/Tab in the decompiler returns to the listing at the same ea",
+ app._active == "listing" and c.lst._cursor_ea() == fn_ea,
+ f"active={app._active} cur_ea={c.lst._cursor_ea()}",
+ )
else:
- c.check("undecompilable function falls back to the listing",
- app._active == "listing", f"active={app._active}")
+ c.check(
+ "undecompilable function falls back to the listing",
+ app._active == "listing",
+ f"active={app._active}",
+ )
@scenario("func_banners")
@@ -3170,18 +4119,27 @@ async def s_func_banners(c: Ctx):
c.lst.model.load_all()
heads = [c.lst.model.get(i) for i in range(len(c.lst.model))]
ci = c.lst.model.index_of_ea(fn.addr)
- c.check("navigation to a function lands on its code head, not a banner",
- ci >= 0 and c.lst.model.get(ci).kind == "code",
- f"kind={c.lst.model.get(ci).kind if ci >= 0 else None}")
- c.check("a SUBROUTINE separator banner is present",
- any(h.kind == "sep" and "S U B R O U T I N E" in h.text for h in heads))
- c.check("a 'name proc' header is present",
- any(h.kind == "funchdr" and h.text.endswith(" proc") for h in heads))
- c.check("a 'name endp' footer is present",
- any(h.kind == "funchdr" and h.text.endswith("endp") for h in heads))
+ c.check(
+ "navigation to a function lands on its code head, not a banner",
+ ci >= 0 and c.lst.model.get(ci).kind == "code",
+ f"kind={c.lst.model.get(ci).kind if ci >= 0 else None}",
+ )
+ c.check(
+ "a SUBROUTINE separator banner is present",
+ any(h.kind == "sep" and "S U B R O U T I N E" in h.text for h in heads),
+ )
+ c.check(
+ "a 'name proc' header is present",
+ any(h.kind == "funchdr" and h.text.endswith(" proc") for h in heads),
+ )
+ c.check(
+ "a 'name endp' footer is present",
+ any(h.kind == "funchdr" and h.text.endswith("endp") for h in heads),
+ )
# the proc header for the origin function carries its name
- hdr = next((h for h in heads if h.kind == "funchdr"
- and h.text == f"{fn.name} proc"), None)
+ hdr = next(
+ (h for h in heads if h.kind == "funchdr" and h.text == f"{fn.name} proc"), None
+ )
c.check("the proc header names the function", hdr is not None, f"fn={fn.name}")
@@ -3246,35 +4204,58 @@ async def s_opfmt_listing(c: Ctx):
ea = head.ea
c.lst.focus()
parked = await _park_on(c, ea)
- c.check("the cursor is on the literal's line", parked,
- f"want {ea:#x}, cursor at {c.lst._cursor_ea():#x}")
+ c.check(
+ "the cursor is on the literal's line",
+ parked,
+ f"want {ea:#x}, cursor at {c.lst._cursor_ea():#x}",
+ )
before = head.text
try:
await c.press("o")
- await c.wait(lambda: c.lst.model.index_of_ea(ea) >= 0
- and (c.lst.model.get(c.lst.model.index_of_ea(ea)) or head).text
- != before, 25)
+ await c.wait(
+ lambda: (
+ c.lst.model.index_of_ea(ea) >= 0
+ and (c.lst.model.get(c.lst.model.index_of_ea(ea)) or head).text
+ != before
+ ),
+ 25,
+ )
i = c.lst.model.index_of_ea(ea)
after = c.lst.model.get(i).text if i >= 0 else before
- c.check("'o' re-renders the literal", after != before,
- f"{before!r} -> {after!r} ea={ea:#x} show={show}")
- c.check("the status names the format it moved to",
- any(f in c.status() for f in show["choices"]),
- f"status={c.status()!r} choices={show['choices']}")
+ c.check(
+ "'o' re-renders the literal",
+ after != before,
+ f"{before!r} -> {after!r} ea={ea:#x} show={show}",
+ )
+ c.check(
+ "the status names the format it moved to",
+ any(f in c.status() for f in show["choices"]),
+ f"status={c.status()!r} choices={show['choices']}",
+ )
c.check("the change is marked unsaved", app._dirty)
# 'O' walks the ring the other way: back to where we started.
await c.press("O")
- await c.wait(lambda: c.lst.model.index_of_ea(ea) >= 0
- and (c.lst.model.get(c.lst.model.index_of_ea(ea)) or head).text
- == before, 25)
+ await c.wait(
+ lambda: (
+ c.lst.model.index_of_ea(ea) >= 0
+ and (c.lst.model.get(c.lst.model.index_of_ea(ea)) or head).text
+ == before
+ ),
+ 25,
+ )
j = c.lst.model.index_of_ea(ea)
- c.check("'O' cycles back", j >= 0 and c.lst.model.get(j).text == before,
- f"{c.lst.model.get(j).text if j >= 0 else None!r} want {before!r}")
+ c.check(
+ "'O' cycles back",
+ j >= 0 and c.lst.model.get(j).text == before,
+ f"{c.lst.model.get(j).text if j >= 0 else None!r} want {before!r}",
+ )
# An explicit format by name (what the palette/RPC use).
r = c.prog.op_format(ea, mode="dec")
- c.check("an explicit format renders decimal",
- r["format"] == "dec" and str(int(show["value"], 16)) in r["text"],
- str(r))
+ c.check(
+ "an explicit format renders decimal",
+ r["format"] == "dec" and str(int(show["value"], 16)) in r["text"],
+ str(r),
+ )
finally:
try:
c.prog.op_format(ea, mode="default", n=show.get("n", -1))
@@ -3293,24 +4274,33 @@ async def s_opfmt_no_literal(c: Ctx):
h = c.lst.model.get(i)
return h is not None and h.kind in ("sep", "funchdr", "label")
- row = next((i for i in range(c.lst.cursor, min(c.lst.cursor + 400,
- len(c.lst.model)))
- if _banner(i)), None)
+ row = next(
+ (
+ i
+ for i in range(c.lst.cursor, min(c.lst.cursor + 400, len(c.lst.model)))
+ if _banner(i)
+ ),
+ None,
+ )
if row is None:
c.check("found a banner row", False)
return
c.lst.focus()
- for _ in range(20): # hold it against a late-landing open
+ for _ in range(20): # hold it against a late-landing open
c.lst.cursor = row
await c.pause(0.05)
if c.lst.cursor == row:
break
- c.check("the cursor is on a banner row", _banner(c.lst.cursor),
- f"row={c.lst.cursor}")
+ c.check(
+ "the cursor is on a banner row", _banner(c.lst.cursor), f"row={c.lst.cursor}"
+ )
await c.press("o")
await c.wait(lambda: "reformat" in c.status() or "format" in c.status(), 15)
- c.check("'o' on a line with no literal explains itself",
- "reformat" in c.status(), f"status={c.status()!r}")
+ c.check(
+ "'o' on a line with no literal explains itself",
+ "reformat" in c.status(),
+ f"status={c.status()!r}",
+ )
@scenario("opfmt_refusal_is_not_swallowed")
@@ -3338,7 +4328,7 @@ async def s_opfmt_refusal_visible(c: Ctx):
c.check("the cursor is on the literal's line", False)
return
try:
- await c.press("o") # a success: sets the flash
+ await c.press("o") # a success: sets the flash
await c.wait(lambda: "\u2192" in c.status(), 25)
good = c.status()
@@ -3351,9 +4341,14 @@ async def s_opfmt_refusal_visible(c: Ctx):
h = c.lst.model.get(i)
return h is not None and h.kind in ("sep", "funchdr", "label")
- row = next((i for i in range(c.lst.cursor,
- min(c.lst.cursor + 400, len(c.lst.model)))
- if _banner(i)), None)
+ row = next(
+ (
+ i
+ for i in range(c.lst.cursor, min(c.lst.cursor + 400, len(c.lst.model)))
+ if _banner(i)
+ ),
+ None,
+ )
if row is None:
c.check("found a banner row to refuse on", False)
return
@@ -3362,11 +4357,13 @@ async def s_opfmt_refusal_visible(c: Ctx):
await c.pause(0.05)
if c.lst.cursor == row:
break
- c.lst.action_op_format("cycle") # no keypress: as the RPC does it
+ c.lst.action_op_format("cycle") # no keypress: as the RPC does it
await c.wait(lambda: c.status() != good, 15)
- c.check("a refusal replaces the previous success on the status bar",
- c.status() != good and "reformat" in c.status(),
- f"still showing {c.status()!r}")
+ c.check(
+ "a refusal replaces the previous success on the status bar",
+ c.status() != good and "reformat" in c.status(),
+ f"still showing {c.status()!r}",
+ )
finally:
try:
c.prog.op_format(head.ea, mode="default", n=show.get("n", -1))
@@ -3380,8 +4377,11 @@ def _styled_cols(strip, style_attr, want):
cols, x = [], 0
for seg in strip:
st = seg.style
- if st is not None and getattr(st, style_attr, None) is not None \
- and str(getattr(st, style_attr)) == want:
+ if (
+ st is not None
+ and getattr(st, style_attr, None) is not None
+ and str(getattr(st, style_attr)) == want
+ ):
cols.extend(range(x, x + len(seg.text)))
x += len(seg.text)
return cols
@@ -3397,15 +4397,22 @@ async def s_opfmt_highlight(c: Ctx):
(usually the same characters) and used to win.
"""
from idatui.app import _S_OPERAND
+
await c.open_biggest("listing")
c.lst.model.load_all()
lst = c.lst
lst.focus()
# A row with two operands, so "which one" is a real question.
- row = next((i for i in range(lst.cursor, min(lst.cursor + 400, len(lst.model)))
- if lst.model.get(i) is not None
- and (lst.model.get(i).ops or ()) and len(lst.model.get(i).ops) >= 2),
- None)
+ row = next(
+ (
+ i
+ for i in range(lst.cursor, min(lst.cursor + 400, len(lst.model)))
+ if lst.model.get(i) is not None
+ and (lst.model.get(i).ops or ())
+ and len(lst.model.get(i).ops) >= 2
+ ),
+ None,
+ )
if row is None:
c.check("found a row with two operands", False)
return
@@ -3424,12 +4431,17 @@ async def s_opfmt_highlight(c: Ctx):
strip = lst.render_line(row - round(lst.scroll_offset.y))
cols = _styled_cols(strip, "bgcolor", want_bg)
seen.append((n, min(cols) if cols else None, max(cols) + 1 if cols else None))
- c.check(f"operand {n} ({h.text[lo:hi]!r}) is marked when the cursor is on it",
- cols and min(cols) == base + lo and max(cols) + 1 == base + hi,
- f"marked={min(cols) if cols else None}.."
- f"{max(cols)+1 if cols else None} want={base+lo}..{base+hi}")
- c.check("the mark MOVES between the operands (it isn't the whole line)",
- len({s[1] for s in seen}) == len(seen), str(seen))
+ c.check(
+ f"operand {n} ({h.text[lo:hi]!r}) is marked when the cursor is on it",
+ cols and min(cols) == base + lo and max(cols) + 1 == base + hi,
+ f"marked={min(cols) if cols else None}.."
+ f"{max(cols) + 1 if cols else None} want={base + lo}..{base + hi}",
+ )
+ c.check(
+ "the mark MOVES between the operands (it isn't the whole line)",
+ len({s[1] for s in seen}) == len(seen),
+ str(seen),
+ )
# ... and the marked operand is the one the edit acts on — either it gets
# reformatted, or the refusal names that same operand. What must never
# happen is a different operand quietly changing.
@@ -3438,11 +4450,14 @@ async def s_opfmt_highlight(c: Ctx):
try:
r = c.prog.op_format(h.ea, mode="show", col=lst.op_col())
got, why = r.get("n"), ""
- except IDAToolError as e: # "operand N (rsp) has no format"
+ except IDAToolError as e: # "operand N (rsp) has no format"
m = re.search(r"operand (\d+)", e.message)
got, why = (int(m.group(1)) if m else None), e.message
- c.check(f"marked operand {n} is the one acted on (or refused)",
- got == n, f"marked op{n}, worker said op{got} {why}")
+ c.check(
+ f"marked operand {n} is the one acted on (or refused)",
+ got == n,
+ f"marked op{n}, worker said op{got} {why}",
+ )
@scenario("opfmt_sticks_to_its_literal")
@@ -3465,8 +4480,7 @@ async def s_opfmt_sticks(c: Ctx):
# The second literal must be one whose printed WIDTH changes as it
# cycles (0x36u vs 54), or the cursor never falls off it and the
# test proves nothing.
- if len(recs) >= 2 and recs[0][1] < recs[1][0] \
- and int(recs[1][2], 16) >= 16:
+ if len(recs) >= 2 and recs[0][1] < recs[1][0] and int(recs[1][2], 16) >= 16:
pick = (fn, line, recs)
break
if pick:
@@ -3476,7 +4490,7 @@ async def s_opfmt_sticks(c: Ctx):
return
fn, line, recs = pick
first, second = recs[0], recs[1]
- target = (second[3], second[4]) # (ea, opnum) of the literal we mean
+ target = (second[3], second[4]) # (ea, opnum) of the literal we mean
other = (first[3], first[4])
try:
# Make it WIDE first (0x30, four characters). The cursor then sits on a
@@ -3491,33 +4505,47 @@ async def s_opfmt_sticks(c: Ctx):
return
dec = c.dec
dec.focus()
- wide = next((r for r in dec._nums.get(line, ())
- if (r[3], r[4]) == target), None)
+ wide = next(
+ (r for r in dec._nums.get(line, ()) if (r[3], r[4]) == target), None
+ )
if wide is None or wide[1] - wide[0] < 3:
- c.check("the literal is now printed wide", False,
- f"nums={dec._nums.get(line)}")
+ c.check(
+ "the literal is now printed wide", False, f"nums={dec._nums.get(line)}"
+ )
return
- dec.cursor, dec.cursor_x = line, wide[1] - 1 # its LAST character
+ dec.cursor, dec.cursor_x = line, wide[1] - 1 # its LAST character
dec.refresh()
await c.pause(0.05)
seen = []
for _ in range(3):
before = dec._texts[line]
await c.press("o")
- await c.wait(lambda: dec.loaded_ea == fn.addr
- and line < len(dec._texts)
- and dec._texts[line] != before, 30)
- cur = next(((r[3], r[4]) for r in dec._nums.get(line, ())
- if r[0] <= dec.cursor_x < r[1]), None)
+ await c.wait(
+ lambda: (
+ dec.loaded_ea == fn.addr
+ and line < len(dec._texts)
+ and dec._texts[line] != before
+ ),
+ 30,
+ )
+ cur = next(
+ (
+ (r[3], r[4])
+ for r in dec._nums.get(line, ())
+ if r[0] <= dec.cursor_x < r[1]
+ ),
+ None,
+ )
seen.append(cur)
- c.check("every press stays on the literal we started on",
- all(s == target for s in seen),
- f"target={target} other={other} landed={seen} "
- f"line={dec._texts[line].strip()!r}")
+ c.check(
+ "every press stays on the literal we started on",
+ all(s == target for s in seen),
+ f"target={target} other={other} landed={seen} "
+ f"line={dec._texts[line].strip()!r}",
+ )
finally:
try:
- c.prog.pc_num_format(fn.addr, mode="default", line=line,
- col=second[0])
+ c.prog.pc_num_format(fn.addr, mode="default", line=line, col=second[0])
except Exception: # noqa: BLE001
pass
c.prog.bump_names()
@@ -3533,6 +4561,7 @@ async def s_cursor_on_visible(c: Ctx):
that hadn't changed.
"""
from idatui.rpc import cursor_on
+
app = c.app
fn = await c.open_biggest("listing")
c.lst.model.load_all()
@@ -3541,44 +4570,66 @@ async def s_cursor_on_visible(c: Ctx):
await c.pause(0.1)
top = round(lst.scroll_offset.y)
# A token that occurs both before the viewport and inside it.
- here = next((t for t in ("rax", "rsp", "eax", "rbp", "rdi")
- if any(t in (lst._line_plain(i) or "")
- for i in range(top, min(top + 20, lst.total)))
- and any(t in (lst._line_plain(i) or "") for i in range(0, top))),
- None)
+ here = next(
+ (
+ t
+ for t in ("rax", "rsp", "eax", "rbp", "rdi")
+ if any(
+ t in (lst._line_plain(i) or "")
+ for i in range(top, min(top + 20, lst.total))
+ )
+ and any(t in (lst._line_plain(i) or "") for i in range(0, top))
+ ),
+ None,
+ )
if here is None:
- c.check("found a token both above and inside the viewport", False,
- f"top={top}")
+ c.check("found a token both above and inside the viewport", False, f"top={top}")
return
found = cursor_on(app, here)
await c.pause(0.1)
c.check(f"cursor_on({here!r}) found it", found)
vis = round(lst.scroll_offset.y)
- c.check("it lands inside the viewport, not thousands of rows above",
- vis <= lst.cursor < vis + lst._visible_height(),
- f"cursor={lst.cursor} viewport={vis}..{vis + lst._visible_height()}")
- c.check("and it searched from the viewport, not from row 0",
- lst.cursor >= top, f"cursor={lst.cursor} was top={top}")
+ c.check(
+ "it lands inside the viewport, not thousands of rows above",
+ vis <= lst.cursor < vis + lst._visible_height(),
+ f"cursor={lst.cursor} viewport={vis}..{vis + lst._visible_height()}",
+ )
+ c.check(
+ "and it searched from the viewport, not from row 0",
+ lst.cursor >= top,
+ f"cursor={lst.cursor} was top={top}",
+ )
# An explicit line still wins, and lands visibly.
- far = next((i for i in range(0, min(top, lst.total))
- if here in (lst._line_plain(i) or "")), None)
+ far = next(
+ (
+ i
+ for i in range(0, min(top, lst.total))
+ if here in (lst._line_plain(i) or "")
+ ),
+ None,
+ )
if far is not None:
cursor_on(app, here, line=far)
await c.pause(0.1)
v2 = round(lst.scroll_offset.y)
- c.check("an explicit line is honoured AND scrolled into view",
- lst.cursor == far and v2 <= far < v2 + lst._visible_height(),
- f"cursor={lst.cursor} want={far} viewport={v2}")
+ c.check(
+ "an explicit line is honoured AND scrolled into view",
+ lst.cursor == far and v2 <= far < v2 + lst._visible_height(),
+ f"cursor={lst.cursor} want={far} viewport={v2}",
+ )
# The `cursor` verb has the same duty: a driver that parks the cursor for
# an edit must leave it where the edit can be watched.
from idatui.rpc import place_cursor
+
deep = min(lst.total - 1, 900)
place_cursor(lst, deep, 0)
await c.pause(0.1)
v3 = round(lst.scroll_offset.y)
- c.check("`cursor line=` scrolls to what it selected",
- v3 <= deep < v3 + lst._visible_height(),
- f"cursor={lst.cursor} viewport={v3}..{v3 + lst._visible_height()}")
+ c.check(
+ "`cursor line=` scrolls to what it selected",
+ v3 <= deep < v3 + lst._visible_height(),
+ f"cursor={lst.cursor} viewport={v3}..{v3 + lst._visible_height()}",
+ )
@scenario("opfmt_decomp")
@@ -3594,7 +4645,7 @@ async def s_opfmt_decomp(c: Ctx):
continue
for i, txt in enumerate(d.code.split("\n")):
m = re.search(r"[=<>+\-*/(,]\s(\d{2,}|0x[0-9A-Fa-f]{2,})\b", txt)
- if m and "//" not in txt[:m.start()]:
+ if m and "//" not in txt[: m.start()]:
pick = (fn, i, m.start(1))
break
if pick:
@@ -3615,14 +4666,24 @@ async def s_opfmt_decomp(c: Ctx):
before = dec._texts[line]
try:
await c.press("o")
- await c.wait(lambda: dec.loaded_ea == fn.addr and line < len(dec._texts)
- and dec._texts[line] != before, 30)
- c.check("'o' re-renders the pseudocode literal",
- line < len(dec._texts) and dec._texts[line] != before,
- f"{before!r} -> {dec._texts[line] if line < len(dec._texts) else None!r}")
- c.check("the status says which format",
- any(f in c.status() for f in ("hex", "dec", "oct", "char", "default")),
- f"status={c.status()!r}")
+ await c.wait(
+ lambda: (
+ dec.loaded_ea == fn.addr
+ and line < len(dec._texts)
+ and dec._texts[line] != before
+ ),
+ 30,
+ )
+ c.check(
+ "'o' re-renders the pseudocode literal",
+ line < len(dec._texts) and dec._texts[line] != before,
+ f"{before!r} -> {dec._texts[line] if line < len(dec._texts) else None!r}",
+ )
+ c.check(
+ "the status says which format",
+ any(f in c.status() for f in ("hex", "dec", "oct", "char", "default")),
+ f"status={c.status()!r}",
+ )
finally:
try:
c.prog.pc_num_format(fn.addr, mode="default", line=line, col=col)
@@ -3664,10 +4725,13 @@ async def _open_graph(c: Ctx, fn=None, t=60):
await c.press("space")
gv = app.query_one(GraphView)
ok = await c.wait(lambda: app._active == "graph" and gv.lay is not None, t)
- c.check("the graph opened", ok,
- f"active={app._active} sticky={app._graph_sticky} "
- f"focus={type(app.focused).__name__} prompt={app._prompt_active()} "
- f"status={c.status()!r}")
+ c.check(
+ "the graph opened",
+ ok,
+ f"active={app._active} sticky={app._graph_sticky} "
+ f"focus={type(app.focused).__name__} prompt={app._prompt_active()} "
+ f"status={c.status()!r}",
+ )
return fn, gv
@@ -3675,26 +4739,58 @@ async def _open_graph(c: Ctx, fn=None, t=60):
async def s_graph_open(c: Ctx):
app = c.app
fn, gv = await _open_graph(c)
- c.check("space opens the graph view", app._active == "graph",
- f"active={app._active} status={c.status()}")
+ c.check(
+ "space opens the graph view",
+ app._active == "graph",
+ f"active={app._active} status={c.status()}",
+ )
if gv.lay is None:
return
- c.check("the graph has the function's blocks",
- len(gv.lay.nodes) == len(gv.fc.blocks) and len(gv.lay.nodes) > 1,
- f"nodes={len(gv.lay.nodes)} blocks={len(gv.fc.blocks) if gv.fc else 0}")
- c.check("it is the right function", gv.fc is not None and gv.fc.func_ea == fn.addr,
- f"{gv.fc.func_ea if gv.fc else None:#x} want {fn.addr:#x}")
+ c.check(
+ "the graph has the function's blocks",
+ len(gv.lay.nodes) == len(gv.fc.blocks) and len(gv.lay.nodes) > 1,
+ f"nodes={len(gv.lay.nodes)} blocks={len(gv.fc.blocks) if gv.fc else 0}",
+ )
+ c.check(
+ "it is the right function",
+ gv.fc is not None and gv.fc.func_ea == fn.addr,
+ f"{gv.fc.func_ea if gv.fc else None:#x} want {fn.addr:#x}",
+ )
c.check("the cursor starts on a real address", gv._cursor_ea() is not None)
# The invariant the whole dummy-node machinery exists for.
boxes = [(n.x, n.y, n.right, n.y + n.h - 1) for n in gv.lay.nodes]
- overlap = any(a[0] <= b[2] and b[0] <= a[2] and a[1] <= b[3] and b[1] <= a[3]
- for i, a in enumerate(boxes) for b in boxes[i + 1:])
+ overlap = any(
+ a[0] <= b[2] and b[0] <= a[2] and a[1] <= b[3] and b[1] <= a[3]
+ for i, a in enumerate(boxes)
+ for b in boxes[i + 1 :]
+ )
c.check("no two blocks overlap", not overlap)
c.check("the status names the graph", "graph" in c.status(), c.status())
+ old_fc = gv.fc
+ old_ea = gv._cursor_ea()
+ await c.press("ctrl+r")
+ rebuilt = await c.wait(
+ lambda: (
+ app.is_graph
+ and gv.fc is not None
+ and gv.fc is not old_fc
+ and gv.lay is not None
+ ),
+ 30,
+ )
+ c.check("Ctrl+R rebuilds the graph", rebuilt)
+ c.check(
+ "Ctrl+R preserves the graph cursor",
+ gv._cursor_ea() == old_ea,
+ f"got={gv._cursor_ea()} want={old_ea}",
+ )
await c.press("space")
await c.wait(lambda: app._active != "graph", 15)
- c.check("space returns to the listing", app._active == "listing",
- f"active={app._active}")
+ c.check(
+ "space returns to the listing",
+ app._active == "listing",
+ f"active={app._active}",
+ )
@scenario("graph_nav")
@@ -3707,8 +4803,11 @@ async def s_graph_nav(c: Ctx):
start_ea = gv._cursor_ea()
await c.press("j")
await c.pause(0.05)
- c.check("j moves the cursor within the block", gv._cursor_ea() != start_ea,
- f"{start_ea:#x} -> {gv._cursor_ea():#x}")
+ c.check(
+ "j moves the cursor within the block",
+ gv._cursor_ea() != start_ea,
+ f"{start_ea:#x} -> {gv._cursor_ea():#x}",
+ )
await c.press("k")
await c.pause(0.05)
c.check("k comes back", gv._cursor_ea() == start_ea)
@@ -3718,24 +4817,35 @@ async def s_graph_nav(c: Ctx):
if succs:
await c.press("J")
await c.pause(0.1)
- c.check("J follows an edge to a successor block",
- gv.cursor_node == succs[0][0],
- f"node={gv.cursor_node} want={succs[0][0]}")
+ c.check(
+ "J follows an edge to a successor block",
+ gv.cursor_node == succs[0][0],
+ f"node={gv.cursor_node} want={succs[0][0]}",
+ )
await c.press("K")
await c.pause(0.1)
- c.check("K goes back up an edge", gv.cursor_node == b0,
- f"node={gv.cursor_node} want={b0}")
+ c.check(
+ "K goes back up an edge",
+ gv.cursor_node == b0,
+ f"node={gv.cursor_node} want={b0}",
+ )
await c.press("0")
await c.pause(0.1)
- c.check("0 returns to the entry block", gv.cursor_node == gv.fc.entry,
- f"node={gv.cursor_node} entry={gv.fc.entry}")
+ c.check(
+ "0 returns to the entry block",
+ gv.cursor_node == gv.fc.entry,
+ f"node={gv.cursor_node} entry={gv.fc.entry}",
+ )
# the cursor is always scrolled into view
cell = gv._cursor_cell()
top, left = int(gv.scroll_offset.y), int(gv.scroll_offset.x)
- c.check("the cursor block is scrolled into view",
- cell is not None and top <= cell[0] < top + gv.size.height
- and left <= cell[1] < left + gv.size.width,
- f"cell={cell} scroll=({top},{left}) size={gv.size}")
+ c.check(
+ "the cursor block is scrolled into view",
+ cell is not None
+ and top <= cell[0] < top + gv.size.height
+ and left <= cell[1] < left + gv.size.width,
+ f"cell={cell} scroll=({top},{left}) size={gv.size}",
+ )
@scenario("graph_zoom")
@@ -3751,18 +4861,24 @@ async def s_graph_zoom(c: Ctx):
await c.press("z")
await c.pause(0.15)
seen.append(gv.ZOOMS[gv._zoom])
- c.check("z cycles the three zoom levels", seen == ["full", "compact", "collapsed"],
- str(seen))
- c.check("collapsed is much smaller than full", gv.lay.height < full_h,
- f"{gv.lay.height} vs {full_h}")
+ c.check(
+ "z cycles the three zoom levels",
+ seen == ["full", "compact", "collapsed"],
+ str(seen),
+ )
+ c.check(
+ "collapsed is much smaller than full",
+ gv.lay.height < full_h,
+ f"{gv.lay.height} vs {full_h}",
+ )
c.check("the cursor survives a zoom", gv._cursor_ea() is not None)
- c.check("the status still names the function",
- gv.fc.name in c.status(), c.status())
+ c.check("the status still names the function", gv.fc.name in c.status(), c.status())
await c.press("z")
await c.pause(0.15)
c.check("and wraps back to full", gv.ZOOMS[gv._zoom] == "full")
- c.check("canvas is restored", gv.lay.height == full_h,
- f"{gv.lay.height} vs {full_h}")
+ c.check(
+ "canvas is restored", gv.lay.height == full_h, f"{gv.lay.height} vs {full_h}"
+ )
@scenario("graph_engine")
@@ -3776,6 +4892,7 @@ async def s_graph_engine(c: Ctx):
them), and a missing pytriskel degrades to native instead of raising.
"""
from idatui import graph_triskel
+
app = c.app
fn, gv = await _open_graph(c)
if gv.lay is None:
@@ -3783,39 +4900,56 @@ async def s_graph_engine(c: Ctx):
return
ea = gv._cursor_ea()
first = gv.lay.stats["engine"]
- c.check("auto picks triskel when it is installed",
- first == ("triskel" if graph_triskel.available() else "native"),
- f"engine={first} available={graph_triskel.available()}")
+ c.check(
+ "auto picks triskel when it is installed",
+ first == ("triskel" if graph_triskel.available() else "native"),
+ f"engine={first} available={graph_triskel.available()}",
+ )
seen = [first]
for _ in range(3):
await c.press("e")
await c.pause(0.2)
seen.append(gv.lay.stats["engine"])
- c.check(f"the view survives engine={gv._engine}",
- gv.lay is not None and gv.lay.width > 0 and gv.lay.height > 0,
- f"{gv.lay.width}x{gv.lay.height}")
- c.check(f"the cursor keeps an address on engine={gv._engine}",
- gv._cursor_ea() is not None)
- c.check(f"the canvas covers every edge on engine={gv._engine}",
- all(0 <= col < gv.lay.width and 0 <= row < gv.lay.height
- for rt in _routes_of(gv.lay) for row, col in rt),
- f"canvas {gv.lay.width}x{gv.lay.height}")
+ c.check(
+ f"the view survives engine={gv._engine}",
+ gv.lay is not None and gv.lay.width > 0 and gv.lay.height > 0,
+ f"{gv.lay.width}x{gv.lay.height}",
+ )
+ c.check(
+ f"the cursor keeps an address on engine={gv._engine}",
+ gv._cursor_ea() is not None,
+ )
+ c.check(
+ f"the canvas covers every edge on engine={gv._engine}",
+ all(
+ 0 <= col < gv.lay.width and 0 <= row < gv.lay.height
+ for rt in _routes_of(gv.lay)
+ for row, col in rt
+ ),
+ f"canvas {gv.lay.width}x{gv.lay.height}",
+ )
c.check("e cycles back round", seen[0] == seen[-1], str(seen))
c.check("native was one of them", "native" in seen, str(seen))
- c.check("the status names the engine", "graph:" in c.status() or
- gv.fc.name in c.status(), c.status())
+ c.check(
+ "the status names the engine",
+ "graph:" in c.status() or gv.fc.name in c.status(),
+ c.status(),
+ )
if ea is not None:
- c.check("the cursor address is unchanged by relayout",
- gv._cursor_ea() is not None)
+ c.check(
+ "the cursor address is unchanged by relayout", gv._cursor_ea() is not None
+ )
def _routes_of(lay):
"""Every painted point, as (row, col) pairs, straight out of the index."""
out = []
for row, runs in lay.painting.hruns.items():
- out.append([(row, lo) for lo, _hi, _s, _e in runs]
- + [(row, hi) for _lo, hi, _s, _e in runs])
+ out.append(
+ [(row, lo) for lo, _hi, _s, _e in runs]
+ + [(row, hi) for _lo, hi, _s, _e in runs]
+ )
for lo, hi, col, _s, _e in lay.painting.vruns:
out.append([(lo, col), (hi, col)])
return out
@@ -3831,6 +4965,7 @@ async def s_graph_render(c: Ctx):
if gv.lay is None:
c.check("graph loaded", False)
return
+
# The layout being ready (`gv.lay`) is not the same as the view having a
# SIZE to render into -- that needs a laid-out frame, and reading glyphs
# before one lands scrapes an empty canvas. Gate on the paint itself.
@@ -3839,17 +4974,28 @@ async def s_graph_render(c: Ctx):
await c.wait(lambda: gv.size.height > 0 and "\u250c" in _blob(), 10)
blob = _blob()
- c.check("boxes are drawn", blob.count("\u250c") >= 1 and blob.count("\u2502") > 4,
- f"corners={blob.count(chr(0x250c))} verts={blob.count(chr(0x2502))}")
- c.check("edges are drawn", any(ch in blob for ch in "\u25bc\u2570\u256d\u256e\u256f"),
- "no edge glyphs on screen")
+ c.check(
+ "boxes are drawn",
+ blob.count("\u250c") >= 1 and blob.count("\u2502") > 4,
+ f"corners={blob.count(chr(0x250C))} verts={blob.count(chr(0x2502))}",
+ )
+ c.check(
+ "edges are drawn",
+ any(ch in blob for ch in "\u25bc\u2570\u256d\u256e\u256f"),
+ "no edge glyphs on screen",
+ )
ea = gv._cursor_ea()
head = gv.cur_head()
- c.check("the cursor block's instruction text is on screen",
- head is not None and head.text.split(" ")[0] in blob,
- f"mnem={head.text.split(' ')[0] if head else None}")
- c.check("the address gutter renders at full zoom",
- ea is not None and f"{ea:08X}" in blob, f"ea={ea:#x}")
+ c.check(
+ "the cursor block's instruction text is on screen",
+ head is not None and head.text.split(" ")[0] in blob,
+ f"mnem={head.text.split(' ')[0] if head else None}",
+ )
+ c.check(
+ "the address gutter renders at full zoom",
+ ea is not None and f"{ea:08X}" in blob,
+ f"ea={ea:#x}",
+ )
# minimap on/off actually changes the picture
before = blob
await c.press("m")
@@ -3881,20 +5027,26 @@ async def s_graph_click(c: Ctx):
top, left = int(gv.scroll_offset.y), int(gv.scroll_offset.x)
target = None
for n in gv.lay.nodes:
- if (n.id != gv.cursor_node and top <= n.y + 1 < top + gv.size.height - 1
- and left <= n.x + 2 < left + gv.size.width - 2):
+ if (
+ n.id != gv.cursor_node
+ and top <= n.y + 1 < top + gv.size.height - 1
+ and left <= n.x + 2 < left + gv.size.width - 2
+ ):
target = n
break
if target is None:
c.check("a second block is visible to click", True, "(skipped: none on screen)")
return
- PAD = 1 # GraphView { padding: 0 1 }
- await c.pilot.click(GraphView,
- offset=(PAD + target.x + 2 - left, target.y + 1 - top))
+ PAD = 1 # GraphView { padding: 0 1 }
+ await c.pilot.click(
+ GraphView, offset=(PAD + target.x + 2 - left, target.y + 1 - top)
+ )
await c.pause(0.15)
- c.check("clicking a block moves the cursor into it",
- gv.cursor_node == target.id,
- f"node={gv.cursor_node} want={target.id}")
+ c.check(
+ "clicking a block moves the cursor into it",
+ gv.cursor_node == target.id,
+ f"node={gv.cursor_node} want={target.id}",
+ )
@scenario("graph_minimap")
@@ -3911,14 +5063,19 @@ async def s_graph_minimap(c: Ctx):
# frame -- not just a settled app.
await c.wait(lambda: gv._minimap_rect() is not None, 5)
rect = gv._minimap_rect()
- c.check("the minimap has a hit-box while it's shown", rect is not None,
- f"size={gv.size} shown={gv._show_minimap}")
+ c.check(
+ "the minimap has a hit-box while it's shown",
+ rect is not None,
+ f"size={gv.size} shown={gv._show_minimap}",
+ )
if rect is None:
return
left, top, mw, mh = rect
- c.check("it sits inside the pane, clear of the scrollbar",
- left + mw <= gv.size.width - 1,
- f"left={left} w={mw} pane={gv.size.width}")
+ c.check(
+ "it sits inside the pane, clear of the scrollbar",
+ left + mw <= gv.size.width - 1,
+ f"left={left} w={mw} pane={gv.size.width}",
+ )
# a big graph, so the overview actually maps to somewhere far away
big = c.find_func(lambda f: f.size > 0x300) or fn
@@ -3946,33 +5103,45 @@ async def s_graph_minimap(c: Ctx):
PAD = 1
await c.pilot.click(GraphView, offset=(PAD + left + mw // 2, top + mh - 2))
await c.pause(0.2)
- c.check("clicking low on the minimap scrolls the view down",
- gv.scroll_offset.y > 0, f"scroll_y={gv.scroll_offset.y}")
+ c.check(
+ "clicking low on the minimap scrolls the view down",
+ gv.scroll_offset.y > 0,
+ f"scroll_y={gv.scroll_offset.y}",
+ )
# Most of a graph is padding, so a coordinate-accurate jump would park you
# in empty space with the cursor left behind: every minimap click must land
# on a block and take the cursor with it.
landed = gv.lay.by_id.get(gv.cursor_node)
- c.check("it snaps the cursor onto a real block",
- landed is not None and landed.block is not None,
- f"node={gv.cursor_node}")
- c.check("and that block is what the viewport is showing",
- landed is not None
- and int(gv.scroll_offset.y) <= landed.y + landed.h
- and landed.y <= int(gv.scroll_offset.y) + gv.size.height,
- f"node.y={landed.y if landed else None} "
- f"scroll={gv.scroll_offset.y} h={gv.size.height}")
+ c.check(
+ "it snaps the cursor onto a real block",
+ landed is not None and landed.block is not None,
+ f"node={gv.cursor_node}",
+ )
+ c.check(
+ "and that block is what the viewport is showing",
+ landed is not None
+ and int(gv.scroll_offset.y) <= landed.y + landed.h
+ and landed.y <= int(gv.scroll_offset.y) + gv.size.height,
+ f"node.y={landed.y if landed else None} "
+ f"scroll={gv.scroll_offset.y} h={gv.size.height}",
+ )
low_node = gv.cursor_node
# and the top of the minimap brings it back to a block up there
await c.pilot.click(GraphView, offset=(PAD + left + mw // 2, top + 1))
await c.pause(0.2)
top_node = gv.lay.by_id.get(gv.cursor_node)
- c.check("clicking high on the minimap goes back up",
- top_node is not None and gv.cursor_node != low_node
- and top_node.y < gv.lay.by_id[low_node].y,
- f"top={gv.cursor_node} low={low_node}")
- c.check("the cursor still has a real address after a minimap jump",
- gv._cursor_ea() is not None)
+ c.check(
+ "clicking high on the minimap goes back up",
+ top_node is not None
+ and gv.cursor_node != low_node
+ and top_node.y < gv.lay.by_id[low_node].y,
+ f"top={gv.cursor_node} low={low_node}",
+ )
+ c.check(
+ "the cursor still has a real address after a minimap jump",
+ gv._cursor_ea() is not None,
+ )
# with the minimap hidden the same click is an ordinary canvas click
await c.press("m")
@@ -3983,16 +5152,19 @@ async def s_graph_minimap(c: Ctx):
# Panning into the padding (which is most of the canvas) must not strand
# you on a blank screen with nothing to navigate back by.
- gv.scroll_to(y=max(gv.lay.height - 1, 0), x=max(gv.lay.width - 1, 0),
- animate=False)
+ gv.scroll_to(y=max(gv.lay.height - 1, 0), x=max(gv.lay.width - 1, 0), animate=False)
await c.pause(0.1)
- c.check("a pan past the graph leaves the viewport empty",
- not gv._viewport_has_block() or True) # setup, not an assertion
+ c.check(
+ "a pan past the graph leaves the viewport empty",
+ not gv._viewport_has_block() or True,
+ ) # setup, not an assertion
gv._snap_into_view()
await c.pause(0.1)
- c.check("panning into empty padding snaps back to a block",
- gv._viewport_has_block(),
- f"scroll={gv.scroll_offset} canvas={gv.lay.width}x{gv.lay.height}")
+ c.check(
+ "panning into empty padding snaps back to a block",
+ gv._viewport_has_block(),
+ f"scroll={gv.scroll_offset} canvas={gv.lay.width}x{gv.lay.height}",
+ )
@scenario("graph_rename")
@@ -4011,8 +5183,10 @@ async def s_graph_rename(c: Ctx):
new = f"gtest_{os.getpid()}"
await c.press("n")
await c.wait(lambda: app.query_one("#rename", Input).display, 10)
- c.check("n opens the rename prompt from the graph",
- app.query_one("#rename", Input).display)
+ c.check(
+ "n opens the rename prompt from the graph",
+ app.query_one("#rename", Input).display,
+ )
inp = app.query_one("#rename", Input)
inp.value = ""
await c.type(new)
@@ -4023,12 +5197,16 @@ async def s_graph_rename(c: Ctx):
got = app.program.resolve(new)
except Exception: # noqa: BLE001
got = None
- c.check("the rename reached the database", got == ea,
- f"resolve({new}) -> {got if got is None else hex(got)} want {ea:#x}")
+ c.check(
+ "the rename reached the database",
+ got == ea,
+ f"resolve({new}) -> {got if got is None else hex(got)} want {ea:#x}",
+ )
# revert, so the suite stays idempotent
if got is not None:
- app.program.client.invoke(
- "rename", batch={"data": {"addr": hex(ea), "new": ""}})
+ app.program.client.call(
+ remote_ops.rename, batch={"data": {"addr": hex(ea), "new": ""}}
+ )
app.program.bump_names()
@@ -4046,17 +5224,30 @@ async def s_graph_sticky(c: Ctx):
c.check("a second function exists", True, "(skipped)")
return
app._goto(other.name)
- ok = await c.wait(lambda: app._cur is not None and app._cur.ea == other.addr
- and app._active == "graph"
- and gv.fc is not None and gv.fc.func_ea == other.addr, 60)
- c.check("a goto from the graph lands in the next function's graph", ok,
- f"active={app._active} sticky={app._graph_sticky} "
- f"cur={app._cur.ea if app._cur else None} "
- f"fc={gv.fc.func_ea if gv.fc else None} want={other.addr:#x}")
+ ok = await c.wait(
+ lambda: (
+ app._cur is not None
+ and app._cur.ea == other.addr
+ and app._active == "graph"
+ and gv.fc is not None
+ and gv.fc.func_ea == other.addr
+ ),
+ 60,
+ )
+ c.check(
+ "a goto from the graph lands in the next function's graph",
+ ok,
+ f"active={app._active} sticky={app._graph_sticky} "
+ f"cur={app._cur.ea if app._cur else None} "
+ f"fc={gv.fc.func_ea if gv.fc else None} want={other.addr:#x}",
+ )
await c.press("space")
await c.wait(lambda: app._active != "graph", 15)
- c.check("space still leaves graph mode", app._active == "listing",
- f"active={app._active}")
+ c.check(
+ "space still leaves graph mode",
+ app._active == "listing",
+ f"active={app._active}",
+ )
c.check("and it stops being sticky", app._graph_sticky is False)
@@ -4074,13 +5265,14 @@ async def run(binary, only=None):
#
# So: work on a scratch copy, seeded from a golden database that nothing
# ever writes back to.
- async with staged(binary, lambda p: IdaTui(open_path=p, keepalive=False),
- prefix="idatui-pilot-") as target:
+ async with staged(
+ binary, lambda p: IdaTui(open_path=p, keepalive=False), prefix="idatui-pilot-"
+ ) as target:
await _run_on(target, only)
async def _run_on(binary, only=None):
- # Code Mode attaches a registered GUI or starts/reuses a managed worker.
+ # IDA Nexus attaches a registered GUI or starts/reuses a managed worker.
app = IdaTui(open_path=binary, keepalive=False)
async with app.run_test(size=(140, 44)) as pilot:
c = Ctx(app, pilot)
@@ -4097,7 +5289,9 @@ async def _run_on(binary, only=None):
except _StopSuite:
raise
except Exception as e: # noqa: BLE001 — isolate: one scenario's crash
- print(f"── {name} ({asyncio.get_event_loop().time() - _t0:.1f}s) CRASHED")
+ print(
+ f"── {name} ({asyncio.get_event_loop().time() - _t0:.1f}s) CRASHED"
+ )
c.check("scenario did not crash", False, f"{type(e).__name__}: {e}")
traceback.print_exc()
# Headless run_test does not reliably emit App.Unmount; explicitly release
@@ -4133,7 +5327,9 @@ def main(argv):
if binary is None: # default target for the pilot
binary = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
- "targets", "echo")
+ "targets",
+ "echo",
+ )
try:
asyncio.run(run(binary, only))
except _StopSuite:
diff --git a/tests/test_thumb_ui.py b/tests/test_thumb_ui.py
index f12fbc4..1b4c438 100644
--- a/tests/test_thumb_ui.py
+++ b/tests/test_thumb_ui.py
@@ -50,7 +50,7 @@ def check(name, ok, detail=""):
#:
#: This suite used to delete <BIN>.i64 and reopen the SAME path for each phase.
#: 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 worker can still
+#: IDA Nexus the database is leased and the previous phase's worker can still
#: hold it through its lease grace, so the delete raced a live owner and the
#: next open never produced a listing (the crash this fixed). Separate paths
#: cannot collide, and nothing has to wait for anyone else to let go.
diff --git a/tests/test_trace_ui.py b/tests/test_trace_ui.py
index 855ce4d..586ce2e 100644
--- a/tests/test_trace_ui.py
+++ b/tests/test_trace_ui.py
@@ -115,7 +115,7 @@ async def run() -> int:
# `app._t` is assigned the moment the key is handled, so it is NOT a
# signal that the VIEW has followed -- the navigation it kicks off
# runs in a worker. Waiting on it and then reading the cursor was a
- # race that the (slower) Code Mode backend loses. Gate on the thing
+ # race that the (slower) IDA Nexus backend loses. Gate on the thing
# the check is about.
await settle(app, lambda: app._t == 1 and lst._cursor_ea() == t.ip(1),
timeout=20)
diff --git a/uv.lock b/uv.lock
index 7cbfa25..42e544a 100644
--- a/uv.lock
+++ b/uv.lock
@@ -12,31 +12,31 @@ wheels = [
]
[[package]]
-name = "ida-codemode"
-version = "0.6.1"
+name = "ida-domain"
+version = "0.5.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "ida-domain" },
+ { name = "idapro" },
{ name = "packaging" },
- { name = "zeromcp" },
+ { name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/5d/55/b9b72626e371bb36b712659b567ad1d890947c237bfbcaeccfb30f12fe80/ida_codemode-0.6.1.tar.gz", hash = "sha256:44df2a986d24a7e35e64b92c910a21eb485dc4c47d1e9db1e572e8fc1aa08a44", size = 188081, upload-time = "2026-08-13T16:46:35.504Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/80/34/be087d3ea1c3a6573e0660cb5b40f0c4ade9ae5772cf1c5d98d52472d28b/ida_domain-0.5.1.tar.gz", hash = "sha256:c49f2c417047d882e954f651b50a709a3f27903b33ba533b794aa54d6536d16f", size = 396413, upload-time = "2026-08-10T13:32:48.45Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/05/20/2397e9b34cefe7ce01945cc2299a01459b9371c48027f61bfdbd4bfcf677/ida_codemode-0.6.1-py3-none-any.whl", hash = "sha256:69a39e25f7441aab794f6737133f8c6155fd794c77924e46e83cb938acf8944f", size = 104728, upload-time = "2026-08-13T16:46:34.265Z" },
+ { url = "https://files.pythonhosted.org/packages/27/78/9c698d818b0fddc6648f703a0821edeeb65b18404b43f556d249c5446c96/ida_domain-0.5.1-py3-none-any.whl", hash = "sha256:bfbb17c7d0cb2ed7d3f21342e1c8787f9018d5c2a94cdfd29e06537dd026a06d", size = 201275, upload-time = "2026-08-10T13:32:46.955Z" },
]
[[package]]
-name = "ida-domain"
-version = "0.5.1"
+name = "ida-nexus"
+version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "idapro" },
+ { name = "ida-domain" },
{ name = "packaging" },
- { name = "typing-extensions" },
+ { name = "zeromcp" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/80/34/be087d3ea1c3a6573e0660cb5b40f0c4ade9ae5772cf1c5d98d52472d28b/ida_domain-0.5.1.tar.gz", hash = "sha256:c49f2c417047d882e954f651b50a709a3f27903b33ba533b794aa54d6536d16f", size = 396413, upload-time = "2026-08-10T13:32:48.45Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/b6/93/2f87cbd64ffc45e181542f133ba8db101c9049155b5f015d9d318f32dcfb/ida_nexus-0.7.0.tar.gz", hash = "sha256:838698c6a2456d474da833b2f4955da9f1fca4a488c95a157539a3c663a919ba", size = 220190, upload-time = "2026-08-20T21:52:18.228Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/27/78/9c698d818b0fddc6648f703a0821edeeb65b18404b43f556d249c5446c96/ida_domain-0.5.1-py3-none-any.whl", hash = "sha256:bfbb17c7d0cb2ed7d3f21342e1c8787f9018d5c2a94cdfd29e06537dd026a06d", size = 201275, upload-time = "2026-08-10T13:32:46.955Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/c2/58704fc74618c7867cf7542d3150a75a7678aae2fff7960fa8e5cc67d934/ida_nexus-0.7.0-py3-none-any.whl", hash = "sha256:a5006c7170a0a758a598b864d6fa248bf4eccbea848a30fc6a3eeeadf638d07a", size = 127656, upload-time = "2026-08-20T21:52:19.395Z" },
]
[[package]]
@@ -53,7 +53,7 @@ name = "idatui"
version = "0.0.1"
source = { editable = "." }
dependencies = [
- { name = "ida-codemode" },
+ { name = "ida-nexus" },
{ name = "pygments" },
{ name = "textual" },
]
@@ -65,7 +65,7 @@ dev = [
[package.metadata]
requires-dist = [
- { name = "ida-codemode", specifier = ">=0.5.3" },
+ { name = "ida-nexus", specifier = ">=0.7.0" },
{ name = "pygments", specifier = ">=2" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8" },
{ name = "textual", specifier = ">=8" },