From 4f1dbce8b2c6f2f57970bf77e67bc425dfa5e999 Mon Sep 17 00:00:00 2001 From: sfz001 Date: Wed, 22 Apr 2026 21:04:36 +0800 Subject: [PATCH] Add Python 3.13 call-path opcodes and fix CALL self-slot direction Python 3.13 introduced several opcodes that pycdc did not handle, and it also changed the relative position of the NULL self-slot that surrounds the callable around ``CALL``. When the callable is loaded via ``LOAD_GLOBAL + NULL`` the layout is [NULL, callable, args...] but when it is loaded via ``LOAD_ATTR`` followed by a separate ``PUSH_NULL`` (the common pattern for non-method calls like ``mod.func(...)`` starting in 3.13) the layout is [callable, NULL, args...] The existing CALL_A handler only recognised the first layout, so calls of the second form decompiled to ``None(...)``. Check for both arrangements. New opcodes handled: * ``MAKE_FUNCTION`` (no-operand 3.13 form) and ``SET_FUNCTION_ATTRIBUTE`` -- push the code object as an empty ``ASTFunction`` and keep it on TOS while attribute values are attached (defaults/kwdefaults/annotations/closure cells are discarded for now; see #579/#581 for fuller support). * ``CALL_KW`` -- replaces the KW_NAMES + CALL pattern; kw-names tuple sits at TOS, the operand is the total argument count. * ``TO_BOOL`` -- no-op for decompilation purposes (fixes #540). * ``MAKE_CELL``, ``COPY_FREE_VARS`` -- prologue ops, no stack effect. * ``LOAD_FAST_CHECK``, ``LOAD_FAST_AND_CLEAR`` -- behave like LOAD_FAST. * ``STORE_FAST_STORE_FAST``, ``STORE_FAST_LOAD_FAST`` -- 3.13 fused locals ops; split into their component operations. * ``POP_JUMP_IF_NONE``, ``POP_JUMP_IF_NOT_NONE`` -- reuse the existing POP_JUMP_IF_FALSE/TRUE pipeline. Fixes #540, #587 and part of the missing-opcode list called out in #547. Adds ``call_kw_3_13`` test that exercises both the ``CALL_KW`` path and ``LOAD_ATTR + PUSH_NULL + CALL_KW`` (``json.dumps({}, ensure_ascii=False)``), which previously rendered as ``None(...)``. --- ASTree.cpp | 161 ++++++++++++++++++++++++++- tests/compiled/call_kw_3_13.3.13.pyc | Bin 0 -> 474 bytes tests/input/call_kw_3_13.py | 9 ++ tests/tokenized/call_kw_3_13.txt | 7 ++ 4 files changed, 174 insertions(+), 3 deletions(-) create mode 100644 tests/compiled/call_kw_3_13.3.13.pyc create mode 100644 tests/input/call_kw_3_13.py create mode 100644 tests/tokenized/call_kw_3_13.txt diff --git a/ASTree.cpp b/ASTree.cpp index f837152f9..0329aa82d 100644 --- a/ASTree.cpp +++ b/ASTree.cpp @@ -600,14 +600,74 @@ PycRef BuildFromCode(PycRef code, PycModule* mod) } PycRef func = stack.top(); stack.pop(); - if ((opcode == Pyc::CALL_A || opcode == Pyc::INSTRUMENTED_CALL_A) && - stack.top() == nullptr) { - stack.pop(); + // Python 3.11+ calls push a NULL self-slot. When the + // callable comes from LOAD_GLOBAL+NULL the layout is + // [NULL, callable, args...]; when it comes from + // LOAD_ATTR followed by PUSH_NULL (common for + // non-method calls like ``mod.func()`` in 3.13) the + // layout is [callable, NULL, args...]. Handle both. + if (opcode == Pyc::CALL_A || opcode == Pyc::INSTRUMENTED_CALL_A) { + if (func == nullptr && !stack.empty()) { + func = stack.top(); + stack.pop(); + } else if (!stack.empty() && stack.top() == nullptr) { + stack.pop(); + } } stack.push(new ASTCall(func, pparamList, kwparamList)); } break; + case Pyc::CALL_KW_A: + { + // Python 3.13+: TOS holds a tuple of keyword-argument + // names; the operand is the total argument count + // (positional + keyword). Below the names tuple are + // the argument values (last ``kwcount`` of them are + // the keyword values in the same order as the names), + // then the callable, then an optional NULL self slot + // (which may sit either above or below the callable + // depending on whether LOAD_GLOBAL+NULL or + // LOAD_ATTR+PUSH_NULL was used). + PycRef kw_names_node = stack.top(); + stack.pop(); + int total = operand; + int kwcount = 0; + std::vector> kw_keys; + if (kw_names_node.type() == ASTNode::NODE_OBJECT) { + PycRef obj = + kw_names_node.cast()->object(); + if (obj.type() == PycObject::TYPE_TUPLE || + obj.type() == PycObject::TYPE_SMALL_TUPLE) { + kw_keys = obj.cast()->values(); + kwcount = (int)kw_keys.size(); + } + } + int pparams = total - kwcount; + ASTCall::kwparam_t kwparamList; + ASTCall::pparam_t pparamList; + for (int i = kwcount - 1; i >= 0; --i) { + PycRef val = stack.top(); + stack.pop(); + kwparamList.push_front(std::make_pair( + new ASTObject(kw_keys[i]), val)); + } + for (int i = 0; i < pparams; ++i) { + PycRef param = stack.top(); + stack.pop(); + pparamList.push_front(param); + } + PycRef func = stack.top(); + stack.pop(); + if (func == nullptr && !stack.empty()) { + func = stack.top(); + stack.pop(); + } else if (!stack.empty() && stack.top() == nullptr) { + stack.pop(); + } + stack.push(new ASTCall(func, pparamList, kwparamList)); + } + break; case Pyc::CALL_FUNCTION_VAR_A: { PycRef var = stack.top(); @@ -1120,6 +1180,8 @@ PycRef BuildFromCode(PycRef code, PycModule* mod) stack.push(new ASTCompare(left, right, operand ? ASTCompare::CMP_IS_NOT : ASTCompare::CMP_IS)); } break; + case Pyc::POP_JUMP_IF_NONE_A: + case Pyc::POP_JUMP_IF_NOT_NONE_A: case Pyc::JUMP_IF_FALSE_A: case Pyc::JUMP_IF_TRUE_A: case Pyc::JUMP_IF_FALSE_OR_POP_A: @@ -1647,6 +1709,39 @@ PycRef BuildFromCode(PycRef code, PycModule* mod) case Pyc::LOAD_NAME_A: stack.push(new ASTName(code->getName(operand))); break; + case Pyc::MAKE_FUNCTION: + { + // Python 3.13+: MAKE_FUNCTION takes no operand. The + // code object is consumed from TOS; defaults, + // kw-defaults, annotations, and closure cells are + // attached by one or more SET_FUNCTION_ATTRIBUTE ops + // that follow. + PycRef fun_code = stack.top(); + stack.pop(); + ASTFunction::defarg_t defArgs, kwDefArgs; + stack.push(new ASTFunction(fun_code, defArgs, kwDefArgs)); + } + break; + case Pyc::SET_FUNCTION_ATTRIBUTE_A: + { + // Python 3.13+: TOS is the function produced by + // MAKE_FUNCTION, and the value below it is the + // attribute value (defaults tuple, kw-defaults dict, + // annotations, or closure cells -- selected by + // operand). Keep the function on top; discard the + // attribute value for now since pycdc does not yet + // render these extras. + PycRef fun = stack.top(); + if (fun != nullptr) { + stack.pop(); + if (!stack.empty()) + stack.pop(); + stack.push(fun); + } else if (!stack.empty()) { + stack.pop(); + } + } + break; case Pyc::MAKE_CLOSURE_A: case Pyc::MAKE_FUNCTION_A: { @@ -1677,6 +1772,66 @@ PycRef BuildFromCode(PycRef code, PycModule* mod) break; case Pyc::NOP: break; + case Pyc::TO_BOOL: + // Python 3.12+: converts TOS to bool. From the decompiler's + // point of view the value it renders is unchanged; the + // opcode only tells the interpreter to keep a cached + // bool-conversion. Treat as a no-op. + break; + case Pyc::MAKE_CELL_A: + // Python 3.11+: allocates a cell for a fast-local. No stack + // effect; the cell plumbing is handled implicitly by the + // existing LOAD_DEREF / STORE_DEREF cases. + break; + case Pyc::COPY_FREE_VARS_A: + // Python 3.11+: prologue that copies free variables from + // the function object into the frame. pycdc resolves free + // variable names via PycCode::getCellVar(), so this is a + // no-op during decompilation. + break; + case Pyc::LOAD_FAST_CHECK_A: + // Python 3.12+: like LOAD_FAST but raises if unbound. For + // decompilation purposes they are interchangeable. + if (mod->verCompare(1, 3) < 0) + stack.push(new ASTName(code->getName(operand))); + else + stack.push(new ASTName(code->getLocal(operand))); + break; + case Pyc::LOAD_FAST_AND_CLEAR_A: + // Python 3.12+: loads a fast-local and then clears it. + // Clearing is a runtime concern; the value on the stack is + // still the local's value, so treat like LOAD_FAST. + stack.push(new ASTName(code->getLocal(operand))); + break; + case Pyc::STORE_FAST_STORE_FAST_A: + { + // Python 3.13: two consecutive STORE_FAST ops. The + // high nibble is the first store, the low nibble the + // second. The top of stack is the second value. + PycRef value2 = stack.top(); + stack.pop(); + PycRef value1 = stack.top(); + stack.pop(); + PycRef name1 = new ASTName( + code->getLocal(operand >> 4)); + PycRef name2 = new ASTName( + code->getLocal(operand & 0xF)); + curblock->append(new ASTStore(value1, name1)); + curblock->append(new ASTStore(value2, name2)); + } + break; + case Pyc::STORE_FAST_LOAD_FAST_A: + { + // Python 3.13: STORE_FAST then LOAD_FAST fused. The + // high nibble is the store, the low nibble the load. + PycRef value = stack.top(); + stack.pop(); + PycRef name = new ASTName( + code->getLocal(operand >> 4)); + curblock->append(new ASTStore(value, name)); + stack.push(new ASTName(code->getLocal(operand & 0xF))); + } + break; case Pyc::POP_BLOCK: { if (curblock->blktype() == ASTBlock::BLK_CONTAINER || diff --git a/tests/compiled/call_kw_3_13.3.13.pyc b/tests/compiled/call_kw_3_13.3.13.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d014e9f5110003fe4c197a91c51208c469a113f0 GIT binary patch literal 474 zcmYjNF-yZh6n>W`X|#5*ZcZ^)Fldb;4vOd^ItUJ)-HN3oN1K`^;Vz}|{kwlF;+uR7Ed57%hk|qf14g?* z0UKDEggT +def greet ( name , greeting ) : + +return greeting + ', ' + name + +print ( greet ( 'world' , greeting = 'hi' ) ) +print ( json . dumps ( { } , ensure_ascii = False ) )