WATaBoy: JIT-Ing Game Boy Instructions to WASM Beats a Native Interpreter
WATaBoy: Outperforming Native Interpreters via JIT-to-Wasm
Published on June 28th, 2026
📌 Background
Prerequisite: This article assumes you are already familiar with the fundamentals of Just-In-Time (JIT) compilation.
"Dolphin isn't on iOS, because you can't do JIT compilation on iOS."
The quote above summarizes the core argument from OatmealDome’s piece, "Why Dolphin Isn’t Coming to the App Store." This sparked a curiosity in me: is there any way to get a CPU-heavy emulator like Dolphin running on iOS? Would we simply have to wait for years until iPhone hardware becomes so powerful that a standard interpreter suffices?
The Browser Loophole
Apple provides one significant exception to its strict JIT bans: web browsers. The JavaScriptCore engine (part of WebKit) utilizes JIT compilation for its high-performance tiers. Essentially, if a JS function is executed frequently enough, the engine optimizes it by compiling it directly into native machine code.
This presents an opportunity. Instead of attempting to generate native machine code (which is forbidden), we can generate WebAssembly (Wasm) bytecode. The browser's own engine will then handle the final step of compiling that Wasm into native code.
After discovering Andy Wingo's insights on just-in-time code generation within webassembly, I realized this was viable. While projects like v86 and The Jiterpreter use this, no console emulator had yet implemented it or benchmarked it against a native interpreter.
For my undergraduate final-year project, I created WATaBoy: a Game Boy emulator that implements both a standard interpreter and a JIT-to-Wasm pipeline.
🚀 The Concept: JIT-to-Wasm
To avoid confusion with the browser's internal Wasm-to-native process, I refer to my implementation as "JIT-to-Wasm."
Caption: WATaBoy translating SM83 instructions into Wasm bytecode.
Experienced emulators might roll their eyes wonder why a Game Boy emulator needs JIT. The Game Boy's CPU isn't particularly demanding. However, as detailed in GameRoy's blog, cycle-accuracy is possible via:
- Interrupt Prediction: Forecasting when interrupts occur to manage JIT block boundaries.
- Interpreter Fallbacks: Switching back to the interpreter when a block is interrupted.
- Lazy Evaluation: Handling non-CPU components (MMIO) only when accessed.
While a 6th-gen console would see more massive gains, the Game Boy was a perfect, manageable scope for a university project.
Performance Comparison Theory
| Method | Execution Path | Speed Potential | iOS Compatibility |
|---|---|---|---|
| Interpreter | Lower | Yes | |
| JIT-to-Wasm | Higher | Yes (via Browser) |
🛠️ Implementation Details
I will focus on the most unique aspect of WATaBoy: Wasm codegen and late-linking using Rust. While I used SIMD for tile rendering, that's a topic for another day.
The Rust Setup
I avoided wasm-bindgen and wasm-pack because they introduce ergonomic hurdles when performing low-level Wasm manipulation. Instead, I followed the "Rust to WebAssembly the hard way" philosophy: passing data across the Rust-JS boundary via the C ABI using pointers and buffer lengths.
Setup Checklist:
- Install Nightly Rust (required for inline Wasm)
- Initialize library project
- Add
wasm-encoderdependency
To set your toolchain:
rustup default nightly (Swap nightly for stable to revert).
cargo new --lib jit-to-wasm
Wasm Code Generation
The wasm-encoder crate allows us to emit Wasm bytes using a builder pattern. While it requires some boilerplate, it is far superior to manually writing byte arrays.
Cargo.toml
[package]
name = "jit-to-wasm"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
[dependencies]
wasm-encoder = "0.252.0"
Generating an add function:
The goal is to create a Wasm module that performs the operation .
use wasm_encoder::*;
fn make_add_module() -> Vec<u8> {
let mut module = Module::new();
// Define the function type: (i32, i32) -> i32
let mut functions = FunctionSection::new();
let type_index = 0;
functions.push(type_index);
let mut my_add_func = Function::new(vec![type_index]);
my_add_func.instructions(|inst| {
inst.local_get(0); // Push left operand
inst.local_get(1); // Push right operand
inst.i32_add(); // Add them
});
module.section(&functions);
module.section(&CodeSection::new(vec![my_add_func]));
module.finish()
}
#[no_mangle]
pub extern "C" fn make_and_execute_add(left: i32, right: i32) -> i32 {
let add_bytecode = make_add_module();
// Execution logic goes here...
0
}
🏗️ Architecture & Linking
The flow of WATaBoy's execution can be visualized as follows:
The Harvard Architecture Hurdle
As noted by Andy Wingo, WebAssembly utilizes a Harvard architecture (separation of code and data), unlike the von Neumann architecture (where code and data share the same memory).
This means the Rust code cannot simply jump to the generated bytecode. We must communicate with the embedder (the JavaScript engine) to compile and link the newly generated Wasm module into the running environment.