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/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/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/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) 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', + }, +});