Skip to content

Browser platform — experimental Warcraft III port

For end-user build, serving, import and run instructions, see Build and run OpenRealm Warcraft III in a browser. This page records implementation stages, ownership and technical limitations. The foundation target below remains a separate smoke test, not the playable engine.

The initial stage added an opt-in Emscripten / SDL2 / WebGL 2 smoke-test target. The repository now also has an experimental statically linked browser engine; follow the run guide to build and launch it. The native build graph in Makefile still produces ELF/dylib/DLL modules and must not be run under emcc as if these were browser-loadable modules.

Browser-only early preparation overlay

The Emscripten engine shows a neutral HTML Preparing map… indicator from CL_BeginLoadingMap() before synchronous map preparation. It stays visible until SCR_UpdateLoadingPlaque() submits a frame with the server-authored loading layout; JavaScript waits two animation frames before hiding it to avoid revealing a stale menu frame. The Warcraft III loading artwork, progress and protocol remain unchanged. Disconnect, shutdown and map-load failure clear the overlay. Only Emscripten compiles this bridge. Tests are under port/web/tests/.

The layout is registered before the client finishes registering the map, so layout presence alone is too early for the visual handoff. The overlay now stays up through re.RegisterMap and is released at the existing 40% loading checkpoint, with the browser's two-frame delay as a compositing safeguard.

Build

Activate an Emscripten SDK (emsdk_env.sh) and run:

make web-warcraft3-foundation
python3 port/web/serve.py

Open http://127.0.0.1:8000/. The server adds the COOP/COEP headers required for Emscripten pthreads and shared memory. Do not open generated HTML from file:// or serve it with a plain static server that omits those headers. A dark canvas and a status of "WebGL 2 probe running" indicate browser-side SDL2, WASM and WebGL 2 initialization. This is not proof that OpenRealm's actual GLES3 renderer is WebGL 2 compatible. No Warcraft game files are used or distributed.

Next integration stages

On-screen browser controls

The browser launcher overlays Esc (send an Escape key press to the game, including during cinematics) and fullscreen (toggle fullscreen for the canvas and controls together) at the upper-right corner of the game view. These are browser DOM controls, not game UI frames, and remain available after the canvas transfers to the rendering pthread. Fullscreen requires a user gesture and may be unavailable under restrictive embedding policies.

  1. Inventory module entry points and shared-library dependencies in the shared, client, server, renderer, games/warcraft-3 and jass modules. Introduce explicit static registration instead of native dynamic loading where required; retain the existing game/client ABI boundaries.
  2. Port the native OpenRealm entry point and frame pump to browser scheduling.
  3. Audit the existing GLES3 renderer and shader paths against WebGL 2.
  4. Provide a WASM filesystem bridge with random-access archive reads and persistent local game data.
  5. The experimental launcher, installer and AudioWorklet playback have since been added. Save persistence and PWA support remain future work.

The source-only platform probe deliberately avoids browser-pthread requirements, proprietary retail archives, new gameplay code and modifications to existing native module ownership.

Phase 2 — engine frame scheduling and module audit

common/main.c now provides OpenRealm_Frame, shared by the native busy loop and the Emscripten callback (emscripten_set_main_loop_arg). It preserves the online → server → client frame order, clock deltas, fast-forward behavior and frame-limit shutdown. Native frame throttling remains native-only. Dedicated stdin polling is excluded from Emscripten.

Run make web-warcraft3-engine-check for a source-only audit of this integration and the existing module entry points. This is not an Emscripten compiler test and does not produce a playable game.

The inspected modules already call GetGameAPI, R_GetAPI and M_GetAPI through direct C symbols. They do not currently require dlopen shims for these entry points. A subsequent patch must link the actual common, server, client, game, renderer, menu, shared, JASS and sheet modules as a single wasm program, resolving portability issues explicitly rather than weakening linker checks. The web-warcraft3-foundation target remains a standalone graphics probe in this phase. Browser file storage and installation are not yet present.

Phase 3 — browser-local game data importer

The ISO 9660/Joliet reader can now be invoked by the installer worker with up to eight ISO files plus direct game archive files in one selection, to selectively copy recognized Warcraft III game MPQs through the existing OPFS generation writer. It requires War3.mpq in the combined selection, rejects duplicate archive names and checks estimated storage quota before staging when the browser provides an estimate. Imports still replace, rather than merge with, the existing installation. Both browser file pickers now accept ISO images alongside standalone archives and show detected files and copy progress (patch 4). Patch 5 extracts the 44-map profile observed on one original RoC Setup.mpq disc, without executing installer code; unknown layouts are reported and skipped.

The platform probe page now exposes a launcher-only file importer. site/installer.js sends File objects to site/install-worker.js, which reads them in 1 MiB chunks and stores them under OPFS openrealm/game-data/wc3/. A versioned installation.json manifest is written last, after copying completes. The worker supports cancellation and removal. OPFS is origin-scoped and no data is sent to a server.

This is a storage foundation, not the game-data engine mount. The importer does not claim that every MPQ is sufficient or valid for a playable Warcraft installation. It supports selected ISO 9660 images, loose MPQ/W3M/W3X files and validated nested Maps/ outputs from recognized original RoC Setup.mpq archives; it does not offer general-purpose installer extraction, CASC or ZIP support. The experimental engine uses a WasmFS OPFS mount to expose the selected archive directory to the native MPQ reader; this needs browser runtime validation. Interrupted commits can leave orphan files, and replacing an existing installation is not a fully atomic transaction; the engine must not trust file presence without validating a manifest and actual archive contents. Avoid importing over an active game session.

The existing experimental WasmFS mount uses seekable reads; end-to-end browser verification and complete installation validation remain necessary. The probe remains only a probe.

Run make web-warcraft3-engine with an activated Emscripten SDK. This new target invokes port/web/build-engine.py, which compiles the real native module source groups into separate WASM objects and attempts a single link. The existing C entry points (GetGameAPI, R_GetAPI, M_GetAPI) are retained. The normal native Makefile outputs are not replaced.

This is an experimental compile/link target, not a claim of a successful build or a working game. The first compiler diagnostics should be resolved in follow-up browser-port patches. In particular, browser networking, audio, OpenGL ES/WebGL API compatibility, filesystem persistence, generated font header production and startup asset mounts require additional integration.

The output build/web/engine/ includes the launcher helper scripts. The page validates its active OPFS installation before loading Emscripten's generated script, then passes the active archive directory to native startup. Existing MPQ/game file loading and full runtime compatibility still require browser verification. Do not upload or ship retail data with this target.

Phase 10 — engine build source preflight

The engine build script generates per-module unity compilation units beneath build/web/engine. Quoted include paths are relative to that generated file, not to the repository working directory. The generator therefore writes relative paths back to each original source file.

Run make web-warcraft3-preflight to validate the source groups and generate unity inputs without invoking Emscripten. Then run make web-warcraft3-engine with an activated SDK and capture the first compiler or linker failure. The preflight checks file existence and include layout only; it cannot prove that the browser code links, renders or boots. Do not redistribute game data.

Browser build compatibility (wasm32)

The experimental engine build excludes g_save.c and links browser-only web_save_unsupported.c functions returning failure. WC3 save/load is therefore unsupported in this build: g_save.c encodes 64 bits of relocation data into a pointer-sized slot, and wasm32 pointer slots are only 32 bits wide. The native save format and its assertions remain unchanged. A browser save implementation must use explicitly sized on-disk relocation records and regression coverage before enabling this feature.

Browser builds also exclude BZ_CLIENT_WORLD from the combined game/engine compilation flags; server/game routing owns the WC3 pathing implementation, while client-world helpers must not be compiled into the game unity module. -sWASMFS=1 is a final-link setting, not a compile-only setting.

Static WASM module symbol ownership

The native libgame and libmenu each compile their own stb_fdf.h implementation, frames registry and host callbacks. These must remain distinct in the browser's single linked executable. The browser-only web_fdf_namespace.h provides module-scoped link names when compiling game and menu; GetGameAPI and M_GetAPI are not renamed.

games/warcraft-3/common/campaign_progress.c is pure shared functionality: it is now compiled exactly once as wc3_common.o, rather than being repeated in both game and menu objects. world_w3.c remains owned by the engine object. These decisions do not change native shared libraries.

WASM FDF parser symbol ownership

The Warcraft III game and menu are separately linked native modules and independently instantiate stb_fdf.h with STB_FDF_IMPLEMENTATION and STB_FDF_GLOBALS. The parser uses module-specific UI_* callbacks and separate frames and strings registries. Both instances must remain independent in the static WASM binary. port/web/src/web_fdf_namespace.h renames the parser functions, enum tables and state together with the UI helpers for the game/menu unity objects. It is deliberately not applied to the universal engine or native builds. Sharing one complete FDF parser between game and menu would incorrectly bind its state and host callbacks to one module.

Text batching and the browser thread stack

A symbolized main-menu frame crash reached process_text through R_DrawText and R_GetTextSize after the renderer and UI had initialized. The previous textBatch_t included 1,020 vertices as a local variable even while measuring text (when no drawing or batching occurs). Nested alignment measurement therefore placed unnecessarily large vertex arrays on the limited WebAssembly engine pthread stack. The renderer now allocates the batch through ri.MemAlloc only for drawing, flushes it, and frees it before returning. Measurement still uses the same positioning and glyph-advance logic without allocating a batch. Native rendering and text output should remain unchanged.

Renderer on the browser engine worker

The browser build uses PROXY_TO_PTHREAD for OPFS, but SDL creates its GL context through EGL. Emscripten proxies EGL context creation to the main thread, so the HTML canvas must stay there. Browser builds use the OFFSCREEN_FRAMEBUFFER rendering path instead of transferring the canvas to the worker. Emscripten owns frame timing; native swap-interval behavior is unchanged. COOP/COEP cross-origin isolation is required for pthreads.

SDL/EGL context ownership on browser pthreads

The engine uses PROXY_TO_PTHREAD for WasmFS OPFS. SDL creates the renderer context through EGL, and Emscripten's EGL context operations are proxied to the browser main thread. Do not combine this SDL/EGL path with transferring #canvas to the pthread: the main-thread EGL implementation then attempts getContext() on an already-transferred HTML canvas and throws InvalidStateError. The browser link deliberately uses -sOFFSCREEN_FRAMEBUFFER=1 without OFFSCREENCANVAS_SUPPORT or OFFSCREENCANVASES_TO_PTHREAD, leaving the canvas on the main thread while allowing the worker to submit WebGL operations through Emscripten's proxy. COOP/COEP isolation is still necessary for pthreads. This resolves the canvas-ownership conflict in configuration; WebGL rendering remains to be validated on the target Emscripten SDK and browser.

Direct WebGL context for browser renderer

The browser renderer uses Emscripten's HTML5 WebGL API directly instead of creating and swapping a context through SDL/EGL on the proxied game pthread. SDL still owns the window and input. WebGL 2 uses EMSCRIPTEN_WEBGL_CONTEXT_PROXY_ALWAYS and explicit swap control; the -sOFFSCREEN_FRAMEBUFFER=1 link setting supplies the presentation buffer. Native rendering remains unchanged. This is an experimental interoperability fix requiring runtime verification of GL initialization, frame presentation, resize and input handling.

Worker-owned WebGL context (follow-up)

Direct WebGL context creation in the browser renderer still deadlocked with EMSCRIPTEN_WEBGL_CONTEXT_PROXY_ALWAYS after shader loading: a synchronous GL proxy on the engine pthread can enter a recursive pthread mutex lock. The browser build now transfers #canvas to the engine pthread with OFFSCREENCANVAS_SUPPORT/OFFSCREENCANVASES_TO_PTHREAD, and the renderer requests EMSCRIPTEN_WEBGL_CONTEXT_PROXY_DISALLOW. The renderer continues to create the context directly via Emscripten, not via SDL/EGL; this distinction avoids the earlier transferred-canvas EGL error. No native build changes.

Requires a cross-origin-isolated page and OffscreenCanvas support. Test worker context creation, shader/resource initialization, frame presentation, resize, input and shutdown in the browser; a successful C link alone is insufficient.

Diagnosing browser worker traps

The normal optimized build omits useful names from WASM stack traces. When startup reaches Loading shaders succeeded but a worker fails with memory access out of bounds, operation does not support unaligned accesses, or an Emscripten pthread/proxy-queue assertion, do not assume the renderer or OPFS is the original cause from the last successful log line alone. A secondary worker failure can follow a primary engine-thread failure.

Build a separate, unoptimized DWARF-symbolized version with:

source ~/emsdk/emsdk_env.sh
python3 port/web/build-engine.py --debug

The result is in build/web/engine-debug/; it leaves the standard build/web/engine/ untouched. Serve the debug directory using the same cross-origin isolation headers as the normal pthread build. In Chrome DevTools (Sources), use WebAssembly DWARF debugging to inspect the first failing thread, its C call stack and source line. Include the failing assertion and first symbolic stack in the issue report. If needed, use the DWARF-capable llvm-addr2line shipped with the Emscripten SDK against the exact matching index.wasm and the index.wasm:0x... offsets; offsets from optimized builds cannot be looked up in a different debug binary.

Keep pthread checks enabled. Do not mask WebAssembly traps by relaxing alignment, disabling mutex assertions or suppressing errors in proxy queues.

Procedural textures and the browser thread stack

The symbolized startup trace identified a stack overflow in R_MakeLoadingIndicatorTexture immediately after shader initialization. Its 128×128 RGBA scratch image previously occupied 64 KiB of the engine thread stack. The selection-circle generator had the same allocation shape, and the blob-shadow generator used another stack-allocated RGBA buffer. These buffers now use the renderer's existing ri.MemAlloc/ri.MemFree lifetime around R_LoadTextureMipLevel, matching normal texture upload ownership. The pixels, texture formats, and non-browser rendering behavior are unchanged.

The change avoids needing to increase the default pthread stack solely for these temporary images. Recheck browser startup after shader loading; other large local buffers might still need their own targeted fixes.

Earlier browser sound initialization limitation (superseded)

The browser executable uses PROXY_TO_PTHREAD, placing engine startup on a worker. SDL 2's Emscripten audio device setup currently accesses JavaScript audioContext state that is unavailable in that worker and throws before client initialization completes. For now S_Init declines to initialize audio on Emscripten and preserves the normal uninitialized-sound guards. At that stage browser playback was silent. The later AudioWorklet integration below provides browser playback; this section records the earlier constraint rather than current behavior. The native SDL audio path remains unchanged.

The current browser audio adapter uses a shared sample ring and AudioWorklet; do not call the standard SDL Emscripten audio device opener from the engine worker. Playback still needs checks across stalls, suspend/resume and device sample rates.

Bundled Warcraft III defaults

The browser engine link embeds the open-source games/warcraft-3/share/ folder under the virtual filesystem path /share/warcraft-3/. In particular, /share/warcraft-3/config.cfg must be present when Com_Init reads the shipped input bindings (MOUSE1 selection, MOUSE2 smart orders, etc.). build-engine.py --check checks that the source config exists; the normal emcc link bundles it using --embed-file. Rebuild the browser engine after changing shared defaults. These files are not copied to OPFS or uploaded by users: the OPFS importer continues to manage Warcraft III archives separately.

This built-in configuration is the read-only starting point; user config loading and persistence are separate concerns. If HUD clicks work while world selection/drag/orders do not, verify this bundle is included before investigating mouse hit-testing.

JASS callback stack usage

JASS filter, player-filter, AI integer callback, and trigger-condition evaluation copy a full VM state to isolate callback context. These scratch VMs live on the engine heap, not its limited WebAssembly pthread stack. In particular, GroupEnumUnitsOfPlayer may evaluate many filtered units during campaign map initialization. Allocation failures reject the corresponding filter or condition without modifying the parent VM state.

Browser audio (AudioWorklet)

On Emscripten the existing 44.1 kHz stereo S16 mixer feeds a dedicated producer pthread under a sound mutex (not SDL's browser audio device). The producer publishes samples to a shared Wasm memory ring; an AudioWorklet consumes them on the browser's real-time audio thread and resamples to the AudioContext output rate. The first user gesture unlocks AudioContext. The audio worklet script is copied alongside index.js by the browser build. The native SDL backend is unchanged. Verify playback for UI sounds, positional unit acknowledgements, dialogue and music; also verify tab suspend/resume, loading stalls and output-device sample rates. Audio may underrun during stalls.

The patch 4 UI adds .iso to the browser file input and reports ISO scanning, validated MPQs with their source disc and size, copy progress, and final manifest commit. Patch 5 extracts known installer-only map contents from selected original RoC Setup.mpq layouts, but does not support incremental RoC/TFT imports: select both discs in one operation. Patch 6 originally rejected the listed September 2002 demo disc by its War3Demo.ico and SCDemo.exe files. The later demo import patch now accepts that disc separately, imports its base MPQ and RetailMaps/ maps, and persists a clearly labelled demo edition across visits. Patch 6 can also recover named whole expansion MPQ members from a TFT installer. The May 2003 TFT disc's internal Setup.mpq layout has not been verified against the actual retail bytes. Patch 7 scans anonymous uncompressed installer blocks for nested MPQs, classifies known base and expansion roles by internal file-path hash evidence, and reports unknown archives without guessing filenames. An incomplete TFT selection still fails without replacing the previous installation. See disc import format limitations.

The web-warcraft3-foundation make rule copies installer.js, install-worker.js, iso9660.js and setup-mpq.js into the probe output next to index.html; the experimental engine build also ships these assets. See browser ISO reader for the worker event contract and headless UI tests.

ISO installation hardening

The browser installer now validates MPQ declared sizes and hash/block table bounds before storing files; the launcher performs equivalent checks before starting the engine. Web Locks prevent concurrent installation commits across tabs, and running game tabs hold leases so cleanup cannot remove their OPFS archive generation. See browser filesystem for lock names, generation retention, manifest completeness and remaining limitations, and ISO import details for real-disc verification gaps.

Experimental static hosting on GitHub Pages

See GitHub Pages deployment and Service Worker example. The deployment below adds browser game hosting to the existing Pages artifact; it does not add automatic offline caching or a documentation navigation link.

GitHub Pages deployment (browser engine only)

The .github/workflows/docs.yml workflow also builds the Emscripten engine and copies runtime assets only to site/play/ before uploading the Pages artifact. port/web/publish-pages.py excludes generated unity C and object files. The installed Warcraft III MPQs are never committed or uploaded; the browser installer continues to use local OPFS. No documentation navigation or Play link is added.

GitHub Pages does not provide the required COOP/COEP headers. The game registers coi-serviceworker.js at /openwarcraft3/play/ with that exact scope; its fetch handler adds the required isolation headers only to GET responses within the game directory. It does not cache assets or intercept documentation requests. On first visit, pages-isolation.js waits for the worker to control the page and reloads once so the navigation receives those headers. Engine startup waits for isolation and requires crossOriginIsolated before starting WASM pthreads. For other hosting paths, the existing server-provided isolation headers remain required.

The automated checks cover synthetic bootstrap, fetch scoping, packaging and wiring. They do not verify actual browser Service Worker activation or CI builds with the pinned Emscripten SDK; those require deployment verification.