aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-07-26 00:00:19 +0200
committerblasty <blasty@local>2026-07-26 00:00:19 +0200
commitba0b26ebb0ce16632e8a2e67675dd79a11e26551 (patch)
tree9eaed56efc36544c5c347e6d120d00c8ddb05eff
parentloading: say how to read a headerless blob (processor, base) (diff)
downloadida-tui-ba0b26ebb0ce16632e8a2e67675dd79a11e26551.tar.gz
ida-tui-ba0b26ebb0ce16632e8a2e67675dd79a11e26551.tar.xz
ida-tui-ba0b26ebb0ce16632e8a2e67675dd79a11e26551.zip
loading: ask how to load an unrecognised file, like IDA does
Last commit let you SAY how to load a blob. This one notices when you should have. Interactive IDA pops a dialog when no loader matches; we silently loaded as x86 at 0 and analysed to nothing, so the flag only helped people who already knew they needed it — which is exactly the people who don't need help. formats.sniff() recognises the formats IDA definitely handles (ELF, PE, Mach-O, dex, wasm, ar, COFF, Intel HEX, S-records). Anything else gets LoadOptionsScreen: a filterable processor list with human labels, a load-address field, Enter to accept, Esc to load it the way IDA would have anyway. Deliberate asymmetry: the sniff only claims formats it is sure about. A false "unknown" costs one dismissible dialog; a false "known" is the silent wrong answer this exists to kill. Esc is always an escape hatch. The list offers 20 processors, not IDA's 73 — most of the rest are museum pieces, and a name typed into the filter that matches nothing is taken literally so nothing is actually unreachable. Endianness is spelled out (arm vs armb) because getting it backwards is the most common route to zero functions. Asked only when nobody has answered yet: not with --processor, not in project mode (entries carry their own), and not when a database exists — the .i64 already records how the image was loaded. Textual trap worth recording: the screen stored the file size in self._size, which is Widget's own backing field for outer_size. Assigning an int to it crashes layout with "'int' object has no attribute 'region'" from deep inside _set_dirty, nowhere near the cause. Same family as the _render collision. The paragraph conversion now lives in exactly one place (formats.load_args); BinaryRef and launch both call it. Verified on a real AArch64 blob: dialog appears, filtering to "arm" leaves two entries, base 0x8000000 accepted -> "-parm -b800000" -> 35 functions at 0x8002440. An ELF never asks, and neither does a blob that already has a .i64. tests: new tests/test_formats.py (21) and a load_options scenario asserting the dialog stays out of the way for a recognised binary. 199/0 scenarios, 39/0 project, 30/0 project UI, 36/0 index, 27/0 pool, 21/0 formats.
-rw-r--r--docs/PROJECTS.md27
-rw-r--r--idatui/app.py174
-rw-r--r--idatui/formats.py122
-rw-r--r--idatui/launch.py11
-rw-r--r--idatui/project.py14
-rw-r--r--tests/test_formats.py93
-rw-r--r--tests/test_scenarios.py21
7 files changed, 441 insertions, 21 deletions
diff --git a/docs/PROJECTS.md b/docs/PROJECTS.md
index fdd15a1..5f8e327 100644
--- a/docs/PROJECTS.md
+++ b/docs/PROJECTS.md
@@ -245,6 +245,33 @@ An ELF/PE/Mach-O says what it is, so IDA figures it out. A raw firmware dump
says nothing, and IDA falls back to **x86 at address 0** — which on an ARM image
analyses to zero functions. It doesn't fail; it just quietly gives you nothing.
+Open one without saying anything and idatui asks, the way interactive IDA does
+when no loader matches:
+
+ ┌ unrecognised file — processor? (20) ────────────────┐
+ │ fw.bin (32,768 bytes) — no loader matched; without │
+ │ a processor IDA assumes x86 at 0 │
+ │ arm │
+ │ arm ARM / AArch64 — little-endian │
+ │ armb ARM — big-endian │
+ │ load address, e.g. 0x8000000 (blank = 0) │
+ │ Enter accept · Tab base address · Esc load as IDA │
+ └─────────────────────────────────────────────────────┘
+
+`formats.sniff()` decides whether to ask, and only recognises formats IDA
+definitely handles (ELF, PE, Mach-O, dex, wasm, ar, COFF, Intel HEX, S-records).
+Being cautious the wrong way costs one dismissible dialog; being cautious the
+other way is the silent-nothing case we're trying to kill. Esc loads it the way
+IDA would have, because the sniff can be wrong.
+
+A text container is only accepted when the whole header is printable — a raw
+image whose first byte happens to be `:` is far likelier than Intel HEX.
+
+The dialog is skipped when the answer already exists: options on the command
+line, options in the project, or a database that already records them.
+
+Or say it up front:
+
ida-tui fw.bin --processor arm --base 0x8000000
or per binary in a project, which is where it belongs for a multi-image firmware:
diff --git a/idatui/app.py b/idatui/app.py
index 7f00f92..f861f42 100644
--- a/idatui/app.py
+++ b/idatui/app.py
@@ -2517,6 +2517,127 @@ class HelpScreen(ModalScreen):
self.dismiss(None)
+class LoadOptionsScreen(ModalScreen):
+ """Ask how to load a file no loader recognised.
+
+ IDA's own answer to an unidentified file is a dialog; ours is this. Without
+ it the fallback is x86 at address 0, which doesn't fail — it analyses to
+ nothing, and you're left wondering why a firmware image has no functions.
+
+ Returns ``{"processor": str, "base": int}``, or ``{}`` to load it the way
+ IDA would have anyway (that IS the right answer sometimes: our sniff only
+ recognises formats we're sure about, so it says "unknown" for things IDA
+ can in fact handle).
+ """
+
+ BINDINGS = [
+ Binding("escape", "close", "Close"),
+ Binding("down,ctrl+n", "cursor_down", show=False),
+ Binding("up,ctrl+p", "cursor_up", show=False),
+ Binding("enter", "choose", show=False, priority=True),
+ ]
+
+ def __init__(self, path: str, size: int = 0) -> None:
+ super().__init__()
+ self._path = path
+ # NOT self._size: that is Textual's own backing field for outer_size, and
+ # assigning an int to it crashes the layout with a bewildering
+ # "'int' object has no attribute 'region'" from deep inside _set_dirty.
+ self._nbytes = size
+ self._results: list[tuple[str, str]] = []
+
+ def compose(self) -> ComposeResult:
+ from .formats import PROCESSORS
+ self._all = list(PROCESSORS)
+ with Vertical(id="pal-box"):
+ yield Static(" unrecognised file \u2014 how should IDA load it?",
+ id="pal-title", markup=False)
+ yield Static(f" {os.path.basename(self._path)} ({self._nbytes:,} bytes) "
+ f"\u2014 no loader matched; without a processor IDA "
+ f"assumes x86 at 0", id="load-note", markup=False)
+ yield Input(placeholder="filter processors\u2026", id="pal-input")
+ yield OptionList(id="pal-list")
+ yield Input(placeholder="load address, e.g. 0x8000000 (blank = 0)",
+ id="load-base")
+ yield Static(" Enter accept \u00b7 Tab base address \u00b7 "
+ "Esc load as IDA would", id="load-help", markup=False)
+
+ def on_mount(self) -> None:
+ self._apply("")
+ self.query_one("#pal-input", Input).focus()
+
+ def on_input_changed(self, event: Input.Changed) -> None:
+ event.stop()
+ if event.input.id == "pal-input":
+ self._apply(event.value.strip())
+
+ def on_input_submitted(self, event: Input.Submitted) -> None:
+ event.stop()
+ self.action_choose()
+
+ def _apply(self, query: str) -> None:
+ q = query.lower()
+ rows = [(name, desc) for name, desc in self._all
+ if not q or q in name.lower() or q in desc.lower()]
+ # An unlisted processor is still valid: IDA has 73 modules and this
+ # offers 20, so a typed name that matches nothing is taken literally
+ # rather than refused.
+ if not rows and q:
+ rows = [(query.strip(), "use this processor name as typed")]
+ self._results = rows
+ ol = self.query_one(OptionList)
+ ol.clear_options()
+ opts = []
+ for name, desc in rows:
+ label = Text()
+ label.append(f" {name:<12}", _S_LABEL)
+ label.append(desc, _S_DIM)
+ opts.append(Option(label))
+ ol.add_options(opts)
+ if rows:
+ ol.highlighted = 0
+ self.query_one("#pal-title", Static).update(
+ f" unrecognised file \u2014 processor? ({len(rows)})")
+
+ def action_cursor_down(self) -> None:
+ ol = self.query_one(OptionList)
+ if ol.option_count:
+ ol.highlighted = min((ol.highlighted or 0) + 1, ol.option_count - 1)
+
+ def action_cursor_up(self) -> None:
+ ol = self.query_one(OptionList)
+ if ol.option_count:
+ ol.highlighted = max((ol.highlighted or 0) - 1, 0)
+
+ def action_choose(self) -> None:
+ ol = self.query_one(OptionList)
+ i = ol.highlighted
+ if i is None or not (0 <= i < len(self._results)):
+ self.dismiss({})
+ return
+ raw = self.query_one("#load-base", Input).value.strip()
+ base = 0
+ if raw:
+ try:
+ base = int(raw, 0)
+ except ValueError:
+ self.query_one("#load-help", Static).update(
+ f" {raw!r} is not an address \u2014 try 0x8000000")
+ self.query_one("#load-base", Input).focus()
+ return
+ if base % 16:
+ # IDA's -b is in paragraphs, so an unaligned base can't be
+ # expressed and would quietly load somewhere else.
+ self.query_one("#load-help", Static).update(
+ f" {base:#x} must be 16-byte aligned")
+ self.query_one("#load-base", Input).focus()
+ return
+ self.dismiss({"processor": self._results[i][0], "base": base})
+
+ def action_close(self) -> None:
+ self.dismiss({})
+
+
class ProjectPalette(ModalScreen):
"""The project's binaries; Enter switches to one. Shows which are resident
(a live worker, so switching is instant) vs cold (needs an open)."""
@@ -3105,7 +3226,8 @@ class IdaTui(App):
#xref-title { dock: top; height: 1; background: $accent; color: $background; text-style: bold; padding: 0 1; }
#xref-list { height: auto; max-height: 100%; }
/* every #pal-box palette centres, not just the symbol one */
- SymbolPalette, StringsPalette, ProjectPalette { align: center middle; }
+ SymbolPalette, StringsPalette, ProjectPalette,
+ LoadOptionsScreen { align: center middle; }
/* Give the stock Ctrl+P command palette side padding instead of full width;
the input + results inherit this width (results is an overlay, so pin it). */
CommandPalette > Vertical { width: 80%; max-width: 120; }
@@ -3115,6 +3237,9 @@ class IdaTui(App):
#pal-title { dock: top; height: 1; background: $accent; color: $background; text-style: bold; padding: 0 1; }
#pal-input { border: none; height: 1; margin: 0 1; background: $panel; color: $text; }
#pal-list { height: auto; max-height: 24; }
+ #load-note { height: 2; padding: 1 1 0 1; color: $text-muted; }
+ #load-base { border: none; height: 1; margin: 1 1 0 1; background: $panel; color: $text; }
+ #load-help { height: 1; padding: 0 1; color: $text-muted; }
StructEditor { align: center middle; }
#se-box { width: 90%; height: 84%; border: thick $accent; background: $panel; }
#se-panes { height: 1fr; }
@@ -3282,13 +3407,56 @@ class IdaTui(App):
# The names pane is an overlay now (Ctrl+N); focus the default code view
# (pseudocode) so app bindings work before anything is opened.
self.query_one(ListingView).focus()
+ if self._rpc_path:
+ self._start_rpc()
+ # A file no loader recognises has to be described before it can be
+ # opened, so ask BEFORE the worker starts — once IDA has made a database
+ # the answer is baked in and changing it means deleting the .i64.
+ if self._should_ask_load_options():
+ self._ask_load_options()
+ return
# Show a loading overlay immediately so a slow open/analysis (big binary)
# isn't just dead air behind empty panes; dismissed once we land.
self._loading_screen = LoadingScreen(self._loading_title())
self.push_screen(self._loading_screen)
self._connect()
- if self._rpc_path:
- self._start_rpc()
+
+ def _should_ask_load_options(self) -> bool:
+ """Ask only when nobody has already answered, and only when it matters.
+
+ Skipped when: options came from the command line or the project (the
+ user already said); a database exists (the answer is recorded in it, and
+ re-passing switches fails the open); or the file is a format IDA
+ recognises, which is nearly always.
+ """
+ if self._project is not None or not self._open_path:
+ return False # project mode carries per-binary options already
+ if self._load_args:
+ return False
+ from .formats import needs_load_options
+ if os.path.exists(self._open_path + ".i64") or os.path.exists(
+ os.path.splitext(self._open_path)[0] + ".i64"):
+ return False
+ return needs_load_options(self._open_path)
+
+ def _ask_load_options(self) -> None:
+ try:
+ size = os.path.getsize(self._open_path)
+ except OSError:
+ size = 0
+ self.push_screen(LoadOptionsScreen(self._open_path, size),
+ self._on_load_options)
+
+ def _on_load_options(self, choice) -> None: # type: ignore[no-untyped-def]
+ from .formats import load_args
+ choice = choice or {}
+ if choice.get("processor"):
+ self._load_args = load_args(choice["processor"], choice.get("base", 0))
+ self._status(f"loading as {choice['processor']} "
+ f"@ {choice.get('base', 0):#x}")
+ self._loading_screen = LoadingScreen(self._loading_title())
+ self.push_screen(self._loading_screen)
+ self._connect()
def _loading_title(self) -> str:
return os.path.basename(self._open_path) if self._open_path else "database"
diff --git a/idatui/formats.py b/idatui/formats.py
new file mode 100644
index 0000000..744276d
--- /dev/null
+++ b/idatui/formats.py
@@ -0,0 +1,122 @@
+"""Recognising what a file is, and what to ask when we can't.
+
+IDA picks a loader by looking at the file. When one matches, it knows the
+processor, the load address and often the entry point, and there is nothing to
+ask. When none matches — a raw firmware dump, a flash image, a decompressed blob
+— IDA falls back to a plain binary load: **x86 at address 0**. That is a silent
+wrong answer, not an error; the database opens, analysis runs, and finds nothing.
+
+Interactive IDA handles this by asking. This module is the "should we ask, and
+what are the sensible answers" half of doing the same.
+
+The sniff deliberately only recognises formats IDA definitely has a loader for.
+Being wrong in the cautious direction costs one dismissible dialog; being wrong
+the other way is the silent-nothing case we're trying to kill.
+"""
+
+from __future__ import annotations
+
+import os
+
+#: (magic, offset, name) for formats IDA loads without help.
+_MAGIC: tuple[tuple[bytes, int, str], ...] = (
+ (b"\x7fELF", 0, "ELF"),
+ (b"MZ", 0, "PE/MZ"),
+ (b"\xfe\xed\xfa\xce", 0, "Mach-O"),
+ (b"\xfe\xed\xfa\xcf", 0, "Mach-O 64"),
+ (b"\xce\xfa\xed\xfe", 0, "Mach-O (LE)"),
+ (b"\xcf\xfa\xed\xfe", 0, "Mach-O 64 (LE)"),
+ (b"\xca\xfe\xba\xbe", 0, "Mach-O fat/Java class"),
+ (b"dex\n", 0, "Dalvik"),
+ (b"\x00asm", 0, "WebAssembly"),
+ (b"!<arch>", 0, "ar archive"),
+ (b"\x4c\x01", 0, "COFF i386"),
+ (b"\x64\x86", 0, "COFF x64"),
+ (b"\x00\x00\x03\xf3", 0, "Amiga hunk"),
+ (b"\x7fCGC", 0, "CGC"),
+)
+
+#: Text-ish container formats: recognised by their first line, not a magic word.
+_TEXT_PREFIX: tuple[tuple[bytes, str], ...] = (
+ (b":", "Intel HEX"),
+ (b"S0", "Motorola S-record"),
+ (b"S1", "Motorola S-record"),
+ (b"S2", "Motorola S-record"),
+ (b"S3", "Motorola S-record"),
+)
+
+
+def sniff(path: str) -> str | None:
+ """The container format of ``path``, or None when nothing recognises it.
+
+ None is the interesting answer: it means IDA will guess, and its guess is
+ x86-at-zero regardless of what the bytes actually are.
+ """
+ try:
+ with open(path, "rb") as f:
+ head = f.read(64)
+ except OSError:
+ return None
+ if not head:
+ return None
+ for magic, off, name in _MAGIC:
+ if head[off:off + len(magic)] == magic:
+ return name
+ # Only treat a text prefix as a format if the whole head is printable —
+ # a raw blob starting with 0x3a (':') is far more likely than Intel HEX.
+ if all(32 <= b < 127 or b in (9, 10, 13) for b in head):
+ for prefix, name in _TEXT_PREFIX:
+ if head.startswith(prefix):
+ return name
+ return None
+
+
+def needs_load_options(path: str) -> bool:
+ """True when we should ask how to load ``path`` rather than let IDA guess."""
+ return os.path.isfile(path) and sniff(path) is None
+
+
+#: Processors worth offering, as (IDA -p name, human label).
+#:
+#: IDA ships 73 processor modules, most of which are museum pieces; a list that
+#: long is a worse answer than a short one plus free text. These are the targets
+#: that actually turn up in firmware work, endianness spelled out because
+#: getting it wrong is the most common way to end up with zero functions.
+PROCESSORS: tuple[tuple[str, str], ...] = (
+ ("arm", "ARM / AArch64 — little-endian"),
+ ("armb", "ARM — big-endian"),
+ ("metapc", "x86 / x86-64"),
+ ("mipsl", "MIPS — little-endian"),
+ ("mipsb", "MIPS — big-endian"),
+ ("ppc", "PowerPC — big-endian"),
+ ("ppcl", "PowerPC — little-endian"),
+ ("sh4", "SuperH SH-4"),
+ ("68k", "Motorola 68000"),
+ ("riscv", "RISC-V"),
+ ("tricore", "Infineon TriCore"),
+ ("xtensa", "Tensilica Xtensa (ESP32 etc.)"),
+ ("avr", "Atmel AVR"),
+ ("z80", "Zilog Z80"),
+ ("tms320c6", "TI TMS320C6x DSP"),
+ ("m32r", "Renesas M32R"),
+ ("arc", "Synopsys ARC"),
+ ("h8", "Renesas H8"),
+ ("sparc", "SPARC"),
+ ("s390", "IBM S/390"),
+)
+
+
+def load_args(processor: str = "", base: int = 0, extra: str = "") -> str:
+ """``processor``/``base`` as IDA command-line switches.
+
+ ``-b`` is in PARAGRAPHS, not bytes: ``-b1000`` loads at 0x10000. Every caller
+ goes through here so that conversion is done in exactly one place.
+ """
+ parts = []
+ if processor:
+ parts.append(f"-p{processor}")
+ if base:
+ parts.append(f"-b{int(base) >> 4:x}")
+ if extra:
+ parts.append(extra)
+ return " ".join(parts)
diff --git a/idatui/launch.py b/idatui/launch.py
index b28559e..f51e5af 100644
--- a/idatui/launch.py
+++ b/idatui/launch.py
@@ -25,14 +25,9 @@ _LOCK_SUFFIXES = (".id0", ".id1", ".id2", ".nam", ".til")
def _load_args(load: dict) -> str:
"""``load`` as IDA switches, for the single-binary path (no project ref)."""
- parts = []
- if load.get("processor"):
- parts.append(f"-p{load['processor']}")
- if load.get("base"):
- parts.append(f"-b{int(load['base']) >> 4:x}") # -b is PARAGRAPHS
- if load.get("ida_args"):
- parts.append(str(load["ida_args"]))
- return " ".join(parts)
+ from .formats import load_args
+ return load_args(load.get("processor", ""), int(load.get("base", 0) or 0),
+ str(load.get("ida_args", "") or ""))
def _log(msg: str) -> None:
diff --git a/idatui/project.py b/idatui/project.py
index 249ccd6..8f2674d 100644
--- a/idatui/project.py
+++ b/idatui/project.py
@@ -68,17 +68,11 @@ class BinaryRef:
"""``processor``/``base`` as IDA command-line switches.
``-b`` is in PARAGRAPHS, not bytes — ``-b1000`` loads at 0x10000. That
- is a trap worth hiding: projects say ``"base": "0x8000000"`` and the
- conversion happens here.
+ trap is worth hiding: projects say ``"base": "0x8000000"`` and the one
+ conversion lives in ``formats.load_args``.
"""
- parts = []
- if self.processor:
- parts.append(f"-p{self.processor}")
- if self.base:
- parts.append(f"-b{self.base >> 4:x}")
- if self.ida_args:
- parts.append(self.ida_args)
- return " ".join(parts)
+ from .formats import load_args
+ return load_args(self.processor, self.base, self.ida_args)
def _as_addr(v) -> int:
diff --git a/tests/test_formats.py b/tests/test_formats.py
new file mode 100644
index 0000000..f88021c
--- /dev/null
+++ b/tests/test_formats.py
@@ -0,0 +1,93 @@
+#!/usr/bin/env python3
+"""Format sniffing + load-switch construction (pure stdlib, no IDA)."""
+import os
+import sys
+import tempfile
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from idatui.formats import (PROCESSORS, load_args, # noqa: E402
+ needs_load_options, sniff)
+
+PASS = FAIL = 0
+
+
+def check(name, ok, detail=""):
+ global PASS, FAIL
+ if ok:
+ PASS += 1
+ print(f" ok {name}")
+ else:
+ FAIL += 1
+ print(f" FAIL {name} {detail}")
+
+
+def main() -> int:
+ with tempfile.TemporaryDirectory() as tmp:
+ def w(name, data):
+ p = os.path.join(tmp, name)
+ with open(p, "wb") as f:
+ f.write(data)
+ return p
+
+ # -- formats IDA can load on its own: never interrupt the user -------- #
+ for name, head in (("elf", b"\x7fELF\x02\x01\x01"), ("pe", b"MZ\x90\x00"),
+ ("macho", b"\xcf\xfa\xed\xfe"), ("dex", b"dex\n035\x00"),
+ ("wasm", b"\x00asm\x01\x00")):
+ p = w(name, head + bytes(60))
+ check(f"{name} is recognised (no dialog)",
+ sniff(p) is not None and not needs_load_options(p), f"{sniff(p)}")
+
+ # -- the case this exists for ----------------------------------------- #
+ blob = w("fw.bin", bytes(range(256)) * 4)
+ check("a headerless blob is not recognised (ask)",
+ sniff(blob) is None and needs_load_options(blob))
+
+ # Intel HEX / S-records are text containers IDA does load.
+ hexf = w("f.hex", b":10010000214601360121470136007EFE09D2190140\n")
+ check("Intel HEX is recognised", sniff(hexf) == "Intel HEX", f"{sniff(hexf)}")
+ srec = w("f.s19", b"S00600004844521B\nS1130000285F245F2212226A000424290008237C\n")
+ check("Motorola S-records are recognised",
+ sniff(srec) == "Motorola S-record", f"{sniff(srec)}")
+
+ # A binary that merely STARTS with ':' is not Intel HEX. Text formats are
+ # only accepted when the whole head is printable, or half the firmware in
+ # the world gets mis-detected on one byte.
+ colon = w("colon.bin", b":\x00\xff\xfe\x01\x02" + bytes(58))
+ check("a blob starting with ':' is not mistaken for Intel HEX",
+ sniff(colon) is None, f"{sniff(colon)}")
+
+ check("an empty file is not recognised", sniff(w("empty", b"")) is None)
+ check("a missing file never asks (nothing to load)",
+ not needs_load_options(os.path.join(tmp, "nope")))
+ check("a directory never asks", not needs_load_options(tmp))
+
+ # -- switch construction ------------------------------------------------- #
+ # -b is in PARAGRAPHS: 0x8000000 >> 4 == 0x800000. Getting this wrong loads
+ # the image 16x off and every address in the database is wrong.
+ check("base is converted to paragraphs",
+ load_args("arm", 0x8000000) == "-parm -b800000",
+ load_args("arm", 0x8000000))
+ check("processor alone", load_args("mipsb") == "-pmipsb", load_args("mipsb"))
+ check("base alone", load_args("", 0x10000) == "-b1000", load_args("", 0x10000))
+ check("base 0 emits no switch (it's the default)",
+ load_args("arm", 0) == "-parm", load_args("arm", 0))
+ check("nothing in, nothing out", load_args() == "")
+ check("extra switches pass through",
+ load_args("arm", 0, "-T binary") == "-parm -T binary")
+
+ check("the processor list leads with the common targets",
+ [n for n, _ in PROCESSORS[:3]] == ["arm", "armb", "metapc"],
+ f"{[n for n, _ in PROCESSORS[:3]]}")
+ check("every processor entry has a human label",
+ all(n and d for n, d in PROCESSORS))
+ check("endianness is spelled out where it matters",
+ all(any(w in d.lower() for w in ("endian",))
+ for n, d in PROCESSORS if n in ("arm", "armb", "mipsb", "mipsl")))
+
+ print(f"\n{PASS} passed, {FAIL} failed")
+ return 1 if FAIL else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py
index b7520d9..ee2f77b 100644
--- a/tests/test_scenarios.py
+++ b/tests/test_scenarios.py
@@ -349,6 +349,27 @@ async def s_palette(c: Ctx):
c.check("Esc closes the palette", not isinstance(app.screen, SymbolPalette))
+@scenario("load_options")
+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())
+ # 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")
+
+
@scenario("command_palette")
async def s_command_palette(c: Ctx):
app = c.app