From e1a488c586dce3f31efcf63aa04ab15fed273f9b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 27 Oct 2026 15:51:12 +0000 Subject: [PATCH 1/2] engine: match current FWGS client ABI without changing desktop input Sync cldll_func_t with current Xash3D FWGS. The new look and move slots are filled only for Emscripten, and stay null on Windows, Linux, and Android so the engine keeps its own mouse and keyboard path. The same commit lets an optional Emscripten side module compile: POSIX macros, a Clang-safe bit-field swap, filesystem_stdio.wasm, ImGui stubs, and a WebGL early-out in R_VidInit. Native presets do not take those branches. GetEntityAPI keeps its existing linkage except under Emscripten, where the side module needs an unmangled export. This change was generated with AI (Cursor). Co-authored-by: johnrowe551 --- client/CMakeLists.txt | 41 ++++++++++++++++-------- client/cdll_int.cpp | 20 ++++++++++++ client/render/gl_rmisc.cpp | 6 ++++ client/ui/imgui_web_stub.cpp | 50 ++++++++++++++++++++++++++++++ cmake/EmscriptenSideModule.cmake | 20 ++++++++++++ engine/cdll_exp.h | 8 +++++ game_shared/filesystem_manager.cpp | 5 ++- game_shared/utlblockmemory.h | 11 +++++-- public/build.h | 1 + server/CMakeLists.txt | 8 ++++- server/dll_int.cpp | 8 +++++ 11 files changed, 161 insertions(+), 17 deletions(-) create mode 100644 client/ui/imgui_web_stub.cpp create mode 100644 cmake/EmscriptenSideModule.cmake diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt index 31e63af03..8731ab231 100644 --- a/client/CMakeLists.txt +++ b/client/CMakeLists.txt @@ -80,24 +80,37 @@ file(GLOB GAME_EVENTS_SOURCES "events/*.cpp") # weapon shared code source files file(GLOB WEAPONS_SHARED_SOURCES "${CMAKE_SOURCE_DIR}/game_shared/weapons/*.cpp") -# ImGui source files -list(APPEND IMGUI_SOURCES - "ui/vgui_support_int.cpp" - "ui/imgui_manager.cpp" - "ui/imgui_window_system.cpp" - "ui/ui_main.cpp" - "ui/ui_demo_window.cpp" - "ui/ui_material_editor.cpp" - "ui/ui_postfx_menu.cpp" -) +# ImGui / in-game editor UI (Emscripten links stubs instead of Dear ImGui) +if(CMAKE_SYSTEM_NAME STREQUAL "Emscripten") + set(IMGUI_SOURCES + "ui/vgui_support_int.cpp" + "ui/imgui_web_stub.cpp" + ) +else() + list(APPEND IMGUI_SOURCES + "ui/vgui_support_int.cpp" + "ui/imgui_manager.cpp" + "ui/imgui_window_system.cpp" + "ui/ui_main.cpp" + "ui/ui_demo_window.cpp" + "ui/ui_material_editor.cpp" + "ui/ui_postfx_menu.cpp" + ) +endif() list(APPEND CLDLL_SOURCES ${IMGUI_SOURCES}) list(APPEND CLDLL_SOURCES ${RENDER_SOURCES}) list(APPEND CLDLL_SOURCES ${ENTITIES_SOURCES}) list(APPEND CLDLL_SOURCES ${GAME_EVENTS_SOURCES}) list(APPEND CLDLL_SOURCES ${WEAPONS_SHARED_SOURCES}) -add_library(${PROJECT_NAME} SHARED ${CLDLL_SOURCES}) +if(CMAKE_SYSTEM_NAME STREQUAL "Emscripten") + add_library(${PROJECT_NAME} MODULE ${CLDLL_SOURCES}) +else() + add_library(${PROJECT_NAME} SHARED ${CLDLL_SOURCES}) +endif() add_dependencies(${PROJECT_NAME} build_info) +include(EmscriptenSideModule) +primext_add_game_module(${PROJECT_NAME} client_emscripten_wasm32) target_include_directories(${PROJECT_NAME} PRIVATE "." @@ -161,8 +174,10 @@ if(ENABLE_TRACY_PROFILER) endif() # link dependency libraries -find_package(imgui CONFIG REQUIRED) -target_link_libraries(${PROJECT_NAME} PRIVATE imgui::imgui) +if(NOT CMAKE_SYSTEM_NAME STREQUAL "Emscripten") + find_package(imgui CONFIG REQUIRED) + target_link_libraries(${PROJECT_NAME} PRIVATE imgui::imgui) +endif() find_package(fmt CONFIG REQUIRED) target_link_libraries(${PROJECT_NAME} PRIVATE fmt::fmt) diff --git a/client/cdll_int.cpp b/client/cdll_int.cpp index dc71696e0..8b84c85f1 100644 --- a/client/cdll_int.cpp +++ b/client/cdll_int.cpp @@ -173,7 +173,9 @@ extern "C" int DLLEXPORT HUD_VidInit( void ) { gHUD.VidInit(); R_VidInit(); +#ifndef __EMSCRIPTEN__ g_ImGuiManager.VidInitialize(); +#endif return 1; } @@ -411,6 +413,11 @@ CL_NewMap Called always when map is changed or restarted ================== */ +#ifdef __EMSCRIPTEN__ +extern "C" void IN_ClientMoveEvent( float forwardmove, float sidemove ); +extern "C" void IN_ClientLookEvent( float relyaw, float relpitch ); +#endif + void CL_NewMap() { // flush collision meshes cache on clientside @@ -468,6 +475,19 @@ cldll_func_t cldll_func = NULL, // HUD_ChatInputPosition, HUD_GetRenderInterface, NULL, /// HUD_ClipMoveToEntity + // Tail matches current Xash3D FWGS. Left null outside Emscripten so the + // engine keeps its own mouse/keyboard path on desktop and Android. +#ifdef __EMSCRIPTEN__ + NULL, // IN_ClientTouchEvent + IN_ClientMoveEvent, + IN_ClientLookEvent, +#else + NULL, + NULL, + NULL, +#endif + NULL, // HUD_GetSoundInterface + NULL, // Voice_StartChannel }; extern "C" void DLLEXPORT GetClientAPI( void *pv ) diff --git a/client/render/gl_rmisc.cpp b/client/render/gl_rmisc.cpp index c5d415221..1a7f593bb 100644 --- a/client/render/gl_rmisc.cpp +++ b/client/render/gl_rmisc.cpp @@ -918,6 +918,12 @@ void R_VidInit( void ) glState.width = RENDER_GET_PARM( PARM_SCREEN_WIDTH, 0 ); glState.height = RENDER_GET_PARM( PARM_SCREEN_HEIGHT, 0 ); +#ifdef __EMSCRIPTEN__ + // PrimeXT's GL FBO/shader setup calls renderer entry points the webgl2 + // ref does not implement. The engine's own WebGL renderer draws the world. + return; +#endif + COpenGLUnitCube::GetInstance().Initialize(); CDebugVisualizerBackend::GetInstance().Initialize(); CDebugOverlay2D::GetInstance().Initialize(); diff --git a/client/ui/imgui_web_stub.cpp b/client/ui/imgui_web_stub.cpp new file mode 100644 index 000000000..9b4a22d19 --- /dev/null +++ b/client/ui/imgui_web_stub.cpp @@ -0,0 +1,50 @@ +// Emscripten-only ImGui stand-in. Part of an AI-generated browser experiment (Cursor). +// Desktop builds do not compile this file. +#include "imgui_manager.h" +#include "imgui_window_system.h" +#include + +class CImGuiBackend {}; + +CImGuiWindowSystem::CImGuiWindowSystem() = default; +void CImGuiWindowSystem::AddWindow(IImGuiWindow *) {} +void CImGuiWindowSystem::LinkWindows() {} +void CImGuiWindowSystem::Initialize() {} +void CImGuiWindowSystem::VidInitialize() {} +void CImGuiWindowSystem::Terminate() {} +void CImGuiWindowSystem::NewFrame() {} +bool CImGuiWindowSystem::KeyInput(bool, int, const char *) { return false; } +bool CImGuiWindowSystem::CursorRequired() { return false; } + +CImGuiManager &g_ImGuiManager = CImGuiManager::GetInstance(); + +CImGuiManager &CImGuiManager::GetInstance() +{ + static CImGuiManager instance; + return instance; +} + +void CImGuiManager::Initialize() {} +void CImGuiManager::VidInitialize() {} +void CImGuiManager::Terminate() {} +void CImGuiManager::NewFrame() {} +bool CImGuiManager::KeyInput(bool, int, const char *) { return false; } +void CImGuiManager::TextInputCallback(const char *) {} + +CImGuiManager::CImGuiManager() : m_pBackend(std::make_unique()) {} +CImGuiManager::~CImGuiManager() = default; + +void CImGuiManager::LoadFonts() {} +void CImGuiManager::ApplyStyles() {} +void CImGuiManager::UpdateMouseState() {} +void CImGuiManager::UpdateCursorState() {} +void CImGuiManager::UpdateTextInputState() {} +void CImGuiManager::UpdateKeyModifiers() {} +void CImGuiManager::HandleKeyInput(bool, int) {} +bool CImGuiManager::HandleMouseInput(bool, int) { return false; } +void CImGuiManager::SetupConfig() {} +void CImGuiManager::SetupKeyboardMapping() {} +void CImGuiManager::SetupCursorMapping() {} +void CImGuiManager::CheckVguiApiPresence() {} +const char *CImGuiManager::GetClipboardText(void *) { return ""; } +void CImGuiManager::SetClipboardText(void *, const char *) {} diff --git a/cmake/EmscriptenSideModule.cmake b/cmake/EmscriptenSideModule.cmake new file mode 100644 index 000000000..7e13e3458 --- /dev/null +++ b/cmake/EmscriptenSideModule.cmake @@ -0,0 +1,20 @@ +# Optional Emscripten helpers. This browser experiment was generated with AI (Cursor). +# vcpkg still archives MODULE libraries; scripts/build-wasm.sh does the final link. +# Native generators never call the body below. + +function(primext_add_game_module target) + if(CMAKE_SYSTEM_NAME STREQUAL "Emscripten") + set_target_properties(${target} PROPERTIES + OUTPUT_NAME "${ARGN}" + PREFIX "" + SUFFIX ".wasm" + ) + target_link_options(${target} PRIVATE + "SHELL:-s SIDE_MODULE=2" + "SHELL:-s EXPORT_ALL=1" + "SHELL:-s ALLOW_MEMORY_GROWTH=1" + "SHELL:-s IMPORTED_MEMORY=1" + "SHELL:-s ERROR_ON_UNDEFINED_SYMBOLS=0" + ) + endif() +endfunction() diff --git a/engine/cdll_exp.h b/engine/cdll_exp.h index c44b70ec0..d2c352254 100644 --- a/engine/cdll_exp.h +++ b/engine/cdll_exp.h @@ -62,6 +62,14 @@ typedef struct cldll_func_s // Xash3D extension int (*pfnGetRenderInterface)( int version, render_api_t *renderfuncs, render_interface_t *callback ); void (*pfnClipMoveToEntity)( struct physent_s *pe, const vec3_t start, vec3_t mins, vec3_t maxs, const vec3_t end, struct pmtrace_s *tr ); + // Xash3D FWGS extension. Same order as current engine/cdll_exp.h. + // sound_api_t is not vendored in this tree; the two pointer arguments + // match the engine's pfnGetSoundInterface calling convention. + int (*pfnTouchEvent)( int type, int fingerID, float x, float y, float dx, float dy ); + void (*pfnMoveEvent)( float forwardmove, float sidemove ); + void (*pfnLookEvent)( float relyaw, float relpitch ); + int (*pfnGetSoundInterface)( int version, const void *api, void *callback ); + int (*pfnVoice_StartChannel)( int samples, byte *data, int entnum ); } cldll_func_t; #endif//CDLL_EXP_H diff --git a/game_shared/filesystem_manager.cpp b/game_shared/filesystem_manager.cpp index 61f7c52c6..1cc0950ac 100644 --- a/game_shared/filesystem_manager.cpp +++ b/game_shared/filesystem_manager.cpp @@ -15,6 +15,7 @@ GNU General Public License for more details. #include "filesystem_manager.h" #include "port.h" +#include "build.h" #include "assert.h" #include "stringlib.h" #include @@ -33,7 +34,9 @@ bool fs::CFilesystemManager::Initialize() assert(m_pFileSystem == nullptr); std::string path; -#if XASH_ANDROID +#if XASH_EMSCRIPTEN + path = "filesystem_stdio.wasm"; +#elif XASH_ANDROID path = fmt::format("{}/lib{}", getenv("XASH3D_ENGLIBDIR"), "filesystem_stdio." OS_LIB_EXT); #else path = ("filesystem_stdio." OS_LIB_EXT); diff --git a/game_shared/utlblockmemory.h b/game_shared/utlblockmemory.h index 79ac96b95..4b98c6dc9 100644 --- a/game_shared/utlblockmemory.h +++ b/game_shared/utlblockmemory.h @@ -119,8 +119,15 @@ void CUtlBlockMemory::Swap( CUtlBlockMemory< T, I > &mem ) { SWAP( m_pMemory, mem.m_pMemory ); SWAP( m_nBlocks, mem.m_nBlocks ); - SWAP( m_nIndexMask, mem.m_nIndexMask ); - SWAP( m_nIndexShift, mem.m_nIndexShift ); + // Clang rejects SWAP() on these bit-fields (Emscripten). Same exchange. + { + const int mask = m_nIndexMask; + const int shift = m_nIndexShift; + m_nIndexMask = mem.m_nIndexMask; + m_nIndexShift = mem.m_nIndexShift; + mem.m_nIndexMask = mask; + mem.m_nIndexShift = shift; + } } diff --git a/public/build.h b/public/build.h index 071c3b190..0ec851fd7 100644 --- a/public/build.h +++ b/public/build.h @@ -94,6 +94,7 @@ Then you can use another oneliner to query all variables: #define XASH_WIN32 1 #elif defined __EMSCRIPTEN__ #define XASH_EMSCRIPTEN 1 + #define XASH_POSIX 1 #elif defined __WATCOMC__ && defined __DOS__ #define XASH_DOS4GW 1 #else // POSIX compatible diff --git a/server/CMakeLists.txt b/server/CMakeLists.txt index 5d6c8a966..c8642f439 100644 --- a/server/CMakeLists.txt +++ b/server/CMakeLists.txt @@ -107,8 +107,14 @@ list(APPEND SVDLL_SOURCES ${MONSTERS_SOURCES}) list(APPEND SVDLL_SOURCES ${WEAPONS_SOURCES}) list(APPEND SVDLL_SOURCES ${GAMERULES_SOURCES}) list(APPEND SVDLL_SOURCES ${WEAPONS_SHARED_SOURCES}) -add_library(${PROJECT_NAME} SHARED ${SVDLL_SOURCES}) +if(CMAKE_SYSTEM_NAME STREQUAL "Emscripten") + add_library(${PROJECT_NAME} MODULE ${SVDLL_SOURCES}) +else() + add_library(${PROJECT_NAME} SHARED ${SVDLL_SOURCES}) +endif() add_dependencies(${PROJECT_NAME} build_info) +include(EmscriptenSideModule) +primext_add_game_module(${PROJECT_NAME} server_emscripten_wasm32) target_include_directories(${PROJECT_NAME} PRIVATE "." diff --git a/server/dll_int.cpp b/server/dll_int.cpp index 8682b8806..fccd9e79a 100644 --- a/server/dll_int.cpp +++ b/server/dll_int.cpp @@ -135,6 +135,11 @@ BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved) } #endif +// Side modules hide symbols unless they are exported with C linkage. +// Windows and Linux builds keep the original signature. +#if defined(__EMSCRIPTEN__) +extern "C" DLLEXPORT +#endif int GetEntityAPI(DLL_FUNCTIONS *pFunctionTable, int interfaceVersion) { if (!pFunctionTable || interfaceVersion != INTERFACE_VERSION) @@ -149,6 +154,9 @@ int GetEntityAPI(DLL_FUNCTIONS *pFunctionTable, int interfaceVersion) return TRUE; } +#if defined(__EMSCRIPTEN__) +extern "C" DLLEXPORT +#endif int GetEntityAPI2(DLL_FUNCTIONS *pFunctionTable, int *interfaceVersion) { if (!pFunctionTable || *interfaceVersion != INTERFACE_VERSION) From d3fe844ad5fdf8a7e2ef71cfa7ae47a80a1241e8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 27 Oct 2026 15:51:22 +0000 Subject: [PATCH 2/2] web: add an optional AI-generated browser shell Documents and builds the Emscripten modules from the previous commit. The GitHub workflow runs only when started by hand, so it does not gate other pull requests. Retail Half-Life assets are not included. The Uplink demo zip is downloaded at build time. This change was generated with AI (Cursor). Co-authored-by: johnrowe551 --- .github/workflows/wasm.yml | 46 + .gitignore | 18 +- README.md | 4 + THIRD_PARTY.md | 17 + scripts/build-wasm.sh | 112 ++ scripts/build-web.sh | 19 + scripts/fetch-demo-assets.sh | 13 + scripts/gen-wasm-exports.py | 34 + scripts/generate-primext-manifest.sh | 20 + scripts/run-native-smoke.sh | 20 + scripts/stage-xash-engine.sh | 15 + scripts/verify-optimization.sh | 59 + scripts/wasm-export-lists/client_minimal.json | 1 + scripts/wasm-export-lists/server_minimal.json | 1 + web/README.md | 44 + web/index.html | 34 + web/package-lock.json | 1236 +++++++++++++++++ web/package.json | 23 + web/playwright.config.ts | 29 + web/src/main.ts | 273 ++++ web/src/style.css | 75 + web/tests/engine.spec.ts | 80 ++ web/tsconfig.json | 19 + web/vite.config.ts | 23 + 24 files changed, 2214 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/wasm.yml create mode 100644 THIRD_PARTY.md create mode 100755 scripts/build-wasm.sh create mode 100755 scripts/build-web.sh create mode 100755 scripts/fetch-demo-assets.sh create mode 100755 scripts/gen-wasm-exports.py create mode 100755 scripts/generate-primext-manifest.sh create mode 100755 scripts/run-native-smoke.sh create mode 100755 scripts/stage-xash-engine.sh create mode 100755 scripts/verify-optimization.sh create mode 100644 scripts/wasm-export-lists/client_minimal.json create mode 100644 scripts/wasm-export-lists/server_minimal.json create mode 100644 web/README.md create mode 100644 web/index.html create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/playwright.config.ts create mode 100644 web/src/main.ts create mode 100644 web/src/style.css create mode 100644 web/tests/engine.spec.ts create mode 100644 web/tsconfig.json create mode 100644 web/vite.config.ts diff --git a/.github/workflows/wasm.yml b/.github/workflows/wasm.yml new file mode 100644 index 000000000..3b38e85c5 --- /dev/null +++ b/.github/workflows/wasm.yml @@ -0,0 +1,46 @@ +# Optional browser build. Generated with AI (Cursor). +# Started only by hand, so it does not run on other pull requests +# and does not sit next to nightly-builds as a required check. +name: wasm + +on: + workflow_dispatch: + +jobs: + browser: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install system packages + run: | + sudo apt-get update + sudo apt-get install -y build-essential cmake ninja-build pkg-config curl unzip python3 + + - name: Setup Emscripten + run: | + git clone --depth 1 https://github.com/emscripten-core/emsdk.git emsdk + ./emsdk/emsdk install 3.1.74 + ./emsdk/emsdk activate 3.1.74 + + - name: Build web bundle + run: | + source emsdk/emsdk_env.sh + chmod +x scripts/*.sh + ./scripts/build-web.sh + + - name: Playwright + working-directory: web + run: | + npm run test:e2e:install + npm run test:e2e + + - name: Upload screenshot + if: always() + uses: actions/upload-artifact@v4 + with: + name: primext-engine-screenshot + path: web/test-results/primext-engine.png + if-no-files-found: warn diff --git a/.gitignore b/.gitignore index ba761920a..3765bf3c2 100644 --- a/.gitignore +++ b/.gitignore @@ -309,6 +309,9 @@ _Pvt_Extensions # QtCreator build-* +# Source scripts, not Qt build directories. +!scripts/build-wasm.sh +!scripts/build-web.sh # Android *.apk @@ -345,4 +348,17 @@ compile_commands.json .cache/ # KDevelop/Kate project directory metadata -.directory \ No newline at end of file +.directory + +# Optional browser port. Build output and demo zips stay local. +/emsdk/ +/build-wasm/ +/build-wasm-pic/ +/web/node_modules/ +/web/dist/ +/web/test-results/ +/web/playwright-report/ +/web/public/wasm/ +/web/public/xash/ +/web/public/primext/ +/web/public/test/*.zip \ No newline at end of file diff --git a/README.md b/README.md index f8bded859..161fc107e 100644 --- a/README.md +++ b/README.md @@ -85,3 +85,7 @@ cmake .. --preset linux-x64-debug cmake --build . ``` 6) Compiled binaries will be located in `build` and `build\primext\bin` directories. + +## WebAssembly (optional) + +An experimental browser build is described in [`web/README.md`](web/README.md). It was generated with AI (Cursor). It is not part of the supported Windows, Linux, or Android workflows, and those builds are left as they were. Drop `web/` and `scripts/build-wasm.sh` if a browser port is out of scope. diff --git a/THIRD_PARTY.md b/THIRD_PARTY.md new file mode 100644 index 000000000..66b4c7b0e --- /dev/null +++ b/THIRD_PARTY.md @@ -0,0 +1,17 @@ +# Third-party components for the optional browser build + +The files added for that build were generated with AI (Cursor). + +## Xash3D FWGS + +The page uses the npm package `xash3d-fwgs@1.2.2` (MIT) as the engine. PrimeXT is still only the client and server libraries. + +## webXash + +Browser loading ideas were adapted from [webXash](https://github.com/x8BitRain/webXash) (MIT). No webXash source is copied here. + +The test fetches the free Half-Life: Uplink demo zip that webXash redistributes. That zip is not committed. Retail Half-Life assets are not part of this tree. + +## Emscripten + +https://github.com/emscripten-core/emsdk — used only when building the browser modules. diff --git a/scripts/build-wasm.sh b/scripts/build-wasm.sh new file mode 100755 index 000000000..8c4a95b18 --- /dev/null +++ b/scripts/build-wasm.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# Optional Emscripten build. Generated with AI (Cursor). +# Build PrimeXT client/server as Xash3D-compatible side modules. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BUILD_DIR="${ROOT}/build-wasm" +OUT_DIR="${ROOT}/web/public/wasm" +VCPKG_TRIPLET="wasm32-emscripten" + +if [[ -f "${ROOT}/emsdk/emsdk_env.sh" ]]; then + # shellcheck source=/dev/null + source "${ROOT}/emsdk/emsdk_env.sh" +elif [[ -f "${EMSDK}/emsdk_env.sh" ]]; then + # shellcheck source=/dev/null + source "${EMSDK}/emsdk_env.sh" +else + echo "Emscripten SDK not found. Clone emsdk into ${ROOT}/emsdk or set EMSDK." >&2 + exit 1 +fi + +if [[ ! -x "${ROOT}/external/vcpkg/vcpkg" ]]; then + "${ROOT}/external/vcpkg/bootstrap-vcpkg.sh" -disableMetrics +fi + +cmake -E make_directory "${BUILD_DIR}" +# -fPIC is required so side modules can export game DLL entry points when using EXPORTED_FUNCTIONS. +emcmake cmake -G Ninja -S "${ROOT}" -B "${BUILD_DIR}" \ + -DCMAKE_MAKE_PROGRAM="$(command -v ninja)" \ + -DCMAKE_TOOLCHAIN_FILE="${ROOT}/external/vcpkg/scripts/buildsystems/vcpkg.cmake" \ + -DVCPKG_TARGET_TRIPLET="${VCPKG_TRIPLET}" \ + -DVCPKG_CHAINLOAD_TOOLCHAIN_FILE="${ROOT}/external/vcpkg/scripts/toolchains/emscripten.cmake" \ + -DVCPKG_C_FLAGS=-fPIC \ + -DVCPKG_CXX_FLAGS=-fPIC \ + -DCMAKE_BUILD_TYPE=Release \ + -DBUILD_GAME_LAUNCHER=OFF \ + -DBUILD_UTILS=OFF \ + -DENABLE_PHYSX=OFF \ + -DENABLE_STATIC_CRT_LINKING=OFF + +cmake --build "${BUILD_DIR}" -j"$(nproc)" + +BIN="${BUILD_DIR}/Release/primext/bin" +VCPKG_LIB="${BUILD_DIR}/vcpkg_installed/${VCPKG_TRIPLET}/lib" +# vcpkg builds MODULE targets as static archives (often named *.wasm by our CMake output rules). +if [[ -f "${BIN}/libclient_emscripten_javascript.a" ]]; then + CLIENT_A="${BIN}/libclient_emscripten_javascript.a" +elif [[ -f "${BIN}/client_emscripten_wasm32_emscripten_javascript.wasm" ]]; then + CLIENT_A="${BIN}/client_emscripten_wasm32_emscripten_javascript.wasm" +else + echo "Client static archive not found under ${BIN}" >&2 + exit 1 +fi +if [[ -f "${BIN}/libserver_emscripten_javascript.a" ]]; then + SERVER_A="${BIN}/libserver_emscripten_javascript.a" +elif [[ -f "${BIN}/server_emscripten_wasm32_emscripten_javascript.wasm" ]]; then + SERVER_A="${BIN}/server_emscripten_wasm32_emscripten_javascript.wasm" +else + echo "Server static archive not found under ${BIN}" >&2 + exit 1 +fi + +EXPORT_DIR="${BUILD_DIR}/wasm-exports" +mkdir -p "${EXPORT_DIR}" + +link_side_module() { + local archive="$1" + local out="$2" + shift 2 + local -a libs=() + local -a export_flags=() + for arg in "$@"; do + if [[ "${arg}" == *.a ]]; then + libs+=("${arg}") + else + export_flags+=("-Wl,--export=${arg}") + fi + done + em++ -O3 -g0 -Wl,--whole-archive "${archive}" -Wl,--no-whole-archive \ + "${libs[@]}" \ + "${export_flags[@]}" \ + -o "${out}" \ + -s SIDE_MODULE=2 \ + -s ALLOW_MEMORY_GROWTH=1 \ + -s IMPORTED_MEMORY=1 \ + -s ERROR_ON_UNDEFINED_SYMBOLS=0 \ + -s ASSERTIONS=0 +} + +mkdir -p "${OUT_DIR}/cl_dlls" "${OUT_DIR}/dlls" + +# imgui is not linked into the side module (vcpkg static lib is not PIC-safe for EXPORTED_FUNCTIONS). +# PrimeXT ImGui editor UI is disabled in the browser build; core client + fmt only. +link_side_module "${CLIENT_A}" \ + "${OUT_DIR}/cl_dlls/client_emscripten_wasm32.wasm" \ + "${VCPKG_LIB}/libfmt.a" \ + GetClientAPI + +# Server_GetPhysicsInterface fills g_physfuncs (file search, traces, entity spawn). +# Without it, GameDLLInit calls a null pfnGetFilesList and the main loop aborts. +link_side_module "${SERVER_A}" \ + "${OUT_DIR}/dlls/server_emscripten_wasm32.wasm" \ + "${VCPKG_LIB}/libfmt.a" \ + GetEntityAPI GetEntityAPI2 GiveFnptrsToDll \ + Server_GetPhysicsInterface Server_GetBlendingInterface + +for wasm in "${OUT_DIR}/cl_dlls/client_emscripten_wasm32.wasm" "${OUT_DIR}/dlls/server_emscripten_wasm32.wasm"; do + size="$(wc -c < "${wasm}")" + echo "Size $(basename "${wasm}"): ${size} bytes" +done + +echo "WASM modules written to ${OUT_DIR}" diff --git a/scripts/build-web.sh b/scripts/build-web.sh new file mode 100755 index 000000000..1d60039d9 --- /dev/null +++ b/scripts/build-web.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +export CMAKE_BUILD_TYPE=Release +"${ROOT}/scripts/build-wasm.sh" +"${ROOT}/scripts/verify-optimization.sh" +"${ROOT}/scripts/stage-xash-engine.sh" + +# PrimeXT mod assets (no proprietary Valve content) +rm -rf "${ROOT}/web/public/primext" +mkdir -p "${ROOT}/web/public/primext" +cp -a "${ROOT}/game_dir/." "${ROOT}/web/public/primext/" +"${ROOT}/scripts/generate-primext-manifest.sh" +"${ROOT}/scripts/fetch-demo-assets.sh" + +cd "${ROOT}/web" +npm run build +echo "Web bundle: ${ROOT}/web/dist" diff --git a/scripts/fetch-demo-assets.sh b/scripts/fetch-demo-assets.sh new file mode 100755 index 000000000..824ce8297 --- /dev/null +++ b/scripts/fetch-demo-assets.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Fetch free demo zips for browser testing (not redistributed in git). +# Uplink: https://www.moddb.com/downloads/half-life-uplink — packaged like webXash's uplink.zip. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +DEST="${ROOT}/web/public/test" +mkdir -p "${DEST}" + +UPLINK_URL="${UPLINK_URL:-https://raw.githubusercontent.com/x8BitRain/webXash/main/public/hl/uplink.zip}" + +echo "Fetching Uplink demo zip..." +curl -fsSL -o "${DEST}/uplink.zip" "${UPLINK_URL}" +ls -la "${DEST}/uplink.zip" diff --git a/scripts/gen-wasm-exports.py b/scripts/gen-wasm-exports.py new file mode 100755 index 000000000..d7ccf5551 --- /dev/null +++ b/scripts/gen-wasm-exports.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""Generate Emscripten EXPORTED_FUNCTIONS JSON from a static archive (llvm-nm).""" +from __future__ import annotations + +import json +import re +import subprocess +import sys +from pathlib import Path + + +def main() -> int: + if len(sys.argv) != 3: + print(f"usage: {sys.argv[0]} ", file=sys.stderr) + return 2 + archive = Path(sys.argv[1]) + out = Path(sys.argv[2]) + root = Path(__file__).resolve().parents[1] + llvm_nm = root / "emsdk" / "upstream" / "bin" / "llvm-nm" + if not llvm_nm.is_file(): + llvm_nm = Path("/workspace/emsdk/upstream/bin/llvm-nm") + text = subprocess.check_output([str(llvm_nm), str(archive)], text=True) + funcs: list[str] = [] + for line in text.splitlines(): + m = re.search(r" T ([A-Za-z0-9_]+)$", line) + if m: + funcs.append("_" + m.group(1)) + out.write_text(json.dumps(sorted(set(funcs))), encoding="utf-8") + print(f"Wrote {len(funcs)} exports to {out}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/generate-primext-manifest.sh b/scripts/generate-primext-manifest.sh new file mode 100755 index 000000000..53e8ab64e --- /dev/null +++ b/scripts/generate-primext-manifest.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +TARGET="${ROOT}/web/public/primext" +cd "${TARGET}" +python3 - <<'PY' +import json +import os +files = [] +for dirpath, _, names in os.walk('.'): + for name in names: + if name == 'manifest.json': + continue + rel = os.path.join(dirpath, name).replace('./', '').replace('\\', '/') + files.append(rel) +files.sort() +with open('manifest.json', 'w', encoding='utf-8') as f: + json.dump({'files': files}, f) +print(f'Wrote manifest with {len(files)} files') +PY diff --git a/scripts/run-native-smoke.sh b/scripts/run-native-smoke.sh new file mode 100755 index 000000000..2c6da0db9 --- /dev/null +++ b/scripts/run-native-smoke.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# PrimeXT ships no C++ unit test suite; this smoke check validates the wasm link step output. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +CLIENT="${ROOT}/web/public/wasm/cl_dlls/client_emscripten_wasm32.wasm" +SERVER="${ROOT}/web/public/wasm/dlls/server_emscripten_wasm32.wasm" + +for f in "${CLIENT}" "${SERVER}"; do + if [[ ! -f "${f}" ]]; then + echo "Missing ${f} — run scripts/build-wasm.sh first" >&2 + exit 1 + fi + size="$(wc -c < "${f}")" + if [[ "${size}" -lt 1000000 ]]; then + echo "Suspiciously small wasm module: ${f} (${size} bytes)" >&2 + exit 1 + fi +done + +echo "Native smoke: wasm artifacts OK (${CLIENT}, ${SERVER})" diff --git a/scripts/stage-xash-engine.sh b/scripts/stage-xash-engine.sh new file mode 100755 index 000000000..f9ed3f1ee --- /dev/null +++ b/scripts/stage-xash-engine.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Copy prebuilt Xash3D FWGS web engine assets from npm (same approach as webXash). +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +WEB="${ROOT}/web" +DEST="${WEB}/public/xash" + +cd "${WEB}" +npm install --no-fund --no-audit +rm -rf "${DEST}" +mkdir -p "${DEST}" +cp -r node_modules/xash3d-fwgs/dist/* "${DEST}/" + +echo "Xash engine staged at ${DEST}" diff --git a/scripts/verify-optimization.sh b/scripts/verify-optimization.sh new file mode 100755 index 000000000..da20c1056 --- /dev/null +++ b/scripts/verify-optimization.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Release-build and WASM size/export smoke checks (run after build-wasm.sh). +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +CLIENT="${ROOT}/web/public/wasm/cl_dlls/client_emscripten_wasm32.wasm" +SERVER="${ROOT}/web/public/wasm/dlls/server_emscripten_wasm32.wasm" + +for f in "${CLIENT}" "${SERVER}"; do + [[ -f "${f}" ]] || { echo "Missing ${f}; run scripts/build-wasm.sh" >&2; exit 1; } +done + +if [[ "${CMAKE_BUILD_TYPE:-Release}" != "Release" ]]; then + echo "WARN: CMAKE_BUILD_TYPE is not Release (${CMAKE_BUILD_TYPE:-unset})" >&2 +fi + +client_size="$(wc -c < "${CLIENT}")" +server_size="$(wc -c < "${SERVER}")" +echo "client wasm: ${client_size} bytes" +echo "server wasm: ${server_size} bytes" + +# PrimeXT client renderer is large; catch accidental debug/unstripped blowups. +max_client=$((95 * 1024 * 1024)) +max_server=$((12 * 1024 * 1024)) +if (( client_size > max_client )); then + echo "Client WASM larger than ${max_client} bytes — check -O3/-g0 link flags" >&2 + exit 1 +fi +if (( server_size > max_server )); then + echo "Server WASM larger than ${max_server} bytes" >&2 + exit 1 +fi + +node --input-type=module -e " +import fs from 'fs'; +const client = new WebAssembly.Module(fs.readFileSync('${CLIENT}')); +const server = new WebAssembly.Module(fs.readFileSync('${SERVER}')); +const ce = WebAssembly.Module.exports(client).map((e) => e.name); +const se = WebAssembly.Module.exports(server).map((e) => e.name); +if (!ce.includes('GetClientAPI')) { + console.error('client missing GetClientAPI export'); + process.exit(1); +} +if (!se.includes('GetEntityAPI')) { + console.error('server missing GetEntityAPI export'); + process.exit(1); +} +if (ce.length > 64) { + console.error('client export table too large:', ce.length, '(trim EXPORTED_FUNCTIONS)'); + process.exit(1); +} +if (se.length > 32) { + console.error('server export table too large:', se.length); + process.exit(1); +} +console.log('exports ok — client', ce.length, 'server', se.length); +" + +"${ROOT}/scripts/run-native-smoke.sh" +echo "Optimization checks passed." diff --git a/scripts/wasm-export-lists/client_minimal.json b/scripts/wasm-export-lists/client_minimal.json new file mode 100644 index 000000000..5afe77101 --- /dev/null +++ b/scripts/wasm-export-lists/client_minimal.json @@ -0,0 +1 @@ +["_GetClientAPI"] diff --git a/scripts/wasm-export-lists/server_minimal.json b/scripts/wasm-export-lists/server_minimal.json new file mode 100644 index 000000000..cc2703188 --- /dev/null +++ b/scripts/wasm-export-lists/server_minimal.json @@ -0,0 +1 @@ +["_GetEntityAPI", "_GetEntityAPI2", "_GiveFnptrsToDll"] diff --git a/web/README.md b/web/README.md new file mode 100644 index 000000000..151feebcb --- /dev/null +++ b/web/README.md @@ -0,0 +1,44 @@ +# PrimeXT in the browser + +This directory was generated with AI (Cursor). It is an optional experiment on top of PrimeXT, not a supported platform. The Windows, Linux, and Android builds do not use it. + +PrimeXT stays the game libraries. The browser page loads them as WebAssembly side modules inside the prebuilt engine package `xash3d-fwgs@1.2.2` (MIT). The page itself follows the loader shape of [webXash](https://github.com/x8BitRain/webXash) (MIT): MEMFS at `/rodir/`, zip or folder mount, and `-ref webgl2`. See [THIRD_PARTY.md](../THIRD_PARTY.md). No webXash source is copied into this tree. + +Retail Half-Life assets are not included. Bring your own legal `valve` folder, or use the free Uplink demo, which the build downloads and does not commit. + +## Build + +Emscripten 3.1.74, CMake, Ninja, and Node are required. PhysX is off for this target. + +```bash +git clone --depth 1 https://github.com/emscripten-core/emsdk.git emsdk +./emsdk/emsdk install 3.1.74 && ./emsdk/emsdk activate 3.1.74 +./scripts/build-web.sh +cd web && npm run preview +``` + +Open http://127.0.0.1:43123 + +```bash +cd web && npm run test:e2e:install && npm run test:e2e +``` + +The Playwright test checks that the menu stays up and that Escape is received. Screenshot: `web/test-results/primext-engine.png`. + +## What works + +| Area | Status | +| --- | --- | +| Client and server modules | Release `-O3`. Client is about 78 MB, server about 2.7 MB | +| Menu | Draws with the Uplink demo, launched as `-game valve` (that zip is a `valve/` tree) | +| Menu input | Click the canvas. New Game opens the difficulty list. Escape opens the quit confirm | +| Filesystem | MEMFS, same idea as webXash | +| Audio | The engine's SDL Emscripten driver starts. It is not tuned separately | +| In-game editor | Stubbed. Dear ImGui is not linked | +| PhysX, multiplayer, compilers | Not in this build | + +## What still fails + +Loading a map reaches the loading plaque and then the engine aborts with `Aborted(OOM)`. `xash3d-fwgs@1.2.2` does not grow its wasm heap past 256 MB, and the PrimeXT client already occupies most of that. In-game movement was not tested because the map never finishes loading. PrimeXT's own OpenGL post-processing is skipped on this target; the engine's WebGL2 renderer draws the menu. + +`scripts/build-wasm.sh` has to name the side-module exports explicitly (`GetClientAPI`, `GetEntityAPI`, `GetEntityAPI2`, `GiveFnptrsToDll`, `Server_GetPhysicsInterface`, `Server_GetBlendingInterface`). vcpkg turns the CMake `MODULE` targets into static archives, so a new engine entry point has to be added to that script or `dlsym` returns null. diff --git a/web/index.html b/web/index.html new file mode 100644 index 000000000..3745259b0 --- /dev/null +++ b/web/index.html @@ -0,0 +1,34 @@ + + + + + + PrimeXT Web + + + +
+
+

PrimeXT (browser)

+

Waiting…

+
+ +
+

+ Retail valve assets are not bundled. Use the free + Half-Life: Uplink demo button (same content as + ModDB), + or open your own install folder. Automated test: + ?autotest=1. +

+ + +
+

+    
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 000000000..1482c4d13 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,1236 @@ +{ + "name": "primext-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "primext-web", + "version": "0.1.0", + "dependencies": { + "fflate": "0.8.2", + "xash3d-fwgs": "1.2.2" + }, + "devDependencies": { + "@playwright/test": "1.52.0", + "@types/node": "22.15.30", + "typescript": "5.8.3", + "vite": "6.3.5" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@playwright/test": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0.tgz", + "integrity": "sha512-uh6W7sb55hl7D6vsAeA+V2p5JnlAqzhqFyF0VcJkKZXkgnFcVG9PziERRHQfPLfNGx1C292a4JqbWzhR8L4R1g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.52.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.65.1.tgz", + "integrity": "sha512-5coxNSjqOI2J3NkVhlp3u+zGcyjnowQFsU9sg7MMz4UPXY4Dv63kFGLt7ConXz9CNq2i0yuOJZAF/2RMrgI2OA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.65.1.tgz", + "integrity": "sha512-K0HFf6XMB3IYs+jDZ1/RRhZaVOwxNXrl0n4MDEyGDjuK7+B6K/nDJp8DCBitO3S1sK/fzNxAf9QZAny4AIUPtg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.65.1.tgz", + "integrity": "sha512-zkZrXYwNlIcSUmcwY2DpsaDHr0XjVUeQHCuSunG3eQD7/5Wn/DvI7OMUE8NRRweK87yG8ebJ1xGOzmwpMsbZQA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.65.1.tgz", + "integrity": "sha512-Zaed+R3cpkkA3aiNd7NO0GV5KFh/LVf8j6bGcoRdpoXkSm/mWH8M0oX2/3u/nyGcx/bHEovD9CzX+w9/gcpI4A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.65.1.tgz", + "integrity": "sha512-h12p3WJWhyJ9kir0zqAPr0rKNmBzOWIjoaOMT9OULs9KZQZCeOMKd+mGSvERjd9VxJYShd3zIxtrVxkYz6BEVA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.65.1.tgz", + "integrity": "sha512-yRBwh/5imKY4DydDulVtrjy8Jf7h7NQJUqZbgmZ3zFsCD9QqlnWvZ7RtEF+ecO1b+IukKjXlu5Bb9fehV3tIbg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.65.1.tgz", + "integrity": "sha512-1XuPiWYYaLE1UDOqhbKVOfsqxvfmFS/lYPSk92U1hEX1qRfSYH0hNeAlbxFIgjp4d6+YQmL95EPRgdvjbZEddA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.65.1.tgz", + "integrity": "sha512-Y3C5e55Yrf6p2r5rBcc4P9hFWVNi6oqWy/53I/AyCJJGlp3KRSpTP8eoUmVntchTbEnhgBgAgLO9flj9AvOdUw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.65.1.tgz", + "integrity": "sha512-Itj6coSswkYPIcVLczObXmwdplHNQQxyfnK1ppBg4czDaX8RiEzLKqU7EMc64BiBuiDZEUS+M93ktM18rv5llw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.65.1.tgz", + "integrity": "sha512-KeO8jTEqdrnhvcahufbimyMoI44vedRUp1bVz2QUnRX2vDm/hxzCPt5F8fERVD5fsHyTW/vXmCElT054VOSVOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.65.1.tgz", + "integrity": "sha512-dDOz5VZZ+ZGnglpmET6VJefamWXFsgaVtuhSClf2/URZBp048jprD6z1hddsIMN6n1w+oUzGZEBp9u6Mlszsfg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.65.1.tgz", + "integrity": "sha512-JbfRKz8nJCuo8gn1Dqx8lQWEHw4qhZ3uRaHphw5vrSjnSptJTKTRC/bLKHh8QQMsEnhL4uq+bdg3tkiXbJ2q/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.65.1.tgz", + "integrity": "sha512-3oQpe1q0emSeCAssVHMQdOz5fz3PBH5my/Fr0ZekV2UMNL4ZiRa1ciikYlGQXyDkeqseYYv1InwOY8YPqY7RIQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.65.1.tgz", + "integrity": "sha512-nNV3HHi23o1LtpGGwH+Bav4hqnc4+klQtC96tSvCfvql8bLlNFTnTFiGl/jai2nve3eEr/vT2ocuyf+h+eMnYQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.65.1.tgz", + "integrity": "sha512-b5zab0FryBxuocuDeqOsYGAg2EzjKw4mKA6LjQXCyewXjGv/BXqACBsY2cO45xM4hV2weYtdjGThP2VB2ByAYg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.65.1.tgz", + "integrity": "sha512-Rh08xZakiUxKNgI3hbgH9mivlx478lSqckS9s+Oc3/TX1VrAH3W8z/5wpJXR/v5VjqcJ/jJNVBg64PU1+iQsTQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.65.1.tgz", + "integrity": "sha512-qDgtS6juN3/k97y4kcD6XLFpXEo4f6CnG8lTOGYwlhrUYTrMyhN0WHdZCPAW5rqS0MqQG7qI7dxnjugt6QB39Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.65.1.tgz", + "integrity": "sha512-44IKzBPqXXVm0kt1O+F5eW4juR5FyIxGNlFkpP8qwIDzucBsjM1HCbdYUQrQw8rU/g6HRl/tfBm0+Qq4H4Ro3w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.65.1.tgz", + "integrity": "sha512-tvYWZ/h2PCHs558Xu7B6LptuRCV8lXV2xI75ONMcldII1m2zlFwuI4BJZi9M6rSMkYqcPLLtPUPrjyqfbzLOdg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.65.1.tgz", + "integrity": "sha512-gdyeucgZCnei3pXwadKVTEAOgRx0B+c847DUPR/PdOMmp3olhfkYo6r0J4PzRFZB5ZICZQTxntyvgmWH5xT7rg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.65.1.tgz", + "integrity": "sha512-Wf/NEKdn2SAL4Nvt8HyDtdkseWTwiLbZEOoNYnEaHkbso0B9eHO42vKnZh+OYsn+IMhAMcWcuIwPlBOgOXsUzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.65.1.tgz", + "integrity": "sha512-x9VSsDmsMsEWy4cr/mFeaEsXnVJ6Kc4NCAU/lCnwml3s7LQbZoa9+/qwc9etWHbuuDT5mYxdyyKUwo76njtjZw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.65.1.tgz", + "integrity": "sha512-RIDbiVvvLkN45mOFpbo6eYMgnuiBEB62BGR/0s4QJgGg2AvVxucDDNn67kpAcUoYyfmY8iX8/RifMcmxmmnejA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.65.1.tgz", + "integrity": "sha512-+KN8HIr88vN59JNxlrM2fMimzY43Y0lQB7+0xdVsFbvFCAA74CLKCdfzIHzzvndJryBc94Pm+MvwtukKjaS2ug==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.65.1.tgz", + "integrity": "sha512-+Y1Sd49cSDq60UOl8/U2HI0ebUvnSl/hDhMHQn+ouw17yb8fYwMWIazzJ4JFpSkjPE7sj5NTNSX9IoyEqcFfyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.15.30", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.30.tgz", + "integrity": "sha512-6Q7lr06bEHdlfplU6YRbgG1SFBdlsfNC4/lX+SkhiTs0cpJkOElmWls8PxDFv4yY/xKb8Y6SO0OmSX4wgqTZbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz", + "integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.52.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz", + "integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/postcss": { + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.65.1.tgz", + "integrity": "sha512-HzD4IdphaPMvuAacnL/vtoq5B8FwqewKvxTtqYibWvP6VDyZ+UsTtlw5F8HIOIIpLQAkN2NOP/0sM3puo9xN/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.65.1", + "@rollup/rollup-android-arm64": "4.65.1", + "@rollup/rollup-darwin-arm64": "4.65.1", + "@rollup/rollup-darwin-x64": "4.65.1", + "@rollup/rollup-freebsd-arm64": "4.65.1", + "@rollup/rollup-freebsd-x64": "4.65.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.65.1", + "@rollup/rollup-linux-arm-musleabihf": "4.65.1", + "@rollup/rollup-linux-arm64-gnu": "4.65.1", + "@rollup/rollup-linux-arm64-musl": "4.65.1", + "@rollup/rollup-linux-loong64-gnu": "4.65.1", + "@rollup/rollup-linux-loong64-musl": "4.65.1", + "@rollup/rollup-linux-ppc64-gnu": "4.65.1", + "@rollup/rollup-linux-ppc64-musl": "4.65.1", + "@rollup/rollup-linux-riscv64-gnu": "4.65.1", + "@rollup/rollup-linux-riscv64-musl": "4.65.1", + "@rollup/rollup-linux-s390x-gnu": "4.65.1", + "@rollup/rollup-linux-x64-gnu": "4.65.1", + "@rollup/rollup-linux-x64-musl": "4.65.1", + "@rollup/rollup-openbsd-x64": "4.65.1", + "@rollup/rollup-openharmony-arm64": "4.65.1", + "@rollup/rollup-win32-arm64-msvc": "4.65.1", + "@rollup/rollup-win32-ia32-msvc": "4.65.1", + "@rollup/rollup-win32-x64-gnu": "4.65.1", + "@rollup/rollup-win32-x64-msvc": "4.65.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "6.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", + "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/xash3d-fwgs": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/xash3d-fwgs/-/xash3d-fwgs-1.2.2.tgz", + "integrity": "sha512-N2w5uSs3OXDGuvgryuKO42LbCQUOveYXDUr9NqmbRnjxpEjVKzhHC+4sTJe6zUTPtiIZSCfd+rHpEa92Da4Vwg==", + "deprecated": "This package is deprecated and will be removed.", + "license": "MIT" + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 000000000..15b42448d --- /dev/null +++ b/web/package.json @@ -0,0 +1,23 @@ +{ + "name": "primext-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite --port 43123", + "build": "vite build", + "preview": "vite preview --port 43123 --host 127.0.0.1", + "test:e2e": "playwright test", + "test:e2e:install": "playwright install chromium" + }, + "dependencies": { + "fflate": "0.8.2", + "xash3d-fwgs": "1.2.2" + }, + "devDependencies": { + "@playwright/test": "1.52.0", + "@types/node": "22.15.30", + "typescript": "5.8.3", + "vite": "6.3.5" + } +} diff --git a/web/playwright.config.ts b/web/playwright.config.ts new file mode 100644 index 000000000..2b9bf20e8 --- /dev/null +++ b/web/playwright.config.ts @@ -0,0 +1,29 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + timeout: 180_000, + expect: { timeout: 120_000 }, + fullyParallel: false, + retries: 0, + reporter: [['list']], + use: { + baseURL: 'http://127.0.0.1:43123', + ...devices['Desktop Chrome'], + viewport: { width: 1024, height: 720 }, + launchOptions: { + args: [ + '--use-gl=angle', + '--use-angle=swiftshader', + '--enable-webgl', + '--ignore-gpu-blocklist', + ], + }, + }, + webServer: { + command: 'npm run preview', + url: 'http://127.0.0.1:43123', + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}); diff --git a/web/src/main.ts b/web/src/main.ts new file mode 100644 index 000000000..3a4853cc3 --- /dev/null +++ b/web/src/main.ts @@ -0,0 +1,273 @@ +/** + * Browser loader for PrimeXT + Xash3D FWGS (WASM). + * Engine integration patterns adapted from webXash (MIT) — https://github.com/x8BitRain/webXash + */ +import { Xash3D } from 'xash3d-fwgs'; +import { unzipSync } from 'fflate'; + +const RO_DIR = '/rodir/'; +const statusEl = document.getElementById('status')!; +const logEl = document.getElementById('log')!; +const canvas = document.getElementById('canvas') as HTMLCanvasElement; + +/** Emscripten/SDL listens on #canvas and the window. Keep focus there so keys and pointer lock reach the engine. */ +function bindGameInput(): void { + canvas.addEventListener('contextmenu', (event) => event.preventDefault()); + const focusGame = () => { + canvas.focus(); + }; + canvas.addEventListener('pointerdown', focusGame); + window.addEventListener( + 'keydown', + (event) => { + if (document.activeElement !== canvas) return; + const block = [' ', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']; + if (block.includes(event.key) || event.key.length === 1) { + event.preventDefault(); + } + }, + { capture: true }, + ); +} +bindGameInput(); + +const ENGINE = '/xash'; +const PRIMEXT_WASM = '/wasm'; +const UPLINK_ZIP = '/test/uplink.zip'; + +const URLS = { + xash: `${ENGINE}/xash.wasm`, + filesystem: `${ENGINE}/filesystem_stdio.wasm`, + menu: `${ENGINE}/libmenu.wasm`, + webgl2: `${ENGINE}/libref_webgl2.wasm`, + extras: `${ENGINE}/valve/extras.pk3`, + client: `${PRIMEXT_WASM}/cl_dlls/client_emscripten_wasm32.wasm`, + server: `${PRIMEXT_WASM}/dlls/server_emscripten_wasm32.wasm`, +}; + +const DYNAMIC_LIBRARIES = [ + 'filesystem_stdio.wasm', + 'libref_webgl2.wasm', + 'cl_dlls/client_emscripten_wasm32.wasm', + 'cl_dlls/menu_emscripten_wasm32.wasm', + 'dlls/server_emscripten_wasm32.wasm', + 'dlls/hl_emscripten_wasm32.wasm', + 'bin/client_emscripten_wasm32.wasm', + 'bin/server_emscripten_wasm32.wasm', +]; + +type LaunchProfile = { + gameDir: string; + mountPrimext: boolean; +}; + +const PROFILE_PRIMEXT: LaunchProfile = { gameDir: 'primext', mountPrimext: true }; +/** Half-Life: Uplink demo. The zip is a valve/ tree (same layout as webXash). */ +const PROFILE_UPLINK: LaunchProfile = { gameDir: 'valve', mountPrimext: false }; + +function log(line: string) { + logEl.textContent += `${line}\n`; + console.log(line); +} + +function setStatus(text: string) { + statusEl.textContent = text; +} + +function locateFile(path: string): string { + switch (path) { + case 'xash.wasm': + return URLS.xash; + case 'filesystem_stdio.wasm': + return URLS.filesystem; + case 'cl_dlls/menu_emscripten_wasm32.wasm': + return URLS.menu; + case 'cl_dlls/client_emscripten_wasm32.wasm': + case 'bin/client_emscripten_wasm32.wasm': + return URLS.client; + case 'dlls/server_emscripten_wasm32.wasm': + case 'dlls/hl_emscripten_wasm32.wasm': + case 'bin/server_emscripten_wasm32.wasm': + return URLS.server; + case 'libref_webgl2.wasm': + return URLS.webgl2; + case 'libvgui_support.wasm': + return URLS.menu; + default: + return path.startsWith('/') ? path : `${ENGINE}/${path}`; + } +} + +async function mountFiles( + xash: Xash3D, + files: Map, +): Promise { + xash.em!.FS.mkdirTree(RO_DIR); + for (const [relative, data] of files) { + const full = RO_DIR + relative.replace(/^\/+/, ''); + const dir = full.substring(0, full.lastIndexOf('/')); + if (dir) xash.em!.FS.mkdirTree(dir); + if (!full.endsWith('/')) { + xash.em!.FS.writeFile(full, data); + } + } + xash.em!.FS.chdir(RO_DIR); +} + +async function mountZip(xash: Xash3D, buffer: ArrayBuffer): Promise { + const entries = unzipSync(new Uint8Array(buffer)); + const map = new Map(); + for (const [name, content] of Object.entries(entries)) { + map.set(name, content); + } + await mountFiles(xash, map); +} + +async function mountPrimextAssets(xash: Xash3D): Promise { + const res = await fetch('/primext/manifest.json'); + if (!res.ok) { + log('No primext manifest — skipping mod file staging'); + return; + } + const manifest = (await res.json()) as { files: string[] }; + const map = new Map(); + for (const rel of manifest.files) { + const fileRes = await fetch(`/primext/${rel}`); + if (!fileRes.ok) continue; + map.set(`primext/${rel}`, new Uint8Array(await fileRes.arrayBuffer())); + } + await mountFiles(xash, map); +} + +async function writeExtras(xash: Xash3D): Promise { + const res = await fetch(URLS.extras); + const buf = await res.arrayBuffer(); + xash.em!.FS.writeFile(RO_DIR + 'extras.pk3', new Uint8Array(buf)); +} + +async function startXash(profile: LaunchProfile): Promise { + const launchArgs = [ + '-game', + profile.gameDir, + '-windowed', + '-ref', + 'webgl2', + '+hud_scale', + '2.5', + '+volume', + '0.05', + ]; + + const xash = new Xash3D({ + canvas, + module: { + arguments: launchArgs, + locateFile, + print: (text: string) => log(text), + printErr: (text: string) => log(`[err] ${text}`), + }, + libraries: { + filesystem: URLS.filesystem, + xash: URLS.xash, + menu: URLS.menu, + client: URLS.client, + server: URLS.server, + render: { gles3compat: URLS.webgl2 }, + }, + dynamicLibraries: DYNAMIC_LIBRARIES, + }); + + await xash.init(); + return xash; +} + +async function runWithZip( + zipBuffer: ArrayBuffer, + profile: LaunchProfile, +): Promise { + setStatus('Starting engine…'); + const xash = await startXash(profile); + setStatus('Mounting game data…'); + await mountZip(xash, zipBuffer); + if (profile.mountPrimext) { + await mountPrimextAssets(xash); + } + await writeExtras(xash); + setStatus('Running'); + (window as unknown as { __primextXash?: Xash3D }).__primextXash = xash; + beginPlay(xash, profile.gameDir); +} + +function beginPlay(xash: Xash3D, gameDir: string): void { + log(`Starting Xash main loop (-game ${gameDir})…`); + xash.main(); + canvas.focus(); + log('Click the view to capture the mouse. WASD moves, mouse looks, Esc opens the menu.'); +} + +async function runWithFolder(fileList: FileList): Promise { + const map = new Map(); + const paths: string[] = []; + for (const file of fileList) { + const rel = (file as File & { webkitRelativePath?: string }) + .webkitRelativePath; + if (!rel) continue; + paths.push(rel); + } + const prefix = paths[0]?.includes('/') + ? paths[0].slice(0, paths[0].indexOf('/') + 1) + : ''; + for (const file of fileList) { + const rel = (file as File & { webkitRelativePath?: string }) + .webkitRelativePath; + if (!rel) continue; + const stripped = rel.startsWith(prefix) ? rel.slice(prefix.length) : rel; + map.set(stripped, new Uint8Array(await file.arrayBuffer())); + } + setStatus('Starting engine…'); + const xash = await startXash(PROFILE_PRIMEXT); + setStatus('Mounting game data…'); + await mountFiles(xash, map); + await mountPrimextAssets(xash); + await writeExtras(xash); + setStatus('Running'); + (window as unknown as { __primextXash?: Xash3D }).__primextXash = xash; + beginPlay(xash, PROFILE_PRIMEXT.gameDir); +} + +async function loadDemoZip(): Promise { + const res = await fetch(UPLINK_ZIP); + if (!res.ok) { + throw new Error('Uplink demo zip missing (run scripts/fetch-demo-assets.sh)'); + } + return await res.arrayBuffer(); +} + +document.getElementById('launch-demo')?.addEventListener('click', async () => { + try { + setStatus('Downloading Half-Life: Uplink demo…'); + await runWithZip(await loadDemoZip(), PROFILE_UPLINK); + } catch (e) { + setStatus(e instanceof Error ? e.message : 'Demo failed'); + } +}); + +document.getElementById('folder')?.addEventListener('change', async (ev) => { + const input = ev.target as HTMLInputElement; + if (!input.files?.length) return; + await runWithFolder(input.files); +}); + +const params = new URLSearchParams(location.search); +if (params.get('autotest') === '1' || params.get('demo') === 'uplink') { + void (async () => { + try { + setStatus('Loading Uplink demo (autotest)…'); + await runWithZip(await loadDemoZip(), PROFILE_UPLINK); + } catch (e) { + setStatus(e instanceof Error ? e.message : 'autotest failed'); + } + })(); +} + +setStatus('Ready — try the Uplink demo or load your own Half-Life folder'); diff --git a/web/src/style.css b/web/src/style.css new file mode 100644 index 000000000..876ff0532 --- /dev/null +++ b/web/src/style.css @@ -0,0 +1,75 @@ +:root { + color-scheme: dark; + font-family: + system-ui, + -apple-system, + Segoe UI, + Roboto, + sans-serif; + background: #0f1115; + color: #e8eaed; +} + +body { + margin: 0; +} + +main { + max-width: 1000px; + margin: 0 auto; + padding: 1rem 1.25rem 2rem; +} + +header h1 { + margin: 0 0 0.25rem; + font-size: 1.35rem; +} + +#status { + margin: 0 0 0.75rem; + opacity: 0.85; +} + +#canvas { + display: block; + width: min(100%, 960px); + aspect-ratio: 16 / 9; + background: #000; + border: 1px solid #333; + border-radius: 6px; + outline: none; + touch-action: none; +} + +#controls { + margin-top: 1rem; + font-size: 0.95rem; +} + +.file { + display: block; + margin: 0.75rem 0; +} + +#log { + margin-top: 1rem; + max-height: 200px; + overflow: auto; + font-size: 0.8rem; + background: #1a1d24; + padding: 0.75rem; + border-radius: 6px; +} + +button { + cursor: pointer; + padding: 0.5rem 0.85rem; + border-radius: 6px; + border: 1px solid #444; + background: #2a3140; + color: inherit; +} + +button:hover { + background: #354055; +} diff --git a/web/tests/engine.spec.ts b/web/tests/engine.spec.ts new file mode 100644 index 000000000..2bef15c78 --- /dev/null +++ b/web/tests/engine.spec.ts @@ -0,0 +1,80 @@ +import { test, expect } from '@playwright/test'; +import path from 'node:path'; +import fs from 'node:fs'; + +test('Uplink demo loads Xash + PrimeXT WASM without fatal errors', async ({ + page, +}) => { + const errors: string[] = []; + page.on('pageerror', (err) => errors.push(String(err))); + page.on('console', (msg) => { + if (msg.type() === 'error') errors.push(msg.text()); + }); + + await page.goto('/?autotest=1', { waitUntil: 'domcontentloaded' }); + + await expect(page.locator('#status')).toHaveText('Running', { + timeout: 120_000, + }); + + await expect + .poll( + async () => + page.evaluate(() => { + const x = (window as unknown as { __primextXash?: { running: boolean; exited: boolean } }) + .__primextXash; + return x?.running === true && x?.exited !== true; + }), + { timeout: 60_000 }, + ) + .toBe(true); + + await page.waitForTimeout(8_000); + + const fatal = errors.filter( + (e) => + /fatal|abort|Aborted|RuntimeError|missing essential exports|can't init client API/i.test( + e, + ) && !/WebGL/i.test(e), + ); + expect(fatal).toEqual([]); + + const shotDir = path.join(process.cwd(), 'test-results'); + fs.mkdirSync(shotDir, { recursive: true }); + await page.screenshot({ + path: path.join(shotDir, 'primext-engine.png'), + fullPage: true, + }); + + const log = await page.locator('#log').innerText(); + expect(log).toMatch(/Starting Xash main loop/i); + + const view = page.locator('#canvas'); + await view.click(); + await page.keyboard.press('Escape'); + await page.waitForTimeout(1_500); + + const fatalAfterInput = errors.filter( + (e) => + /fatal|abort|Aborted|RuntimeError|missing essential exports|can't init client API/i.test( + e, + ) && !/WebGL/i.test(e), + ); + expect(fatalAfterInput).toEqual([]); + await expect + .poll( + async () => + page.evaluate(() => { + const x = (window as unknown as { __primextXash?: { running: boolean; exited: boolean } }) + .__primextXash; + return x?.running === true && x?.exited !== true; + }), + { timeout: 10_000 }, + ) + .toBe(true); + + await page.screenshot({ + path: path.join(shotDir, 'primext-input.png'), + fullPage: true, + }); +}); diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 000000000..ac97e7c8a --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "isolatedModules": true, + "moduleDetection": "force", + "strict": true, + "noEmit": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 000000000..21e0856b0 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + build: { + minify: 'esbuild', + target: 'es2022', + cssMinify: true, + reportCompressedSize: true, + rollupOptions: { + output: { + manualChunks: undefined, + }, + }, + }, + server: { + port: 43123, + host: '127.0.0.1', + }, + preview: { + port: 43123, + host: '127.0.0.1', + }, +});