DEV Community

Cover image for I ran a 2005 Windows game in a browser tab: static recompilation to WebAssembly and WebGPU
Md. Mobin
Md. Mobin Subscriber

Posted on

I ran a 2005 Windows game in a browser tab: static recompilation to WebAssembly and WebGPU

Greetings, fellow coders, and welcome back! 🚀 Today's adventure is a little unusual: I got a 2005 Windows game running inside a browser tab. No emulator, no cloud streaming, no "just use GeForce Now". The game is Prince of Persia: The Two Thrones, and yes, the Prince can run along the ramparts at 1080p, in Chrome, on my laptop. Buckle up, because the road there was paved with lost wake-ups, black skies and one very guilty C cast. 🗡️

In this post, we'll walk through how the whole thing works: how an old x86 .exe becomes C, then WebAssembly, how Direct3D 9 ends up as WebGPU, and the bugs that made me question my career choices. So grab your favorite coding beverage, get comfy, and let's go.

Disclaimer. This is an independent, non-commercial technical experiment, not affiliated with or endorsed by Ubisoft. Prince of Persia and The Two Thrones belong to Ubisoft. The repository contains no game files, no assets and no code taken from the game's executable or data. It works only with a genuine copy you own, which stays on your computer, and it is not for piracy or for distributing the game.

Table of Contents

Introduction

I wanted to know if a classic game could live in a browser without an emulator. The answer, for this one, is yes. It boots, shows English menus, plays the intro cutscene, loads level 1 and responds to the keyboard, with sound.

Emulation vs static recompilation

An emulator reads x86 instructions at run time and pretends to be a CPU. A static recompiler does the translation once, ahead of time, and turns the program into code in another language. Here the target is C, which then compiles to a native build (great for debugging) and, with Emscripten, to WebAssembly with threads for the browser.

Why bother? Both builds come from the same generated C, so I could chase most bugs on my desktop with real debuggers first, then ship the very same logic to Chrome.

The pipeline

POP3.EXE (x86)
   │  Ghidra: find ~29,000 functions
   ▼
Python translator ── 2.8M x86 instructions → C (registers, flags, x87, MMX/SSE)
   │
   ▼
clang / Emscripten ── native build  +  WebAssembly build
   │
   ▼
Windows layer in C ── kernel32, user32, DirectInput, DirectSound, Bink stub, Direct3D 9
   │
   ▼
Browser ── WebGPU renderer + Web Audio (AudioWorklet) + files from OPFS
Enter fullscreen mode Exit fullscreen mode

The game never knows it left Windows. Every Windows API it imports is a host function I wrote: files, threads and events, keyboard and pad, a software mixer for DirectSound, and a Direct3D 9 device.

Direct3D 9 on WebGPU

Direct3D calls are not executed straight away. The game thread records them as commands, and an executor replays them as WebGPU passes. The game's vertex shaders (shader model 1.x) run on the CPU, four vertices at a time with SIMD vector types, and across cores in the native build. Pixel work goes to the GPU.

Bugs worth telling

1. The lost wake-up. Level loading froze at a black screen. The game's audio streaming uses a small pool of I/O worker threads, and a job queue could lose a wake-up, but only if WaitForMultipleObjects polled slowly, which my first version did. The fix was a real condition variable that every SetEvent signals. Lesson: "works on real Windows" can hide a race that a slow wait exposes.

2. The black sky and sea. Direct3D and WebGPU cull modes map straight through, and I had swapped them "just to be safe". That culled exactly the faces the sky, sea and ground are drawn with. Removing my cleverness brought the world back. 🌅

3. Crackling audio, traced to one cast. The sound had bursts of full-scale noise. I added a switch that dumps the mixer output, found runs of saturated samples, and walked backwards through the game's code until I reached its MMX mixer. It sums sources as 32-bit values and packs them with packssdw. My helper for reading a signed 32-bit lane looked like this:

return sgn ? (int32_t)v : v;   // v is uint32_t
Enter fullscreen mode Exit fullscreen mode

In C, that expression has type uint32_t, so the sign was silently lost. Negative samples became huge positives, and the pack saturated them. Casting to a 64-bit signed type first fixed it. After the fix, the share of one-second windows with audible discontinuities went from 44 of 82 to 1 of 82.

4. 800×600 is a default, not a limit. To get 1080p I patched the game's default video size (an immediate pair of stores in a few functions) in the generated code, and taught the renderer to letterbox to the game's aspect ratio. The game was happy to draw wider than it was shipped.

Not downloading 1.5 GB

The game folder is 1.5 GB. The page copies only the small files. The big archives become sparse files in the browser's private storage (OPFS), and a small layer in the Windows host fetches the 1 MB chunks the game actually reads with HTTP range requests. Reaching level 1 takes about 100 MB. The files always come from your own disk or your own server.

Where it stands

  • ✅ Boots, English menus, intro cutscene, level 1 on the ship deck, keyboard control, sound
  • ✅ 1080p, 16:9
  • ⚠️ About 20–30 fps on a laptop; paced at 30 fps, with dips in heavy scenes because vertex shading is on the CPU
  • ❌ Not done or not verified: combat, gamepad, the real Bink movies (stubbed), constant 30 fps everywhere

Demo

Takeaways

  • Native and Wasm builds from the same C means most debugging happens on the desktop.
  • Record your outputs (audio samples, draw lists). "It sounds wrong" becomes data you can analyse.
  • Check the type of ternary expressions in C. Seriously.

Thanks to the Ghidra team, the Emscripten and WebGPU communities, and everyone who documents old file formats and APIs. And of course to Ubisoft for a game worth this much effort. 🙏

Source code

Github Repo. You will need your own legitimately obtained copy of the game to try it.

Follow me on

Top comments (0)