Some people have asked me how i did the Umineko web assembly, some asked me to write about how i achieved it. I said i would when it was functinally complete, so here is the deep technical dive. Please enjoy
This is he full PS3/Umineko Project build running as WebAssembly in a tab. Here is how I did it and why it was painful.
The game runs on ONScripter-RU, a C 14 visual novel engine originally built for Windows, macOS, Linux, iOS, and Android. It was never designed for the browser. It depends on multithreading, synchronous file I/O, GPU rendering, and over a dozen native C/C libraries.
The codebase is, to put it lightly, is a absolute cluster-fuck Barely any documentation, deeply coupled components, and architectural decisions that seem designed to make porting as difficult as possible. I had to modify 18 source files across the engine just for the Emscripten target, all gated behind # ifdef __EMSCRIPTEN__ so the native builds still work.
The tool that makes this possible is Emscripten, a compiler toolchain that takes C/C and outputs WebAssembly. It provides drop-in replacements for gcc/g (emcc/em ) and ships with browser-compatible ports of common libraries like SDL2, FreeType, zlib, libpng, and libvorbis.
But not everything the engine needs has a port. FFmpeg, SDL2_gpu, libass, HarfBuzz, and FriBidi all had to be cross-compiled from source using emconfigure and emmake. Each one came with its own set of build issues.
The first major problem was threading. ONScripter-RU uses threads everywhere. Video decoding has a demuxer thread, a video decoder thread, and an audio decoder thread all communicating through semaphore-gated packet queues.
Subtitle rendering runs on its own background thread. File loading happens off the main thread. Emscripten technically supports pthreads via SharedArrayBuffer, but that requires specific COOP/COEP headers, has browser compatibility issues, and the engine's threading model is deeply embedded in its architecture in ways that do not translate cleanly.
I went the other route, single-threaded synchronous execution using Emscripten's ASYNCIFY transform. ASYNCIFY rewrites the compiled code so that synchronous C functions can yield to the browser's event loop and resume later.
The engine's main loop calls emscripten_sleep() to yield, so the browser tab does not freeze. But every subsystem that relied on threads had to be rewritten.
For video playback, I wrote pumpSynchronous() - a function that replaces the entire multi-threaded decode pipeline. Instead of three threads communicating through queues, a single function call reads packets from the container with av_read_frame(), routes them to the appropriate decoder, converts video frames from YUV to RGB, and pushes finished frames into a bounded queue.
The queue is capped at 6 frames because at 1080p RGB24, each frame is roughly 6MB, and WebAssembly has a 2GB memory ceiling. Overflow means OOM and a dead tab.
For file I/O, the challenge was different. Umineko has over 101,000 game files. Backgrounds, sprites, character portraits, audio tracks, video files, subtitle files. Loading all of them at startup would mean downloading gigabytes before the game even starts. The solution was a lazy loading virtual filesystem. At container startup, a script walks the game directory and generates a manifest.json listing every file.
When the browser loads the page, it creates the full directory tree in Emscripten's virtual filesystem and writes 0-byte stubs for every asset. Critical files like the game scripts, fonts, and configuration are fetched eagerly before main() runs. Everything else stays as a stub.
When the engine opens a file, a patched FileIO::openFile() checks if it is a 0-byte stub. If it is, it calls an EM_ASYNC_JS function that does an await fetch() to download the real file over HTTP, writes the contents into the virtual filesystem, and then returns the file handle to the C code. From the engine's perspective, it called fopen() and got a file.
It has no idea an HTTP request happened in between. The game starts in seconds, downloading only about 2MB of essential data, with everything else streaming in on demand as you play.
The graphics pipeline was more straightforward but still required work. ONScripter-RU uses SDL2_gpu with a GLES2 backend. On native platforms this talks to OpenGL ES. In Emscripten, SDL2 creates a canvas element and binds a WebGL context. The engine renders everything as GPU textures via GLSL shaders. One thing that tripped me up was WebGL's default behaviour of clearing the drawing buffer between frames.
The engine uses a partial-redraw dirty rect system that assumes the previous frame's pixels are still there. I had to enable preserveDrawingBuffer on the WebGL context to stop it from wiping the canvas every frame.
Audio was one of the easier pieces. SDL2_mixer feeds into Emscripten's SDL2 audio backend, which routes to the Web Audio API. BGM, sound effects, and voice lines all work through this path. The output is 48kHz 32-bit float stereo. The browser handles the rest.
The subtitle system was one of the more frustrating challenges. Umineko's video cutscenes have song lyrics displayed as timed .ass subtitles (Italian original English translation). This requires a full text shaping and rendering stack: libass for parsing and rendering ASS subtitles, HarfBuzz for complex text shaping, FriBidi for Unicode bidirectional text, and FreeType for font rasterisation.
libass and FriBidi compiled without too much trouble once I patched out iconv detection in libass's configure script (Emscripten does not have iconv). HarfBuzz 2.5.2 was a different story. It uses # pragma GCC diagnostic error in its main header file (hb.hh) to promote specific compiler warnings to hard errors. Clang 18, which ships with Emscripten 3.1.51, introduced a new warning called -Wcast-function-type-strict. HarfBuzz's own code triggers this warning in several places, and the pragma turns it into an error.
The fix is not in any Makefile or configure script. It is a source-level pragma that overrides anything you pass on the command line. I went through about 10 different approaches before finding that HarfBuzz has an internal kill switch macro: -DHB_NO_PRAGMA_GCC_DIAGNOSTIC_ERROR. Adding that to the build flags disables all the diagnostic error pragmas and lets it compile cleanly.
Once the subtitle libraries were built, the rendering itself needed work. The native engine decodes subtitles on a background thread that feeds frames into a queue. On Emscripten, that thread never starts because there are no threads. I had to add subtitle blending directly into the synchronous video decode path.
After each video frame is decoded and colour-converted, but before it enters the frame queue, libass renders the subtitle overlay for that frame's timestamp and blends it onto the pixel data. The subtitle rendering that normally happens across two threads now happens inline in a single function.
The save system uses Emscripten's IDBFS driver. At startup, /home/web_user/.onscripter is mounted as an IDBFS volume and synced from IndexedDB. The engine reads and writes save files using normal C file operations, completely unaware that the underlying storage is a browser database. A periodic sync runs every 5 seconds, and a beforeunload handler flushes on tab close. Save data survives page refreshes, tab closures, and browser restarts.
The Docker build puts this all together. A multi-stage Dockerfile starts from emscripten/emsdk:3.1.51, cross-compiles all six native libraries (SDL2_gpu, FFmpeg 3.3.9, FriBidi 1.0.5, HarfBuzz 2.5.2, libass 0.14.0), clones the forked engine, embeds GLSL shaders into a C source file, compiles 60 source files into a single WASM binary, and packages everything into an nginx:alpine image that serves the game. The final output is three files: an HTML shell, a JavaScript loader, and the WASM binary.
The game is fully playable. Script execution, text rendering, image display, visual effects, video cutscenes with subtitles, audio, save/load, settings; everything works. The entire thing runs client-side in a browser tab.