d2r-mobile Runs Diablo II on iPhone Without Emulating a Single Instruction

The Switch and your phone already speak the same language. Everything else (the OS, the graphics API, the 30-second save timer) is what had to change.

9 min read • View on GitHub • More from jhaizhou-ops

A long circuit board shaped like a game cartridge runs across the frame, its traces unbroken. At three points, small railway-switch gate symbols divert a single trace out of the board, over a small external module, and back in. A handheld console silhouette sits at the left edge and a phone silhouette at the right, connected by the same continuous trace.
The board is the game's shipped binary, mostly untouched. The three gates are the cut points where control briefly leaves the program and enters the host runtime. That is the entire thesis of d2r-mobile in one image.
Key Takeaways

It's Not an Emulator. The CPU Was Never the Problem.

A Switch game is running on an iPhone, and it is not being emulated. That claim sounds like marketing. It is actually an architectural fact about two chips that happen to share a language.

The Nintendo Switch is built on ARM Cortex cores. Every iPhone and Apple-silicon Mac is built on ARM64 cores. Same instruction set. The bytes of Diablo II: Resurrected's compiled program are already valid ARM64 code. An emulator's entire job, translating one instruction set into another, is simply unnecessary here.

So d2r-mobile does something narrower. It loads the game's shipped NSO modules, overwrites a small set of instructions, and reimplements everything around the program: the operating system calls, the graphics API, the input layer. The game runs natively. The platform is what gets translated.

That reframing is the whole story. A compatibility layer can be dramatically smaller and faster than an emulator when the instruction set matches. The cost is that it works for one game, on one hardware family, and the two are welded together forever.

An open-source iOS / macOS compatibility layer for Diablo II: Resurrected with the Reign of the Warlock expansion.

d2r-mobile project, Project description in GitHub repository · jhaizhou-ops/d2r-mobile

Cut Points and Gates: Surgery on a Shipped Binary

If you don't rewrite the instructions, what do you rewrite? A scalpel's worth.

An offline rewriter walks the game's binary and replaces selected instructions with branches into a gate mechanism. Function entries that need host intervention get replaced with a two-instruction cut point: svc #n; ret. When the game's program counter reaches a cut point, control leaves the binary and enters the host runtime, which decides what to do and hands control back.

Hundreds of instructions patched out of millions. The binary between the cut points is untouched.

Hover a cut point to see the instruction it replaced. Click a host service to isolate every call that routes to it. The program counter sweeps left to right and pauses each time it leaves the binary.

The project takes patching safety seriously. Before it writes anything, it checks that the original value at a known offset matches what it expects. If the build ID doesn't match, it refuses to patch.

int32_t *p = (int32_t *)(m->base + 0x20cefa8);
if (*p != 30) { LOG("...构建号不符?),不改", *p); return; }

That guard is the difference between a working port and a corrupted binary. The same pattern appears at main+0x20a0074, where a sentinel mismatch triggers a hard die() instead of a quiet skip.

Rebuilding the Operating System the Game Expects

The game doesn't just compute. It calls into an operating system. It opens files through nn::fs, reads input through nn::hid, checks the clock through nn::oe, queries add-on content through nn::aoc, and asks about parental controls through nn::pctl. None of those exist on iOS.

So the project implements them. High-level emulation means writing a shim for every function the game calls into the OS. When the game hits a cut point that routes to nn::fs::OpenFile, the host runtime intercepts it and calls the equivalent Apple platform function instead. The game never knows the difference.

Networking is cut entirely. It returns a generic failure code. The online features of Diablo II: Resurrected simply don't exist in this build, and the project doesn't pretend otherwise.

The takeaway is that HLE is not clever. It is exhaustive. Every function the game calls needs a host-side answer, and the list is long.

NVN on Metal: Translating the Graphics API

The graphics path is where the real weight of the port lives. Nintendo's NVN graphics API is reimplemented on top of Apple's Metal.

Shaders are translated offline, not at runtime. A tool called dmsh_xlate converts NVN binary shader form into Metal Shading Language. A locked build chain (dmsh_chain.lock) keeps the translation reproducible across machines. A set of analysis scripts studies the tile-based deferred rendering behavior that Apple GPUs use and Tegra GPUs don't.

This is not a shim. It is a graphics backend, written against a console API, targeting a mobile GPU architecture the original game was never built for.

The 30-Second Save That Would Have Killed Your Character

Here is the detail that reveals the entire mindset. The game throttles non-forced saves to once every 30 seconds per container. On a console, that's fine. You're not going anywhere.

On a phone, iOS can kill a suspended app at any moment. A player creates a new character, backgrounds the app to answer a message, and the app is gone. The character might never have reached disk.

A close-up of a hand holding a phone as the screen darkens, with a half-rendered game character mid-save. Behind the phone, one hand lifts away a large hourglass marked 30 while another hand sets down a smaller hourglass marked 3, sand falling in both.
The game's original 30-second save throttle is a console assumption. On iOS, where a suspended app can be killed without warning, the host swaps in a 3-second timer instead.

The host lowers the throttle to 3 seconds and exposes it as DM_SAVE_THROTTLE. That's it. One behavioral change, ten times more frequent saves, a small battery cost, and a character that survives an interrupted session.

No general emulator would make this change. It only makes sense if you understand that the target platform is a phone that can vanish your session without warning.

What It Isn't

Three honest caveats, stated plainly because the project states them plainly.

  1. It is not a general Switch emulator. It targets one game, one build, and one hardware family.
  2. It does not ship game files. Users must own the Switch release and provide the files themselves.
  3. It is not an official Blizzard product, and its legal footing is unusual.

A source of game content. It contains none and distributes none.

d2r-mobile project, Repository statement on game content · jhaizhou-ops/d2r-mobile
ProjectApproachCPU handlingGPU handlingScopeNeeds game files?
d2r-mobileCompatibility layerNone (native ARM64)NVN reimplemented on MetalOne gameYes (Switch release)
Ryujinx / SudachiGeneral Switch emulatorEmulatedEmulatedMany Switch titlesYes
Winlator / MoboxWindows-on-Android stackEmulated (Box64 / Wine)Translated (DXVK, etc.)Many Windows appsYes
DevilutionXEngine reimplementationNativeNativeOriginal DiabloYes (original assets)
Official D2RFirst-party portNativeNativeD2RNo

Read the CPU handling column. d2r-mobile is the only row that says none. That single cell is the whole article: when the instruction set already matches, you don't need to translate the program, you need to translate the world around it.