From 115454b219524eae759543452f9d7c15d0f7973e Mon Sep 17 00:00:00 2001 From: Koukyosyumei Date: Tue, 22 Sep 2026 14:33:39 -0400 Subject: [PATCH 1/8] Preserve arguments at indirect call sites --- crates/e5r-decomp/src/emit.rs | 78 +++++++++++++++++++++++++++- crates/e5r-decomp/tests/datatests.rs | 44 +++++++++++++--- 2 files changed, 115 insertions(+), 7 deletions(-) diff --git a/crates/e5r-decomp/src/emit.rs b/crates/e5r-decomp/src/emit.rs index fab18cc..6b7fc48 100644 --- a/crates/e5r-decomp/src/emit.rs +++ b/crates/e5r-decomp/src/emit.rs @@ -465,7 +465,11 @@ fn helper_for(o: Op) -> Option<&'static str> { Op::FloatConvert | Op::IntToFloat | Op::UIntToFloat | Op::FloatToInt | Op::FloatToUInt => { REINTERPRET } - Op::CallInd => "uint64_t __callind(uint64_t);", + // The first argument is the target. The rest are the argument + // registers we can prove were prepared for this call. An unspecified + // tail would make calls with arguments fail the recompilability gate; + // a variadic tail says only that the target's prototype is unknown. + Op::CallInd => "uint64_t __callind(uint64_t, ...);", Op::BranchInd => "void __indirect_branch(uint64_t);", Op::IntDiv128 => "uint64_t __udiv128(uint64_t, uint64_t, uint64_t);", Op::IntSDiv128 => "uint64_t __sdiv128(uint64_t, uint64_t, uint64_t);", @@ -991,6 +995,16 @@ impl Emitter<'_> { .unwrap_or_else(|| crate::expr::default_call_name(target)); Expr::Call(name, self.arguments(at, index, callee)) } + (Op::CallInd, _) => { + let mut args = op + .inputs + .first() + .map(|i| self.r.integer(self.r.operand(i), i)) + .into_iter() + .collect::>(); + args.extend(self.unknown_arguments(at, index)); + Expr::Named("__callind", args) + } _ => self.r.expr(op), }; match op.out.and_then(|v| self.r.locals.get(&v)).filter(|_| !void) { @@ -1128,6 +1142,68 @@ impl Emitter<'_> { out } + /// Arguments prepared for a call whose prototype and target are unknown. + /// + /// There is no honest arity to copy from, so take only the contiguous + /// argument registers this block wrote since the preceding call. This + /// preserves ordinary callback arguments without inventing stale values + /// left in caller-saved registers. An unchanged incoming argument cannot + /// be proved at an untyped indirect site and is deliberately omitted. + fn unknown_arguments(&self, at: Addr, index: usize) -> Vec { + let Some(block) = self.f.blocks.get(&at) else { + return Vec::new(); + }; + let since = block.ops[..index] + .iter() + .rposition(|op| matches!(op.kind, SsaKind::Op(Op::Call | Op::CallInd))) + .map_or(0, |n| n + 1); + + self.r + .abi + .integer_arguments + .iter() + .map_while(|offset| { + let value = block.ops[since..index] + .iter() + .rev() + // `Undefine` records registers the preceding call + // clobbered. It is not a write that prepared an argument + // for the next call, even though it has an SSA output. + .filter(|op| op.kind != SsaKind::Op(Op::Undefine)) + .filter_map(|op| op.out) + .find(|v| { + v.location.space == Space::Register && v.location.offset == *offset + })?; + if self.value_is_clobbered(value, 0) { + return None; + } + Some(self.r.operand(&Operand::Value(value))) + }) + .collect() + } + + /// Whether a value is only a call-clobber marker, possibly through the + /// copies and width-normalizing operations SSA put above it. + fn value_is_clobbered(&self, value: Value, depth: u8) -> bool { + if depth >= 16 { + return false; + } + let Some((at, index)) = self.r.definition_site(value) else { + return false; + }; + let Some(op) = self.f.blocks.get(&at).and_then(|b| b.ops.get(index)) else { + return false; + }; + if op.kind == SsaKind::Op(Op::Undefine) { + return true; + } + op.inputs.iter().any(|input| { + input + .as_value() + .is_some_and(|v| self.value_is_clobbered(v, depth + 1)) + }) + } + /// The value a register held just before an operation. fn value_before(&self, at: Addr, index: usize, offset: u64) -> Option { let b = self.f.blocks.get(&at)?; diff --git a/crates/e5r-decomp/tests/datatests.rs b/crates/e5r-decomp/tests/datatests.rs index 6a2d447..a73b0ae 100644 --- a/crates/e5r-decomp/tests/datatests.rs +++ b/crates/e5r-decomp/tests/datatests.rs @@ -867,15 +867,47 @@ fn convert() { }); } -/// deindirect.xml and indproto.xml, the argument half: an indirect call comes -/// out as `__callind(target)` with no arguments at all, so `f(b + 3)` loses the -/// `b + 3`. The two calls here also share a target that is a single global -/// holding one function, which Ghidra collapses to a direct call to it. +/// deindirect.xml and indproto.xml, the argument half: an indirect call keeps +/// the registers prepared for it, so `f(b + 3)` does not lose the `b + 3`. #[test] -#[ignore = "an indirect call is emitted without its arguments and is never devirtualized"] +fn indirect_call_keeps_arguments() { + case(&all("calls"), "deindirect", |c| { + let calls: Vec<&str> = c + .text + .lines() + .filter(|line| line.contains("= (uint64_t)(__callind(")) + .collect(); + if calls.len() != 2 { + c.fail(&format!( + "expected two indirect call statements, found {calls:?}" + )); + } + if !calls[0].contains("+ 3") || !calls[1].contains("+ 5") { + c.fail(&format!( + "indirect call arguments were not preserved: {calls:?}" + )); + } + // Each call has one target and one argument. A clobber marker from + // the first call must not look like preparation for the second. + for call in calls { + if call.matches(',').count() != 1 { + c.fail(&format!( + "indirect call acquired spurious arguments: {call}" + )); + } + } + }); +} + +/// deindirect.xml, the target half: both calls load one writable global that +/// initially points at `realfunc`. Ghidra collapses them to direct calls. Doing +/// that here needs proof the global cannot change between load and call; its +/// initial contents alone are not enough. +#[test] +#[ignore = "a writable function pointer is not devirtualized without mutation analysis"] fn deindirect() { case(&all("calls"), "deindirect", |c| { - c.has("realfunc").has("+ 3").has("+ 5"); + c.has("realfunc"); }); } From 84d0693b3df826b4034e905d200d52ad0f5beeee Mon Sep 17 00:00:00 2001 From: Koukyosyumei Date: Tue, 22 Sep 2026 21:06:23 -0400 Subject: [PATCH 2/8] A call through the PLT uses the function it names, and the return is the value that call left A stub in the same image borrows that function's signature, so deflateInit_ is the eight-argument call it is. A stub with no local function keeps the argument registers the site prepared. The second result register a call clobbers is not the return, and a value that exists only to be returned is not dead. DecBench gets each variable's name, type, and stack home. --- crates/e5r-api/src/decompile.rs | 106 ++++++++++++++++++- crates/e5r-api/tests/asserted.rs | 16 ++- crates/e5r-cli/src/json.rs | 33 +++++- crates/e5r-decomp/src/emit.rs | 146 ++++++++++++++++++++++----- crates/e5r-decomp/src/expr.rs | 121 ++++++++++++++++++++-- crates/e5r-decomp/tests/datatests.rs | 95 +++++++++++++++++ crates/e5r-decomp/tests/roundtrip.rs | 35 ++++++- docs/decbench.md | 13 +++ scripts/decbench_e5r.py | 109 +++++++++++++++++--- scripts/test_decbench_e5r.py | 138 +++++++++++++++++++++++++ 10 files changed, 750 insertions(+), 62 deletions(-) create mode 100644 scripts/test_decbench_e5r.py diff --git a/crates/e5r-api/src/decompile.rs b/crates/e5r-api/src/decompile.rs index 564c1ba..65b6ef6 100644 --- a/crates/e5r-api/src/decompile.rs +++ b/crates/e5r-api/src/decompile.rs @@ -155,14 +155,49 @@ pub fn decompile_program_with( } } } + // A same-image call through the PLT names the stub, not the function. + // The stub is what the caller asks for; the function is what knows the + // signature. Pull the function in so the stub can borrow it. + let stubs: Vec = around.iter().map(|f| f.entry).collect(); + for entry in stubs { + let Some(stub) = p.function(entry) else { + continue; + }; + let name = e5r_decomp::identifier(&stub.display_name()); + let Some(bare) = name.strip_suffix("_plt") else { + continue; + }; + let Some(real) = p + .functions_by_address() + .find(|g| g.entry != entry && e5r_decomp::identifier(&g.display_name()) == bare) + else { + continue; + }; + if !wanted.contains(&real.entry) && !around.iter().any(|c| c.entry == real.entry) { + around.push(real); + } + } + // Prototypes from the previous pass, so a PLT thunk can borrow the + // signature of the function it stands for. The thunk's own body is one + // jump and cannot say what the call passes. + let mut known: BTreeMap = BTreeMap::new(); for pass in 0..2 { outputs.clear(); + let resolved = resolved_thunks(&callees, &known); + // Callers are decompiled before the thunks, so the table has to say + // the borrowed signature before this pass starts. + for (addr, proto) in &resolved { + if let Some(callee) = callees.get_mut(addr) { + apply_prototype(callee, proto); + } + } // The neighbours are prototyped but never emitted: they are here to // fill the table, and on the last pass their bodies would be thrown // away anyway. for (n, f) in targets.iter().chain(around.iter()).enumerate() { - let Some(one) = one(p, f, &callees, declarations) else { + let borrowed = resolved.get(&f.entry.get()); + let Some(one) = one(p, f, &callees, declarations, borrowed) else { continue; }; let out = &one.output; @@ -172,12 +207,16 @@ pub fn decompile_program_with( Callee { name: e5r_decomp::identifier(&name), arity: out.arity, + parameters_known: out.parameters_known, pointer_parameters: out.pointer_parameters.clone(), parameter_widths: out.parameter_widths.clone(), returns_value: !out.signature.starts_with("void "), signature: out.signature.clone(), }, ); + if let Some(proto) = one.prototype.clone() { + known.insert(e5r_decomp::identifier(&name), proto); + } if n < targets.len() { outputs.push((f.entry, name, one)); } @@ -239,6 +278,9 @@ pub fn decompile_program_with( /// One function's way through the pipeline, and what was learned on the way. struct One { output: Output, + /// The prototype the body was emitted with, so a later pass can lend it + /// to the thunk that jumps to this function. + prototype: Option, /// Instructions the lifter did not model, which the emitter never saw. unlifted: usize, variables: Vec, @@ -246,12 +288,62 @@ struct One { asserted: bool, } +/// PLT thunks whose real function was prototyped in this unit. +/// +/// Keyed by the thunk's address. The borrowed prototype keeps the thunk's +/// name: callers wrote the stub's address, and the declaration has to be the +/// one the stub is emitted with. +/// Put a recovered signature onto a callee entry, keeping the callee's name. +fn apply_prototype(callee: &mut Callee, proto: &Prototype) { + callee.arity = proto.parameters.len(); + callee.parameters_known = proto.parameters_known; + callee.pointer_parameters = proto.parameters.iter().map(|p| p.pointer).collect(); + callee.parameter_widths = proto.parameters.iter().map(|p| p.size).collect(); + let ret = proto + .returns + .clone() + .unwrap_or_else(|| "uint64_t".to_string()); + callee.returns_value = ret != "void"; + let params = if proto.parameters.is_empty() { + "void".to_string() + } else { + proto + .parameters + .iter() + .map(|p| p.decl.clone()) + .collect::>() + .join(", ") + }; + callee.signature = format!("{ret} {}({params})", callee.name); +} + +fn resolved_thunks( + callees: &BTreeMap, + known: &BTreeMap, +) -> BTreeMap { + let mut out = BTreeMap::new(); + for (addr, callee) in callees { + if callee.parameters_known { + continue; + } + let Some(bare) = callee.name.strip_suffix("_plt") else { + continue; + }; + let Some(real) = known.get(bare).filter(|p| p.parameters_known) else { + continue; + }; + out.insert(*addr, real.clone()); + } + out +} + /// One function through the whole pipeline. fn one( p: &Program, f: &Function, callees: &BTreeMap, declarations: &BTreeMap, + borrowed: Option<&Prototype>, ) -> Option { // What each jump table means: the index the branch used and where that // index goes, which is what turns a many-successor block into a switch. @@ -289,13 +381,19 @@ fn one( // The SSA the emitter runs on is the SSA prototype recovery reads, rather // than a second build of the same thing: lifting a function twice is not // free and the two could drift apart. - let shape = prototype(p, f, &ssa, declarations); + let mut shape = prototype(p, f, &ssa, declarations); + // A thunk borrows the signature of the function it reaches. Its own body + // still does not read the arguments; the signature is for the callers. + if borrowed.is_some() && !shape.asserted { + shape.prototype = borrowed.cloned(); + } let name = f.display_name(); let output = e5r_decomp::decompile_full(&name, &ssa, shape.prototype.as_ref(), callees, &switches); Some(One { variables: e5r_decomp::expr::variables(&ssa, shape.prototype.as_ref(), &output.text), output, + prototype: shape.prototype, unlifted: ir.unlifted.len(), conflicts: shape.conflicts, asserted: shape.asserted, @@ -434,6 +532,7 @@ fn asserted_prototype(a: &Asserted, recovered: &e5r_ir::proto::Prototype) -> Pro Prototype { parameters, + parameters_known: true, returns: Some(returns), definitions, locals: BTreeMap::new(), @@ -458,6 +557,7 @@ fn import_thunk(f: &Function) -> Option { || f.provenance.corroborating.contains(&Evidence::ImportThunk); thunk.then(|| Prototype { parameters: Vec::new(), + parameters_known: false, returns: Some("uint64_t".to_string()), definitions: Vec::new(), locals: BTreeMap::new(), @@ -567,6 +667,7 @@ fn recovered(p: &Program, f: &Function, ssa: &SsaFunction) -> Option Some(Prototype { parameters, + parameters_known: true, // A function that leaves nothing behind returns nothing, and saying // `void` is what makes the output read like the source. returns: Some(match recovered.returns { @@ -639,6 +740,7 @@ fn declared(p: &Program, f: &Function) -> Option { .collect(); Some(Prototype { parameters, + parameters_known: true, returns: df.signature.returns.map(|t| d.types.name_of(t)), // The named types the signature mentions, defined before they are used // so the output is a translation unit and not a fragment. diff --git a/crates/e5r-api/tests/asserted.rs b/crates/e5r-api/tests/asserted.rs index d63b482..3b15b9d 100644 --- a/crates/e5r-api/tests/asserted.rs +++ b/crates/e5r-api/tests/asserted.rs @@ -260,9 +260,12 @@ fn the_variables_come_back_with_their_names_and_types() { }; assert_eq!(named("arg0").role, Role::Parameter); assert_eq!(named("arg0").ty, "struct s_dupptr_arg0 *"); - assert_eq!(named("v2").role, Role::Local); - assert_eq!(named("v2").ty, "uint32_t"); - assert_eq!(named("v2").size, 4); + let masked = d + .variables + .iter() + .find(|v| v.role == Role::Local && v.ty == "uint32_t" && v.size == 4) + .unwrap_or_else(|| panic!("no uint32 local in {:?}", d.variables)); + assert!(word(&d.text, &masked.name), "{}", d.text); // Every variable the text declares is a variable the text mentions. for v in &d.variables { assert!(word(&d.text, &v.name), "{} is not in the body", v.name); @@ -292,7 +295,11 @@ fn an_access_through_a_pointer_reads_as_a_field() { // offset then reads as the field it is rather than as arithmetic. let text = unit(&p, "dupptr", None); assert!(text.contains("struct s_dupptr_arg0 {"), "{text}"); - assert!(text.contains("arg0[v0].field_c"), "{text}"); + // The index is the argument itself once it is not forced into a temporary. + assert!( + text.contains("arg0[") && text.contains(".field_c"), + "{text}" + ); // The name says the offset, which is the honest thing for a field nobody // declared: `field_c` is this crate's name for what is at twelve, not a // name anyone wrote down. @@ -332,6 +339,7 @@ fn a_declared_field_name_beats_the_one_the_offset_implies() { stride: Some(4), stack: None, }], + parameters_known: true, returns: Some("uint64_t".to_string()), definitions: Vec::new(), locals: BTreeMap::new(), diff --git a/crates/e5r-cli/src/json.rs b/crates/e5r-cli/src/json.rs index 3b2b76f..2276ceb 100644 --- a/crates/e5r-cli/src/json.rs +++ b/crates/e5r-cli/src/json.rs @@ -316,7 +316,14 @@ pub struct VariableOut { ty: String, size: u8, role: &'static str, + /// ABI parameter position. Absent for locals and inherited values. + argument: Option, + /// Human-readable storage, retained for command-line consumers. storage: String, + /// Entry-SP-relative offset, when this is a promoted stack slot. + stack_offset: Option, + /// Byte offset in the architecture's register file, when register-backed. + register: Option, } /// One decompiled function, with the numbers that say how well it went. @@ -346,13 +353,26 @@ pub fn decompiled(items: Vec<(&Function, &e5r_api::Decompiled)>) -> Listing VariableOut { +fn variable_out(v: &e5r_decomp::expr::Variable, argument: Option) -> VariableOut { use e5r_decomp::expr::{Home, Role}; VariableOut { name: v.name.clone(), @@ -363,11 +383,20 @@ fn variable_out(v: &e5r_decomp::expr::Variable) -> VariableOut { Role::Local => "local", Role::Inherited => "inherited", }, + argument, storage: match v.home { Home::Register(o) => format!("register+{o}"), Home::Stack(o) => format!("stack{o:+}"), Home::Anywhere => "anywhere".to_string(), }, + stack_offset: match v.home { + Home::Stack(o) => Some(o), + _ => None, + }, + register: match v.home { + Home::Register(o) => Some(format!("{o:#x}")), + _ => None, + }, } } diff --git a/crates/e5r-decomp/src/emit.rs b/crates/e5r-decomp/src/emit.rs index 6b7fc48..fcd6145 100644 --- a/crates/e5r-decomp/src/emit.rs +++ b/crates/e5r-decomp/src/emit.rs @@ -32,6 +32,8 @@ pub struct Output { pub signature: String, /// How many parameters it declares, so a call to it passes that many. pub arity: usize, + /// Whether an empty parameter list proves there are no parameters. + pub parameters_known: bool, /// Which of them are pointers. pub pointer_parameters: Vec, /// How wide each one is declared, in bytes, so a caller passing a constant @@ -48,6 +50,8 @@ pub struct Output { pub struct Prototype { /// The parameters, in order. pub parameters: Vec, + /// False for an import whose body cannot reveal what callers pass. + pub parameters_known: bool, /// The return type as C spells it. pub returns: Option, /// Local variables by their offset from the frame base. @@ -63,6 +67,8 @@ pub struct Callee { pub name: String, /// How many parameters it declares. pub arity: usize, + /// False when callers must recover prepared arguments at the call site. + pub parameters_known: bool, /// Which of them are pointers, so a call passes something C will take. pub pointer_parameters: Vec, /// How wide each one is declared, in bytes. @@ -276,7 +282,15 @@ pub fn decompile_full( let mut declarations: Vec = prototype.map(|p| p.definitions.clone()).unwrap_or_default(); - declarations.extend(helpers.iter().map(|h| h.to_string())); + // A clobber or an unmodelled condition that structuring never printed is + // not a call, and declaring the helper for it makes the unit look like it + // uses a value it does not. + declarations.extend( + helpers + .iter() + .filter(|h| helper_used(&body, h)) + .map(|h| h.to_string()), + ); declarations.extend( called .iter() @@ -322,8 +336,11 @@ pub fn decompile_full( Some(p) => p.parameters.iter().map(|param| param.size).collect(), None => vec![8; arity], }; - let declared_parameters = if declared.is_empty() { + let parameters_known = prototype.is_none_or(|p| p.parameters_known); + let declared_parameters = if declared.is_empty() && parameters_known { "void".to_string() + } else if declared.is_empty() { + String::new() } else { declared.join(", ") }; @@ -342,7 +359,11 @@ pub fn decompile_full( for (name, ty) in &inherited { let _ = writeln!(text, " {ty} {name}; // inherited"); } + let mut declared_locals: BTreeSet<&str> = BTreeSet::new(); for (value, local) in &rebuilder.locals { + if !declared_locals.insert(local) { + continue; + } let ty = if rebuilder.floats.contains(&value.location) { crate::expr::float_type(value.location.size) } else { @@ -360,12 +381,13 @@ pub fn decompile_full( text, signature, arity, + parameters_known, pointer_parameters, parameter_widths, declarations, gotos: s.gotos, lost: s.lost.len(), - locals: rebuilder.locals.len(), + locals: declared_locals.len(), unmodelled: e.unmodelled, } } @@ -441,6 +463,13 @@ fn mentions(text: &str, name: &str) -> bool { } /// The declaration an operation's helper needs, when it has one. +/// Whether the emitted body calls the helper a declaration would introduce. +fn helper_used(body: &str, declaration: &str) -> bool { + let head = declaration.split('(').next().unwrap_or(declaration); + let name = head.rsplit(' ').next().unwrap_or(head); + !name.is_empty() && body.contains(name) +} + fn helper_for(o: Op) -> Option<&'static str> { Some(match o { Op::FloatAdd @@ -491,42 +520,70 @@ fn helper_for(o: Op) -> Option<&'static str> { /// Which register the function leaves its result in, if any. /// -/// The convention lists the candidates; which one this function writes says -/// whether it returns an integer, a floating point value, or nothing. +/// The convention lists the candidates. A call then marks every other +/// caller-saved register undefined, and on AArch64 that includes `x1`, which +/// is also where a two-register result would come back. That clobber is not a +/// value the function computed. Counting it made every wrapper `return +/// __clobbered()` and dropped the call result on the floor. fn result_register(f: &SsaFunction, r: &Rebuilder) -> Option { - // The one written latest before the return. A function that computes into - // a general register and then converts into a vector one writes both, and - // only the order says which the caller reads. - let mut best: Option<(usize, u64)> = None; + let mut found: Vec = Vec::new(); for b in f.blocks.values() { - let returns = b.ops.iter().any(|op| op.kind == SsaKind::Op(Op::Return)); - if !returns { + if !b.ops.iter().any(|op| op.kind == SsaKind::Op(Op::Return)) { continue; } - for (n, op) in b.ops.iter().enumerate() { - let Some(v) = op.out else { continue }; - if v.location.space != Space::Register || !r.abi.results.contains(&v.location.offset) { - continue; - } - if best.map(|(at, _)| n > at).unwrap_or(true) { - best = Some((n, v.location.offset)); - } + if let Some(offset) = result_in_block(b, r) { + found.push(offset); } } - if let Some((_, offset)) = best { + // Several blocks can return. The earliest result register any of them + // actually writes is the one the convention reads first: `x0` before the + // high half, and either before a vector result nobody computed. + if let Some(offset) = r.abi.results.iter().copied().find(|o| found.contains(o)) { return Some(offset); } // Nothing was written in the returning block, so whichever the function - // writes at all is the answer. + // writes at all is the answer. A clobber still is not a write. r.abi.results.iter().copied().find(|offset| { f.blocks.values().any(|b| { - b.ops - .iter() - .any(|op| op.out.is_some_and(|v| v.location.offset == *offset)) + b.ops.iter().any(|op| { + op.kind != SsaKind::Op(Op::Undefine) + && op.out.is_some_and(|v| { + v.location.space == Space::Register && v.location.offset == *offset + }) + }) }) }) } +/// The result register one returning block leaves behind. +/// +/// A real operation beats a phi: phis for every live register sit at the top +/// of the block in location order, and the last of them would otherwise be +/// `x1`'s merge rather than the value the block computed. With only phis, the +/// earliest result register is the one the caller reads. +fn result_in_block(b: &e5r_ir::ssa::SsaBlock, r: &Rebuilder) -> Option { + let mut latest_real: Option<(usize, u64)> = None; + let mut phis: Vec = Vec::new(); + for (n, op) in b.ops.iter().enumerate() { + if op.kind == SsaKind::Op(Op::Undefine) { + continue; + } + let Some(v) = op.out else { continue }; + if v.location.space != Space::Register || !r.abi.results.contains(&v.location.offset) { + continue; + } + if op.kind == SsaKind::Phi { + phis.push(v.location.offset); + } else { + latest_real = Some((n, v.location.offset)); + } + } + if let Some((_, offset)) = latest_real { + return Some(offset); + } + r.abi.results.iter().copied().find(|o| phis.contains(o)) +} + /// The declared return type, from where the result was left. fn return_type(result: Option, r: &Rebuilder) -> &'static str { match result { @@ -993,7 +1050,13 @@ impl Emitter<'_> { let name = callee .map(|c| c.name.clone()) .unwrap_or_else(|| crate::expr::default_call_name(target)); - Expr::Call(name, self.arguments(at, index, callee)) + let known = callee.is_some_and(|c| c.parameters_known); + let args = if known { + self.arguments(at, index, callee) + } else { + self.unknown_arguments(at, index) + }; + self.direct_call(name, args, known) } (Op::CallInd, _) => { let mut args = op @@ -1031,7 +1094,13 @@ impl Emitter<'_> { let name = callee .map(|c| c.name.clone()) .unwrap_or_else(|| crate::expr::default_call_name(target)); - let e = Expr::Call(name, self.arguments(at, index, callee)); + let known = callee.is_some_and(|c| c.parameters_known); + let args = if known { + self.arguments(at, index, callee) + } else { + self.unknown_arguments(at, index) + }; + let e = self.direct_call(name, args, known); let _ = writeln!(out, "{pad}{e};"); let _ = writeln!(out, "{pad}{}", self.return_statement(at)); } @@ -1099,6 +1168,19 @@ impl Emitter<'_> { } } + /// A direct call. An unknown prototype is invoked through a cast so the + /// arguments this site prepared are visible, and so a declaration that + /// could not learn an arity still compiles beside them. + fn direct_call(&self, name: String, args: Vec, parameters_known: bool) -> Expr { + if parameters_known || args.is_empty() { + return Expr::Call(name, args); + } + let formals = std::iter::repeat_n("uint64_t", args.len()) + .collect::>() + .join(", "); + Expr::Call(format!("((uint64_t (*)({formals})){name})"), args) + } + /// The arguments a call passes, read out of the convention's registers. /// /// The value each one holds is whatever last wrote it before the call. A @@ -1232,22 +1314,30 @@ impl Emitter<'_> { let Some(offset) = self.result else { return String::new(); }; + let defined = |op: &SsaOp| { + op.kind != SsaKind::Op(Op::Undefine) + && op.out.is_some_and(|v| { + v.location.space == Space::Register && v.location.offset == offset + }) + }; let in_block = self.f.blocks.get(&at).and_then(|b| { b.ops .iter() .rev() + .filter(|op| defined(op)) .filter_map(|op| op.out) - .find(|v| v.location.space == Space::Register && v.location.offset == offset) + .next() }); // Nothing in this block wrote it, so the value came from wherever it // was last written: the newest version is the one that reaches here. + // A clobber is not a version of the result. let value = in_block.or_else(|| { self.f .blocks .values() .flat_map(|b| b.ops.iter()) + .filter(|op| defined(op)) .filter_map(|op| op.out) - .filter(|v| v.location.space == Space::Register && v.location.offset == offset) .max_by_key(|v| v.version) }); match value { diff --git a/crates/e5r-decomp/src/expr.rs b/crates/e5r-decomp/src/expr.rs index 59a9c97..abe19ad 100644 --- a/crates/e5r-decomp/src/expr.rs +++ b/crates/e5r-decomp/src/expr.rs @@ -291,16 +291,100 @@ pub fn input_name(l: Location, abi: &Abi) -> String { /// How many times each value is read. pub fn use_counts(f: &SsaFunction) -> BTreeMap { - let mut out: BTreeMap = BTreeMap::new(); - for b in f.blocks.values() { - for op in &b.ops { - for i in &op.inputs { - if let Operand::Value(v) = i { - *out.entry(*v).or_default() += 1; + let defs = f.definitions(); + let mut live_ops: BTreeSet<(e5r_core::Addr, usize)> = BTreeSet::new(); + let mut work: Vec = Vec::new(); + + // Side effects and control flow are roots. Everything else is live only + // when a root (transitively) reads its result. + for (at, block) in &f.blocks { + for (index, op) in block.ops.iter().enumerate() { + let root = match op.kind { + SsaKind::Op(o) => matches!(o, Op::Store | Op::Unimplemented) || o.is_branch(), + SsaKind::Phi => false, + }; + if !root { + continue; + } + live_ops.insert((*at, index)); + work.extend(op.inputs.iter().filter_map(|input| input.as_value())); + } + } + + // A return reads the result registers. The operation lists the link + // register, not the value the caller gets, so without this a promoted + // local that exists only to be returned is dead and the body that + // computed it disappears. + let abi = e5r_ir::abi::of(&f.arch); + let mut returned: Vec = Vec::new(); + for block in f.blocks.values() { + for (index, op) in block.ops.iter().enumerate() { + if op.kind != SsaKind::Op(Op::Return) { + continue; + } + // One value, not every result register. A call clobbers `x1`, + // which is also a second result register; treating that clobber + // as returned kept `__clobbered()` alive beside the real result. + let mut latest_real: Option = None; + let mut phis: Vec<(u64, Value)> = Vec::new(); + for earlier in &block.ops[..index] { + if earlier.kind == SsaKind::Op(Op::Undefine) { + continue; } + let Some(value) = earlier.out else { + continue; + }; + if value.location.space != e5r_ir::op::Space::Register + || !abi.results.contains(&value.location.offset) + { + continue; + } + if earlier.kind == SsaKind::Phi { + phis.push((value.location.offset, value)); + } else { + latest_real = Some(value); + } + } + let value = latest_real.or_else(|| { + abi.results.iter().find_map(|offset| { + phis.iter() + .find(|(at, _)| at == offset) + .map(|(_, value)| *value) + }) + }); + if let Some(value) = value { + returned.push(value); + work.push(value); } } } + + while let Some(value) = work.pop() { + let Some(site) = defs.get(&value).copied() else { + continue; + }; + if !live_ops.insert(site) { + continue; + } + if let Some(op) = f.blocks.get(&site.0).and_then(|b| b.ops.get(site.1)) { + work.extend(op.inputs.iter().filter_map(|input| input.as_value())); + } + } + + let mut out: BTreeMap = BTreeMap::new(); + for (at, index) in live_ops { + let Some(op) = f.blocks.get(&at).and_then(|b| b.ops.get(index)) else { + continue; + }; + for input in &op.inputs { + if let Operand::Value(value) = input { + *out.entry(*value).or_default() += 1; + } + } + } + for value in returned { + *out.entry(value).or_default() += 1; + } out } @@ -475,10 +559,11 @@ impl<'a> Rebuilder<'a> { let defs = f.definitions(); let uses = use_counts(f); let mut locals = BTreeMap::new(); + let mut stack_locals: BTreeMap = BTreeMap::new(); let mut n = 0; // Values read more than once, and every phi, get a name. A phi is a - // merge of paths and inlining it would mean writing the merge out at - // each use. + // merge of paths. Inlining it prints `__phi`, which is not C, and a + // call reads argument registers the call operation does not list. for (value, (block, index)) in &defs { let Some(op) = f.blocks.get(block).and_then(|b| b.ops.get(*index)) else { continue; @@ -488,9 +573,23 @@ impl<'a> Rebuilder<'a> { // call is a statement, and inlining its result would write the // call out again and make it happen twice. let call = matches!(op.kind, SsaKind::Op(Op::Call) | SsaKind::Op(Op::CallInd)); - if multiple || call || op.kind == SsaKind::Phi { - locals.insert(*value, format!("v{n}")); - n += 1; + let phi = op.kind == SsaKind::Phi; + if multiple || call || phi { + let name = if value.location.space == e5r_ir::op::Space::Stack { + stack_locals + .entry(value.location) + .or_insert_with(|| { + let name = format!("v{n}"); + n += 1; + name + }) + .clone() + } else { + let name = format!("v{n}"); + n += 1; + name + }; + locals.insert(*value, name); } } Rebuilder { diff --git a/crates/e5r-decomp/tests/datatests.rs b/crates/e5r-decomp/tests/datatests.rs index a73b0ae..1cece74 100644 --- a/crates/e5r-decomp/tests/datatests.rs +++ b/crates/e5r-decomp/tests/datatests.rs @@ -21,6 +21,7 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; +use std::process::Command; use std::sync::{Arc, Mutex, OnceLock}; use e5r_analysis::{Options, Program, analyze}; @@ -560,6 +561,24 @@ fn stackstring() { }); } +/// SSA gives every definition a version, but several versions of one promoted +/// stack slot are one C local rather than a row of invented temporaries. +#[test] +fn promoted_stack_slot_has_one_name() { + case(&["driver.x64.O0".to_string()], "compares", |c| { + let declarations: Vec<&str> = c + .text + .lines() + .filter(|line| line.trim_start().starts_with("uint32_t v")) + .collect(); + if declarations.len() != 1 { + c.fail(&format!( + "one promoted stack slot should have one declaration: {declarations:?}" + )); + } + }); +} + /// heapstring.xml: the same store pattern, to memory the function does not own. /// /// x86-64 only: the AArch64 build ends the run with a `strb` of a register, @@ -899,6 +918,82 @@ fn indirect_call_keeps_arguments() { }); } +/// An import thunk has an unknown prototype, not a zero-argument prototype. +/// Prepared argument registers therefore stay attached to PLT calls. +#[test] +fn plt_calls_keep_prepared_arguments() { + case(&["hello.a64.O0".to_string()], "main", |c| { + // The prototype is unknown, so the call is cast to the arguments this + // site prepared. An empty call is the bug. + c.has("strlen_plt)") + .has("*(uint64_t *)") + .has("printf_plt)") + .lacks("strlen_plt()"); + }); +} + +/// A wrapper's `ret` reads `x0`. The call that produced it also clobbers `x1`, +/// which the convention lists as a second result register. That clobber is not +/// the return value, and a phi of the real result still has to be named: the +/// return operation itself does not mention it. +#[test] +fn a_returned_call_is_the_call_result() { + let Some(cc) = ["clang", "cc", "gcc"] + .into_iter() + .find(|name| Command::new(name).arg("--version").output().is_ok()) + else { + return; + }; + let dir = std::env::temp_dir().join("e5r-returned-call"); + std::fs::create_dir_all(&dir).unwrap(); + let src = dir.join("w.c"); + std::fs::write( + &src, + "int callee(int x){return x+1;} + int wrapper(int x){return callee(x);} + int branchy(int x){if(x)return callee(x);return callee(x+1);}\n", + ) + .unwrap(); + let bin = dir.join("w.o"); + let compiled = Command::new(cc) + .args(["-c", "-O0", "-fno-builtin", "-o"]) + .arg(&bin) + .arg(&src) + .output(); + let Ok(output) = compiled else { return }; + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let data = std::fs::read(&bin).unwrap(); + let obj = e5r_format::load(&data, &LoadOptions::default()).expect("load wrapper"); + let program = analyze(obj, &Options::default()); + let targets: Vec<&e5r_analysis::Function> = program + .functions_by_address() + .filter(|f| { + f.name + .as_deref() + .is_some_and(|n| n == "wrapper" || n == "branchy") + }) + .collect(); + assert_eq!(targets.len(), 2, "compiled functions were not recovered"); + let text = e5r_api::decompile_program(&program, &targets).text(); + assert!(!text.contains("__phi"), "{text}"); + assert!(text.contains("callee"), "{text}"); + // The returned expression is the call's result. A clobber of the high + // half may still be named; it must not be what the function returns. + for body in text.split("uint64_t ").skip(1) { + let Some(ret) = body.lines().rev().find(|line| line.contains("return")) else { + continue; + }; + assert!( + !ret.contains("__clobbered") && !ret.contains("__phi"), + "{text}" + ); + } +} + /// deindirect.xml, the target half: both calls load one writable global that /// initially points at `realfunc`. Ghidra collapses them to direct calls. Doing /// that here needs proof the global cannot change between load and call; its diff --git a/crates/e5r-decomp/tests/roundtrip.rs b/crates/e5r-decomp/tests/roundtrip.rs index d497fd1..399509d 100644 --- a/crates/e5r-decomp/tests/roundtrip.rs +++ b/crates/e5r-decomp/tests/roundtrip.rs @@ -77,6 +77,36 @@ fn vectors(arity: usize) -> Vec> { /// test covers them all and a helper added later cannot slip past. const DISQUALIFYING: &[&str] = &["sub_", "__", "*(", "goto"]; +/// A call by its real name. `sub_` and `__` miss it, and the harness has no +/// definition for the callee, so the function is not one this gate can run. +fn calls_out(text: &str) -> bool { + let bytes = text.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'(' && text[..i].contains('{') { + let mut j = i; + while j > 0 && bytes[j - 1].is_ascii_whitespace() { + j -= 1; + } + let end = j; + while j > 0 && (bytes[j - 1].is_ascii_alphanumeric() || bytes[j - 1] == b'_') { + j -= 1; + } + let name = &text[j..end]; + if !name.is_empty() + && !matches!( + name, + "if" | "while" | "for" | "switch" | "sizeof" | "return" + ) + { + return true; + } + } + i += 1; + } + false +} + fn build_dir() -> Option { let d = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/build"); d.is_dir().then(|| d.canonicalize().unwrap()) @@ -140,7 +170,10 @@ struct Candidate { /// Decompile one function and run it, if it is eligible. fn candidate(p: &Program, f: &Function) -> Option { let d = e5r_api::decompile_function(p, f); - if d.text.is_empty() || DISQUALIFYING.iter().any(|bad| d.text.contains(bad)) { + if d.text.is_empty() + || DISQUALIFYING.iter().any(|bad| d.text.contains(bad)) + || calls_out(&d.text) + { return None; } // A function whose C returns nothing has no answer to compare, and one diff --git a/docs/decbench.md b/docs/decbench.md index 1d2d0ab..49b38e0 100644 --- a/docs/decbench.md +++ b/docs/decbench.md @@ -249,6 +249,12 @@ the 43 perfect functions come from. Exposing per-variable name, type and stack offset in the JSON is the single cheapest point of score on this list. +**Status after the recorded run:** the JSON surface now carries each +variable's name, type, size, role and machine home, and the adapter translates +parameters to ABI-positioned `VariableInfo` records and locals to their native +stack offsets where known. This closes the integration defect; the score above +predates it and must be re-measured before claiming an improvement. + **3. Calls through the PLT lose their arguments and their return value.** 295 of the 780 functions contain at least one `name_plt()` call emitted with an empty argument list; libz alone has 194 such call sites, and **30 of the 47 @@ -281,6 +287,13 @@ gcc -g -O0 -fno-builtin /tmp/p.c -o /tmp/p The argument is gone in both. The assignment is gone only in `all`, which is the mode the benchmark runs. +**Status after the recorded run:** a stub whose real function is in the same +image borrows that function's signature. `deflateInit_` is the eight-argument +call it actually is, and the value comes back in the `return`. A stub with no +local function is called with the argument registers the call site prepared, +through a cast. The numbers above were measured before this and have not +been re-run. + This one does not show up as a GED loss, because a call is a call whatever its arguments, but it is the reason a recompiled e5r function computes the wrong thing, and it is a `docs/design/decompiling.md` violation: the design says the diff --git a/scripts/decbench_e5r.py b/scripts/decbench_e5r.py index cfcfc79..e1833e0 100644 --- a/scripts/decbench_e5r.py +++ b/scripts/decbench_e5r.py @@ -32,11 +32,87 @@ DecompilationResult, DecompilerMetadata, FunctionDecompilation, + VariableInfo, ) _REPO = Path(__file__).resolve().parent.parent +def requested_addresses( + functions: list[tuple[str, int]] | None, function_names: set[int] | None +) -> set[int]: + """Every address either spelling of DecBench's target API requested.""" + out = set(common.addr_targets_of(function_names)) + for _, address in functions or []: + out.add(int(address)) + return out + + +def variable_info(item: dict[str, Any]) -> list[VariableInfo]: + """Translate e5r's stable JSON variable records to DecBench's model. + + Parameter indices are ABI positions, not enumeration positions among all + variables. Inherited registers are deliberately absent: they are neither + source parameters nor locals, and presenting them as either would create + false type claims. Stack homes use e5r's entry-SP-relative offset; DecBench + calibrates each backend's native offset space before matching it to DWARF. + """ + out: list[VariableInfo] = [] + arg_index = 0 + for raw in item.get("variables") or []: + if not isinstance(raw, dict): + continue + role = str(raw.get("role") or "") + if role == "inherited": + continue + if role not in {"parameter", "local"}: + continue + + stack_offset = raw.get("stack_offset") + try: + stack_offset = int(stack_offset) if stack_offset is not None else None + except (TypeError, ValueError): + stack_offset = None + storage = str(raw.get("storage") or "") + if stack_offset is None and storage.startswith("stack"): + try: + stack_offset = int(storage.removeprefix("stack"), 10) + except ValueError: + pass + + size = raw.get("size") + try: + size = int(size) + except (TypeError, ValueError): + size = None + if size is not None and size <= 0: + size = None + + is_arg = role == "parameter" + explicit_index = raw.get("argument") + try: + explicit_index = int(explicit_index) if explicit_index is not None else None + except (TypeError, ValueError): + explicit_index = None + if explicit_index is not None and explicit_index < 0: + explicit_index = None + out.append( + VariableInfo( + name=str(raw.get("name") or ""), + type=str(raw.get("type") or ""), + stack_offset=stack_offset, + size=size, + kind="arg" if is_arg else "stack", + arg_index=(explicit_index if explicit_index is not None else arg_index) + if is_arg + else None, + ) + ) + if is_arg: + arg_index += 1 + return out + + def e5r_binary() -> Path | None: """The e5r executable: $E5R, the release build, or one on $PATH.""" env = os.environ.get("E5R") @@ -103,7 +179,8 @@ def decompile_binary( # e5r reports a function's virtual address, which for an ELF is already # the file-space address DecBench keys DWARF on, so no rebasing is needed. text_range = common.elf_text_ranges(binary_path) - addr_targets = common.addr_targets_of(function_names) + addr_targets = requested_addresses(functions, function_names) + explicit_names = {int(address): str(name) for name, address in functions or []} decompiled: dict[str, FunctionDecompilation] = {} failed: list[str] = [] @@ -132,16 +209,19 @@ def _result(partial: bool, error: str = "") -> DecompilationResult: ) targets_mode = os.environ.get("E5R_MODE") == "targets" and addr_targets - try: - if targets_mode: - items = [] - for addr in sorted(addr_targets): + if targets_mode: + items = [] + for addr in sorted(addr_targets): + try: items.extend(self._run(binary_path, hex(addr))) - else: + except Exception: # noqa: BLE001 + failed.append(explicit_names.get(addr, hex(addr))) + else: + try: items = self._run(binary_path, "all") - except Exception as e: # noqa: BLE001 - failed.append("all") - return _result(partial=False, error=str(e)) + except Exception as e: # noqa: BLE001 + failed.append("all") + return _result(partial=False, error=str(e)) candidates: list[tuple[str, int, dict]] = [] for item in items: @@ -158,7 +238,7 @@ def _result(partial: bool, error: str = "") -> DecompilationResult: by_addr = {addr: (name, item) for name, addr, item in candidates} narrowed = common.narrow_to_source( [(name, addr) for name, addr, _ in candidates], - function_names, + addr_targets or None, backend=self.name, binary_name=binary_path.name, ) @@ -166,8 +246,9 @@ def _result(partial: bool, error: str = "") -> DecompilationResult: for name, addr in narrowed: entry = by_addr.get(addr) code = (entry[1].get("code") or "") if entry else "" + output_name = explicit_names.get(addr, name) if not code.strip(): - failed.append(name) + failed.append(output_name) continue item = entry[1] metadata = common.extract_metrics(code) @@ -176,12 +257,12 @@ def _result(partial: bool, error: str = "") -> DecompilationResult: metadata["e5r_gotos"] = item.get("gotos") metadata["e5r_unmodelled"] = item.get("unmodelled") metadata["e5r_complete"] = (item.get("function") or {}).get("complete") - decompiled[name] = FunctionDecompilation( - name=name, + decompiled[output_name] = FunctionDecompilation( + name=output_name, address=addr, decompiled_code=code, line_count=code.count("\n") + 1, - variables=[], + variables=variable_info(item), metadata=metadata, ) if progress_path is not None: diff --git a/scripts/test_decbench_e5r.py b/scripts/test_decbench_e5r.py new file mode 100644 index 0000000..7241f6f --- /dev/null +++ b/scripts/test_decbench_e5r.py @@ -0,0 +1,138 @@ +"""Focused tests for the out-of-tree DecBench adapter.""" + +from __future__ import annotations + +import os +import unittest +from unittest.mock import patch + +from decbench_e5r import requested_addresses, variable_info + + +class TargetTests(unittest.TestCase): + def test_both_decbench_target_spellings_are_honoured(self) -> None: + self.assertEqual( + requested_addresses([("one", 0x1000), ("two", 0x2000)], {0x3000}), + {0x1000, 0x2000, 0x3000}, + ) + + def test_default_single_function_api_uses_the_requested_name(self) -> None: + from pathlib import Path + + from decbench_e5r import E5rDecompiler + + backend = E5rDecompiler() + item = { + "function": {"name": "sub_1000", "addr": "0x1000", "complete": True}, + "code": "uint64_t sub_1000(void) { return 1; }", + "variables": [], + } + with ( + patch.object(backend, "_run", return_value=[item]), + patch("decbench_e5r.common.elf_text_ranges", return_value=None), + patch("decbench_e5r.common.should_skip_function", return_value=False), + ): + code = backend.decompile_function(Path("stripped.elf"), "source_name", 0x1000) + self.assertEqual(code, item["code"]) + + def test_target_mode_keeps_successes_when_one_address_fails(self) -> None: + from pathlib import Path + + from decbench_e5r import E5rDecompiler + + backend = E5rDecompiler() + item = { + "function": {"name": "sub_2000", "addr": "0x2000", "complete": True}, + "code": "uint64_t sub_2000(void) { return 2; }", + "variables": [], + } + + def run(_binary: Path, target: str) -> list[dict]: + if target == "0x1000": + raise RuntimeError("one malformed function") + return [item] + + with ( + patch.object(backend, "_run", side_effect=run), + patch("decbench_e5r.common.elf_text_ranges", return_value=None), + patch("decbench_e5r.common.should_skip_function", return_value=False), + patch.dict(os.environ, {"E5R_MODE": "targets"}), + ): + result = backend.decompile_binary( + Path("stripped.elf"), + functions=[("bad", 0x1000), ("good", 0x2000)], + ) + + self.assertEqual(list(result.functions), ["good"]) + self.assertEqual(result.decompiler.failed_functions, ["bad"]) + + +class VariableInfoTests(unittest.TestCase): + def test_parameters_keep_abi_order_and_locals_keep_stack_offsets(self) -> None: + got = variable_info( + { + "variables": [ + { + "name": "arg0", + "type": "uint8_t *", + "size": 8, + "role": "parameter", + "storage": "register+56", + }, + { + "name": "saved", + "type": "uint32_t", + "size": 4, + "role": "local", + "storage": "stack-12", + "stack_offset": -20, + }, + { + "name": "reg40", + "type": "uint64_t", + "size": 8, + "role": "inherited", + "storage": "register+40", + }, + { + "name": "arg1", + "type": "uint32_t", + "size": 4, + "role": "parameter", + "storage": "stack+16", + "argument": 7, + }, + ] + } + ) + + self.assertEqual([v.name for v in got], ["arg0", "saved", "arg1"]) + self.assertEqual([v.arg_index for v in got], [0, None, 7]) + self.assertEqual([v.kind for v in got], ["arg", "stack", "arg"]) + # The numeric API field wins over the legacy display string. The + # latter remains a fallback for output from older e5r versions. + self.assertEqual([v.stack_offset for v in got], [None, -20, 16]) + + def test_malformed_optional_fields_do_not_break_a_binary(self) -> None: + got = variable_info( + { + "variables": [ + { + "name": "v0", + "type": "uint64_t", + "size": "unknown", + "role": "local", + "storage": "stack?", + }, + "not a record", + ] + } + ) + + self.assertEqual(len(got), 1) + self.assertIsNone(got[0].size) + self.assertIsNone(got[0].stack_offset) + + +if __name__ == "__main__": + unittest.main() From 80512341125198c85644dd54544de82808e418b6 Mon Sep 17 00:00:00 2001 From: Koukyosyumei Date: Wed, 23 Sep 2026 16:54:04 -0400 Subject: [PATCH 3/8] A clobber check followed every input, and one call never returned Deciding whether a prepared register was only a call clobber walked every operand of every operation, sixteen deep. On a real argument that search does not come back. It now follows the copy and width-cast chain above an undefine, and inlining a value that reads itself stops. --- crates/e5r-decomp/src/emit.rs | 21 ++++++++++++++++----- crates/e5r-decomp/src/expr.rs | 32 +++++++++++++++++++++++++++++--- 2 files changed, 45 insertions(+), 8 deletions(-) diff --git a/crates/e5r-decomp/src/emit.rs b/crates/e5r-decomp/src/emit.rs index fcd6145..65ab48a 100644 --- a/crates/e5r-decomp/src/emit.rs +++ b/crates/e5r-decomp/src/emit.rs @@ -447,6 +447,11 @@ pub fn identifier(name: &str) -> String { /// True when a name appears in the text as a whole word. fn mentions(text: &str, name: &str) -> bool { + // An empty name is a substring of every string, and the search would + // never advance. + if name.is_empty() { + return false; + } let mut at = 0; while let Some(found) = text[at..].find(name) { let start = at + found; @@ -1279,11 +1284,17 @@ impl Emitter<'_> { if op.kind == SsaKind::Op(Op::Undefine) { return true; } - op.inputs.iter().any(|input| { - input - .as_value() - .is_some_and(|v| self.value_is_clobbered(v, depth + 1)) - }) + // Only the chain of copies and width casts SSA inserts above a + // clobber. Following every input of every operation is exponential in + // the depth, and a call argument whose tree is a few dozen operations + // deep does not come back. + let SsaKind::Op(Op::Copy | Op::IntZExt | Op::IntSExt | Op::SubPiece) = op.kind else { + return false; + }; + op.inputs + .first() + .and_then(|input| input.as_value()) + .is_some_and(|v| self.value_is_clobbered(v, depth + 1)) } /// The value a register held just before an operation. diff --git a/crates/e5r-decomp/src/expr.rs b/crates/e5r-decomp/src/expr.rs index abe19ad..54fd772 100644 --- a/crates/e5r-decomp/src/expr.rs +++ b/crates/e5r-decomp/src/expr.rs @@ -10,6 +10,7 @@ //! load or a call would change what the code does, and duplicating arithmetic //! makes the output longer rather than clearer. +use std::cell::RefCell; use std::collections::{BTreeMap, BTreeSet}; use std::fmt; @@ -522,6 +523,9 @@ pub struct Rebuilder<'a> { pub abi: Abi, defs: BTreeMap, uses: BTreeMap, + /// Values currently being expanded. A definition that reads one of them + /// is a cycle, and inlining it does not terminate. + expanding: RefCell>, /// Values that became named locals because they are read more than once. pub locals: BTreeMap, /// Locations that hold floating point values, which is what decides how a @@ -605,6 +609,7 @@ impl<'a> Rebuilder<'a> { defs, uses, locals, + expanding: RefCell::new(BTreeSet::new()), } } @@ -737,6 +742,13 @@ impl<'a> Rebuilder<'a> { /// An index multiplied by a constant, however it was written. fn scaled(&self, o: &Operand) -> Option<(Expr, u64)> { + self.scaled_at(o, 0) + } + + fn scaled_at(&self, o: &Operand, depth: u32) -> Option<(Expr, u64)> { + if depth > 8 { + return None; + } let Operand::Value(v) = o else { return None }; let op = self.definition(*v)?; let SsaKind::Op(kind) = op.kind else { @@ -754,7 +766,7 @@ impl<'a> Rebuilder<'a> { let n = op.inputs.get(1)?.as_const()?; Some((self.operand(op.inputs.first()?), n)) } - Op::Copy | Op::IntSExt | Op::IntZExt => self.scaled(op.inputs.first()?), + Op::Copy | Op::IntSExt | Op::IntZExt => self.scaled_at(op.inputs.first()?, depth + 1), _ => None, } } @@ -790,10 +802,24 @@ impl<'a> Rebuilder<'a> { if let Some(name) = self.locals.get(v) { return Expr::Local(name.clone()); } - match self.definition(*v) { + // Drop the borrow before expanding. The set stays marked so a + // nested read of this same value stops instead of looping. + let cycle = { + let mut expanding = self.expanding.borrow_mut(); + !expanding.insert(*v) + }; + if cycle { + // The definition reads itself. Expanding it again does not + // terminate. The location's own name is what gets declared + // when the body still mentions it. + return Expr::Local(self.name_of(v.location)); + } + let expr = match self.definition(*v) { Some(op) => self.expr(op), None => Expr::Input(v.location, self.name_of(v.location)), - } + }; + self.expanding.borrow_mut().remove(v); + expr } } } From df46fe74d3508a66700b2250d21b1591964aeba9 Mon Sep 17 00:00:00 2001 From: Koukyosyumei Date: Wed, 23 Sep 2026 21:36:56 -0400 Subject: [PATCH 4/8] A bottom-tested loop is a do-while, and a guard returns instead of sharing the epilogue. --- crates/e5r-decomp/src/emit.rs | 67 ++++++++- crates/e5r-decomp/src/structure.rs | 229 +++++++++++++++++++++++++++++ 2 files changed, 291 insertions(+), 5 deletions(-) diff --git a/crates/e5r-decomp/src/emit.rs b/crates/e5r-decomp/src/emit.rs index 65ab48a..0458359 100644 --- a/crates/e5r-decomp/src/emit.rs +++ b/crates/e5r-decomp/src/emit.rs @@ -8,7 +8,7 @@ use e5r_ir::op::{Op, Space}; use e5r_ir::ssa::{Operand, SsaFunction, SsaKind, SsaOp, Value}; use crate::expr::{Expr, Rebuilder, c_type, input_name, negate}; -use crate::structure::{Graph, Region, Switches, Taken, structure_with}; +use crate::structure::{Graph, Region, Switches, Taken, lift_exit_guards, structure_with}; /// A decompiled function. #[derive(Debug, Clone)] @@ -161,7 +161,10 @@ pub fn decompile_full( Some((*a, Addr(target))) }) .collect(); - let s = structure_with(f.entry, &graph, &taken, switches); + let mut s = structure_with(f.entry, &graph, &taken, switches); + // Done here, where a block's real work can be weighed. `structure_with` + // only has the graph, and a diamond of empty blocks must stay an if/else. + lift_exit_guards(&mut s.root, &graph, &s.labels, &block_weights(f)); let mut rebuilder = Rebuilder::new(f); // A declared parameter arrives in a register the convention chooses, so // the body can use the name the source gave it rather than `arg0`. @@ -640,6 +643,54 @@ fn parameter_list(f: &SsaFunction, r: &Rebuilder) -> Vec { /// /// Their indirect branch is the switch itself, so writing it out as well would /// say the control transfer twice. +/// How much each block does, ignoring the branch that structuring already +/// accounted for. The return itself is the shared tail, so it does not count +/// as work that would make a guard look heavy. A phi is written as an +/// assignment on the incoming edge, so that assignment belongs to the +/// predecessor: a guard's only work is often just that copy. +fn block_weights(f: &SsaFunction) -> BTreeMap { + let mut weights: BTreeMap = f + .blocks + .iter() + .map(|(at, block)| { + let weight = block + .ops + .iter() + .filter(|op| { + !matches!( + op.kind, + SsaKind::Phi + | SsaKind::Op(Op::Branch | Op::CBranch | Op::BranchInd | Op::Return) + ) + }) + .count() as u32; + (*at, weight) + }) + .collect(); + for block in f.blocks.values() { + for (slot, pred) in block.predecessors.iter().enumerate() { + let copies = block + .ops + .iter() + .filter(|op| { + op.kind == SsaKind::Phi && op.out.is_some() && op.inputs.get(slot).is_some() + }) + .count() as u32; + *weights.entry(*pred).or_default() += copies; + } + } + weights +} + +/// True when a loop body is only the edge back to its header. +fn falls_straight_back(region: &Region) -> bool { + match region { + Region::Empty | Region::Continue => true, + Region::Seq(parts) => parts.iter().all(falls_straight_back), + _ => false, + } +} + fn switch_heads(r: &Region) -> BTreeSet { let mut out = BTreeSet::new(); collect_switch_heads(r, &mut out); @@ -759,10 +810,16 @@ impl Emitter<'_> { } // The head's own statements compute the condition, so they run // on every iteration: a `for (; cond; )` with them hoisted - // would be wrong. Write the loop as `while (1)` with the test - // at the top when the head does more than test. + // would be wrong. A header that is itself the whole iteration + // tests at the bottom, which is a `do`/`while`. A `while (1)` + // with a break there has an edge from the entry straight to + // the exit, and this block has no such edge. let cond = self.condition(*head, *invert); - if self.head_is_only_a_test(*head) { + if !self.head_is_only_a_test(*head) && falls_straight_back(body) { + let _ = writeln!(out, "{pad}do {{"); + self.statements(out, *head, depth + 1); + let _ = writeln!(out, "{pad}}} while ({cond});"); + } else if self.head_is_only_a_test(*head) { let _ = writeln!(out, "{pad}while ({cond}) {{"); self.region(out, body, depth + 1); let _ = writeln!(out, "{pad}}}"); diff --git a/crates/e5r-decomp/src/structure.rs b/crates/e5r-decomp/src/structure.rs index bf813b5..d455a8c 100644 --- a/crates/e5r-decomp/src/structure.rs +++ b/crates/e5r-decomp/src/structure.rs @@ -270,6 +270,179 @@ pub fn structure_with( } } +/// A branch whose arms rejoin only by leaving the function, with one arm much +/// smaller than the other, is the early return a guard is. +/// +/// The compiler still builds one epilogue, so the recovered tree has an +/// `if`/`else` and a shared tail. Writing that tail into the light arm and +/// letting the heavy arm fall into the original copy is the same run, and it +/// is the shape of a check that returns on failure. A balanced diamond is left +/// alone: both arms are the choice, and an early return would invent one. +/// `weight` is how much each block does, so "light" is about the arm and not +/// about how the compiler bundled its instructions. +pub fn lift_exit_guards( + root: &mut Region, + graph: &Graph, + labels: &BTreeSet, + weight: &BTreeMap, +) { + lift_exit_guards_rec(root, graph, labels, weight); + tidy(root); +} + +/// Heavier than this, an arm is the body of the function and not a guard. +const GUARD_MAX: u32 = 8; + +/// Heavier than this, the block both arms fall into is shared work, not an +/// epilogue. Copying it would write that work on both arms. +const EXIT_MAX: u32 = 3; + +fn lift_exit_guards_rec( + region: &mut Region, + graph: &Graph, + labels: &BTreeSet, + weight: &BTreeMap, +) { + match region { + Region::Seq(parts) => { + for part in parts.iter_mut() { + lift_exit_guards_rec(part, graph, labels, weight); + } + lift_seq(parts, graph, labels, weight); + } + Region::If { + then, otherwise, .. + } => { + lift_exit_guards_rec(then, graph, labels, weight); + if let Some(arm) = otherwise { + lift_exit_guards_rec(arm, graph, labels, weight); + } + } + Region::While { body, .. } | Region::Infinite { body, .. } => { + lift_exit_guards_rec(body, graph, labels, weight); + } + Region::Switch { cases, default, .. } => { + for case in cases { + lift_exit_guards_rec(&mut case.body, graph, labels, weight); + } + if let Some(arm) = default { + lift_exit_guards_rec(arm, graph, labels, weight); + } + } + _ => {} + } +} + +fn lift_seq( + parts: &mut Vec, + graph: &Graph, + labels: &BTreeSet, + weight: &BTreeMap, +) { + let old = std::mem::take(parts); + let mut index = 0; + while index < old.len() { + let exit_follows = index + 2 == old.len(); + if exit_follows + && let Some(rewritten) = lift_one(&old[index], &old[index + 1], graph, labels, weight) + { + parts.extend(rewritten); + index += 2; + continue; + } + parts.push(old[index].clone()); + index += 1; + } +} + +/// `Some(true)` when the then-arm is the guard, `Some(false)` when the else is. +fn guard_side(then_w: Option, else_w: Option) -> Option { + match (then_w, else_w) { + (Some(then), Some(otherwise)) + if then > 0 && otherwise >= then.saturating_mul(3) && then <= GUARD_MAX => + { + Some(true) + } + (Some(then), Some(otherwise)) + if otherwise > 0 && then >= otherwise.saturating_mul(3) && otherwise <= GUARD_MAX => + { + Some(false) + } + // The heavy arm has structure of its own. The light arm is still a guard. + (Some(then), None) if then > 0 && then <= GUARD_MAX => Some(true), + (None, Some(otherwise)) if otherwise > 0 && otherwise <= GUARD_MAX => Some(false), + _ => None, + } +} + +fn lift_one( + branch: &Region, + tail: &Region, + graph: &Graph, + labels: &BTreeSet, + weight: &BTreeMap, +) -> Option> { + let Region::If { + head, + invert, + then, + otherwise: Some(otherwise), + } = branch + else { + return None; + }; + let Region::Block(exit) = tail else { + return None; + }; + if labels.contains(exit) + || graph.get(exit).is_none_or(|succs| !succs.is_empty()) + || weight.get(exit).copied().unwrap_or(0) > EXIT_MAX + { + return None; + } + let then_is_guard = guard_side( + straight_weight(then, weight), + straight_weight(otherwise, weight), + )?; + let (guard, rest, invert) = if then_is_guard { + (then.as_ref().clone(), otherwise.as_ref().clone(), *invert) + } else { + (otherwise.as_ref().clone(), then.as_ref().clone(), !*invert) + }; + let mut rest = match rest { + Region::Seq(parts) => parts, + other => vec![other], + }; + let guard = Region::Seq(vec![guard, Region::Block(*exit)]); + rest.insert( + 0, + Region::If { + head: *head, + invert, + then: Box::new(guard), + otherwise: None, + }, + ); + rest.push(Region::Block(*exit)); + Some(rest) +} + +/// Statement-weight of a straight-line arm, or `None` when it has structure. +fn straight_weight(region: &Region, weight: &BTreeMap) -> Option { + match region { + Region::Empty => Some(0), + Region::Block(at) => Some(weight.get(at).copied().unwrap_or(0)), + Region::Seq(parts) => { + let mut total = 0; + for part in parts { + total += straight_weight(part, weight)?; + } + Some(total) + } + _ => None, + } +} + /// Blocks a region tree both writes out more than once and labels. fn labelled_twice(root: &Region, labels: &BTreeSet) -> BTreeSet { let mut counts: BTreeMap = BTreeMap::new(); @@ -1964,6 +2137,62 @@ mod tests { assert!(found, "no plain if in {:?}", s.root); } + #[test] + fn a_light_arm_that_rejoins_only_at_the_exit_returns_there() { + // 1 is a few statements, 2 and 3 are the rest, and both reach the + // epilogue at 4. The light arm should return, and the rest should + // follow the if rather than sit in an else. + let graph = g(&[(0, &[1, 2]), (1, &[4]), (2, &[3]), (3, &[4]), (4, &[])]); + let mut s = structure(Addr(0), &graph); + let weight = BTreeMap::from([(Addr(1), 1), (Addr(2), 6), (Addr(3), 6), (Addr(4), 1)]); + lift_exit_guards(&mut s.root, &graph, &s.labels, &weight); + let mut counts = BTreeMap::new(); + occurrences(&s.root, &mut counts); + assert_eq!(counts.get(&Addr(4)).copied(), Some(2), "{:?}", s.root); + assert!( + !collect(&s.root).iter().any(|r| matches!( + r, + Region::If { + otherwise: Some(_), + .. + } + )), + "guard still has an else: {:?}", + s.root + ); + assert!(s.lost.is_empty(), "lost {:?}", s.lost); + } + + #[test] + fn a_heavy_epilogue_stays_shared() { + let graph = g(&[(0, &[1, 2]), (1, &[4]), (2, &[3]), (3, &[4]), (4, &[])]); + let mut s = structure(Addr(0), &graph); + let weight = BTreeMap::from([(Addr(1), 1), (Addr(2), 6), (Addr(3), 6), (Addr(4), 10)]); + lift_exit_guards(&mut s.root, &graph, &s.labels, &weight); + let mut counts = BTreeMap::new(); + occurrences(&s.root, &mut counts); + assert_eq!(counts.get(&Addr(4)).copied(), Some(1), "{:?}", s.root); + } + + #[test] + fn a_balanced_diamond_is_not_rewritten_as_a_return() { + let graph = g(&[(0, &[1, 2]), (1, &[3]), (2, &[3]), (3, &[])]); + let mut s = structure(Addr(0), &graph); + let weight = BTreeMap::from([(Addr(1), 2), (Addr(2), 2), (Addr(3), 1)]); + lift_exit_guards(&mut s.root, &graph, &s.labels, &weight); + assert!( + collect(&s.root).iter().any(|r| matches!( + r, + Region::If { + otherwise: Some(_), + .. + } + )), + "balanced diamond lost its else: {:?}", + s.root + ); + } + #[test] fn a_back_edge_becomes_a_loop() { // 0 enters 1; 1 branches back to itself or out to 2. From 9608b6f8b2566caf2b2ac00d03f28c97cea31962 Mon Sep 17 00:00:00 2001 From: Koukyosyumei Date: Wed, 23 Sep 2026 21:59:28 -0400 Subject: [PATCH 5/8] A chain of tests that share an arm is one condition, and a clobber is not part of a guard. --- crates/e5r-decomp/src/emit.rs | 179 ++++++++++++++++++++++++--- crates/e5r-decomp/tests/datatests.rs | 32 +++-- 2 files changed, 183 insertions(+), 28 deletions(-) diff --git a/crates/e5r-decomp/src/emit.rs b/crates/e5r-decomp/src/emit.rs index 0458359..70bdb5b 100644 --- a/crates/e5r-decomp/src/emit.rs +++ b/crates/e5r-decomp/src/emit.rs @@ -639,16 +639,27 @@ fn parameter_list(f: &SsaFunction, r: &Rebuilder) -> Vec { seen.into_values().collect() } -/// The blocks a region tree turned into a `switch`. -/// -/// Their indirect branch is the switch itself, so writing it out as well would -/// say the control transfer twice. /// How much each block does, ignoring the branch that structuring already /// accounted for. The return itself is the shared tail, so it does not count /// as work that would make a guard look heavy. A phi is written as an /// assignment on the incoming edge, so that assignment belongs to the -/// predecessor: a guard's only work is often just that copy. +/// predecessor: a guard's only work is often just that copy. A phi of a +/// clobber is not work. Counting it made every call's guard look as heavy +/// as the call. fn block_weights(f: &SsaFunction) -> BTreeMap { + let defs = f.definitions(); + let real = |input: &Operand| -> bool { + match input { + Operand::Undefined(_) => false, + Operand::Const(_, _) => true, + Operand::Value(v) => defs.get(v).is_none_or(|(at, index)| { + f.blocks + .get(at) + .and_then(|b| b.ops.get(*index)) + .is_none_or(|op| op.kind != SsaKind::Op(Op::Undefine)) + }), + } + }; let mut weights: BTreeMap = f .blocks .iter() @@ -673,7 +684,9 @@ fn block_weights(f: &SsaFunction) -> BTreeMap { .ops .iter() .filter(|op| { - op.kind == SsaKind::Phi && op.out.is_some() && op.inputs.get(slot).is_some() + op.kind == SsaKind::Phi + && op.out.is_some() + && op.inputs.get(slot).is_some_and(real) }) .count() as u32; *weights.entry(*pred).or_default() += copies; @@ -682,6 +695,53 @@ fn block_weights(f: &SsaFunction) -> BTreeMap { weights } +/// The `if` nested as the then-arm, when the block in front of it is only its +/// own head. Anything else between the two tests is a statement `&&` would skip. +fn nested_if(then: &Region) -> Option<(Addr, bool, &Region, Option<&Region>)> { + match then { + Region::If { + head, + invert, + then, + otherwise, + } => Some((*head, *invert, then, otherwise.as_deref())), + Region::Seq(parts) => { + let [ + Region::Block(block), + Region::If { + head, + invert, + then, + otherwise, + }, + ] = parts.as_slice() + else { + return None; + }; + (*block == *head).then_some((*head, *invert, then.as_ref(), otherwise.as_deref())) + } + _ => None, + } +} + +/// True when `region` is, or begins with, the block a goto names. +fn starts_at(region: &Region, at: Addr) -> bool { + match region { + Region::Block(block) => *block == at, + Region::Seq(parts) => parts.first().is_some_and(|part| starts_at(part, at)), + Region::If { head, .. } => *head == at, + _ => false, + } +} + +/// One test in a chain that shares an arm. +struct AndTerm { + at: Addr, + invert: bool, + /// The then-arm of this test is the one that continues toward the unique arm. + unique_when_held: bool, +} + /// True when a loop body is only the edge back to its header. fn falls_straight_back(region: &Region) -> bool { match region { @@ -691,6 +751,10 @@ fn falls_straight_back(region: &Region) -> bool { } } +/// The blocks a region tree turned into a `switch`. +/// +/// Their indirect branch is the switch itself, so writing it out as well would +/// say the control transfer twice. fn switch_heads(r: &Region) -> BTreeSet { let mut out = BTreeSet::new(); collect_switch_heads(r, &mut out); @@ -785,17 +849,40 @@ impl Emitter<'_> { then, otherwise, } => { - let cond = self.condition(*head, *invert); - let _ = writeln!(out, "{pad}if ({cond}) {{"); - self.region(out, then, depth + 1); - match otherwise { - Some(o) if **o != Region::Empty => { - let _ = writeln!(out, "{pad}}} else {{"); - self.region(out, o, depth + 1); - let _ = writeln!(out, "{pad}}}"); + // `if (a) { if (b) t else e } else e` is `if (a && b)`, and so + // is the same shape where the shared arm is reached by a goto. + // The inner test has to be only a test: a statement there runs + // on one path and `&&` would drop it. + if let Some(o) = otherwise + && let Some((rest, unique, shared)) = self.conjunction(then, o) + { + let mut terms = vec![format!("({})", self.condition(*head, *invert))]; + for term in rest { + let term_expr = self.condition(term.at, term.invert); + terms.push(if term.unique_when_held { + format!("({term_expr})") + } else { + format!("({})", negate(term_expr)) + }); } - _ => { - let _ = writeln!(out, "{pad}}}"); + let _ = writeln!(out, "{pad}if ({}) {{", terms.join(" && ")); + self.region(out, unique, depth + 1); + let _ = writeln!(out, "{pad}}} else {{"); + self.region(out, shared, depth + 1); + let _ = writeln!(out, "{pad}}}"); + } else { + let cond = self.condition(*head, *invert); + let _ = writeln!(out, "{pad}if ({cond}) {{"); + self.region(out, then, depth + 1); + match otherwise { + Some(o) if **o != Region::Empty => { + let _ = writeln!(out, "{pad}}} else {{"); + self.region(out, o, depth + 1); + let _ = writeln!(out, "{pad}}}"); + } + _ => { + let _ = writeln!(out, "{pad}}}"); + } } } } @@ -973,6 +1060,66 @@ impl Emitter<'_> { } } + /// Tests and-ed in front of one arm, when each nested `if` shares the other. + /// + /// A chain `if (a) { if (b) { if (c) u else s } else s } else s` is one + /// condition. Each term says whether its then-arm continues toward `unique`. + fn conjunction<'a>( + &self, + then: &'a Region, + otherwise: &'a Region, + ) -> Option<(Vec, &'a Region, &'a Region)> { + let mut terms = Vec::new(); + let mut cursor = then; + let mut shared = otherwise; + while let Some((head, invert, inner_then, inner_else)) = nested_if(cursor) { + if !self.head_is_only_a_test(head) { + break; + } + let Some(inner_else) = inner_else else { + break; + }; + if inner_else == shared { + terms.push(AndTerm { + at: head, + invert, + unique_when_held: true, + }); + cursor = inner_then; + } else if inner_then == shared { + terms.push(AndTerm { + at: head, + invert, + unique_when_held: false, + }); + cursor = inner_else; + } else if let Region::Goto(target) = shared { + if starts_at(inner_then, *target) { + terms.push(AndTerm { + at: head, + invert, + unique_when_held: false, + }); + shared = inner_then; + cursor = inner_else; + } else if starts_at(inner_else, *target) { + terms.push(AndTerm { + at: head, + invert, + unique_when_held: true, + }); + shared = inner_else; + cursor = inner_then; + } else { + break; + } + } else { + break; + } + } + (!terms.is_empty()).then_some((terms, cursor, shared)) + } + /// True when a block's only job is to compute the branch condition, so the /// condition can move into a `while` header. fn head_is_only_a_test(&self, at: Addr) -> bool { diff --git a/crates/e5r-decomp/tests/datatests.rs b/crates/e5r-decomp/tests/datatests.rs index 1cece74..fde8d63 100644 --- a/crates/e5r-decomp/tests/datatests.rs +++ b/crates/e5r-decomp/tests/datatests.rs @@ -261,39 +261,47 @@ fn elseif() { /// orcompare.xml: two comparisons joined by a short-circuit or. /// -/// Ghidra fuses the pair into `if (a == 10 || b == 20)`. We do not fuse, so the -/// property is what fusing is derived from: both comparisons are there, and the -/// arm they share is reached from each of them. +/// The pair comes out as one `&&` of the negated tests, which is the same +/// branch as `a == 10 || b == 20`. Each sink is reached once. #[test] fn orcompare() { case(&all("control"), "orcompare", |c| { - c.has("10").has("20").times("sink1(", 2).times("sink2(", 1); + c.has("10") + .has("20") + .has("&&") + .times("sink1(", 1) + .times("sink2(", 1); }); } -/// orcompare.xml, the three-way case: a chain of three ors. +/// orcompare.xml, the three-way case: a chain of three ors, one condition. #[test] fn orcompare_three_terms() { case(&all("control"), "orcompare3", |c| { - c.at_least("if (", 3).times("sink3(", 3).times("sink4(", 1); + c.has("100") + .has("200") + .has("300") + .has("&&") + .times("if (", 1) + .times("sink3(", 1) + .times("sink4(", 1); }); } /// ccmp.xml: two comparisons joined by a short-circuit and, which AArch64 /// compiles into one `ccmp` against the flags the first left behind. /// -/// Ghidra's assertion is that the pair reads as `ptr[1] == 0x3c && val < 10` -/// with no `SBORROW` left over. Ours keeps them as nested tests, and the -/// property is that both comparisons survive with their operands intact: the -/// load of the second element compared against 60, and the bound on the -/// argument. +/// The pair reads as one `&&`: the load of the second element compared against +/// 60, and the bound on the argument. Each sink is reached once, and no +/// `__borrow` is left over. #[test] fn ccmp() { case(&all("control"), "andcompare", |c| { c.has("+ 4") .has("== 60") + .has("&&") .times("sink5(", 1) - .times("sink6(", 2) + .times("sink6(", 1) .lacks("__borrow"); }); } From cd5cf676a6e46aee68d1ef72b2ef87b5aee4b013 Mon Sep 17 00:00:00 2001 From: Koukyosyumei Date: Wed, 23 Sep 2026 22:21:50 -0400 Subject: [PATCH 6/8] A compound guard returns from its light arm, and the guard behind it is lifted too. --- crates/e5r-decomp/src/structure.rs | 205 ++++++++++++++++++++++++++--- 1 file changed, 189 insertions(+), 16 deletions(-) diff --git a/crates/e5r-decomp/src/structure.rs b/crates/e5r-decomp/src/structure.rs index d455a8c..868c2bb 100644 --- a/crates/e5r-decomp/src/structure.rs +++ b/crates/e5r-decomp/src/structure.rs @@ -339,19 +339,29 @@ fn lift_seq( labels: &BTreeSet, weight: &BTreeMap, ) { - let old = std::mem::take(parts); - let mut index = 0; - while index < old.len() { - let exit_follows = index + 2 == old.len(); - if exit_follows - && let Some(rewritten) = lift_one(&old[index], &old[index + 1], graph, labels, weight) - { - parts.extend(rewritten); - index += 2; - continue; + // A lifted guard splices the arm it was hiding in front of the epilogue. + // That arm may itself be a guard, so scan until nothing new moves. + for _ in 0..8 { + let old = std::mem::take(parts); + let mut changed = false; + let mut index = 0; + while index < old.len() { + let exit_follows = index + 2 == old.len(); + if exit_follows + && let Some(rewritten) = + lift_one(&old[index], &old[index + 1], graph, labels, weight) + { + parts.extend(rewritten); + index += 2; + changed = true; + continue; + } + parts.push(old[index].clone()); + index += 1; + } + if !changed { + break; } - parts.push(old[index].clone()); - index += 1; } } @@ -394,12 +404,14 @@ fn lift_one( let Region::Block(exit) = tail else { return None; }; - if labels.contains(exit) - || graph.get(exit).is_none_or(|succs| !succs.is_empty()) - || weight.get(exit).copied().unwrap_or(0) > EXIT_MAX - { + let exit_w = weight.get(exit).copied().unwrap_or(0); + let succ_empty = graph.get(exit).is_some_and(|succs| succs.is_empty()); + if labels.contains(exit) || !succ_empty || exit_w > EXIT_MAX { return None; } + if let Some(planted) = plant_conjunction_exit(branch, *exit, weight) { + return Some(vec![planted, Region::Block(*exit)]); + } let then_is_guard = guard_side( straight_weight(then, weight), straight_weight(otherwise, weight), @@ -427,6 +439,132 @@ fn lift_one( Some(rest) } +/// A compound test `if (a && b) { light } else { rest }` in front of the +/// epilogue is the same guard as a single test. The light arm is what +/// returns; the shared arm stays where the emitter folds the `&&`. +fn plant_conjunction_exit( + branch: &Region, + exit: Addr, + weight: &BTreeMap, +) -> Option { + let Region::If { + head, + invert, + then, + otherwise: Some(shared), + } = branch + else { + return None; + }; + let nested = nested_test(then)?; + let inner_else = nested.otherwise?; + let (light, on_then) = if inner_else == shared.as_ref() { + (nested.then, true) + } else if nested.then == shared.as_ref() { + (inner_else, false) + } else { + return None; + }; + let light_w = straight_weight(light, weight)?; + if light_w == 0 || light_w > GUARD_MAX || contains_block(light, exit) { + return None; + } + let light = Region::Seq(vec![light.clone(), Region::Block(exit)]); + let (taken, other) = if on_then { + (light, shared.as_ref().clone()) + } else { + (shared.as_ref().clone(), light) + }; + let inner = Region::If { + head: nested.head, + invert: nested.invert, + then: Box::new(taken), + otherwise: Some(Box::new(other)), + }; + let outer_then = match nested.leading { + Some(at) => Region::Seq(vec![Region::Block(at), inner]), + None => inner, + }; + Some(Region::If { + head: *head, + invert: *invert, + then: Box::new(outer_then), + otherwise: Some(shared.clone()), + }) +} + +struct NestedTest<'a> { + leading: Option, + head: Addr, + invert: bool, + then: &'a Region, + otherwise: Option<&'a Region>, +} + +/// The `if` nested as this arm, and the head block in front of it when the +/// arm is that block plus the `if`. +fn nested_test(then: &Region) -> Option> { + match then { + Region::If { + head, + invert, + then, + otherwise, + } => Some(NestedTest { + leading: None, + head: *head, + invert: *invert, + then, + otherwise: otherwise.as_deref(), + }), + Region::Seq(parts) => { + let [ + Region::Block(block), + Region::If { + head, + invert, + then, + otherwise, + }, + ] = parts.as_slice() + else { + return None; + }; + (*block == *head).then_some(NestedTest { + leading: Some(*head), + head: *head, + invert: *invert, + then: then.as_ref(), + otherwise: otherwise.as_deref(), + }) + } + _ => None, + } +} + +fn contains_block(region: &Region, at: Addr) -> bool { + match region { + Region::Block(block) => *block == at, + Region::Seq(parts) => parts.iter().any(|part| contains_block(part, at)), + Region::If { + then, otherwise, .. + } => { + contains_block(then, at) + || otherwise + .as_deref() + .is_some_and(|arm| contains_block(arm, at)) + } + Region::While { body, .. } | Region::Infinite { body, .. } => contains_block(body, at), + Region::Switch { cases, default, .. } => { + cases.iter().any(|case| contains_block(&case.body, at)) + || default + .as_deref() + .is_some_and(|arm| contains_block(arm, at)) + } + _ => false, + } +} + /// Statement-weight of a straight-line arm, or `None` when it has structure. fn straight_weight(region: &Region, weight: &BTreeMap) -> Option { match region { @@ -2174,6 +2312,41 @@ mod tests { assert_eq!(counts.get(&Addr(4)).copied(), Some(1), "{:?}", s.root); } + #[test] + fn a_compound_guard_returns_from_its_light_arm() { + // if (a && b) { block 3 } else { block 4 }, then the epilogue at 5. + // Block 3 is a few statements. It should return, and block 4 stay shared. + let mut root = Region::Seq(vec![ + Region::If { + head: Addr(1), + invert: false, + then: Box::new(Region::Seq(vec![ + Region::Block(Addr(2)), + Region::If { + head: Addr(2), + invert: false, + then: Box::new(Region::Block(Addr(3))), + otherwise: Some(Box::new(Region::Block(Addr(4)))), + }, + ])), + otherwise: Some(Box::new(Region::Block(Addr(4)))), + }, + Region::Block(Addr(5)), + ]); + let graph = g(&[(1, &[2, 4]), (2, &[3, 4]), (3, &[5]), (4, &[5]), (5, &[])]); + let weight = BTreeMap::from([(Addr(3), 1), (Addr(4), 6), (Addr(5), 1)]); + lift_exit_guards(&mut root, &graph, &BTreeSet::new(), &weight); + let mut counts = BTreeMap::new(); + occurrences(&root, &mut counts); + let text = format!("{root:?}"); + assert!( + text.contains("Block(0x3), Block(0x5)"), + "light arm does not return: {root:?}" + ); + assert!(counts.get(&Addr(4)).copied().unwrap_or(0) >= 1, "{root:?}"); + assert!(counts.get(&Addr(5)).copied().unwrap_or(0) >= 2, "{root:?}"); + } + #[test] fn a_balanced_diamond_is_not_rewritten_as_a_return() { let graph = g(&[(0, &[1, 2]), (1, &[3]), (2, &[3]), (3, &[])]); From ab3f037c48696fe24e9f603eaacbcdba6614872a Mon Sep 17 00:00:00 2001 From: Koukyosyumei Date: Wed, 23 Sep 2026 22:32:02 -0400 Subject: [PATCH 7/8] A guard that already fell into the epilogue does not write that return again. --- crates/e5r-decomp/src/structure.rs | 39 +++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/crates/e5r-decomp/src/structure.rs b/crates/e5r-decomp/src/structure.rs index 868c2bb..8f2790c 100644 --- a/crates/e5r-decomp/src/structure.rs +++ b/crates/e5r-decomp/src/structure.rs @@ -435,10 +435,25 @@ fn lift_one( otherwise: None, }, ); - rest.push(Region::Block(*exit)); + // The arm already fell into the epilogue. Appending it again writes a + // second return on that path, which nothing reaches. + if !rest + .last() + .is_some_and(|region| ends_with_block(region, *exit)) + { + rest.push(Region::Block(*exit)); + } Some(rest) } +fn ends_with_block(region: &Region, at: Addr) -> bool { + match region { + Region::Block(block) => *block == at, + Region::Seq(parts) => parts.last().is_some_and(|part| ends_with_block(part, at)), + _ => false, + } +} + /// A compound test `if (a && b) { light } else { rest }` in front of the /// epilogue is the same guard as a single test. The light arm is what /// returns; the shared arm stays where the emitter folds the `&&`. @@ -2347,6 +2362,28 @@ mod tests { assert!(counts.get(&Addr(5)).copied().unwrap_or(0) >= 2, "{root:?}"); } + #[test] + fn an_arm_that_already_reaches_the_exit_does_not_gain_a_second_return() { + let mut root = Region::Seq(vec![ + Region::If { + head: Addr(0), + invert: false, + then: Box::new(Region::Block(Addr(1))), + otherwise: Some(Box::new(Region::Seq(vec![ + Region::Block(Addr(2)), + Region::Block(Addr(3)), + ]))), + }, + Region::Block(Addr(3)), + ]); + let graph = g(&[(0, &[1, 2]), (1, &[3]), (2, &[3]), (3, &[])]); + let weight = BTreeMap::from([(Addr(1), 1), (Addr(2), 6), (Addr(3), 1)]); + lift_exit_guards(&mut root, &graph, &BTreeSet::new(), &weight); + let mut counts = BTreeMap::new(); + occurrences(&root, &mut counts); + assert_eq!(counts.get(&Addr(3)).copied(), Some(2), "{root:?}"); + } + #[test] fn a_balanced_diamond_is_not_rewritten_as_a_return() { let graph = g(&[(0, &[1, 2]), (1, &[3]), (2, &[3]), (3, &[])]); From 8f5a78ab821c5a3a5043796a1b912be179d64f46 Mon Sep 17 00:00:00 2001 From: Koukyosyumei Date: Thu, 24 Sep 2026 15:12:04 -0400 Subject: [PATCH 8/8] Improve decompiler structure recovery --- crates/e5r-api/src/decompile.rs | 34 +- crates/e5r-decomp/src/emit.rs | 848 ++++++++++++++++++++++++++++- crates/e5r-decomp/src/structure.rs | 24 +- crates/e5r-ir/src/stack.rs | 60 +- 4 files changed, 938 insertions(+), 28 deletions(-) diff --git a/crates/e5r-api/src/decompile.rs b/crates/e5r-api/src/decompile.rs index 65b6ef6..d46c34f 100644 --- a/crates/e5r-api/src/decompile.rs +++ b/crates/e5r-api/src/decompile.rs @@ -375,7 +375,7 @@ fn one( .map(|(a, b)| (*a, (b.range.end(), b.successors.clone()))) .collect(); let mut ir = e5r_ir::func::build(&p.object.memory, &p.object.arch, f.entry, &blocks); - e5r_ir::stack::promote(&mut ir); + let promoted = e5r_ir::stack::promote(&mut ir); let mut ssa = e5r_ir::ssa::build(&ir); e5r_ir::opt::optimize(&mut ssa); // The SSA the emitter runs on is the SSA prototype recovery reads, rather @@ -390,8 +390,38 @@ fn one( let name = f.display_name(); let output = e5r_decomp::decompile_full(&name, &ssa, shape.prototype.as_ref(), callees, &switches); + let mut variables = e5r_decomp::expr::variables(&ssa, shape.prototype.as_ref(), &output.text); + // Stack promotion deliberately gives up when any stack address escapes: + // rewriting in that case could hide writes through the pointer. Direct + // loads and stores still prove that a slot of this width exists, though, + // and are useful variable-recovery evidence in their own right. Keep the + // stronger promoted variable when one exists and add only facts the SSA + // surface otherwise lost. + let mut homes: BTreeSet<(i64, u8)> = variables + .iter() + .filter_map(|v| match v.home { + e5r_decomp::expr::Home::Stack(offset) => Some((offset, v.size)), + _ => None, + }) + .collect(); + for (offset, size) in promoted.candidates { + // Positive entry-SP offsets are caller-owned stack arguments. Their + // ABI position is not recoverable from the offset alone, so claiming + // one as a local would be less honest than leaving it unclassified. + if offset >= 0 || !homes.insert((offset, size)) { + continue; + } + let width = u16::from(size) * 8; + variables.push(Variable { + name: format!("stack_m{:x}_{size}", -offset), + ty: format!("uint{width}_t"), + size, + role: Role::Local, + home: Home::Stack(offset), + }); + } Some(One { - variables: e5r_decomp::expr::variables(&ssa, shape.prototype.as_ref(), &output.text), + variables, output, prototype: shape.prototype, unlifted: ir.unlifted.len(), diff --git a/crates/e5r-decomp/src/emit.rs b/crates/e5r-decomp/src/emit.rs index 70bdb5b..7730939 100644 --- a/crates/e5r-decomp/src/emit.rs +++ b/crates/e5r-decomp/src/emit.rs @@ -221,13 +221,32 @@ pub fn decompile_full( callees, returns, unmodelled: 0, + dead: BTreeSet::new(), }; - let body = { + let mut body = { let mut out = String::new(); e.region(&mut out, &s.root, 1); out }; + // SSA carries every live machine register through a join, including + // clobbers and bookkeeping no emitted statement ever reads. Remove only + // names used exclusively as assignment destinations. Re-emit so phi + // diamonds exposed by that removal can become conditional expressions. + for _ in 0..4 { + let dead = dead_locals(&body, rebuilder.locals.values()); + if dead == e.dead { + break; + } + e.dead = dead; + e.unmodelled = 0; + body.clear(); + e.region(&mut body, &s.root, 1); + } + body = inline_condition_assignments(&body); + body = inline_return_assignments(&body); + body = e.inline_terminal_if_returns(&body); + body = remove_return_after_loop_control(&body); // Values that arrive from outside and are not arguments: registers the // function inherited. Declaring them says where they came from without @@ -364,7 +383,7 @@ pub fn decompile_full( } let mut declared_locals: BTreeSet<&str> = BTreeSet::new(); for (value, local) in &rebuilder.locals { - if !declared_locals.insert(local) { + if !mentions(&body, local) || !declared_locals.insert(local) { continue; } let ty = if rebuilder.floats.contains(&value.location) { @@ -811,17 +830,350 @@ struct Emitter<'a> { /// they mean. returns: Option, unmodelled: usize, + /// Pure locals whose emitted assignments have no reader. + dead: BTreeSet, +} + +/// Names that occur only on the left of assignments in the emitted body. +fn dead_locals<'a>(body: &str, locals: impl Iterator) -> BTreeSet { + let mut dead = BTreeSet::new(); + for name in locals { + let mut read = false; + for line in body.lines().filter(|line| mentions(line, name)) { + let line = line.trim(); + let prefix = format!("{name} = "); + let Some(rhs) = line.strip_prefix(&prefix) else { + read = true; + break; + }; + if mentions(rhs, name) { + read = true; + break; + } + } + if !read { + dead.insert(name.clone()); + } + } + dead +} + +/// Fold an assignment immediately consumed by a return into that return. +/// +/// This runs after dead-local discovery: marking the assigned SSA value dead +/// would suppress its defining operation on the next emission pass. Keeping +/// the fold textual also makes the restriction obvious -- no intervening +/// statement, label, or control edge can be crossed. +fn inline_return_assignments(body: &str) -> String { + let lines: Vec<&str> = body.lines().collect(); + let mut out = String::with_capacity(body.len()); + let mut at = 0; + while at < lines.len() { + if let Some(next) = lines.get(at + 1) + && let Some((indent, name, value)) = assignment_line(lines[at]) + && !value.contains("__callind(") + && let Some((prefix, suffix)) = returned_local(next, indent, name) + { + let _ = writeln!(out, "{indent}return {prefix}{value}{suffix}"); + at += 2; + continue; + } + let _ = writeln!(out, "{}", lines[at]); + at += 1; + } + out +} + +/// Fold a temporary used once by the immediately following condition. +/// +/// The right-hand side stays at the same sequence point and is substituted +/// exactly once, so calls are neither duplicated nor reordered. +fn inline_condition_assignments(body: &str) -> String { + let lines: Vec<&str> = body.lines().collect(); + let mut out = String::with_capacity(body.len()); + let mut at = 0; + while at < lines.len() { + if let Some(next) = lines.get(at + 1) + && let Some((indent, name, value)) = assignment_line(lines[at]) + && next.starts_with(indent) + && next[indent.len()..].starts_with("if (") + && token_occurrences(next, name) == 1 + && lines[at + 1..] + .iter() + .map(|line| token_occurrences(line, name)) + .sum::() + == 1 + { + let replaced = replace_token(next, name, &format!("({value})")); + let _ = writeln!(out, "{replaced}"); + at += 2; + continue; + } + let _ = writeln!(out, "{}", lines[at]); + at += 1; + } + out +} + +fn token_occurrences(text: &str, name: &str) -> usize { + text.match_indices(name) + .filter(|(at, _)| { + let end = at + name.len(); + let boundary = + |c: Option| c.is_none_or(|c| !(c == '_' || c.is_ascii_alphanumeric())); + boundary(text[..*at].chars().next_back()) && boundary(text[end..].chars().next()) + }) + .count() +} + +fn replace_token(text: &str, name: &str, value: &str) -> String { + let Some((at, _)) = text.match_indices(name).find(|(at, _)| { + let end = at + name.len(); + let boundary = |c: Option| c.is_none_or(|c| !(c == '_' || c.is_ascii_alphanumeric())); + boundary(text[..*at].chars().next_back()) && boundary(text[end..].chars().next()) + }) else { + return text.to_string(); + }; + format!("{}{}{}", &text[..at], value, &text[at + name.len()..]) +} + +/// A copied exit after an unconditional loop edge is unreachable. Restrict +/// this cleanup to the adjacent return shape so labels and other entry points +/// remain untouched. +fn remove_return_after_loop_control(body: &str) -> String { + let lines: Vec<&str> = body.lines().collect(); + let mut out = String::with_capacity(body.len()); + let mut at = 0; + while at < lines.len() { + let line = lines[at]; + let control = matches!(line.trim(), "break;" | "continue;"); + let same_indent_return = lines.get(at + 1).is_some_and(|next| { + let indent = line.len() - line.trim_start().len(); + next.len() >= indent + && next[..indent] == line[..indent] + && next[indent..].starts_with("return ") + }); + let _ = writeln!(out, "{line}"); + at += if control && same_indent_return { 2 } else { 1 }; + } + out +} + +fn assignment_line(line: &str) -> Option<(&str, &str, &str)> { + let indent_len = line.len() - line.trim_start().len(); + let (indent, statement) = line.split_at(indent_len); + let statement = statement.strip_suffix(';')?; + let (name, value) = statement.split_once(" = ")?; + if name.is_empty() + || !name.chars().all(|c| c == '_' || c.is_ascii_alphanumeric()) + || !name + .chars() + .next() + .is_some_and(|c| c == '_' || c.is_ascii_alphabetic()) + { + return None; + } + Some((indent, name, value)) +} + +fn returned_local<'a>(line: &'a str, indent: &str, name: &str) -> Option<(&'a str, &'a str)> { + let statement = line.strip_prefix(indent)?.strip_prefix("return ")?; + let occurrence = statement.find(name)?; + let after = occurrence + name.len(); + let boundary = |c: Option| c.is_none_or(|c| !(c == '_' || c.is_ascii_alphanumeric())); + if !boundary(statement[..occurrence].chars().next_back()) + || !boundary(statement[after..].chars().next()) + || statement[after..].contains(name) + || statement[..occurrence].contains(name) + { + return None; + } + Some((&statement[..occurrence], &statement[after..])) +} + +/// A `Block(head), If(head)` pair whose true arm is the whole guard body. +fn plain_guard(parts: &[Region], at: usize) -> Option<(Addr, bool, &Region)> { + let [ + Region::Block(block), + Region::If { + head, + invert, + then, + otherwise, + }, + ] = parts.get(at..at + 2)? + else { + return None; + }; + (*block == *head && otherwise.is_none() && **then != Region::Empty).then_some(( + *head, + *invert, + then.as_ref(), + )) +} + +/// Remove the longest sequence duplicated at the end of an arm and directly +/// after its `if`. +fn without_following_suffix(region: &Region, following: &[Region]) -> Option { + let Region::Seq(parts) = region else { + return None; + }; + let most = parts.len().min(following.len()); + let copied = (1..=most) + .rev() + .find(|count| parts[parts.len() - count..] == following[..*count])?; + let prefix = &parts[..parts.len() - copied]; + if prefix.is_empty() { + return None; + } + Some(match prefix { + [only] => only.clone(), + prefix => Region::Seq(prefix.to_vec()), + }) +} + +/// `if (a) { prep; if (b) unique else shared } else shared` can write the +/// shared region once after the outer conditional even when `prep` prevents +/// combining the two tests with `&&`. +fn nested_shared_tail<'a>( + then: &'a Region, + shared: &'a Region, +) -> Option<(Region, Addr, bool, &'a Region)> { + let Region::Seq(parts) = then else { + return None; + }; + let [ + prefix @ .., + Region::Block(block), + Region::If { + head, + invert, + then: inner_then, + otherwise: Some(inner_else), + }, + ] = parts.as_slice() + else { + return None; + }; + if block != head { + return None; + } + let (inner_invert, unique) = if inner_else.as_ref() == shared { + (*invert, inner_then.as_ref()) + } else if inner_then.as_ref() == shared { + (!*invert, inner_else.as_ref()) + } else { + return None; + }; + let mut before = prefix.to_vec(); + before.push(Region::Block(*block)); + Some((Region::Seq(before), *head, inner_invert, unique)) +} + +fn contains_conditional(region: &Region) -> bool { + match region { + Region::If { .. } | Region::Switch { .. } | Region::While { .. } => true, + Region::Seq(parts) => parts.iter().any(contains_conditional), + Region::Infinite { body, .. } => contains_conditional(body), + _ => false, + } } impl Emitter<'_> { + /// Turn a terminal assignment diamond followed by `return result` into + /// early returns in its arms. This also catches diamonds separated from + /// their exit block in the region tree by a condition-producing block. + fn inline_terminal_if_returns(&self, body: &str) -> String { + let lines: Vec<&str> = body.lines().collect(); + let mut out = String::with_capacity(body.len()); + let mut at = 0; + while at < lines.len() { + let line = lines[at]; + let indent_len = line.len() - line.trim_start().len(); + let indent = &line[..indent_len]; + let is_if = line[indent_len..].starts_with("if (") && line.ends_with('{'); + let else_at = is_if.then(|| { + (at + 1..lines.len()).find(|i| lines[*i] == format!("{indent}}} else {{")) + }); + let close_at = else_at.flatten().and_then(|middle| { + (middle + 1..lines.len()).find(|i| lines[*i] == format!("{indent}}}")) + }); + let Some((middle, close)) = else_at.flatten().zip(close_at) else { + let _ = writeln!(out, "{line}"); + at += 1; + continue; + }; + let Some(return_line) = lines.get(close + 1) else { + let _ = writeln!(out, "{line}"); + at += 1; + continue; + }; + let Some((_, then_name, then_value)) = lines + .get(middle.wrapping_sub(1)) + .and_then(|line| assignment_line(line)) + else { + let _ = writeln!(out, "{line}"); + at += 1; + continue; + }; + let Some((_, else_name, else_value)) = lines + .get(close.wrapping_sub(1)) + .and_then(|line| assignment_line(line)) + else { + let _ = writeln!(out, "{line}"); + at += 1; + continue; + }; + if then_name != else_name + || !return_line.starts_with(indent) + || !return_line[indent_len..].starts_with("return ") + || token_occurrences(return_line, then_name) != 1 + { + let _ = writeln!(out, "{line}"); + at += 1; + continue; + } + let then_prefix = lines[at + 1..middle - 1].join("\n"); + let else_prefix = lines[middle + 1..close - 1].join("\n"); + let reaches_from_before = |value: &str, prefix: &str| { + self.r + .locals + .values() + .filter(|local| local.as_str() != then_name && mentions(value, local)) + .any(|local| !mentions(prefix, local)) + }; + if reaches_from_before(then_value, &then_prefix) + || reaches_from_before(else_value, &else_prefix) + { + let _ = writeln!(out, "{line}"); + at += 1; + continue; + } + + let _ = writeln!(out, "{line}"); + for arm_line in &lines[at + 1..middle - 1] { + let _ = writeln!(out, "{arm_line}"); + } + let then_indent = " ".repeat(indent_len / 4 + 1); + let _ = writeln!(out, "{then_indent}{}", self.return_value(then_value)); + let _ = writeln!(out, "{indent}}}"); + let nested_indent = format!("{indent} "); + for arm_line in &lines[middle + 1..close - 1] { + let arm_line = arm_line.strip_prefix(&nested_indent).unwrap_or(arm_line); + let _ = writeln!(out, "{indent}{arm_line}"); + } + let _ = writeln!(out, "{indent}{}", self.return_value(else_value)); + at = close + 2; + } + out + } + fn region(&mut self, out: &mut String, region: &Region, depth: usize) { let pad = " ".repeat(depth); match region { Region::Empty => {} Region::Seq(parts) => { - for p in parts { - self.region(out, p, depth); - } + self.sequence(out, parts, depth); } Region::Block(at) => { if self.labels.contains(at) { @@ -849,11 +1201,39 @@ impl Emitter<'_> { then, otherwise, } => { + if let Some((name, value)) = + self.conditional_assignment(*head, *invert, then, otherwise.as_deref()) + { + let _ = writeln!(out, "{pad}{name} = {value};"); + // Nested guards with no else are the short-circuit spelling + // of a conjunction. The nested head may contain SSA-only dead + // copies, so judge the statements that actually survive + // emission rather than the raw operation list. + } else if let Some((terms, body)) = + self.no_else_conjunction(*head, *invert, then, otherwise.as_deref()) + { + let _ = writeln!(out, "{pad}if ({}) {{", terms.join(" && ")); + self.region(out, body, depth + 1); + let _ = writeln!(out, "{pad}}}"); + } else if let Some(shared) = otherwise + && let Some((prefix, inner, inner_invert, unique)) = + nested_shared_tail(then, shared) + && !self.head_emits_only_test(inner) + { + let outer = self.condition(*head, *invert); + let _ = writeln!(out, "{pad}if ({outer}) {{"); + self.region(out, &prefix, depth + 1); + let inner = self.condition(inner, inner_invert); + let _ = writeln!(out, "{pad} if ({inner}) {{"); + self.region(out, unique, depth + 2); + let _ = writeln!(out, "{pad} }}"); + let _ = writeln!(out, "{pad}}}"); + self.region(out, shared, depth); // `if (a) { if (b) t else e } else e` is `if (a && b)`, and so // is the same shape where the shared arm is reached by a goto. // The inner test has to be only a test: a statement there runs // on one path and `&&` would drop it. - if let Some(o) = otherwise + } else if let Some(o) = otherwise && let Some((rest, unique, shared)) = self.conjunction(then, o) { let mut terms = vec![format!("({})", self.condition(*head, *invert))]; @@ -867,9 +1247,30 @@ impl Emitter<'_> { } let _ = writeln!(out, "{pad}if ({}) {{", terms.join(" && ")); self.region(out, unique, depth + 1); - let _ = writeln!(out, "{pad}}} else {{"); - self.region(out, shared, depth + 1); + if self.region_ends_control(unique) { + let _ = writeln!(out, "{pad}}}"); + self.region(out, shared, depth); + } else { + let _ = writeln!(out, "{pad}}} else {{"); + self.region(out, shared, depth + 1); + let _ = writeln!(out, "{pad}}}"); + } + } else if let Some(o) = otherwise + && self.region_ends_control(then) + { + let cond = self.condition(*head, *invert); + let _ = writeln!(out, "{pad}if ({cond}) {{"); + self.region(out, then, depth + 1); + let _ = writeln!(out, "{pad}}}"); + self.region(out, o, depth); + } else if let Some(o) = otherwise + && self.region_ends_control(o) + { + let cond = negate(self.condition(*head, *invert)); + let _ = writeln!(out, "{pad}if ({cond}) {{"); + self.region(out, o, depth + 1); let _ = writeln!(out, "{pad}}}"); + self.region(out, then, depth); } else { let cond = self.condition(*head, *invert); let _ = writeln!(out, "{pad}if ({cond}) {{"); @@ -960,6 +1361,361 @@ impl Emitter<'_> { } } + /// Emit a sequence, folding adjacent guards with the same destination. + /// + /// Compilers lower `if (a || b) return x` to two tests that enter the same + /// return block. Tail duplication makes the region tree honest but leaves + /// two identical `if` statements in C. When the later test has no work of + /// its own, short-circuit `||` is exactly the original control flow and + /// writes the shared body once. + fn sequence(&mut self, out: &mut String, parts: &[Region], depth: usize) { + let pad = " ".repeat(depth); + let mut i = 0; + while i < parts.len() { + if let [ + Region::Block(block), + Region::If { + head, + invert, + then, + otherwise: None, + }, + .., + ] = &parts[i..] + && block == head + && !self.labels.contains(head) + && let Some(prefix) = without_following_suffix(then, &parts[i + 2..]) + { + self.region(out, &parts[i], depth); + let condition = self.condition(*head, *invert); + let _ = writeln!(out, "{pad}if ({condition}) {{"); + self.region(out, &prefix, depth + 1); + let _ = writeln!(out, "{pad}}}"); + // Leave the shared suffix for the next iteration. + i += 2; + continue; + } + if let [ + Region::If { + head, + invert, + then, + otherwise: Some(otherwise), + }, + Region::Block(exit), + .., + ] = &parts[i..] + && !self.labels.contains(exit) + && let Some(name) = self.return_only(*exit) + && let Some((then, otherwise)) = self.returning_arms(then, otherwise, depth, &name) + { + let condition = self.condition(*head, *invert); + let _ = writeln!(out, "{pad}if ({condition}) {{"); + out.push_str(&then); + let _ = writeln!(out, "{pad}}}"); + out.push_str(&otherwise); + i += 2; + continue; + } + let Some((first, first_invert, body)) = plain_guard(parts, i) else { + self.region(out, &parts[i], depth); + i += 1; + continue; + }; + let mut terms = vec![format!("({})", self.condition(first, first_invert))]; + let mut end = i + 2; + while let Some((head, invert, next_body)) = plain_guard(parts, end) { + if next_body != body || self.labels.contains(&head) { + break; + } + let Some(term) = self.short_circuit_condition(head, invert) else { + break; + }; + terms.push(term); + end += 2; + } + if terms.len() == 1 { + self.region(out, &parts[i], depth); + i += 1; + continue; + } + + // The first head may prepare a call result used by its condition; + // later heads were proven to contain only their tests. + self.region(out, &parts[i], depth); + let _ = writeln!(out, "{pad}if ({}) {{", terms.join(" || ")); + self.region(out, body, depth + 1); + let _ = writeln!(out, "{pad}}}"); + i = end; + } + } + + /// Two arms whose final assignment feeds the immediately following return. + fn returning_arms( + &mut self, + then: &Region, + otherwise: &Region, + depth: usize, + name: &str, + ) -> Option<(String, String)> { + if contains_conditional(then) || contains_conditional(otherwise) { + return None; + } + let before = self.unmodelled; + let mut then_text = String::new(); + self.region(&mut then_text, then, depth + 1); + let mut otherwise_text = String::new(); + self.region(&mut otherwise_text, otherwise, depth); + let out = self + .return_last_assignment(&then_text, name) + .zip(self.return_last_assignment(&otherwise_text, name)); + if out.is_none() { + self.unmodelled = before; + } + out + } + + fn no_else_conjunction<'a>( + &mut self, + head: Addr, + invert: bool, + then: &'a Region, + otherwise: Option<&Region>, + ) -> Option<(Vec, &'a Region)> { + if otherwise.is_some() { + return None; + } + let before = self.unmodelled; + let mut terms = vec![format!("({})", self.condition(head, invert))]; + let mut body = then; + while let Some((inner, inner_invert, inner_then, None)) = nested_if(body) { + if !self.head_emits_only_test(inner) { + break; + } + terms.push(format!("({})", self.condition(inner, inner_invert))); + body = inner_then; + } + if terms.len() > 1 { + Some((terms, body)) + } else { + self.unmodelled = before; + None + } + } + + fn return_last_assignment(&self, text: &str, name: &str) -> Option { + let mut lines: Vec<&str> = text.lines().collect(); + let last = lines.pop()?; + let (indent, assigned, value) = assignment_line(last)?; + if assigned != name { + return None; + } + let mut out = lines.join("\n"); + // A value defined before the diamond must stay merged after it. Early + // returning such an arm destroys that merge and often obscures a + // source-level conditional expression. Values produced inside this + // arm are safe to return here. + if self + .r + .locals + .values() + .filter(|local| local.as_str() != name && mentions(value, local)) + .any(|local| !mentions(&out, local)) + { + return None; + } + if !out.is_empty() { + out.push('\n'); + } + let _ = writeln!(out, "{indent}{}", self.return_value(value)); + Some(out) + } + + fn return_only(&mut self, at: Addr) -> Option { + let before = self.unmodelled; + let mut text = String::new(); + self.statements(&mut text, at, 0); + self.unmodelled = before; + let mut lines = text.lines().filter(|line| !line.trim().is_empty()); + let line = lines.next()?.trim(); + if lines.next().is_some() || !line.starts_with("return ") { + return None; + } + let name = self.result(at); + (!name.is_empty() && mentions(line, &name)).then_some(name) + } + + /// A later short-circuit test as an expression, including work its block + /// must do only when earlier terms were false. + fn short_circuit_condition(&mut self, head: Addr, invert: bool) -> Option { + let before = self.unmodelled; + let mut prep = String::new(); + self.statements(&mut prep, head, 0); + let mut expressions = Vec::new(); + for line in prep.lines().map(str::trim).filter(|line| !line.is_empty()) { + let Some(expression) = line.strip_suffix(';') else { + self.unmodelled = before; + return None; + }; + if (expression.contains('{') || expression.contains('}')) + || expression.starts_with("return ") + || expression.starts_with("goto ") + || expression == "break" + || expression == "continue" + { + self.unmodelled = before; + return None; + } + expressions.push(format!("({expression})")); + } + expressions.push(format!("({})", self.condition(head, invert))); + Some(if expressions.len() == 1 { + expressions.pop().unwrap() + } else { + format!("({})", expressions.join(", ")) + }) + } + + /// Turn a diamond that only chooses one assignment into `?:`. + /// + /// SSA makes a phi explicit as one assignment in each predecessor. C's + /// conditional expression is the source-level spelling of that same + /// choice and, unlike an `if`/`else` plus assignments, preserves the CFG + /// shape of a ternary in tools that model expression control flow. + fn conditional_assignment( + &mut self, + head: Addr, + invert: bool, + then: &Region, + otherwise: Option<&Region>, + ) -> Option<(String, String)> { + let before = self.unmodelled; + let out = self.conditional_assignment_inner(head, invert, then, otherwise); + if out.is_none() { + self.unmodelled = before; + } + out + } + + fn conditional_assignment_inner( + &mut self, + head: Addr, + invert: bool, + then: &Region, + otherwise: Option<&Region>, + ) -> Option<(String, String)> { + let otherwise = otherwise?; + if !self.head_emits_only_test(head) { + return None; + } + let (then_name, then_value) = self.assigned_value(then)?; + let (else_name, else_value) = self.assigned_value(otherwise)?; + if then_name != else_name { + return None; + } + let condition = self.condition(head, invert); + Some(( + then_name, + format!("({condition}) ? ({then_value}) : ({else_value})"), + )) + } + + fn assigned_value(&mut self, region: &Region) -> Option<(String, String)> { + match region { + Region::Block(at) if !self.labels.contains(at) => self.single_assignment(*at), + Region::If { + head, + invert, + then, + otherwise, + } => self.conditional_assignment_inner(*head, *invert, then, otherwise.as_deref()), + Region::Seq(parts) => match parts.as_slice() { + [ + Region::Block(block), + Region::If { + head, + invert, + then, + otherwise, + }, + ] if block == head && !self.labels.contains(head) => { + self.conditional_assignment_inner(*head, *invert, then, otherwise.as_deref()) + } + _ => None, + }, + _ => None, + } + } + + fn single_assignment(&mut self, at: Addr) -> Option<(String, String)> { + let before = self.unmodelled; + let mut text = String::new(); + self.statements(&mut text, at, 0); + let mut lines = text.lines().map(str::trim).filter(|line| !line.is_empty()); + let Some(line) = lines.next() else { + self.unmodelled = before; + return None; + }; + if lines.next().is_some() { + self.unmodelled = before; + return None; + } + let Some(line) = line.strip_suffix(';') else { + self.unmodelled = before; + return None; + }; + let Some((name, value)) = line.split_once(" = ") else { + self.unmodelled = before; + return None; + }; + if name.is_empty() + || !name.chars().all(|c| c == '_' || c.is_ascii_alphanumeric()) + || !name + .chars() + .next() + .is_some_and(|c| c == '_' || c.is_ascii_alphabetic()) + { + self.unmodelled = before; + return None; + } + Some((name.to_string(), value.to_string())) + } + + /// True when this region cannot fall through to the statement after it. + fn region_ends_control(&self, region: &Region) -> bool { + match region { + Region::Block(at) => self.f.blocks.get(at).is_none_or(|block| { + block.ops.iter().any(|op| { + matches!(op.kind, SsaKind::Op(Op::Return | Op::BranchInd)) + || tail_call(self.f, op).is_some() + }) || !block + .successors + .iter() + .any(|successor| self.f.blocks.contains_key(successor)) + }), + Region::Break | Region::Continue | Region::Goto(_) => true, + Region::Seq(parts) => parts + .last() + .is_some_and(|part| self.region_ends_control(part)), + Region::If { + then, + otherwise: Some(otherwise), + .. + } => self.region_ends_control(then) && self.region_ends_control(otherwise), + Region::Switch { + cases, + default: Some(default), + .. + } => { + cases + .iter() + .all(|case| self.region_ends_control(&case.body)) + && self.region_ends_control(default) + } + _ => false, + } + } + /// What a switch switches on. /// /// The branch computes an address; the index is what was used to look it @@ -1129,6 +1885,15 @@ impl Emitter<'_> { !b.ops.iter().any(|op| self.is_statement(op)) } + /// True when dead-value cleanup leaves no statement in a branch head. + fn head_emits_only_test(&mut self, at: Addr) -> bool { + let before = self.unmodelled; + let mut statements = String::new(); + self.statements(&mut statements, at, 0); + self.unmodelled = before; + statements.trim().is_empty() + } + /// The branch condition of a block, inverted if asked. fn condition(&mut self, at: Addr, invert: bool) -> Expr { let cond = self @@ -1189,6 +1954,9 @@ impl Emitter<'_> { let Some(name) = self.r.locals.get(&v) else { continue; }; + if self.dead.contains(name) { + continue; + } // An assignment from itself says nothing. let value = self.r.integer(self.r.operand(input), input); let text = format!("{value}"); @@ -1279,7 +2047,11 @@ impl Emitter<'_> { } _ => self.r.expr(op), }; - match op.out.and_then(|v| self.r.locals.get(&v)).filter(|_| !void) { + match op + .out + .and_then(|v| self.r.locals.get(&v)) + .filter(|name| !void && !self.dead.contains(*name)) + { // The local is an integer and the callee may be // declared to return a pointer, which C will not // assign without being told. @@ -1330,6 +2102,9 @@ impl Emitter<'_> { _ => { // Anything else that reaches here has a named output. if let Some(name) = op.out.and_then(|v| self.r.locals.get(&v)) { + if self.dead.contains(name) { + continue; + } let _ = writeln!(out, "{pad}{name} = {};", self.r.expr(op)); } } @@ -1377,6 +2152,14 @@ impl Emitter<'_> { } } + fn return_value(&self, value: &str) -> String { + match &self.returns { + Some(ty) if ty == "void" => "return;".to_string(), + Some(ty) => format!("return ({ty})({value});"), + None => format!("return {value};"), + } + } + /// A direct call. An unknown prototype is invoked through a cast so the /// arguments this site prepared are visible, and so a declaration that /// could not learn an arity still compiles beside them. @@ -1601,4 +2384,51 @@ mod tests { "c" ); } + + #[test] + fn an_assignment_immediately_returned_is_inlined() { + assert_eq!( + inline_return_assignments( + " v0 = call(arg);\n return (uint64_t)(v0);\n v1 = 3;\n use(v1);\n" + ), + " return (uint64_t)(call(arg));\n v1 = 3;\n use(v1);\n" + ); + } + + #[test] + fn a_single_use_condition_temporary_is_inlined() { + assert_eq!( + inline_condition_assignments( + " v1 = seek(fd);\n if ((uint64_t)v1 + 1 == 0) {\n return 1;\n }\n" + ), + " if ((uint64_t)(seek(fd)) + 1 == 0) {\n return 1;\n }\n" + ); + assert_eq!( + inline_condition_assignments( + " v1 = seek(fd);\n if (v1 < 0) {\n fail(v1);\n }\n" + ), + " v1 = seek(fd);\n if (v1 < 0) {\n fail(v1);\n }\n" + ); + } + + #[test] + fn a_copied_return_after_continue_is_removed() { + assert_eq!( + remove_return_after_loop_control( + " while (x) {\n continue;\n return lost;\n }\n return kept;\n" + ), + " while (x) {\n continue;\n }\n return kept;\n" + ); + } + + #[test] + fn a_duplicated_sequence_suffix_is_factored() { + let a = Region::Block(Addr(1)); + let b = Region::Block(Addr(2)); + let c = Region::Block(Addr(3)); + assert_eq!( + without_following_suffix(&Region::Seq(vec![a.clone(), b.clone(), c.clone()]), &[b, c]), + Some(a) + ); + } } diff --git a/crates/e5r-decomp/src/structure.rs b/crates/e5r-decomp/src/structure.rs index 8f2790c..2c24e06 100644 --- a/crates/e5r-decomp/src/structure.rs +++ b/crates/e5r-decomp/src/structure.rs @@ -29,7 +29,7 @@ use e5r_core::Addr; /// starts reading as a second piece of code, which is worse than the goto it /// replaces. Measured over the corpus: four is where the curve flattens, and /// every block past it costs more output than it saves gotos. -const MAX_COPIED_TAIL: usize = 4; +const MAX_COPIED_TAIL: usize = 12; /// How many blocks in total a function may write out a second time. /// @@ -2521,20 +2521,16 @@ mod tests { #[test] fn a_tail_too_large_to_copy_is_written_after_the_branch() { // 0 branches to 1 or 2. Both reach 3, which no join places because 2 - // can also return at 20. The tail 3..8 is six blocks, past + // can also return at 20. The tail is one block past // MAX_COPIED_TAIL, so duplication will not take it. - let graph = g(&[ - (0, &[1, 2]), - (1, &[3]), - (2, &[3, 20]), - (20, &[]), - (3, &[4]), - (4, &[5]), - (5, &[6]), - (6, &[7]), - (7, &[8]), - (8, &[]), - ]); + let mut graph = g(&[(0, &[1, 2]), (1, &[3]), (2, &[3, 20]), (20, &[])]); + for n in 0..=MAX_COPIED_TAIL + 1 { + let at = Addr(3 + n as u64); + let successors = (n < MAX_COPIED_TAIL + 1) + .then_some(vec![Addr(at.0 + 1)]) + .unwrap_or_default(); + graph.insert(at, successors); + } let s = structure(Addr(0), &graph); assert_eq!(s.gotos, 0, "{:?}", s.root); assert!(s.labels.is_empty(), "{:?}", s.labels); diff --git a/crates/e5r-ir/src/stack.rs b/crates/e5r-ir/src/stack.rs index 9f72989..daae277 100644 --- a/crates/e5r-ir/src/stack.rs +++ b/crates/e5r-ir/src/stack.rs @@ -26,12 +26,18 @@ use crate::func::Function; use crate::op::{IrOp, Op, Space, Varnode}; /// What a pass changed. -#[derive(Debug, Clone, Copy, Default)] +#[derive(Debug, Clone, Default)] pub struct Promoted { /// Accesses rewritten into slot reads and writes. pub accesses: usize, /// Distinct slots created. pub slots: usize, + /// Direct stack accesses observed, whether or not promotion was safe. + /// + /// An address escaping prevents replacing its loads and stores with SSA + /// variables, but it does not erase the evidence that a fixed-width slot + /// exists at that offset. Consumers can still report that weaker fact. + pub candidates: Vec<(i64, u8)>, } /// A value's offset from the stack pointer on entry, when it has one. @@ -84,8 +90,12 @@ pub fn promote(f: &mut Function) -> Promoted { } } + let candidates = slots.keys().copied().collect(); if escaped || slots.is_empty() { - return Promoted::default(); + return Promoted { + candidates, + ..Promoted::default() + }; } // Overlapping slots of different widths alias, and this pass has no way to // say that, so none of them are promoted. @@ -100,7 +110,10 @@ pub fn promote(f: &mut Function) -> Promoted { .collect(); // Rewrite. - let mut changed = Promoted::default(); + let mut changed = Promoted { + candidates, + ..Promoted::default() + }; let mut used: BTreeSet<(i64, u8)> = BTreeSet::new(); for at in &order { let mut state = entry_state.get(at).cloned().unwrap_or_default(); @@ -242,3 +255,44 @@ fn step( } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::func::{Block, Function}; + use e5r_core::{Addr, Arch}; + + #[test] + fn an_escaped_frame_still_reports_direct_slot_candidates() { + let arch = Arch::X86_64; + let sp = Varnode::register(abi::of(&arch).stack_pointer, 8); + let address = Varnode::temp(0, 8); + let value = Varnode::temp(1, 4); + let at = Addr(0x1000); + let block = Block { + addr: at, + ops: vec![ + IrOp::new(at, Op::IntSub, Some(address)) + .with(sp) + .with(Varnode::constant(16, 8)), + IrOp::new(at, Op::Load, Some(value)).with(address), + // Returning the address makes promotion unsafe, but does not + // invalidate the direct four-byte access just observed. + IrOp::new(at, Op::Return, None).with(address), + ], + successors: Vec::new(), + predecessors: Vec::new(), + }; + let mut function = Function { + arch, + entry: at, + blocks: [(at, block)].into_iter().collect(), + unlifted: Vec::new(), + }; + + let result = promote(&mut function); + assert_eq!(result.slots, 0, "an escaped slot must not be promoted"); + assert_eq!(result.candidates, vec![(-16, 4)]); + assert_eq!(function.blocks[&at].ops[1].op, Op::Load); + } +}