Learning Objectives
- Read the 16-byte `.pyc` header from PEP 552 and unmarshal the code object without the `marshal` module, for files written by four interpreter versions.
- Parse a JVM class file from JVMS Chapter 4: constant pool, fields, methods, the `Code` attribute, and the tables that `javac -g` adds.
- Disassemble the JVM opcodes in `Ledger.class` and prove the listing against `javap -c` on four builds of the same source.
- Read a WebAssembly module section by section, decode every function body, and prove the result against `wasm-objdump -d`.
- Walk the ECMA-335 metadata of a .NET assembly from the PE data directory to the `MethodDef` rows and the CIL of each method, checked against the reader library that ILSpy uses.
- Measure, for one function in five containers, which names, types, and control structure each format keeps and which it loses.
Beyond Native Code: Python, JVM, .NET, and WebAssembly Bytecode
> A bytecode file is a program plus the compiler’s notes about it, and this lesson builds the readers that show which notes each container keeps.
Type: Build
Languages: Python, Java, C#, WebAssembly text
Prerequisites: Phase 13 · 32 (ELF from scratch), 33 (disassembly and control flow)
Time: ~150 minutes
Learning Objectives
- Read the 16-byte
.pycheader from PEP 552 and unmarshal the code object without themarshalmodule, for files written by four interpreter versions. - Parse a JVM class file from JVMS Chapter 4: constant pool, fields, methods, the
Codeattribute, and the tables thatjavac -gadds. - Disassemble the JVM opcodes in
Ledger.classand prove the listing againstjavap -con four builds of the same source. - Read a WebAssembly module section by section, decode every function body, and prove the result against
wasm-objdump -d. - Walk the ECMA-335 metadata of a .NET assembly from the PE data directory to the
MethodDefrows and the CIL of each method, checked against the reader library that ILSpy uses. - Measure, for one function in five containers, which names, types, and control structure each format keeps and which it loses.
The Problem
You built ledger.py and found ledger.cpython-314.pyc in __pycache__. You asked an agent what the file contains. The agent ran a decompiler. The tool printed Unsupported opcode and a line that said # WARNING: Decompyle incomplete. The agent concluded that the file was obfuscated.
The file was not obfuscated. Its first two bytes are 2b 0e, the little-endian word 3627. That is the number CPython 3.14 writes, and the decompiler’s opcode tables stop at 3.13. The tool was out of date. The agent did not read the header, so it could not tell the difference.
Lesson 33 showed what native code loses: parameter names, types, and the switch that became a jump table. Bytecode containers lose less, and each one loses something different. A pyc keeps every variable name and no type. A class file keeps types and keeps local names only when you ask. A Wasm module keeps the control structure and drops names unless a custom section carries them. A .NET assembly keeps parameter names in a table and leaves local names to a separate file. You cannot judge a decompiler’s output on any of them until you know which notes the container had to begin with.
This lesson builds four readers in the Python standard library. Each one reads the container from its first byte, lists one function, and is checked against the format’s own tool on fixtures you built. The last section puts grade from ledger side by side in all five containers.
re-43-five-containers
The pyc Header Is Four Words
PEP 552 defines the header as four 32-bit words. The first is the magic number. The second is a bit field. Bit 0 set means the file is hash-based. Bit 1 set means check_source: the interpreter hashes the source at import and compares. With bit 0 clear, words three and four are the source mtime and the source size. With bit 0 set, they hold a 64-bit SipHash of the source.
The magic number lives in Include/internal/pycore_magic_number.h. Its rule since 3.11 is that Python 3.n starts at 2900 + 50n, and each bytecode change inside a release cycle adds one. The stored token is the 16-bit number followed by rn, so a pyc opened in text mode gets a wrong magic. The final values are 3495 for 3.11, 3531 for 3.12, 3571 for 3.13, 3627 for 3.14, and 3666 for 3.15. Python 3.15.0 was released on 2026-10-09 and is not installed on the machine that built these fixtures, so its number comes from the header file and not from a file here.
The fixtures hold two pyc files per interpreter. compile_pyc.py wrote them with py_compile.compile(..., dfile="ledger.py") so that the stored filename is ledger.py and not the path of the build machine. The first 16 bytes of the 3.14 files:
checked-hash 2b 0e 0d 0a 03 00 00 00 c7 16 c7 fc c5 1d 81 01
timestamp 2b 0e 0d 0a 00 00 00 00 8d 60 cf 6a f1 01 00 00
Read the timestamp header word by word. 03 00 00 00 or 00 00 00 00 is the flag word. 8d 60 cf 6a is 0x6acf608d = 1791975565, the mtime of ledger.py when the file was built. f1 01 00 00 is 497, the source size. The checked-hash file carries flags 3 and eight hash bytes instead. The four interpreters agree on everything in the header except the magic word:
| Interpreter | Magic bytes | Word | File bytes | grade.co_code bytes | grade instructions |
|—|—|—|—|—|—|
| 3.11.14 | a7 0d 0d 0a | 3495 | 1054 | 80 | 31 |
| 3.12.12 | cb 0d 0d 0a | 3531 | 937 | 62 | 26 |
| 3.13.12 | f3 0d 0d 0a | 3571 | 943 | 70 | 26 |
| 3.14.6 | 2b 0e 0d 0a | 3627 | 965 | 96 | 35 |
read_pyc_header() in code/main.py returns these fields and names the version from the word. importlib.util.MAGIC_NUMBER gives the same four bytes for the running interpreter. python3 code/main.py compare compiles the source with every python3.11 to python3.15 found on PATH. For each one it prints the header our reader sees next to the facts that interpreter reports about itself.
Marshal Writes the Code Object Field by Field
Byte 16 of every fixture is e3. That is 0x80 | ord('c'): the marshal type code for a code object with FLAG_REF set, which tells the reader to remember the object for later r references. The marshal documentation says the format is undocumented on purpose and that code objects are not compatible between versions. Both statements are true, and the format is still small enough to read. Python/marshal.c lists the type codes: N None, i a 32-bit int, s bytes, z and Z short ASCII strings, ( and ) tuples, c a code object, r a back reference. Containers reserve their reference slot before their children, so the reader does the same.
w_complex_object() writes a code object in a fixed order. For 3.11 to 3.15 it is argcount, posonlyargcount, kwonlyargcount, stacksize, flags as 32-bit ints, then code, consts, names, localsplusnames, localspluskinds, filename, name, qualname as objects, then firstlineno, then linetable and exceptiontable. Decode the module code object of the 3.14 file by hand from byte 17:
00 00 00 00 argcount 0
00 00 00 00 posonlyargcount 0
00 00 00 00 kwonlyargcount 0
01 00 00 00 stacksize 1
00 00 00 00 flags 0
f3 22 00 00 00 type 's' with FLAG_REF, 34 bytes of co_code follow
80 00 52 06 74 00 52 01 17 00 74 01 ...
The code bytes are two per instruction: 80 00 is RESUME 0, 52 06 is LOAD_CONST 6, 74 00 is STORE_NAME 0, and 17 00 is MAKE_FUNCTION. The 3.14 listing in the fixtures prints the same five module-level statements. After code come the constants, which hold the four nested code objects, and after them names: ('LABELS', 'percent_change', 'grade', 'total', 'grade_label').
Unmarshaller in code/main.py reads this layout and the older one that 3.9 and 3.10 used, where nlocals, varnames, freevars, and cellvars were separate fields. It builds a CodeObject with the same attribute names the real object has. varnames filters localsplusnames by the CO_FAST_LOCAL bit of localspluskinds. For grade in every one of the four files it returns ('score', 'bucket'), firstlineno 10, and a non-empty linetable. The test test_unmarshaller_agrees_with_the_running_interpreter compares every field with marshal.loads() on the file the running interpreter wrote. The other three files are readable by our code and unreadable by marshal under this interpreter.
Four Interpreters, Four Bytecodes for grade
The dis documentation carries one warning on every version: bytecode is an implementation detail of CPython and no guarantee is made that it stays the same. The fixtures show what that means for eleven lines of Python. python3.1N -m dis ledger.py was captured for each interpreter.
In 3.11 grade starts like this:
11 2 LOAD_FAST 0 (score)
4 LOAD_CONST 1 (10)
6 BINARY_OP 2 (//)
10 STORE_FAST 1 (bucket)
12 12 LOAD_FAST 1 (bucket)
14 LOAD_CONST 2 (9)
16 COMPARE_OP 5 (>=)
22 POP_JUMP_FORWARD_IF_FALSE 2 (to 28)
BINARY_OP sits at offset 6 and the next instruction at 10. The two bytes between them are an inline cache entry from PEP 659, the specializing adaptive interpreter. COMPARE_OP at 16 is followed by 22, so it owns two cache entries. The caches are hidden unless you pass show_caches=True. 3.12 replaces LOAD_CONST plus RETURN_VALUE with RETURN_CONST, renames the jump to POP_JUMP_IF_FALSE, and shrinks grade from 80 to 62 bytes. 3.13 prints labels L1 to L4 instead of offsets and gives the CLI -C for caches and -O for offsets. 3.14 removes RETURN_CONST again, adds LOAD_SMALL_INT and LOAD_FAST_BORROW, and inserts NOT_TAKEN after each conditional jump:
12 20 LOAD_FAST_BORROW 1 (bucket)
22 LOAD_SMALL_INT 9
24 COMPARE_OP 188 (bool(>=))
26 CACHE 0 (counter: 0)
28 POP_JUMP_IF_FALSE 3 (to L1)
30 CACHE 0 (counter: 0)
32 NOT_TAKEN
The -C -O listing shows 13 CACHE entries inside grade: five after BINARY_OP, one after each COMPARE_OP, one after each POP_JUMP_IF_FALSE. The adaptive fixture shows what the caches are for. After 200 calls, dis.dis(grade, adaptive=True) prints RESUME_CHECK and COMPARE_OP_INT where the plain listing had RESUME and COMPARE_OP, and total shows FOR_ITER_LIST, BINARY_OP_ADD_INT, and JUMP_BACKWARD_NO_JIT. The specialised forms live in the same co_code bytes. A pyc never contains them, because specialisation happens in memory.
This is why the decompilers have version ceilings. uncompyle6 3.9.3 states that it accepts bytecode from Python 1.0 to 3.8. decompyle3 3.9.3 reads 3.7 and 3.8. xdis 6.3.0, the cross-version disassembler behind both, reads up to 3.13. pycdc has opcode tables up to python_3_13.cpp in its build file, and on an unknown opcode prints Unsupported opcode and marks the output incomplete. PyLingual claims every released version since 3.6 and uses interpreters from 3.8 to 3.15 to recompile and check its own output. For a 3.14 file, the first three refuse or fail, and only the last two apply. Read the magic word before you pick a tool.
The Class File Keeps a Dictionary of Itself
JVMS Java SE 25, section 4.1, gives the layout as one structure: magic, minor_version, major_version, constant_pool_count, the constant pool, access_flags, this_class, super_class, interfaces, fields, methods, attributes. The magic is 0xCAFEBABE. A Java SE 25 virtual machine accepts major versions 45 through 69, and a minor of 65535 marks a preview-feature file. The first ten bytes of Ledger-g.class are ca fe ba be 00 00 00 41 00 5e: major 65 is Java 21, and 0x5e = 94 is the constant pool count. Ledger-jdk25.class, built by javac 25.0.2 from the same source, starts ca fe ba be 00 00 00 45: major 69.
The constant pool is where the class file keeps its own dictionary. Entry #13 in the -g build is a Methodref that points at class #8 and name-and-type #14. Following the indexes gives Ledger.grade:(I)I, which is exactly what javap -v prints after //. The descriptor (I)I is one int parameter and an int result. ([I)I on total says the parameter is an int array. Every method, field, and class the code touches has such an entry, and the verifier in section 4.10 refuses a file whose instructions disagree with them. The ClassFile class in code/main.py reads all 17 constant tags from table 4.4 and treats a Long or Double as two slots. It raises on an unknown tag and on bytes after the last attribute.
Attributes hang off the class, each field, each method, and the Code attribute itself. JVMS says the virtual machine must ignore attributes it does not know, which is why debug tables can be added or dropped without changing a method body. The javac manual says -g generates all debugging information including local variables, that the default generates line numbers and the source file only, and that -g:none generates nothing. The three builds measure that:
| Build | Bytes | Constant pool count | LineNumberTable | LocalVariableTable | SourceFile | grade code bytes |
|—|—|—|—|—|—|—|
| javac -g | 1832 | 94 | 7 methods | 6 methods | yes | 50 |
| javac | 1581 | 82 | 7 methods | none | yes | 50 |
| javac -g:none | 1404 | 79 | none | none | no | 50 |
The 50 code bytes of grade are identical in all three files, and the test checks it. What -g adds for grade is one LocalVariableTable row: start 0, length 50, slot 0, name score, descriptor I. Without it a decompiler sees slot 0 of type int and has to invent a name. StackMapTable stays in every build because the verifier needs it, and the BootstrapMethods attribute stays because main concatenates strings through invokedynamic.
Decode tableswitch by Hand
Chapter 6 of JVMS defines each instruction as one opcode byte and the operands that opcode names. Branch offsets are 16-bit signed values relative to the address of the branch opcode, which differs from x86-64, where the displacement is relative to the next instruction. tableswitch and lookupswitch begin with zero to three padding bytes so that their 32-bit operands start at an address that is a multiple of four.
The bytes of grade from pc 4 in Ledger-g.class:
aa tableswitch at pc 4
00 00 00 padding to pc 8
00 00 00 2c default: 4 + 44 = 48
00 00 00 06 low 6
00 00 00 0a high 10
00 00 00 2a case 6: 4 + 42 = 46
00 00 00 28 case 7: 4 + 40 = 44
00 00 00 26 case 8: 4 + 38 = 42
00 00 00 24 case 9: 4 + 36 = 40
00 00 00 24 case 10: 4 + 36 = 40
Thirty-six bytes, one instruction, and the whole switch (score / 10) with its shared case for 9 and 10. The native build of the same switch in lesson 33 needed a bounds check, a lea, a movslq, an add, an indirect jump, and five relocations in another section. Here the targets are in the instruction, and so is the fact that they belong together.
disassemble_jvm() covers all 202 defined opcodes with their operand widths, follows the padding rule, and renders operands the way javap does. compare_with_javap() parses the captured javap -c -p text, including the multi-line tableswitch block, and checks offset, mnemonic, and operand for every instruction. All four builds agree on 108 of 108 instructions. The test test_branch_offsets_are_relative_to_the_branch_instruction checks ifne at pc 1 landing on 6, if_icmpge at 13 landing on 33, and goto at 30 landing back on 10.
radare2 6.2.4, built from source for these lessons, reads the same file. rabin2 -I reports arch java, lang java 21, linenum true, and lsyms true, which is its reading of the version and the two debug tables. pd 14 @ sym.Ledger.grade prints the tableswitch with arrows to its five targets. It also prints Unable to parse code attribute: 2 on this file, which is a limitation the tool names itself and a reason to keep javap as the oracle. Decompilers for this format are mature by comparison with Python. CFR 0.152 is MIT licensed. Procyon 0.6.0 and Vineflower 1.12.0 are Apache-2.0, and Vineflower is a Fernflower fork with Java 21 support. Fernflower itself lives inside the IntelliJ Community repository. JADX 1.5.6 covers the Android DEX format, and JD-GUI had its last release in 2019. Harrand, Soto-Valero, Monperrus, and Baudry measured eight of them in 2020. The best produced syntactically correct output for 84 percent of classes and semantically equivalent output for 78 percent. Their meta-decompiler recovered 37.6 percent of the classes that no single tool handled. Types, names, and verified stacks make the format decompilable. They do not make any one decompiler complete.
A Wasm Module Is a List of Sections
The core specification defines a module as the preamble 00 61 73 6d 01 00 00 00, the string