Back to Blog
App DevelopmentPublished on October 12, 2026

Static Binary Recompilation to WebAssembly: Reverse-Engineering Legacy Architectures for the Browser

Explore how Ahead-of-Time (AOT) static binary recompilation translates legacy machine architectures directly into high-throughput WebAssembly modules. Learn how to overcome W^X sandbox constraints, lift branch delay slots, and map asynchronous coprocessors to modern web runtimes.

Beyond Interpreters and JITs: The Rise of Static Recompilation

For decades, preserving and running software built for legacy computing architectures—such as the complex multi-processor designs of early-2000s hardware like the PlayStation 2 or custom MIPS/PowerPC embedded systems—relied on two main paradigms: software interpreters and dynamic binary recompilers (Just-In-Time or JIT compilers).

Interpreters fetch, decode, and execute instructions cycle by cycle within a virtualized loop. While portable and resilient to arbitrary self-modifying code, they exhibit severe performance overheads, frequently running an order of magnitude slower than native silicon. Dynamic recompilation overcomes this by translating guest basic blocks into host machine code on the fly at runtime, caching blocks in an executable memory heap.

However, in modern security-hardened environments—most notably the browser sandbox governed by WebAssembly—traditional JIT architectures hit an immovable barrier: the W^X (Write XOR Execute) policy. WebAssembly runtimes explicitly forbid generating or modifying executable byte code inside linear memory at runtime without invoking heavy runtime compilation primitives that degrade frame latencies.

This constraint has catalyzed the resurgence of Static Binary Recompilation (AOT - Ahead-Of-Time recompilation). Instead of parsing binaries dynamically during execution, an AOT pipeline lifts entire guest machine code executables directly into high-level Intermediate Representations (IR) such as LLVM IR or directly into WebAssembly (Wasm) ASTs. The output is a standard, statically compiled .wasm module that runs at near-native execution speed directly inside Chrome, Firefox, or Safari, fully utilizing hardware acceleration through WebGPU and SIMD.


Anatomy of the Decompilation Pipeline: Lifting MIPS to Wasm

Translating an instruction set like the 64-bit/128-bit MIPS III (specifically the R5900 "Emotion Engine" core) to standard WebAssembly requires solving critical structural mismatches between low-level silicon behavior and WebAssembly's high-level structured control flow.

1. Control Flow Graph (CFG) Recovery and Indirect Jumps

WebAssembly does not permit arbitrary computed jumps to raw physical addresses. It mandates structured control flow: block, loop, if, and br_table. When an arbitrary binary is statically lifted, indirect jumps (e.g., function pointers, virtual method tables, and jr $ra returns) cannot simply jump to a dynamically computed memory pointer.

To bridge this gap, an AOT static recompiler must construct a global Control Flow Graph:

  1. Recursive Descent Disassembly: Identify all function entry points starting from ELF headers, export symbols, and known interrupt handlers.
  2. Basic Block Segmentation: Break assembly streams into deterministic basic blocks terminated by branch or jump instructions.
  3. Relocation & Jump Table Synthesis: Transform calculated jump targets into an explicit dispatch table leveraging WebAssembly's call_indirect or a master dispatch br_table index.
// Example of transforming a MIPS Jump Register (jr $ra) into a structured Wasm Dispatcher
fn emit_indirect_dispatch(target_address_reg: Register) -> WasmBlock {
    let block = WasmBlock::new();
    block.emit_instruction(Opcode::I32Load {
        offset: calculate_recompiler_offset(target_address_reg),
    });
    // Dispatch to registered basic-block indices via function pointer table
    block.emit_instruction(Opcode::CallIndirect {
        type_idx: FUNC_SIG_GENERIC_BASIC_BLOCK,
        table_idx: TABLE_DISPATCH_MAP,
    });
    block
}

2. Resolving the Branch Delay Slot Problem

Classic RISC architectures enforce branch delay slots: the instruction immediately following a conditional branch or jump is executed before the branch actually takes effect. WebAssembly possesses no native concept of delayed execution slots.

There are two primary strategies to translate delay slots during static recompilation:

  • Instruction Inversion: Swap the branch condition check and the delay slot instruction, adjusting condition registers if the delay slot alters flags evaluated by the jump.
  • State Interleaving: If the delay slot modifies a register used by the conditional check itself, the recompiler must lift the delay slot to a temporary register, evaluate the branch, commit the delay slot register, and then alter the control flow path.

Emulating Asymmetric Hardware: Vector Units & 128-Bit Registers

One of the most complex engineering challenges when recompiling platforms like the PS2 to WebAssembly is replicating 128-bit vector coprocessors (such as the Vector Units VU0 and VU1) without destroying runtime throughput.

Mapping 128-bit Integers and Floats to WebAssembly Fixed-Width SIMD

The MIPS R5900 features 128-bit wide general-purpose registers (GPRs) capable of operating on packed 32-bit integers, 16-bit halfwords, or single-precision floats simultaneously. WebAssembly includes the Fixed-Width 128-bit SIMD extension (v128), which maps cleanly onto host x86-64 SSE/AVX or ARM NEON instructions.

When lifting custom instructions (e.g., MIPS PADDUW - Parallel Add Unsigned Word), the recompiler outputs raw Wasm SIMD bytecodes:

;; Lifting: PADDUW $r3, $r1, $r2
;; WebAssembly Equivalent using v128
local.get $r1_vec
local.get $r2_vec
i32x4.add
local.set $r3_vec

Asynchronous Execution via Web Workers and SharedArrayBuffer

In silicon, Vector Units often run asynchronously alongside the main CPU core, communicating through Direct Memory Access (DMA) channels and FIFO buffers. Because JavaScript and WebAssembly run on an event loop, emulating this coprocessor parallelism without stutter requires a multi-threaded architecture:

  1. Core Runtime Thread (Web Worker 1): Executes the lifted CPU instruction stream.
  2. Vector Coprocessor Thread (Web Worker 2): Recompiles microcode routines statically to separate Wasm modules, triggered via atomic flags.
  3. Graphics Processing (Web Worker 3 / Main Thread): Reads from shared display buffers and blits framebuffers using WebGPU.

These components synchronize across memory boundaries using SharedArrayBuffer with synchronization orchestrated through Atomics.wait() and Atomics.notify().


Memory Virtualization: Structuring the Linear Address Space

Legacy platforms feature memory maps with non-contiguous spaces: scratchpad RAM, kernel RAM, hardware-mapped I/O registers (MMIO), and video memory. WebAssembly, by contrast, gives the program a single, contiguous, growable array: WebAssembly.Memory.

Fast Address Translation via Masking and Base Pointers

Dynamic bounds checking on every single read or write kills runtime performance. To maintain 60+ FPS in an AOT-recompiled binary, address resolution must avoid branching.

Instead of evaluating:

if (addr >= 0x70000000 && addr < 0x70004000) {
    return read_scratchpad(addr);
} else if (addr < 0x02000000) {
    return main_ram[addr];
}

The recompiler allocates a single contiguous memory arena (typically 64MB to 128MB) within the 4GB 32-bit WebAssembly address space and leverages constant bit-masking:

;; Fast load: address masking to mirror memory regions
;; Masking out cache control bits (e.g., unmapped KSEG1 0xA0000000)
local.get $guest_address
i32.const 0x1FFFFFFF ;; Strip top 3 bits of virtual address space
i32.and
i32.load offset=0     ;; Read directly from the linear memory base

MMIO accesses (such as writing to a timer register or serial port) are isolated during the decompilation phase: any load or store instruction targeting static, known MMIO addresses is replaced at compile time with a direct call to a JavaScript- or Wasm-implemented peripheral handler function.


Practical Tutorial: Building a Minimal Instruction Lifter

Below is a working implementation of a miniature static lifter in Rust that translates a raw MIPS R-Type instruction into WebAssembly binary bytecode using the wasm-encoder crate.

use wasm_encoder::{
    CodeSection, Function, Instruction, ValType
};

#[derive(Debug)]
struct MipsInstruction {
    opcode: u8,
    rs: u8,
    rt: u8,
    rd: u8,
    funct: u8,
}

fn decode_mips(raw: u32) -> MipsInstruction {
    MipsInstruction {
        opcode: ((raw >> 26) & 0x3F) as u8,
        rs: ((raw >> 21) & 0x1F) as u8,
        rt: ((raw >> 16) & 0x1F) as u8,
        rd: ((raw >> 11) & 0x1F) as u8,
        funct: (raw & 0x3F) as u8,
    }
}

fn lift_block_to_wasm(instructions: &[u32]) -> Function {
    let mut func = Function::new(vec![]); // Register file stored in linear memory or locals

    for &raw_inst in instructions {
        let inst = decode_mips(raw_inst);
        
        match (inst.opcode, inst.funct) {
            // MIPS ADDU: rd = rs + rt (No arithmetic trap on overflow)
            (0x00, 0x21) => {
                // Fetch guest register rs from local variables
                func.instruction(&Instruction::LocalGet(inst.rs as u32));
                // Fetch guest register rt
                func.instruction(&Instruction::LocalGet(inst.rt as u32));
                // Add the two 32-bit registers
                func.instruction(&Instruction::I32Add);
                // Store the result directly into guest register rd
                func.instruction(&Instruction::LocalSet(inst.rd as u32));
            },
            // MIPS ORI: rt = rs | immediate
            (0x0D, _) => {
                let imm = (raw_inst & 0xFFFF) as i32;
                func.instruction(&Instruction::LocalGet(inst.rs as u32));
                func.instruction(&Instruction::I32Const(imm));
                func.instruction(&Instruction::I32Or);
                func.instruction(&Instruction::LocalSet(inst.rt as u32));
            },
            _ => {
                // Handle unsupported instructions via an external trap or panic stub
                func.instruction(&Instruction::Unreachable);
            }
        }
    }

    func.instruction(&Instruction::End);
    func
}

When integrated into a build pipeline, this logic parses the entirety of an unpacked ELF binary, generating thousands of deterministic WebAssembly functions that mirror the original game logic perfectly.


The Verdict: Recompilation as the Standard for Software Preservation

Static binary recompilation to WebAssembly fundamentally changes the economics of legacy software distribution and retro-computing. By shifting the complex parsing, CFG resolution, and register mapping steps from the user's browser CPU to an offline build step, applications achieve:

  1. Instant Startup: Zero JIT warmup time, eliminated compilation stutter, and predictable frame pacing.
  2. Unrestricted Portability: Full execution inside modern, unprivileged browser runtimes without requiring native installers, drivers, or emulation binaries.
  3. Memory Safety: Host systems are protected from legacy buffer overflows by WebAssembly’s robust software-fault isolation (SFI) guarantees.

As WebAssembly continues to mature with the stabilization of 64-bit Memory (Memory64), relaxed SIMD primitives, and threads, AOT static recompilation is poised to become the definitive strategy for running complex systems software directly on the open web.

#WebAssembly#Systems Programming#Compilers#Emulation#Reverse Engineering