From 5682bcb11b32d4b6af0feb97d7ec5a5896b862fa Mon Sep 17 00:00:00 2001 From: Francois Chagnon Date: Tue, 13 Oct 2026 16:32:41 -0400 Subject: [PATCH 1/2] Fix retained operands and bounded OR tails in Boolean call arguments --- src/decompiler.rs | 73 +++++++++++++++++----- tests/fixtures/boolean_call_arguments.py | 35 +++++++++++ tests/integration.rs | 24 +++++++ tests/pyc/boolean_call_arguments.3.11.pyc | Bin 0 -> 2347 bytes tests/pyc/boolean_call_arguments.3.12.pyc | Bin 0 -> 2177 bytes tests/pyc/boolean_call_arguments.3.13.pyc | Bin 0 -> 2503 bytes tests/pyc/boolean_call_arguments.3.14.pyc | Bin 0 -> 2661 bytes 7 files changed, 116 insertions(+), 16 deletions(-) create mode 100644 tests/fixtures/boolean_call_arguments.py create mode 100644 tests/pyc/boolean_call_arguments.3.11.pyc create mode 100644 tests/pyc/boolean_call_arguments.3.12.pyc create mode 100644 tests/pyc/boolean_call_arguments.3.13.pyc create mode 100644 tests/pyc/boolean_call_arguments.3.14.pyc diff --git a/src/decompiler.rs b/src/decompiler.rs index 33a3465d..53ebf8ee 100644 --- a/src/decompiler.rs +++ b/src/decompiler.rs @@ -26399,18 +26399,18 @@ impl<'a> Ctx<'a> { )) } - /// 3.14 value-position `A1 and ... An or C`: every operand carries its + /// 3.13+ value-position `A1 and ... An or C1 or ... Cm`: each link carries its /// own COPY/TO_BOOL value-preserving idiom; the and-links PJIF to the /// or-continuation (this jump's target) and the LAST and-operand PJITs /// to the merge M; [target, M) evaluates C (t_pb f1/g_val 3.14: the /// per-jump value_merge blocks mis-nest the chain into /// `a and (b or c)`). Read-only; returns (merged value, merge offset). - fn try_value_and_or_314( + fn try_value_and_or( &self, cond: &ExprRef, target: usize, ) -> Option<(ExprRef, usize)> { - if !self.version.at_least(3, 14) { + if !self.version.at_least(3, 13) { return None; } let is_pjif = |o: Op| { @@ -26491,8 +26491,9 @@ impl<'a> Ctx<'a> { if ands.len() < 2 { return None; } - // [target, m) is the or's second operand: a pure value region - // (its leading POP_TOP discards the arriving operand copy) + // [target, m) contains the remaining OR operands. Each non-final + // operand must use the same COPY/TO_BOOL/PJIT-to-M value protocol; + // arbitrary statement branches or other merge targets do not qualify. let mi = *self.idx_of.get(&m)?; if mi <= ti { return None; @@ -26507,14 +26508,42 @@ impl<'a> Ctx<'a> { if cstart >= mi { return None; } - if !self.instrs[cstart..mi].iter().all(|x| { - x.target.is_none() - && (is_pure_value_op(x.op) - || matches!(x.op, Op::NOP | Op::NOT_TAKEN | Op::CACHE | Op::POP_TOP)) - }) { + let mut or_tail = Vec::new(); + let mut operand_start = cstart; + let mut k = cstart; + while k < mi { + let ins = self.instrs[k]; + if is_pjit(ins.op) && idiom_link(k) { + if ins.target != Some(m) + || self.instrs[k - 2].arg != 1 + || k - 2 <= operand_start + { + return None; + } + or_tail.push(self.sim_value_region(operand_start, k - 2)?); + k += 1; + while k < mi && pad(self.instrs[k].op) { + k += 1; + } + if k >= mi || self.instrs[k].op != Op::POP_TOP { + return None; + } + k += 1; + operand_start = k; + continue; + } + if ins.target.is_some() + || !(is_pure_value_op(ins.op) + || matches!(ins.op, Op::COPY | Op::TO_BOOL | Op::NOP | Op::NOT_TAKEN | Op::CACHE)) + { + return None; + } + k += 1; + } + if operand_start >= mi { return None; } - let c = self.sim_value_region(cstart, mi)?; + or_tail.push(self.sim_value_region(operand_start, mi)?); let mut and_flat = Vec::new(); for v in ands { flatten_boolop(v, BoolOpKind::And, &mut and_flat); @@ -26525,7 +26554,9 @@ impl<'a> Ctx<'a> { }) as ExprRef; let mut or_flat = Vec::new(); flatten_boolop(and, BoolOpKind::Or, &mut or_flat); - flatten_boolop(c, BoolOpKind::Or, &mut or_flat); + for value in or_tail { + flatten_boolop(value, BoolOpKind::Or, &mut or_flat); + } Some(( Rc::new(Expr::BoolOp { op: BoolOpKind::Or, @@ -34775,10 +34806,20 @@ if split_cond { // (the per-jump value_merge blocks below would mis-nest it). // !jump_if_true path only, where `c` is `cond` un-negated. if !jump_if_true { - if let Some((merged, m)) = self.try_value_and_or_314(&c, target) { - self.push(merged); - self.skip_until = Some(m); - return; + if let Some((merged, m)) = self.try_value_and_or(&c, target) { + // COPY kept this exact operand below its consumed truth + // test. The folded expression already owns that value; + // leaving it live creates an extra call argument (or steals + // the caller's callee/NULL prefix). Consume only this saved + // operand, preserving every unrelated stack entry. + if self.stack.last().map_or(false, |value| { + matches!(value, Sv::E(saved) if expr_eq(saved, &c)) + }) { + self.stack.pop(); + self.push(merged); + self.skip_until = Some(m); + return; + } } } // 3.12+ chained comparison: the then arm recomputes the next link diff --git a/tests/fixtures/boolean_call_arguments.py b/tests/fixtures/boolean_call_arguments.py new file mode 100644 index 00000000..de66ce33 --- /dev/null +++ b/tests/fixtures/boolean_call_arguments.py @@ -0,0 +1,35 @@ +def select(call, before, first, second, last, after): + return call(before(), (first() and second()) or last(), after()) + + +def repeated_prefix(call, value, second, last, after): + return call(value, (value and second()) or last(), after()) + + +def alternatives(a, b, c, d, e): + return bool((a and b) or c or d or e) + + +def or_prefix(a, b, c, d, e): + return bool(c or d or (a and b) or e) + + +def method(a, b, c, d, e): + return bool((a and a.get("available")) or b or c or d or e) + + +def assignment(a, b, c, d, e): + value = (a and a.get("available")) or b or c or d or e + return bool(value) + + +def pure_or(a, b, c, d, e): + return bool(a or b or c or d or e) + + +def pure_and(a, b, c, d, e): + return bool(a and b and c and d and e) + + +def following_branch(call, a, b, c, d, e): + return call((a and b) or c, d if e else 0) diff --git a/tests/integration.rs b/tests/integration.rs index f4af5082..e3c53d12 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -169,3 +169,27 @@ fn py314_hello() { "py3.14 hello", ); } + +#[test] +fn boolean_value_chains_preserve_call_arguments_and_prefixes() { + for version in ["3.11", "3.12", "3.13", "3.14"] { + let fixture = format!("boolean_call_arguments.{version}.pyc"); + let (src, clean) = decompile_fixture(&fixture); + assert!(clean, "{fixture}: output marked incomplete:\n{src}"); + assert_contains( + &src, + &[ + "return call(before(), first() and second() or last(), after())", + "return call(value, value and second() or last(), after())", + "return bool(a and b or c or d or e)", + "return bool(c or d or a and b or e)", + "return bool(a and a.get('available') or b or c or d or e)", + "value = a and a.get('available') or b or c or d or e\n return bool(value)", + "return bool(a or b or c or d or e)", + "return bool(a and b and c and d and e)", + "return call(a and b or c, d if e else 0)", + ], + &fixture, + ); + } +} diff --git a/tests/pyc/boolean_call_arguments.3.11.pyc b/tests/pyc/boolean_call_arguments.3.11.pyc new file mode 100644 index 0000000000000000000000000000000000000000..82e30327b3535b139276bf0ad25611d8cf24f0ab GIT binary patch literal 2347 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 Gmi-qNN{$%- literal 0 HcmV?d00001 diff --git a/tests/pyc/boolean_call_arguments.3.14.pyc b/tests/pyc/boolean_call_arguments.3.14.pyc new file mode 100644 index 0000000000000000000000000000000000000000..98fbb201a1023d14819c62dea1247829b2819d71 GIT binary patch literal 2661 zcmcIm&2Jk;6rZto9e>1$O`#4+1vQqFtRrF~#fp?xDM1NbP$8?z;-ezkcsGuvjX!4B zZn*ixg^ME&F(5&z9!TKQs+S=CiA6pbR@^voLkWKX@6E35O=PPoDkJ&##Fl9E^b4oNo0~BCXp#1m3@Fd*$?QK1wcU-0Yy0g z7?8&R$K)VjP!6St6fOk%UJ1cIE_zYre(#Ya$l|LT-@PN!~x~ zX))+N6$Vf9;-J5b>p@GAQ+oH5NC8_YD7tP3cC_6J(`<3K#0=987+RrHR&7C7;Gd}M zni?~3yR*F6^QLB)Ms~M!VAh#tWOphRT~o^2=xbYH#rnQhHjTyV=jch-3MLzemr+M! z0ia2Kk4zp0z6ySw`*x)}leK2D4;zmTy3?!H^y-t1r|}}qt!qkhDtF3KoS|QZ;K5@uWJkxCM<*y1~Bju7llQj zXHPP%swt+XZdaKG`Y;F=!8t}6fD>c9X7ExS-$M_rQG|7kWv~enho7+q&?Gz`x^c;hORd^b{jlEtw3Cp!u{A5UcA_}; z@seZDKu;V$+ZuH`PzkQwcij1F&jy!y0Rk(4s zD~w@1#X4iW$=M?hNR_k|OXJZ)+fKT+=Gf!)PGr^+X8$8mbqG$wyo@ygj`a zhT6MCJba41z5DZG|0`q+`Q(LVAGm>*BA<~#U!s5EpmTc$`0Y6|%?BAi>tuLrx1#Hn z2c>dxdxt6I!XC?juQ%Z5xkBCBlpYhTEgN9L>MjSeOC(c&klYC^DBvA zx#q=)pB40bu- Te;-Yphbf)I!G(VSvfS;zIef4` literal 0 HcmV?d00001 From d05e66c5260ce1e0817c4e86b5bdda2aa4e17c1c Mon Sep 17 00:00:00 2001 From: Francois Chagnon Date: Tue, 13 Oct 2026 18:35:26 -0400 Subject: [PATCH 2/2] Add harness coverage for the reconstruction fix --- .../cases/b_boolean_call_arguments.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 tests/behavior/cases/b_boolean_call_arguments.py diff --git a/tests/behavior/cases/b_boolean_call_arguments.py b/tests/behavior/cases/b_boolean_call_arguments.py new file mode 100644 index 00000000..173d1eed --- /dev/null +++ b/tests/behavior/cases/b_boolean_call_arguments.py @@ -0,0 +1,31 @@ +# MIN_VERSION: 3.13 + + +def select(call, before, first, second, last, after): + return call(before(), (first() and second()) or last(), after()) + + +def alternatives(a, b, c, d, e): + return bool((a and b) or c or d or e) + + +def before(): + print("before") + return 1 +def first(): + print("first") + return 0 +def second(): + print("second") + return 2 +def last(): + print("last") + return 3 +def after(): + print("after") + return 4 +def call(*args): + return args +print(select(call, before, first, second, last, after)) +print(alternatives(1, 0, 0, 0, 5)) +print(alternatives(1, 2, 0, 0, 0))