Porting GTA V to Apple plateforms

On October 6, a site called playgta5.com let anyone play GTA V in a Chrome tab. The game ran on your own machine, inside a web page, with nothing to install. About 10,000 people showed up the same day, the host fell over, and a few hours later the site didn't answer anymore. Nobody has confirmed who built it, and nobody has confirmed who took it down either (it wasn't me btw)

Three days later, GTA V was running on my iPhone. That's Muguet, and it starts from the exact same file the website was sending to your browser

The video got way more replies than I could answer in a tweet, so this post goes through the questions one by one, and each answer goes all the way down into the code

What was the website actually running ?

The best breakdown is this write-up, which went through everything the page downloaded. The short version :

  • the whole game engine was compiled to 64-bit WebAssembly : one game.wasm of about 63 MB, with the function names still in it
  • the engine still thinks it's talking to Direct3D 11. Every D3D call gets written as a small command into a ring buffer in shared memory, and a separate web worker reads that ring and replays it on WebGPU
  • the game's shaders were translated ahead of time, from Direct3D bytecode to WGSL (WebGPU's shading language)
  • game files were streamed from the server in small blocks with HTTP range requests, and cached in the browser
  • audio went through another ring, read by an audio worklet
  • dozens of engine threads, each one a web worker sharing the same memory

So the page is basically two things. A big game.wasm that is the game, and a pile of JavaScript around it that pretends to be a PC : a GPU, a disk, a sound card, threads

That second part is the one Muguet replaces

Is it just the browser version in an app ?

It's the same game.wasm, but there's no browser anywhere in the app, not even a hidden WebView

A .wasm file is a program that can't do anything on its own. Every time it needs the outside world (open a file, start a thread, get the time, draw something), it calls an import, a function it expects the host to provide. In the browser, those imports are the JavaScript glue. In Muguet they're Rust

The code itself runs on wasmtime, which compiles WebAssembly to native arm64 with Cranelift. GTA V needs a few features most wasm programs don't, so the engine is configured like this (main.rs) :

config.wasm_memory64(true);
config.wasm_threads(true);
config.shared_memory(true);
config.wasm_bulk_memory(true);
config.wasm_multi_memory(true);

Then every piece of JavaScript the website shipped gets a native replacement :

  • threads : every engine thread becomes a real native thread, running its own instance of the module on the same shared memory. The ones that matter for frame pacing (the render thread, the audio mixer) get the highest QoS class so iOS schedules them first
  • files : the game reads its files through an import called httpfs_manifest and an I/O table in shared memory. On the website, a worker answered that table with range requests. In Muguet, a native thread (io.rs) watches the same table, claims each request with an atomic compare-and-swap, and reads the bytes straight from the files on disk
  • audio : the same sample ring, read by cpal on the native audio output (audio.rs). It asks for 48 kHz and falls back to the device's own rate when that fails, which was a thing on some bluetooth headphones (thanks GuruSteve)
  • graphics : the same D3D command ring, read by a native GPU thread instead of a web worker. More on that one below, it's the biggest piece

The game has no idea any of this changed. It writes into the same rings and the same tables, and something different answers

How does it run on an iPhone without JIT ?

On the Mac, Muguet compiles game.wasm to arm64 the first time you launch it, then caches the result as a .cwasm file. The cache name includes the wasm's size, its date and a hash of the patches I apply, so it rebuilds by itself when either changes (and old ones get deleted, thanks inj-src). That first compile is why the first launch takes a while : it's 63 MB of game

On iOS that's impossible. Generating executable code at runtime needs JIT, and getting JIT on an iPhone is a whole hassle I really didn't want people to deal with. So the compiling happens on the Mac, at build time. make-ios.sh does it in four steps :

  1. pull b/<build>/game.wasm out of your game zip
  2. --patch it (the multiplayer hooks and a few fixes, see below)
  3. run wasm-opt -O3 on it, keeping the names (that one takes a few minutes, once)
  4. --precompile-ios it : wasmtime compiles for aarch64-apple-ios and writes a .cwasm

Then the fun part. That .cwasm gets glued straight into the app's executable, in its own segment :

std::arch::global_asm!(
    ".section __GAME,__code,regular,pure_instructions",
    ".p2align 14",
    "_muguet_game_code:",
    concat!(".incbin \"", env!("MUGUET_GAME_CODE"), "\""),
    "_muguet_game_code_end:",
    ".text",
);

build.rs tells the linker that __GAME is readable and executable (-segprot,__GAME,rx,rx), and .p2align 14 lines it up on a 16 KB page. So when Xcode signs the app, the whole game is signed with it, exactly like any other code in the binary

At launch, wasmtime's deserialize_raw points at that segment instead of loading anything. Normally wasmtime would then ask the system to make its code executable. Here a tiny custom code memory says “already done” and returns. The code was executable from the moment iOS loaded the app, so nothing is ever generated at runtime, and there's no JIT anywhere

Will it run on my iPhone ?

The honest answer depends on memory, way more than on the chip

GTA V asks for a big heap, and it's a 64-bit wasm memory : it can grow up to 16 GB. On a Mac that's fine, wasmtime reserves 17 GB of address space with big guard regions and lets the OS deal with it. iOS is a lot less chill about memory, so the heap is capped. It's 8 GB with a paid developer account (which can get the extended virtual addressing entitlement), and 5 GB for free accounts and .ipa installs

Both builds also need the increased memory limit entitlement. Without it you can launch the game, but you won't go far. The iOS launcher shows how much memory the system will actually give the game, so you know where you stand before pressing play

Then the game itself has to behave. GTA V streams the world through a big allocator pool that keeps growing as you drive around. Muguet hooks rage::sysMemGrowBuddyAllocator::GrowHeap and refuses to grow it past 2.5 GB on iOS (Divyanshu found that one)

In practice I said iPhone 15 Pro and later when people asked, anything with 8 GB of RAM or more. On my iPhone 17 it averages around 50-60 fps

What about older iPhones ?

They run into the GPU, and that's where the black screens came from 😭

  • BC textures : the game's textures are BC-compressed, a desktop format. Older iPhone GPUs can't sample it at all, so the world was literally black. The first fix decoded them on the CPU, then inj-src spread that over all the cores, then c0rmac moved the most common one (BC1) to the GPU, transcoding it to ASTC 4x4 in a compute shader (bc1_astc.wgsl)
  • float textures : some GPUs can't filter 32-bit float textures, which made effects and shadows disappear. Those get converted to 16-bit floats on the way in, and the shaders that read them are patched to match
  • MSAA : the game asks for whatever sample count it wants, so it's capped to what the GPU supports. There's also a switch to turn MSAA and TXAA off completely
  • shader quality : forced to high, because normal crashes on launch lmao

How does the graphics part work natively ?

gpu.rs is the biggest file in Muguet, about 3,800 lines. It runs on its own thread, reads the same command ring the website's GPU worker read, and replays it on wgpu, which runs on Metal

The ring speaks 48 opcodes, numbered 0 to 47. For example 3 creates a texture, 13 uploads one, 14 creates a shader, 32 is a draw and 7 presents the frame. The game writes them, bumps a head counter, and the GPU thread executes until it catches up, writing a tail counter back and waking up anyone who's waiting on it with an atomic notify

The shaders were already translated to WGSL by whoever built the web port, so they come with the game files as shaders/<hash>.wgsl. When the game creates a shader, Muguet loads the WGSL with that hash and hands it to wgpu. A few get small fixes on the way (the minimap's alpha mask was broken until Kagander fixed it)

The hard part is pipelines. Metal wants the complete pipeline (shaders, blend, depth, vertex layout…) before it draws, and building one takes time. GTA V changes state constantly, so new combinations keep appearing in the middle of a frame. Pipelines are built in the background, and a draw waits for its pipeline for a bit before giving up, with a shared budget of 8 ms per frame so a burst of new pipelines can't freeze the game. Everything that got built is also remembered and warmed up during the next loading screen

A lot of the speed came from the contributors here. Divyanshu cut the allocations and hashing out of every draw, pooled the staging buffers, tuned how many frames the game can queue on iOS (four made it skip GPU readbacks, it ended up at two) and patched wgpu-core itself to skip empty command buffers on Metal. c0rmac fixed a heap corruption coming from GPU readbacks and a leak in the GPU object table. That's why there's a patched copy of wgpu-core in vendor/

Can I play with my friends ?

Yes, but keep your expectations low, it's a small multiplayer I originally made for my Mac port. Don't expect FiveM or sum

The game has no network code that can work here, so the multiplayer gets added to the game itself. This is where the function names in game.wasm matter. They're still there, as full C++ names, so patch.rs can find any function by name and rewrite it before compiling. It does two things

First, it injects calls into a few game functions, so Muguet gets told when they run :

("muguet_script", "GtaThread::Update(int)", &[Arg::Local0, Arg::TlsBase]),
("muguet_explosion", "CExplosionManager::AddExplosion(CExplosionManager::CExplosionArgs&, CProjectile*, bool)", &[Arg::Local0]),
("muguet_pool", "rage::sysMemGrowBuddyAllocator::GrowHeap()", &[Arg::PoolGrow]),

The first one runs on every script tick, which is where mp.rs does its work. The pool one is the iOS memory cap from earlier, same trick

Second, it exports a bunch of the game's own script commands (CreatePed, SetEntityCoordsNoOffset, SetPedIntoVehicle, GetEntityVelocity…) so Rust can call them directly. Other players are just peds that Muguet spawns and moves with the same functions mission scripts use

The network part is plain UDP on port 47520. One player hosts from the launcher, or you run muguet-server on any machine, and everyone else joins with that address. Story missions stay single player

The same patcher also fixes a few bugs that only show up in this build. One function that preloads movies now returns right away, because its loading thread never runs here. One scenario check had a hardcoded 6 that ran off the end of its entity list, so it's a 5 now. And a couple of help texts that were missing from the game files (the duck in a car one, and the glasses camera in Casing the Jewel Store) get filled in

Did you use AI ?

Yes, for some parts. Opus helped me on some parts and rough edges I had no idea about, especially the wasm side, but a lot is myself or me steering it massively. The Metal side and the rest were mainly done by hand

Same goes for this post btw : Claude proofread it and made some parts clearer

So how do I try it ?

You need your own copy of the game as a zip, with data/ and b/<build>/ (with game.wasm and shaders/ in it). No game files come with Muguet, because I care about me not getting sued

Then one line in Terminal :

/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/c22dev/muguet/main/build.sh)"

It installs what's missing, asks if you want the Mac or the iPhone version, and builds it. A free Apple account works (with the 5 GB heap). Install the iPhone build with AltStore, it doesn't work well with Sideloadly or SideStore

Muguet has 162 commits right now, and a big part of them aren't mine. Divyanshu alone did 48, from a lot of the touch controls (drive-bys, radio wheel, sniper zoom…) to 120 Hz on iOS. Huge thanks to divyanshugarg36, c0rmac, Kagander, inj-src, Dbhardwaj99, dre4moff, kjnefk, GuruSteve, diamondraras and lunar-me

People also asked about battery life. I still haven't measured it, but I already know it won't be great 😭

All posts