Make the pseudo decompiler output identical on both backends

* The `pseudo` engine's text differed between backends (2652 vs 3112
  bytes in the QA run) with identical eval settings. The cause is in
  Rizin: since rizin#6062, `ds_init()` suppresses the jump arrows
  (`asm.lines`) whenever `core->is_pipe` is set, and `rz_core_cmd_str()`
  sets it for every captured command, so in-process `pd*` text never has
  them. Clearing `is_pipe` from the outside is not possible, and would
  let `!` commands write to the MCP stdout.
* Render the pseudo code without jump arrows (`asm.lines=false`), which
  makes both backends print the same text, and with `pdr` instead of
  `pdf`: on /bin/ls, `pdf @ main` takes ~30 s (in Rizin 0.9.1 and dev
  alike) because every pointer operand classifies a buffer as large as
  the whole function, while `pdr` prints the same blocks in 0.4 s.
* A test decompiles `main`, `entry0` and the three largest functions on
  both backends and requires identical text within 10 s each.
This commit is contained in:
Anton Kochkov authored and NOT XVilka committed 2026-10-30 18:35:59 +08:00
1 parent 410424658e
commit df1579e0ad
3 files changed
+62 -9

No files matched your search

+8 -5
View File
@@ -26,7 +26,9 @@ The server can talk to Rizin two ways, selected with `--transport`:
| Run needs | nothing external | a `rizin` **binary** |
| Hard-cancel a stuck command | No (in-process C call can't be force-killed) | Yes (the process is killed on timeout) |
Both run the exact same Rizin commands and return identical JSON. On this
Both run the exact same Rizin commands and return identical JSON. Plain-text
disassembly through the raw `command` tool can differ in one respect: Rizin
omits the jump arrows (`asm.lines`) from output captured in process. On this
machine, opening `/bin/ls` and issuing commands measured roughly:
```
@@ -135,11 +137,12 @@ produces the full listing, which the server then slices.
### Notes on a few tools
- **Decompilation.** Rizin 0.9 has no bundled C decompiler. `decompile` uses
- **Decompilation.** Rizin has no bundled C decompiler. `decompile` uses
whichever decompiler plugin is installed — rz-ghidra (`pdg`) or jsdec (`pdd`) —
and otherwise falls back to built-in pseudo-disassembly (an `asm.pseudo`
rendering of the function, applied via a temporary eval so no global state
changes). `decompiler` can force `ghidra`, `jsdec`, or `pseudo`; `decompilers`
and otherwise falls back to built-in pseudo-disassembly: an `asm.pseudo`
rendering of the function's basic blocks (`pdr`), without jump arrows, via
temporary evals so no global state changes. That text is identical on both
backends. `decompiler` can force `ghidra`, `jsdec`, or `pseudo`; `decompilers`
reports what is available. See *External plugins* below.
- **FLIRT.** The `flirt_*` tools wrap Rizin's built-in `F` command family:
`flirt_list`/`flirt_apply_sigdb` work against the signature database (sigdb)
+8 -4
View File
@@ -740,7 +740,8 @@ impl RizinServer {
#[tool(
description = "Decompile the function at a location to pseudo-C. `decompiler` selects \
the engine: `auto` (default) tries rz-ghidra (pdg), then jsdec (pdd), then built-in \
pseudo-disassembly; or force `ghidra`, `jsdec`, or `pseudo`. rz-ghidra and jsdec are \
pseudo-disassembly of the function's basic blocks (pdr); or force `ghidra`, `jsdec`, \
or `pseudo`. rz-ghidra and jsdec are \
optional plugins; see `decompilers` for what is installed. The engine actually used is \
reported in structuredContent."
)]
@@ -754,11 +755,14 @@ impl RizinServer {
// (engine name, command) candidates for each selection.
let ghidra = ("ghidra", format!("pdg{suffix}"));
let jsdec = ("jsdec", format!("pdd{suffix}"));
// Pseudo-disassembly of the whole function, via a temporary eval so the
// global `asm.pseudo` setting is left untouched.
// Pseudo-disassembly of the whole function, with temporary evals so the
// global settings are left untouched. Without the jump arrows
// (`asm.lines`), which Rizin omits from captured in-process output, the
// text is the same on both backends. `pdr` walks the basic blocks:
// `pdf` spends seconds per pointer operand in a large function.
let pseudo = (
"pseudo",
format!("pdf @e:asm.pseudo=true{}", at_suffix(&p.at).mcp()?),
format!("pdr @e:asm.pseudo=true @e:asm.lines=false{suffix}"),
);
let candidates: Vec<(&str, String)> = match which.as_str() {
+46
View File
@@ -602,3 +602,49 @@ async fn errors_say_what_went_wrong() {
}
let _ = std::fs::remove_file(&pat);
}
#[tokio::test]
async fn pseudo_decompile_is_the_same_on_both_backends() {
let backends = transports();
if backends.len() < 2 {
eprintln!("skipping: needs both backends");
return;
}
let mut texts: Vec<Vec<String>> = Vec::new();
for t in &backends {
let s = Session::start(*t, |_| {}).await;
s.ok(
"open_file",
json!({ "path": target(), "analysis": "basic" }),
)
.await;
// `main`, `entry0`, and a few more of the larger functions.
let funcs = s.items("list_functions", json!({ "limit": 1000 })).await;
let mut sized: Vec<(u64, u64)> = funcs
.iter()
.filter_map(|f| Some((f["size"].as_u64()?, f["offset"].as_u64()?)))
.collect();
sized.sort_unstable_by(|a, b| b.cmp(a));
let mut at: Vec<String> = vec!["main".into(), "entry0".into()];
at.extend(sized.iter().take(3).map(|(_, o)| format!("0x{o:x}")));
let mut out = Vec::new();
for a in &at {
let started = std::time::Instant::now();
let r = s
.call("decompile", json!({ "at": a, "decompiler": "pseudo" }))
.await
.unwrap_or_else(|e| panic!("{t:?} {a}: {e:?}"));
assert!(
started.elapsed() < std::time::Duration::from_secs(10),
"{t:?} {a}: took {:?}",
started.elapsed()
);
out.push(r.content[0].as_text().unwrap().text.clone());
}
texts.push(out);
}
for (i, (a, b)) in texts[0].iter().zip(&texts[1]).enumerate() {
assert!(!a.trim().is_empty());
assert_eq!(a, b, "function #{i} differs between backends");
}
}