Skip to content

Commit 30a4ca0

Browse files
committed
Support emscripten/pygbag in the meson buildconfig
1 parent ef5e641 commit 30a4ca0

File tree

8 files changed

+194
-32
lines changed

8 files changed

+194
-32
lines changed

.github/workflows/build-emsdk.yml

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -32,43 +32,45 @@ concurrency:
3232
cancel-in-progress: true
3333

3434
jobs:
35-
build:
35+
build-pygbag:
3636
runs-on: ubuntu-22.04
3737
env:
3838
# pin SDK version to the latest, update manually
39-
SDK_VERSION: 3.1.32.0
40-
SDK_ARCHIVE: python3.11-wasm-sdk-Ubuntu-22.04.tar.lz4
39+
SDK_VERSION: 3.1.61.12bi
40+
SDK_ARCHIVE: python3.13-wasm-sdk-Ubuntu-22.04.tar.lz4
4141
SDKROOT: /opt/python-wasm-sdk
42+
PYBUILD: 3.13
4243

4344
steps:
4445
- uses: actions/[email protected]
4546

46-
- name: Regen with latest cython (using system python3)
47-
run: |
48-
pip3 install cython==3.0.10
49-
python3 setup.py cython_only
50-
5147
- name: Install python-wasm-sdk
5248
run: |
5349
sudo apt-get install lz4
54-
echo https://github.com/pygame-web/python-wasm-sdk/releases/download/$SDK_VERSION/$SDK_ARCHIVE
5550
curl -sL --retry 5 https://github.com/pygame-web/python-wasm-sdk/releases/download/$SDK_VERSION/$SDK_ARCHIVE | tar xvP --use-compress-program=lz4
56-
# do not let SDL1 interfere
57-
rm -rf /opt/python-wasm-sdk/emsdk/upstream/emscripten/cache/sysroot/include/SDL
5851
working-directory: /opt
5952

6053
- name: Build WASM with emsdk
61-
run: |
62-
${SDKROOT}/python3-wasm setup.py build -j$(nproc)
63-
64-
- name: Generate libpygame.a static binaries archive
65-
run: |
66-
mkdir -p dist
67-
SYS_PYTHON=python3 /opt/python-wasm-sdk/emsdk/upstream/emscripten/emar rcs dist/libpygame.a $(find build/temp.wasm32-*/ | grep o$)
54+
run: ${SDKROOT}/python3-wasm dev.py build --wheel
6855

6956
# Upload the generated files under github actions assets section
7057
- name: Upload dist
7158
uses: actions/upload-artifact@v4
7259
with:
7360
name: pygame-wasm-dist
7461
path: ./dist/*
62+
63+
build-pyodide:
64+
name: Pyodide build
65+
runs-on: ubuntu-latest
66+
steps:
67+
- uses: actions/[email protected]
68+
69+
- uses: pypa/[email protected]
70+
env:
71+
CIBW_PLATFORM: pyodide
72+
73+
- uses: actions/upload-artifact@v4
74+
with:
75+
name: pyodide-wheels
76+
path: wheelhouse/*.whl

dev.py

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import re
1212
import subprocess
1313
import sys
14+
import sysconfig
1415
from enum import Enum
1516
from pathlib import Path
1617
from typing import Any, Union
@@ -35,6 +36,13 @@
3536
# We assume this script works with any pip version above this.
3637
PIP_MIN_VERSION = "23.1"
3738

39+
# we will assume dev.py wasm builds are made for pygbag.
40+
host_gnu_type = sysconfig.get_config_var("HOST_GNU_TYPE")
41+
if isinstance(host_gnu_type, str) and "wasm" in host_gnu_type:
42+
wasm = "wasi" if "wasi" in host_gnu_type else "emscripten"
43+
else:
44+
wasm = ""
45+
3846

3947
class Colors(Enum):
4048
RESET = "\033[0m"
@@ -187,9 +195,51 @@ def check_module_in_constraint(mod: str, constraint: str):
187195
return mod.lower().strip() == constraint_mod[0]
188196

189197

198+
def get_wasm_cross_file(sdkroot: Path):
199+
emsdk_dir = sdkroot / "emsdk"
200+
bin_dir = emsdk_dir / "upstream" / "emscripten"
201+
node_bin_dir = emsdk_dir / "node" / "22.16.0_64bit" / "bin"
202+
203+
sysroot_dir = bin_dir / "cache" / "sysroot"
204+
inc_dir = sysroot_dir / "include"
205+
lib_dir = sysroot_dir / "lib" / "wasm32-emscripten" / "pic"
206+
207+
inc_dirs = [
208+
inc_dir / "SDL2",
209+
inc_dir / "freetype2",
210+
sdkroot / "devices" / "emsdk" / "usr" / "include" / "SDL2",
211+
]
212+
lib_dirs = [lib_dir]
213+
c_args = [f"-I{x}" for x in inc_dirs] + [f"-L{x}" for x in lib_dirs]
214+
return f"""
215+
[host_machine]
216+
system = 'emscripten'
217+
cpu_family = 'wasm32'
218+
cpu = 'wasm'
219+
endian = 'little'
220+
221+
[binaries]
222+
c = {str(bin_dir / 'emcc')!r}
223+
cpp = {str(bin_dir / 'em++')!r}
224+
ar = {str(bin_dir / 'emar')!r}
225+
strip = {str(bin_dir / 'emstrip')!r}
226+
exe_wrapper = {str(node_bin_dir / 'node')!r}
227+
228+
[project options]
229+
emscripten_type = 'pygbag'
230+
231+
[built-in options]
232+
c_args = {c_args!r}
233+
"""
234+
235+
190236
class Dev:
191237
def __init__(self) -> None:
192-
self.py: Path = Path(sys.executable)
238+
self.py: Path = (
239+
Path(os.environ["SDKROOT"]) / "python3-wasm"
240+
if wasm
241+
else Path(sys.executable)
242+
)
193243
self.args: dict[str, Any] = {}
194244

195245
self.deps: dict[str, set[str]] = {
@@ -227,12 +277,22 @@ def cmd_build(self):
227277
build_suffix += "-sdl3"
228278
if coverage:
229279
build_suffix += "-cov"
280+
281+
build_dir = Path(f".mesonpy-build{build_suffix}")
230282
install_args = [
231283
"--no-build-isolation",
232-
f"-Cbuild-dir=.mesonpy-build{build_suffix}",
284+
f"-Cbuild-dir={build_dir}",
233285
]
234286

235287
if not wheel_dir:
288+
if wasm:
289+
pprint(
290+
"Editable builds are not supported on WASM as of now. "
291+
"Pass --wheel to do a regular build",
292+
Colors.RED,
293+
)
294+
sys.exit(1)
295+
236296
# editable install
237297
if not quiet:
238298
install_args.append("-Ceditable-verbose=true")
@@ -259,6 +319,14 @@ def cmd_build(self):
259319
if sanitize:
260320
install_args.append(f"-Csetup-args=-Db_sanitize={sanitize}")
261321

322+
if wasm:
323+
wasm_cross_file = build_dir / "meson-cross-wasm.ini"
324+
build_dir.mkdir(exist_ok=True)
325+
wasm_cross_file.write_text(get_wasm_cross_file(self.py.parent))
326+
install_args.append(
327+
f"-Csetup-args=--cross-file={wasm_cross_file.resolve()}"
328+
)
329+
262330
info_str = (
263331
f"with {debug=}, {lax=}, {sdl3=}, {stripped=}, {coverage=} and {sanitize=}"
264332
)
@@ -497,6 +565,10 @@ def prep_env(self):
497565
pprint("pip version is too old or unknown, attempting pip upgrade")
498566
pip_install(self.py, ["-U", "pip"])
499567

568+
if wasm:
569+
# dont try to install any deps on WASM, exit early
570+
return
571+
500572
deps = self.deps.get(self.args["command"], set())
501573
ignored_deps = self.args["ignore_dep"]
502574
deps_filtered = deps.copy()

meson.build

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,7 @@ elif host_machine.system() == 'android'
3434
'However it may be added in the future',
3535
)
3636
elif host_machine.system() == 'emscripten'
37-
plat = 'emscripten'
38-
error(
39-
'The meson buildconfig of pygame-ce does not support emscripten for now. ',
40-
'However it may be added in the future',
41-
)
37+
plat = 'emscripten-@0@'.format(get_option('emscripten_type'))
4238
else
4339
# here it one of: cygwin, dragonfly, freebsd, gnu, haiku, netbsd, openbsd, sunos
4440
plat = 'unix'
@@ -90,6 +86,54 @@ endif
9086

9187
pg_inc_dirs = []
9288
pg_lib_dirs = []
89+
90+
if plat == 'emscripten-pygbag'
91+
sdl_dep = declare_dependency(
92+
link_args: ['-lSDL2'],
93+
)
94+
sdl_image_dep = declare_dependency(
95+
link_args: ['-lSDL2_image'],
96+
)
97+
sdl_mixer_dep = declare_dependency(
98+
link_args: ['-lSDL2_mixer_ogg', '-logg', '-lvorbis'],
99+
)
100+
freetype_dep = declare_dependency(
101+
link_args: ['-lfreetype', '-lharfbuzz']
102+
)
103+
sdl_ttf_dep = declare_dependency(
104+
link_args: ['-lSDL2_ttf'],
105+
dependencies: [freetype_dep]
106+
)
107+
elif plat == 'emscripten-generic'
108+
# Check out before-build attribute in [tool.cibuildwheel.pyodide] section
109+
# of pyproject.toml to see how these dependencies were installed.
110+
wasm_exceptions = ['-fwasm-exceptions', '-sSUPPORT_LONGJMP=wasm']
111+
add_global_arguments(wasm_exceptions, language: 'c')
112+
add_global_link_arguments(wasm_exceptions, language: 'c')
113+
114+
sdl_flags = ['-sUSE_SDL=2']
115+
freetype_flags = ['-sUSE_FREETYPE=1']
116+
sdl_dep = declare_dependency(
117+
compile_args: sdl_flags,
118+
link_args: sdl_flags + ['-lSDL2', '-lhtml5'],
119+
)
120+
# TODO: add png
121+
sdl_image_dep = declare_dependency(
122+
link_args: ['-lSDL2_image-bmp-gif-jpg-qoi-svg-tga', '-ljpeg'],
123+
)
124+
sdl_mixer_dep = declare_dependency(
125+
link_args: ['-lSDL2_mixer-mid-mod-mp3-ogg', '-lmodplug', '-lmpg123', '-logg', '-lvorbis'],
126+
)
127+
freetype_dep = declare_dependency(
128+
compile_args: freetype_flags,
129+
link_args: freetype_flags + ['-lfreetype-legacysjlj', '-lharfbuzz']
130+
)
131+
sdl_ttf_dep = declare_dependency(
132+
link_args: ['-lSDL2_ttf'],
133+
dependencies: [freetype_dep]
134+
)
135+
else
136+
93137
if plat == 'win' and host_machine.cpu_family().startswith('x86')
94138
# yes, this is a bit ugly and hardcoded but it is what it is
95139
# TODO (middle-term goal) - Should migrate away from this
@@ -311,8 +355,10 @@ if not freetype_dep.found()
311355
)
312356
endif
313357

358+
endif # emscripten
359+
314360
portmidi_dep = dependency('portmidi', required: false)
315-
if not portmidi_dep.found()
361+
if not portmidi_dep.found() and not plat.startswith('emscripten')
316362
portmidi_dep = declare_dependency(
317363
include_directories: pg_inc_dirs,
318364
dependencies: cc.find_library(
@@ -436,7 +482,7 @@ endif
436482
subdir('src_c')
437483
subdir('src_py')
438484

439-
if not get_option('stripped')
485+
if not get_option('stripped') and not plat.startswith('emscripten')
440486
# run make_docs and make docs
441487
if not fs.is_dir('docs/generated')
442488
make_docs = files('buildconfig/make_docs.py')

meson_options.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,6 @@ option('coverage', type: 'boolean', value: false)
4040

4141
# Controls whether to use SDL3 instead of SDL2. The default is to use SDL2
4242
option('sdl_api', type: 'integer', min: 2, max: 3, value: 2)
43+
44+
# Specify the type of emscripten build being done.
45+
option('emscripten_type', type: 'combo', choices: ['pygbag', 'generic'], value: 'generic')

pyproject.toml

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,9 @@ pygame_ce = 'pygame.__briefcase.pygame_ce:PygameCEGuiBootstrap'
5555
[build-system]
5656
requires = [
5757
"meson-python<=0.18.0",
58-
"meson<=1.8.2",
59-
"ninja<=1.12.1",
60-
"cython<=3.1.2",
58+
"meson<=1.9.1",
59+
"ninja<=1.13.0",
60+
"cython<=3.1.4",
6161
"sphinx<=8.2.3",
6262
"sphinx-autoapi<=3.6.0",
6363
"pyproject-metadata!=0.9.1",
@@ -97,6 +97,13 @@ setup-args = [
9797
"-Derror_docs_missing=true",
9898
]
9999

100+
[tool.cibuildwheel.pyodide]
101+
build = "cp313-*" # build only for the latest python version.
102+
# TODO: png has issues, fix it and add support for it
103+
before-build = "embuilder build sdl2 'sdl2_mixer:formats=ogg,mp3,mod,mid' 'sdl2_image:formats=bmp,gif,jpg,qoi,svg,tga' sdl2_ttf freetype-legacysjlj libpng-legacysjlj libhtml5 --pic"
104+
test-command = "" # TODO: fix runtime issues and then figure out how to test
105+
106+
100107
[tool.ruff]
101108
exclude = [
102109
"buildconfig/*.py",

src_c/meson.build

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,25 @@
1+
if plat.startswith('emscripten')
2+
3+
base_files = ['base.c', 'bitmask.c', 'SDL_gfx/SDL_gfxPrimitives.c']
4+
cython_files = [
5+
'cython/pygame/_sdl2/audio.pyx',
6+
'cython/pygame/_sdl2/mixer.pyx',
7+
'cython/pygame/_sdl2/sdl2.pyx',
8+
'cython/pygame/_sdl2/video.pyx',
9+
]
10+
11+
# make one big shared build on emscripten
12+
pygame = py.extension_module(
13+
'base',
14+
base_files + cython_files,
15+
c_args: ['-DBUILD_STATIC=1'],
16+
dependencies: pg_base_deps + [sdl_image_dep, sdl_mixer_dep, sdl_ttf_dep, freetype_dep],
17+
install: true,
18+
subdir: pg,
19+
)
20+
21+
else # regular build
22+
123
# first the "required" modules
224

325
base = py.extension_module(
@@ -327,6 +349,7 @@ subdir('_sdl2')
327349
endif
328350

329351
# pygame._camera
352+
if not plat.startswith('emscripten')
330353
pg_camera_sources = ['_camera.c']
331354
pg_camera_link = []
332355
if plat == 'win'
@@ -357,6 +380,7 @@ _camera = py.extension_module(
357380
install: true,
358381
subdir: pg,
359382
)
383+
endif
360384

361385
# pygame.scrap
362386
pg_scrap_link = [] # TODO: should this link logic be improved/made meson-ey?
@@ -452,3 +476,5 @@ if portmidi_dep.found()
452476
subdir: pg,
453477
)
454478
endif
479+
480+
endif # emscripten

src_c/mixer.c

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -447,12 +447,18 @@ _init(int freq, int size, int channels, int chunk, char *devicename,
447447
if (!PG_InitSubSystem(SDL_INIT_AUDIO)) {
448448
return RAISE(pgExc_SDLError, SDL_GetError());
449449
}
450-
450+
#if __EMSCRIPTEN__
451+
if (Mix_OpenAudio(freq, fmt, channels, chunk) == -1) {
452+
SDL_QuitSubSystem(SDL_INIT_AUDIO);
453+
return RAISE(pgExc_SDLError, SDL_GetError());
454+
}
455+
#else
451456
if (Mix_OpenAudioDevice(freq, fmt, channels, chunk, devicename,
452457
allowedchanges) == -1) {
453458
SDL_QuitSubSystem(SDL_INIT_AUDIO);
454459
return RAISE(pgExc_SDLError, SDL_GetError());
455460
}
461+
#endif
456462
Mix_ChannelFinished(endsound_callback);
457463
Mix_VolumeMusic(127);
458464
}

src_py/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ def warn(self):
115115
except ModuleNotFoundError:
116116
pygame_static = None
117117

118-
if pygame_static:
118+
if pygame_static and hasattr(pygame_static, 'import_cython'):
119119
pygame = sys.modules[__name__]
120120

121121
# cython modules use multiphase initialisation when not in builtin Inittab.

0 commit comments

Comments
 (0)