From 9efb91158ff2eff03d93422ac39c60d63a2d6890 Mon Sep 17 00:00:00 2001 From: Francois Chagnon Date: Tue, 13 Oct 2026 15:35:45 -0400 Subject: [PATCH 1/2] Preserve surviving bindings for repeated unpack targets --- src/decompiler.rs | 37 ++++++++++++++++++-- tests/fixtures/repeated_unpack_bindings.py | 35 ++++++++++++++++++ tests/integration.rs | 31 ++++++++++++++++ tests/pyc/repeated_unpack_bindings.3.12.pyc | Bin 0 -> 1561 bytes tests/pyc/repeated_unpack_bindings.3.13.pyc | Bin 0 -> 1553 bytes tests/pyc/repeated_unpack_bindings.3.14.pyc | Bin 0 -> 1549 bytes 6 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/repeated_unpack_bindings.py create mode 100644 tests/pyc/repeated_unpack_bindings.3.12.pyc create mode 100644 tests/pyc/repeated_unpack_bindings.3.13.pyc create mode 100644 tests/pyc/repeated_unpack_bindings.3.14.pyc diff --git a/src/decompiler.rs b/src/decompiler.rs index 33a3465d..49bef2dc 100644 --- a/src/decompiler.rs +++ b/src/decompiler.rs @@ -42539,9 +42539,39 @@ fn cmp_from_index(idx: usize) -> CmpOp { // ===================== stack events, calls, functions, classes ===================== impl<'a> Ctx<'a> { + fn discarded_unpack_target(&self, remaining: usize) -> ExprRef { + // Repeated local targets can have their earlier stores replaced by + // POP_TOP. Their names may then be obfuscated while the surviving + // store keeps its slot. Reuse the next direct local store's spelling: + // the compiler elides the duplicate instead of creating an extra `_` + // local. Do not cross nested unpacking, side effects or control flow. + let name = if self.version.at_least(3, 13) { + self.idx_of.get(&self.cur_offset).and_then(|&index| { + let mut discarded = 1usize; + for next in &self.instrs[index + 1..] { + match next.op { + Op::NOP | Op::CACHE | Op::EXTENDED_ARG | Op::NOT_TAKEN => {}, + Op::POP_TOP => { + discarded += 1; + if discarded >= remaining { return None; } + } + Op::STORE_FAST => return self.code.varnames.get(next.arg as usize).cloned(), + Op::STORE_FAST_STORE_FAST | Op::STORE_FAST_LOAD_FAST => { + return self.code.varnames.get(((next.arg >> 4) & 0xF) as usize).cloned(); + } + Op::STORE_DEREF => return self.code.deref_name(next.arg as usize).map(str::to_string), + _ => return None, + } + } + None + }) + } else { None }; + self.name_expr(name.as_deref().unwrap_or("_")) + } + fn handle_pop_top(&mut self) { - // 3.14 `_` elision: the compiler replaces the store of a - // throwaway `_` name inside a tuple unpack with a POP_TOP + // 3.13+ repeated-target elision: the compiler replaces an + // overwritten local store inside a tuple unpack with a POP_TOP // discarding that slot (UNPACK_SEQUENCE; POP_TOP; // STORE_FAST_STORE_FAST). The open unpack frame must consume // the slot as a placeholder target or the tuple never @@ -42552,8 +42582,9 @@ impl<'a> Ctx<'a> { if *remaining > 0 { let is_slot = matches!(self.stack.last(), Some(Sv::E(e)) if Rc::ptr_eq(e, fv)); if is_slot { + let target = self.discarded_unpack_target(*remaining); if let Some(Sv::E(_)) = self.stack.pop() { - self.collect_unpack_target(self.name_expr("_")); + self.collect_unpack_target(target); } return; } diff --git a/tests/fixtures/repeated_unpack_bindings.py b/tests/fixtures/repeated_unpack_bindings.py new file mode 100644 index 00000000..2b97c72e --- /dev/null +++ b/tests/fixtures/repeated_unpack_bindings.py @@ -0,0 +1,35 @@ +# Versioned .pyc files are consumed by tests/integration.rs. + +# CPython 3.13 starts replacing overwritten stores with POP_TOP. + +def repeated_loop(values): + result = [] + for value, discarded, discarded, discarded in values: + result.append(value is not None) + return result + + +def repeated_list(values): + return [value is not None for value, discarded, discarded, discarded in values] + + +def underscore_loop(values): + result = [] + for value, _, discarded, discarded in values: + result.append((value, discarded, _)) + return result + + +def underscore_list(values): + return [(value, discarded, _) for value, _, discarded, discarded in values] + + +def nested_loop(values): + result = [] + for value, (discarded, discarded) in values: + result.append((value, discarded)) + return result + + +def nested_list(values): + return [(value, discarded) for value, (discarded, discarded) in values] diff --git a/tests/integration.rs b/tests/integration.rs index f4af5082..0eb2f0be 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -169,3 +169,34 @@ fn py314_hello() { "py3.14 hello", ); } + +#[test] +fn overwritten_unpack_slots_reuse_the_surviving_binding() { + for version in ["3.12", "3.13", "3.14"] { + let (src, clean) = decompile_fixture(&format!("repeated_unpack_bindings.{version}.pyc")); + assert!(clean, "Python {version}: output marked incomplete:\n{src}"); + assert_contains(&src, &[ + r#"def repeated_loop(values): + result = [] + for value, discarded, discarded, discarded in values: + result.append(value is not None) + return result"#, + r#"def repeated_list(values): + return [value is not None for value, discarded, discarded, discarded in values]"#, + r#"def underscore_loop(values): + result = [] + for value, _, discarded, discarded in values: + result.append((value, discarded, _)) + return result"#, + r#"def underscore_list(values): + return [(value, discarded, _) for value, _, discarded, discarded in values]"#, + r#"def nested_loop(values): + result = [] + for value, (discarded, discarded) in values: + result.append((value, discarded)) + return result"#, + r#"def nested_list(values): + return [(value, discarded) for value, (discarded, discarded) in values]"#, + ], version); + } +} diff --git a/tests/pyc/repeated_unpack_bindings.3.12.pyc b/tests/pyc/repeated_unpack_bindings.3.12.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3b14a1b62286ee190ca834ba41d20bf99ef99b84 GIT binary patch literal 1561 zcmaKs!B5jr9LL{lyV22&F(p$$jG{w21&k4u#28}GARwsIg9ft9w%~M*@w%y)l5j9O z6O!SH9d~5$!jYSQfR_y94=O*Y}$5_w)Ppo5#~A5<=d6 zO+NqKlWqA4dG(;>Y_|)NWulY!JmgrcR+N^In@TOvJRI!;KC2= z4nbHZ(`24nWROg@5;7qXGB3=Fcv|#D?J9T|4@=NVihe22P_lSZA{GsiJRQr^k~5M> z7;!x>m7K35v6Nw!oXjv&v1G}?pGz)1YHAUt8@dUfBB$Zl4<-#WY4%P<-zHP=b}us$ zMkHzI;Z!^k(O!n1MdNxjK5h0S=1QLGWNc<8!Q8M1$}#&OERb!Fcj3$^w54V(1v12F~SUItJcWo|xJKM7EZr@Ns1u3)#H}2(!=2mB&5GSnW zLWUu!;oMfkS(=v);%rHlI3?DQZnsHuNetNFy9Q9c@08^TksNc>n_Tjz-@L(%~rXkve6Y^!GB>>zKGEi<0i5@Wo^>RPR;2HC(L zk944#fURb5cuN^6o*l^b6@vpS{i{(Vv+h2%p^Oxy5$LoLyVHDa&N0==|317~8P+o= z&CnpeFE1Hhquj=Q`NpdmJry%17 literal 0 HcmV?d00001 diff --git a/tests/pyc/repeated_unpack_bindings.3.13.pyc b/tests/pyc/repeated_unpack_bindings.3.13.pyc new file mode 100644 index 0000000000000000000000000000000000000000..de82713859dd366dbc8961d5b03bb71c3d5563b6 GIT binary patch literal 1553 zcmai!O-vI(6vyA}ZkL6nARj?RFnmLcp@1ck#25~w0m@fcOYlT0T}WfyHZxr#H%^Kt zxsY&d;)QzR$ia)=;M58dtOrfJc~W}fVtg}ON}GsovM;Z*JJbIEzxQw6?XHpm;On~| zZ-2j;n*WY{@gjvT--pdM7|;w941$#y5?0BOvC4*mRWV#xU2CeRV&!5V-|oVje?fj4 z%QpDHA|YsiiCX}+0$@pP&<2(qA`7Lv@Lhg|Bm<_&Na0Bv%(MdN_?O9IA)tHr&5#Iv1HCAKIbYUQMMAM5i`Q@>!1yp6GvVJsd`&}uw`OJajqHsI7mQ&3*+mko#Kg5Dk{p7 zv<}y}FxG=*13phL9P7qmAlosK>fXJ%H@UyQ(|AqR z`-Pk-Y)W7^J2DcTh+6qO>ZPub?3YVWw_KtYqA17baXGrUZ$ey-EIax*6x4U2u-3LB zCS@yeYAy?KjrjZ;-fRHN1vu|uR_obxZFzQz*zDc9OjG}fb_cQb4+(4({VvDGZ528v zu(`yxC^Mx14n61Nov24_LrcSc8L^?oe0Z5+m4YjLDxjmac%nxsq1S*5jzn(26$&}U zCVYGyZ`OqcDNf9O(&rAXYTcWzY;E^e z<<96u%cWifbFUrB(evjb_W|Sa?E9iu)p;evrsNx?- zB$V JtJ6fx`5zn^F24W( literal 0 HcmV?d00001 diff --git a/tests/pyc/repeated_unpack_bindings.3.14.pyc b/tests/pyc/repeated_unpack_bindings.3.14.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b94832cd1faec0c69d2b2c8894692c04b343d39e GIT binary patch literal 1549 zcmai!%TE(g6voe;nGREi@{)kcoAPQgw53f%o6O1Wy`3}tzTf#~pxRX_0>I}_Z{JLe z!^0oAE`CUUb>YQD0yJoXIA{bb(FCl5CSnye39FPz!-(fK3U&5Za*~3^7C&O7FtovQJ3RV1|t5Pud~a3gCn`2oX1=$Xtr#zu5ng?<@-!NYL}SlQpRtv56%W_e*9}^QJ(whO2ulJ^T{Vfow~>1p#kcL) z-rDsfpPVYyA6*|@J9=_7-Pm`c^#2mUKlWX%OFX*3d!Wo61vj`m6TBUBW{E5bmgoaZ z2&j3F2=i#8lX)>G1YNcVmS_kD`e31h_Bslb^-4iXq!qZJPF5mu z*5MwLW%go8z}c*JtS%k}GF?;2p1oW9(+69-4cV&Jqrew+F)b}#X4OS*<-59OxlD|V z_j55byipPLZCeI)BWUUGpqINtqF*cl-Esw*i=qsk$6@PYu5ocWvSRCFNEq6@!Wt_U z(W$w*PW2UztpU$#@iKi_3fR0u8KrmIvE$h-LbLYj(v1Tr${mC@FwCJ*^t&7yvz6-% z@MZ;QOQI!lZ0I-}ZCa1ehL=bDB0@uh+2C4|QHm}9Ef0>?U_Xyk!tMrc*aDe+CeS8W zrjIdf#N#Hs%x)~maBA+WI)5Z*dMA_mzVl#gXF6Nec_e4m`Lr~Dd92QJg70fvlrn<< z3ah}vx{3ArxGuZ>LJ7l?uwF5wY)XnzwB@KV%p=B+qq>Rb_;LVqkV~-><{8W=J==~< zP0x;VcdQUGE8WGXZtU(*|a@W?!R1*SZn$N%FJVLOrGbTA|d3wQX=)|)r34I=SqIL NU@w2;?kwRm{s* Date: Tue, 13 Oct 2026 18:34:06 -0400 Subject: [PATCH 2/2] Add harness coverage for the reconstruction fix --- .../cases/b_repeated_unpack_bindings.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 tests/behavior/cases/b_repeated_unpack_bindings.py diff --git a/tests/behavior/cases/b_repeated_unpack_bindings.py b/tests/behavior/cases/b_repeated_unpack_bindings.py new file mode 100644 index 00000000..40f05f9b --- /dev/null +++ b/tests/behavior/cases/b_repeated_unpack_bindings.py @@ -0,0 +1,31 @@ +# MIN_VERSION: 3.13 + + +def underscore_loop(values): + result = [] + for value, _, discarded, discarded in values: + result.append((value, discarded, _)) + return result + + +def underscore_list(values): + return [(value, discarded, _) for value, _, discarded, discarded in values] + + +def nested_loop(values): + result = [] + for value, (discarded, discarded) in values: + result.append((value, discarded)) + return result + + +def nested_list(values): + return [(value, discarded) for value, (discarded, discarded) in values] + + +rows = [(1, 2, 3, 4), (5, 6, 7, 8)] +print(underscore_loop(rows)) +print(underscore_list(rows)) +nested = [(1, (2, 3)), (4, (5, 6))] +print(nested_loop(nested)) +print(nested_list(nested))