From f20ecc74aef42112c96c8ef6a9bd2106562bd4a6 Mon Sep 17 00:00:00 2001 From: past-due <30942300+past-due@users.noreply.github.com> Date: Mon, 29 Jan 2024 14:58:21 -0500 Subject: [PATCH 01/34] seqdisp.cpp: Add cancel functionality to on-demand video provider --- src/seqdisp.cpp | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/seqdisp.cpp b/src/seqdisp.cpp index ea153bcb78a..ab618060940 100644 --- a/src/seqdisp.cpp +++ b/src/seqdisp.cpp @@ -148,6 +148,9 @@ class OnDemandVideoDownloader // Returns whether an error occurred trying to get the video data bool getVideoDataError(const WzString& videoName); + // Cancel video download + bool cancelDownloadRequest(const WzString& videoName); + // Clear all cached requests void clear(); @@ -180,6 +183,7 @@ class OnDemandVideoDownloader private: AsyncURLRequestHandle requestHandle; friend bool OnDemandVideoDownloader::requestVideoData(const WzString& videoName); + friend bool OnDemandVideoDownloader::cancelDownloadRequest(const WzString& videoName); }; std::unordered_map> priorRequests; optional baseURLPath; @@ -327,6 +331,22 @@ bool OnDemandVideoDownloader::getVideoDataError(const WzString& videoName) return false; } +// Cancel video download request +bool OnDemandVideoDownloader::cancelDownloadRequest(const WzString& videoName) +{ + auto it = priorRequests.find(videoName); + if (it == priorRequests.end()) + { + return false; + } + if (it->second->requestHandle) + { + urlRequestSetCancelFlag(it->second->requestHandle); + } + priorRequests.erase(it); + return true; +} + void OnDemandVideoDownloader::clear() { priorRequests.clear(); @@ -709,6 +729,8 @@ bool seq_StopFullScreenVideo() loop_ClearVideoPlaybackMode(); } + onDemandVideoProvider.cancelDownloadRequest(currVideoName); + seq_Shutdown(); wzCachedSeqText.clear(); From 4c2e8879f2b1ca597594774f58c8d39426398d43 Mon Sep 17 00:00:00 2001 From: past-due <30942300+past-due@users.noreply.github.com> Date: Tue, 6 Feb 2024 13:09:12 -0500 Subject: [PATCH 02/34] [CMake] Add EXCLUDE_FROM_ALL --- 3rdparty/CMakeLists.txt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/3rdparty/CMakeLists.txt b/3rdparty/CMakeLists.txt index 0f2c6498c1c..3637a62ed63 100644 --- a/3rdparty/CMakeLists.txt +++ b/3rdparty/CMakeLists.txt @@ -1,13 +1,13 @@ cmake_minimum_required (VERSION 3.5...3.24) SET(UTF8PROC_INSTALL OFF CACHE BOOL "Enable installation of utf8proc" FORCE) -add_subdirectory(utf8proc) +add_subdirectory(utf8proc EXCLUDE_FROM_ALL) set_property(TARGET utf8proc PROPERTY FOLDER "3rdparty") -add_subdirectory(launchinfo) +add_subdirectory(launchinfo EXCLUDE_FROM_ALL) set_property(TARGET launchinfo PROPERTY FOLDER "3rdparty") -add_subdirectory(fmt) +add_subdirectory(fmt EXCLUDE_FROM_ALL) set_property(TARGET fmt PROPERTY FOLDER "3rdparty") # inih library @@ -34,7 +34,7 @@ set_property(TARGET re2 PROPERTY FOLDER "3rdparty") set_property(TARGET re2 PROPERTY XCODE_ATTRIBUTE_GCC_WARN_ABOUT_MISSING_FIELD_INITIALIZERS NO) # -Wmissing-field-initializers set_property(TARGET re2 PROPERTY XCODE_ATTRIBUTE_WARNING_CFLAGS "-Wno-missing-field-initializers") -add_subdirectory(EmbeddedJSONSignature) +add_subdirectory(EmbeddedJSONSignature EXCLUDE_FROM_ALL) set_property(TARGET EmbeddedJSONSignature PROPERTY FOLDER "3rdparty") if(ENABLE_DISCORD) From 0b9850fa842bfcd4650248e1de9fdb7b00ff06c9 Mon Sep 17 00:00:00 2001 From: past-due <30942300+past-due@users.noreply.github.com> Date: Tue, 6 Feb 2024 19:45:21 -0500 Subject: [PATCH 03/34] game.cpp: Remove unnecessary include --- src/game.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/src/game.cpp b/src/game.cpp index f6e97f97c34..8e95e239f4e 100644 --- a/src/game.cpp +++ b/src/game.cpp @@ -94,7 +94,6 @@ #include "multigifts.h" #include "wzscriptdebug.h" #include "gamehistorylogger.h" -#include "build_tools/autorevision.h" #include #if defined(__clang__) From 5728d35f560b4a87caee44a9f7273b758fcf5281 Mon Sep 17 00:00:00 2001 From: past-due <30942300+past-due@users.noreply.github.com> Date: Wed, 7 Feb 2024 12:40:38 -0500 Subject: [PATCH 04/34] Update .gitignore --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index f10bf931526..6bb34e0176a 100644 --- a/.gitignore +++ b/.gitignore @@ -271,7 +271,6 @@ devpkg/* *.tlog *.lastbuildstate *.cache* -*manifest.* *.exp warzone*.*build *.lnk From 3119b065a36b9fc9d81ea5cb0b3ac202d8d95500 Mon Sep 17 00:00:00 2001 From: past-due <30942300+past-due@users.noreply.github.com> Date: Tue, 6 Feb 2024 20:15:43 -0500 Subject: [PATCH 05/34] Initial Emscripten support --- .../toolchain/Toolchain-Emscripten.cmake | 33 + .../overlay-ports/sdl2/alsa-dep-fix.patch | 13 + .ci/vcpkg/overlay-ports/sdl2/deps.patch | 13 + .../overlay-ports/sdl2/emscripten-webgl.patch | 320 ++++ .ci/vcpkg/overlay-ports/sdl2/portfile.cmake | 142 ++ .ci/vcpkg/overlay-ports/sdl2/usage | 8 + .ci/vcpkg/overlay-ports/sdl2/vcpkg.json | 69 + .../overlay-triplets/wasm32-emscripten.cmake | 32 + 3rdparty/CMakeLists.txt | 3 + CMakeLists.txt | 150 +- cmake/EmscriptenCompressZip.cmake | 155 ++ cmake/EmscriptenCompressZipCopy.cmake | 11 + cmake/WZVcpkgInit.cmake | 11 + data/CMakeLists.txt | 146 +- data/base/shaders/gfx_text.vert | 2 - doc/CMakeLists.txt | 2 +- icons/CMakeLists.txt | 2 +- lib/exceptionhandler/exceptionhandler.cpp | 2 +- lib/framework/debug.cpp | 38 + lib/framework/debug.h | 4 + lib/framework/i18n.cpp | 59 +- lib/framework/wzglobal.h | 1 + lib/ivis_opengl/CMakeLists.txt | 17 +- lib/ivis_opengl/gfx_api_gl.cpp | 497 +++++- lib/ivis_opengl/gfx_api_gl.h | 10 + lib/ivis_opengl/textdraw.cpp | 4 + lib/sdl/main_sdl.cpp | 189 ++- .../assets/android-chrome-192x192.png | Bin 0 -> 23331 bytes .../assets/android-chrome-512x512.png | Bin 0 -> 129994 bytes .../emscripten/assets/apple-touch-icon.png | Bin 0 -> 18763 bytes platforms/emscripten/assets/favicon-16x16.png | Bin 0 -> 968 bytes platforms/emscripten/assets/favicon-32x32.png | Bin 0 -> 2061 bytes platforms/emscripten/assets/favicon.ico | Bin 0 -> 15086 bytes platforms/emscripten/manifest.json | 30 + platforms/emscripten/postjs.js | 60 + platforms/emscripten/prejs.js | 0 platforms/emscripten/shell.html | 1370 +++++++++++++++++ po/CMakeLists.txt | 10 +- src/CMakeLists.txt | 183 ++- src/emscripten_helpers.cpp | 89 ++ src/emscripten_helpers.h | 33 + src/frontend.cpp | 5 + src/game.cpp | 13 +- src/game.h | 7 +- src/loadsave.cpp | 2 +- src/loop.cpp | 25 +- src/main.cpp | 12 + src/stdinreader.cpp | 2 +- src/updatemanager.cpp | 11 + src/urlhelpers.cpp | 12 + src/urlrequest_emscripten.cpp | 572 +++++++ src/wrappers.cpp | 26 + src/wzpropertyproviders.cpp | 17 +- src/wzpropertyproviders.h | 4 +- vcpkg.json | 41 +- 55 files changed, 4322 insertions(+), 135 deletions(-) create mode 100644 .ci/emscripten/toolchain/Toolchain-Emscripten.cmake create mode 100644 .ci/vcpkg/overlay-ports/sdl2/alsa-dep-fix.patch create mode 100644 .ci/vcpkg/overlay-ports/sdl2/deps.patch create mode 100644 .ci/vcpkg/overlay-ports/sdl2/emscripten-webgl.patch create mode 100644 .ci/vcpkg/overlay-ports/sdl2/portfile.cmake create mode 100644 .ci/vcpkg/overlay-ports/sdl2/usage create mode 100644 .ci/vcpkg/overlay-ports/sdl2/vcpkg.json create mode 100644 .ci/vcpkg/overlay-triplets/wasm32-emscripten.cmake create mode 100644 cmake/EmscriptenCompressZip.cmake create mode 100644 cmake/EmscriptenCompressZipCopy.cmake create mode 100644 platforms/emscripten/assets/android-chrome-192x192.png create mode 100644 platforms/emscripten/assets/android-chrome-512x512.png create mode 100644 platforms/emscripten/assets/apple-touch-icon.png create mode 100644 platforms/emscripten/assets/favicon-16x16.png create mode 100644 platforms/emscripten/assets/favicon-32x32.png create mode 100644 platforms/emscripten/assets/favicon.ico create mode 100644 platforms/emscripten/manifest.json create mode 100644 platforms/emscripten/postjs.js create mode 100644 platforms/emscripten/prejs.js create mode 100644 platforms/emscripten/shell.html create mode 100644 src/emscripten_helpers.cpp create mode 100644 src/emscripten_helpers.h create mode 100644 src/urlrequest_emscripten.cpp diff --git a/.ci/emscripten/toolchain/Toolchain-Emscripten.cmake b/.ci/emscripten/toolchain/Toolchain-Emscripten.cmake new file mode 100644 index 00000000000..752998f7f57 --- /dev/null +++ b/.ci/emscripten/toolchain/Toolchain-Emscripten.cmake @@ -0,0 +1,33 @@ +# Toolchain for compiling for Emscripten + +# Note: +# This is a bit of a hack. +# We actually want to use the Emscripten.cmake toolchain provided by Emscripten... +# However we also want to override and enable pthreads for everything + +if(NOT DEFINED ENV{EMSCRIPTEN_ROOT}) + find_path(EMSCRIPTEN_ROOT "emcc") +else() + set(EMSCRIPTEN_ROOT "$ENV{EMSCRIPTEN_ROOT}") +endif() + +if(NOT EMSCRIPTEN_ROOT) + if(NOT DEFINED ENV{EMSDK}) + message(FATAL_ERROR "The emcc compiler not found in PATH") + endif() + set(EMSCRIPTEN_ROOT "$ENV{EMSDK}/upstream/emscripten") +endif() + +if(NOT EXISTS "${EMSCRIPTEN_ROOT}/cmake/Modules/Platform/Emscripten.cmake") + message(FATAL_ERROR "Emscripten.cmake toolchain file not found") +endif() + +include("${EMSCRIPTEN_ROOT}/cmake/Modules/Platform/Emscripten.cmake") + +# Always enable PThreads +set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -s USE_PTHREADS=1 -s USE_SDL=0") +set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -s USE_PTHREADS=1 -s USE_SDL=0") + +# Enable optimizations for release builds +set(CMAKE_CXX_FLAGS_RELEASE "${CMAKE_CXX_FLAGS_RELEASE} -O2") +set(CMAKE_C_FLAGS_RELEASE "${CMAKE_C_FLAGS_RELEASE} -O2") diff --git a/.ci/vcpkg/overlay-ports/sdl2/alsa-dep-fix.patch b/.ci/vcpkg/overlay-ports/sdl2/alsa-dep-fix.patch new file mode 100644 index 00000000000..5b2c77b937d --- /dev/null +++ b/.ci/vcpkg/overlay-ports/sdl2/alsa-dep-fix.patch @@ -0,0 +1,13 @@ +diff --git a/SDL2Config.cmake.in b/SDL2Config.cmake.in +index cc8bcf26d..ead829767 100644 +--- a/SDL2Config.cmake.in ++++ b/SDL2Config.cmake.in +@@ -35,7 +35,7 @@ include("${CMAKE_CURRENT_LIST_DIR}/sdlfind.cmake") + + set(SDL_ALSA @SDL_ALSA@) + set(SDL_ALSA_SHARED @SDL_ALSA_SHARED@) +-if(SDL_ALSA AND NOT SDL_ALSA_SHARED AND TARGET SDL2::SDL2-static) ++if(SDL_ALSA) + sdlFindALSA() + endif() + unset(SDL_ALSA) diff --git a/.ci/vcpkg/overlay-ports/sdl2/deps.patch b/.ci/vcpkg/overlay-ports/sdl2/deps.patch new file mode 100644 index 00000000000..a8637d8c801 --- /dev/null +++ b/.ci/vcpkg/overlay-ports/sdl2/deps.patch @@ -0,0 +1,13 @@ +diff --git a/cmake/sdlchecks.cmake b/cmake/sdlchecks.cmake +index 65a98efbe..2f99f28f1 100644 +--- a/cmake/sdlchecks.cmake ++++ b/cmake/sdlchecks.cmake +@@ -352,7 +352,7 @@ endmacro() + # - HAVE_SDL_LOADSO opt + macro(CheckLibSampleRate) + if(SDL_LIBSAMPLERATE) +- find_package(SampleRate QUIET) ++ find_package(SampleRate CONFIG REQUIRED) + if(SampleRate_FOUND AND TARGET SampleRate::samplerate) + set(HAVE_LIBSAMPLERATE TRUE) + set(HAVE_LIBSAMPLERATE_H TRUE) diff --git a/.ci/vcpkg/overlay-ports/sdl2/emscripten-webgl.patch b/.ci/vcpkg/overlay-ports/sdl2/emscripten-webgl.patch new file mode 100644 index 00000000000..a14af9f05bb --- /dev/null +++ b/.ci/vcpkg/overlay-ports/sdl2/emscripten-webgl.patch @@ -0,0 +1,320 @@ +diff --git a/src/video/emscripten/SDL_emscriptenopengles.c b/src/video/emscripten/SDL_emscriptenopengles.c +--- a/src/video/emscripten/SDL_emscriptenopengles.c ++++ b/src/video/emscripten/SDL_emscriptenopengles.c +@@ -20,81 +20,139 @@ + */ + #include "../../SDL_internal.h" + +-#if SDL_VIDEO_DRIVER_EMSCRIPTEN && SDL_VIDEO_OPENGL_EGL ++#if SDL_VIDEO_DRIVER_EMSCRIPTEN + + #include ++#include + #include + + #include "SDL_emscriptenvideo.h" + #include "SDL_emscriptenopengles.h" + #include "SDL_hints.h" + +-#define LOAD_FUNC(NAME) _this->egl_data->NAME = NAME; ++int Emscripten_GLES_LoadLibrary(_THIS, const char *path) ++{ ++ return 0; ++} ++ ++void Emscripten_GLES_UnloadLibrary(_THIS) ++{ ++} + +-/* EGL implementation of SDL OpenGL support */ ++void * Emscripten_GLES_GetProcAddress(_THIS, const char *proc) ++{ ++ return emscripten_webgl_get_proc_address(proc); ++} + +-int Emscripten_GLES_LoadLibrary(_THIS, const char *path) ++int Emscripten_GLES_SetSwapInterval(_THIS, int interval) + { +- /*we can't load EGL dynamically*/ +- _this->egl_data = (struct SDL_EGL_VideoData *) SDL_calloc(1, sizeof(SDL_EGL_VideoData)); +- if (!_this->egl_data) { +- return SDL_OutOfMemory(); ++ if (interval < 0) { ++ return SDL_SetError("Late swap tearing currently unsupported"); ++ } else if(interval == 0) { ++ emscripten_set_main_loop_timing(EM_TIMING_SETTIMEOUT, 0); ++ } else { ++ emscripten_set_main_loop_timing(EM_TIMING_RAF, interval); + } ++ return 0; ++} ++ ++int Emscripten_GLES_GetSwapInterval(_THIS) ++{ ++ int mode, value; ++ ++ emscripten_get_main_loop_timing(&mode, &value); ++ ++ if(mode == EM_TIMING_RAF) ++ return value; ++ ++ return 0; ++} ++ ++SDL_GLContext Emscripten_GLES_CreateContext(_THIS, SDL_Window * window) ++{ ++ SDL_WindowData *window_data; ++ ++ EmscriptenWebGLContextAttributes attribs; ++ EMSCRIPTEN_WEBGL_CONTEXT_HANDLE context; + +- /* Emscripten forces you to manually cast eglGetProcAddress to the real +- function type; grep for "__eglMustCastToProperFunctionPointerType" in +- Emscripten's egl.h for details. */ +- _this->egl_data->eglGetProcAddress = (void *(EGLAPIENTRY *)(const char *)) eglGetProcAddress; +- +- LOAD_FUNC(eglGetDisplay); +- LOAD_FUNC(eglInitialize); +- LOAD_FUNC(eglTerminate); +- LOAD_FUNC(eglChooseConfig); +- LOAD_FUNC(eglGetConfigAttrib); +- LOAD_FUNC(eglCreateContext); +- LOAD_FUNC(eglDestroyContext); +- LOAD_FUNC(eglCreateWindowSurface); +- LOAD_FUNC(eglDestroySurface); +- LOAD_FUNC(eglMakeCurrent); +- LOAD_FUNC(eglSwapBuffers); +- LOAD_FUNC(eglSwapInterval); +- LOAD_FUNC(eglWaitNative); +- LOAD_FUNC(eglWaitGL); +- LOAD_FUNC(eglBindAPI); +- LOAD_FUNC(eglQueryString); +- LOAD_FUNC(eglGetError); +- +- _this->egl_data->egl_display = _this->egl_data->eglGetDisplay(EGL_DEFAULT_DISPLAY); +- if (!_this->egl_data->egl_display) { +- return SDL_SetError("Could not get EGL display"); ++ emscripten_webgl_init_context_attributes(&attribs); ++ ++ attribs.alpha = _this->gl_config.alpha_size > 0; ++ attribs.depth = _this->gl_config.depth_size > 0; ++ attribs.stencil = _this->gl_config.stencil_size > 0; ++ attribs.antialias = _this->gl_config.multisamplebuffers == 1; ++ ++ if(_this->gl_config.major_version == 3) ++ attribs.majorVersion = 2; /* WebGL 2.0 ~= GLES 3.0 */ ++ ++ window_data = (SDL_WindowData *) window->driverdata; ++ ++ if (window_data->gl_context) { ++ SDL_SetError("Cannot create multiple webgl contexts per window"); ++ return NULL; + } + +- if (_this->egl_data->eglInitialize(_this->egl_data->egl_display, NULL, NULL) != EGL_TRUE) { +- return SDL_SetError("Could not initialize EGL"); ++ context = emscripten_webgl_create_context(window_data->canvas_id, &attribs); ++ ++ if (context < 0) { ++ SDL_SetError("Could not create webgl context"); ++ return NULL; + } + +- if (path) { +- SDL_strlcpy(_this->gl_config.driver_path, path, sizeof(_this->gl_config.driver_path) - 1); +- } else { +- *_this->gl_config.driver_path = '\0'; ++ if (emscripten_webgl_make_context_current(context) != EMSCRIPTEN_RESULT_SUCCESS) { ++ emscripten_webgl_destroy_context(context); ++ return NULL; + } + +- return 0; ++ window_data->gl_context = (SDL_GLContext)context; ++ ++ return (SDL_GLContext)context; + } + +-SDL_EGL_CreateContext_impl(Emscripten) +-SDL_EGL_MakeCurrent_impl(Emscripten) ++void Emscripten_GLES_DeleteContext(_THIS, SDL_GLContext context) ++{ ++ SDL_Window *window; ++ ++ /* remove the context from its window */ ++ for (window = _this->windows; window != NULL; window = window->next) { ++ SDL_WindowData *window_data; ++ window_data = (SDL_WindowData *) window->driverdata; ++ ++ if (window_data->gl_context == context) { ++ window_data->gl_context = NULL; ++ } ++ } ++ ++ emscripten_webgl_destroy_context((EMSCRIPTEN_WEBGL_CONTEXT_HANDLE)context); ++} + + int Emscripten_GLES_SwapWindow(_THIS, SDL_Window *window) + { +- EGLBoolean ret = SDL_EGL_SwapBuffers(_this, ((SDL_WindowData *) window->driverdata)->egl_surface); + if (emscripten_has_asyncify() && SDL_GetHintBoolean(SDL_HINT_EMSCRIPTEN_ASYNCIFY, SDL_TRUE)) { + /* give back control to browser for screen refresh */ + emscripten_sleep(0); + } +- return ret; ++ return 0; ++} ++ ++int Emscripten_GLES_MakeCurrent(_THIS, SDL_Window * window, SDL_GLContext context) ++{ ++ /* it isn't possible to reuse contexts across canvases */ ++ if (window && context) { ++ SDL_WindowData *window_data; ++ window_data = (SDL_WindowData *) window->driverdata; ++ ++ if (context != window_data->gl_context) { ++ return SDL_SetError("Cannot make context current to another window"); ++ } ++ } ++ ++ if (emscripten_webgl_make_context_current((EMSCRIPTEN_WEBGL_CONTEXT_HANDLE)context) != EMSCRIPTEN_RESULT_SUCCESS) { ++ return SDL_SetError("Unable to make context current"); ++ } ++ return 0; + } + +-#endif /* SDL_VIDEO_DRIVER_EMSCRIPTEN && SDL_VIDEO_OPENGL_EGL */ ++#endif /* SDL_VIDEO_DRIVER_EMSCRIPTEN */ + + /* vi: set ts=4 sw=4 expandtab: */ +diff --git a/src/video/emscripten/SDL_emscriptenopengles.h b/src/video/emscripten/SDL_emscriptenopengles.h +--- a/src/video/emscripten/SDL_emscriptenopengles.h ++++ b/src/video/emscripten/SDL_emscriptenopengles.h +@@ -23,25 +23,24 @@ + #ifndef SDL_emscriptenopengles_h_ + #define SDL_emscriptenopengles_h_ + +-#if SDL_VIDEO_DRIVER_EMSCRIPTEN && SDL_VIDEO_OPENGL_EGL ++#if SDL_VIDEO_DRIVER_EMSCRIPTEN + + #include "../SDL_sysvideo.h" +-#include "../SDL_egl_c.h" + + /* OpenGLES functions */ +-#define Emscripten_GLES_GetAttribute SDL_EGL_GetAttribute +-#define Emscripten_GLES_GetProcAddress SDL_EGL_GetProcAddress +-#define Emscripten_GLES_UnloadLibrary SDL_EGL_UnloadLibrary +-#define Emscripten_GLES_SetSwapInterval SDL_EGL_SetSwapInterval +-#define Emscripten_GLES_GetSwapInterval SDL_EGL_GetSwapInterval +-#define Emscripten_GLES_DeleteContext SDL_EGL_DeleteContext + + extern int Emscripten_GLES_LoadLibrary(_THIS, const char *path); ++extern void Emscripten_GLES_UnloadLibrary(_THIS); ++extern void * Emscripten_GLES_GetProcAddress(_THIS, const char *proc); ++extern int Emscripten_GLES_SetSwapInterval(_THIS, int interval); ++extern int Emscripten_GLES_GetSwapInterval(_THIS); ++ + extern SDL_GLContext Emscripten_GLES_CreateContext(_THIS, SDL_Window * window); ++extern void Emscripten_GLES_DeleteContext(_THIS, SDL_GLContext context); + extern int Emscripten_GLES_SwapWindow(_THIS, SDL_Window * window); + extern int Emscripten_GLES_MakeCurrent(_THIS, SDL_Window * window, SDL_GLContext context); + +-#endif /* SDL_VIDEO_DRIVER_EMSCRIPTEN && SDL_VIDEO_OPENGL_EGL */ ++#endif /* SDL_VIDEO_DRIVER_EMSCRIPTEN */ + + #endif /* SDL_emscriptenopengles_h_ */ + +diff --git a/src/video/emscripten/SDL_emscriptenvideo.c b/src/video/emscripten/SDL_emscriptenvideo.c +--- a/src/video/emscripten/SDL_emscriptenvideo.c ++++ b/src/video/emscripten/SDL_emscriptenvideo.c +@@ -27,7 +27,6 @@ + #include "SDL_hints.h" + #include "../SDL_sysvideo.h" + #include "../SDL_pixels_c.h" +-#include "../SDL_egl_c.h" + #include "../../events/SDL_events_c.h" + + #include "SDL_emscriptenvideo.h" +@@ -106,7 +105,6 @@ static SDL_VideoDevice *Emscripten_CreateDevice(void) + device->UpdateWindowFramebuffer = Emscripten_UpdateWindowFramebuffer; + device->DestroyWindowFramebuffer = Emscripten_DestroyWindowFramebuffer; + +-#if SDL_VIDEO_OPENGL_EGL + device->GL_LoadLibrary = Emscripten_GLES_LoadLibrary; + device->GL_GetProcAddress = Emscripten_GLES_GetProcAddress; + device->GL_UnloadLibrary = Emscripten_GLES_UnloadLibrary; +@@ -116,7 +114,6 @@ static SDL_VideoDevice *Emscripten_CreateDevice(void) + device->GL_GetSwapInterval = Emscripten_GLES_GetSwapInterval; + device->GL_SwapWindow = Emscripten_GLES_SwapWindow; + device->GL_DeleteContext = Emscripten_GLES_DeleteContext; +-#endif + + device->free = Emscripten_DeleteDevice; + +@@ -247,21 +244,6 @@ static int Emscripten_CreateWindow(_THIS, SDL_Window *window) + } + } + +-#if SDL_VIDEO_OPENGL_EGL +- if (window->flags & SDL_WINDOW_OPENGL) { +- if (!_this->egl_data) { +- if (SDL_GL_LoadLibrary(NULL) < 0) { +- return -1; +- } +- } +- wdata->egl_surface = SDL_EGL_CreateSurface(_this, 0); +- +- if (wdata->egl_surface == EGL_NO_SURFACE) { +- return SDL_SetError("Could not create GLES window surface"); +- } +- } +-#endif +- + wdata->window = window; + + /* Setup driver data for this window */ +@@ -314,12 +296,6 @@ static void Emscripten_DestroyWindow(_THIS, SDL_Window *window) + data = (SDL_WindowData *)window->driverdata; + + Emscripten_UnregisterEventHandlers(data); +-#if SDL_VIDEO_OPENGL_EGL +- if (data->egl_surface != EGL_NO_SURFACE) { +- SDL_EGL_DestroySurface(_this, data->egl_surface); +- data->egl_surface = EGL_NO_SURFACE; +- } +-#endif + + /* We can't destroy the canvas, so resize it to zero instead */ + emscripten_set_canvas_element_size(data->canvas_id, 0, 0); +@@ -341,6 +317,7 @@ static void Emscripten_SetWindowFullscreen(_THIS, SDL_Window *window, SDL_VideoD + SDL_bool is_desktop_fullscreen = (window->flags & SDL_WINDOW_FULLSCREEN_DESKTOP) == SDL_WINDOW_FULLSCREEN_DESKTOP; + int res; + ++ SDL_zero(strategy); + strategy.scaleMode = is_desktop_fullscreen ? EMSCRIPTEN_FULLSCREEN_SCALE_STRETCH : EMSCRIPTEN_FULLSCREEN_SCALE_ASPECT; + + if (!is_desktop_fullscreen) { +diff --git a/src/video/emscripten/SDL_emscriptenvideo.h b/src/video/emscripten/SDL_emscriptenvideo.h +--- a/src/video/emscripten/SDL_emscriptenvideo.h ++++ b/src/video/emscripten/SDL_emscriptenvideo.h +@@ -28,18 +28,13 @@ + #include + #include + +-#if SDL_VIDEO_OPENGL_EGL +-#include +-#endif +- + typedef struct SDL_WindowData + { +-#if SDL_VIDEO_OPENGL_EGL +- EGLSurface egl_surface; +-#endif + SDL_Window *window; + SDL_Surface *surface; + ++ SDL_GLContext gl_context; ++ + char *canvas_id; + + float pixel_ratio; diff --git a/.ci/vcpkg/overlay-ports/sdl2/portfile.cmake b/.ci/vcpkg/overlay-ports/sdl2/portfile.cmake new file mode 100644 index 00000000000..8a6534f0ea5 --- /dev/null +++ b/.ci/vcpkg/overlay-ports/sdl2/portfile.cmake @@ -0,0 +1,142 @@ +vcpkg_from_github( + OUT_SOURCE_PATH SOURCE_PATH + REPO libsdl-org/SDL + REF "release-${VERSION}" + SHA512 d3cf7d356b79184dd211c9fbbfcb2a83d1acb68ee549ab82be109cd899039f18f0dbf3aedbf0800793c3a68580688014863b5d9bf79bcd366ff0e88252955e3c + HEAD_REF main + PATCHES + deps.patch + alsa-dep-fix.patch + emscripten-webgl.patch +) + +string(COMPARE EQUAL "${VCPKG_LIBRARY_LINKAGE}" "static" SDL_STATIC) +string(COMPARE EQUAL "${VCPKG_LIBRARY_LINKAGE}" "dynamic" SDL_SHARED) +string(COMPARE EQUAL "${VCPKG_CRT_LINKAGE}" "static" FORCE_STATIC_VCRT) + +vcpkg_check_features(OUT_FEATURE_OPTIONS FEATURE_OPTIONS + FEATURES + alsa SDL_ALSA + alsa CMAKE_REQUIRE_FIND_PACKAGE_ALSA + ibus SDL_IBUS + samplerate SDL_LIBSAMPLERATE + vulkan SDL_VULKAN + wayland SDL_WAYLAND + x11 SDL_X11 + INVERTED_FEATURES + alsa CMAKE_DISABLE_FIND_PACKAGE_ALSA +) + +if ("x11" IN_LIST FEATURES) + message(WARNING "You will need to install Xorg dependencies to use feature x11:\nsudo apt install libx11-dev libxft-dev libxext-dev\n") +endif() +if ("wayland" IN_LIST FEATURES) + message(WARNING "You will need to install Wayland dependencies to use feature wayland:\nsudo apt install libwayland-dev libxkbcommon-dev libegl1-mesa-dev\n") +endif() +if ("ibus" IN_LIST FEATURES) + message(WARNING "You will need to install ibus dependencies to use feature ibus:\nsudo apt install libibus-1.0-dev\n") +endif() + +if(VCPKG_TARGET_IS_UWP) + set(configure_opts WINDOWS_USE_MSBUILD) +endif() + +if(VCPKG_TARGET_IS_EMSCRIPTEN) + list(APPEND FEATURE_OPTIONS "-DSDL_PTHREADS=ON") +endif() + +vcpkg_cmake_configure( + SOURCE_PATH "${SOURCE_PATH}" + ${configure_opts} + OPTIONS ${FEATURE_OPTIONS} + -DSDL_STATIC=${SDL_STATIC} + -DSDL_SHARED=${SDL_SHARED} + -DSDL_FORCE_STATIC_VCRT=${FORCE_STATIC_VCRT} + -DSDL_LIBC=ON + -DSDL_TEST=OFF + -DSDL_INSTALL_CMAKEDIR="cmake" + -DCMAKE_DISABLE_FIND_PACKAGE_Git=ON + -DPKG_CONFIG_USE_CMAKE_PREFIX_PATH=ON + -DSDL_LIBSAMPLERATE_SHARED=OFF + MAYBE_UNUSED_VARIABLES + SDL_FORCE_STATIC_VCRT + PKG_CONFIG_USE_CMAKE_PREFIX_PATH +) + +vcpkg_cmake_install() +vcpkg_cmake_config_fixup(CONFIG_PATH cmake) + +file(REMOVE_RECURSE + "${CURRENT_PACKAGES_DIR}/debug/include" + "${CURRENT_PACKAGES_DIR}/debug/share" + "${CURRENT_PACKAGES_DIR}/bin/sdl2-config" + "${CURRENT_PACKAGES_DIR}/debug/bin/sdl2-config" + "${CURRENT_PACKAGES_DIR}/SDL2.framework" + "${CURRENT_PACKAGES_DIR}/debug/SDL2.framework" + "${CURRENT_PACKAGES_DIR}/share/licenses" + "${CURRENT_PACKAGES_DIR}/share/aclocal" +) + +file(GLOB BINS "${CURRENT_PACKAGES_DIR}/debug/bin/*" "${CURRENT_PACKAGES_DIR}/bin/*") +if(NOT BINS) + file(REMOVE_RECURSE + "${CURRENT_PACKAGES_DIR}/bin" + "${CURRENT_PACKAGES_DIR}/debug/bin" + ) +endif() + +if(VCPKG_TARGET_IS_WINDOWS AND NOT VCPKG_TARGET_IS_UWP AND NOT VCPKG_TARGET_IS_MINGW) + if(NOT DEFINED VCPKG_BUILD_TYPE OR VCPKG_BUILD_TYPE STREQUAL "release") + file(MAKE_DIRECTORY "${CURRENT_PACKAGES_DIR}/lib/manual-link") + file(RENAME "${CURRENT_PACKAGES_DIR}/lib/SDL2main.lib" "${CURRENT_PACKAGES_DIR}/lib/manual-link/SDL2main.lib") + endif() + if(NOT DEFINED VCPKG_BUILD_TYPE OR VCPKG_BUILD_TYPE STREQUAL "debug") + file(MAKE_DIRECTORY "${CURRENT_PACKAGES_DIR}/debug/lib/manual-link") + file(RENAME "${CURRENT_PACKAGES_DIR}/debug/lib/SDL2maind.lib" "${CURRENT_PACKAGES_DIR}/debug/lib/manual-link/SDL2maind.lib") + endif() + + file(GLOB SHARE_FILES "${CURRENT_PACKAGES_DIR}/share/sdl2/*.cmake") + foreach(SHARE_FILE ${SHARE_FILES}) + vcpkg_replace_string("${SHARE_FILE}" "lib/SDL2main" "lib/manual-link/SDL2main") + endforeach() +endif() + +vcpkg_copy_pdbs() + +set(DYLIB_COMPATIBILITY_VERSION_REGEX "set\\(DYLIB_COMPATIBILITY_VERSION (.+)\\)") +set(DYLIB_CURRENT_VERSION_REGEX "set\\(DYLIB_CURRENT_VERSION (.+)\\)") +file(STRINGS "${SOURCE_PATH}/CMakeLists.txt" DYLIB_COMPATIBILITY_VERSION REGEX ${DYLIB_COMPATIBILITY_VERSION_REGEX}) +file(STRINGS "${SOURCE_PATH}/CMakeLists.txt" DYLIB_CURRENT_VERSION REGEX ${DYLIB_CURRENT_VERSION_REGEX}) +string(REGEX REPLACE ${DYLIB_COMPATIBILITY_VERSION_REGEX} "\\1" DYLIB_COMPATIBILITY_VERSION "${DYLIB_COMPATIBILITY_VERSION}") +string(REGEX REPLACE ${DYLIB_CURRENT_VERSION_REGEX} "\\1" DYLIB_CURRENT_VERSION "${DYLIB_CURRENT_VERSION}") + +if(NOT DEFINED VCPKG_BUILD_TYPE OR VCPKG_BUILD_TYPE STREQUAL "debug") + vcpkg_replace_string("${CURRENT_PACKAGES_DIR}/debug/lib/pkgconfig/sdl2.pc" "-lSDL2main" "-lSDL2maind") + vcpkg_replace_string("${CURRENT_PACKAGES_DIR}/debug/lib/pkgconfig/sdl2.pc" "-lSDL2 " "-lSDL2d ") + vcpkg_replace_string("${CURRENT_PACKAGES_DIR}/debug/lib/pkgconfig/sdl2.pc" "-lSDL2-static " "-lSDL2-staticd ") +endif() + +if(VCPKG_LIBRARY_LINKAGE STREQUAL "dynamic" AND VCPKG_TARGET_IS_WINDOWS AND NOT VCPKG_TARGET_IS_MINGW) + if(NOT DEFINED VCPKG_BUILD_TYPE OR VCPKG_BUILD_TYPE STREQUAL "release") + vcpkg_replace_string("${CURRENT_PACKAGES_DIR}/lib/pkgconfig/sdl2.pc" "-lSDL2-static " " ") + endif() + if(NOT DEFINED VCPKG_BUILD_TYPE OR VCPKG_BUILD_TYPE STREQUAL "debug") + vcpkg_replace_string("${CURRENT_PACKAGES_DIR}/debug/lib/pkgconfig/sdl2.pc" "-lSDL2-staticd " " ") + endif() +endif() + +if(VCPKG_TARGET_IS_UWP) + if(NOT DEFINED VCPKG_BUILD_TYPE OR VCPKG_BUILD_TYPE STREQUAL "release") + vcpkg_replace_string("${CURRENT_PACKAGES_DIR}/lib/pkgconfig/sdl2.pc" "$<$:d>.lib" "") + vcpkg_replace_string("${CURRENT_PACKAGES_DIR}/lib/pkgconfig/sdl2.pc" "-l-nodefaultlib:" "-nodefaultlib:") + endif() + if(NOT DEFINED VCPKG_BUILD_TYPE OR VCPKG_BUILD_TYPE STREQUAL "debug") + vcpkg_replace_string("${CURRENT_PACKAGES_DIR}/debug/lib/pkgconfig/sdl2.pc" "$<$:d>.lib" "d") + vcpkg_replace_string("${CURRENT_PACKAGES_DIR}/debug/lib/pkgconfig/sdl2.pc" "-l-nodefaultlib:" "-nodefaultlib:") + endif() +endif() + +vcpkg_fixup_pkgconfig() + +file(INSTALL "${CMAKE_CURRENT_LIST_DIR}/usage" DESTINATION "${CURRENT_PACKAGES_DIR}/share/${PORT}") +vcpkg_install_copyright(FILE_LIST "${SOURCE_PATH}/LICENSE.txt") diff --git a/.ci/vcpkg/overlay-ports/sdl2/usage b/.ci/vcpkg/overlay-ports/sdl2/usage new file mode 100644 index 00000000000..1cddcd46ffc --- /dev/null +++ b/.ci/vcpkg/overlay-ports/sdl2/usage @@ -0,0 +1,8 @@ +sdl2 provides CMake targets: + + find_package(SDL2 CONFIG REQUIRED) + target_link_libraries(main + PRIVATE + $ + $,SDL2::SDL2,SDL2::SDL2-static> + ) diff --git a/.ci/vcpkg/overlay-ports/sdl2/vcpkg.json b/.ci/vcpkg/overlay-ports/sdl2/vcpkg.json new file mode 100644 index 00000000000..d0932679abf --- /dev/null +++ b/.ci/vcpkg/overlay-ports/sdl2/vcpkg.json @@ -0,0 +1,69 @@ +{ + "name": "sdl2", + "version": "2.28.5", + "port-version": 3, + "description": "Simple DirectMedia Layer is a cross-platform development library designed to provide low level access to audio, keyboard, mouse, joystick, and graphics hardware via OpenGL and Direct3D.", + "homepage": "https://www.libsdl.org/download-2.0.php", + "license": "Zlib", + "dependencies": [ + { + "name": "dbus", + "default-features": false, + "platform": "linux" + }, + { + "name": "vcpkg-cmake", + "host": true + }, + { + "name": "vcpkg-cmake-config", + "host": true + } + ], + "default-features": [ + { + "name": "ibus", + "platform": "linux" + }, + { + "name": "wayland", + "platform": "linux" + }, + { + "name": "x11", + "platform": "linux" + } + ], + "features": { + "alsa": { + "description": "Support for alsa audio", + "dependencies": [ + { + "name": "alsa", + "platform": "linux" + } + ] + }, + "ibus": { + "description": "Build with ibus IME support", + "supports": "linux" + }, + "samplerate": { + "description": "Use libsamplerate for audio rate conversion", + "dependencies": [ + "libsamplerate" + ] + }, + "vulkan": { + "description": "Vulkan functionality for SDL" + }, + "wayland": { + "description": "Build with Wayland support", + "supports": "linux" + }, + "x11": { + "description": "Build with X11 support", + "supports": "!windows" + } + } +} diff --git a/.ci/vcpkg/overlay-triplets/wasm32-emscripten.cmake b/.ci/vcpkg/overlay-triplets/wasm32-emscripten.cmake new file mode 100644 index 00000000000..7f9ecc1b227 --- /dev/null +++ b/.ci/vcpkg/overlay-triplets/wasm32-emscripten.cmake @@ -0,0 +1,32 @@ +set(VCPKG_ENV_PASSTHROUGH_UNTRACKED EMSCRIPTEN_ROOT EMSDK PATH) + +if(NOT DEFINED ENV{EMSCRIPTEN_ROOT}) + find_path(EMSCRIPTEN_ROOT "emcc") +else() + set(EMSCRIPTEN_ROOT "$ENV{EMSCRIPTEN_ROOT}") +endif() + +if(NOT EMSCRIPTEN_ROOT) + if(NOT DEFINED ENV{EMSDK}) + message(FATAL_ERROR "The emcc compiler not found in PATH") + endif() + set(EMSCRIPTEN_ROOT "$ENV{EMSDK}/upstream/emscripten") +endif() + +if(NOT EXISTS "${EMSCRIPTEN_ROOT}/cmake/Modules/Platform/Emscripten.cmake") + message(FATAL_ERROR "Emscripten.cmake toolchain file not found") +endif() + +# Get the path to *this* triplet file, and then back up to get the known path to the meta-toolchain in WZ's repo +get_filename_component(WZ_WASM_META_TOOLCHAIN "${CMAKE_CURRENT_LIST_DIR}/../../emscripten/toolchain/Toolchain-Emscripten.cmake" ABSOLUTE) +if(NOT EXISTS "${WZ_WASM_META_TOOLCHAIN}") + message(FATAL_ERROR "Failed to find WZ's Toolchain-Emscripten.cmake") +endif() + +set(VCPKG_TARGET_ARCHITECTURE wasm32) +set(VCPKG_CRT_LINKAGE dynamic) +set(VCPKG_LIBRARY_LINKAGE static) +set(VCPKG_CMAKE_SYSTEM_NAME Emscripten) +set(VCPKG_CHAINLOAD_TOOLCHAIN_FILE "${WZ_WASM_META_TOOLCHAIN}") + +set(VCPKG_BUILD_TYPE "release") diff --git a/3rdparty/CMakeLists.txt b/3rdparty/CMakeLists.txt index 3637a62ed63..65187258c73 100644 --- a/3rdparty/CMakeLists.txt +++ b/3rdparty/CMakeLists.txt @@ -54,6 +54,9 @@ set(SQLITECPP_RUN_CPPLINT OFF CACHE BOOL "Run cpplint.py tool for Google C++ Sty set(SQLITECPP_RUN_CPPCHECK OFF CACHE BOOL "Run cppcheck C++ static analysis tool." FORCE) set(SQLITECPP_BUILD_EXAMPLES OFF CACHE BOOL "Build examples." FORCE) set(SQLITECPP_BUILD_TESTS OFF CACHE BOOL "Build and run tests." FORCE) +if(CMAKE_SYSTEM_NAME MATCHES "Emscripten") + set(SQLITECPP_USE_STACK_PROTECTION OFF CACHE BOOL "USE Stack Protection hardening." FORCE) +endif() add_subdirectory(SQLiteCpp EXCLUDE_FROM_ALL) set_property(TARGET SQLiteCpp PROPERTY FOLDER "3rdparty") diff --git a/CMakeLists.txt b/CMakeLists.txt index a57272e9d32..3ff20101610 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -14,14 +14,21 @@ endif() include(CMakeDependentOption) -OPTION(ENABLE_DOCS "Enable documentation generation" ON) -OPTION(ENABLE_NLS "Native Language Support" ON) OPTION(WZ_ENABLE_WARNINGS "Enable (additional) warnings" OFF) OPTION(WZ_ENABLE_WARNINGS_AS_ERRORS "Enable compiler flags that treat (most) warnings as errors" ON) -OPTION(WZ_ENABLE_BACKEND_VULKAN "Enable Vulkan backend" ON) OPTION(WZ_ENABLE_BASIS_UNIVERSAL "Enable Basis Universal texture support" ON) OPTION(WZ_DEBUG_GFX_API_LEAKS "Enable debugging for graphics API leaks" ON) OPTION(WZ_FORCE_MINIMAL_OPUSFILE "Force a minimal build of Opusfile, since WZ does not need (or want) HTTP stream support" ON) +OPTION(ENABLE_NLS "Native Language Support" ON) +if(NOT CMAKE_SYSTEM_NAME MATCHES "Emscripten") + OPTION(ENABLE_DOCS "Enable documentation generation" ON) + OPTION(WZ_ENABLE_BACKEND_VULKAN "Enable Vulkan backend" ON) + set(WZ_USE_STACK_PROTECTION ON CACHE BOOL "Use Stack Protection hardening." FORCE) +else() + set(WZ_SKIP_ADDITIONAL_FONTS ON CACHE BOOL "Skip additional fonts (used to display CJK glyphs)" FORCE) + set(WZ_USE_STACK_PROTECTION OFF CACHE BOOL "Use Stack Protection hardening." FORCE) + OPTION(WZ_EMSCRIPTEN_COMPRESS_OUTPUT "Compress Emscripten output (generate .gz files)" ON) +endif() # Dev options OPTION(WZ_PROFILING_NVTX "Add NVTX-based profiling instrumentation to the code" OFF) @@ -93,6 +100,12 @@ if(NOT DEFINED WZ_DATADIR) set(WZ_DATADIR "${CMAKE_INSTALL_DATAROOTDIR}/warzone2100${WZ_OUTPUT_NAME_SUFFIX}") endif() endif() +if(NOT DEFINED WZ_LOCALEDIR) + set(WZ_LOCALEDIR "${CMAKE_INSTALL_LOCALEDIR}") + if(CMAKE_SYSTEM_NAME MATCHES "Emscripten") + set(WZ_LOCALEDIR "/share/locale") + endif() +endif() if(CMAKE_SYSTEM_NAME MATCHES "Windows") if(NOT CMAKE_INSTALL_BINDIR STREQUAL "bin") # Windows builds expect a non-empty BINDIR @@ -133,6 +146,63 @@ if(CMAKE_HOST_SYSTEM_NAME MATCHES "Darwin") list(APPEND CMAKE_PREFIX_PATH "/usr/local/opt/gettext") endif() +if(CMAKE_SYSTEM_NAME MATCHES "Emscripten") + # Check minimum Emscripten version + if (NOT "${EMSCRIPTEN_VERSION}" VERSION_GREATER 3.1.0) + message(FATAL_ERROR "Emscripten version must be at least 3.1.0") + endif() + + # For Emscripten, we must currently get the following packages from Emscripten ports: + # - SDL2 # NOT FOR NOW - must use vcpkg port as it has custom patches + # - Freetype + # - Harfbuzz + # We must also specify: + # - Pthread support + # - Exception support + # - WebGL 2.0 support [LINK - see src/CMakeLists.txt] + # - Fetch API [LINK - see src/CMakeLists.txt] + + set(COMP_AND_LINK_FLAGS "-fwasm-exceptions") + if (WZ_EMSCRIPTEN_ENABLE_ASAN) + set(COMP_AND_LINK_FLAGS "${COMP_AND_LINK_FLAGS} -fsanitize=address -fsanitize-recover=address") + endif() + #set(USE_FLAGS "-s USE_PTHREADS=1 -s USE_SDL=2 -s USE_FREETYPE=1 -s USE_HARFBUZZ=1") + set(USE_FLAGS "-s USE_PTHREADS=1 -s USE_SDL=0 -s USE_FREETYPE=1 -s USE_HARFBUZZ=1") + + # Set various flags and executable settings + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} ${COMP_AND_LINK_FLAGS} ${USE_FLAGS}") + set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} ${COMP_AND_LINK_FLAGS} ${USE_FLAGS}") + set(CMAKE_EXECUTABLE_SUFFIX .html) + + # -fwasm-exceptions must be passed to linker as well + add_link_options( + "$<$:-fwasm-exceptions>" + ) + if (WZ_EMSCRIPTEN_ENABLE_ASAN) + add_link_options( + "$<$:-fsanitize=address>" + "$<$:-fsanitize-recover=address>" + ) + endif() + + # enable separate-dwarf debug info for Debug/RelWithDebInfo builds + set(debug_builds_only "$,$>") + add_compile_options( + "$<$,${debug_builds_only}>:-gseparate-dwarf>" + ) + add_link_options( + "$<$,${debug_builds_only}>:-gseparate-dwarf>" + ) + if (WZ_EMSCRIPTEN_ENABLE_ASAN) + add_compile_options( + #"$<$,${debug_builds_only}>:-gsource-map>" + ) + add_link_options( + #"$<$,${debug_builds_only}>:-gsource-map>" + ) + endif() +endif() + INCLUDE(AddTargetLinkFlagsIfSupported) # Use "-fPIC" / "-fPIE" for all targets by default, including static libs @@ -163,8 +233,8 @@ if("${CMAKE_CXX_COMPILER_ID}" MATCHES "MSVC") # Default stack size is 1MB - increase to better match other platforms set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} /STACK:8000000") -elseif("${CMAKE_CXX_COMPILER_ID}" MATCHES "GNU|Clang" AND NOT APPLE) - # Ensure all builds always have debug info built (Xcode is handled separately below) +elseif("${CMAKE_CXX_COMPILER_ID}" MATCHES "GNU|Clang" AND NOT APPLE AND NOT CMAKE_SYSTEM_NAME MATCHES "Emscripten") + # Ensure all builds always have debug info built (Xcode is handled separately below, Emscripten handled above) set(CMAKE_CXX_FLAGS_RELEASE "${CMAKE_CXX_FLAGS_RELEASE} -g") # Ensure symbols can be demangled on Linux Debug builds set(CMAKE_CXX_FLAGS_DEBUG "${CMAKE_CXX_FLAGS_DEBUG} -rdynamic") @@ -199,34 +269,36 @@ endif() include(CheckCCompilerFlag) include(CheckCXXCompilerFlag) -# Enable stack protection, if supported by the compiler -# Prefer -fstack-protector-strong if supported, fall-back to -fstack-protector -check_c_compiler_flag(-fstack-protector-strong HAS_CFLAG_FSTACK_PROTECTOR_STRONG) -if (HAS_CFLAG_FSTACK_PROTECTOR_STRONG) - set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -fstack-protector-strong") -else() - check_c_compiler_flag(-fstack-protector HAS_CFLAG_FSTACK_PROTECTOR) - if (HAS_CFLAG_FSTACK_PROTECTOR) - set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -fstack-protector") +if (WZ_USE_STACK_PROTECTION) + # Enable stack protection, if supported by the compiler + # Prefer -fstack-protector-strong if supported, fall-back to -fstack-protector + check_c_compiler_flag(-fstack-protector-strong HAS_CFLAG_FSTACK_PROTECTOR_STRONG) + if (HAS_CFLAG_FSTACK_PROTECTOR_STRONG) + set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -fstack-protector-strong") + else() + check_c_compiler_flag(-fstack-protector HAS_CFLAG_FSTACK_PROTECTOR) + if (HAS_CFLAG_FSTACK_PROTECTOR) + set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -fstack-protector") + endif() endif() -endif() -check_cxx_compiler_flag(-fstack-protector-strong HAS_CXXFLAG_FSTACK_PROTECTOR_STRONG) -if (HAS_CXXFLAG_FSTACK_PROTECTOR_STRONG) - set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fstack-protector-strong") -else() - check_cxx_compiler_flag(-fstack-protector HAS_CXXFLAG_FSTACK_PROTECTOR) - if (HAS_CXXFLAG_FSTACK_PROTECTOR) - set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fstack-protector") + check_cxx_compiler_flag(-fstack-protector-strong HAS_CXXFLAG_FSTACK_PROTECTOR_STRONG) + if (HAS_CXXFLAG_FSTACK_PROTECTOR_STRONG) + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fstack-protector-strong") + else() + check_cxx_compiler_flag(-fstack-protector HAS_CXXFLAG_FSTACK_PROTECTOR) + if (HAS_CXXFLAG_FSTACK_PROTECTOR) + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fstack-protector") + endif() + endif() + # Enable -fstack-clash-protection if available + check_c_compiler_flag(-fstack-clash-protection HAS_CFLAG_FSTACK_CLASH_PROTECTION) + if (HAS_CFLAG_FSTACK_CLASH_PROTECTION AND NOT (MINGW OR APPLE)) + set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -fstack-clash-protection") + endif() + check_cxx_compiler_flag(-fstack-clash-protection HAS_CXXFLAG_FSTACK_CLASH_PROTECTION) + if (HAS_CXXFLAG_FSTACK_CLASH_PROTECTION AND NOT (MINGW OR APPLE)) + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fstack-clash-protection") endif() -endif() -# Enable -fstack-clash-protection if available -check_c_compiler_flag(-fstack-clash-protection HAS_CFLAG_FSTACK_CLASH_PROTECTION) -if (HAS_CFLAG_FSTACK_CLASH_PROTECTION AND NOT (MINGW OR APPLE)) - set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -fstack-clash-protection") -endif() -check_cxx_compiler_flag(-fstack-clash-protection HAS_CXXFLAG_FSTACK_CLASH_PROTECTION) -if (HAS_CXXFLAG_FSTACK_CLASH_PROTECTION AND NOT (MINGW OR APPLE)) - set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fstack-clash-protection") endif() include(CheckCompilerFlagsOutput) @@ -455,6 +527,11 @@ macro(CONFIGURE_WZ_COMPILER_WARNINGS) set(CMAKE_XCODE_ATTRIBUTE_CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK YES) # -Warc-repeated-use-of-weak set(CMAKE_XCODE_ATTRIBUTE_CLANG_WARN__ARC_BRIDGE_CAST_NONARC YES) # -Warc-bridge-casts-disallowed-in-nonarc + elseif(CMAKE_SYSTEM_NAME MATCHES "Emscripten") + + list(APPEND WZ_TARGET_ADDITIONAL_C_BUILD_FLAGS -fno-common -fno-math-errno -fno-rounding-math -ffp-model=precise) + list(APPEND WZ_TARGET_ADDITIONAL_CXX_BUILD_FLAGS -fno-common -fno-math-errno -fno-rounding-math -ffp-model=precise) + else() # GCC, Clang, etc # Comments are provided next to each warning option detailing expected compiler support (from GCC 3.4+, Clang 3.2+ - earlier versions may / may not support these options) @@ -735,8 +812,14 @@ CHECK_FUNCTION_EXISTS(gettext HAVE_GETTEXT) CHECK_FUNCTION_EXISTS(iconv HAVE_ICONV) CHECK_CXX_SYMBOL_EXISTS(strlcat "string.h" HAVE_SYSTEM_STRLCAT) CHECK_CXX_SYMBOL_EXISTS(strlcpy "string.h" HAVE_SYSTEM_STRLCPY) -CHECK_CXX_SYMBOL_EXISTS(strlcat "string.h" HAVE_VALID_STRLCAT) -CHECK_CXX_SYMBOL_EXISTS(strlcpy "string.h" HAVE_VALID_STRLCPY) +if (NOT EMSCRIPTEN) + CHECK_CXX_SYMBOL_EXISTS(strlcpy "string.h" HAVE_VALID_STRLCPY) + CHECK_CXX_SYMBOL_EXISTS(strlcat "string.h" HAVE_VALID_STRLCAT) +else() + # Emscripten's implementation currently leads to ASAN errors, so disable the built-in and use WZ's custom backup implementation + set(HAVE_VALID_STRLCPY OFF CACHE BOOL "Have valid strlcpy" FORCE) + set(HAVE_VALID_STRLCAT OFF CACHE BOOL "Have valid strlcat" FORCE) +endif() CHECK_CXX_SYMBOL_EXISTS(putenv "stdlib.h" HAVE_PUTENV) CHECK_CXX_SYMBOL_EXISTS(setenv "stdlib.h" HAVE_SETENV) CHECK_CXX_SYMBOL_EXISTS(posix_spawn "spawn.h" HAVE_POSIX_SPAWN) @@ -760,7 +843,6 @@ else() endif() set(WZ_BINDIR "${CMAKE_INSTALL_BINDIR}") -set(WZ_LOCALEDIR "${CMAKE_INSTALL_LOCALEDIR}") message(STATUS "WZ_BINDIR=\"${WZ_BINDIR}\"") message(STATUS "WZ_LOCALEDIR=\"${WZ_LOCALEDIR}\"") function(CHECK_IS_ABSOLUTE_PATH _var _output) diff --git a/cmake/EmscriptenCompressZip.cmake b/cmake/EmscriptenCompressZip.cmake new file mode 100644 index 00000000000..f4bd03b14c9 --- /dev/null +++ b/cmake/EmscriptenCompressZip.cmake @@ -0,0 +1,155 @@ +# +# Provides a function COMPRESS_ZIP that is compatible with the function in FindZIP.cmake, but just copies the files to a folder +# +# +# Copyright © 2018-2024 pastdue ( https://github.com/past-due/ ) and contributors +# License: MIT License ( https://opensource.org/licenses/MIT ) +# +# Script Version: 2024-01-26a +# + +cmake_minimum_required(VERSION 3.5...3.24) + +set(_THIS_MODULE_BASE_DIR "${CMAKE_CURRENT_LIST_DIR}") + +# COMPRESS_ZIP(OUTPUT outputFile +# [COMPRESSION_LEVEL <0 | 1 | 3 | 5 | 7 | 9>] +# PATHS [files...] [WORKING_DIRECTORY dir] +# [PATHS [files...] [WORKING_DIRECTORY dir]] +# [DEPENDS [depends...]] +# [BUILD_ALWAYS_TARGET [target name]] +# [IGNORE_GIT] +# [QUIET]) +# +# Compress a list of files / folders into a ZIP file, named . +# Any directories specified will cause the directory's contents to be recursively included. +# +# If COMPRESSION_LEVEL is specified, the ZIP compression level setting will be passed +# through to the ZIP_EXECUTABLE. A compression level of "0" means no compression. +# +# If WORKING_DIRECTORY is specified, the WORKING_DIRECTORY will be set for the execution of +# the ZIP_EXECUTABLE. +# +# Each set of PATHS may also optionally specify an associated WORKING_DIRECTORY. +# +# DEPENDS may be used to specify additional dependencies, which are appended to the +# auto-generated list of dependencies used for the internal call to `add_custom_command`. +# +# If BUILD_ALWAYS_TARGET is specified, uses add_custom_target to create a target that is always built. +# +# QUIET attempts to suppress (most) output from the ZIP_EXECUTABLE that is used. +# (This option may have no effect, if unsupported by the ZIP_EXECUTABLE.) +# +function(COMPRESS_ZIP) + + set(_options ALL IGNORE_GIT QUIET) + set(_oneValueArgs OUTPUT COMPRESSION_LEVEL BUILD_ALWAYS_TARGET) #WORKING_DIRECTORY) + set(_multiValueArgs DEPENDS) + + CMAKE_PARSE_ARGUMENTS(_parsedArguments "${_options}" "${_oneValueArgs}" "${_multiValueArgs}" ${ARGN}) + + # Check that OUTPUT was provided + if(NOT _parsedArguments_OUTPUT) + message( FATAL_ERROR "Missing required OUTPUT parameter" ) + endif() + + # Check arguments "unparsed" by CMAKE_PARSE_ARGUMENTS for PATHS sets + set(_COMMAND_LIST) + set(_depends_PATHS) + set(_inPATHSet FALSE) + set(_expecting_WORKINGDIR FALSE) + unset(_currentPATHSet_PATHS) + unset(_currentPATHSet_WORKINGDIR) + foreach(currentArg ${_parsedArguments_UNPARSED_ARGUMENTS}) + if("${currentArg}" STREQUAL "PATHS") + if(_expecting_WORKINGDIR) + # Provided "WORKING_DIRECTORY" keyword, but no variable after it + message( FATAL_ERROR "WORKING_DIRECTORY keyword provided, but missing variable afterwards" ) + endif() + if(_inPATHSet AND DEFINED _currentPATHSet_PATHS) + # Ending one non-empty PATH set, beginning another + if(NOT DEFINED _currentPATHSet_WORKINGDIR) + set(_currentPATHSet_WORKINGDIR "${CMAKE_CURRENT_SOURCE_DIR}") + endif() + foreach (_path ${_currentPATHSet_PATHS}) + list(APPEND _COMMAND_LIST + COMMAND + ${CMAKE_COMMAND} -E chdir ${_currentPATHSet_WORKINGDIR} + ${CMAKE_COMMAND} -DSOURCE=${_path} -DDEST_DIR=${_parsedArguments_OUTPUT} -P ${_THIS_MODULE_BASE_DIR}/EmscriptenCompressZipCopy.cmake + ) + set(_dependPath "${_currentPATHSet_WORKINGDIR}/${_path}") +# list(APPEND _COMMAND_LIST +# COMMAND +# ${CMAKE_COMMAND} -DSOURCE=${_dependPath} -DDEST_DIR=${_parsedArguments_OUTPUT} -P ${_THIS_MODULE_BASE_DIR}/EmscriptenCompressZipCopy.cmake +# ) + list(APPEND _depends_PATHS "${_dependPath}") + endforeach() + endif() + set(_inPATHSet TRUE) + unset(_currentPATHSet_PATHS) + unset(_currentPATHSet_WORKINGDIR) + elseif("${currentArg}" STREQUAL "WORKING_DIRECTORY") + if(NOT _inPATHSet) + message( FATAL_ERROR "WORKING_DIRECTORY must be specified at end of PATHS set" ) + endif() + if(_expecting_WORKINGDIR) + message( FATAL_ERROR "Duplicate WORKING_DIRECTORY keyword" ) + endif() + if(DEFINED _currentPATHSet_WORKINGDIR) + message( FATAL_ERROR "PATHS set has more than one WORKING_DIRECTORY keyword" ) + endif() + set(_expecting_WORKINGDIR TRUE) + elseif(_expecting_WORKINGDIR) + set(_currentPATHSet_WORKINGDIR "${currentArg}") + set(_expecting_WORKINGDIR FALSE) + elseif(_inPATHSet) + # Treat argument as a PATH + list(APPEND _currentPATHSet_PATHS "${currentArg}") + else() + # Unexpected argument + message( FATAL_ERROR "Unexpected argument: ${currentArg}" ) + endif() + endforeach() + if(_expecting_WORKINGDIR) + # Provided "WORKING_DIRECTORY" keyword, but no variable after it + message( FATAL_ERROR "WORKING_DIRECTORY keyword provided, but missing variable afterwards" ) + endif() + if(_inPATHSet AND DEFINED _currentPATHSet_PATHS) + # Ending one non-empty PATH set + if(NOT DEFINED _currentPATHSet_WORKINGDIR) + set(_currentPATHSet_WORKINGDIR "${CMAKE_CURRENT_SOURCE_DIR}") + endif() + foreach (_path ${_currentPATHSet_PATHS}) + set(_dependPath "${_currentPATHSet_WORKINGDIR}/${_path}") + list(APPEND _COMMAND_LIST + COMMAND + ${CMAKE_COMMAND} -E chdir ${_currentPATHSet_WORKINGDIR} + ${CMAKE_COMMAND} -DSOURCE=${_path} -DDEST_DIR=${_parsedArguments_OUTPUT} -P ${_THIS_MODULE_BASE_DIR}/EmscriptenCompressZipCopy.cmake + ) + list(APPEND _depends_PATHS "${_dependPath}") + endforeach() + endif() + + if(_parsedArguments_DEPENDS) + list(APPEND _depends_PATHS ${_parsedArguments_DEPENDS}) + endif() + + if(NOT _parsedArguments_BUILD_ALWAYS_TARGET) + add_custom_command( + OUTPUT "${_parsedArguments_OUTPUT}" + ${_COMMAND_LIST} + DEPENDS ${_depends_PATHS} + WORKING_DIRECTORY "${_workingDirectory}" + VERBATIM + ) + else() + add_custom_target( + ${_parsedArguments_BUILD_ALWAYS_TARGET} ALL + ${_COMMAND_LIST} + DEPENDS ${_depends_PATHS} + WORKING_DIRECTORY "${_workingDirectory}" + VERBATIM + ) + endif() + +endfunction() diff --git a/cmake/EmscriptenCompressZipCopy.cmake b/cmake/EmscriptenCompressZipCopy.cmake new file mode 100644 index 00000000000..72556ec46d0 --- /dev/null +++ b/cmake/EmscriptenCompressZipCopy.cmake @@ -0,0 +1,11 @@ +# SOURCE denotes the file or directory to copy. +# DEST_DIR denotes the directory for copy to. +if(IS_DIRECTORY "${SOURCE}") + set(SOURCE "${SOURCE}/") + set(_dest_subdir "${SOURCE}") +else() + get_filename_component(_dest_subdir "${SOURCE}" DIRECTORY) +endif() +file(COPY "${SOURCE}" DESTINATION "${DEST_DIR}/${_dest_subdir}" + PATTERN ".git*" EXCLUDE +) diff --git a/cmake/WZVcpkgInit.cmake b/cmake/WZVcpkgInit.cmake index 1be0867bcd6..efdb6ae5420 100644 --- a/cmake/WZVcpkgInit.cmake +++ b/cmake/WZVcpkgInit.cmake @@ -20,6 +20,11 @@ if(NOT DEFINED VCPKG_OVERLAY_TRIPLETS) list(APPEND VCPKG_OVERLAY_TRIPLETS "${_build_dir_overlay_triplets}") endif() unset(_build_dir_overlay_triplets) + set(_ci_dir_overlay_triplets "${CMAKE_CURRENT_SOURCE_DIR}/.ci/vcpkg/overlay-triplets") + if(EXISTS "${_ci_dir_overlay_triplets}" AND IS_DIRECTORY "${_ci_dir_overlay_triplets}") + list(APPEND VCPKG_OVERLAY_TRIPLETS "${_ci_dir_overlay_triplets}") + endif() + unset(_ci_dir_overlay_triplets) if(DEFINED VCPKG_OVERLAY_TRIPLETS) set(VCPKG_OVERLAY_TRIPLETS "${VCPKG_OVERLAY_TRIPLETS}" CACHE STRING "") endif() @@ -37,3 +42,9 @@ if(NOT DEFINED VCPKG_OVERLAY_PORTS OR VCPKG_OVERLAY_PORTS STREQUAL "") set(VCPKG_OVERLAY_PORTS "${VCPKG_OVERLAY_PORTS}" CACHE STRING "") endif() endif() + +if(VCPKG_TARGET_TRIPLET MATCHES "wasm32-emscripten") + if(NOT DEFINED VCPKG_CHAINLOAD_TOOLCHAIN_FILE) + set(VCPKG_CHAINLOAD_TOOLCHAIN_FILE "${CMAKE_CURRENT_SOURCE_DIR}/.ci/emscripten/toolchain/Toolchain-Emscripten.cmake") + endif() +endif() diff --git a/data/CMakeLists.txt b/data/CMakeLists.txt index 4d8fb935522..f8a37fc20c3 100644 --- a/data/CMakeLists.txt +++ b/data/CMakeLists.txt @@ -8,7 +8,12 @@ endif() OPTION(WZ_INCLUDE_TERRAIN_HIGH "Include high terrain texture pack" ON) OPTION(WZ_DOWNLOAD_PREBUILT_PACKAGES "Download prebuilt texture packages (if OFF, will generate from scratch - this can take a while to encode textures)" ON) -find_package(ZIP REQUIRED) +if(CMAKE_SYSTEM_NAME MATCHES "Emscripten") + # "Fake" COMPRESS_ZIP that just stages the needed files in folders + include(EmscriptenCompressZip) +else() + find_package(ZIP REQUIRED) +endif() ########################### # Prebuilt package DL info @@ -221,7 +226,45 @@ if(WZ_ENABLE_BASIS_UNIVERSAL AND NOT WZ_CI_DISABLE_BASIS_COMPRESS_TEXTURES) endif() WZ_BASIS_ENCODE_TEXTURES(OUTPUT_DIR "${_output_dir}" TYPE "TEXTURE" RESIZE "${_terrain_max_size}" RDO ENCODING_TARGET texture_encoding TARGET_FOLDER data ALL FILES ${TEXPAGES_TERRAIN}) - set(PROCESSED_TEXTURE_FILES ${TEXPAGES_TERRAIN}) + set(PROCESSED_TEXTURE_FILES "${TEXPAGES_TERRAIN}") + + if(CMAKE_SYSTEM_NAME MATCHES "Emscripten") + # Additional Normal Texpages (that are 1024x1024) + file(GLOB TEXPAGES_BASE_1024 + "${CMAKE_CURRENT_SOURCE_DIR}/base/texpages/page-8-player-buildings-bases-rockies.png" + "${CMAKE_CURRENT_SOURCE_DIR}/base/texpages/page-8-player-buildings-bases-urban.png" + "${CMAKE_CURRENT_SOURCE_DIR}/base/texpages/page-8-player-buildings-bases.png" + "${CMAKE_CURRENT_SOURCE_DIR}/base/texpages/page-9-player-buildings-bases-rockies.png" + "${CMAKE_CURRENT_SOURCE_DIR}/base/texpages/page-9-player-buildings-bases-urban.png" + "${CMAKE_CURRENT_SOURCE_DIR}/base/texpages/page-9-player-buildings-bases.png" + "${CMAKE_CURRENT_SOURCE_DIR}/base/texpages/page-11-player-buildings.png" + "${CMAKE_CURRENT_SOURCE_DIR}/base/texpages/page-12-player-buildings.png" + "${CMAKE_CURRENT_SOURCE_DIR}/base/texpages/page-13-player-buildings.png" + "${CMAKE_CURRENT_SOURCE_DIR}/base/texpages/page-29-features-arizona.png" + "${CMAKE_CURRENT_SOURCE_DIR}/base/texpages/page-30-features-rockies.png" + "${CMAKE_CURRENT_SOURCE_DIR}/base/texpages/page-31-features-urban.png" + "${CMAKE_CURRENT_SOURCE_DIR}/base/texpages/page-34-buildings.png" + "${CMAKE_CURRENT_SOURCE_DIR}/base/texpages/page-111-player-buildings-nexus-bases.png" + "${CMAKE_CURRENT_SOURCE_DIR}/base/texpages/page-112-player-buildings-nexus.png" + "${CMAKE_CURRENT_SOURCE_DIR}/base/texpages/page-114-player-buildings_Collective.png" + "${CMAKE_CURRENT_SOURCE_DIR}/base/texpages/page-116-player-buildings_nex.png" + "${CMAKE_CURRENT_SOURCE_DIR}/base/texpages/page-117-player-buildings-bases_nex.png" + "${CMAKE_CURRENT_SOURCE_DIR}/base/texpages/page-120-player-buildings-bases_Collective.png" + "${CMAKE_CURRENT_SOURCE_DIR}/base/texpages/page-121-player-buildings_Collective.png" + "${CMAKE_CURRENT_SOURCE_DIR}/base/texpages/page-122-player-buildings_Collective.png" + "${CMAKE_CURRENT_SOURCE_DIR}/base/texpages/page-123-laboratories_Collective.png" + ) + + set(_output_dir "${CMAKE_CURRENT_BINARY_DIR}/base/texpages") + file(MAKE_DIRECTORY "${_output_dir}") + set(_texpage_max_size 1024) + if(CMAKE_SYSTEM_NAME MATCHES "Emscripten") + set(_texpage_max_size 512) + endif() + + WZ_BASIS_ENCODE_TEXTURES(OUTPUT_DIR "${_output_dir}" TYPE "TEXTURE" RESIZE "${_texpage_max_size}" RDO ENCODING_TARGET texture_encoding TARGET_FOLDER data ALL FILES ${TEXPAGES_BASE_1024}) + list(APPEND PROCESSED_TEXTURE_FILES "${TEXPAGES_BASE_1024}") + endif() # Backdrops file(GLOB TEXPAGES_BACKDROPS_ALL @@ -245,6 +288,17 @@ if(WZ_ENABLE_BASIS_UNIVERSAL AND NOT WZ_CI_DISABLE_BASIS_COMPRESS_TEXTURES) WZ_BASIS_ENCODE_TEXTURES(OUTPUT_DIR "${_output_dir}" TYPE "UITEXTURE" RESIZE "${_bdrop_max_size}" RDO ENCODING_TARGET texture_encoding TARGET_FOLDER data ALL FILES ${TEXPAGES_BACKDROPS}) + # Excluding pre-generated decal mips + file(GLOB DECALS_128 LIST_DIRECTORIES false CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/base/texpages/tertilesc*hw-128/*.png") + if (DECALS_128) + file(GLOB DECALS_ALL LIST_DIRECTORIES false CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/base/texpages/tertilesc*hw-*/*.png") + set(DECALS_skipped "${DECALS_ALL}") + unset(DECALS_ALL) + foreach(high_qual_decal IN LISTS DECALS_128) + list(REMOVE_ITEM DECALS_skipped "${high_qual_decal}") + endforeach() + endif() + # The rest of the texture files set(_output_dir "${CMAKE_CURRENT_BINARY_DIR}/base/texpages") file(GLOB_RECURSE ALL_TEXPAGES LIST_DIRECTORIES false CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/base/texpages/*.png" "${CMAKE_CURRENT_SOURCE_DIR}/base/texpages/compression_overrides.txt" "${CMAKE_CURRENT_SOURCE_DIR}/base/texpages/*.radar") @@ -252,6 +306,7 @@ if(WZ_ENABLE_BASIS_UNIVERSAL AND NOT WZ_CI_DISABLE_BASIS_COMPRESS_TEXTURES) list(APPEND ALL_TEXPAGES_unprocessed ${ALL_TEXPAGES}) list(REMOVE_ITEM ALL_TEXPAGES_unprocessed ${PROCESSED_TEXTURE_FILES}) list(REMOVE_ITEM ALL_TEXPAGES_unprocessed ${TEXPAGES_BACKDROPS_ALL}) + list(REMOVE_ITEM ALL_TEXPAGES_unprocessed ${DECALS_skipped}) foreach(TEXPAGE_FILE ${ALL_TEXPAGES_unprocessed}) file(RELATIVE_PATH _output_name "${CMAKE_CURRENT_SOURCE_DIR}/base/texpages" "${TEXPAGE_FILE}") message(STATUS "Copy unprocessed image file: ${_output_name}") @@ -562,10 +617,6 @@ set(DATA_FILES if(WZ_INCLUDE_VIDEOS) list(APPEND DATA_FILES "${CMAKE_CURRENT_BINARY_DIR}/sequences.wz") endif() -install(FILES ${DATA_FILES} - DESTINATION "${WZ_DATADIR}" - COMPONENT Data -) set(DATA_TERRAIN_OVERRIDES_FILES "${CMAKE_CURRENT_BINARY_DIR}/terrain_overrides/classic.wz" @@ -573,25 +624,78 @@ set(DATA_TERRAIN_OVERRIDES_FILES if(TARGET data_terrain_overrides_high OR EXISTS "${CMAKE_CURRENT_BINARY_DIR}/terrain_overrides/high.wz") list(APPEND DATA_TERRAIN_OVERRIDES_FILES "${CMAKE_CURRENT_BINARY_DIR}/terrain_overrides/high.wz") endif() -install(FILES ${DATA_TERRAIN_OVERRIDES_FILES} - DESTINATION "${WZ_DATADIR}/terrain_overrides" - COMPONENT Data -) -install(FILES - ${wz2100_fonts_FILES} - COMPONENT Data DESTINATION "${WZ_DATADIR}/fonts" -) +if(NOT CMAKE_SYSTEM_NAME MATCHES "Emscripten") -file(GLOB DATA_MUSIC_FILES "${CMAKE_CURRENT_SOURCE_DIR}/music/*.opus" "${CMAKE_CURRENT_SOURCE_DIR}/music/albums/*/*.*") -foreach(_music_file ${DATA_MUSIC_FILES}) - file(RELATIVE_PATH _music_file_relative_path "${CMAKE_CURRENT_SOURCE_DIR}/music" "${_music_file}") - get_filename_component(_music_file_subdir_path "${_music_file_relative_path}" DIRECTORY) - install(FILES ${_music_file} - DESTINATION "${WZ_DATADIR}/music/${_music_file_subdir_path}" + install(FILES ${DATA_FILES} + DESTINATION "${WZ_DATADIR}" COMPONENT Data ) -endforeach() + install(FILES ${DATA_TERRAIN_OVERRIDES_FILES} + DESTINATION "${WZ_DATADIR}/terrain_overrides" + COMPONENT Data + ) + install(FILES + ${wz2100_fonts_FILES} + COMPONENT Data DESTINATION "${WZ_DATADIR}/fonts" + ) + + file(GLOB DATA_MUSIC_FILES "${CMAKE_CURRENT_SOURCE_DIR}/music/*.opus" "${CMAKE_CURRENT_SOURCE_DIR}/music/albums/*/*.*") + foreach(_music_file ${DATA_MUSIC_FILES}) + file(RELATIVE_PATH _music_file_relative_path "${CMAKE_CURRENT_SOURCE_DIR}/music" "${_music_file}") + get_filename_component(_music_file_subdir_path "${_music_file_relative_path}" DIRECTORY) + install(FILES ${_music_file} + DESTINATION "${WZ_DATADIR}/music/${_music_file_subdir_path}" + COMPONENT Data + ) + endforeach() + +else() + # Emscripten: + + if (NOT DEFINED EMSCRIPTEN_ROOT_PATH OR NOT EMSCRIPTEN_ROOT_PATH) + message(WARNING "Invalid EMSCRIPTEN_ROOT_PATH? (=${EMSCRIPTEN_ROOT_PATH})") + endif() + find_file(EMSCRIPTEN_FILE_PACKAGER_PY NAMES "file_packager.py" PATHS "${EMSCRIPTEN_ROOT_PATH}/tools" NO_CMAKE_FIND_ROOT_PATH) + if (NOT EMSCRIPTEN_FILE_PACKAGER_PY) + message(FATAL_ERROR "Unable to find Emscripten file_packager.py") + endif() + find_package (Python3 COMPONENTS Interpreter REQUIRED) + + # Bundle the classic terrain into a separate package + file(MAKE_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/empackaged/terrain_overrides") + add_custom_target( + data_terrain_overrides_classic_packaging ALL + ${Python3_EXECUTABLE} "${EMSCRIPTEN_FILE_PACKAGER_PY}" warzone2100-terrain-classic.data --preload "${CMAKE_CURRENT_BINARY_DIR}/terrain_overrides/classic.wz@/data/terrain_overrides/classic/" --js-output=warzone2100-terrain-classic.js --use-preload-cache --indexedDB-name=EM_PRELOAD_TERRAIN_CLASSIC_CACHE --no-node + WORKING_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/empackaged/terrain_overrides" + DEPENDS data_terrain_overrides_classic + VERBATIM + ) + list(APPEND DATA_ADDITIONAL_EMPACKAGE_DIRS "${CMAKE_CURRENT_BINARY_DIR}/empackaged/terrain_overrides") + + # Bundle the original music into a separate package + file(MAKE_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/music_staging") + file(GLOB DATA_ORIG_MUSIC_FILES "${CMAKE_CURRENT_SOURCE_DIR}/music/*.opus" "${CMAKE_CURRENT_SOURCE_DIR}/music/albums/original_soundtrack/*.*") + foreach(_music_file ${DATA_ORIG_MUSIC_FILES}) + file(RELATIVE_PATH _music_file_relative_path "${CMAKE_CURRENT_SOURCE_DIR}/music" "${_music_file}") + get_filename_component(_music_file_subdir_path "${_music_file_relative_path}" DIRECTORY) + file(MAKE_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/music_staging/${_music_file_subdir_path}") + configure_file("${_music_file}" "${CMAKE_CURRENT_BINARY_DIR}/music_staging/${_music_file_subdir_path}" COPYONLY) + endforeach() + file(MAKE_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/empackaged/music") + add_custom_target( + data_music_empackage ALL + ${Python3_EXECUTABLE} "${EMSCRIPTEN_FILE_PACKAGER_PY}" warzone2100-music.data --preload "${CMAKE_CURRENT_BINARY_DIR}/music_staging@/data/music/" --js-output=warzone2100-music.js --use-preload-cache --indexedDB-name=EM_PRELOAD_MUSIC_CACHE --no-node + WORKING_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/empackaged/music" + VERBATIM + ) + list(APPEND DATA_ADDITIONAL_EMPACKAGE_DIRS "${CMAKE_CURRENT_BINARY_DIR}/empackaged/music") + + set(DATA_ADDITIONAL_EMPACKAGE_DIRS ${DATA_ADDITIONAL_EMPACKAGE_DIRS} PARENT_SCOPE) + set(DATA_ADDITIONAL_EMPACKAGE_BASEDIR "${CMAKE_CURRENT_BINARY_DIR}" PARENT_SCOPE) + + +endif() set(DATA_FILES ${DATA_FILES} PARENT_SCOPE) set(DATA_TERRAIN_OVERRIDES_FILES ${DATA_TERRAIN_OVERRIDES_FILES} PARENT_SCOPE) diff --git a/data/base/shaders/gfx_text.vert b/data/base/shaders/gfx_text.vert index 0cfca770767..4a556ec7aa1 100644 --- a/data/base/shaders/gfx_text.vert +++ b/data/base/shaders/gfx_text.vert @@ -6,11 +6,9 @@ uniform mat4 posMatrix; #if (!defined(GL_ES) && (__VERSION__ >= 130)) || (defined(GL_ES) && (__VERSION__ >= 300)) in vec4 vertex; in vec2 vertexTexCoord; -in vec4 vertexColor; #else attribute vec4 vertex; attribute vec2 vertexTexCoord; -attribute vec4 vertexColor; #endif #if (!defined(GL_ES) && (__VERSION__ >= 130)) || (defined(GL_ES) && (__VERSION__ >= 300)) diff --git a/doc/CMakeLists.txt b/doc/CMakeLists.txt index d5a381a33ce..caf742ba5ee 100644 --- a/doc/CMakeLists.txt +++ b/doc/CMakeLists.txt @@ -250,7 +250,7 @@ install(FILES ${wz2100_doc_FILES} DESTINATION "${CMAKE_INSTALL_DOCDIR}${WZ_OUTPU install(FILES ${host_doc_FILES} DESTINATION "${CMAKE_INSTALL_DOCDIR}${WZ_OUTPUT_NAME_SUFFIX}/hosting" COMPONENT Docs) install(FILES ${host_doc_linux_scripts_FILES} DESTINATION "${CMAKE_INSTALL_DOCDIR}${WZ_OUTPUT_NAME_SUFFIX}/hosting/linux_scripts" COMPONENT Docs) -if(UNIX AND NOT SKIPPED_DOC_GENERATION) +if(UNIX AND NOT EMSCRIPTEN AND NOT SKIPPED_DOC_GENERATION) # Man-page gzip and installation find_program(GZIP_BIN NAMES gzip PATHS /bin /usr/bin /usr/local/bin) diff --git a/icons/CMakeLists.txt b/icons/CMakeLists.txt index 401df561b41..e46694a4d32 100644 --- a/icons/CMakeLists.txt +++ b/icons/CMakeLists.txt @@ -1,4 +1,4 @@ -if(UNIX AND NOT APPLE AND NOT WIN32) +if(UNIX AND NOT APPLE AND NOT WIN32 AND NOT EMSCRIPTEN) include(GNUInstallDirs) if (NOT DEFINED WZ_APPSTREAM_ID) diff --git a/lib/exceptionhandler/exceptionhandler.cpp b/lib/exceptionhandler/exceptionhandler.cpp index 1b3e794f528..b681d9d52c8 100644 --- a/lib/exceptionhandler/exceptionhandler.cpp +++ b/lib/exceptionhandler/exceptionhandler.cpp @@ -784,7 +784,7 @@ void setupExceptionHandler(int argc, const char * const *argv, const char *packa #if defined(WZ_OS_WIN) ExchndlSetup(packageVersion, writeDir, portable_mode); -#elif defined(WZ_OS_UNIX) && !defined(WZ_OS_MAC) +#elif defined(WZ_OS_UNIX) && !defined(WZ_OS_MAC) && !defined(__EMSCRIPTEN__) programCommand = argv[0]; // Get full path to this program. Needed for gdb to find the binary. diff --git a/lib/framework/debug.cpp b/lib/framework/debug.cpp index 5db22d17d58..f0375c30cf3 100644 --- a/lib/framework/debug.cpp +++ b/lib/framework/debug.cpp @@ -350,6 +350,9 @@ void debug_init() enabled_debug[LOG_INFO] = true; enabled_debug[LOG_FATAL] = true; enabled_debug[LOG_POPUP] = true; +#if defined(__EMSCRIPTEN__) + enabled_debug[LOG_SOUND] = false; // must be false or sound breaks (some openal edge case) +#endif #ifdef DEBUG enabled_debug[LOG_WARNING] = true; #endif @@ -803,3 +806,38 @@ void _debug_multiline(int line, code_part part, const char *function, const std: } } +#if defined(__EMSCRIPTEN__) + +#include + +/** + * Callback for outputting to a emscripten log / console + * + * \param data Ignored. Use NULL. + * \param outputBuffer Buffer containing the preprocessed text to output. + */ +void debug_callback_emscripten_log(WZ_DECL_UNUSED void **data, const char *outputBuffer, code_part part) +{ + int flags = EM_LOG_NO_PATHS | EM_LOG_CONSOLE; + switch (part) + { + case LOG_ERROR: + flags |= EM_LOG_ERROR; + break; + case LOG_WARNING: + flags |= EM_LOG_WARN; + break; + default: + break; + } + if (outputBuffer[strlen(outputBuffer) - 1] != '\n') + { + emscripten_log(flags, "%s\n", outputBuffer); + } + else + { + emscripten_log(flags, "%s", outputBuffer); + } +} + +#endif diff --git a/lib/framework/debug.h b/lib/framework/debug.h index 7cfbe73012a..5a75c4873e5 100644 --- a/lib/framework/debug.h +++ b/lib/framework/debug.h @@ -265,6 +265,10 @@ void debug_callback_file_exit(void **data); void debug_callback_stderr(void **data, const char *outputBuffer, code_part part); +#if defined(__EMSCRIPTEN__) +void debug_callback_emscripten_log(void **data, const char *outputBuffer, code_part part); +#endif + #if defined(_WIN32) && defined(DEBUG) void debug_callback_win32debug(void **data, const char *outputBuffer, code_part part); #endif diff --git a/lib/framework/i18n.cpp b/lib/framework/i18n.cpp index f4a105457ad..79be814d2d8 100644 --- a/lib/framework/i18n.cpp +++ b/lib/framework/i18n.cpp @@ -17,6 +17,7 @@ Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA */ #include "frame.h" +#include "string_ext.h" #include #include @@ -331,6 +332,16 @@ static bool setLocaleWindows(USHORT usPrimaryLanguage, USHORT usSubLanguage) return success; } +# elif defined(__EMSCRIPTEN__) +static bool setLocaleEmscripten(const char *localeFilename) +{ + setlocale(LC_ALL, localeFilename); + + const char * numericLocale = setlocale(LC_NUMERIC, "C"); // set radix character to the period (".") + debug(LOG_WZ, "LC_NUMERIC: \"%s\"", (numericLocale != nullptr) ? numericLocale : ""); + + return true; +} # else /*! * Set the prefered locale @@ -426,6 +437,8 @@ bool setLanguage(const char *language) # if defined(WZ_OS_WIN) return setLocaleWindows(map[i].usPrimaryLanguage, map[i].usSubLanguage); +# elif defined(__EMSCRIPTEN__) + return setLocaleEmscripten(map[i].localeFilename); # else return setLocaleUnix(map[i].locale) || setLocaleUnix(map[i].localeFallback) || setLocaleUnix_LANGUAGEFallback(map[i].localeFilename); # endif @@ -560,9 +573,53 @@ static bool checkSupportsLANGUAGEenvVarOverride() #endif // defined(_LIBINTL_H) && defined(LIBINTL_VERSION) } +#if defined(__EMSCRIPTEN__) +static std::string getEmscriptenDefaultLanguage() +{ + auto pEnvLang = getenv("LANG"); + if (pEnvLang) + { + std::string defaultLanguage = pEnvLang; + // Remove any .UTF-8 suffix + if (strEndsWith(defaultLanguage, ".UTF-8")) + { + defaultLanguage = defaultLanguage.substr(0, defaultLanguage.size() - 6); + } + // Find matching closest language (if present) + for (unsigned i = 0; i < ARRAY_SIZE(map); i++) + { + if (strcmp(defaultLanguage.c_str(), map[i].language) == 0) + { + return map[i].language; + } + } + for (unsigned i = 0; i < ARRAY_SIZE(map); i++) + { + if (strcmp(defaultLanguage.c_str(), map[i].localeFilename) == 0) + { + return map[i].language; + } + } + for (unsigned i = 0; i < ARRAY_SIZE(map); i++) + { + if (strcmp(defaultLanguage.c_str(), map[i].localeFallback) == 0) + { + return map[i].language; + } + } + } + return ""; +} +#endif + void initI18n() { std::string textdomainDirectory; + std::string defaultLanguage = ""; // "" is system default (in most cases) + +#if defined(__EMSCRIPTEN__) + defaultLanguage = getEmscriptenDefaultLanguage(); +#endif #if defined(_LIBINTL_H) && defined(LIBINTL_VERSION) int wz_libintl_maj = LIBINTL_VERSION >> 16; @@ -623,7 +680,7 @@ void initI18n() // Should come *after* bindTextDomain canUseLANGUAGEEnvVar = checkSupportsLANGUAGEenvVarOverride(); - if (!setLanguage("")) // set to system default + if (!setLanguage(defaultLanguage.c_str())) // set to system default { // no system default? debug(LOG_ERROR, "initI18n: No system language found"); diff --git a/lib/framework/wzglobal.h b/lib/framework/wzglobal.h index 9c7bfd463d2..ad320136c43 100644 --- a/lib/framework/wzglobal.h +++ b/lib/framework/wzglobal.h @@ -155,6 +155,7 @@ #elif defined(__INTEGRITY) # define WZ_OS_INTEGRITY #elif defined(__MAKEDEPEND__) +#elif defined(__EMSCRIPTEN__) #else # error "Warzone has not been tested on this OS. Please contact warzone2100-project@lists.sourceforge.net" #endif /* WZ_OS_x */ diff --git a/lib/ivis_opengl/CMakeLists.txt b/lib/ivis_opengl/CMakeLists.txt index 1b4fb550450..02ce6baea78 100644 --- a/lib/ivis_opengl/CMakeLists.txt +++ b/lib/ivis_opengl/CMakeLists.txt @@ -73,8 +73,12 @@ if(WZ_USE_SPNG) else() find_package(PNG 1.2 REQUIRED) endif() -find_package(Freetype REQUIRED) -find_package(Harfbuzz 1.0 REQUIRED) +if(CMAKE_SYSTEM_NAME MATCHES "Emscripten") + # We should be using the Emscripten port linker flags for FreeType & Harfbuzz +else() + find_package(Freetype REQUIRED) + find_package(Harfbuzz 1.0 REQUIRED) +endif() find_package(Fribidi) # recommended, but optional include(CheckCXXCompilerFlag) @@ -84,8 +88,13 @@ set_property(TARGET ivis-opengl PROPERTY FOLDER "lib") include(WZTargetConfiguration) WZ_TARGET_CONFIGURATION(ivis-opengl) -target_include_directories(ivis-opengl PRIVATE ${HARFBUZZ_INCLUDE_DIRS} ${FREETYPE_INCLUDE_DIR_ft2build}) -target_link_libraries(ivis-opengl PRIVATE framework launchinfo ${HARFBUZZ_LIBRARIES} ${FREETYPE_LIBRARIES}) +target_link_libraries(ivis-opengl PRIVATE framework launchinfo) +if(CMAKE_SYSTEM_NAME MATCHES "Emscripten") + # We should be using the Emscripten port linker flags for FreeType & Harfbuzz +else() + target_include_directories(ivis-opengl PRIVATE ${HARFBUZZ_INCLUDE_DIRS} ${FREETYPE_INCLUDE_DIR_ft2build}) + target_link_libraries(ivis-opengl PRIVATE ${HARFBUZZ_LIBRARIES} ${FREETYPE_LIBRARIES}) +endif() if(WZ_USE_SPNG) target_link_libraries(ivis-opengl PRIVATE $,spng::spng,spng::spng_static>) else() diff --git a/lib/ivis_opengl/gfx_api_gl.cpp b/lib/ivis_opengl/gfx_api_gl.cpp index 120fd5133e9..18af7ea05fd 100644 --- a/lib/ivis_opengl/gfx_api_gl.cpp +++ b/lib/ivis_opengl/gfx_api_gl.cpp @@ -50,6 +50,42 @@ # define WZ_GL_TIMER_QUERY_SUPPORTED #endif +#if defined(__EMSCRIPTEN__) +#include +# if defined(WZ_STATIC_GL_BINDINGS) +# include +# endif + +// forward-declarations +static std::unordered_set supportedWebGLExtensions; +static bool getWebGLExtensions(); + +static int GLAD_GL_ES_VERSION_3_0 = 0; +static int GLAD_GL_EXT_texture_filter_anisotropic = 0; + +#ifndef GL_COMPRESSED_RGB8_ETC2 +# define GL_COMPRESSED_RGB8_ETC2 0x9274 +#endif +#ifndef GL_COMPRESSED_RGBA8_ETC2_EAC +# define GL_COMPRESSED_RGBA8_ETC2_EAC 0x9278 +#endif +#ifndef GL_COMPRESSED_R11_EAC +# define GL_COMPRESSED_R11_EAC 0x9270 +#endif +#ifndef GL_COMPRESSED_RG11_EAC +# define GL_COMPRESSED_RG11_EAC 0x9272 +#endif + +#ifndef GL_COMPRESSED_RGBA_ASTC_4x4_KHR +# define GL_COMPRESSED_RGBA_ASTC_4x4_KHR 0x93B0 +#endif + +#ifndef GL_FRAMEBUFFER_INCOMPLETE_LAYER_TARGETS +# define GL_FRAMEBUFFER_INCOMPLETE_LAYER_TARGETS 0x8DA8 +#endif + +#endif + struct OPENGL_DATA { char vendor[256] = {}; @@ -68,9 +104,15 @@ static bool perfStarted = false; static std::unordered_set debugLiveTextures; #endif +#if !defined(WZ_STATIC_GL_BINDINGS) PFNGLDRAWARRAYSINSTANCEDPROC wz_dyn_glDrawArraysInstanced = nullptr; PFNGLDRAWELEMENTSINSTANCEDPROC wz_dyn_glDrawElementsInstanced = nullptr; PFNGLVERTEXATTRIBDIVISORPROC wz_dyn_glVertexAttribDivisor = nullptr; +#else +#define wz_dyn_glDrawArraysInstanced glDrawArraysInstanced +#define wz_dyn_glDrawElementsInstanced glDrawElementsInstanced +#define wz_dyn_glVertexAttribDivisor glVertexAttribDivisor +#endif static const GLubyte* wzSafeGlGetString(GLenum name); @@ -86,17 +128,20 @@ static GLenum to_gl_internalformat(const gfx_api::pixel_format& format, bool gle case gfx_api::pixel_format::FORMAT_RGB8_UNORM_PACK8: return GL_RGB8; case gfx_api::pixel_format::FORMAT_RG8_UNORM: +#if !defined(__EMSCRIPTEN__) if (gles && GLAD_GL_EXT_texture_rg) { // the internal format is GL_RG_EXT return GL_RG_EXT; } else +#endif { - // for Desktop OpenGL, use GL_RG8 for the internal format + // for Desktop OpenGL (or WebGL 2.0), use GL_RG8 for the internal format return GL_RG8; } case gfx_api::pixel_format::FORMAT_R8_UNORM: +#if !defined(__EMSCRIPTEN__) if ((!gles && GLAD_GL_VERSION_3_0) || (gles && GLAD_GL_ES_VERSION_3_0)) { // OpenGL 3.0+ or OpenGL ES 3.0+ @@ -110,6 +155,10 @@ static GLenum to_gl_internalformat(const gfx_api::pixel_format& format, bool gle // (b) it ensures the single channel value ends up in "red" so the shaders don't have to care return GL_LUMINANCE; } +#else + // WebGL 2.0 + return GL_R8; +#endif // COMPRESSED FORMAT case gfx_api::pixel_format::FORMAT_RGB_BC1_UNORM: return GL_COMPRESSED_RGB_S3TC_DXT1_EXT; @@ -118,11 +167,19 @@ static GLenum to_gl_internalformat(const gfx_api::pixel_format& format, bool gle case gfx_api::pixel_format::FORMAT_RGBA_BC3_UNORM: return GL_COMPRESSED_RGBA_S3TC_DXT5_EXT; case gfx_api::pixel_format::FORMAT_R_BC4_UNORM: +#if defined(__EMSCRIPTEN__) && defined(GL_EXT_texture_compression_rgtc) + return GL_COMPRESSED_RED_RGTC1_EXT; +#else return GL_COMPRESSED_RED_RGTC1; +#endif case gfx_api::pixel_format::FORMAT_RG_BC5_UNORM: +#if defined(__EMSCRIPTEN__) && defined(GL_EXT_texture_compression_rgtc) + return GL_COMPRESSED_RED_GREEN_RGTC2_EXT; +#else return GL_COMPRESSED_RG_RGTC2; +#endif case gfx_api::pixel_format::FORMAT_RGBA_BPTC_UNORM: - return GL_COMPRESSED_RGBA_BPTC_UNORM_ARB; // same value as GL_COMPRESSED_RGBA_BPTC_UNORM_EXT + return GL_COMPRESSED_RGBA_BPTC_UNORM_EXT; // same value as GL_COMPRESSED_RGBA_BPTC_UNORM_ARB case gfx_api::pixel_format::FORMAT_RGB8_ETC1: return GL_ETC1_RGB8_OES; case gfx_api::pixel_format::FORMAT_RGB8_ETC2: @@ -149,21 +206,28 @@ static GLenum to_gl_format(const gfx_api::pixel_format& format, bool gles) case gfx_api::pixel_format::FORMAT_RGBA8_UNORM_PACK8: return GL_RGBA; case gfx_api::pixel_format::FORMAT_BGRA8_UNORM_PACK8: +#if defined(__EMSCRIPTEN__) + return GL_INVALID_ENUM; +#else return GL_BGRA; +#endif case gfx_api::pixel_format::FORMAT_RGB8_UNORM_PACK8: return GL_RGB; case gfx_api::pixel_format::FORMAT_RG8_UNORM: +#if !defined(__EMSCRIPTEN__) if (gles && GLAD_GL_EXT_texture_rg) { // the internal format is GL_RG_EXT return GL_RG_EXT; } else +#endif { - // for Desktop OpenGL, use GL_RG for the format + // for Desktop OpenGL or WebGL 2.0, use GL_RG for the format return GL_RG; } case gfx_api::pixel_format::FORMAT_R8_UNORM: +#if !defined(__EMSCRIPTEN__) if ((!gles && GLAD_GL_VERSION_3_0) || (gles && GLAD_GL_ES_VERSION_3_0)) { // OpenGL 3.0+ or OpenGL ES 3.0+ @@ -177,6 +241,10 @@ static GLenum to_gl_format(const gfx_api::pixel_format& format, bool gles) // (b) it ensures the single channel value ends up in "red" so the shaders don't have to care return GL_LUMINANCE; } +#else + // WebGL 2.0 + return GL_RED; +#endif // COMPRESSED FORMAT default: return to_gl_internalformat(format, gles); @@ -273,11 +341,13 @@ static GLenum to_gl(const gfx_api::context::context_value property) // NOTE: Not to be used in critical code paths, but more for initialization static bool _wzGLCheckErrors(int line, const char *function) { +#if !defined(WZ_STATIC_GL_BINDINGS) if (glGetError == nullptr) { // function not available? can't check... return true; } +#endif GLenum err = glGetError(); if (err == GL_NO_ERROR) { @@ -313,14 +383,18 @@ static bool _wzGLCheckErrors(int line, const char *function) // Once GL_OUT_OF_MEMORY is set, the state of the OpenGL context is *undefined* encounteredCriticalError = true; break; +#ifdef GL_STACK_UNDERFLOW case GL_STACK_UNDERFLOW: errAsStr = "GL_STACK_UNDERFLOW"; encounteredCriticalError = true; break; +#endif +#ifdef GL_STACK_OVERFLOW case GL_STACK_OVERFLOW: errAsStr = "GL_STACK_OVERFLOW"; encounteredCriticalError = true; break; +#endif } if (enabled_debug[part]) @@ -335,7 +409,9 @@ static bool _wzGLCheckErrors(int line, const char *function) static void _wzGLClearErrors() { // clear OpenGL error queue +#if !defined(WZ_STATIC_GL_BINDINGS) if (glGetError != nullptr) +#endif { while(glGetError() != GL_NO_ERROR) { } // clear the OpenGL error queue } @@ -909,14 +985,18 @@ const char * shaderVersionString(SHADER_VERSION_ES version) GLint wz_GetGLIntegerv(GLenum pname, GLint defaultValue = 0) { GLint retVal = defaultValue; +#if !defined(WZ_STATIC_GL_BINDINGS) ASSERT_OR_RETURN(retVal, glGetIntegerv != nullptr, "glGetIntegerv is null"); if (glGetError != nullptr) +#endif { while(glGetError() != GL_NO_ERROR) { } // clear the OpenGL error queue } glGetIntegerv(pname, &retVal); GLenum err = GL_NO_ERROR; +#if !defined(WZ_STATIC_GL_BINDINGS) if (glGetError != nullptr) +#endif { err = glGetError(); } @@ -1077,6 +1157,7 @@ desc(createInfo.state_desc), vertex_buffer_desc(createInfo.attribute_description const char *shaderVersionStr = shaderVersionString(getMaximumShaderVersionForCurrentGLESContext(VERSION_ES_100, VERSION_ES_300)); vertexShaderHeader = shaderVersionStr; + fragmentShaderHeader = shaderVersionStr; // OpenGL ES Shading Language - 4. Variables and Types - pp. 35-36 // https://www.khronos.org/registry/gles/specs/2.0/GLSL_ES_Specification_1.0.17.pdf?#page=41 // @@ -1084,7 +1165,12 @@ desc(createInfo.state_desc), vertex_buffer_desc(createInfo.attribute_description // > Hence for float, floating point vector and matrix variable declarations, either the // > declaration must include a precision qualifier or the default float precision must // > have been previously declared. - fragmentShaderHeader = std::string(shaderVersionStr) + "#if GL_FRAGMENT_PRECISION_HIGH\nprecision highp float;\nprecision highp int;\n#else\nprecision mediump float;\n#endif\n"; +#if defined(__EMSCRIPTEN__) + vertexShaderHeader += "precision highp float;\n"; + fragmentShaderHeader += "precision highp float;precision highp int;\n"; +#else + fragmentShaderHeader = "#if GL_FRAGMENT_PRECISION_HIGH\nprecision highp float;\nprecision highp int;\n#else\nprecision mediump float;\n#endif\n"; +#endif fragmentShaderHeader += "#if __VERSION__ >= 300 || defined(GL_EXT_texture_array)\nprecision lowp sampler2DArray;\n#endif\n"; fragmentShaderHeader += "#if __VERSION__ >= 300\nprecision lowp sampler2DShadow;\nprecision lowp sampler2DArrayShadow;\n#endif\n"; } @@ -2557,8 +2643,10 @@ void gl_context::bind_vertex_buffers(const std::size_t& first, const std::vector if (get_type(attribute.type) == GL_INT || attribute.type == gfx_api::vertex_attribute_type::u8x4_uint) { - // glVertexAttribIPointer only supported in: OpenGL 3.0+, OpenGL ES 3.0+ - ASSERT(glVertexAttribIPointer != nullptr, "Missing glVertexAttribIPointer?"); + #if !defined(WZ_STATIC_GL_BINDINGS) + // glVertexAttribIPointer only supported in: OpenGL 3.0+, OpenGL ES 3.0+ + ASSERT(glVertexAttribIPointer != nullptr, "Missing glVertexAttribIPointer?"); + #endif glVertexAttribIPointer(static_cast(attribute.id), get_size(attribute.type), get_type(attribute.type), static_cast(buffer_desc.stride), reinterpret_cast(attribute.offset + std::get<1>(vertex_buffers_offset[i]))); } else @@ -2714,9 +2802,13 @@ void gl_context::bind_textures(const std::vector& textur case gfx_api::sampler_type::nearest_border: glTexParameteri(type, GL_TEXTURE_MIN_FILTER, GL_NEAREST); glTexParameteri(type, GL_TEXTURE_MAG_FILTER, GL_NEAREST); +#if !defined(__EMSCRIPTEN__) glTexParameteri(type, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER); glTexParameteri(type, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER); glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, to_gl(desc.border)); +#else + // POSSIBLE FIXME: Emulate GL_CLAMP_TO_BORDER for WebGL? +#endif break; case gfx_api::sampler_type::bilinear: glTexParameteri(type, GL_TEXTURE_MIN_FILTER, GL_LINEAR); @@ -2733,9 +2825,13 @@ void gl_context::bind_textures(const std::vector& textur case gfx_api::sampler_type::bilinear_border: glTexParameteri(type, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(type, GL_TEXTURE_MAG_FILTER, GL_LINEAR); +#if !defined(__EMSCRIPTEN__) glTexParameteri(type, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER); glTexParameteri(type, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER); glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, to_gl(desc.border)); +#else + // POSSIBLE FIXME: Emulate GL_CLAMP_TO_BORDER for WebGL? +#endif break; case gfx_api::sampler_type::anisotropic_repeat: glTexParameteri(type, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); @@ -2811,11 +2907,13 @@ void gl_context::set_polygon_offset(const float& offset, const float& slope) void gl_context::set_depth_range(const float& min, const float& max) { +#if !defined(__EMSCRIPTEN__) if (!gles) { glDepthRange(min, max); } else +#endif { glDepthRangef(min, max); } @@ -2830,6 +2928,7 @@ int32_t gl_context::get_context_value(const context_value property) return maxMultiSampleBufferFormatSamples; case gfx_api::context::context_value::MAX_VERTEX_OUTPUT_COMPONENTS: // special-handling for MAX_VERTEX_OUTPUT_COMPONENTS +#if !defined(__EMSCRIPTEN__) if (!gles) { if (GLAD_GL_VERSION_3_2) @@ -2855,6 +2954,10 @@ int32_t gl_context::get_context_value(const context_value property) value *= 4; } } +#else + // For WebGL 2 + glGetIntegerv(GL_MAX_VERTEX_OUTPUT_COMPONENTS, &value); +#endif break; default: glGetIntegerv(to_gl(property), &value); @@ -2864,6 +2967,8 @@ int32_t gl_context::get_context_value(const context_value property) return value; } +#if !defined(__EMSCRIPTEN__) + uint64_t gl_context::get_estimated_vram_mb(bool dedicatedOnly) { if (GLAD_GL_NVX_gpu_memory_info) @@ -2907,6 +3012,15 @@ uint64_t gl_context::get_estimated_vram_mb(bool dedicatedOnly) return 0; } +#else + +uint64_t gl_context::get_estimated_vram_mb(bool dedicatedOnly) +{ + return 0; +} + +#endif + // MARK: gl_context - debug void gl_context::debugStringMarker(const char *str) @@ -3023,6 +3137,8 @@ uint64_t gl_context::debugGetPerfValue(PERF_POINT pp) #endif } +#if !defined(__EMSCRIPTEN__) + // Returns a space-separated list of OpenGL extensions static std::string getGLExtensions() { @@ -3067,6 +3183,22 @@ static std::string getGLExtensions() return extensions; } +#else + +static std::string getGLExtensions() +{ + std::string extensions; + char* spaceSeparatedExtensions = emscripten_webgl_get_supported_extensions(); + if (spaceSeparatedExtensions) + { + extensions = std::string(spaceSeparatedExtensions); + free(spaceSeparatedExtensions); + } + return extensions; +} + +#endif // !defined(__EMSCRIPTEN__) + std::map gl_context::getBackendGameInfo() { std::map backendGameInfo; @@ -3244,6 +3376,7 @@ static void GLAPIENTRY khr_callback(GLenum source, GLenum type, GLuint id, GLenu uint32_t gl_context::getSuggestedDefaultDepthBufferResolution() const { +#if defined(GL_NVX_gpu_memory_info) // Use a (very simple) heuristic, that may or may not be useful - but basically try to find graphics cards that have lots of memory... if (GLAD_GL_NVX_gpu_memory_info) { @@ -3261,6 +3394,7 @@ uint32_t gl_context::getSuggestedDefaultDepthBufferResolution() const return 2048; } } +#endif // else if (GLAD_GL_ATI_meminfo) // { // // For GL_ATI_meminfo, we could get the current free texture memory (w/ GL_TEXTURE_FREE_MEMORY_ATI, checking stats_kb[0]) @@ -3424,7 +3558,9 @@ bool gl_context::createDefaultTextures() static const GLubyte* wzSafeGlGetString(GLenum name) { static const GLubyte emptyString[1] = {0}; +#if !defined(WZ_STATIC_GL_BINDINGS) ASSERT_OR_RETURN(emptyString, glGetString != nullptr, "glGetString is null"); +#endif auto result = glGetString(name); if (result == nullptr) { @@ -3487,14 +3623,15 @@ bool gl_context::isBlocklistedGraphicsDriver() const bool gl_context::initGLContext() { frameNum = 1; + gles = backend_impl->isOpenGLES(); +#if !defined(WZ_STATIC_GL_BINDINGS) GLADloadproc func_GLGetProcAddress = backend_impl->getGLGetProcAddress(); if (!func_GLGetProcAddress) { debug(LOG_FATAL, "backend_impl->getGLGetProcAddress() returned NULL"); return false; } - gles = backend_impl->isOpenGLES(); if (!gles) { if (!gladLoadGLLoader(func_GLGetProcAddress)) @@ -3511,6 +3648,7 @@ bool gl_context::initGLContext() return false; } } +#endif /* Dump general information about OpenGL implementation to the console and the dump file */ ssprintf(opengl.vendor, "OpenGL Vendor: %s", wzSafeGlGetString(GL_VENDOR)); @@ -3549,6 +3687,8 @@ bool gl_context::initGLContext() i = j + 1; } +#if !defined(__EMSCRIPTEN__) + /* Dump extended information about OpenGL implementation to the console */ std::string line; @@ -3617,6 +3757,43 @@ bool gl_context::initGLContext() return false; } +#else + + // Emscripten-specific + + const char* version = (const char*)wzSafeGlGetString(GL_VERSION); + bool WZ_WEB_GL_VERSION_2_0 = false; + if (strncmp(version, "OpenGL ES 2.0", 13) == 0) + { + // WebGL 1 - not supported + debug(LOG_POPUP, "WebGL 2.0 not supported. Please upgrade your browser / drivers."); + return false; + } + else if (strncmp(version, "OpenGL ES 3.0", 13) == 0) + { + // WebGL 2 + WZ_WEB_GL_VERSION_2_0 = true; + GLAD_GL_ES_VERSION_3_0 = 1; + } + else + { + debug(LOG_POPUP, "Unsupported WebGL version string: %s", version); + return false; + } + + debug(LOG_3D, " * WebGL 2.0 %s supported!", WZ_WEB_GL_VERSION_2_0 ? "is" : "is NOT"); + + if (!getWebGLExtensions()) + { + debug(LOG_ERROR, "Failed to get WebGL extensions"); + } + GLAD_GL_EXT_texture_filter_anisotropic = supportedWebGLExtensions.count("EXT_texture_filter_anisotropic") > 0; + + debug(LOG_3D, " * Anisotropic filtering %s supported.", GLAD_GL_EXT_texture_filter_anisotropic ? "is" : "is NOT"); + // FUTURE TODO: Check and output other extensions + +#endif + fragmentHighpFloatAvailable = true; fragmentHighpIntAvailable = true; if (gles) @@ -3708,6 +3885,7 @@ bool gl_context::initGLContext() } #endif +#if !defined(__EMSCRIPTEN__) if (GLAD_GL_VERSION_3_0) // if context is OpenGL 3.0+ { // Very simple VAO code - just bind a single global VAO (this gets things working, but is not optimal) @@ -3722,6 +3900,7 @@ bool gl_context::initGLContext() glBindVertexArray(vaoId); wzGLCheckErrors(); } +#endif #if defined(WZ_GL_TIMER_QUERY_SUPPORTED) if (GLAD_GL_ARB_timer_query) @@ -3735,6 +3914,7 @@ bool gl_context::initGLContext() { maxTextureAnisotropy = 0.f; glGetFloatv(GL_MAX_TEXTURE_MAX_ANISOTROPY_EXT, &maxTextureAnisotropy); + debug(LOG_3D, " * (current) maxTextureAnisotropy: %f", maxTextureAnisotropy); wzGLCheckErrors(); } @@ -3832,6 +4012,176 @@ bool gl_context::supportsInstancedRendering() return hasInstancedRenderingSupport; } +bool gl_context::textureFormatIsSupported(gfx_api::pixel_format_target target, gfx_api::pixel_format format, gfx_api::pixel_format_usage::flags usage) +{ + size_t formatIdx = static_cast(format); + ASSERT_OR_RETURN(false, formatIdx < textureFormatsSupport[static_cast(target)].size(), "Invalid format index: %zu", formatIdx); + return (textureFormatsSupport[static_cast(target)][formatIdx] & usage) == usage; +} + +#if defined(__EMSCRIPTEN__) + +static gfx_api::pixel_format_usage::flags getPixelFormatUsageSupport_gl(GLenum target, gfx_api::pixel_format format, bool gles) +{ + gfx_api::pixel_format_usage::flags retVal = gfx_api::pixel_format_usage::none; + + switch (format) + { + // UNCOMPRESSED FORMATS + case gfx_api::pixel_format::FORMAT_RGBA8_UNORM_PACK8: + retVal |= gfx_api::pixel_format_usage::sampled_image; + break; + case gfx_api::pixel_format::FORMAT_BGRA8_UNORM_PACK8: + retVal |= gfx_api::pixel_format_usage::sampled_image; + break; + case gfx_api::pixel_format::FORMAT_RGB8_UNORM_PACK8: + retVal |= gfx_api::pixel_format_usage::sampled_image; + break; + case gfx_api::pixel_format::FORMAT_RG8_UNORM: + // supported in WebGL2 + retVal |= gfx_api::pixel_format_usage::sampled_image; + break; + case gfx_api::pixel_format::FORMAT_R8_UNORM: + retVal |= gfx_api::pixel_format_usage::sampled_image; + break; + // COMPRESSED FORMAT + case gfx_api::pixel_format::FORMAT_RGB_BC1_UNORM: + case gfx_api::pixel_format::FORMAT_RGBA_BC2_UNORM: + case gfx_api::pixel_format::FORMAT_RGBA_BC3_UNORM: + if (supportedWebGLExtensions.count("WEBGL_compressed_texture_s3tc") > 0) + { + retVal |= gfx_api::pixel_format_usage::sampled_image; + } + break; + case gfx_api::pixel_format::FORMAT_R_BC4_UNORM: + case gfx_api::pixel_format::FORMAT_RG_BC5_UNORM: + // not supported + break; + case gfx_api::pixel_format::FORMAT_RGBA_BPTC_UNORM: + // not supported + break; + case gfx_api::pixel_format::FORMAT_RGB8_ETC1: + // not supported + break; + case gfx_api::pixel_format::FORMAT_RGB8_ETC2: + case gfx_api::pixel_format::FORMAT_RGBA8_ETC2_EAC: + case gfx_api::pixel_format::FORMAT_R11_EAC: + case gfx_api::pixel_format::FORMAT_RG11_EAC: + if (supportedWebGLExtensions.count("WEBGL_compressed_texture_etc") > 0) + { + retVal |= gfx_api::pixel_format_usage::sampled_image; + } + break; + case gfx_api::pixel_format::FORMAT_ASTC_4x4_UNORM: + if (supportedWebGLExtensions.count("WEBGL_compressed_texture_astc") > 0) + { + retVal |= gfx_api::pixel_format_usage::sampled_image; + } + break; + default: + debug(LOG_INFO, "Unrecognised pixel format"); + } + + return retVal; +} + +void gl_context::initPixelFormatsSupport() +{ + // set any existing entries to false + for (size_t target = 0; target < gfx_api::PIXEL_FORMAT_TARGET_COUNT; target++) + { + for (size_t i = 0; i < textureFormatsSupport[target].size(); i++) + { + textureFormatsSupport[target][i] = gfx_api::pixel_format_usage::none; + } + textureFormatsSupport[target].resize(static_cast(gfx_api::MAX_PIXEL_FORMAT) + 1, gfx_api::pixel_format_usage::none); + } + + // check if 2D texture array support is available + has2DTextureArraySupport = true; // Texture arrays are supported in OpenGL ES 3.0+ / WebGL 2.0 + + #define PIXEL_2D_FORMAT_SUPPORT_SET(x) \ + textureFormatsSupport[static_cast(gfx_api::pixel_format_target::texture_2d)][static_cast(x)] = getPixelFormatUsageSupport_gl(GL_TEXTURE_2D, x, gles); + + #define PIXEL_2D_TEXTURE_ARRAY_FORMAT_SUPPORT_SET(x) \ + if (has2DTextureArraySupport) { textureFormatsSupport[static_cast(gfx_api::pixel_format_target::texture_2d_array)][static_cast(x)] = getPixelFormatUsageSupport_gl(GL_TEXTURE_2D_ARRAY, x, gles); } + + // The following are always guaranteed to be supported + PIXEL_2D_FORMAT_SUPPORT_SET(gfx_api::pixel_format::FORMAT_RGBA8_UNORM_PACK8) + PIXEL_2D_FORMAT_SUPPORT_SET(gfx_api::pixel_format::FORMAT_RGB8_UNORM_PACK8) + PIXEL_2D_FORMAT_SUPPORT_SET(gfx_api::pixel_format::FORMAT_R8_UNORM) + + PIXEL_2D_TEXTURE_ARRAY_FORMAT_SUPPORT_SET(gfx_api::pixel_format::FORMAT_RGBA8_UNORM_PACK8) + PIXEL_2D_TEXTURE_ARRAY_FORMAT_SUPPORT_SET(gfx_api::pixel_format::FORMAT_RGB8_UNORM_PACK8) + PIXEL_2D_TEXTURE_ARRAY_FORMAT_SUPPORT_SET(gfx_api::pixel_format::FORMAT_R8_UNORM) + + // RG8 + // WebGL: WebGL 2.0 + PIXEL_2D_FORMAT_SUPPORT_SET(gfx_api::pixel_format::FORMAT_RG8_UNORM) + PIXEL_2D_TEXTURE_ARRAY_FORMAT_SUPPORT_SET(gfx_api::pixel_format::FORMAT_RG8_UNORM) + + // S3TC + // WebGL: WEBGL_compressed_texture_s3tc + if (supportedWebGLExtensions.count("WEBGL_compressed_texture_s3tc") > 0) + { + PIXEL_2D_FORMAT_SUPPORT_SET(gfx_api::pixel_format::FORMAT_RGB_BC1_UNORM) // DXT1 + PIXEL_2D_FORMAT_SUPPORT_SET(gfx_api::pixel_format::FORMAT_RGBA_BC2_UNORM) // DXT3 + PIXEL_2D_FORMAT_SUPPORT_SET(gfx_api::pixel_format::FORMAT_RGBA_BC3_UNORM) // DXT5 + + PIXEL_2D_TEXTURE_ARRAY_FORMAT_SUPPORT_SET(gfx_api::pixel_format::FORMAT_RGB_BC1_UNORM) // DXT1 + PIXEL_2D_TEXTURE_ARRAY_FORMAT_SUPPORT_SET(gfx_api::pixel_format::FORMAT_RGBA_BC2_UNORM) // DXT3 + PIXEL_2D_TEXTURE_ARRAY_FORMAT_SUPPORT_SET(gfx_api::pixel_format::FORMAT_RGBA_BC3_UNORM) // DXT5 + } + + // BPTC + // WebGL: Theoretically could check EXT_texture_compression_bptc? + + // ETC1 + + // ETC2 + // WebGL: WEBGL_compressed_texture_etc + if (supportedWebGLExtensions.count("WEBGL_compressed_texture_etc") > 0) + { + // NOTES: + // WebGL 2.0 claims it is supported for 2d texture arrays + bool canSupport2DTextureArrays = true; + + PIXEL_2D_FORMAT_SUPPORT_SET(gfx_api::pixel_format::FORMAT_RGB8_ETC2) + if (canSupport2DTextureArrays) + { + PIXEL_2D_TEXTURE_ARRAY_FORMAT_SUPPORT_SET(gfx_api::pixel_format::FORMAT_RGB8_ETC2) + } + + PIXEL_2D_FORMAT_SUPPORT_SET(gfx_api::pixel_format::FORMAT_RGBA8_ETC2_EAC) + if (canSupport2DTextureArrays) + { + PIXEL_2D_TEXTURE_ARRAY_FORMAT_SUPPORT_SET(gfx_api::pixel_format::FORMAT_RGBA8_ETC2_EAC) + } + + PIXEL_2D_FORMAT_SUPPORT_SET(gfx_api::pixel_format::FORMAT_R11_EAC) + if (canSupport2DTextureArrays) + { + PIXEL_2D_TEXTURE_ARRAY_FORMAT_SUPPORT_SET(gfx_api::pixel_format::FORMAT_R11_EAC) + } + + PIXEL_2D_FORMAT_SUPPORT_SET(gfx_api::pixel_format::FORMAT_RG11_EAC) + if (canSupport2DTextureArrays) + { + PIXEL_2D_TEXTURE_ARRAY_FORMAT_SUPPORT_SET(gfx_api::pixel_format::FORMAT_RG11_EAC) + } + } + + // ASTC (LDR) + // WebGL: WEBGL_compressed_texture_astc + if (supportedWebGLExtensions.count("WEBGL_compressed_texture_astc") > 0) + { + PIXEL_2D_FORMAT_SUPPORT_SET(gfx_api::pixel_format::FORMAT_ASTC_4x4_UNORM) + PIXEL_2D_TEXTURE_ARRAY_FORMAT_SUPPORT_SET(gfx_api::pixel_format::FORMAT_ASTC_4x4_UNORM) + } +} + +#else // !defined(__EMSCRIPTEN__) // Regular OpenGL / OpenGL ES implementation + static gfx_api::pixel_format_usage::flags getPixelFormatUsageSupport_gl(GLenum target, gfx_api::pixel_format format, bool gles) { gfx_api::pixel_format_usage::flags retVal = gfx_api::pixel_format_usage::none; @@ -3904,13 +4254,6 @@ static gfx_api::pixel_format_usage::flags getPixelFormatUsageSupport_gl(GLenum t return retVal; } -bool gl_context::textureFormatIsSupported(gfx_api::pixel_format_target target, gfx_api::pixel_format format, gfx_api::pixel_format_usage::flags usage) -{ - size_t formatIdx = static_cast(format); - ASSERT_OR_RETURN(false, formatIdx < textureFormatsSupport[static_cast(target)].size(), "Invalid format index: %zu", formatIdx); - return (textureFormatsSupport[static_cast(target)][formatIdx] & usage) == usage; -} - void gl_context::initPixelFormatsSupport() { // set any existing entries to false @@ -4122,8 +4465,11 @@ void gl_context::initPixelFormatsSupport() maxMultiSampleBufferFormatSamples = std::max(maxMultiSampleBufferFormatSamples, 0); } +#endif + bool gl_context::initInstancedFunctions() { +#if !defined(WZ_STATIC_GL_BINDINGS) wz_dyn_glDrawArraysInstanced = nullptr; wz_dyn_glDrawElementsInstanced = nullptr; wz_dyn_glVertexAttribDivisor = nullptr; @@ -4179,7 +4525,7 @@ bool gl_context::initInstancedFunctions() wz_dyn_glVertexAttribDivisor = nullptr; return false; } - +#endif return true; } @@ -4233,14 +4579,24 @@ bool gl_context::shouldDraw() void gl_context::shutdown() { - if(glClear) glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); +#if !defined(WZ_STATIC_GL_BINDINGS) + if (glClear) +#endif + { + glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); + } deleteSceneRenderpass(); - if (glDeleteFramebuffers && depthFBO.size() > 0) +#if !defined(WZ_STATIC_GL_BINDINGS) + if (glDeleteFramebuffers) +#endif { - glDeleteFramebuffers(static_cast(depthFBO.size()), depthFBO.data()); - depthFBO.clear(); + if (depthFBO.size() > 0) + { + glDeleteFramebuffers(static_cast(depthFBO.size()), depthFBO.data()); + depthFBO.clear(); + } } if (depthTexture) @@ -4267,7 +4623,9 @@ void gl_context::shutdown() pDefaultDepthTexture = nullptr; } +#if !defined(WZ_STATIC_GL_BINDINGS) if (glDeleteBuffers) // glDeleteBuffers might be NULL (if initializing the OpenGL loader library fails) +#endif { glDeleteBuffers(1, &scratchbuffer); scratchbuffer = 0; @@ -4280,6 +4638,7 @@ void gl_context::shutdown() } #endif +#if !defined(__EMSCRIPTEN__) if (GLAD_GL_VERSION_3_0) // if context is OpenGL 3.0+ { // Cleanup from very simple VAO code (just bind a single global VAO) @@ -4292,6 +4651,7 @@ void gl_context::shutdown() vaoId = 0; } } +#endif for (auto& pipelineInfo : createdPipelines) { @@ -4337,6 +4697,7 @@ gfx_api::context::swap_interval_mode gl_context::getSwapInterval() const bool gl_context::supportsMipLodBias() const { +#if !defined(__EMSCRIPTEN__) if (!gles) { if (GLAD_GL_VERSION_2_1) @@ -4357,6 +4718,11 @@ bool gl_context::supportsMipLodBias() const } return false; } +#else + // Can support on OpenGL ES 2.0+ + // By providing bias to texture() (OpenGL ES 3.0+) or texture2d() (OpenGL ES 2.0) sampling call in shader + return true; +#endif } bool gl_context::supports2DTextureArrays() const @@ -4366,10 +4732,15 @@ bool gl_context::supports2DTextureArrays() const bool gl_context::supportsIntVertexAttributes() const { +#if !defined(__EMSCRIPTEN__) // glVertexAttribIPointer requires: OpenGL 3.0+ or OpenGL ES 3.0+ bool hasRequiredVersion = (!gles && GLAD_GL_VERSION_3_0) || (gles && GLAD_GL_ES_VERSION_3_0); bool hasRequiredFunction = glVertexAttribIPointer != nullptr; return hasRequiredVersion && hasRequiredFunction; +#else + // Always available in WebGL 2.0 + return true; +#endif } size_t gl_context::maxFramesInFlight() const @@ -4428,8 +4799,12 @@ static const char *cbframebuffererror(GLenum err) case GL_FRAMEBUFFER_UNDEFINED: return "GL_FRAMEBUFFER_UNDEFINED"; case GL_FRAMEBUFFER_INCOMPLETE_ATTACHMENT: return "GL_FRAMEBUFFER_INCOMPLETE_ATTACHMENT"; case GL_FRAMEBUFFER_INCOMPLETE_MISSING_ATTACHMENT: return "GL_FRAMEBUFFER_INCOMPLETE_MISSING_ATTACHMENT"; +#ifdef GL_FRAMEBUFFER_INCOMPLETE_DRAW_BUFFER case GL_FRAMEBUFFER_INCOMPLETE_DRAW_BUFFER: return "GL_FRAMEBUFFER_INCOMPLETE_DRAW_BUFFER"; +#endif +#ifdef GL_FRAMEBUFFER_INCOMPLETE_READ_BUFFER case GL_FRAMEBUFFER_INCOMPLETE_READ_BUFFER: return "GL_FRAMEBUFFER_INCOMPLETE_READ_BUFFER"; +#endif case GL_FRAMEBUFFER_UNSUPPORTED: return "GL_FRAMEBUFFER_UNSUPPORTED"; case GL_FRAMEBUFFER_INCOMPLETE_MULTISAMPLE: return "GL_FRAMEBUFFER_INCOMPLETE_MULTISAMPLE"; case GL_FRAMEBUFFER_INCOMPLETE_LAYER_TARGETS: return "GL_FRAMEBUFFER_INCOMPLETE_LAYER_TARGETS"; @@ -4441,6 +4816,7 @@ size_t gl_context::initDepthPasses(size_t resolution) { depthPassCount = std::min(depthPassCount, WZ_MAX_SHADOW_CASCADES); +#if !defined(__EMSCRIPTEN__) if (depthPassCount > 1) { if ((!gles && !GLAD_GL_VERSION_3_0) || (gles && !GLAD_GL_ES_VERSION_3_0)) @@ -4449,12 +4825,18 @@ size_t gl_context::initDepthPasses(size_t resolution) debug(LOG_ERROR, "Cannot create depth texture array - requires OpenGL 3.0+ / OpenGL ES 3.0+ - this will fail"); } } +#endif // delete prior depth texture & FBOs (if present) - if (glDeleteFramebuffers && depthFBO.size() > 0) +#if !defined(WZ_STATIC_GL_BINDINGS) + if (glDeleteFramebuffers) +#endif { - glDeleteFramebuffers(static_cast(depthFBO.size()), depthFBO.data()); - depthFBO.clear(); + if (depthFBO.size() > 0) + { + glDeleteFramebuffers(static_cast(depthFBO.size()), depthFBO.data()); + depthFBO.clear(); + } } if (depthTexture) { @@ -4490,7 +4872,9 @@ size_t gl_context::initDepthPasses(size_t resolution) glBindFramebuffer(GL_FRAMEBUFFER, newFBO); if (depthTexture->isArray()) { +#if !defined(WZ_STATIC_GL_BINDINGS) ASSERT(glFramebufferTextureLayer != nullptr, "glFramebufferTextureLayer is not available?"); +#endif glFramebufferTextureLayer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, depthTexture->id(), 0, static_cast(i)); // OpenGL 3.0+ / ES 3.0+ only } else @@ -4516,15 +4900,20 @@ size_t gl_context::initDepthPasses(size_t resolution) void gl_context::deleteSceneRenderpass() { // delete prior scene texture & FBOs (if present) - if (glDeleteFramebuffers && sceneFBO.size() > 0) - { - glDeleteFramebuffers(static_cast(sceneFBO.size()), sceneFBO.data()); - sceneFBO.clear(); - } - if (glDeleteFramebuffers && sceneResolveFBO.size() > 0) +#if !defined(WZ_STATIC_GL_BINDINGS) + if (glDeleteFramebuffers) +#endif { - glDeleteFramebuffers(static_cast(sceneResolveFBO.size()), sceneResolveFBO.data()); - sceneResolveFBO.clear(); + if (sceneFBO.size() > 0) + { + glDeleteFramebuffers(static_cast(sceneFBO.size()), sceneFBO.data()); + sceneFBO.clear(); + } + if (sceneResolveFBO.size() > 0) + { + glDeleteFramebuffers(static_cast(sceneResolveFBO.size()), sceneResolveFBO.data()); + sceneResolveFBO.clear(); + } } if (sceneMsaaRBO) { @@ -4547,12 +4936,14 @@ bool gl_context::createSceneRenderpass() { deleteSceneRenderpass(); +#if !defined(__EMSCRIPTEN__) if ( ! ((!gles && GLAD_GL_VERSION_3_0) || (gles && GLAD_GL_ES_VERSION_3_0)) ) { // The following requires OpenGL 3.0+ or OpenGL ES 3.0+ debug(LOG_ERROR, "Unsupported version of OpenGL / OpenGL ES."); return false; } +#endif wzGLClearErrors(); // clear OpenGL error states @@ -4709,19 +5100,31 @@ void gl_context::endSceneRenderPass() invalid_ap[0] = GL_DEPTH_STENCIL_ATTACHMENT; glInvalidateFramebuffer(GL_FRAMEBUFFER, 1, invalid_ap); } +#if !defined(__EMSCRIPTEN__) else { invalid_ap[0] = GL_DEPTH_ATTACHMENT; invalid_ap[1] = GL_STENCIL_ATTACHMENT; - if (!gles && GLAD_GL_ARB_invalidate_subdata && glInvalidateFramebuffer) + if (!gles && GLAD_GL_ARB_invalidate_subdata) { - glInvalidateFramebuffer(GL_FRAMEBUFFER, 2, invalid_ap); + #if !defined(WZ_STATIC_GL_BINDINGS) + if (glInvalidateFramebuffer) + #endif + { + glInvalidateFramebuffer(GL_FRAMEBUFFER, 2, invalid_ap); + } } - else if (gles && GLAD_GL_EXT_discard_framebuffer && glDiscardFramebufferEXT) + else if (gles && GLAD_GL_EXT_discard_framebuffer) { - glDiscardFramebufferEXT(GL_FRAMEBUFFER, 2, invalid_ap); + #if !defined(WZ_STATIC_GL_BINDINGS) + if (glDiscardFramebufferEXT) + #endif + { + glDiscardFramebufferEXT(GL_FRAMEBUFFER, 2, invalid_ap); + } } } +#endif // If MSAA is enabled, use glBiltFramebuffer from the intermediate MSAA-enabled renderbuffer storage to a standard texture (resolving MSAA) bool usingMSAAIntermediate = (sceneMsaaRBO != 0); @@ -4748,10 +5151,12 @@ void gl_context::endSceneRenderPass() } else { +#if defined(GL_ARB_invalidate_subdata) if (!gles && GLAD_GL_ARB_invalidate_subdata && glInvalidateFramebuffer) { glInvalidateFramebuffer(GL_READ_FRAMEBUFFER, 1, invalid_msaarbo_ap); } +#endif // else if (gles && GLAD_GL_EXT_discard_framebuffer && glDiscardFramebufferEXT) // { // // NOTE: glDiscardFramebufferEXT only supports GL_FRAMEBUFFER... but that doesn't work here @@ -4779,3 +5184,27 @@ gfx_api::abstract_texture* gl_context::getSceneTexture() { return sceneTexture; } + +#if defined(__EMSCRIPTEN__) +static bool getWebGLExtensions() +{ + supportedWebGLExtensions.clear(); + char* spaceSeparatedExtensions = emscripten_webgl_get_supported_extensions(); + if (!spaceSeparatedExtensions) + { + return false; + } + + debug(LOG_INFO, "Supported WebGL extensions: %s", spaceSeparatedExtensions); + + std::vector strings; + std::istringstream str_stream(spaceSeparatedExtensions); + std::string s; + while (getline(str_stream, s, ' ')) { + supportedWebGLExtensions.insert(s); + } + + free(spaceSeparatedExtensions); + return true; +} +#endif diff --git a/lib/ivis_opengl/gfx_api_gl.h b/lib/ivis_opengl/gfx_api_gl.h index 0b33b408fb5..4c0a7bf9fd0 100644 --- a/lib/ivis_opengl/gfx_api_gl.h +++ b/lib/ivis_opengl/gfx_api_gl.h @@ -21,7 +21,17 @@ #include "gfx_api.h" +#if defined(__EMSCRIPTEN__) +# define WZ_STATIC_GL_BINDINGS +#endif + +#if !defined(__EMSCRIPTEN__) || !defined(WZ_STATIC_GL_BINDINGS) #include +#else +// Emscripten uses static linking for performance +#include +typedef void* (* GLADloadproc)(const char *name); +#endif #include #include #include diff --git a/lib/ivis_opengl/textdraw.cpp b/lib/ivis_opengl/textdraw.cpp index 64cbfb118a0..c9812cbf823 100644 --- a/lib/ivis_opengl/textdraw.cpp +++ b/lib/ivis_opengl/textdraw.cpp @@ -1076,7 +1076,9 @@ static bool inline initializeCJKFontsIfNeeded() cjkFonts->smallBold = std::make_unique(getGlobalFTlib().lib, CJK_FONT_PATH, 9 * 64, horizDPI, vertDPI, 700); } catch (const std::exception &e) { +#if !defined(__EMSCRIPTEN__) debug(LOG_ERROR, "Failed to load font:\n%s", e.what()); +#endif delete cjkFonts; cjkFonts = nullptr; failedToLoadCJKFonts = true; @@ -1203,7 +1205,9 @@ void iV_TextInit(unsigned int horizScalePercentage, unsigned int vertScalePercen // (since it's only loaded on-demand, and thus might fail with a fatal error later if missing) if (PHYSFS_exists(CJK_FONT_PATH) == 0) { +#if !defined(__EMSCRIPTEN__) debug(LOG_FATAL, "Missing data file: %s", CJK_FONT_PATH); +#endif } m_unicode_funcs_hb = hb_unicode_funcs_get_default(); diff --git a/lib/sdl/main_sdl.cpp b/lib/sdl/main_sdl.cpp index 49f7d29d99d..96128bd8109 100644 --- a/lib/sdl/main_sdl.cpp +++ b/lib/sdl/main_sdl.cpp @@ -70,6 +70,10 @@ #include "cocoa_wz_menus.h" #endif +#if defined(__EMSCRIPTEN__) +#include +#endif + #include using nonstd::optional; using nonstd::nullopt; @@ -93,8 +97,9 @@ int main(int argc, char *argv[]) static SDL_Window *WZwindow = nullptr; static optional WZbackend = video_backend::opengl; -#if defined(WZ_OS_MAC) || defined(WZ_OS_WIN) +#if defined(WZ_OS_MAC) || defined(WZ_OS_WIN) || defined(__EMSCRIPTEN__) // on macOS, SDL_WINDOW_FULLSCREEN_DESKTOP *must* be used (or high-DPI fullscreen toggling breaks) +// on Emscripten (browser), SDL_WINDOW_FULLSCREEN_DESKTOP should be used (to avoid various toggling breaks) const WINDOW_MODE WZ_SDL_DEFAULT_FULLSCREEN_MODE = WINDOW_MODE::desktop_fullscreen; #else const WINDOW_MODE WZ_SDL_DEFAULT_FULLSCREEN_MODE = WINDOW_MODE::fullscreen; @@ -214,6 +219,17 @@ static optional wzQuitExitCode; bool wzReduceDisplayScalingIfNeeded(int currWidth, int currHeight); +#if defined(__EMSCRIPTEN__) + +#include +#include + +void wzemscripten_startup_ensure_canvas_displayed(); +bool wz_emscripten_enable_soft_fullscreen(); +EM_BOOL wz_emscripten_fullscreenchange_callback(int eventType, const EmscriptenFullscreenChangeEvent *fullscreenChangeEvent, void *userData); + +#endif + /**************************/ /*** Misc support ***/ /**************************/ @@ -394,7 +410,9 @@ static std::vector& sortGfxBackendsForCurrentSystem(std::vector wzAvailableGfxBackends() { std::vector availableBackends; +#if !defined(__EMSCRIPTEN__) availableBackends.push_back(video_backend::opengl); +#endif #if !defined(WZ_OS_MAC) // OpenGL ES is not supported on macOS, and WZ doesn't currently ship with an OpenGL ES library on macOS availableBackends.push_back(video_backend::opengles); #endif @@ -412,7 +430,10 @@ video_backend wzGetDefaultGfxBackendForCurrentSystem() { // SDL backend supports: OpenGL, OpenGLES, Vulkan (if compiled with support), DirectX (on Windows, via LibANGLE) -#if defined(_WIN32) && defined(WZ_BACKEND_DIRECTX) && (defined(_M_ARM64) || defined(_M_ARM)) +#if defined(__EMSCRIPTEN__) + // For Emscripten, OpenGLES (WebGL) should be the default + return video_backend::opengles; +#elif defined(_WIN32) && defined(WZ_BACKEND_DIRECTX) && (defined(_M_ARM64) || defined(_M_ARM)) // On ARM-based Windows, DirectX should be the default (for compatibility) return video_backend::directx; #else @@ -666,7 +687,7 @@ WINDOW_MODE wzGetCurrentWindowMode() std::vector wzSupportedWindowModes() { -#if defined(WZ_OS_MAC) +#if defined(WZ_OS_MAC) || defined(__EMSCRIPTEN__) // on macOS, SDL_WINDOW_FULLSCREEN_DESKTOP *must* be used (or high-DPI fullscreen toggling breaks) // thus "classic" fullscreen is not supported return {WINDOW_MODE::desktop_fullscreen, WINDOW_MODE::windowed}; @@ -751,8 +772,8 @@ WINDOW_MODE wzGetToggleFullscreenMode() bool wzChangeWindowMode(WINDOW_MODE mode, bool silent) { - auto currMode = wzGetCurrentWindowMode(); - if (currMode == mode) + auto previousMode = wzGetCurrentWindowMode(); + if (previousMode == mode) { // already in this mode return true; @@ -766,15 +787,27 @@ bool wzChangeWindowMode(WINDOW_MODE mode, bool silent) if (!silent) { - debug(LOG_INFO, "Changing window mode: %s -> %s", to_display_string(currMode).c_str(), to_display_string(mode).c_str()); + debug(LOG_INFO, "Changing window mode: %s -> %s", to_display_string(previousMode).c_str(), to_display_string(mode).c_str()); } int sdl_result = -1; switch (mode) { case WINDOW_MODE::desktop_fullscreen: - sdl_result = SDL_SetWindowFullscreen(WZwindow, SDL_WINDOW_FULLSCREEN_DESKTOP); - if (sdl_result != 0) { return false; } +#if defined(__EMSCRIPTEN__) + emscripten_exit_soft_fullscreen(); +#endif + sdl_result = SDL_SetWindowFullscreen(WZwindow, SDL_WINDOW_FULLSCREEN_DESKTOP); // TODO: Currently crashes in Emscripten builds? + if (sdl_result != 0) + { +#if defined(__EMSCRIPTEN__) + if (previousMode == WINDOW_MODE::windowed) + { + wz_emscripten_enable_soft_fullscreen(); + } +#endif + return false; + } wzSetWindowIsResizable(false); break; case WINDOW_MODE::windowed: @@ -814,8 +847,20 @@ bool wzChangeWindowMode(WINDOW_MODE mode, bool silent) } case WINDOW_MODE::fullscreen: { +#if defined(__EMSCRIPTEN__) + emscripten_exit_soft_fullscreen(); +#endif sdl_result = SDL_SetWindowFullscreen(WZwindow, SDL_WINDOW_FULLSCREEN); - if (sdl_result != 0) { return false; } + if (sdl_result != 0) + { +#if defined(__EMSCRIPTEN__) + if (previousMode == WINDOW_MODE::windowed) + { + wz_emscripten_enable_soft_fullscreen(); + } +#endif + return false; + } wzSetWindowIsResizable(false); int currWidth = 0, currHeight = 0; SDL_GetWindowSize(WZwindow, &currWidth, &currHeight); @@ -3183,6 +3228,10 @@ bool wzMainScreenSetup(optional backend, int antialiasing, WINDOW } #endif +#if defined(__EMSCRIPTEN__) + wzemscripten_startup_ensure_canvas_displayed(); +#endif + SDL_gfx_api_Impl_Factory::Configuration sdl_impl_config; if (backend.has_value()) @@ -3263,6 +3312,20 @@ bool wzMainScreenSetup(optional backend, int antialiasing, WINDOW if (backend.has_value()) { wzMainScreenSetup_VerifyWindow(); + +#if defined(__EMSCRIPTEN__) + // Catch the full screen change events + // - If user-initiated (i.e. by pressing ESC or similar to exit fullscreen), SDL does not currently expose this event + // - SDL itself sets a fullscreenchange callback on the document, which must remain for SDL functionality - fortunately, emscripten lets us set one on the canvas element itself + emscripten_set_fullscreenchange_callback("#canvas", nullptr, 0, wz_emscripten_fullscreenchange_callback); + + auto mode = wzGetCurrentWindowMode(); + if (mode == WINDOW_MODE::windowed) + { + // Enable "soft fullscreen" - where the canvas automatically fills the window + wz_emscripten_enable_soft_fullscreen(); + } +#endif } #if defined(WZ_OS_WIN) @@ -3555,9 +3618,20 @@ static void handleActiveEvent(SDL_Event *event) } static SDL_Event event; +#if defined(__EMSCRIPTEN__) +std::function saved_onShutdown; +unsigned lastLoopReturn = 0; +#endif void wzEventLoopOneFrame(void* arg) { +#if defined(__EMSCRIPTEN__) + if (lastLoopReturn > 0) + { + wz_emscripten_did_finish_render(lastLoopReturn - wzGetTicks()); + } +#endif + /* Deal with any windows messages */ while (SDL_PollEvent(&event)) { @@ -3584,12 +3658,27 @@ void wzEventLoopOneFrame(void* arg) inputhandleText(&event.text); break; case SDL_QUIT: +#if defined(__EMSCRIPTEN__) + // Exit "soft fullscreen" - (as long as we aren't in "real" fullscreen mode) + emscripten_exit_soft_fullscreen(); + + // Actually trigger cleanup code + if (saved_onShutdown) + { + saved_onShutdown(); + } + wzShutdown(); + + // Stop Emscripten from calling the main loop + emscripten_cancel_main_loop(); +#else ASSERT(arg != nullptr, "No valid bContinue"); if (arg) { bool *bContinue = static_cast(arg); *bContinue = false; } +#endif return; default: break; @@ -3617,6 +3706,9 @@ void wzEventLoopOneFrame(void* arg) processScreenSizeChangeNotificationIfNeeded(); mainLoop(); // WZ does its thing inputNewFrame(); // reset input states +#if defined(__EMSCRIPTEN__) + lastLoopReturn = wzGetTicks(); +#endif } // Actual mainloop @@ -3624,6 +3716,11 @@ void wzMainEventLoop(std::function onShutdown) { event.type = 0; +#if defined(__EMSCRIPTEN__) + saved_onShutdown = onShutdown; + // Receives a function to call and some user data to provide it. + emscripten_set_main_loop_arg(wzEventLoopOneFrame, nullptr, -1, true); +#else bool bContinue = true; while (bContinue) { @@ -3634,6 +3731,7 @@ void wzMainEventLoop(std::function onShutdown) { onShutdown(); } +#endif } void wzPumpEventsWhileLoading() @@ -3686,3 +3784,76 @@ uint64_t wzGetCurrentSystemRAM() int value = SDL_GetSystemRAM(); return (value > 0) ? static_cast(value) : 0; } + +// MARK: - Emscripten-specific functions + +#if defined(__EMSCRIPTEN__) + +void wzemscripten_startup_ensure_canvas_displayed() +{ + MAIN_THREAD_EM_ASM({ + if (typeof wz_js_display_canvas === "function") { + wz_js_display_canvas(); + } + else { + console.log('Cannot find wz_js_display_canvas function'); + } + }); +} + +EM_BOOL wz_emscripten_window_resized_callback(int eventType, const void *reserved, void *userData) +{ + double width, height; + emscripten_get_element_css_size("#canvas", &width, &height); + + int newWindowWidth = (int)width, newWindowHeight = (int)height; + + wzAsyncExecOnMainThread([newWindowWidth, newWindowHeight]{ + // resize SDL window + SDL_SetWindowSize(WZwindow, newWindowWidth, newWindowHeight); + + unsigned int oldWindowWidth = windowWidth; + unsigned int oldWindowHeight = windowHeight; + handleWindowSizeChange(oldWindowWidth, oldWindowHeight, newWindowWidth, newWindowHeight); + // Store the new values (in case the user manually resized the window bounds) + war_SetWidth(newWindowWidth); + war_SetHeight(newWindowHeight); + }); + return EMSCRIPTEN_RESULT_SUCCESS; +} + +bool wz_emscripten_enable_soft_fullscreen() +{ + // Enable "soft fullscreen" - where the canvas automatically fills the window + debug(LOG_INFO, "Would enter soft fullscreen"); + EmscriptenFullscreenStrategy strategy; + strategy.scaleMode = EMSCRIPTEN_FULLSCREEN_CANVAS_SCALE_STDDEF; + strategy.filteringMode = EMSCRIPTEN_FULLSCREEN_FILTERING_DEFAULT; + strategy.canvasResizedCallback = wz_emscripten_window_resized_callback; + strategy.canvasResizedCallbackUserData = nullptr; // pointer to user data + strategy.canvasResizedCallbackTargetThread = pthread_self(); // not used + EMSCRIPTEN_RESULT result = emscripten_enter_soft_fullscreen("#canvas", &strategy); + return result == EMSCRIPTEN_RESULT_SUCCESS || result == EMSCRIPTEN_RESULT_DEFERRED; +} + +EM_BOOL wz_emscripten_fullscreenchange_callback(int eventType, const EmscriptenFullscreenChangeEvent *fullscreenChangeEvent, void *userData) +{ + if (!fullscreenChangeEvent->isFullscreen) + { + // browser left fullscreen, so reset soft fullscreen mode + wzAsyncExecOnMainThread([]{ + + war_setWindowMode(WINDOW_MODE::windowed); // persist the change + + wz_emscripten_enable_soft_fullscreen(); + + // manually trigger resize callback + wz_emscripten_window_resized_callback(EMSCRIPTEN_EVENT_FULLSCREENCHANGE, nullptr, nullptr); + + }); + } + + return EM_FALSE; // return false to ensure this event "bubbles up" to the SDL event handler +} + +#endif diff --git a/platforms/emscripten/assets/android-chrome-192x192.png b/platforms/emscripten/assets/android-chrome-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..71647e3ac4875551a19d298f3175d25f36a90164 GIT binary patch literal 23331 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

r3B)4- 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 ckYj$F@`}h!)w1@E69{o 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

  • $G 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%0DuuF{yihJ_C)4t{ShVO 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 literal 0 HcmV?d00001 diff --git a/platforms/emscripten/assets/favicon.ico b/platforms/emscripten/assets/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..f5ad349852e09aba207a73e2963dfc30c18aa824 GIT binary patch literal 15086 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 An*aa+ literal 0 HcmV?d00001 diff --git a/platforms/emscripten/manifest.json b/platforms/emscripten/manifest.json new file mode 100644 index 00000000000..6e1099cd2b7 --- /dev/null +++ b/platforms/emscripten/manifest.json @@ -0,0 +1,30 @@ +{ + "name": "Warzone 2100", + "short_name": "Warzone 2100", + "description": "The Web Edition of Warzone 2100. Command the forces of The Project in a battle to rebuild the world.", + "icons": [ + { + "src": "./assets/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "./assets/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#000000", + "background_color": "#000000", + "display": "standalone", + "orientation": "landscape", + "related_applications": [ + { + "platform": "windows", + "url": "https://apps.microsoft.com/detail/9MW0Z4MPCS8C" + } + ], + "prefer_related_applications": true, + "start_url": "./", + "scope": "." +} \ No newline at end of file diff --git a/platforms/emscripten/postjs.js b/platforms/emscripten/postjs.js new file mode 100644 index 00000000000..6c57c163b85 --- /dev/null +++ b/platforms/emscripten/postjs.js @@ -0,0 +1,60 @@ + +/* Setup persistent config dir */ +function wzSetupPersistentConfigDir() { + + Module['WZVAL_configDirPath'] = ''; + if (typeof wz_js_get_config_dir_path === "function") { + Module['WZVAL_configDirPath'] = wz_js_get_config_dir_path(); + } + else { + console.log('Unable to get config dir suffix'); + Module['WZVAL_configDirPath'] = '/warzone2100'; + } + + let configDirPath = Module['WZVAL_configDirPath']; + + // Create a directory in IDBFS to store the config dir + FS.mkdir(configDirPath); + + // Mount IDBFS as the file system + FS.mount(FS.filesystems.IDBFS, {}, configDirPath); + + // Synchronize IDBFS -> Emscripten virtual filesystem + Module["addRunDependency"]("persistent_warzone2100_config_dir"); + FS.syncfs(true, (err) => { + console.log('loaded from idbfs', FS.readdir(configDirPath)); + Module["removeRunDependency"]("persistent_warzone2100_config_dir"); + }) +} +function wzSaveConfigDirToPersistentStore(callback) { + let configDirPath = Module['WZVAL_configDirPath']; + FS.syncfs(false, (err) => { + console.log('saved to idbfs', FS.readdir(configDirPath)); + if (callback) callback(); + }) +} +Module.wzSaveConfigDirToPersistentStore = wzSaveConfigDirToPersistentStore; + +if (!Module['preRun']) +{ + Module['preRun'] = []; +} +Module['preRun'].push(wzSetupPersistentConfigDir); + +Module["onExit"] = function() { + // Sync IDBFS + wzSaveConfigDirToPersistentStore(() => { + if (typeof wz_js_display_loading_indicator === "function") { + wz_js_display_loading_indicator(false); + } + else { + alert('It is now safe to close your browser window.'); + } + }); + + if (typeof wz_js_handle_app_exit === "function") { + wz_js_handle_app_exit(); + } +} + +Module['ASAN_OPTIONS'] = 'halt_on_error=0' diff --git a/platforms/emscripten/prejs.js b/platforms/emscripten/prejs.js new file mode 100644 index 00000000000..e69de29bb2d diff --git a/platforms/emscripten/shell.html b/platforms/emscripten/shell.html new file mode 100644 index 00000000000..54117374a30 --- /dev/null +++ b/platforms/emscripten/shell.html @@ -0,0 +1,1370 @@ + + + + + + + + Warzone 2100 - Web Edition + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    +
    +
    +
    +
    +
    +
    +
    + Loading ... +
    +
    +
    + + + + +
    +
    + +
    + + +
    +
    +

    Too Small

    +

    Window / display is too small for Warzone 2100

    +

    Required Minimum: 640x480

    +
    +
    +

    Or get the Full Desktop version, with high-resolution remastered graphics, online multiplayer, better performance, and more.

    +

    + Download Warzone 2100 +

    +
    +
    + + + + + + + + + + {{{ SCRIPT }}} + + diff --git a/po/CMakeLists.txt b/po/CMakeLists.txt index 21b396d5bd1..6fab830a222 100644 --- a/po/CMakeLists.txt +++ b/po/CMakeLists.txt @@ -174,12 +174,18 @@ set(wz2100_translations_LOCALE_FOLDER "${CMAKE_CURRENT_BINARY_DIR}/locale") # On CMake configure, clear the build dir "locale" folder (to ensure re-generation) file(REMOVE_RECURSE "${wz2100_translations_LOCALE_FOLDER}/") +set(_po_install_config) +if(NOT CMAKE_SYSTEM_NAME MATCHES "Emscripten") + set(_po_install_config + INSTALL_DESTINATION "${WZ_LOCALEDIR}" + INSTALL_COMPONENT Languages) +endif() + # Build the *.gmo files from the *.po files (using the .pot file) include(GNUInstallDirs) file (GLOB POFILES *.po) GETTEXT_CREATE_TRANSLATIONS_WZ ("${_potFile}" ALL - INSTALL_DESTINATION "${CMAKE_INSTALL_LOCALEDIR}" - INSTALL_COMPONENT Languages + ${_po_install_config} MSGMERGE_OPTIONS --quiet --no-wrap --width=1 TARGET_FOLDER "po" POFILES ${POFILES}) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 4c65f854e83..d4df7b285ef 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -116,8 +116,12 @@ target_link_libraries(warzone2100 optional-lite) target_link_libraries(warzone2100 quickjs) target_link_libraries(warzone2100 inih) -include(IncludeFindCurl) -target_link_libraries(warzone2100 CURL::libcurl) +if(CMAKE_SYSTEM_NAME MATCHES "Emscripten") + # Exclude CURL implementation, Use Fetch API for Emscripten +else() + include(IncludeFindCurl) + target_link_libraries(warzone2100 CURL::libcurl) +endif() target_link_libraries(warzone2100 re2::re2) find_package(SQLite3 3.14 REQUIRED) @@ -182,7 +186,7 @@ if (DEFINED CURL_OPENSSL_REQUIRES_CALLBACKS) message(STATUS "Ignoring cURL OpenSSL backend, as other thread-safe backend(s) exist") endif() endif() -if (NOT DEFINED CURL_SUPPORTED_SSL_BACKENDS) +if (NOT DEFINED CURL_SUPPORTED_SSL_BACKENDS AND NOT CMAKE_SYSTEM_NAME MATCHES "Emscripten") if (NOT VCPKG_TOOLCHAIN) # ignore warning when using vcpkg message(WARNING "Could not determine cURL's SSL/TLS backends; if cURL is built with OpenSSL < 1.1.0 or GnuTLS < 2.11.0, this may result in thread-safety issues") endif() @@ -230,6 +234,101 @@ endif() # (WZ_APP_INSTALL_DEST is for the platform / generator-specific overrides *in* this file) set(WZ_APP_INSTALL_DEST "${CMAKE_INSTALL_BINDIR}") +####################### +# Emscripten Build Config + +if(CMAKE_SYSTEM_NAME MATCHES "Emscripten") + + # WZ requires WebGL 2 + target_link_options(warzone2100 PRIVATE "SHELL:-s MIN_WEBGL_VERSION=2") + target_link_options(warzone2100 PRIVATE "SHELL:-s MAX_WEBGL_VERSION=2") + + target_link_options(warzone2100 PRIVATE "SHELL:-s FETCH=1") + target_link_options(warzone2100 PRIVATE "SHELL:-s WASM_BIGINT") + + target_link_options(warzone2100 PRIVATE "SHELL:-s ENVIRONMENT=web,worker") # Worker must also be enabled because multithreading is enabled + + target_link_options(warzone2100 PRIVATE "SHELL:-s MINIFY_HTML=0") # Disable MINIFY_HTML + + target_link_options(warzone2100 PRIVATE "SHELL:-s TOTAL_STACK=10485760") + target_link_options(warzone2100 PRIVATE "SHELL:-s INITIAL_MEMORY=268435456") + target_link_options(warzone2100 PRIVATE "SHELL:-s ALLOW_MEMORY_GROWTH=1") + target_link_options(warzone2100 PRIVATE "SHELL:-s DEMANGLE_SUPPORT=1") +# target_link_options(warzone2100 PRIVATE "SHELL:-s GL_ASSERTIONS=1") # Useful for debugging, but impacts performance +# target_link_options(warzone2100 PRIVATE "SHELL:-s LZ4=1") # LZ4 can't be used while also supporting gettext translations (no support for mmap in LZ4 filesystem backend) + #target_link_options(warzone2100 PRIVATE "SHELL:-s FULL_ES3=1") + target_link_options(warzone2100 PRIVATE "SHELL:-s PTHREAD_POOL_SIZE=8") + + # Needed for SDL2 + if ("${EMSCRIPTEN_VERSION}" VERSION_GREATER 3.1.50) + target_link_options(warzone2100 PRIVATE "SHELL:-s GL_ENABLE_GET_PROC_ADDRESS=1") + endif() + + # Display more information on linking issues + target_link_options(warzone2100 PRIVATE "SHELL:-s LLD_REPORT_UNDEFINED") + + # Offscreen canvas and proxy to pthread support + # - not currently supported - seems to lead to eventual freezes in Firefox and flickering in Safari + # target_link_options(warzone2100 PRIVATE "SHELL:-s PROXY_TO_PTHREAD=1") + # target_link_options(warzone2100 PRIVATE "SHELL:-s OFFSCREENCANVAS_SUPPORT=1") + + target_link_options(warzone2100 PRIVATE "SHELL:-lidbfs.js") # Explicitly include IDBFS, see: https://github.com/emscripten-core/emscripten/issues/9406 + + target_link_options(warzone2100 PRIVATE "SHELL:--shell-file ${PROJECT_SOURCE_DIR}/platforms/emscripten/shell.html") + target_link_options(warzone2100 PRIVATE "SHELL:--pre-js ${PROJECT_SOURCE_DIR}/platforms/emscripten/prejs.js") + target_link_options(warzone2100 PRIVATE "SHELL:--post-js ${PROJECT_SOURCE_DIR}/platforms/emscripten/postjs.js") + + target_link_options(warzone2100 PRIVATE "SHELL:-s MODULARIZE") + target_link_options(warzone2100 PRIVATE "SHELL:-s EXPORT_NAME=createWZModule") + target_link_options(warzone2100 PRIVATE "SHELL:-s EXIT_RUNTIME") + + # Bundle core data files + foreach(_data_file ${DATA_FILES}) + get_filename_component(_foldername "${_data_file}" NAME_WE) + message(STATUS "COPYING DATA: ${_foldername}") + target_link_options(warzone2100 PRIVATE "SHELL:--preload-file ${_data_file}@/data/${_foldername}/") + endforeach() + + # Bundle needed base fonts + foreach(_font_file ${DATA_FONTS}) + get_filename_component(_fontname "${_font_file}" NAME) + message(STATUS "COPYING FONT FILE: ${_fontname} (from: ${_font_file})") + target_link_options(warzone2100 PRIVATE "SHELL:--preload-file ${_font_file}@/data/fonts/") + endforeach() + + # Bundle the translations + if(ENABLE_NLS AND TARGET translations AND DEFINED wz2100_translations_LOCALE_FOLDER) + if (NOT WZ_LOCALEDIR_ISABSOLUTE) + message(FATAL_ERROR "Expected absolute path for WZ_LOCALEDIR: ${WZ_LOCALEDIR}") + endif() + target_link_options(warzone2100 PRIVATE "SHELL:--preload-file ${wz2100_translations_LOCALE_FOLDER}@${WZ_LOCALEDIR}/") + endif() + + # Cache preload data in browser (if possible) + target_link_options(warzone2100 PRIVATE "SHELL:--use-preload-cache") + + # Add dependencies on optional packages (if available) + if (TARGET data_music_empackage) + add_dependencies(warzone2100 data_music_empackage) + else() + message(WARNING "Missing data_music package - Emscripten build will not ship with music") + endif() + if (TARGET data_terrain_overrides_classic_packaging) + add_dependencies(warzone2100 data_terrain_overrides_classic_packaging) + else() + message(WARNING "Missing data_terrain_overrides_classic package - Emscripten build will not ship with classic terrain overrides") + endif() + + # Install the app files directly in the destination root + set(WZ_APP_INSTALL_DEST ".") + + # Rename output warzone2100.html -> index.html + add_custom_command(TARGET warzone2100 POST_BUILD + COMMAND ${CMAKE_COMMAND} -E rename "$" "$/index.html" + VERBATIM) + +endif() + ####################### # macOS Build Config @@ -583,7 +682,7 @@ if(CMAKE_SYSTEM_NAME MATCHES "Windows") endif() endif() -if(NOT CMAKE_SYSTEM_NAME MATCHES "Darwin" AND NOT MSVC) +if(NOT CMAKE_SYSTEM_NAME MATCHES "(Darwin|Emscripten)" AND NOT MSVC) # Ensure noexecstack ADD_TARGET_LINK_FLAGS_IF_SUPPORTED(TARGET warzone2100 LINK_FLAGS "-Wl,-z,noexecstack" CACHED_RESULT_NAME LINK_FLAG_WL_Z_NOEXECSTACK_SUPPORTED) # Enable RELRO (if supported) @@ -645,10 +744,80 @@ endif() ####################### # Install -install(TARGETS warzone2100 COMPONENT Core DESTINATION "${WZ_APP_INSTALL_DEST}") -# For Portable packages only, copy the ".portable" file that triggers portable mode (Windows-only) -install(FILES "${CMAKE_SOURCE_DIR}/pkg/portable.in" COMPONENT PortableConfig DESTINATION "${WZ_APP_INSTALL_DEST}" RENAME ".portable" EXCLUDE_FROM_ALL) +if(NOT CMAKE_SYSTEM_NAME MATCHES "Emscripten") + + # Executable + install(TARGETS warzone2100 COMPONENT Core DESTINATION "${WZ_APP_INSTALL_DEST}") + + # For Portable packages only, copy the ".portable" file that triggers portable mode (Windows-only) + install(FILES "${CMAKE_SOURCE_DIR}/pkg/portable.in" COMPONENT PortableConfig DESTINATION "${WZ_APP_INSTALL_DEST}" RENAME ".portable" EXCLUDE_FROM_ALL) + +else() + + # For Emscripten: install the generated files + install(FILES + "$/index.html" + COMPONENT Core + DESTINATION "${WZ_APP_INSTALL_DEST}" + RENAME "index.html") + install(FILES + "$/warzone2100.js" + "$/warzone2100.worker.js" + "$/warzone2100.wasm" + "$/warzone2100.data" + COMPONENT Core + DESTINATION "${WZ_APP_INSTALL_DEST}") + + # Install manifest file + configure_file("${CMAKE_SOURCE_DIR}/platforms/emscripten/manifest.json" "${CMAKE_CURRENT_BINARY_DIR}/manifest.json" COPYONLY) + install(FILES + "${CMAKE_SOURCE_DIR}/platforms/emscripten/manifest.json" + COMPONENT Core + DESTINATION "${WZ_APP_INSTALL_DEST}") + + # Install static assets + file(GLOB static_assets "${CMAKE_SOURCE_DIR}/platforms/emscripten/assets/*.*") + if (static_assets) + install(DIRECTORY DESTINATION "${WZ_APP_INSTALL_DEST}/assets" COMPONENT Core) + install(FILES + ${static_assets} + COMPONENT Core + DESTINATION "${WZ_APP_INSTALL_DEST}/assets") + # Also place assets in build dir so it can be run directly + foreach(asset IN LISTS static_assets) + configure_file("${asset}" "${CMAKE_CURRENT_BINARY_DIR}/" COPYONLY) + endforeach() + endif() + + # Install additional (optional) file packages + foreach(_empackage_dir IN LISTS DATA_ADDITIONAL_EMPACKAGE_DIRS) + install(DIRECTORY + ${_empackage_dir} + COMPONENT Core + DESTINATION "${WZ_APP_INSTALL_DEST}") + endforeach() + + if (WZ_EMSCRIPTEN_COMPRESS_OUTPUT AND NOT CMAKE_VERSION VERSION_LESS "3.18.0") + install(CODE " + execute_process(COMMAND \${CMAKE_COMMAND} -E echo \"++install CODE: CMAKE_PREFIX_PATH: ${CMAKE_PREFIX_PATH}\") + + file(GLOB _files_to_compress LIST_DIRECTORIES false \"\${CMAKE_INSTALL_PREFIX}/${WZ_APP_INSTALL_DEST}/*.js\" \"\${CMAKE_INSTALL_PREFIX}/${WZ_APP_INSTALL_DEST}/*.wasm\" \"\${CMAKE_INSTALL_PREFIX}/${WZ_APP_INSTALL_DEST}/*.data\" \"\${CMAKE_INSTALL_PREFIX}/${WZ_APP_INSTALL_DEST}/*.html\") + + list(LENGTH _files_to_compress _num_files_to_compress) + if (_num_files_to_compress GREATER 0) + foreach(_input_file IN LISTS _files_to_compress) + get_filename_component(_input_file_filename \"\${_input_file}\" NAME) + execute_process(COMMAND \${CMAKE_COMMAND} -E echo \"++install CODE: Compressing file: \${_input_file_filename} -> \${_input_file_filename}.gz\") + file(ARCHIVE_CREATE OUTPUT \"\${CMAKE_INSTALL_PREFIX}/${WZ_APP_INSTALL_DEST}/\${_input_file_filename}.gz\" PATHS \"\${_input_file}\" FORMAT raw COMPRESSION GZip COMPRESSION_LEVEL 7) + endforeach() + else() + message(WARNING \"Did not find any files to compress in: \${CMAKE_INSTALL_PREFIX}/${WZ_APP_INSTALL_DEST}/\") + endif() + " COMPONENT Core) + endif() + +endif() ##################### # Installing Required Runtime Dependencies diff --git a/src/emscripten_helpers.cpp b/src/emscripten_helpers.cpp new file mode 100644 index 00000000000..f016b4e062e --- /dev/null +++ b/src/emscripten_helpers.cpp @@ -0,0 +1,89 @@ +/* + This file is part of Warzone 2100. + Copyright (C) 2022-2024 Warzone 2100 Project + + Warzone 2100 is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + Warzone 2100 is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Warzone 2100; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +#if defined(__EMSCRIPTEN__) + +#include "emscripten_helpers.h" +#include +#include +#include + +/* Older Emscriptens don't have this, but it's needed for wasm64 compatibility. */ +#ifndef MAIN_THREAD_EM_ASM_PTR + #ifdef __wasm64__ + #error You need to upgrade your Emscripten compiler to support wasm64 + #else + #define MAIN_THREAD_EM_ASM_PTR MAIN_THREAD_EM_ASM_INT + #endif +#endif + +EM_JS_DEPS(wz2100emhelpers, "$stringToUTF8,$UTF8ToString"); + +static std::string windowLocationURL; + +std::string WZ_GetEmscriptenWindowLocationURL() +{ + return windowLocationURL; +} + +void initWZEmscriptenHelpers() +{ + // Get window location URL + char *str = (char*)MAIN_THREAD_EM_ASM_PTR({ + let jsString = window.location.href; + let lengthBytes = lengthBytesUTF8(jsString) + 1; + let stringOnWasmHeap = _malloc(lengthBytes); + stringToUTF8(jsString, stringOnWasmHeap, lengthBytes); + return stringOnWasmHeap; + }); + windowLocationURL = (str) ? str : ""; + free(str); // Each call to _malloc() must be paired with free(), or heap memory will leak! +} + +void WZ_EmscriptenSyncPersistFSChanges() +{ + emscripten_runtime_keepalive_push(); // Must be used so that onExit handlers aren't called + emscripten_pause_main_loop(); + + // Note: Module.resumeMainLoop() is equivalent to emscripten_resume_main_loop() + MAIN_THREAD_EM_ASM({ + if (typeof wz_js_display_saving_indicator === "function") { + wz_js_display_saving_indicator(true); + } + let handleFinished = function() { + if (typeof wz_js_display_saving_indicator === "function") { + wz_js_display_saving_indicator(false); + } + Module.resumeMainLoop(); + runtimeKeepalivePop(); + }; + try { + Module.wzSaveConfigDirToPersistentStore(() => { + handleFinished(); + }); + } + catch (error) { + console.error(error); + // Always resume the main loop on error + handleFinished(); + } + }); +} + +#endif diff --git a/src/emscripten_helpers.h b/src/emscripten_helpers.h new file mode 100644 index 00000000000..3100d81d784 --- /dev/null +++ b/src/emscripten_helpers.h @@ -0,0 +1,33 @@ +/* + This file is part of Warzone 2100. + Copyright (C) 2022-2024 Warzone 2100 Project + + Warzone 2100 is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + Warzone 2100 is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Warzone 2100; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +#pragma once + +#if defined(__EMSCRIPTEN__) + +#include + +std::string WZ_GetEmscriptenWindowLocationURL(); + +void WZ_EmscriptenSyncPersistFSChanges(); + +// must be called on the main thread +void initWZEmscriptenHelpers(); + +#endif diff --git a/src/frontend.cpp b/src/frontend.cpp index 20265e0ac25..416c0e324c9 100644 --- a/src/frontend.cpp +++ b/src/frontend.cpp @@ -271,6 +271,9 @@ bool runTitleMenu() break; case FRONTEND_MULTIPLAYER: changeTitleMode(MULTI); +#if defined(__EMSCRIPTEN__) + wzDisplayDialog(Dialog_Information, "Multiplayer requires the native version.", "The web version of Warzone 2100 does not support online multiplayer. Please visit https://wz2100.net to download the native version for your platform."); +#endif break; case FRONTEND_SINGLEPLAYER: changeTitleMode(SINGLE); @@ -621,8 +624,10 @@ void startMultiPlayerMenu() addSideText(FRONTEND_SIDETEXT , FRONTEND_SIDEX, FRONTEND_SIDEY, _("MULTI PLAYER")); +#if !defined(__EMSCRIPTEN__) addTextButton(FRONTEND_HOST, FRONTEND_POS2X, FRONTEND_POS2Y, _("Host Game"), WBUT_TXTCENTRE); addTextButton(FRONTEND_JOIN, FRONTEND_POS3X, FRONTEND_POS3Y, _("Join Game"), WBUT_TXTCENTRE); +#endif addTextButton(FRONTEND_REPLAY, FRONTEND_POS7X, FRONTEND_POS7Y, _("View Replay"), WBUT_TXTCENTRE); addMultiBut(psWScreen, FRONTEND_BOTFORM, FRONTEND_QUIT, 10, 10, 30, 29, P_("menu", "Return"), IMAGE_RETURN, IMAGE_RETURN_HI, IMAGE_RETURN_HI); diff --git a/src/game.cpp b/src/game.cpp index 8e95e239f4e..19450279a8d 100644 --- a/src/game.cpp +++ b/src/game.cpp @@ -114,6 +114,10 @@ # pragma GCC diagnostic ignored "-Wunused-function" #endif +#if defined(__EMSCRIPTEN__) +# include "emscripten_helpers.h" +#endif + bool saveJSONToFile(const nlohmann::json& obj, const char* pFileName) { std::ostringstream stream; @@ -3358,7 +3362,7 @@ bool loadGame(const char *pGameToLoad, bool keepObjects, bool freeMem, bool User } // ----------------------------------------------------------------------------------------- -bool saveGame(const char *aFileName, GAME_TYPE saveType) +bool saveGame(const char *aFileName, GAME_TYPE saveType, bool isAutoSave) { size_t fileExtension; char CurrentFileName[PATH_MAX] = {'\0'}; @@ -3644,6 +3648,13 @@ bool saveGame(const char *aFileName, GAME_TYPE saveType) // strip the last filename CurrentFileName[fileExtension - 1] = '\0'; +#if defined(__EMSCRIPTEN__) + if (!isAutoSave) + { + WZ_EmscriptenSyncPersistFSChanges(); // NOTE: Will block main loop iterations until it finishes (asynchronously) + } +#endif + /* Start the game clock */ triggerEvent(TRIGGER_GAME_SAVED); gameTimeStart(); diff --git a/src/game.h b/src/game.h index 3cb8052e56a..f7d445d6eb9 100644 --- a/src/game.h +++ b/src/game.h @@ -55,7 +55,7 @@ bool loadScriptState(char *pFileName); /// Load the terrain types bool loadTerrainTypeMap(const char *pFilePath); -bool saveGame(const char *aFileName, GAME_TYPE saveType); +bool saveGame(const char *aFileName, GAME_TYPE saveType, bool isAutoSave = false); // Get the campaign number for loadGameInit game UDWORD getCampaign(const char *fileName); @@ -70,4 +70,9 @@ void gameScreenSizeDidChange(unsigned int oldWidth, unsigned int oldHeight, unsi void gameDisplayScaleFactorDidChange(float newDisplayScaleFactor); nonstd::optional parseJsonFile(const char *filename); bool saveJSONToFile(const nlohmann::json& obj, const char* pFileName); + +#if defined(__EMSCRIPTEN__) +void wz_emscripten_did_finish_render(unsigned int browserRenderDelta); +#endif + #endif // __INCLUDED_SRC_GAME_H__ diff --git a/src/loadsave.cpp b/src/loadsave.cpp index ffb0980d6a7..2f72a548179 100644 --- a/src/loadsave.cpp +++ b/src/loadsave.cpp @@ -1161,7 +1161,7 @@ bool autoSave() std::string suggestedName = suggestSaveName(dir).toStdString(); char savefile[PATH_MAX]; snprintf(savefile, sizeof(savefile), "%s/%s_%s.gam", dir, suggestedName.c_str(), savedate); - if (saveGame(savefile, GTYPE_SAVE_MIDMISSION)) + if (saveGame(savefile, GTYPE_SAVE_MIDMISSION, true)) { console(_("AutoSave %s"), savegameWithoutExtension(savefile)); return true; diff --git a/src/loop.cpp b/src/loop.cpp index 8b50b544dc7..9d64d8759b8 100644 --- a/src/loop.cpp +++ b/src/loop.cpp @@ -603,6 +603,24 @@ void setMaxFastForwardTicks(optional value, bool fixedToNormalTickRate) fastForwardTicksFixedToNormalTickRate = fixedToNormalTickRate; } +static int renderBudget = 0; // Scaled time spent rendering minus scaled time spent updating. +const Rational renderFraction(2, 5); // Minimum fraction of time spent rendering. +const Rational updateFraction = Rational(1) - renderFraction; + +#if defined(__EMSCRIPTEN__) +unsigned lastRenderDelta = 0; +void wz_emscripten_did_finish_render(unsigned int browserRenderDelta) +{ + if (GetGameMode() != GS_NORMAL) + { + return; + } + renderBudget += (lastRenderDelta + browserRenderDelta) * updateFraction.n; + renderBudget = std::min(renderBudget, (renderFraction * 500).floor()); + lastRenderDelta = 0; +} +#endif + /* The main game loop */ GAMECODE gameLoop() { @@ -610,10 +628,7 @@ GAMECODE gameLoop() static uint32_t lastFlushTime = 0; static size_t numForcedUpdatesLastCall = 0; - static int renderBudget = 0; // Scaled time spent rendering minus scaled time spent updating. static bool previousUpdateWasRender = false; - const Rational renderFraction(2, 5); // Minimum fraction of time spent rendering. - const Rational updateFraction = Rational(1) - renderFraction; // Shouldn't this be when initialising the game, rather than randomly called between ticks? countUpdate(false); // kick off with correct counts @@ -683,8 +698,12 @@ GAMECODE gameLoop() pie_ScreenFrameRenderEnd(); // must happen here for proper renderBudget calculation unsigned after = wzGetTicks(); +#if defined(__EMSCRIPTEN__) + lastRenderDelta = (after - before); +#else renderBudget += (after - before) * updateFraction.n; renderBudget = std::min(renderBudget, (renderFraction * 500).floor()); +#endif previousUpdateWasRender = true; if (headlessGameMode() && autogame_enabled()) diff --git a/src/main.cpp b/src/main.cpp index 1d78634f109..505aa315183 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1619,6 +1619,10 @@ void osSpecificPostInit_Win() } #endif /* defined(WZ_OS_WIN) */ +#if defined(__EMSCRIPTEN__) +# include "emscripten_helpers.h" +#endif + void osSpecificFirstChanceProcessSetup() { #if defined(WZ_OS_WIN) @@ -1637,6 +1641,10 @@ void osSpecificFirstChanceProcessSetup() #else // currently, no-op #endif + +#if defined(__EMSCRIPTEN__) // must be separate, because WZ_OS_UNIX is also defined for emscripten builds + initWZEmscriptenHelpers(); +#endif } void osSpecificPostInit() @@ -1838,7 +1846,11 @@ int realmain(int argc, char *argv[]) osSpecificFirstChanceProcessSetup(); debug_init(); +#if defined(__EMSCRIPTEN__) + debug_register_callback(debug_callback_emscripten_log, nullptr, nullptr, nullptr); +#else debug_register_callback(debug_callback_stderr, nullptr, nullptr, nullptr); +#endif #if defined(_WIN32) && defined(DEBUG_INSANE) debug_register_callback(debug_callback_win32debug, NULL, NULL, NULL); #endif // WZ_OS_WIN && DEBUG_INSANE diff --git a/src/stdinreader.cpp b/src/stdinreader.cpp index a9484976b02..cdbe6d5cff1 100644 --- a/src/stdinreader.cpp +++ b/src/stdinreader.cpp @@ -109,7 +109,7 @@ int quitSignalEventFd = -1; int quitSignalPipeFds[2] = {-1, -1}; #endif -#if !defined(_WIN32) && (defined(HAVE_SYS_EVENTFD_H) || defined(HAVE_UNISTD_H)) +#if !defined(_WIN32) && !defined(__EMSCRIPTEN__) && (defined(HAVE_SYS_EVENTFD_H) || defined(HAVE_UNISTD_H)) # define WZ_STDIN_READER_SUPPORTED #endif diff --git a/src/updatemanager.cpp b/src/updatemanager.cpp index 8dbe7657715..c29a8eca050 100644 --- a/src/updatemanager.cpp +++ b/src/updatemanager.cpp @@ -26,6 +26,7 @@ using json = nlohmann::json; #include #include #include +#include #include "lib/framework/wzglobal.h" // required for config.h #include "lib/framework/frame.h" @@ -529,6 +530,11 @@ ProcessResult WzUpdateManager::processUpdateJSONFile(const json& updateData, boo void WzUpdateManager::initUpdateCheck() { std::vector updateDataUrls = {"https://data.wz2100.net/wz2100.json", "https://warzone2100.github.io/update-data/wz2100.json"}; +#if defined(__EMSCRIPTEN__) + // Bypass browser cache (if needed) by appending a query string parameter + std::string queryStringParam = std::to_string(std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()).count()); + updateDataUrls.insert(updateDataUrls.begin() + 1, "https://data.wz2100.net/wz2100.json?v=" + queryStringParam); +#endif initProcessData(updateDataUrls, WzUpdateManager::processUpdateJSONFile, updatesCachePaths, nullptr); } @@ -669,6 +675,11 @@ ProcessResult WzCompatCheckManager::processCompatCheckJSONFile(const json& updat void WzCompatCheckManager::initCompatCheck() { std::vector updateDataUrls = {"https://data.wz2100.net/wz2100_compat.json", "https://warzone2100.github.io/update-data/wz2100_compat.json"}; +#if defined(__EMSCRIPTEN__) + // Bypass browser cache (if needed) by appending a query string parameter + std::string queryStringParam = std::to_string(std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()).count()); + updateDataUrls.insert(updateDataUrls.begin() + 1, "https://data.wz2100.net/wz2100_compat.json?v=" + queryStringParam); +#endif initProcessData(updateDataUrls, WzCompatCheckManager::processCompatCheckJSONFile, compatCachePaths, []() { // set an unsuccessful result (if no prior result set) setCompatCheckResults(CompatCheckResults(false), true); diff --git a/src/urlhelpers.cpp b/src/urlhelpers.cpp index 3ef201c84c3..c1c6823fca9 100644 --- a/src/urlhelpers.cpp +++ b/src/urlhelpers.cpp @@ -206,6 +206,16 @@ bool openURLInBrowser(char const *url) return succeededOpeningUrl; } +#if defined(__EMSCRIPTEN__) + +std::string urlEncode(const char* urlFragment) +{ + // TODO: Implement? + return urlFragment; +} + +#else // !defined(__EMSCRIPTEN__) + #include std::string urlEncode(const char* urlFragment) @@ -239,6 +249,8 @@ std::string urlEncode(const char* urlFragment) return result; } +#endif // defined(__EMSCRIPTEN__) + bool openFolderInDefaultFileManager(const char* path) { #if defined(WZ_OS_WIN) diff --git a/src/urlrequest_emscripten.cpp b/src/urlrequest_emscripten.cpp new file mode 100644 index 00000000000..47aea771c01 --- /dev/null +++ b/src/urlrequest_emscripten.cpp @@ -0,0 +1,572 @@ +/* + This file is part of Warzone 2100. + Copyright (C) 2022 Warzone 2100 Project + + Warzone 2100 is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + Warzone 2100 is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Warzone 2100; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +#if defined(__EMSCRIPTEN__) + +#include "urlrequest.h" +#include "urlrequest_private.h" +#include "lib/framework/frame.h" +#include "lib/framework/wzapp.h" +#include +#include + +#include + +#define MAXIMUM_DOWNLOAD_SIZE 2147483647L +#define MAXIMUM_IN_MEMORY_DOWNLOAD_SIZE 1 << 29 // 512 MB, default max download limit + +class URLTransferRequest; + +static WZ_MUTEX *urlRequestMutex = nullptr; +static std::list> activeURLRequests; + +struct AsyncRequestImpl : public AsyncRequest +{ +public: + AsyncRequestImpl(const std::shared_ptr& request) + : weakRequest(request) + { } +public: + std::weak_ptr weakRequest; +}; + +class EmFetchHTTPResponseDetails : public HTTPResponseDetails { +public: + EmFetchHTTPResponseDetails(bool fetchResult, long httpStatusCode, std::shared_ptr responseHeaders) + : HTTPResponseDetails(httpStatusCode, responseHeaders) + , _fetchResult(fetchResult) + { } + virtual ~EmFetchHTTPResponseDetails() + { } + + std::string getInternalResultDescription() const override + { + return (_fetchResult) ? "Success" : "Returned Error"; + } + +private: + bool _fetchResult; +}; + +void wz_fetch_success(emscripten_fetch_t *fetch); +void wz_fetch_failure(emscripten_fetch_t *fetch); +void wz_fetch_onProgress(emscripten_fetch_t *fetch); +void wz_fetch_onReadyStateChange(emscripten_fetch_t *fetch); + +class URLTransferRequest : public std::enable_shared_from_this +{ +public: + emscripten_fetch_t *handle = nullptr; + + struct FetchRequestHeaders + { + private: + std::vector fetchHeadersLayout; + public: + FetchRequestHeaders(const std::unordered_map& requestHeaders) + { + for (auto it : requestHeaders) + { + const auto& header_key = it.first; + const auto& header_value = it.second; + fetchHeadersLayout.push_back(strdup(it.first.c_str())); + fetchHeadersLayout.push_back(strdup(it.second.c_str())); + } + fetchHeadersLayout.push_back(0); + } + ~FetchRequestHeaders() + { + for (auto& value : fetchHeadersLayout) + { + if (value) + { + free(value); + } + } + fetchHeadersLayout.clear(); + } + + // FetchRequestHeaders is non-copyable + FetchRequestHeaders(const FetchRequestHeaders&) = delete; + FetchRequestHeaders& operator=(const FetchRequestHeaders&) = delete; + + // FetchRequestHeaders is movable + FetchRequestHeaders(FetchRequestHeaders&& other) = default; + FetchRequestHeaders& operator=(FetchRequestHeaders&& other) = default; + public: + const char* const * getPointer() + { + return fetchHeadersLayout.data(); + } + }; + +public: + URLTransferRequest() + { } + + virtual ~URLTransferRequest() + { } + + void cancel() + { + deliberatelyCancelled = true; + emscripten_fetch_close(handle); + handle = nullptr; + } + + virtual const std::string& url() const = 0; + virtual const char* requestMethod() const { return "GET"; } + virtual InternetProtocol protocol() const = 0; + virtual bool noProxy() const = 0; + virtual const std::unordered_map& requestHeaders() const = 0; + virtual uint64_t maxDownloadSize() const { return MAXIMUM_DOWNLOAD_SIZE; } + + virtual emscripten_fetch_t* initiateFetch() + { + // Create emscripten fetch attr struct + emscripten_fetch_attr_t attr; + emscripten_fetch_attr_init(&attr); + + // Set request method + strcpy(attr.requestMethod, requestMethod()); + + // Always load to memory + attr.attributes = EMSCRIPTEN_FETCH_LOAD_TO_MEMORY; + + if (waitOnShutdown()) + { + attr.attributes |= EMSCRIPTEN_FETCH_WAITABLE; + } + + if (noProxy()) + { + wzAsyncExecOnMainThread([]{ + debug(LOG_NET, "Fetch: NOPROXY is not supported"); + }); + } + + formattedRequestHeaders = std::make_shared(requestHeaders()); + attr.requestHeaders = formattedRequestHeaders->getPointer(); + + // Set callbacks + attr.onsuccess = wz_fetch_success; + attr.onerror = wz_fetch_failure; + attr.onprogress = wz_fetch_onProgress; + attr.onreadystatechange = wz_fetch_onReadyStateChange; + + // Set userdata to point to this + attr.userData = (void*)this; + + // Create handle + handle = emscripten_fetch(&attr, url().c_str()); + + return handle; + } + + bool wasCancelled() const { return deliberatelyCancelled; } + + virtual bool hasWriteMemoryCallback() { return false; } + virtual bool writeMemoryCallback(const void *contents, uint64_t numBytes, uint64_t dataOffset) { return false; } + + virtual bool onProgressUpdate(uint64_t dltotal, uint64_t dlnow) { return false; } + + virtual bool waitOnShutdown() const { return false; } + + virtual void handleRequestSuccess(unsigned short status) { } + virtual void handleRequestError(unsigned short status) { } + virtual void requestFailedToFinish(URLRequestFailureType type) { } + +protected: + friend void wz_fetch_onReadyStateChange(emscripten_fetch_t *fetch); + std::shared_ptr responseHeaders = std::make_shared(); +private: + bool deliberatelyCancelled = false; + std::shared_ptr formattedRequestHeaders; +}; + +void wz_fetch_success(emscripten_fetch_t *fetch) +{ + std::shared_ptr pSharedRequest; + if (fetch->userData) + { + URLTransferRequest* pRequest = static_cast(fetch->userData); + + if (pRequest->hasWriteMemoryCallback()) + { + // Need to pass the downloaded block to the callback + pRequest->writeMemoryCallback(fetch->data, fetch->numBytes, fetch->dataOffset); + } + + pRequest->handleRequestSuccess(fetch->status); + + // now remove from the list of activeURLRequests + pSharedRequest = pRequest->shared_from_this(); + wzMutexLock(urlRequestMutex); + activeURLRequests.remove(pSharedRequest); + wzMutexUnlock(urlRequestMutex); + } + + emscripten_fetch_close(fetch); +} + +void wz_fetch_failure(emscripten_fetch_t *fetch) +{ + if (!fetch) return; + bool wasCancelled = false; + std::shared_ptr pSharedRequest; + if (fetch->userData) + { + URLTransferRequest* pRequest = static_cast(fetch->userData); + wasCancelled = pRequest->wasCancelled(); + if (!wasCancelled) + { + // handle the error + pRequest->handleRequestError(fetch->status); + } + + // now remove from the list of activeURLRequests + pSharedRequest = pRequest->shared_from_this(); + wzMutexLock(urlRequestMutex); + activeURLRequests.remove(pSharedRequest); + wzMutexUnlock(urlRequestMutex); + } + + if (!wasCancelled) // until emscripten bug is fixed, can't call emscripten_fetch_close from within error handler if already triggered by cancellation + { + emscripten_fetch_close(fetch); + } +} + +void wz_fetch_onProgress(emscripten_fetch_t *fetch) +{ + if (!fetch) return; + if (fetch->status != 200) return; + if (fetch->userData == nullptr) return; + + URLTransferRequest* pRequest = static_cast(fetch->userData); + pRequest->onProgressUpdate(fetch->totalBytes /* NOTE: May be 0 */, fetch->dataOffset + fetch->numBytes); +} + +void wz_fetch_onReadyStateChange(emscripten_fetch_t *fetch) +{ + if (fetch->readyState != 2) return; // 2 = HEADERS_RECEIVED + + if (fetch->userData == nullptr) return; + URLTransferRequest* pRequest = static_cast(fetch->userData); + + size_t headersLengthBytes = emscripten_fetch_get_response_headers_length(fetch) + 1; + char *headerString = new char[headersLengthBytes]; + emscripten_fetch_get_response_headers(fetch, headerString, headersLengthBytes); + char **responseHeaders = emscripten_fetch_unpack_response_headers(headerString); + delete[] headerString; + + int numHeaders = 0; + for(; responseHeaders[numHeaders * 2]; ++numHeaders) + { + if (responseHeaders[(numHeaders * 2) + 1] == nullptr) + { + break; + } + + std::string header_key = (responseHeaders[numHeaders * 2]) ? responseHeaders[numHeaders * 2] : ""; + std::string header_value = (responseHeaders[(numHeaders * 2) + 1]) ? responseHeaders[(numHeaders * 2) + 1] : ""; + trim_str(header_key); + trim_str(header_value); + pRequest->responseHeaders->responseHeaders[header_key] = header_value; + } + + emscripten_fetch_free_unpacked_response_headers(responseHeaders); +} + +class RunningURLTransferRequestBase : public URLTransferRequest +{ +public: + virtual const URLRequestBase& getBaseRequest() const = 0; +public: + RunningURLTransferRequestBase() + : URLTransferRequest() + { } + + virtual const std::string& url() const override + { + return getBaseRequest().url; + } + + virtual InternetProtocol protocol() const override + { + return getBaseRequest().protocol; + } + + virtual bool noProxy() const override + { + return getBaseRequest().noProxy; + } + + virtual const std::unordered_map& requestHeaders() const override + { + return getBaseRequest().getRequestHeaders(); + } + + virtual bool onProgressUpdate(uint64_t dltotal, uint64_t dlnow) override + { + auto request = getBaseRequest(); + if (request.progressCallback) + { + request.progressCallback(request.url, static_cast(dltotal), static_cast(dlnow)); + } + return false; + } + + virtual void handleRequestSuccess(unsigned short status) override + { + onSuccess(EmFetchHTTPResponseDetails(true, status, responseHeaders)); + } + + virtual void handleRequestError(unsigned short status) override + { + onFailure(URLRequestFailureType::TRANSFER_FAILED, std::make_shared(false, status, responseHeaders)); + } + + virtual void requestFailedToFinish(URLRequestFailureType type) override + { + ASSERT_OR_RETURN(, type != URLRequestFailureType::TRANSFER_FAILED, "TRANSFER_FAILED should be handled by handleRequestDone"); + onFailure(type, nullptr); + } + +private: + virtual void onSuccess(const HTTPResponseDetails& responseDetails) = 0; + + void onFailure(URLRequestFailureType type, const std::shared_ptr& transferDetails) + { + auto request = getBaseRequest(); + + const std::string& url = request.url; + switch (type) + { + case URLRequestFailureType::INITIALIZE_REQUEST_ERROR: + wzAsyncExecOnMainThread([url]{ + debug(LOG_NET, "Fetch: Failed to initialize request for (%s)", url.c_str()); + }); + break; + case URLRequestFailureType::TRANSFER_FAILED: + if (!transferDetails) + { + wzAsyncExecOnMainThread([url]{ + debug(LOG_ERROR, "Fetch: Request for (%s) failed - but no transfer failure details provided!", url.c_str()); + }); + } + else + { + long httpStatusCode = transferDetails->httpStatusCode(); + wzAsyncExecOnMainThread([url, httpStatusCode]{ + debug(LOG_NET, "Fetch: Request for (%s) failed with HTTP response code: %ld", url.c_str(), httpStatusCode); + }); + } + break; + case URLRequestFailureType::CANCELLED: + wzAsyncExecOnMainThread([url]{ + debug(LOG_NET, "Fetch: Request for (%s) was cancelled", url.c_str()); + }); + break; + case URLRequestFailureType::CANCELLED_BY_SHUTDOWN: + wzAsyncExecOnMainThread([url]{ + debug(LOG_NET, "Fetch: Request for (%s) was cancelled by application shutdown", url.c_str()); + }); + break; + } + + if (request.onFailure) + { + request.onFailure(request.url, type, transferDetails); + } + } +}; + +class RunningURLDataRequest : public RunningURLTransferRequestBase +{ +public: + URLDataRequest request; + std::shared_ptr chunk; +private: + uint64_t _maxDownloadSize = MAXIMUM_IN_MEMORY_DOWNLOAD_SIZE; // 512 MB, default max download limit + +public: + RunningURLDataRequest(const URLDataRequest& request) + : RunningURLTransferRequestBase() + , request(request) + { + chunk = std::make_shared(); + if (request.maxDownloadSizeLimit > 0) + { + _maxDownloadSize = std::min(request.maxDownloadSizeLimit, static_cast(MAXIMUM_IN_MEMORY_DOWNLOAD_SIZE)); + } + } + + virtual const URLRequestBase& getBaseRequest() const override + { + return request; + } + + virtual uint64_t maxDownloadSize() const override + { + // For downloading to memory, set a lower default max download limit + return _maxDownloadSize; + } + + virtual bool hasWriteMemoryCallback() override { return true; } + virtual bool writeMemoryCallback(const void *contents, uint64_t numBytes, uint64_t dataOffset) override + { + size_t realsize = numBytes; + MemoryStruct *mem = chunk.get(); + +#if UINT64_MAX > SIZE_MAX + if (numBytes > static_cast(std::numeric_limits::max())) + { + return false; + } + if (dataOffset > static_cast(std::numeric_limits::max())) + { + return false; + } + if ((dataOffset + numBytes) > static_cast(std::numeric_limits::max())) + { + return false; + } +#endif + + if (static_cast(dataOffset + numBytes) > mem->size) + { + char *ptr = (char*) realloc(mem->memory, static_cast(dataOffset + numBytes) + 1); + if(ptr == NULL) { + /* out of memory! */ + return false; + } + mem->memory = ptr; + mem->size = static_cast(dataOffset + numBytes); + } + + memcpy(&(mem->memory[static_cast(dataOffset)]), contents, static_cast(numBytes)); + mem->memory[mem->size] = 0; + + return true; + } + +private: + void onSuccess(const HTTPResponseDetails& responseDetails) override + { + if (request.onSuccess) + { + request.onSuccess(request.url, responseDetails, chunk); + } + } +}; + + +// Request data from a URL (stores the response in memory) +// Generally, you should define both onSuccess and onFailure callbacks +// If you want to actually process the response, you *must* define an onSuccess callback +// +// IMPORTANT: Callbacks may be called on a background thread +AsyncURLRequestHandle urlRequestData(const URLDataRequest& request) +{ + ASSERT_OR_RETURN(nullptr, !request.url.empty(), "A valid request must specify a URL"); + ASSERT_OR_RETURN(nullptr, request.maxDownloadSizeLimit <= (curl_off_t)MAXIMUM_IN_MEMORY_DOWNLOAD_SIZE, "Requested maxDownloadSizeLimit exceeds maximum in-memory download size limit %zu", (size_t)MAXIMUM_IN_MEMORY_DOWNLOAD_SIZE); + + std::shared_ptr pNewRequest = std::make_shared(request); + std::shared_ptr requestHandle = std::make_shared(pNewRequest); + + wzMutexLock(urlRequestMutex); + activeURLRequests.push_back(pNewRequest); + wzMutexUnlock(urlRequestMutex); + + if (!pNewRequest->initiateFetch()) + { + pNewRequest->requestFailedToFinish(URLRequestFailureType::INITIALIZE_REQUEST_ERROR); + wzMutexLock(urlRequestMutex); + activeURLRequests.remove(pNewRequest); + wzMutexUnlock(urlRequestMutex); + return nullptr; + } + + return requestHandle; +} + +// Download a file (stores the response in the outFilePath) +// Generally, you should define both onSuccess and onFailure callbacks +// +// IMPORTANT: Callbacks may be called on a background thread +AsyncURLRequestHandle urlDownloadFile(const URLFileDownloadRequest& request) +{ + // TODO: Implement + return nullptr; +} + +// Sets a flag that will cancel an asynchronous url request +// NOTE: It is possible that the request will finish successfully before it is cancelled. +void urlRequestSetCancelFlag(AsyncURLRequestHandle requestHandle) +{ + std::shared_ptr pRequestImpl = std::dynamic_pointer_cast(requestHandle); + if (pRequestImpl) + { + if (auto request = pRequestImpl->weakRequest.lock()) + { + request->cancel(); + } + } +} + +void urlRequestInit() +{ + // Currently, nothing to do +} +void urlRequestOutputDebugInfo() +{ + // Currently a no-op for Emscripten Fetch backend +} +void urlRequestShutdown() +{ + // For now, just cancel all outstanding non-"waitOnShutdown" requests + // FUTURE TODO: Examine how to best handle "waitOnShutdown" requests? + + // build a copy of the active list - the actual active list is managed by the callbacks for url requests + wzMutexLock(urlRequestMutex); + auto activeURLRequestsCopy = activeURLRequests; + wzMutexUnlock(urlRequestMutex); + + debug(LOG_NET, "urlRequestShutdown: Remaining requests: %zu", activeURLRequestsCopy.size()); + + auto it = activeURLRequestsCopy.begin(); + while (it != activeURLRequestsCopy.end()) + { + auto runningTransfer = *it; + if (!runningTransfer->waitOnShutdown()) + { + // just cancel it + runningTransfer->cancel(); // this will ultimately handle removing from the main global list + runningTransfer->requestFailedToFinish(URLRequestFailureType::CANCELLED_BY_SHUTDOWN); + it = activeURLRequestsCopy.erase(it); + } + else + { + ++it; + } + } +} + +#endif diff --git a/src/wrappers.cpp b/src/wrappers.cpp index 237ce72b8a5..b4b00180145 100644 --- a/src/wrappers.cpp +++ b/src/wrappers.cpp @@ -45,6 +45,10 @@ #include "wrappers.h" #include "titleui/titleui.h" +#if defined(__EMSCRIPTEN__) +#include +#endif + struct STAR { int xPos; @@ -278,6 +282,20 @@ void loadingScreenCallback() wzPumpEventsWhileLoading(); } +#if defined(__EMSCRIPTEN__) +void wzemscripten_display_web_loading_indicator(int x) +{ + MAIN_THREAD_EM_ASM({ + if (typeof wz_js_display_loading_indicator === "function") { + wz_js_display_loading_indicator($0); + } + else { + console.log('Cannot find wz_js_display_loading_indicator function'); + } + }, x); +} +#endif + // fill buffers with the static screen void initLoadingScreen(bool drawbdrop) { @@ -286,8 +304,12 @@ void initLoadingScreen(bool drawbdrop) wzShowMouse(false); pie_SetFogStatus(false); +#if !defined(__EMSCRIPTEN__) // setup the callback.... resSetLoadCallback(loadingScreenCallback); +#else + wzemscripten_display_web_loading_indicator(1); +#endif if (drawbdrop) { @@ -311,7 +333,11 @@ void closeLoadingScreen() free(stars); stars = nullptr; } +#if !defined(__EMSCRIPTEN__) resSetLoadCallback(nullptr); +#else + wzemscripten_display_web_loading_indicator(0); +#endif } diff --git a/src/wzpropertyproviders.cpp b/src/wzpropertyproviders.cpp index cd5a75036db..a4f362cdee6 100644 --- a/src/wzpropertyproviders.cpp +++ b/src/wzpropertyproviders.cpp @@ -59,6 +59,11 @@ #include #endif +// Includes for Emscripten +#if defined(__EMSCRIPTEN__) +# include "emscripten_helpers.h" +#endif + // MARK: - BuildPropertyProvider enum class BuildProperty { @@ -313,9 +318,13 @@ static const std::unordered_map Date: Thu, 8 Feb 2024 14:59:52 -0500 Subject: [PATCH 06/34] [GitHub Actions] Initial Emscripten CI --- .github/workflows/CI_emscripten.yml | 99 +++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 .github/workflows/CI_emscripten.yml diff --git a/.github/workflows/CI_emscripten.yml b/.github/workflows/CI_emscripten.yml new file mode 100644 index 00000000000..3366ea8a542 --- /dev/null +++ b/.github/workflows/CI_emscripten.yml @@ -0,0 +1,99 @@ +name: Emscripten + +on: + push: + branches-ignore: + - 'l10n_**' # Push events to translation service branches (that begin with "l10n_") + pull_request: + # Match all pull requests... + paths-ignore: + # Except some text-only files / documentation + - 'ChangeLog' + # Except those that only include changes to stats + - 'data/base/stats/**' + - 'data/mp/stats/**' + - 'data/mp/multiplay/script/functions/camTechEnabler.js' + # Support running after "Draft Tag Release" workflow completes, as part of automated release process + workflow_run: + workflows: ["Draft Tag Release"] + push: + tags: + - '*' + types: + - completed + +env: + # Customize the CMake build type here (Release, Debug, RelWithDebInfo, etc.) + BUILD_TYPE: Release + EMSDK_VERSION: 3.1.53 + +jobs: + build: + name: 'wasm32' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + path: 'src' + - name: Configure Repo Checkout + id: checkout-config + working-directory: ./src + env: + WORKFLOW_RUN_CONCLUSION: ${{ github.event.workflow_run.conclusion }} + WORKFLOW_RUN_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + run: | + . .ci/githubactions/checkout_config.sh + - name: Prepare Git Repo for autorevision + working-directory: '${{ github.workspace }}/src' + run: cmake -P .ci/githubactions/prepare_git_repo.cmake + - name: Init Git Submodules + working-directory: '${{ github.workspace }}/src' + run: git submodule update --init --recursive + - name: Prep Directories + run: | + mkdir -p "${{ github.workspace }}/build" + mkdir -p "${{ github.workspace }}/installed" + + - name: Prep Build Environment + run: | + # Install additional host tools + DEBIAN_FRONTEND=noninteractive sudo apt-get -y install cmake git zip unzip gettext asciidoctor + + - name: Install EMSDK + id: emsdk + run: | + git clone https://github.com/emscripten-core/emsdk.git + cd emsdk + # Download and install the latest SDK tools + ./emsdk install ${EMSDK_VERSION} + # Make the "latest" SDK "active" for the current user. (writes .emscripten file) + ./emsdk activate ${EMSDK_VERSION} + # Output full path to activation script + echo "CI_EMSDK_ENV_SCRIPT_PATH=$(pwd)/emsdk_env.sh" >> $GITHUB_OUTPUT + echo "CI_EMSDK_ENV_SCRIPT_PATH=$(pwd)/emsdk_env.sh" >> $GITHUB_ENV + + - name: CMake Configure + working-directory: '${{ github.workspace }}/build' + env: + WZ_INSTALL_DIR: '${{ github.workspace }}/installed' + run: | + # Setup vcpkg in build dir + git clone https://github.com/microsoft/vcpkg.git vcpkg + # CMake Configure + source "${CI_EMSDK_ENV_SCRIPT_PATH}" + echo "::add-matcher::${GITHUB_WORKSPACE}/src/.ci/githubactions/pattern_matchers/cmake.json" + cmake -DCMAKE_BUILD_TYPE=${BUILD_TYPE} -DWZ_ENABLE_WARNINGS:BOOL=ON -DWZ_DISTRIBUTOR:STRING="${WZ_DISTRIBUTOR}" "-DCMAKE_TOOLCHAIN_FILE=vcpkg/scripts/buildsystems/vcpkg.cmake" "-DVCPKG_TARGET_TRIPLET=wasm32-emscripten" "-DCMAKE_INSTALL_PREFIX:PATH=${WZ_INSTALL_DIR}" "${{ github.workspace }}/src" + echo "::remove-matcher owner=cmake::" + + - name: CMake Build + working-directory: '${{ github.workspace }}/build' + run: | + source "${CI_EMSDK_ENV_SCRIPT_PATH}" + echo "::add-matcher::${GITHUB_WORKSPACE}/src/.ci/githubactions/pattern_matchers/clang.json" + cmake --build . --config ${BUILD_TYPE} --target install + echo "::remove-matcher owner=clang::" + + - name: Debug Output + working-directory: ${{github.workspace}}/installed + run: ls -al From 81ae7083a2433a3524fe9b67da65317cd1ebb79b Mon Sep 17 00:00:00 2001 From: past-due <30942300+past-due@users.noreply.github.com> Date: Fri, 9 Feb 2024 17:33:56 -0500 Subject: [PATCH 07/34] Emscripten: Generate a service worker using workbox-cli --- cmake/FindNPM.cmake | 63 ++++++++++++ cmake/FindWorkboxCLI.cmake | 38 ++++++++ .../cmake/WorkboxRemoveOldFiles.cmake | 22 +++++ platforms/emscripten/no-op-service-worker.js | 8 ++ platforms/emscripten/wz-workbox-config.js | 95 +++++++++++++++++++ src/CMakeLists.txt | 41 ++++++++ 6 files changed, 267 insertions(+) create mode 100644 cmake/FindNPM.cmake create mode 100644 cmake/FindWorkboxCLI.cmake create mode 100644 platforms/emscripten/cmake/WorkboxRemoveOldFiles.cmake create mode 100644 platforms/emscripten/no-op-service-worker.js create mode 100644 platforms/emscripten/wz-workbox-config.js diff --git a/cmake/FindNPM.cmake b/cmake/FindNPM.cmake new file mode 100644 index 00000000000..7aadab61b70 --- /dev/null +++ b/cmake/FindNPM.cmake @@ -0,0 +1,63 @@ +# FindNPM +# -------- +# +# This module finds an installed npm. +# It sets the following variables: +# +# NPM_FOUND - True when NPM is found +# NPM_GLOBAL_PREFIX_DIR - The global prefix directory +# NPM_EXECUTABLE - The path to the npm executable +# NPM_VERSION - The version number of the npm executable + +find_program(NPM_EXECUTABLE NAMES npm HINTS /usr) + +if (NPM_EXECUTABLE) + + # Get the global npm prefix + execute_process(COMMAND ${NPM_EXECUTABLE} prefix -g + OUTPUT_VARIABLE NPM_GLOBAL_PREFIX_DIR + ERROR_VARIABLE NPM_prefix_g_error + RESULT_VARIABLE NPM_prefix_g_result_code + ) + # Remove spaces and newlines + string (STRIP ${NPM_GLOBAL_PREFIX_DIR} NPM_GLOBAL_PREFIX_DIR) + if (NPM_prefix_g_result_code) + if(NPM_FIND_REQUIRED) + message(SEND_ERROR "Command \"${NPM_EXECUTABLE} prefix -g\" failed with output:\n${NPM_prefix_g_error}") + else() + message(STATUS "Command \"${NPM_EXECUTABLE} prefix -g\" failed with output:\n${NPM_prefix_g_error}") + endif() + endif() + unset(NPM_prefix_g_error) + unset(NPM_prefix_g_result_code) + + # Get the VERSION + execute_process(COMMAND ${NPM_EXECUTABLE} -v + OUTPUT_VARIABLE NPM_VERSION + ERROR_VARIABLE NPM_version_error + RESULT_VARIABLE NPM_version_result_code + ) + if(NPM_version_result_code) + if(NPM_FIND_REQUIRED) + message(SEND_ERROR "Command \"${NPM_EXECUTABLE} -v\" failed with output:\n${NPM_version_error}") + else() + message(STATUS "Command \"${NPM_EXECUTABLE} -v\" failed with output:\n${NPM_version_error}") + endif() + endif() + unset(NPM_version_error) + unset(NPM_version_result_code) + + # Remove spaces and newlines + string (STRIP ${NPM_VERSION} NPM_VERSION) +else() + if (NPM_FIND_REQUIRED) + message(SEND_ERROR "Failed to find npm executable") + endif() +endif() + +find_package_handle_standard_args(NPM + REQUIRED_VARS NPM_EXECUTABLE NPM_GLOBAL_PREFIX_DIR + VERSION_VAR NPM_VERSION +) + +mark_as_advanced(NPM_GLOBAL_PREFIX_DIR NPM_EXECUTABLE NPM_VERSION) diff --git a/cmake/FindWorkboxCLI.cmake b/cmake/FindWorkboxCLI.cmake new file mode 100644 index 00000000000..1ec407f1f21 --- /dev/null +++ b/cmake/FindWorkboxCLI.cmake @@ -0,0 +1,38 @@ +# FindWorkboxCLI +# -------------- +# +# This module finds a globally-installed workbox-cli. +# It sets the following variables: +# +# WorkboxCLI_FOUND - True when workbox-cli is found +# WorkboxCLI_COMMAND - The command used to execute workbox-cli + +find_package(NPM REQUIRED) + +# Check for workbox-cli (global install) +execute_process(COMMAND + ${CMAKE_COMMAND} -E env NPM_CONFIG_PREFIX=${NPM_GLOBAL_PREFIX_DIR} ${NPM_EXECUTABLE} list -g workbox-cli + OUTPUT_VARIABLE NPM_LIST_GLOBAL_WORKBOX_CLI_output + ERROR_VARIABLE NPM_LIST_GLOBAL_WORKBOX_CLI_error + RESULT_VARIABLE NPM_LIST_GLOBAL_WORKBOX_CLI_result_code +) + +if (NPM_LIST_GLOBAL_WORKBOX_CLI_result_code EQUAL 0) + set(WorkboxCLI_COMMAND "${CMAKE_COMMAND}" -E env "NPM_CONFIG_PREFIX=${NPM_GLOBAL_PREFIX_DIR}" "${NPM_EXECUTABLE}" exec -no -- workbox-cli) +else() + if(WorkboxCLI_FIND_REQUIRED) + message(SEND_ERROR "Command \"${NPM_EXECUTABLE} list -g workbox-cli\" failed with output:\n${NPM_LIST_GLOBAL_WORKBOX_CLI_error}") + else() + message(STATUS "Command \"${NPM_EXECUTABLE} list -g workbox-cli\" failed with output:\n${NPM_LIST_GLOBAL_WORKBOX_CLI_error}") + endif() +endif() + +unset(NPM_LIST_GLOBAL_WORKBOX_CLI_output) +unset(NPM_LIST_GLOBAL_WORKBOX_CLI_error) +unset(NPM_LIST_GLOBAL_WORKBOX_CLI_result_code) + +find_package_handle_standard_args(WorkboxCLI + REQUIRED_VARS WorkboxCLI_COMMAND +) + +mark_as_advanced(NPM_GLOBAL_PREFIX_DIR NPM_EXECUTABLE NPM_VERSION) diff --git a/platforms/emscripten/cmake/WorkboxRemoveOldFiles.cmake b/platforms/emscripten/cmake/WorkboxRemoveOldFiles.cmake new file mode 100644 index 00000000000..a5ee2ca7ce6 --- /dev/null +++ b/platforms/emscripten/cmake/WorkboxRemoveOldFiles.cmake @@ -0,0 +1,22 @@ +cmake_minimum_required(VERSION 3.5...3.24) +if(${CMAKE_VERSION} VERSION_LESS 3.12) + cmake_policy(VERSION ${CMAKE_MAJOR_VERSION}.${CMAKE_MINOR_VERSION}) +endif() + +# Remove the files that workbox-cli generates as output from the current/working directory +# - service-worker.js +# - service-worker.js.map +# - workbox-*.js +# - workbox-*.js.map + +file(GLOB WORKBOX_GENERATED_FILES LIST_DIRECTORIES false + "${CMAKE_CURRENT_SOURCE_DIR}/service-worker.js" + "${CMAKE_CURRENT_SOURCE_DIR}/service-worker.js.map" + "${CMAKE_CURRENT_SOURCE_DIR}/workbox-*.js" + "${CMAKE_CURRENT_SOURCE_DIR}/workbox-*.js.map" +) + +foreach(_file IN LISTS WORKBOX_GENERATED_FILES) + execute_process(COMMAND ${CMAKE_COMMAND} -E echo "Removing old generated file: ${_file}") + file(REMOVE "${_file}") +endforeach() diff --git a/platforms/emscripten/no-op-service-worker.js b/platforms/emscripten/no-op-service-worker.js new file mode 100644 index 00000000000..f19c0117628 --- /dev/null +++ b/platforms/emscripten/no-op-service-worker.js @@ -0,0 +1,8 @@ +// no-op service worker + +self.addEventListener('install', () => { + // Skip over the "waiting" lifecycle state, to ensure that our + // new service worker is activated immediately, even if there's + // another tab open controlled by our older service worker code. + self.skipWaiting(); +}); diff --git a/platforms/emscripten/wz-workbox-config.js b/platforms/emscripten/wz-workbox-config.js new file mode 100644 index 00000000000..7ecd9164344 --- /dev/null +++ b/platforms/emscripten/wz-workbox-config.js @@ -0,0 +1,95 @@ +// NOTE: This config should be run on the *installed* files +module.exports = { + // ----------------------------- + // Pre-cache configuration + globDirectory: './', + globPatterns: [ + 'index.html', + 'manifest.json', + // warzone2100.js, warzone2100.worker.js + '*.js', + // Core WASM file + 'warzone2100.wasm', + // Specific favicon assets + 'assets/favicon-16x16.png', + 'assets/favicon-32x32.png', + 'assets/android-chrome-192x192.png', + // Any remaining css, js, or json files from the assets directory + 'assets/*.{css,js,json}' + ], + globIgnores: [ + '**\/node_modules\/**\/*', + '**/*.data', // do not precache .data files, which are often huge + '**/*.debug.wasm', // do not precache wasm debug symbols + '**\/music\/**\/*', // do not precache music (which is optional) + '**\/terrain_overrides\/**\/*' // do not precache terrain_overrides (which are optional) + ], + // NOTE: These should match the versions used in shell.html! + additionalManifestEntries: [ + { url: 'https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.2/css/bootstrap.min.css', revision: null, integrity: 'sha512-b2QcS5SsA8tZodcDtGRELiGv5SaKSk1vDHDaQRda0htPYWZ6046lr3kJ5bAAQdpV2mmA/4v0wQF9MyU6/pDIAg==' }, + { url: 'https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.2/js/bootstrap.bundle.min.js', revision: null, integrity: 'sha512-X/YkDZyjTf4wyc2Vy16YGCPHwAY8rZJY+POgokZjQB2mhIRFJCckEGc6YyX9eNsPfn0PzThEuNs+uaomE5CO6A==' } + ], + maximumFileSizeToCacheInBytes: 104857600, // Must be greater than the file size of any precached files + // ----------------------------- + // Runtime caching configuration + runtimeCaching: [ + // NOTE: In regards to warzone2100.data: + // - We do not want to precache it, because it's huge (and precaching doesn't let us show a nice progress bar currently) + // - It is version/build-specific, and should always be in sync with the other data / files + // - It is already cached by the Emscripten preload cache, which handles checking the expected cached file checksum + // So we do not want to runtime cache it via the service worker + // + // NOTE: These named runtime caches are shared across all different build variants of WZ hosted on a domain + // So set maxEntries to constrain cache size and evict old entries + // + // Cache music & terrain_overrides data loader JS for offline use + { + urlPattern: new RegExp('/(music|terrain_overrides)/.*\.js$'), + handler: 'NetworkFirst', + options: { + cacheName: 'optional-data-js-loaders', + expiration: { + maxEntries: 12, + purgeOnQuotaError: true + } + } + }, + // Music & terrain_overrides data + // Note: Each build will generate music and terrain_override packages, but these are not expected to change frequently. + // Since the .data files should be the same (unless Emscripten's file_packager.py changes its packing format), + // ideally do not cache them here, and instead rely on Emscripten's preload-cache (which will handle differences if they occur) + // (Otherwise we'd end up with likely duplicate data in the cache, if the user loads multiple build variants of WZ hosted on a domain) + // { + // urlPattern: new RegExp('/(music|terrain_overrides)/.*\.data$'), + // handler: 'NetworkFirst', + // options: { + // cacheName: 'optional-data-packages', + // expiration: { + // maxEntries: 4, + // purgeOnQuotaError: true + // } + // } + // }, + // + // Backup on-demand caching of any additional utilized CSS and JS files for offline use + // (useful in case someone forgot to update the additionalManifestEntries above) + { + urlPattern: new RegExp('/.*\.(js|css)$'), + handler: 'NetworkFirst', + options: { + cacheName: 'additional-dependencies', + expiration: { + maxEntries: 20, + purgeOnQuotaError: true + } + } + }, + ], + // ----------------------------- + swDest: './service-worker.js', + offlineGoogleAnalytics: false, + ignoreURLParametersMatching: [ + /^utm_/, + /^fbclid$/ + ] +}; diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index d4df7b285ef..32396009f2d 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -327,6 +327,34 @@ if(CMAKE_SYSTEM_NAME MATCHES "Emscripten") COMMAND ${CMAKE_COMMAND} -E rename "$" "$/index.html" VERBATIM) + # If workbox-cli is available, generate a proper service worker + set(GENERATED_SERVICE_WORKER FALSE) + find_package(WorkboxCLI) + if (WorkboxCLI_FOUND) + add_custom_target(generate_service_worker ALL + COMMAND ${CMAKE_COMMAND} -P "${CMAKE_SOURCE_DIR}/platforms/emscripten/cmake/WorkboxRemoveOldFiles.cmake" + COMMAND ${WorkboxCLI_COMMAND} generateSW "${CMAKE_SOURCE_DIR}/platforms/emscripten/wz-workbox-config.js" + WORKING_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}" + BYPRODUCTS "${CMAKE_CURRENT_BINARY_DIR}/service-worker.js" + VERBATIM + ) + add_dependencies(generate_service_worker warzone2100) + set(GENERATED_SERVICE_WORKER TRUE) + endif() + # Otherwise, copy the no-op-service-worker.js -> service-worker.js + if (NOT GENERATED_SERVICE_WORKER) + message(WARNING "workbox-cli is not available - will use a no-op service worker") + # configure_file("${CMAKE_SOURCE_DIR}/platforms/emscripten/no-op-service-worker.js" "${CMAKE_CURRENT_BINARY_DIR}/service-worker.js" COPYONLY) + add_custom_target(generate_service_worker ALL + COMMAND ${CMAKE_COMMAND} -E copy_if_different "${CMAKE_SOURCE_DIR}/platforms/emscripten/no-op-service-worker.js" "${CMAKE_CURRENT_BINARY_DIR}/service-worker.js" + WORKING_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}" + DEPENDS "${CMAKE_SOURCE_DIR}/platforms/emscripten/no-op-service-worker.js" + BYPRODUCTS "${CMAKE_CURRENT_BINARY_DIR}/service-worker.js" + VERBATIM + ) + add_dependencies(generate_service_worker warzone2100) + endif() + endif() ####################### @@ -798,6 +826,19 @@ else() DESTINATION "${WZ_APP_INSTALL_DEST}") endforeach() + # Install service worker (and associated files) + install(CODE " + file(GLOB _service_worker_files LIST_DIRECTORIES false \"${CMAKE_CURRENT_BINARY_DIR}/service-worker.js\" \"${CMAKE_CURRENT_BINARY_DIR}/workbox-*.js\") + list(LENGTH _service_worker_files _num_service_worker_files) + if (_num_service_worker_files GREATER 0) + foreach(_input_file IN LISTS _service_worker_files) + file(INSTALL \"\${_input_file}\" DESTINATION \"\${CMAKE_INSTALL_PREFIX}/${WZ_APP_INSTALL_DEST}\") + endforeach() + else() + message(WARNING \"Did not find any service worker files in: ${CMAKE_CURRENT_BINARY_DIR}/\") + endif() + " COMPONENT Core) + if (WZ_EMSCRIPTEN_COMPRESS_OUTPUT AND NOT CMAKE_VERSION VERSION_LESS "3.18.0") install(CODE " execute_process(COMMAND \${CMAKE_COMMAND} -E echo \"++install CODE: CMAKE_PREFIX_PATH: ${CMAKE_PREFIX_PATH}\") From 79f4dc82b157b4df722723237732026f71797d30 Mon Sep 17 00:00:00 2001 From: past-due <30942300+past-due@users.noreply.github.com> Date: Fri, 9 Feb 2024 17:39:17 -0500 Subject: [PATCH 08/34] [GitHub Actions] Emscripten: Install workbox-cli --- .github/workflows/CI_emscripten.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/CI_emscripten.yml b/.github/workflows/CI_emscripten.yml index 3366ea8a542..9a720f549e6 100644 --- a/.github/workflows/CI_emscripten.yml +++ b/.github/workflows/CI_emscripten.yml @@ -60,6 +60,11 @@ jobs: # Install additional host tools DEBIAN_FRONTEND=noninteractive sudo apt-get -y install cmake git zip unzip gettext asciidoctor + - uses: actions/setup-node@v4 + + - name: Install workbox-cli + run: npm install workbox-cli --global + - name: Install EMSDK id: emsdk run: | From f800fc098e91cfb8a3f0f4b8da1c8c832c00ffe2 Mon Sep 17 00:00:00 2001 From: past-due <30942300+past-due@users.noreply.github.com> Date: Fri, 9 Feb 2024 20:10:36 -0500 Subject: [PATCH 09/34] Add Emscripten docs --- platforms/emscripten/README-build.md | 49 ++++++++++++++++++ platforms/emscripten/README.md | 75 ++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 platforms/emscripten/README-build.md create mode 100644 platforms/emscripten/README.md diff --git a/platforms/emscripten/README-build.md b/platforms/emscripten/README-build.md new file mode 100644 index 00000000000..6410b6e523b --- /dev/null +++ b/platforms/emscripten/README-build.md @@ -0,0 +1,49 @@ +# Building Warzone 2100 for the Web + +## Prerequisites: + +- **Git** +- [**Emscripten 3.1.53+**](https://emscripten.org/docs/getting_started/downloads.html) +- [**CMake 2.27+**](https://cmake.org/download/#latest) +- [**workbox-cli**](https://developer.chrome.com/docs/workbox/modules/workbox-cli) (to generate a service worker) +- For language support: [_Gettext_](https://www.gnu.org/software/gettext/) +- To generate documentation: [_Asciidoctor_](https://asciidoctor.org/) + +## Building: + +1. [Install the Emscripten SDK](https://emscripten.org/docs/getting_started/downloads.html) +2. Install [workbox-cli](https://developer.chrome.com/docs/workbox/modules/workbox-cli) + ``` + npm install workbox-cli --global + ``` +3. Follow the instructions for [Warzone 2100: Getting the Source](https://github.com/Warzone2100/warzone2100#getting-the-source) +4. `mkdir` a new build folder (as a sibling directory to the warzone2100 repo) +5. `cd` into the build folder +6. Clone vcpkg into the build folder + ``` + git clone https://github.com/microsoft/vcpkg.git vcpkg + ``` +7. Run CMake configure: + ```shell + # Specify your own install dir + export WZ_INSTALL_DIR="~/wz_web/installed" + cmake -S ../warzone2100/ -B . -DCMAKE_BUILD_TYPE=Release "-DCMAKE_TOOLCHAIN_FILE=vcpkg/scripts/buildsystems/vcpkg.cmake" "-DVCPKG_TARGET_TRIPLET=wasm32-emscripten" "-DCMAKE_INSTALL_PREFIX:PATH=${WZ_INSTALL_DIR}" + ``` +8. Run CMake build & install: + ``` + cmake --build . --target install + ``` + +## Testing: + +1. Run a local web server and open the browser to the compiled WebAssembly version of WZ. + From the build directory: + ``` + emrun --browser chrome src/index.html + ``` + From the install directory: + ```shell + cd "${WZ_INSTALL_DIR}" + emrun --browser chrome ./index.html + ``` + Reference: [`emrun` documentation](https://emscripten.org/docs/compiling/Running-html-files-with-emrun.html) \ No newline at end of file diff --git a/platforms/emscripten/README.md b/platforms/emscripten/README.md new file mode 100644 index 00000000000..f80b44a5729 --- /dev/null +++ b/platforms/emscripten/README.md @@ -0,0 +1,75 @@ +# Warzone 2100 - Web Edition + +The Warzone 2100 Web Edition uses [Emscripten](https://emscripten.org) to compile Warzone 2100 to run in modern web browsers via [WebGL2](https://get.webgl.org/webgl2/) and [WebAssembly](https://webassembly.org). + + + * [Browser Compatibility](#browser-compatibility) + * [Features & Differences](#features--differences) + * [Persisting Data](#persisting-data) + * [Campaign Videos](#campaign-videos) + * [↗ Building](README-build.md) + + +## Browser Compatibility: + +To run Warzone 2100 in your web browser, we recommend: +- Recent versions of Chrome, Edge, Firefox, or Safari + - With JavaScript and modern WebAssembly support enabled +- WebGL 2.0 support +- 4-8+ GiB of RAM +- Minimum 1024 x 768 resolution display / browser window +- Keyboard & mouse are also strongly recommended + +The page will automatically perform a basic series of checks for compatibility and inform you if any issues were detected. + +## Features & Differences: + +This port is able to support most core Warzone 2100 features, including: campaign, challenges, and skirmish. + +> [!TIP] +> Some functionality may be limited or not available, due to size constraints or restrictions of the browser environment. + +| Feature | Web | Native | +| :----------------------------- | :---: | :---: | +| Campaign | ✅ | ✅ | +| Campaign Videos | ✅1 | ✅ | +| Challenges | ✅ | ✅ | +| Skirmish | ✅ | ✅ | +| Savegames | ✅ | ✅ | +| HQ graphics | ❌ | ✅ | +| HQ music | ❌ | ✅ | +| Additional music | ❌ | ✅ | +| Multiplayer (online) | ❌ | ✅ | +| Mods | ❌2 | ✅ | +| Multi-language support | ✅3 | ✅ | +| Performance | 🆗 | ✅🚀 | + +> [!NOTE] +> 1 The Web port supports low-quality video sequences, which it streams on-demand. _An active Internet connection is required._ +> 2 The Web port does not currently provide an interface for uploading mods into the configuration directory, but support _could_ be added in the future. +> 3 The Web port currently supports _most_ of the same languages, but certain languages that require additional large fonts (ex. Chinese, Korean, Japanese) are unsupported. + +The Web Edition also ships with textures that have been optimized for size, at the expense of quality. + +> If you want the highest quality textures, and the complete set of features, you should consider downloading the latest [native build for your system](https://github.com/Warzone2100/warzone2100/releases/latest). + +## Persisting Data: + +The Web Edition can persist Warzone 2100 settings, configuration, savegames and more in your browser storage using technologies such as IndexedDB. + +> [!IMPORTANT] +> If you clear your browser's cache / history, this will probably clear your savegames. + +> [!IMPORTANT] +> Some web browsers may also automatically clear storage for domains that haven't been visited in a while. +> + +## Campaign Videos: + +The Web Edition can automatically stream campaign video sequences on-demand (albeit at low-quality). + +> [!IMPORTANT] +> If you've never played Warzone 2100 before, the campaign videos provide critical context for the plot of the campaign. +> **It is strongly recommended you play with an active Internet connection** so these videos can be streamed during gameplay. + +If you have a spotty Internet connection, you should strongly consider the native builds instead, which include the campaign videos for offline viewing. From 88dfc99822e90df8e67d91e7ac10bf254c7db4c0 Mon Sep 17 00:00:00 2001 From: past-due <30942300+past-due@users.noreply.github.com> Date: Fri, 9 Feb 2024 20:32:13 -0500 Subject: [PATCH 10/34] [GitHub Actions] Emscripten: Package and upload artifacts --- .github/workflows/CI_emscripten.yml | 34 +++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/.github/workflows/CI_emscripten.yml b/.github/workflows/CI_emscripten.yml index 9a720f549e6..48d83e9f2da 100644 --- a/.github/workflows/CI_emscripten.yml +++ b/.github/workflows/CI_emscripten.yml @@ -26,6 +26,8 @@ env: # Customize the CMake build type here (Release, Debug, RelWithDebInfo, etc.) BUILD_TYPE: Release EMSDK_VERSION: 3.1.53 + WZ_BUILD_DESC: web-wasm32 + CMAKE_BUILD_PARALLEL_LEVEL: 4 # See: https://github.blog/2024-01-17-github-hosted-runners-double-the-power-for-open-source/ jobs: build: @@ -54,6 +56,7 @@ jobs: run: | mkdir -p "${{ github.workspace }}/build" mkdir -p "${{ github.workspace }}/installed" + mkdir -p "${{ github.workspace }}/output" - name: Prep Build Environment run: | @@ -102,3 +105,34 @@ jobs: - name: Debug Output working-directory: ${{github.workspace}}/installed run: ls -al + + - name: Package Archive + working-directory: '${{ github.workspace }}/build' + env: + OUTPUT_DIR: "${{ github.workspace }}/output" + run: | + cpack --config "./CPackConfig.cmake" -G ZIP -D CPACK_PACKAGE_FILE_NAME="warzone2100_archive" -D CPACK_INCLUDE_TOPLEVEL_DIRECTORY=OFF -D CPACK_ARCHIVE_COMPONENT_INSTALL=ON -D CPACK_COMPONENTS_GROUPING=ALL_COMPONENTS_IN_ONE + OUTPUT_FILE_NAME="warzone2100_${WZ_BUILD_DESC}_archive.zip" + mv "./warzone2100_archive.zip" "${OUTPUT_DIR}/${OUTPUT_FILE_NAME}" + echo "Generated .zip: \"${OUTPUT_FILE_NAME}\"" + echo " -> SHA512: $(sha512sum "${OUTPUT_DIR}/${OUTPUT_FILE_NAME}")" + echo " -> Size (bytes): $(stat -c %s "${OUTPUT_DIR}/${OUTPUT_FILE_NAME}")" + echo "WZ_FULL_OUTPUT_ZIP_PATH=${OUTPUT_DIR}/${OUTPUT_FILE_NAME}" >> $GITHUB_ENV + + - name: 'Upload Artifact - (Archive)' + uses: actions/upload-artifact@v3 + if: success() && (github.repository == 'Warzone2100/warzone2100') + with: + name: warzone2100_${{ env.WZ_BUILD_DESC }}_archive + path: '${{ env.WZ_FULL_OUTPUT_ZIP_PATH }}' + if-no-files-found: 'error' + + - name: Upload Release Assets + if: success() && (github.event_name == 'workflow_run' && github.event.workflow_run.name == 'Draft Tag Release') && (matrix.deploy_release == true) + run: | + SOURCE_TAG="${WZ_GITHUB_REF#refs/tags/}" + gh release upload "${SOURCE_TAG}" "${{ env.WZ_FULL_OUTPUT_ZIP_PATH }}" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + WZ_GITHUB_REF: ${{ steps.checkout-config.outputs.WZ_GITHUB_REF }} From 34e9946b8863a6d538a11fa8cf26add37b8b053d Mon Sep 17 00:00:00 2001 From: past-due <30942300+past-due@users.noreply.github.com> Date: Sat, 10 Feb 2024 17:23:02 -0500 Subject: [PATCH 11/34] Emscripten: Adjust paths to optional packages --- data/CMakeLists.txt | 2 +- platforms/emscripten/shell.html | 40 ++++++++++----------------------- src/CMakeLists.txt | 26 +++++++++++++++++---- 3 files changed, 35 insertions(+), 33 deletions(-) diff --git a/data/CMakeLists.txt b/data/CMakeLists.txt index f8a37fc20c3..50614b661f1 100644 --- a/data/CMakeLists.txt +++ b/data/CMakeLists.txt @@ -692,7 +692,7 @@ else() list(APPEND DATA_ADDITIONAL_EMPACKAGE_DIRS "${CMAKE_CURRENT_BINARY_DIR}/empackaged/music") set(DATA_ADDITIONAL_EMPACKAGE_DIRS ${DATA_ADDITIONAL_EMPACKAGE_DIRS} PARENT_SCOPE) - set(DATA_ADDITIONAL_EMPACKAGE_BASEDIR "${CMAKE_CURRENT_BINARY_DIR}" PARENT_SCOPE) + set(DATA_ADDITIONAL_EMPACKAGE_BASEDIR "${CMAKE_CURRENT_BINARY_DIR}/empackaged" PARENT_SCOPE) endif() diff --git a/platforms/emscripten/shell.html b/platforms/emscripten/shell.html index 54117374a30..ec8feca025c 100644 --- a/platforms/emscripten/shell.html +++ b/platforms/emscripten/shell.html @@ -612,7 +612,7 @@

    Update Required

    if (window.location.hostname == 'play.wz2100.net') { return 'https://data.'+window.location.hostname; } else { - return null; + return ''; } })(); let WZ_VIDEO_SEQUENCES_BASE_URL = (() => { @@ -622,20 +622,8 @@

    Update Required

    return 'sequences/'; // default is just relative to current path } })(); - let WZ_MUSIC_BASE_URL = (() => { - if (window.location.hostname == 'play.wz2100.net') { - return 'https://data.play.wz2100.net/music/'; - } else { - return 'music/'; // default is just relative to current path - } - })(); - let WZ_DATA_TERRAIN_BASE_URL = (() => { - if (window.location.hostname == 'play.wz2100.net') { - return 'https://data.play.wz2100.net/terrain_overrides/'; - } else { - return 'terrain_overrides/'; // default is just relative to current path - } - })(); + let WZ_MUSIC_BASE_URL = WZ_DATA_FILES_URL_HOST + WZ_DATA_FILES_URL_SUBDIR + 'pkg/music/'; + let WZ_DATA_TERRAIN_BASE_URL = WZ_DATA_FILES_URL_HOST + WZ_DATA_FILES_URL_SUBDIR + 'pkg/terrain_overrides/'; // Initialize title badge (based on location pathname) (function() { @@ -1258,32 +1246,28 @@

    Update Required

    } Module['locateFile'] = function(path, prefix) { - // If it's a data file, and a custom data files host is specified + // Custom handling for .data files if (path.endsWith(".data")) { + // NOTE: Prefix is not guaranteed to be set for these by the preloader, so construct the paths manually + // Shared data files // - music if (path === 'warzone2100-music.data') { let musicDataURL = WZ_MUSIC_BASE_URL + path; - console.log(musicDataURL); + console.debug('Loading: ' + musicDataURL); return musicDataURL; } // - terrain_overrides if (path.startsWith('warzone2100-terrain-')) { let terrainDataURL = WZ_DATA_TERRAIN_BASE_URL + path; - console.log(terrainDataURL); + console.debug('Loading: ' + terrainDataURL); return terrainDataURL; } - if (WZ_DATA_FILES_URL_HOST !== null) { - // Regular build-specific data files - // Prefix is not guaranteed to be set for these by the preloader, so construct the path manually - var subPath = WZ_DATA_FILES_URL_SUBDIR; - if (!subPath.endsWith('/')) { subPath += '/'; } - subPath += path; - var dataURL = new URL(subPath, WZ_DATA_FILES_URL_HOST); - console.log(dataURL.toString()); - return dataURL; - } + // Regular build-specific data files + var dataURL = WZ_DATA_FILES_URL_HOST + WZ_DATA_FILES_URL_SUBDIR + path; + console.debug('Loading: ' + dataURL); + return dataURL; } // otherwise, use the default, the prefix (JS file's dir) + the path return prefix + path; diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 32396009f2d..3026e82ca8d 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -327,6 +327,14 @@ if(CMAKE_SYSTEM_NAME MATCHES "Emscripten") COMMAND ${CMAKE_COMMAND} -E rename "$" "$/index.html" VERBATIM) + # Copy additional (optional) file packages to build dir to allow direct-running + foreach(_empackage_dir IN LISTS DATA_ADDITIONAL_EMPACKAGE_DIRS) + file(RELATIVE_PATH _empackage_dir_subdir_path "${DATA_ADDITIONAL_EMPACKAGE_BASEDIR}" "${_empackage_dir}") + add_custom_command(TARGET warzone2100 POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_directory_if_different "${_empackage_dir}" "$/pkg/${_empackage_dir_subdir_path}" + VERBATIM) + endforeach() + # If workbox-cli is available, generate a proper service worker set(GENERATED_SERVICE_WORKER FALSE) find_package(WorkboxCLI) @@ -823,7 +831,7 @@ else() install(DIRECTORY ${_empackage_dir} COMPONENT Core - DESTINATION "${WZ_APP_INSTALL_DEST}") + DESTINATION "${WZ_APP_INSTALL_DEST}/pkg") endforeach() # Install service worker (and associated files) @@ -843,14 +851,24 @@ else() install(CODE " execute_process(COMMAND \${CMAKE_COMMAND} -E echo \"++install CODE: CMAKE_PREFIX_PATH: ${CMAKE_PREFIX_PATH}\") - file(GLOB _files_to_compress LIST_DIRECTORIES false \"\${CMAKE_INSTALL_PREFIX}/${WZ_APP_INSTALL_DEST}/*.js\" \"\${CMAKE_INSTALL_PREFIX}/${WZ_APP_INSTALL_DEST}/*.wasm\" \"\${CMAKE_INSTALL_PREFIX}/${WZ_APP_INSTALL_DEST}/*.data\" \"\${CMAKE_INSTALL_PREFIX}/${WZ_APP_INSTALL_DEST}/*.html\") + file(GLOB _files_to_compress LIST_DIRECTORIES false + \"\${CMAKE_INSTALL_PREFIX}/${WZ_APP_INSTALL_DEST}/*.js\" + \"\${CMAKE_INSTALL_PREFIX}/${WZ_APP_INSTALL_DEST}/*.wasm\" + \"\${CMAKE_INSTALL_PREFIX}/${WZ_APP_INSTALL_DEST}/*.data\" + \"\${CMAKE_INSTALL_PREFIX}/${WZ_APP_INSTALL_DEST}/*.html\" + \"\${CMAKE_INSTALL_PREFIX}/${WZ_APP_INSTALL_DEST}/pkg/music/*.js\" + \"\${CMAKE_INSTALL_PREFIX}/${WZ_APP_INSTALL_DEST}/pkg/terrain_overrides/*.js\" + \"\${CMAKE_INSTALL_PREFIX}/${WZ_APP_INSTALL_DEST}/pkg/terrain_overrides/*.data\" + ) list(LENGTH _files_to_compress _num_files_to_compress) if (_num_files_to_compress GREATER 0) foreach(_input_file IN LISTS _files_to_compress) + file(RELATIVE_PATH _input_file_relative_path \"\${CMAKE_INSTALL_PREFIX}/${WZ_APP_INSTALL_DEST}\" \"\${_input_file}\") + get_filename_component(_input_file_subdir_path \"\${_input_file_relative_path}\" DIRECTORY) get_filename_component(_input_file_filename \"\${_input_file}\" NAME) - execute_process(COMMAND \${CMAKE_COMMAND} -E echo \"++install CODE: Compressing file: \${_input_file_filename} -> \${_input_file_filename}.gz\") - file(ARCHIVE_CREATE OUTPUT \"\${CMAKE_INSTALL_PREFIX}/${WZ_APP_INSTALL_DEST}/\${_input_file_filename}.gz\" PATHS \"\${_input_file}\" FORMAT raw COMPRESSION GZip COMPRESSION_LEVEL 7) + execute_process(COMMAND \${CMAKE_COMMAND} -E echo \"++install CODE: Compressing file: \${_input_file_subdir_path}/\${_input_file_filename} -> \${_input_file_subdir_path}/\${_input_file_filename}.gz\") + file(ARCHIVE_CREATE OUTPUT \"\${CMAKE_INSTALL_PREFIX}/${WZ_APP_INSTALL_DEST}/\${_input_file_subdir_path}/\${_input_file_filename}.gz\" PATHS \"\${_input_file}\" FORMAT raw COMPRESSION GZip COMPRESSION_LEVEL 7) endforeach() else() message(WARNING \"Did not find any files to compress in: \${CMAKE_INSTALL_PREFIX}/${WZ_APP_INSTALL_DEST}/\") From 12970160ff2a632d7da6733737b6471b588c3b71 Mon Sep 17 00:00:00 2001 From: past-due <30942300+past-due@users.noreply.github.com> Date: Sat, 10 Feb 2024 17:54:29 -0500 Subject: [PATCH 12/34] Emscripten: Attempt to opt-in to persistent storage on user-initiated save This may cause a browser permission prompt. See: - https://developer.mozilla.org/en-US/docs/Web/API/StorageManager/persist - https://web.dev/articles/storage-for-the-web - https://web.dev/articles/persistent-storage --- platforms/emscripten/README.md | 18 ++++++++++++++---- platforms/emscripten/shell.html | 31 +++++++++++++++++++++++++++++++ src/emscripten_helpers.cpp | 6 +++--- src/emscripten_helpers.h | 2 +- src/game.cpp | 2 +- 5 files changed, 50 insertions(+), 9 deletions(-) diff --git a/platforms/emscripten/README.md b/platforms/emscripten/README.md index f80b44a5729..42b1a152fd3 100644 --- a/platforms/emscripten/README.md +++ b/platforms/emscripten/README.md @@ -57,12 +57,22 @@ The Web Edition also ships with textures that have been optimized for size, at t The Web Edition can persist Warzone 2100 settings, configuration, savegames and more in your browser storage using technologies such as IndexedDB. -> [!IMPORTANT] -> If you clear your browser's cache / history, this will probably clear your savegames. +When you explicitly save your game, Warzone 2100 will ask the browser to opt-in to [persistent storage](https://web.dev/articles/persistent-storage). + +Depending on your browser, you may receive a prompt (ex. on Firefox), or this may automatically succeed (or be denied by the browser) without any prompt or notice. + +> [!TIP] +> By default, browsers store data in a "best-effort" manner. +> This means it may be cleared by the browser: +> - When storage is low +> - If a site hasn't been visited in a while +> - Or for other reasons +> +> **Persistent storage can help prevent the browser from automatically evicting your saved games and data.** +> See: [Storage for the Web: Eviction](https://web.dev/articles/storage-for-the-web#eviction) > [!IMPORTANT] -> Some web browsers may also automatically clear storage for domains that haven't been visited in a while. -> +> If you manually clear your browser's cache / history for all sites, this will still clear your savegames. ## Campaign Videos: diff --git a/platforms/emscripten/shell.html b/platforms/emscripten/shell.html index ec8feca025c..807368936a6 100644 --- a/platforms/emscripten/shell.html +++ b/platforms/emscripten/shell.html @@ -575,6 +575,37 @@

    Update Required

    let canvasContainer = document.getElementById('emscripten_container'); canvasContainer.style.display = 'block'; } + function wz_js_save_config_dir_to_persistent_storage(isUserInitiatedSave, callback) { + Module.wzSaveConfigDirToPersistentStore(() => { + if (!isUserInitiatedSave) { + // Immediately call the callback + if (callback) callback(); + return; + } + // If it's a user-initiated save... + // Also check for permanent storage persistence, and potentially prompt the user if not enabled + if (!navigator.storage || !navigator.storage.persist) { + // Not available - Immediately call the callback + if (callback) callback(); + return; + } + try { + navigator.storage.persist().then((persistent) => { + if (persistent) { + console.debug("Storage will not be cleared except by explicit user action"); + } else { + console.debug("Storage may be cleared by the UA under storage pressure."); + } + if (callback) callback(); + }); + } + catch (err) { + // call the callback anyway + console.warn('navigator.storage.persist API appears to be unavailable'); + if (callback) callback(); + } + }); + } -
    -
    +
    +

    Too Small

    -

    Window / display is too small for Warzone 2100

    -

    Required Minimum: 640x480

    +

    Window / display is too small for Warzone 2100

    +

    Required Minimum: 640x480

    -

    Or get the Full Desktop version, with high-resolution remastered graphics, online multiplayer, better performance, and more.

    +

    Or get the Full Desktop version, with HQ remastered graphics, Online multiplayer, Better performance, and more.

    Download Warzone 2100

    @@ -522,19 +522,19 @@

    -
    -
    -
    @@ -664,8 +664,8 @@

    Update Required

    return 'sequences/'; // default is just relative to current path } })(); - let WZ_MUSIC_BASE_URL = WZ_DATA_FILES_URL_HOST + WZ_DATA_FILES_URL_SUBDIR + 'pkg/music/'; - let WZ_DATA_TERRAIN_BASE_URL = WZ_DATA_FILES_URL_HOST + WZ_DATA_FILES_URL_SUBDIR + 'pkg/terrain_overrides/'; + let WZ_MUSIC_PKG_SUBDIR = 'pkg/music/'; + let WZ_TERRAIN_PKG_SUBDIR = 'pkg/terrain_overrides/'; // Initialize config dir suffix and the title badge (based on location pathname) var WZ_CONFIG_DIR_SUFFIX = ""; @@ -955,14 +955,10 @@

    Update Required

    // optional data file loads let start_optional_promises = data_load_promises.length; if (WZ_LAUNCH_OPTIONS['load_music']) { - // music data file - let musicJSURL = WZ_MUSIC_BASE_URL + 'warzone2100-music.js'; - data_load_promises.push(loadScriptAsync(musicJSURL)); + data_load_promises.push(loadScriptAsync(WZ_MUSIC_PKG_SUBDIR + 'warzone2100-music.js')); } if (WZ_LAUNCH_OPTIONS['load_classic_terrain']) { - // classic terrain pack - let terrainJSURL = WZ_DATA_TERRAIN_BASE_URL + 'warzone2100-terrain-classic.js'; - data_load_promises.push(loadScriptAsync(terrainJSURL)); + data_load_promises.push(loadScriptAsync(WZ_TERRAIN_PKG_SUBDIR + 'warzone2100-terrain-classic.js')); } Promise.allSettled(data_load_promises) @@ -1352,13 +1348,13 @@

    Update Required

    // Shared data files // - music if (path === 'warzone2100-music.data') { - let musicDataURL = WZ_MUSIC_BASE_URL + path; + let musicDataURL = WZ_DATA_FILES_URL_HOST + WZ_DATA_FILES_URL_SUBDIR + WZ_MUSIC_PKG_SUBDIR + path; console.debug('Loading: ' + musicDataURL); return musicDataURL; } // - terrain_overrides if (path.startsWith('warzone2100-terrain-')) { - let terrainDataURL = WZ_DATA_TERRAIN_BASE_URL + path; + let terrainDataURL = WZ_DATA_FILES_URL_HOST + WZ_DATA_FILES_URL_SUBDIR + WZ_TERRAIN_PKG_SUBDIR + path; console.debug('Loading: ' + terrainDataURL); return terrainDataURL; } From 59fb48ee82e7f905f98f22eea99c0bf75ac5e541 Mon Sep 17 00:00:00 2001 From: past-due <30942300+past-due@users.noreply.github.com> Date: Mon, 12 Feb 2024 14:58:38 -0500 Subject: [PATCH 20/34] [OpenGL] Emscripten: Explicitly enable WebGL extensions --- lib/ivis_opengl/gfx_api_gl.cpp | 226 ++++++++++++++++++++++++--------- 1 file changed, 164 insertions(+), 62 deletions(-) diff --git a/lib/ivis_opengl/gfx_api_gl.cpp b/lib/ivis_opengl/gfx_api_gl.cpp index 18af7ea05fd..f75b971e907 100644 --- a/lib/ivis_opengl/gfx_api_gl.cpp +++ b/lib/ivis_opengl/gfx_api_gl.cpp @@ -57,8 +57,8 @@ # endif // forward-declarations -static std::unordered_set supportedWebGLExtensions; -static bool getWebGLExtensions(); +static std::unordered_set enabledWebGLExtensions; +static bool initWebGLExtensions(); static int GLAD_GL_ES_VERSION_3_0 = 0; static int GLAD_GL_EXT_texture_filter_anisotropic = 0; @@ -3137,38 +3137,59 @@ uint64_t gl_context::debugGetPerfValue(PERF_POINT pp) #endif } +static std::vector splitStringIntoVector(const char* pStr, char delimeter) +{ + const char *pStrBegin = pStr; + const char *pStrEnd = pStrBegin + strlen(pStr); + std::vector result; + for (const char *i = pStrBegin; i < pStrEnd;) + { + const char *j = std::find(i, pStrEnd, delimeter); + result.push_back(std::string(i, j)); + i = j + 1; + } + return result; +} + +static std::vector wzGLGetStringi_GL_EXTENSIONS_Impl() +{ + std::vector extensions; + +#if !defined(WZ_STATIC_GL_BINDINGS) + if (!glGetIntegerv || !glGetStringi) + { + return extensions; + } +#endif + + GLint ext_count = 0; + glGetIntegerv(GL_NUM_EXTENSIONS, &ext_count); + if (ext_count < 0) + { + ext_count = 0; + } + for (GLint i = 0; i < ext_count; i++) + { + const char *pGLStr = (const char*) glGetStringi(GL_EXTENSIONS, i); + if (pGLStr != nullptr) + { + extensions.push_back(std::string(pGLStr)); + } + } + + return extensions; +} + #if !defined(__EMSCRIPTEN__) // Returns a space-separated list of OpenGL extensions -static std::string getGLExtensions() +static std::vector getGLExtensions() { - std::string extensions; + std::vector extensions; if (GLAD_GL_VERSION_3_0) { // OpenGL 3.0+ - if (!glGetIntegerv || !glGetStringi) - { - return extensions; - } - - GLint ext_count = 0; - glGetIntegerv(GL_NUM_EXTENSIONS, &ext_count); - if (ext_count < 0) - { - ext_count = 0; - } - for (GLint i = 0; i < ext_count; i++) - { - const char *pGLStr = (const char*) glGetStringi(GL_EXTENSIONS, i); - if (pGLStr != nullptr) - { - if (!extensions.empty()) - { - extensions += " "; - } - extensions += pGLStr; - } - } + return wzGLGetStringi_GL_EXTENSIONS_Impl(); } else { @@ -3177,7 +3198,7 @@ static std::string getGLExtensions() const char *pExtensionsStr = (const char *) glGetString(GL_EXTENSIONS); if (pExtensionsStr != nullptr) { - extensions = std::string(pExtensionsStr); + extensions = splitStringIntoVector(pExtensionsStr, ' '); } } return extensions; @@ -3185,16 +3206,11 @@ static std::string getGLExtensions() #else -static std::string getGLExtensions() +// Return a list of *enabled* WebGL extensions +static std::vector getGLExtensions() { - std::string extensions; - char* spaceSeparatedExtensions = emscripten_webgl_get_supported_extensions(); - if (spaceSeparatedExtensions) - { - extensions = std::string(spaceSeparatedExtensions); - free(spaceSeparatedExtensions); - } - return extensions; + // Note: Only works after initWebGLExtensions() has been called + return std::vector(enabledWebGLExtensions.begin(), enabledWebGLExtensions.end()); } #endif // !defined(__EMSCRIPTEN__) @@ -3206,7 +3222,11 @@ std::map gl_context::getBackendGameInfo() backendGameInfo["openGL_renderer"] = opengl.renderer; backendGameInfo["openGL_version"] = opengl.version; backendGameInfo["openGL_GLSL_version"] = opengl.GLSLversion; - backendGameInfo["GL_EXTENSIONS"] = getGLExtensions(); +#if !defined(__EMSCRIPTEN__) + backendGameInfo["GL_EXTENSIONS"] = fmt::format("{}",fmt::join(getGLExtensions()," ")); +#else + backendGameInfo["GL_EXTENSIONS"] = fmt::format("{}",fmt::join(enabledWebGLExtensions," ")); +#endif return backendGameInfo; } @@ -3676,21 +3696,11 @@ bool gl_context::initGLContext() khr_debug = false; #endif - std::string extensionsStr = getGLExtensions(); - const char *extensionsBegin = extensionsStr.data(); - const char *extensionsEnd = extensionsBegin + strlen(extensionsBegin); - std::vector glExtensions; - for (const char *i = extensionsBegin; i < extensionsEnd;) - { - const char *j = std::find(i, extensionsEnd, ' '); - glExtensions.push_back(std::string(i, j)); - i = j + 1; - } - #if !defined(__EMSCRIPTEN__) /* Dump extended information about OpenGL implementation to the console */ + std::vector glExtensions = getGLExtensions(); std::string line; for (unsigned n = 0; n < glExtensions.size(); ++n) { @@ -3783,11 +3793,11 @@ bool gl_context::initGLContext() debug(LOG_3D, " * WebGL 2.0 %s supported!", WZ_WEB_GL_VERSION_2_0 ? "is" : "is NOT"); - if (!getWebGLExtensions()) + if (!initWebGLExtensions()) { debug(LOG_ERROR, "Failed to get WebGL extensions"); } - GLAD_GL_EXT_texture_filter_anisotropic = supportedWebGLExtensions.count("EXT_texture_filter_anisotropic") > 0; + GLAD_GL_EXT_texture_filter_anisotropic = enabledWebGLExtensions.count("EXT_texture_filter_anisotropic") > 0; debug(LOG_3D, " * Anisotropic filtering %s supported.", GLAD_GL_EXT_texture_filter_anisotropic ? "is" : "is NOT"); // FUTURE TODO: Check and output other extensions @@ -4048,17 +4058,23 @@ static gfx_api::pixel_format_usage::flags getPixelFormatUsageSupport_gl(GLenum t case gfx_api::pixel_format::FORMAT_RGB_BC1_UNORM: case gfx_api::pixel_format::FORMAT_RGBA_BC2_UNORM: case gfx_api::pixel_format::FORMAT_RGBA_BC3_UNORM: - if (supportedWebGLExtensions.count("WEBGL_compressed_texture_s3tc") > 0) + if (enabledWebGLExtensions.count("WEBGL_compressed_texture_s3tc") > 0) { retVal |= gfx_api::pixel_format_usage::sampled_image; } break; case gfx_api::pixel_format::FORMAT_R_BC4_UNORM: case gfx_api::pixel_format::FORMAT_RG_BC5_UNORM: - // not supported + if (enabledWebGLExtensions.count("EXT_texture_compression_rgtc") > 0) + { + retVal |= gfx_api::pixel_format_usage::sampled_image; + } break; case gfx_api::pixel_format::FORMAT_RGBA_BPTC_UNORM: - // not supported + if (enabledWebGLExtensions.count("EXT_texture_compression_bptc") > 0) + { + retVal |= gfx_api::pixel_format_usage::sampled_image; + } break; case gfx_api::pixel_format::FORMAT_RGB8_ETC1: // not supported @@ -4067,13 +4083,13 @@ static gfx_api::pixel_format_usage::flags getPixelFormatUsageSupport_gl(GLenum t case gfx_api::pixel_format::FORMAT_RGBA8_ETC2_EAC: case gfx_api::pixel_format::FORMAT_R11_EAC: case gfx_api::pixel_format::FORMAT_RG11_EAC: - if (supportedWebGLExtensions.count("WEBGL_compressed_texture_etc") > 0) + if (enabledWebGLExtensions.count("WEBGL_compressed_texture_etc") > 0) { retVal |= gfx_api::pixel_format_usage::sampled_image; } break; case gfx_api::pixel_format::FORMAT_ASTC_4x4_UNORM: - if (supportedWebGLExtensions.count("WEBGL_compressed_texture_astc") > 0) + if (enabledWebGLExtensions.count("WEBGL_compressed_texture_astc") > 0) { retVal |= gfx_api::pixel_format_usage::sampled_image; } @@ -4122,7 +4138,7 @@ void gl_context::initPixelFormatsSupport() // S3TC // WebGL: WEBGL_compressed_texture_s3tc - if (supportedWebGLExtensions.count("WEBGL_compressed_texture_s3tc") > 0) + if (enabledWebGLExtensions.count("WEBGL_compressed_texture_s3tc") > 0) { PIXEL_2D_FORMAT_SUPPORT_SET(gfx_api::pixel_format::FORMAT_RGB_BC1_UNORM) // DXT1 PIXEL_2D_FORMAT_SUPPORT_SET(gfx_api::pixel_format::FORMAT_RGBA_BC2_UNORM) // DXT3 @@ -4133,14 +4149,28 @@ void gl_context::initPixelFormatsSupport() PIXEL_2D_TEXTURE_ARRAY_FORMAT_SUPPORT_SET(gfx_api::pixel_format::FORMAT_RGBA_BC3_UNORM) // DXT5 } + // RGTC + // WebGL: EXT_texture_compression_rgtc + if (enabledWebGLExtensions.count("EXT_texture_compression_rgtc") > 0) + { + // Note: EXT_texture_compression_rgtc does *NOT* support glCompressedTex*Image3D (even for 2d texture arrays)? + PIXEL_2D_FORMAT_SUPPORT_SET(gfx_api::pixel_format::FORMAT_R_BC4_UNORM) + PIXEL_2D_FORMAT_SUPPORT_SET(gfx_api::pixel_format::FORMAT_RG_BC5_UNORM) + } + // BPTC - // WebGL: Theoretically could check EXT_texture_compression_bptc? + // WebGL: EXT_texture_compression_bptc + if (enabledWebGLExtensions.count("EXT_texture_compression_bptc") > 0) + { + PIXEL_2D_FORMAT_SUPPORT_SET(gfx_api::pixel_format::FORMAT_RGBA_BPTC_UNORM) + PIXEL_2D_TEXTURE_ARRAY_FORMAT_SUPPORT_SET(gfx_api::pixel_format::FORMAT_RGBA_BPTC_UNORM) + } // ETC1 // ETC2 // WebGL: WEBGL_compressed_texture_etc - if (supportedWebGLExtensions.count("WEBGL_compressed_texture_etc") > 0) + if (enabledWebGLExtensions.count("WEBGL_compressed_texture_etc") > 0) { // NOTES: // WebGL 2.0 claims it is supported for 2d texture arrays @@ -4173,7 +4203,7 @@ void gl_context::initPixelFormatsSupport() // ASTC (LDR) // WebGL: WEBGL_compressed_texture_astc - if (supportedWebGLExtensions.count("WEBGL_compressed_texture_astc") > 0) + if (enabledWebGLExtensions.count("WEBGL_compressed_texture_astc") > 0) { PIXEL_2D_FORMAT_SUPPORT_SET(gfx_api::pixel_format::FORMAT_ASTC_4x4_UNORM) PIXEL_2D_TEXTURE_ARRAY_FORMAT_SUPPORT_SET(gfx_api::pixel_format::FORMAT_ASTC_4x4_UNORM) @@ -5186,9 +5216,31 @@ gfx_api::abstract_texture* gl_context::getSceneTexture() } #if defined(__EMSCRIPTEN__) -static bool getWebGLExtensions() + +static std::vector getEmscriptenSupportedGLExtensions() { - supportedWebGLExtensions.clear(); + // Use the GL_NUM_EXTENSIONS / GL_EXTENSIONS implementation to get the list of extensions that *Emscripten* natively supports + // This may be a subset of all the extensions that the browser supports + // (WebGL extensions must be enabled to be available) + auto extensions = wzGLGetStringi_GL_EXTENSIONS_Impl(); + + // Remove the "GL_" prefix that Emscripten may add + const std::string GL_prefix = "GL_"; + for (auto& extensionStr : extensions) + { + if (extensionStr.rfind(GL_prefix, 0) == 0) + { + extensionStr = extensionStr.substr(GL_prefix.length()); + } + } + + return extensions; +} + +static bool initWebGLExtensions() +{ + // Get list of _supported_ WebGL extensions (which may be a superset of the Emscripten-default-enabled ones) + std::unordered_set supportedWebGLExtensions; char* spaceSeparatedExtensions = emscripten_webgl_get_supported_extensions(); if (!spaceSeparatedExtensions) { @@ -5205,6 +5257,56 @@ static bool getWebGLExtensions() } free(spaceSeparatedExtensions); + + // Get the list of Emscripten-enabled / default-supported WebGL extensions + auto glExtensionsResult = getEmscriptenSupportedGLExtensions(); + std::unordered_set emscriptenSupportedExtensionsList(glExtensionsResult.begin(), glExtensionsResult.end()); + + // Enable WebGL extensions + // NOTE: It is possible to compile for Emscripten without auto-enabling of the default set of extensions + // So we *MUST* always call emscripten_webgl_enable_extension() for each desired extension + enabledWebGLExtensions.clear(); + auto ctx = emscripten_webgl_get_current_context(); + auto tryEnableWebGLExtension = [&](const char* extensionName, bool bypassEmscriptenSupportedCheck = false) { + if (supportedWebGLExtensions.count(extensionName) == 0) + { + debug(LOG_3D, "Extension not available: %s", extensionName); + return; + } + if (!bypassEmscriptenSupportedCheck && emscriptenSupportedExtensionsList.count(extensionName) == 0) + { + debug(LOG_3D, "Skipping due to lack of Emscripten-advertised support: %s", extensionName); + return; + } + if (!emscripten_webgl_enable_extension(ctx, extensionName)) + { + debug(LOG_3D, "Failed to enable extension: %s", extensionName); + return; + } + + debug(LOG_3D, "Enabled extension: %s", extensionName); + enabledWebGLExtensions.insert(extensionName); + }; + + // NOTE: WebGL 2 includes some features by default (that used to be in extensions) + // See: https://webgl2fundamentals.org/webgl/lessons/webgl1-to-webgl2.html#features-you-can-take-for-granted + + // general + tryEnableWebGLExtension("EXT_color_buffer_half_float"); + tryEnableWebGLExtension("EXT_color_buffer_float"); + tryEnableWebGLExtension("EXT_float_blend"); + tryEnableWebGLExtension("EXT_texture_filter_anisotropic", true); + tryEnableWebGLExtension("OES_texture_float_linear"); + tryEnableWebGLExtension("WEBGL_blend_func_extended"); + + // compressed texture format extensions + tryEnableWebGLExtension("WEBGL_compressed_texture_s3tc", true); + tryEnableWebGLExtension("WEBGL_compressed_texture_s3tc_srgb", true); + tryEnableWebGLExtension("EXT_texture_compression_rgtc", true); + tryEnableWebGLExtension("EXT_texture_compression_bptc", true); + tryEnableWebGLExtension("WEBGL_compressed_texture_etc", true); + tryEnableWebGLExtension("WEBGL_compressed_texture_astc", true); + return true; } #endif From ee1a2f92949470f4687c5cb2d05855863dfcdf32 Mon Sep 17 00:00:00 2001 From: past-due <30942300+past-due@users.noreply.github.com> Date: Mon, 12 Feb 2024 14:59:14 -0500 Subject: [PATCH 21/34] debug.cpp: Adjust defaults for Emscripten --- lib/framework/debug.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/framework/debug.cpp b/lib/framework/debug.cpp index f0375c30cf3..ddbfe9a0677 100644 --- a/lib/framework/debug.cpp +++ b/lib/framework/debug.cpp @@ -351,7 +351,12 @@ void debug_init() enabled_debug[LOG_FATAL] = true; enabled_debug[LOG_POPUP] = true; #if defined(__EMSCRIPTEN__) - enabled_debug[LOG_SOUND] = false; // must be false or sound breaks (some openal edge case) + // start with certain options off so that we can control them predictably from the command-line options via the web interface + enabled_debug[LOG_INFO] = false; + enabled_debug[LOG_WARNING] = false; + enabled_debug[LOG_3D] = false; + // must be false or sound breaks (some openal edge case) + enabled_debug[LOG_SOUND] = false; #endif #ifdef DEBUG enabled_debug[LOG_WARNING] = true; From 75da34d0fd4cba59ed4db6c398880343cfc02998 Mon Sep 17 00:00:00 2001 From: past-due <30942300+past-due@users.noreply.github.com> Date: Mon, 12 Feb 2024 18:52:25 -0500 Subject: [PATCH 22/34] Emscripten: Various shell tweaks - Avoid layout recalc at load - Better handling of missing (or incorrect) content-length header for wasm download progress --- platforms/emscripten/shell.html | 40 ++++++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/platforms/emscripten/shell.html b/platforms/emscripten/shell.html index 121736fca8e..4737530acbd 100644 --- a/platforms/emscripten/shell.html +++ b/platforms/emscripten/shell.html @@ -39,6 +39,9 @@ .options-link:hover{ text-decoration: underline!important; } + .nav-wz-img { + margin-top: -3px; + } .nav-masthead .nav-link { color: rgba(255, 255, 255, .5); border-bottom: .25rem solid transparent; @@ -348,7 +351,7 @@
    - Warzone 2100 Web Edition + Warzone 2100 Web Edition
    + +
    + +
    +
    +
    + +
    + + + +
    +

    Unsupported Browser

    +

    Unfortunately, your browser does not support the Web Edition of Warzone 2100

    +

    :-(

    +

    Please try updating to the latest version of a supported browser
    (ex. Chrome, Edge, Firefox, or Safari).

    +

    Missing or disabled features: WebAssembly, WebGL 2

    +
    + +
    +

    Launch the Web Edition

    +

    Classic look, Medium-quality textures, Campaign & skirmish

    +

    + +

    +

    If this is your first time, this will download approximately 60MiB of data.

    +

    Plays Best With: Mouse & Keyboard|View Options

    +
    + +
    +

    Loading ...

    +
    +
    + +
    +

     

    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +

    + +

    +

    An error occurred trying to load Warzone 2100.

    +

    Please check your Internet connection, and reload to try again.

    +

    Error Message:

    +

    + +

    +
    + +
    +

    Thanks for playing!

    +

    If you'd like to play again, simply reload.

    +

    + +

    +

    Or help support by donating:

    + Donate +
    + + +
    +