dconstruct

About this project


dconstruct is a reverse engineering toolchain featuring a disassembler, decompiler, and binary editor. It reads the custom bytecode used by Naughty Dog in games such as The Last of Us and Uncharted, reconstructs human-readable assembly and C-like pseudo code, and can produce control flow graphs for every function in a binary.

The project focuses on accurate control flow reconstruction, optimization passes that transform decompiled output into cleaner code, and speed — decompiling thousands of files takes only seconds.

Decompilation animation

Technical details


What is a Disassembler?

A disassembler is a tool that reads binary instructions — raw bytecode or machine code — and translates each instruction into a human-readable form called a mnemonic. Disassemblers generally don't try to interpret much about the meaning of instructions; they transform them one-to-one into their readable equivalents.

For example, the raw bytes

15 00 00 00
4A 01 01 00
43 31 01 00
1C 00 00 01

are disassembled into:

Assembly
LookupPointer r0, 0
LoadStaticU64Imm r1, 1
Move r49, r1
CallFf r0, r0, 1

All numbers in the bytecode are written in hexadecimal. The first column represents the opcode — the type of instruction to execute. The next column is the destination register, where the result will be stored. The remaining columns are operands: either registers or literal values on which the operation is performed.

dconstruct's disassembler goes further than a basic mnemonic translation. It resolves symbol table entries, annotates each instruction with the names of functions and variables being referenced, and inserts branch labels to make the control flow traceable:

Assembly
15 00 00 00 LookupPointer r0, 0 r0 = ST[0] -> <is-player-abby?>
4A 01 01 00 LoadStaticU64Imm r1, 1 r1 = ST[1] -> <player>
43 31 01 00 Move r49, r1 r49 = player
1C 00 00 01 CallFf r0, r0, 1 r0 = is-player-abby?(player)
2F 0D 00 00 BranchIfNot r0, 0xD IF NOT r0 => L_0

This level of annotation is useful when you want to see the raw contents of a binary without the tool making too many assumptions. But for large code blocks, the lack of higher-level structure makes disassembly tedious to follow — which is where decompilation comes in.

What is a Decompiler?

A decompiler is the inverse of a compiler. Where a compiler takes human-written source code and produces machine instructions, a decompiler takes disassembled instructions and attempts to reconstruct the original source code — producing what is known as pseudo code.

This is a fundamentally hard problem: compilation is a many-to-one mapping. Many different source programs can produce the same bytecode, and compiler optimizations destroy structural information along the way. A decompiler must infer structure — variables, conditionals, loops — from a flat sequence of instructions and branches, without any guarantee that the result matches what the programmer originally wrote.

dconstruct produces C-like pseudo code rather than attempting to reconstruct the original source language's syntax, making the output accessible to anyone familiar with imperative programming.

From Bytes to Code: A Walkthrough

To illustrate the full pipeline, consider a function called sqrt-sign. Its compiled form is a block of raw bytes:

43 00 31 00 15 01 00 00 43 02 00 00 43 31 02 00
1B 01 01 01 43 02 00 00 40 03 01 00 24 02 02 03
2F 0B 02 00 40 02 02 00 2D 0C 00 00 40 02 03 00
43 03 02 00 15 04 04 00 43 05 01 00 43 31 05 00
1C 04 04 01 07 03 03 04 43 01 03 00 00 01 01 00

From this, it's virtually impossible to tell what the code does.

Step 1: Disassembly

The disassembler decodes each instruction, resolves symbol table references, and inserts branch labels:

Assembly
sqrt-sign = script-lambda [0x9A8D8] {
    [1 args]
    0000 0x09A928 43 00 31 00 Move r0, r49 r0 = arg_0
    0001 0x09A930 15 01 00 00 LookupPointer r1, 0 r1 = ST[0] -> <absf>
    0002 0x09A938 43 02 00 00 Move r2, r0 r2 = arg_0
    0003 0x09A940 43 31 02 00 Move r49, r2 r49 = arg_0
    0004 0x09A948 1B 01 01 01 Call r1, r1, 1 r1 = absf(arg_0)
    0005 0x09A950 43 02 00 00 Move r2, r0 r2 = arg_0
    0006 0x09A958 40 03 01 00 LoadStaticFloatImm r3, 1 r3 = ST[1] -> <0.000000>
    0007 0x09A960 24 02 02 03 FGreaterThanEqual r2, r2, r3 r2 = r2 >= r3
    0008 0x09A968 2F 0B 02 00 BranchIfNot r2, 0xB IF NOT r2 => L_0
    0009 0x09A970 40 02 02 00 LoadStaticFloatImm r2, 2 r2 = ST[2] -> <1.000000>
    000A 0x09A978 2D 0C 00 00 Branch 0xC GOTO => L_1
L_0:
    000B 0x09A980 40 02 03 00 LoadStaticFloatImm r2, 3 r2 = ST[3] -> <-1.000000>
L_1:
    000C 0x09A988 43 03 02 00 Move r3, r2 r3 = -1.000000
    000D 0x09A990 15 04 04 00 LookupPointer r4, 4 r4 = ST[4] -> <sqrt>
    000E 0x09A998 43 05 01 00 Move r5, r1 r5 = RET_absf
    000F 0x09A9A0 43 31 05 00 Move r49, r5 r49 = RET_absf
    0010 0x09A9A8 1C 04 04 01 CallFf r4, r4, 1 r4 = sqrt(RET_absf)
    0011 0x09A9B0 07 03 03 04 FMul r3, r3, r4 -1.000000 = -1.000000 * RET_sqrt
    0012 0x09A9B8 43 01 03 00 Move r1, r3 r1 = -1.000000
    0013 0x09A9C0 00 01 01 00 Return r1 Return

    SYMBOL TABLE:
    0000 0x09A9C8 function: absf
    0001 0x09A9D0 float: 0.000000
    0002 0x09A9D8 float: 1.000000
    0003 0x09A9E0 float: -1.000000
    0004 0x09A9E8 function: sqrt
}

The code is now readable, but even a single branch creates a structure that's tedious to follow in flat assembly.

Step 2: Control Flow Analysis

Control flow graph for sqrt-sign

A Control Flow Graph (CFG) is a directed graph where each node contains a straight-line sequence of instructions (a basic block), and edges represent branches — the points where execution can diverge. Building a CFG is one of the critical intermediate steps in decompilation: it exposes the structure that was destroyed during compilation.

By analyzing the CFG's topology — identifying dominators, back-edges, and merge points — the decompiler can reconstruct high-level constructs like if/else branches, for loops, and while loops from what is otherwise a flat list of instructions and jumps.

Step 3: Decompiled Pseudo Code

C
u64? sqrt-sign(f32 arg_0) {
    f32 var_1;
    if (arg_0 >= 0.00) {
        var_1 = 1.00;
    } else {
        var_1 = -1.00;
    }
    return var_1 * sqrt(absf(arg_0));
}

The purpose of the function is now immediately clear: it takes the absolute value of the argument, computes the square root, and multiplies by the original sign. So sqrt-sign(-9) = -3.

Optimization Passes

Raw decompiled output tends to be verbose — compilers introduce temporary variables, redundant moves, and structural patterns that a human programmer would never write. dconstruct applies several optimization passes to clean up the pseudo code and bring it closer to what a person would actually write.

Function Call Inlining

When a variable is only used once and its value is a function call, the variable can be eliminated and the call inlined at the use site. Before:

C
u64? set-arrow-explosive-handle-rootvars() {
    u64? var_0 = get-uint64(fx-handle, self);
    u64? var_1 = get-float(kill, self);
    set-effect-float(var_0, killradius, var_1);
    u64? var_2 = get-uint64(fx-handle, self);
    u64? var_3 = get-float(strong, self);
    set-effect-float(var_2, strongradius, var_3);
    u64? var_4 = get-uint64(fx-handle, self);
    u64? var_5 = get-float(weak, self);
    u64? var_6 = set-effect-float(var_4, weakradius, var_5);
    return var_6;
}

After inlining:

C
u64? set-arrow-explosive-handle-rootvars() {
    set-effect-float(get-uint64(fx-handle, self), killradius, get-float(kill, self));
    set-effect-float(get-uint64(fx-handle, self), strongradius, get-float(strong, self));
    return set-effect-float(get-uint64(fx-handle, self), weakradius, get-float(weak, self));
}

Loop Pattern Recognition

The decompiler detects common loop idioms. A counted for loop iterating over an array with an index variable and a length check is a strong signal for a foreach construct. Before:

C
u64? bmm-deactivate-all(u64? arg_0) {
    u64? var_0 = darray-count(arg_0);
    begin-foreach();
    for (u64 i = 0; i < var_0; i++) {
        u64? var_1 = darray-at(arg_0, i);
        // ...
        net-send-event-all(deactivate, var_2);
    }
    u64? var_3 = end-foreach();
    return var_3;
}

After transformation:

C
u64? bmm-deactivate-all(u64? arg_0) {
    foreach (u64? var_1 : arg_0) {
        // ...
        net-send-event-all(deactivate, var_2);
    }
}

Match Expression Synthesis

Long if/else if chains that compare the same variable against consecutive integer constants are recognized as a switch/match pattern. Before:

C
string get-faction-name(u16 arg_0) {
    string var_0;
    if (arg_0 == 0) {
        var_0 = "Militia";
    } else if (arg_0 == 1) {
        var_0 = "Scars";
    } else if (arg_0 == 2) {
        var_0 = "Rattlers";
    } else if (arg_0 == 3) {
        var_0 = "Infected";
    } else {
        var_0 = "Invalid";
    }
    return var_0;
}

After synthesis:

C
string get-faction-name(u16 arg_0) {
    return match (arg_0) {
        0 -> "Militia"
        1 -> "Scars"
        2 -> "Rattlers"
        3 -> "Infected"
        else -> "Invalid"
    };
}

Structure Disassembly

Beyond executable code, binaries also contain typed data structures — arrays, structs, symbol identifiers, and nested compositions of these. dconstruct's disassembler interprets these structures recursively, resolving type information and symbol names from the binary's own metadata:

C
*ellie-weapons* = symbol-array [0x00190] {
[0] int: 6
[1] int: 0
[2] array [0x198] {size: 6} {
    [0] anonymous struct [0x780] {
        [0] sid: pistol-beretta
    }
    [1] anonymous struct [0x788] {
        [0] sid: pistol-revolver-taurus
    }
    [2] anonymous struct [0x790] {
        [0] sid: rifle-remington-bolt
    }
    [3] anonymous struct [0x798] {
        [0] sid: bow-ellie
    }
    [4] anonymous struct [0x7a0] {
        [0] sid: shotgun-remington-pump
    }
    [5] anonymous struct [0x7a8] {
        [0] sid: rifle-mpx5
    }
}
}

This makes it possible to understand not just the code, but also the data it operates on — the full picture of what a binary contains.

Architecture

The toolchain is structured as a multi-pass pipeline:

  1. Binary parsing — reads the file format, identifies code sections and data sections, and builds an address map
  2. Disassembly — decodes each instruction, resolves symbol table references, and emits annotated assembly
  3. CFG construction — splits disassembled functions into basic blocks and connects them via branch edges
  4. Control flow structuring — analyzes the CFG to identify loops, conditionals, and structured control flow patterns
  5. Code generation — emits C-like pseudo code from the structured representation
  6. Optimization passes — inlines single-use variables, detects foreach patterns, synthesizes match expressions, and removes dead code

The entire pipeline is optimized for throughput: decompiling every single binary file in a large codebase takes only a few seconds.

Reverse Engineering with ImHex

Much of the initial reverse engineering was done using ImHex, a hex editor that supports a pattern language called hexpat. A custom hexpat pattern file was written to map out the binary structure of the DC files — defining structs, enums, bitfields, and pointer chains so that the raw hex view became an annotated, navigable data structure. The insights gained from this pattern file directly informed the design of dconstruct's binary parser. A detailed writeup on the hexpat language and the techniques used can be found in the Guides section.