Skip to content

Commit e43b5ec

Browse files
committed
build(web): Add Emscripten WebAssembly target and CI workflow
Adds a web-generalsmd-sdl3-bgfx preset that cross-compiles GeneralsMD to WebAssembly with bgfx on WebGL 2, plus a GitHub Actions job that builds it in the Emscripten SDK container and uploads the servable page as an artifact. Build plumbing: - render-backend/bgfx: accept an "essl" renderer (OpenGL ES 3.0 / WebGL 2) and compile the shader set to the 300_es profile. - bgfx: a cross build cannot run the shaderc it would produce, so build a host shaderc from the already-fetched bgfx tree, or reuse one via GGC_SHADERC_EXE. - compilers: memory growth, pthreads and -fexceptions for every wasm target, since the dependencies need them too. - sdl3/openal/gamespy: static SDL3, Emscripten's built-in OpenAL, and the Linux code paths for the GamespySDK. - webfont: a browser has no system fonts, so fetch one upstream (pinned) and embed it, and open it directly since there is no Fontconfig either. - Main: emit index.html/.js/.wasm and link with Asyncify plus a pre-spawned worker pool. - scripts/build/web: build the minimal FFmpeg the audio and video paths need, which no Emscripten port provides, mirroring the Linux script. Engine changes, all under __EMSCRIPTEN__: - GameMain runs one iteration per animation frame instead of blocking in GameEngine::execute, which a browser tab cannot host. - timeGetTime uses CLOCK_MONOTONIC; musl declares CLOCK_BOOTTIME but does not implement it, which left every timeGetTime-gated loop stuck at 0. - bgfx takes the canvas selector as its window handle, and skips the single-threaded renderFrame call it asserts on. - FramePacer runs uncapped because requestAnimationFrame already paces frames. - GameLOD keeps the shell map on; its CPU benchmark cannot run in a browser. - SDL3Main mounts IndexedDB so options and saves survive a reload.
1 parent 01c95ff commit e43b5ec

23 files changed

Lines changed: 1114 additions & 19 deletions

File tree

.github/workflows/web-build.yml

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
name: Web (WebAssembly) Build
2+
3+
run-name: web build from ${{ inputs.ref || github.ref_name }}
4+
5+
on:
6+
push:
7+
branches:
8+
- bobtista/topic/trunk
9+
# Working branches for build-infrastructure changes, so a push iterates on CI
10+
# without opening a pull request first.
11+
- 'ci/**'
12+
paths:
13+
- '.github/workflows/web-build.yml'
14+
- 'CMakePresets.json'
15+
- 'CMakeLists.txt'
16+
- 'cmake/**'
17+
- 'scripts/build/web/**'
18+
- 'Core/**'
19+
- 'Dependencies/**'
20+
- 'GeneralsMD/**'
21+
pull_request:
22+
paths:
23+
- '.github/workflows/web-build.yml'
24+
- 'CMakePresets.json'
25+
- 'CMakeLists.txt'
26+
- 'cmake/**'
27+
- 'scripts/build/web/**'
28+
- 'Core/**'
29+
- 'Dependencies/**'
30+
- 'GeneralsMD/**'
31+
workflow_dispatch:
32+
inputs:
33+
ref:
34+
description: "Branch, tag, or SHA to build"
35+
required: false
36+
type: string
37+
38+
concurrency:
39+
group: web-build-${{ github.ref }}
40+
cancel-in-progress: true
41+
42+
jobs:
43+
web:
44+
name: web-generalsmd-sdl3-bgfx
45+
runs-on: ubuntu-latest
46+
timeout-minutes: 120
47+
# The Emscripten SDK image carries emcc, cmake, make, node and a host g++ (needed
48+
# for the host shaderc the cross build compiles its shaders with).
49+
container: emscripten/emsdk:6.0.5
50+
steps:
51+
- name: Checkout Code
52+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
53+
with:
54+
ref: ${{ inputs.ref || github.ref_name }}
55+
56+
- name: Mark Workspace Safe For Git
57+
run: git config --global --add safe.directory "${GITHUB_WORKSPACE}"
58+
59+
- name: Cache FFmpeg For wasm
60+
id: cache-ffmpeg-wasm
61+
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
62+
with:
63+
path: ${{ github.workspace }}/build/ffmpeg-wasm
64+
key: ffmpeg-wasm-${{ hashFiles('scripts/build/web/build-ffmpeg-wasm.sh') }}
65+
66+
- name: Build FFmpeg For wasm
67+
if: steps.cache-ffmpeg-wasm.outputs.cache-hit != 'true'
68+
run: |
69+
NPROC="$(nproc)" bash scripts/build/web/build-ffmpeg-wasm.sh "${GITHUB_WORKSPACE}/build/ffmpeg-wasm"
70+
71+
- name: Cache CMake Dependencies
72+
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
73+
with:
74+
path: |
75+
build/web-generalsmd-sdl3-bgfx/_deps
76+
build/web-generalsmd-sdl3-bgfx/host-shaderc
77+
key: cmake-deps-web-generalsmd-sdl3-bgfx-${{ hashFiles('CMakePresets.json','cmake/**/*.cmake','**/CMakeLists.txt') }}
78+
restore-keys: |
79+
cmake-deps-web-generalsmd-sdl3-bgfx-
80+
81+
- name: Configure with CMake
82+
run: |
83+
FFMPEG_PREFIX="${GITHUB_WORKSPACE}/build/ffmpeg-wasm"
84+
cmake --preset web-generalsmd-sdl3-bgfx \
85+
-DFFMPEG_INCLUDE_DIR="${FFMPEG_PREFIX}/include" \
86+
-DFFMPEG_AVCODEC_LIBRARY="${FFMPEG_PREFIX}/lib/libavcodec.a" \
87+
-DFFMPEG_AVFORMAT_LIBRARY="${FFMPEG_PREFIX}/lib/libavformat.a" \
88+
-DFFMPEG_AVUTIL_LIBRARY="${FFMPEG_PREFIX}/lib/libavutil.a" \
89+
-DFFMPEG_SWSCALE_LIBRARY="${FFMPEG_PREFIX}/lib/libswscale.a"
90+
91+
- name: Build GeneralsMD
92+
run: |
93+
cmake --build build/web-generalsmd-sdl3-bgfx --config Release --target z_generals -j"$(nproc)"
94+
95+
- name: Stage
96+
run: |
97+
STAGE="${GITHUB_WORKSPACE}/stage-web/GeneralsZH-web"
98+
mkdir -p "${STAGE}"
99+
# Ninja Multi-Config appends the configuration to the output directory.
100+
WEB_DIR="build/web-generalsmd-sdl3-bgfx/web"
101+
if [ -d "${WEB_DIR}/Release" ]; then
102+
WEB_DIR="${WEB_DIR}/Release"
103+
fi
104+
cp -r "${WEB_DIR}"/. "${STAGE}/"
105+
cp scripts/build/web/serve.py "${STAGE}/serve.py"
106+
{
107+
echo "Command & Conquer Generals Zero Hour (GeneralsMD) WebAssembly build"
108+
echo "preset: web-generalsmd-sdl3-bgfx (Release, wasm32)"
109+
echo "ref: ${{ inputs.ref || github.ref_name }}"
110+
echo "commit: $(git rev-parse HEAD)"
111+
echo "built: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
112+
echo
113+
echo "Serve this directory over HTTP with cross-origin isolation enabled"
114+
echo "(COOP: same-origin, COEP: require-corp) and point serve.py at a copy"
115+
echo "of the game data you own. The page will not boot from file://."
116+
} > "${STAGE}/BUILD_INFO.txt"
117+
118+
- name: Zip
119+
run: |
120+
cd "${GITHUB_WORKSPACE}/stage-web/GeneralsZH-web"
121+
zip -ry "${GITHUB_WORKSPACE}/GeneralsZH-web.zip" .
122+
123+
- name: Upload Artifact
124+
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
125+
with:
126+
name: GeneralsZH-web
127+
path: GeneralsZH-web.zip
128+
retention-days: 30
129+
if-no-files-found: error

CMakeLists.txt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,12 @@ if(SAGE_USE_OPENAL)
8383
include(cmake/openal.cmake)
8484
endif()
8585

86+
# TheSuperHackers @build githubawn 29/07/2026 The web build has no system fonts to
87+
# render text with, so one is fetched and embedded in the module.
88+
if(EMSCRIPTEN)
89+
include(cmake/webfont.cmake)
90+
endif()
91+
8692
# Rendering backend selection. Must come after config.cmake (which sets
8793
# IS_VS6_BUILD) and before Core is added (which references the backend flag).
8894
include(cmake/render-backend.cmake)

CMakePresets.json

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,30 @@
230230
"GGC_BGFX_RENDERER": "vulkan"
231231
}
232232
},
233+
{
234+
"name": "web-generalsmd-sdl3-bgfx",
235+
"inherits": "default",
236+
"hidden": false,
237+
"displayName": "Emscripten Web GeneralsMD SDL3 bgfx Release",
238+
"binaryDir": "${sourceDir}/build/${presetName}",
239+
"cacheVariables": {
240+
"CMAKE_BUILD_TYPE": "Release",
241+
"CMAKE_SYSTEM_NAME": "Emscripten",
242+
"CMAKE_TOOLCHAIN_FILE": "$env{EMSDK}/upstream/emscripten/cmake/Modules/Platform/Emscripten.cmake",
243+
"RTS_BUILD_GENERALS": "OFF",
244+
"RTS_BUILD_GENERALS_TOOLS": "OFF",
245+
"RTS_BUILD_ZEROHOUR": "ON",
246+
"RTS_BUILD_ZEROHOUR_TOOLS": "OFF",
247+
"RTS_BUILD_CORE_TOOLS": "OFF",
248+
"RTS_BUILD_OPTION_FFMPEG": "ON",
249+
"RTS_CRASHDUMP_ENABLE": "OFF",
250+
"SAGE_USE_SDL3": "ON",
251+
"SAGE_USE_OPENAL": "ON",
252+
"RTS_GAMEMEMORY_ENABLE": "OFF",
253+
"GGC_RENDER_BACKEND": "bgfx",
254+
"GGC_BGFX_RENDERER": "essl"
255+
}
256+
},
233257
{
234258
"name": "win32-generalsmd-sdl3-bgfx",
235259
"inherits": "win32-bgfx",
@@ -362,6 +386,16 @@
362386
"z_generals"
363387
]
364388
},
389+
{
390+
"name": "web-generalsmd-sdl3-bgfx",
391+
"configurePreset": "web-generalsmd-sdl3-bgfx",
392+
"displayName": "Build Emscripten Web GeneralsMD SDL3 bgfx Release",
393+
"description": "Build WebAssembly Release (bgfx + SDL3 + OpenAL + FFmpeg, GeneralsMD only)",
394+
"configuration": "Release",
395+
"targets": [
396+
"z_generals"
397+
]
398+
},
365399
{
366400
"name": "win64",
367401
"configurePreset": "win64",

Core/GameEngine/Source/Common/FramePacer.cpp

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,17 @@ void FramePacer::update()
6363
{
6464
// TheSuperHackers @bugfix xezon 05/08/2025 Re-implements the frame rate limiter
6565
// with higher resolution counters to cap the frame rate more accurately to the desired limit.
66+
#if defined(__EMSCRIPTEN__)
67+
// TheSuperHackers @performance githubawn 29/07/2026 The web main loop is driven by
68+
// requestAnimationFrame, which already paces to the display refresh. Letting the
69+
// busy-wait limiter run on top of that is actively harmful: the spin overruns the
70+
// vsync slot so rAF only fires every other refresh (pinning the game near 30fps),
71+
// and it burns the one main thread the browser needs back. Run uncapped and just
72+
// measure the real frame time for logic time scaling.
73+
const UnsignedInt maxFps = RenderFpsPreset::UncappedFpsValue;
74+
#else
6675
const UnsignedInt maxFps = getActualFramesPerSecondLimit();// allowFpsLimit ? getFramesPerSecondLimit() : RenderFpsPreset::UncappedFpsValue;
76+
#endif
6777
m_updateTime = m_frameRateLimit.wait(maxFps);
6878
updatePerformanceLog();
6979
}

Core/Libraries/Source/WWVegas/WW3D2/BgfxBackend.cpp

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,37 @@ int DX8Wrapper_PreserveFPU = 0;
168168
#include "vs_smudge_spirv.bin.h"
169169
#include "fs_smudge_spirv.bin.h"
170170
#define GGC_BGFX_SHADER(name) name##_spirv
171+
// TheSuperHackers @build githubawn 29/07/2026 OpenGL ES 3.0 / WebGL 2 bytecode for
172+
// the Emscripten build.
173+
#elif defined(GGC_BGFX_RENDERER_ESSL)
174+
#include "vs_passthrough_essl.bin.h"
175+
#include "fs_passthrough_essl.bin.h"
176+
#include "vs_uber_essl.bin.h"
177+
#include "vs_uber_instanced_essl.bin.h"
178+
#include "vs_trees_essl.bin.h"
179+
#include "fs_uber_essl.bin.h"
180+
#include "fs_uber_array_essl.bin.h"
181+
#include "fs_uber_frameconst_essl.bin.h"
182+
#include "vs_uber_array_essl.bin.h"
183+
#include "vs_shadow_volume_essl.bin.h"
184+
#include "fs_shadow_volume_essl.bin.h"
185+
#include "vs_shadow_apply_essl.bin.h"
186+
#include "fs_shadow_apply_essl.bin.h"
187+
#include "vs_scene_composite_essl.bin.h"
188+
#include "fs_scene_composite_essl.bin.h"
189+
#include "fs_bloom_bright_essl.bin.h"
190+
#include "fs_bloom_blur_essl.bin.h"
191+
#include "fs_ssao_essl.bin.h"
192+
#include "fs_copy_essl.bin.h"
193+
#include "vs_scene_depth_essl.bin.h"
194+
#include "vs_scene_depth_instanced_essl.bin.h"
195+
#include "fs_scene_depth_essl.bin.h"
196+
#include "vs_shadow_caster_essl.bin.h"
197+
#include "vs_shadow_caster_instanced_essl.bin.h"
198+
#include "fs_shadow_caster_essl.bin.h"
199+
#include "vs_smudge_essl.bin.h"
200+
#include "fs_smudge_essl.bin.h"
201+
#define GGC_BGFX_SHADER(name) name##_essl
171202
#else
172203
#include "vs_passthrough_dx11.bin.h"
173204
#include "fs_passthrough_dx11.bin.h"
@@ -1433,6 +1464,8 @@ bgfx::RendererType::Enum GetConfiguredRendererType()
14331464
return bgfx::RendererType::Metal;
14341465
#elif defined(GGC_BGFX_RENDERER_VULKAN)
14351466
return bgfx::RendererType::Vulkan;
1467+
#elif defined(GGC_BGFX_RENDERER_ESSL)
1468+
return bgfx::RendererType::OpenGLES;
14361469
#else
14371470
return bgfx::RendererType::Direct3D11;
14381471
#endif
@@ -1444,6 +1477,14 @@ void *GetNativeWindowHandle(void *window)
14441477
{
14451478
return NULL;
14461479
}
1480+
#if defined(__EMSCRIPTEN__)
1481+
// TheSuperHackers @bugfix githubawn 29/07/2026 On Emscripten bgfx reads
1482+
// platformData.nwh as an HTML canvas CSS selector (a const char *), not as a
1483+
// window pointer. Passing the SDL_Window * makes document.querySelector throw on
1484+
// the garbage string, so bgfx never binds a canvas and the page stays black.
1485+
// "#canvas" is the element SDL3 creates its window on by default.
1486+
return (void *)"#canvas";
1487+
#endif
14471488
#if defined(SAGE_USE_SDL3)
14481489
#if defined(__APPLE__)
14491490
// TheSuperHackers @bugfix bobtista 30/04/2026 SDL_Metal_CreateView
@@ -4152,7 +4193,12 @@ void BgfxBackend::Initialize(void * hwnd, int /*width*/, int /*height*/)
41524193
{
41534194
// Single-threaded mode (default): this pre-init call tells bgfx::init() not to
41544195
// create an internal render thread, so bgfx::frame() runs renderFrame() inline.
4196+
// TheSuperHackers @bugfix githubawn 29/07/2026 bgfx is already built
4197+
// single-threaded on Emscripten, where this call asserts ("This call only makes
4198+
// sense if used with multi-threaded renderer") and aborts before init.
4199+
#if !defined(__EMSCRIPTEN__)
41554200
bgfx::renderFrame();
4201+
#endif
41564202
}
41574203
else
41584204
{

Core/Libraries/Source/WWVegas/WW3D2/CMakeLists.txt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,13 @@ add_library(corei_ww3d2 INTERFACE)
251251

252252
target_sources(corei_ww3d2 INTERFACE ${WW3D2_SRC})
253253

254-
if(UNIX AND NOT APPLE)
254+
if(EMSCRIPTEN)
255+
# TheSuperHackers @build githubawn 29/07/2026 Emscripten supplies FreeType as a
256+
# port (built on demand and cached by emcc), and there is no Fontconfig for it:
257+
# the web build opens an embedded font by path instead of matching one.
258+
target_compile_options(corei_ww3d2 INTERFACE "-sUSE_FREETYPE=1")
259+
target_link_options(corei_ww3d2 INTERFACE "-sUSE_FREETYPE=1")
260+
elseif(UNIX AND NOT APPLE)
255261
find_package(Freetype REQUIRED)
256262
find_package(Fontconfig REQUIRED)
257263
target_link_libraries(corei_ww3d2 INTERFACE

Core/Libraries/Source/WWVegas/WW3D2/render2dsentence.cpp

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,13 @@
6363
#if !defined(_WIN32) && !defined(__APPLE__)
6464
#include <ft2build.h>
6565
#include FT_FREETYPE_H
66+
// TheSuperHackers @build githubawn 29/07/2026 Emscripten has a FreeType port but no
67+
// Fontconfig, and no system font directories for it to search anyway; the web build
68+
// opens an embedded font directly instead.
69+
#if !defined(__EMSCRIPTEN__)
6670
#include <fontconfig/fontconfig.h>
6771
#endif
72+
#endif
6873

6974

7075
////////////////////////////////////////////////////////////////////////////////////
@@ -1796,6 +1801,19 @@ FontCharsClass::Create_GDI_Font (const char *font_name)
17961801
resolved_name = "Arial";
17971802
}
17981803

1804+
#if defined(__EMSCRIPTEN__)
1805+
// TheSuperHackers @port githubawn 29/07/2026 A browser has no Fontconfig and no
1806+
// system fonts to match against, so the lookup below cannot run and every string
1807+
// would render blank. Use the font embedded in the module instead (fetched and
1808+
// embedded by cmake/webfont.cmake), picking the bold face by name.
1809+
const char *embedded_font = IsBold ? "/fonts/DejaVuSans-Bold.ttf" : "/fonts/DejaVuSans.ttf";
1810+
FT_Error face_error = FT_New_Face(FTLibrary, embedded_font, 0, &FTFace);
1811+
if (face_error != 0) {
1812+
FT_Done_FreeType(FTLibrary);
1813+
FTLibrary = nullptr;
1814+
return false;
1815+
}
1816+
#else
17991817
FcConfig *config = FcInitLoadConfigAndFonts();
18001818
FcPattern *pattern = config != nullptr
18011819
? FcNameParse(reinterpret_cast<const FcChar8 *>(resolved_name))
@@ -1839,6 +1857,7 @@ FontCharsClass::Create_GDI_Font (const char *font_name)
18391857
FTLibrary = nullptr;
18401858
return false;
18411859
}
1860+
#endif // __EMSCRIPTEN__
18421861

18431862
int font_height = max(1, static_cast<int>(FT_MulDiv(PointSize, 96, 72)));
18441863
if (FT_Set_Pixel_Sizes(FTFace, 0, static_cast<FT_UInt>(font_height)) != 0) {

Dependencies/Utility/Utility/endian_compat.h

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
#include <Utility/stdint_adapter.h>
2626

2727

28-
#if defined(__linux__) || defined(__CYGWIN__)
28+
#if defined(__linux__) || defined(__CYGWIN__) || defined(__EMSCRIPTEN__)
2929
#include <endian.h>
3030

3131
#elif defined(__APPLE__)
@@ -116,7 +116,7 @@
116116

117117

118118
// Endian helper function data types
119-
#if defined(__linux__) || defined(__CYGWIN__)
119+
#if defined(__linux__) || defined(__CYGWIN__) || defined(__EMSCRIPTEN__)
120120
typedef uint16_t SwapType16;
121121
typedef uint32_t SwapType32;
122122
typedef uint64_t SwapType64;

Dependencies/Utility/Utility/time_compat.h

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,15 @@ static inline MMRESULT timeEndPeriod(int) { return TIMERR_NOERROR; }
2828
inline unsigned int timeGetTime()
2929
{
3030
struct timespec ts;
31-
#if defined(CLOCK_BOOTTIME)
31+
#if defined(__EMSCRIPTEN__)
32+
// TheSuperHackers @bugfix githubawn 29/07/2026 Emscripten's musl declares
33+
// CLOCK_BOOTTIME but does not implement it, so clock_gettime() fails and leaves ts
34+
// zeroed and timeGetTime() always returns 0. Every timeGetTime-gated loop then stops
35+
// making progress - Shell::update's 30Hz gate never opens, so menu update callbacks
36+
// never run and menus draw but never navigate. CLOCK_MONOTONIC is supported, and is
37+
// what GetTickCount() below already uses.
38+
clock_gettime(CLOCK_MONOTONIC, &ts);
39+
#elif defined(CLOCK_BOOTTIME)
3240
clock_gettime(CLOCK_BOOTTIME, &ts);
3341
#else
3442
clock_gettime(CLOCK_MONOTONIC, &ts);

GeneralsMD/Code/GameEngine/Source/Common/GameLOD.cpp

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,9 +618,15 @@ void GameLODManager::applyStaticLODLevel(StaticGameLODLevel level)
618618
TheWritableGlobalData->m_useFpsLimit = lodInfo->m_useFpsLimit;
619619
TheWritableGlobalData->m_useTrees = requestedTrees;
620620

621+
#if !defined(__EMSCRIPTEN__)
622+
// TheSuperHackers @bugfix githubawn 29/07/2026 The CPU-MHz / memory benchmark
623+
// this reads cannot run in a browser, so it always reports the machine as
624+
// low-end and turned the 3D shell map off - the main menu came up with a black
625+
// background instead. Leave the shell map enabled on the web build.
621626
if (!m_memPassed || isReallyLowMHz()) {
622627
TheWritableGlobalData->m_shellMapOn = false;
623628
}
629+
#endif
624630
}
625631

626632
if (TheTerrainVisual)

0 commit comments

Comments
 (0)