diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9be0fc8165..88fb766dc1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,8 +14,14 @@ jobs: run: | source emscripten/get_emscripten.sh emmake emscripten/build.sh + echo "::group::Module sizes (what WebKit must compile on the phone)" + ls -l build/install/*.so build/install/hl2_launcher.wasm | awk '{ printf "%9.2f MB %s\n", $5/1048576, $9 }' | sort -rn + du -cb build/install/*.so | tail -n 1 | awk '{ printf "total side modules: %.1f MB\n", $1/1048576 }' + echo "::endgroup::" cd build/install/ - zip -r ../../out.zip * + # "." rather than "*": the glob skips dotfiles, which would drop + # .nojekyll and leave a manual GitHub Pages deploy broken. + zip -r ../../out.zip . - name: Upload artifact uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000000..268f0e22ae --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,78 @@ +name: Deploy to GitHub Pages + +# GitHub Pages cannot send Cross-Origin-Opener-Policy / Cross-Origin-Embedder- +# Policy, which the threaded wasm build needs for SharedArrayBuffer. The bundle +# ships coi-serviceworker.js, which adds those headers from a service worker, so +# the published site ends up cross-origin isolated anyway. +# +# Run it by hand from any branch: Actions -> Deploy to GitHub Pages -> Run +# workflow. Pages must be set to "GitHub Actions" as its source, under +# Settings -> Pages. + +on: + push: + branches: [master] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +# Never let two deploys race; queue them instead. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Build wasm + run: | + source emscripten/get_emscripten.sh + emmake emscripten/build.sh + + # Optional. Set the CHUNKS_BASE_URL repository variable (Settings -> + # Secrets and variables -> Actions -> Variables) to publish the game data + # alongside the engine. Mind the limits: GitHub Pages refuses to publish a + # site over 1GB, so for the full game you usually want to leave this unset + # and host the chunks elsewhere, pointing at them with ?chunks=. + - name: Fetch game data + if: vars.CHUNKS_BASE_URL != '' + run: | + python3 emscripten/fetch_chunks.py \ + --dir build/install \ + --base-url "${{ vars.CHUNKS_BASE_URL }}" + + - name: Report bundle size + run: | + du -sh build/install + echo "--- largest files ---" + find build/install -type f -printf '%s\t%p\n' | sort -rn | head -10 | \ + awk -F'\t' '{ printf "%8.1f MB %s\n", $1/1048576, $2 }' + total=$(du -sb build/install | cut -f1) + if [ "$total" -gt 1073741824 ]; then + echo "::error::Site is $((total/1048576))MB. GitHub Pages will not publish a site over 1GB." + exit 1 + fi + + - uses: actions/configure-pages@v5 + + - uses: actions/upload-pages-artifact@v3 + with: + path: build/install + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/README.md b/README.md index 3a50ceeabf..60fe10ad83 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,76 @@ + sound + saving/loading (works, TODO: save to browser storage) + sometimes render breaks (something related to lightmaps?) -+ fullscreen html button (works through game settings) ++ fullscreen html button (works through game settings; iPhone Safari has no + fullscreen API at all -- use Add to Home Screen) + +## running it + +Pushing to GitHub is enough to **build** it -- the `Build` workflow compiles the +wasm bundle and uploads `release.zip` -- but a build on its own is not a playable +game. Three things have to come together: + +**1. Get the engine.** Open the repo's Actions tab, pick the latest green `Build` +run, and download the `release.zip` artifact. Unzip it. + +**2. Add the game data.** The zip contains the engine and *no* Portal content. +The engine fetches `chunks/.data` at runtime; without those files the page +stops with "Could not load game data". You need to own Portal. + +```sh +cd +python3 fetch_chunks.py --dir . +``` + +Or build the chunks from your own Portal install with `emscripten/repackage.js` +(see [packing game data](#packing-game-data)). + +**3. Serve it cross-origin isolated, over HTTPS or localhost.** This is not +optional: the engine uses threads, threads need `SharedArrayBuffer`, and browsers +only hand one out to a page sending `Cross-Origin-Opener-Policy` and +`Cross-Origin-Embedder-Policy`. Opening `hl2_launcher.html` as a `file://` URL +will never work. + +```sh +python3 serve.py --dir . +# http://localhost:8080/ +``` + +### GitHub Pages + +Pages cannot send those headers, so the bundle ships `coi-serviceworker.js`, +which installs them from a service worker instead. Set **Settings > Pages > +Source** to **GitHub Actions**, then run **Actions > Deploy to GitHub Pages**. +The first visit reloads itself once to pick the worker up. + +The catch is size, not headers: GitHub blocks files over 100MB and will not +publish a Pages site over 1GB, so the game data usually has to live elsewhere. +Host the chunks anywhere (a GitHub Release, R2, any static host) and point at +them: + +``` +https://.github.io//?chunks=https://your-host.example/portal/chunks +``` + +Then open it, tap/click **Tap to play**, and use the main menu to start a new +game. If something is missing, the page now says what on screen rather than +showing a black screen. + +To play on an actual iPhone you need an HTTPS address, so put the server behind +a tunnel (`cloudflared tunnel --url http://localhost:8080`) or deploy to a host +that respects the bundled `_headers` file, such as Netlify or Cloudflare Pages. +Full details in [emscripten/README-hosting.md](emscripten/README-hosting.md). + +## phones (iPhone / iPad / Android) + +Touch controls are turned on automatically on touch devices: drag the left half +of the screen to move, the right half to look, and use the on-screen buttons for +jump / crouch / use / both portals. Play in landscape. + +iPhone needs iOS 17+ with Lockdown Mode off. See +[emscripten/README-hosting.md](emscripten/README-hosting.md) for the full +requirements, HTTPS tunnelling for on-device testing, fullscreen on iPhone, and +the memory knobs. ## building @@ -36,11 +105,16 @@ embuilder --force --pic build sdl2 sdl2-mt # patch glMapBufferRange to allow some "unsupported" parameters patch /emsdk/upstream/emscripten/src/lib/libwebgl.js emscripten/libwebgl.patch -emmake ./build_emscripten.sh +emmake ./emscripten/build.sh ``` then download packed game data (yikes.pw/portal/chunks/mapName.data for each map) and put it to ./build/install/chunks/ +`build.sh` reads `EM_INITIAL_MEMORY`, `EM_MAXIMUM_MEMORY` and +`EM_PTHREAD_POOL_SIZE` from the environment if you want to tune it for a +specific target. + ## packing game data + first of all, you'll need to build engine from https://github.com/nillerusr/source-engine for your native arch and after that you should add that printf to ./filesystem/basefilesystem.cpp, to dump all files that engine would access (textures/models that map needs) diff --git a/appframework/sdlmgr.cpp b/appframework/sdlmgr.cpp index 4a2a84a924..cf68ea9001 100644 --- a/appframework/sdlmgr.cpp +++ b/appframework/sdlmgr.cpp @@ -25,6 +25,7 @@ #ifdef __EMSCRIPTEN__ #include +#include #include #endif @@ -1198,10 +1199,27 @@ void CSDLMgr::OnFrameRendered() SDL_SetWindowGrab( m_Window, bWindowGrab ); SDL_SetRelativeMouseMode( bRelativeMouseMode ); #ifdef __EMSCRIPTEN__ - if (bWindowGrab) - emscripten_request_pointerlock("canvas", true); - else - emscripten_exit_pointerlock(); + // iOS has never shipped the Pointer Lock API, and on a touch device the + // engine's on-screen touch controls drive the view anyway. Requesting a + // lock there throws out of the deferred-call handler on every click, so + // only ask where the browser actually supports it. + // + // This runs on the proxied main pthread, which has no DOM of its own -- + // the probe has to be evaluated on the browser main thread. + static const bool bPointerLockSupported = MAIN_THREAD_EM_ASM_INT({ + var body = document.body; + if (!body) return 0; + if (navigator.maxTouchPoints > 0 && !window.matchMedia('(pointer: fine)').matches) return 0; + return (body.requestPointerLock || body.webkitRequestPointerLock) ? 1 : 0; + }) != 0; + + if ( bPointerLockSupported ) + { + if (bWindowGrab) + emscripten_request_pointerlock("canvas", true); + else + emscripten_exit_pointerlock(); + } #endif SDL_ShowCursor( m_bCursorVisible ? 1 : 0 ); diff --git a/emscripten/README-hosting.md b/emscripten/README-hosting.md new file mode 100644 index 0000000000..b912967ea7 --- /dev/null +++ b/emscripten/README-hosting.md @@ -0,0 +1,172 @@ +# Hosting the web build + +## The one hard requirement: cross-origin isolation + +The engine is linked with pthreads (`-sUSE_PTHREADS -sSHARED_MEMORY=1 +-sPROXY_TO_PTHREAD`). Threads need `SharedArrayBuffer`, and browsers only hand +one out to a **cross-origin isolated** document. Two response headers make a +page isolated: + +``` +Cross-Origin-Opener-Policy: same-origin +Cross-Origin-Embedder-Policy: require-corp +``` + +Serve the build without them and it never starts, on any browser. The shell now +detects this and says so on screen instead of leaving a black page. + +Isolation also requires a **secure context**: `https://` or `http://localhost`. +A plain `http://192.168.1.x` address is not secure, so opening the dev server's +LAN address directly on a phone will never work no matter what headers you send. + +### Local + +```sh +python3 emscripten/serve.py --dir build/install +# http://localhost:8080/hl2_launcher.html +``` + +### Testing on a real iPhone + +The phone needs HTTPS, so tunnel the local server: + +```sh +python3 emscripten/serve.py --dir build/install & +cloudflared tunnel --url http://localhost:8080 +# or: ssh -R 80:localhost:8080 nokey@localhost.run +``` + +Open the resulting `https://` URL on the phone. + +### Netlify / Cloudflare Pages + +Copy `emscripten/_headers` to the site root. `build.sh` already copies it into +`build/install/`. + +### nginx + +```nginx +location / { + add_header Cross-Origin-Opener-Policy same-origin always; + add_header Cross-Origin-Embedder-Policy require-corp always; + types { application/wasm wasm; } +} +``` + +### Apache + +```apache +Header always set Cross-Origin-Opener-Policy "same-origin" +Header always set Cross-Origin-Embedder-Policy "require-corp" +AddType application/wasm .wasm +``` + +### GitHub Pages + +GitHub Pages cannot set response headers, so it cannot make a page cross-origin +isolated the normal way. The bundle works around it with +`coi-serviceworker.js`: a service worker adds the headers to responses on their +way in, so the page ends up isolated anyway. `shell.html` loads it from `` +and it is copied into the bundle automatically. + +Setup: + +1. **Settings > Pages > Build and deployment > Source: GitHub Actions.** +2. Run **Actions > Deploy to GitHub Pages > Run workflow** (it also runs on + every push to `master`). You can run it from any branch. +3. Open the published URL. The first visit registers the worker and reloads + once by itself; after that it starts normally. + +What to expect: + +- One extra page load on the first visit, and again after a hard reload. +- It needs HTTPS, which GitHub Pages provides. +- **Safari Private Browsing disables service workers**, so an isolated page is + impossible there. The page says so rather than failing silently. +- A host that sends real headers is still more reliable. Netlify and Cloudflare + Pages are both free and read the bundled `_headers` file. + +#### Game data does not fit on GitHub Pages + +This is the part that usually bites. GitHub refuses any file over 100MB, and +Pages will not publish a site larger than **1GB**. Git LFS does not help -- +Pages serves the LFS pointer file, not the object. The full set of Portal +chunks is normally well past that. + +So host the engine on Pages and the chunks somewhere else, and point the page at +them: + +``` +https://.github.io//?chunks=https://your-host.example/portal/chunks +``` + +or edit `index.html` to set `window.CHUNK_BASE_URL` before the engine loads. +Anywhere works -- a GitHub Release (assets can be up to 2GB each), Cloudflare R2, +Backblaze B2, any static host. Two requirements for a cross-origin chunk host: + +- it must send `Access-Control-Allow-Origin` (CORS), or the browser blocks the + request outright; +- it should send `Cross-Origin-Resource-Policy: cross-origin`. The service + worker adds this for you on Pages, but a host with real COEP headers needs it + set properly. + +If your chunks *do* fit under 1GB, set a `CHUNKS_BASE_URL` repository variable +(Settings > Secrets and variables > Actions > Variables) and the deploy workflow +downloads them into the site for you. The workflow fails the build with a clear +message if the result exceeds the 1GB limit, rather than letting the deploy fail +mysteriously. + +## Playing on iPhone / iPad + +Requirements: + +- **iOS 17 or newer.** The build renders from a worker through + `OffscreenCanvas`, which Safari gained in 16.4, and relies on WebGL 2 and + growable `SharedArrayBuffer`. iOS 17 is the first release where all of it is + reliable. +- **Lockdown Mode off** (Settings > Privacy & Security). It disables the JIT and + most of WebAssembly. +- **Landscape.** The touch layout puts the move zone on the left half and the + look zone on the right half of the screen. + +Controls come from the engine's built-in touch layer, which `pre.js` turns on +automatically when it sees a touch device: + +| Action | Input | +| --- | --- | +| Move | drag on the left half of the screen | +| Look | drag on the right half | +| Fire portal / alt portal | the two on-screen buttons on the right | +| Jump, crouch, use | on-screen buttons on the right edge | +| Menu | on-screen button, top left | + +The layout is editable in-game via the `edit` button, or with the `touch_*` +console variables (`touch_yaw`, `touch_pitch`, `touch_forwardzone`, +`touch_sidezone`, `touch_addbutton`, ...). + +### Button icons + +The move and look zones are invisible by design, and they work with no assets at +all. The labelled buttons want `materials/vgui/touch/*.vtf`, which ships with the +Android build of source-engine rather than with desktop Portal. Drop those files +into `/portal/materials/vgui/touch/` (or `hl2/`) before running +`repackage.js` and they are packed into the base chunk. Without them the buttons +are still there and still work, they just draw with the missing-material texture. +The textures must be square and power-of-two. + +### Fullscreen on iPhone + +iPhone Safari does not implement `Element.requestFullscreen`, so no web page can +go fullscreen in the browser. To get a chrome-free game, use **Share > Add to +Home Screen** and launch it from the Home Screen icon; the shell ships the +`apple-mobile-web-app-capable` meta tag that makes that a standalone app window. +iPad Safari does support fullscreen, and the Fullscreen button works there. + +### Memory + +Memory is the tightest constraint on a phone. The build starts at 512 MB and +grows (`EM_INITIAL_MEMORY` / `EM_MAXIMUM_MEMORY` in `build.sh`), and `pre.js` +passes `+mat_picmip 2` plus a resolution capped to a 1280x720 pixel budget on +touch devices. If the game still aborts partway through a chapter, lower +`EM_MAXIMUM_MEMORY` and raise `mat_picmip`, and close other Safari tabs -- +iOS kills the tab well before a desktop browser would. diff --git a/emscripten/_headers b/emscripten/_headers new file mode 100644 index 0000000000..2cacf6b43e --- /dev/null +++ b/emscripten/_headers @@ -0,0 +1,12 @@ +# Cross-origin isolation headers. +# +# This build uses pthreads, which need SharedArrayBuffer, which browsers only +# expose on a cross-origin isolated page. Without these two headers the game +# does not start at all -- on desktop or on a phone. +# +# Netlify and Cloudflare Pages read this file verbatim from the site root. +# For other hosts, the equivalent config is in emscripten/README-hosting.md. +/* + Cross-Origin-Opener-Policy: same-origin + Cross-Origin-Embedder-Policy: require-corp + Cross-Origin-Resource-Policy: same-origin diff --git a/emscripten/build.sh b/emscripten/build.sh index 8054ae61cf..eded1c2c85 100755 --- a/emscripten/build.sh +++ b/emscripten/build.sh @@ -9,6 +9,22 @@ fi export CC=emcc export CXX=em++ +# Memory layout. +# +# Mobile browsers (iOS Safari in particular) refuse a single huge upfront +# WebAssembly.Memory reservation: asking for 2047mb aborts before main() with +# "Out of memory" / "Aborted()". Start small and grow instead -- the ceiling +# stays the same, but startup now succeeds on phones. Override either value +# from the environment if you are targeting only desktop. +INITIAL_MEMORY=${EM_INITIAL_MEMORY:-512mb} +MAXIMUM_MEMORY=${EM_MAXIMUM_MEMORY:-2047mb} + +# Worker pool. A fixed pool of 8 is more than an iPhone has cores for, and +# PTHREAD_POOL_SIZE_STRICT=2 turns "pool exhausted" into a hard abort. Size the +# pool to the device and downgrade strictness to a warning so a phone with 4 +# cores spawns the extra threads on demand instead of dying. +PTHREAD_POOL_SIZE=${EM_PTHREAD_POOL_SIZE:-'Math.min(navigator.hardwareConcurrency || 4, 8)'} + set -ex #rm -rf build/install @@ -25,8 +41,11 @@ done emcc \ -sUSE_BZIP2=1 -sUSE_SDL=2 -sUSE_FREETYPE=1 -sUSE_LIBJPEG=1 -sUSE_LIBPNG -sMALLOC=mimalloc \ - -sMAIN_MODULE -sINITIAL_MEMORY=2047mb -sSHARED_MEMORY=1 -sUSE_PTHREADS -sPTHREAD_POOL_SIZE=8 -sPTHREAD_POOL_SIZE_STRICT=2 \ + -sMAIN_MODULE \ + -sINITIAL_MEMORY=$INITIAL_MEMORY -sMAXIMUM_MEMORY=$MAXIMUM_MEMORY -sALLOW_MEMORY_GROWTH=1 \ + -sSHARED_MEMORY=1 -sUSE_PTHREADS -sPTHREAD_POOL_SIZE="$PTHREAD_POOL_SIZE" -sPTHREAD_POOL_SIZE_STRICT=1 \ -sFULL_ES3 -sSTACK_SIZE=4mb --shell-file=emscripten/shell.html \ + -sENVIRONMENT=web,worker \ -sPROXY_TO_PTHREAD -sOFFSCREENCANVASES_TO_PTHREAD="#canvas" -sOFFSCREENCANVAS_SUPPORT=1 \ --pre-js emscripten/pre.js --post-js emscripten/post.js \ -L build/install/ \ @@ -35,4 +54,33 @@ emcc \ -o build/launcher_main/hl2_launcher.html cp build/launcher_main/hl2_launcher.* build/install/ -cp -r emscripten/assets build/install/ \ No newline at end of file +cp -r emscripten/assets build/install/ + +# Ship the pieces needed to actually run the bundle, so the CI artifact is +# self-contained: a dev server that sends the cross-origin isolation headers, +# the same headers for static hosts, and the game-data fetcher. +cp emscripten/serve.py build/install/ +cp emscripten/_headers build/install/ +cp emscripten/fetch_chunks.py build/install/ +cp emscripten/pre.js build/install/ # fetch_chunks.py reads the map list from it + +# Required on hosts that cannot send COOP/COEP themselves, GitHub Pages above +# all. shell.html loads it from ; without it there is no SharedArrayBuffer +# and the engine never starts. +cp emscripten/coi-serviceworker.js build/install/ + +# Stops GitHub Pages running the output through Jekyll, which would drop files +# and directories whose names begin with an underscore. +touch build/install/.nojekyll + +# The entry point is hl2_launcher.html; give the directory root an index so +# opening the bundle just works. +cat > build/install/index.html <<'HTML' + + +yikes! + + + +

Loading...

+HTML diff --git a/emscripten/coi-serviceworker.js b/emscripten/coi-serviceworker.js new file mode 100644 index 0000000000..aecbb26e6e --- /dev/null +++ b/emscripten/coi-serviceworker.js @@ -0,0 +1,171 @@ +/* + * Cross-origin isolation via service worker. + * + * The engine is built with threads, so it needs SharedArrayBuffer, which a + * browser only hands to a cross-origin isolated page -- one served with + * Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy. Some hosts + * cannot set response headers at all. GitHub Pages is the notable one. + * + * A service worker sits between the page and the network, so it can add those + * headers to responses on the way in. The first visit loads uncontrolled, + * registers the worker and reloads once; from then on the page is isolated. + * + * This is the well-known coi-serviceworker technique (Guido Zuidhof's + * coi-serviceworker popularised it); this is an independent implementation. + * + * Caveats worth knowing before relying on it: + * - It costs one extra page load the first time, and after a hard reload. + * - Service workers need HTTPS. GitHub Pages is HTTPS, so that is fine. + * - Safari Private Browsing disables service workers entirely; there is no + * way to make an isolated page there. + * - Cross-origin subresources still have to be fetchable. The worker adds + * Cross-Origin-Resource-Policy to what it passes through, but the remote + * host must still allow the request with CORS. + * + * A host that sends the real headers is always more reliable. Use the bundled + * _headers file (Netlify, Cloudflare Pages) or serve.py where you can. + */ + +if (typeof window === 'undefined') { + // ---------------------------------------------------------------- worker + + self.addEventListener('install', () => self.skipWaiting()); + + self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim())); + + self.addEventListener('message', (event) => { + if (!event.data) return; + if (event.data.type === 'deregister') { + self.registration.unregister() + .then(() => self.clients.matchAll()) + .then((clients) => clients.forEach((client) => client.navigate(client.url))); + } + }); + + self.addEventListener('fetch', (event) => { + const request = event.request; + + // Range requests and cache-only probes must pass through untouched; + // rewriting them breaks media playback and the navigation preload. + if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') return; + + event.respondWith( + fetch(request) + .then((response) => { + // An opaque response has no readable body or headers, so + // there is nothing to rewrite. Handing it back unchanged + // lets COEP reject it, which is the correct outcome. + if (response.status === 0) return response; + + const headers = new Headers(response.headers); + headers.set('Cross-Origin-Opener-Policy', 'same-origin'); + headers.set('Cross-Origin-Embedder-Policy', 'require-corp'); + // Lets COEP accept subresources the host did not mark up. + headers.set('Cross-Origin-Resource-Policy', 'cross-origin'); + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: headers + }); + }) + .catch((err) => { + console.error('[coi] fetch failed:', request.url, err); + throw err; + }) + ); + }); +} else { + // ------------------------------------------------------------------ page + + window.coiServiceWorker = { reloading: false, state: 'idle', reason: '' }; + + (function register() { + const coi = window.coiServiceWorker; + + if (window.crossOriginIsolated) { + coi.state = 'isolated'; + return; + } + + if (!window.isSecureContext) { + coi.state = 'unavailable'; + coi.reason = 'The page is not a secure context. Service workers, and ' + + 'therefore this workaround, need HTTPS or localhost.'; + return; + } + + if (!('serviceWorker' in navigator)) { + coi.state = 'unavailable'; + coi.reason = 'This browser has no service worker support. In Safari, ' + + 'check that you are not in a Private Browsing tab.'; + return; + } + + // Reloading to pick up the worker is only safe if we can remember that + // we did it. Reading storage can throw outright (Safari in private + // browsing, third-party cookie blocking), and a reload we cannot record + // is a reload that repeats forever, so treat "don't know" as "don't". + const KEY = 'coi-reloaded'; + + let alreadyTried; // true | false | null when storage is unusable + try { + alreadyTried = sessionStorage.getItem(KEY) === '1'; + } catch (e) { + alreadyTried = null; + } + + if (alreadyTried === null) { + coi.state = 'unavailable'; + coi.reason = 'sessionStorage is not usable, so the page cannot safely ' + + 'reload itself without risking a reload loop. This usually means a ' + + 'private browsing window.'; + return; + } + + if (alreadyTried) { + coi.state = 'failed'; + coi.reason = 'The service worker was registered but the page is still ' + + 'not cross-origin isolated. Some hosts and browser settings block ' + + 'this workaround; serve the page with real COOP/COEP headers instead.'; + return; + } + + // Records the attempt first and only reloads if that stuck. + function reloadOnce() { + try { + sessionStorage.setItem(KEY, '1'); + } catch (e) { + coi.state = 'failed'; + coi.reason = 'Could not record the reload attempt, so the page is ' + + 'not reloading to avoid looping forever.'; + return; + } + coi.reloading = true; + window.location.reload(); + } + + coi.state = 'registering'; + + // currentScript is null for deferred, async and module scripts, so fall + // back to the conventional filename next to the page. + const scriptUrl = (document.currentScript && document.currentScript.src) || + 'coi-serviceworker.js'; + + navigator.serviceWorker.register(scriptUrl, { + scope: './' + }).then((registration) => { + registration.addEventListener('updatefound', reloadOnce); + + // Registered and active but not controlling this load: one reload + // puts the page under the worker, and it comes back isolated. + if (registration.active && !navigator.serviceWorker.controller) { + reloadOnce(); + } + }).catch((err) => { + coi.state = 'failed'; + coi.reason = 'Could not register the service worker: ' + err; + console.error('[coi] registration failed:', err); + }); + })(); +} diff --git a/emscripten/fetch_chunks.py b/emscripten/fetch_chunks.py new file mode 100755 index 0000000000..74b774cb26 --- /dev/null +++ b/emscripten/fetch_chunks.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Download the packed Portal map chunks next to a built web bundle. + +The CI artifact contains the engine only -- no game data. The engine asks for +./chunks/.data at runtime, and without those files the page stops at +"Could not load game data". + +The map list is read straight out of emscripten/pre.js so the two cannot drift. + + python3 emscripten/fetch_chunks.py --dir build/install + +You need to own Portal. --base-url points at wherever your chunks are; the +default is the location this project's README documents. To build the chunks +from your own Portal install instead, see emscripten/repackage.js. +""" + +import argparse +import os +import re +import sys +import urllib.error +import urllib.request + +DEFAULT_BASE_URL = "https://yikes.pw/portal/chunks" +PRE_JS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "pre.js") + + +def map_names(pre_js=PRE_JS): + """Pull mapsOrdered out of pre.js so this list stays in sync with the engine.""" + with open(pre_js, encoding="utf-8") as fh: + source = fh.read() + block = re.search(r"mapsOrdered\s*=\s*\[(.*?)\]", source, re.S) + if not block: + sys.exit(f"could not find mapsOrdered in {pre_js}") + return re.findall(r"'([^']+)'", block.group(1)) + + +def human(n): + for unit in ("B", "KB", "MB", "GB"): + if n < 1024 or unit == "GB": + return f"{n:.0f}{unit}" if unit == "B" else f"{n:.1f}{unit}" + n /= 1024 + + +def download(url, dest): + tmp = dest + ".part" + with urllib.request.urlopen(url) as response: + total = int(response.headers.get("Content-Length") or 0) + done = 0 + with open(tmp, "wb") as out: + while True: + block = response.read(1 << 20) + if not block: + break + out.write(block) + done += len(block) + if total: + pct = 100 * done / total + print(f"\r {pct:5.1f}% {human(done)} / {human(total)}", end="") + else: + print(f"\r {human(done)}", end="") + print() + os.replace(tmp, dest) + return done + + +def main(argv=None): + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--dir", default="build/install", + help="the built bundle; chunks land in /chunks (default: %(default)s)") + parser.add_argument("--base-url", default=DEFAULT_BASE_URL, + help="where to fetch .data from (default: %(default)s)") + parser.add_argument("--force", action="store_true", + help="re-download chunks that are already present") + args = parser.parse_args(argv) + + out_dir = os.path.join(os.path.abspath(args.dir), "chunks") + os.makedirs(out_dir, exist_ok=True) + + maps = map_names() + print(f"{len(maps)} chunks -> {out_dir}") + + total = 0 + failed = [] + for i, name in enumerate(maps, 1): + dest = os.path.join(out_dir, f"{name}.data") + if os.path.exists(dest) and not args.force: + size = os.path.getsize(dest) + print(f"[{i}/{len(maps)}] {name}: already have it ({human(size)})") + total += size + continue + + url = f"{args.base_url.rstrip('/')}/{name}.data" + print(f"[{i}/{len(maps)}] {name}") + try: + total += download(url, dest) + except (urllib.error.URLError, OSError) as err: + print(f" FAILED: {err}") + failed.append(name) + + print(f"\n{human(total)} in {out_dir}") + if failed: + print(f"could not fetch: {', '.join(failed)}") + print("The game will still start, but those maps will not load.") + return 1 + + print("Serve it with: python3 serve.py --dir " + args.dir) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/emscripten/post.js b/emscripten/post.js index 1582b1dfc0..fe8306bfc8 100644 --- a/emscripten/post.js +++ b/emscripten/post.js @@ -1,16 +1,56 @@ +;(() => { + // Also loaded inside pthread workers, which have no DOM. + if(typeof window === 'undefined' || typeof document === 'undefined') return; + const host = window.GameHost || {}; + + // The shell already told the player what is missing. Park main() here rather + // than letting startup fail somewhere deep in the runtime with a stack trace + // nobody can act on. This dependency is deliberately never removed. + if(host.supported === false) { + addRunDependency('unsupported-browser') + return + } -;(() => { - if(typeof window === 'undefined') return; // fix for accidental close via browser shortcut ctrl+w, crouch+move forward obviously window.addEventListener('beforeunload', function (event) { - event.preventDefault() + // Only once there is a session worth protecting, otherwise every + // navigation away from the loading screen prompts for nothing. + if(!host.started) return; + event.preventDefault(); + event.returnValue = ''; }) canvasElement.onkeypress = e => e.preventDefault() + // iOS reclaims the WebGL context whenever the tab is backgrounded or memory + // gets tight. Emscripten cannot rebuild the engine's GL state, so tell the + // player instead of leaving them on a frozen frame. + canvasElement.addEventListener('webglcontextrestored', () => { + console.warn('WebGL context restored; engine state cannot be recovered') + }, false) + + // Hold main() until the player taps. iOS refuses to start an AudioContext, + // enter fullscreen or lock orientation outside a user gesture, so the engine + // must not initialise before we have one. + addRunDependency('user-gesture') + let gestureReleased = false + window.addEventListener('gamehost:play', () => { + if(gestureReleased) return + gestureReleased = true + removeRunDependency('user-gesture') + }, { once: true }) + addRunDependency('load_game_data') - dataLoader.loadMapWithDeps('background1').then(x => { + dataLoader.loadMapWithDeps('background1').then(() => { removeRunDependency('load_game_data') + if(typeof window.showPlayGate === 'function') window.showPlayGate() + }, err => { + console.error(err) + if(typeof window.showFatal === 'function') { + window.showFatal('Could not load game data', + String(err && err.message || err) + + '\n\nThe packed map chunks must be served from ./chunks/ next to this page.') + } }) })(); diff --git a/emscripten/pre.js b/emscripten/pre.js index 52aff49088..28a73a106a 100644 --- a/emscripten/pre.js +++ b/emscripten/pre.js @@ -1,3 +1,7 @@ +// NOTE: this file is also loaded by every pthread worker, where there is no +// `window` and no DOM. Guard anything browser-side with isMainThread. +var isMainThread = (typeof window !== 'undefined' && typeof document !== 'undefined'); + Module['arguments'] = Module['arguments'] || [] Module['arguments'].push( '-game', 'portal', @@ -8,6 +12,54 @@ Module['arguments'].push( '+mat_colorcorrection', '1' ) +if (isMainThread) { + var host = window.GameHost || {}; + var res = window.gameResolution; + + // Start at the resolution the shell picked for this screen. Without this the + // engine falls back to a desktop default, and on a phone that means both a + // wrong aspect ratio and a render target far too large to hit a playable + // frame rate. + if (res && res.w && res.h) { + Module['arguments'].push('-w', String(res.w), '-h', String(res.h)) + } + + if (host.isTouch) { + // A phone has no keyboard and no pointer lock (iOS has never shipped + // it), so the engine's on-screen touch controls are the only way to + // move or look. They default to off everywhere except Android. + Module['arguments'].push('+touch_enable', '1') + Module['arguments'].push('+touch_draw', '1') + + // Memory, not shading, is what kills this build on an iPhone: drop + // texture detail so the working set fits. + Module['arguments'].push('+mat_picmip', '2') + } +} + +// Where the packed map data lives. It defaults to ./chunks next to the page, +// but the whole game is far too big for some hosts -- GitHub Pages caps a +// published site at 1GB and GitHub refuses any file over 100MB -- so the data +// often has to live somewhere else. Point at it with ?chunks=, or set +// window.CHUNK_BASE_URL before the engine loads. +// +// A cross-origin host must send CORS headers, or the browser blocks the +// request before the data ever arrives. +function chunkBaseUrl() { + if (!isMainThread) return 'chunks' + + try { + const fromQuery = new URLSearchParams(window.location.search).get('chunks') + if (fromQuery) return fromQuery.replace(/\/+$/, '') + } catch (e) { /* malformed query string; fall through */ } + + if (typeof window.CHUNK_BASE_URL === 'string' && window.CHUNK_BASE_URL) { + return window.CHUNK_BASE_URL.replace(/\/+$/, '') + } + + return 'chunks' +} + class DataLoader { mapsOrdered = [ 'background1', @@ -44,26 +96,36 @@ class DataLoader { // schedule next map if it exists const next = this.mapsOrdered[index + 1] if(next) { - this.loadMapCached(next) + // Prefetching must never take the current load down with it, and an + // unhandled rejection here would surface as a fatal error dialog. + this.loadMapCached(next).catch(err => { + console.warn(`prefetch of ${next} failed:`, err) + }) } } async loadMapCached(mapName) { if(mapName in this.loadedMaps) return this.loadedMaps[mapName] const promise = this.loadMap(mapName) + // A failed download must not be cached as permanently failed, otherwise + // every later attempt at this map replays the same error. + promise.catch(() => { delete this.loadedMaps[mapName] }) this.loadedMaps[mapName] = promise return promise } - async setProgress(mapName, progress) { + setProgress(mapName, progress) { + if(!isMainThread || typeof spinnerElement === 'undefined') return + if(progress < 1) { spinnerElement.style.display = '' - statusElement.innerText = `Downloading map ${mapName}` + statusElement.textContent = `Downloading map ${mapName}` progressElement.hidden = false + progressElement.max = 1 progressElement.value = progress } else { spinnerElement.style.display = 'none' - statusElement.innerText = '' + statusElement.textContent = '' progressElement.hidden = true } } @@ -77,44 +139,82 @@ class DataLoader { const xhr = new XMLHttpRequest() xhr.responseType = 'arraybuffer' xhr.onprogress = e => { - this.setProgress(mapName, e.loaded / e.total) + if(e.lengthComputable) this.setProgress(mapName, e.loaded / e.total) } xhr.onerror = () => { - reject(new Error(`cannot load map ${mapName}`)) + this.setProgress(mapName, 1) + reject(new Error(`cannot load map ${mapName}: network error`)) + } + + xhr.ontimeout = () => { + this.setProgress(mapName, 1) + reject(new Error(`cannot load map ${mapName}: timed out`)) } xhr.onload = e => { this.setProgress(mapName, 1) - const dv = new DataView(xhr.response) - - let offset = 0 - - // data format: { pathLen: uint32le, dataLen: uint32le, path: bytes, blob: bytes }[] - while(offset < dv.byteLength) { - const pathLen = dv.getInt32(offset, true) - const dataLen = dv.getInt32(offset + 4, true) - const path = new TextDecoder().decode(new DataView( - dv.buffer, - offset + 8, - pathLen - )) - const blob = new Uint8Array( - dv.buffer, - offset + 8 + pathLen, - dataLen - ) - offset += 8 + pathLen + dataLen - - const dir = path.replace(/\/[^\/]+$/, '') - FS.mkdirTree(dir) - FS.writeFile(path, blob) + + // A 404 still fires onload. Parsing an error page as chunk data + // walks off the end of the buffer and takes the engine with it. + if(xhr.status !== 200 && xhr.status !== 0) { + reject(new Error(`cannot load map ${mapName}: HTTP ${xhr.status} ` + + `from ${chunkBaseUrl()}/${mapName}.data`)) + return } - resolve() + if(!xhr.response || xhr.response.byteLength === 0) { + reject(new Error(`cannot load map ${mapName}: empty response`)) + return + } + + try { + const dv = new DataView(xhr.response) + + let offset = 0 + + // data format: { pathLen: uint32le, dataLen: uint32le, path: bytes, blob: bytes }[] + while(offset < dv.byteLength) { + if(offset + 8 > dv.byteLength) { + throw new Error('truncated chunk header') + } + + const pathLen = dv.getInt32(offset, true) + const dataLen = dv.getInt32(offset + 4, true) + + if(pathLen < 0 || dataLen < 0 || offset + 8 + pathLen + dataLen > dv.byteLength) { + throw new Error('corrupt chunk entry') + } + + const path = new TextDecoder().decode(new DataView( + dv.buffer, + offset + 8, + pathLen + )) + const blob = new Uint8Array( + dv.buffer, + offset + 8 + pathLen, + dataLen + ) + offset += 8 + pathLen + dataLen + + const dir = path.replace(/\/[^\/]+$/, '') + FS.mkdirTree(dir) + FS.writeFile(path, blob) + } + + resolve() + } catch(err) { + reject(new Error(`cannot unpack map ${mapName}: ${err.message}`)) + } + } + + try { + xhr.open('GET', `${chunkBaseUrl()}/${mapName}.data`, true) + xhr.send() + } catch(err) { + reject(err) } - xhr.open('GET', `chunks/${mapName}.data`, true) - xhr.send() return promise } @@ -123,8 +223,20 @@ class DataLoader { const dataLoader = new DataLoader() Module.downloadMap = (lock, mapName) => { - dataLoader.loadMapWithDeps(mapName).then(() => { + // The engine thread is parked in memory.atomic.wait32 with no timeout. If we + // ever fail to store-and-notify, the game hangs forever with a black screen, + // so release the lock on failure too and let the engine report the missing + // map itself. + const release = () => { Atomics.store(HEAP32, lock, 0) Atomics.notify(HEAP32, lock) + } + + dataLoader.loadMapWithDeps(mapName).then(release, err => { + console.error(err) + if(isMainThread && typeof window.showFatal === 'function') { + window.showFatal('Could not load map data', String(err && err.message || err)) + } + release() }) -} \ No newline at end of file +} diff --git a/emscripten/repackage.js b/emscripten/repackage.js index d340922b1e..d741a2ed35 100644 --- a/emscripten/repackage.js +++ b/emscripten/repackage.js @@ -157,10 +157,22 @@ prepend.push(...[ ...fs.globSync(baseGamePath + '/hl2/hl2_misc/materials/vgui/**/*.*'), ...fs.globSync(baseGamePath + '/hl2/hl2_pak/materials/vgui/**/*.*'), ...fs.globSync(baseGamePath + '/hl2/hl2_textures/materials/console/*') -].map(x => +].map(x => `GAME ${x.replace(/^.+\/hl2\/.+?\//g, '')}` )) +// Touch button icons. These are not in the desktop VPKs -- they come from the +// Android build of source-engine -- so they sit loose in the mod folder and the +// globs above miss them. Without them the on-screen controls still work, but +// every button draws with the missing-material texture. The globs are no-ops +// when the files are absent. +prepend.push(...[ + ...fs.globSync(baseGamePath + '/hl2/materials/vgui/touch/*.vtf'), + ...fs.globSync(baseGamePath + '/portal/materials/vgui/touch/*.vtf'), +].map(x => + `GAME ${x.replace(/^.+?\/materials\//, 'materials/')}` +)) + let currentMap = 'background01' let initMapLoaded = false diff --git a/emscripten/serve.py b/emscripten/serve.py new file mode 100755 index 0000000000..4f68173b4d --- /dev/null +++ b/emscripten/serve.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Serve the emscripten build with the headers the engine needs. + +The build is linked with pthreads, so the page must be able to allocate a +SharedArrayBuffer. Browsers only hand one out to a cross-origin isolated +document, which means the server has to send COOP and COEP. A plain +`python3 -m http.server` does not, and the game fails at startup with +"SharedArrayBuffer is not defined". + +Usage: + python3 serve.py [--port 8080] [--dir build/install] [--bind 0.0.0.0] + +To test on an actual iPhone, note that cross-origin isolation also requires a +secure context. http://localhost counts as secure, but http://192.168.x.x does +not -- so pointing your phone straight at this server will not work. Put it +behind HTTPS, for example: + + ssh -R 80:localhost:8080 nokey@localhost.run + # or + cloudflared tunnel --url http://localhost:8080 +""" + +import argparse +import functools +import http.server +import os +import socketserver +import sys + +EXTRA_TYPES = { + ".wasm": "application/wasm", + ".data": "application/octet-stream", + ".js": "text/javascript", + ".mjs": "text/javascript", + ".symbols": "text/plain", + ".map": "application/json", +} + + +class Handler(http.server.SimpleHTTPRequestHandler): + def end_headers(self): + # The two headers that make SharedArrayBuffer available. + self.send_header("Cross-Origin-Opener-Policy", "same-origin") + self.send_header("Cross-Origin-Embedder-Policy", "require-corp") + self.send_header("Cross-Origin-Resource-Policy", "same-origin") + # The build changes every time; never let a stale wasm be reused. + self.send_header("Cache-Control", "no-store") + super().end_headers() + + def guess_type(self, path): + ext = os.path.splitext(str(path))[1].lower() + if ext in EXTRA_TYPES: + return EXTRA_TYPES[ext] + return super().guess_type(path) + + +class Server(socketserver.ThreadingTCPServer): + # Map chunks are large and the page fetches several at once; a threading + # server keeps one slow download from blocking the rest. + allow_reuse_address = True + daemon_threads = True + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--port", type=int, default=8080) + parser.add_argument("--bind", default="0.0.0.0") + parser.add_argument("--dir", default=".", help="directory to serve (default: current)") + args = parser.parse_args(argv) + + root = os.path.abspath(args.dir) + if not os.path.isdir(root): + sys.exit(f"no such directory: {root}") + + handler = functools.partial(Handler, directory=root) + with Server((args.bind, args.port), handler) as httpd: + print(f"serving {root}") + print(f" http://localhost:{args.port}/hl2_launcher.html") + print(" cross-origin isolation: enabled (COOP + COEP)") + try: + httpd.serve_forever() + except KeyboardInterrupt: + print("\nbye") + + +if __name__ == "__main__": + main() diff --git a/emscripten/shell.html b/emscripten/shell.html index 9ed3bcca24..1d428026d5 100644 --- a/emscripten/shell.html +++ b/emscripten/shell.html @@ -3,112 +3,645 @@ + + + + + + + + + + + + yikes! + + + + - + - +
+ -
-
Downloading...
+ - - - - + -
- -
+
+ +
+
Downloading...
+ +
+ + -
- + + +
- {{{ SCRIPT }}} diff --git a/game/client/touch.cpp b/game/client/touch.cpp index f8c4f71805..3a67e71655 100644 --- a/game/client/touch.cpp +++ b/game/client/touch.cpp @@ -450,10 +450,17 @@ void CTouchControls::CreateAtlasTexture() int atlasSize = 0; stbrp_rect *rects = (stbrp_rect*)malloc(textureList.Count()*sizeof(stbrp_rect)); - memset(rects, 0, sizeof(stbrp_node)*textureList.Count()); + // This array is stbrp_rect, not stbrp_node. Clearing it with the node size + // left the tail of every entry uninitialised. + memset(rects, 0, sizeof(stbrp_rect)*textureList.Count()); if( touchTextureID ) + { vgui::surface()->DeleteTextureByID( touchTextureID ); + // Paint() keys off this id; leaving it set after the delete hands the + // renderer a stale texture if we bail out below. + touchTextureID = 0; + } int rectCount = 0; @@ -494,7 +501,7 @@ void CTouchControls::CreateAtlasTexture() continue; } if( t->vtf->Height() != t->vtf->Width() || (t->vtf->Height() & (t->vtf->Height() - 1)) != 0 ) - Error("%s texture is wrong! Don't use npot textures for touch."); + Error("%s texture is wrong! Don't use npot textures for touch.", t->szName); t->height = t->vtf->Height(); t->width = t->vtf->Width(); @@ -746,44 +753,53 @@ void CTouchControls::Paint() } } - m_pMesh = pRenderContext->GetDynamicMesh( true, NULL, NULL, g_pMatSystemSurface->DrawGetTextureMaterial(touchTextureID) ); - meshBuilder.Begin( m_pMesh, MATERIAL_QUADS, meshCount ); - - for( it = btns.begin(); it != btns.end(); it++ ) + // When none of the touch textures made it into the atlas -- which is what + // happens on builds whose game data has no vgui/touch materials at all -- + // touchTextureID stays 0 and meshCount stays 0. Asking for the material of + // texture id 0 and then building a zero-quad mesh is invalid; skip the + // atlas pass entirely instead. The buttons themselves were already drawn + // individually in the loop above. + if( meshCount > 0 && touchTextureID ) { - CTouchButton *btn = *it; + m_pMesh = pRenderContext->GetDynamicMesh( true, NULL, NULL, g_pMatSystemSurface->DrawGetTextureMaterial(touchTextureID) ); + meshBuilder.Begin( m_pMesh, MATERIAL_QUADS, meshCount ); - if( btn->texture != NULL && !(btn->flags & TOUCH_FL_HIDE) && !btn->texture->textureID ) + for( it = btns.begin(); it != btns.end(); it++ ) { - CTouchTexture *t = btn->texture; + CTouchButton *btn = *it; - int alpha = (btn->color.a > MIN_ALPHA_IN_CUTSCENE) ? max(MIN_ALPHA_IN_CUTSCENE, btn->color.a-m_AlphaDiff) : btn->color.a; - rgba_t color(btn->color.r, btn->color.g, btn->color.b, alpha); + if( btn->texture != NULL && !(btn->flags & TOUCH_FL_HIDE) && !btn->texture->textureID ) + { + CTouchTexture *t = btn->texture; - meshBuilder.Position3f( btn->x1*screen_w, btn->y1*screen_h, 0 ); - meshBuilder.Color4ubv( color ); - meshBuilder.TexCoord2f( 0, t->X0, t->Y0 ); - meshBuilder.AdvanceVertexF(); + int alpha = (btn->color.a > MIN_ALPHA_IN_CUTSCENE) ? max(MIN_ALPHA_IN_CUTSCENE, btn->color.a-m_AlphaDiff) : btn->color.a; + rgba_t color(btn->color.r, btn->color.g, btn->color.b, alpha); - meshBuilder.Position3f( btn->x2*screen_w, btn->y1*screen_h, 0 ); - meshBuilder.Color4ubv( color ); - meshBuilder.TexCoord2f( 0, t->X1, t->Y0 ); - meshBuilder.AdvanceVertexF(); + meshBuilder.Position3f( btn->x1*screen_w, btn->y1*screen_h, 0 ); + meshBuilder.Color4ubv( color ); + meshBuilder.TexCoord2f( 0, t->X0, t->Y0 ); + meshBuilder.AdvanceVertexF(); - meshBuilder.Position3f( btn->x2*screen_w, btn->y2*screen_h, 0 ); - meshBuilder.Color4ubv( color ); - meshBuilder.TexCoord2f( 0, t->X1, t->Y1 ); - meshBuilder.AdvanceVertexF(); + meshBuilder.Position3f( btn->x2*screen_w, btn->y1*screen_h, 0 ); + meshBuilder.Color4ubv( color ); + meshBuilder.TexCoord2f( 0, t->X1, t->Y0 ); + meshBuilder.AdvanceVertexF(); + + meshBuilder.Position3f( btn->x2*screen_w, btn->y2*screen_h, 0 ); + meshBuilder.Color4ubv( color ); + meshBuilder.TexCoord2f( 0, t->X1, t->Y1 ); + meshBuilder.AdvanceVertexF(); - meshBuilder.Position3f( btn->x1*screen_w, btn->y2*screen_h, 0 ); - meshBuilder.Color4ubv( color ); - meshBuilder.TexCoord2f( 0, t->X0, t->Y1 ); - meshBuilder.AdvanceVertexF(); + meshBuilder.Position3f( btn->x1*screen_w, btn->y2*screen_h, 0 ); + meshBuilder.Color4ubv( color ); + meshBuilder.TexCoord2f( 0, t->X0, t->Y1 ); + meshBuilder.AdvanceVertexF(); + } } - } - meshBuilder.End(); - m_pMesh->Draw(); + meshBuilder.End(); + m_pMesh->Draw(); + } if( m_flHideTouch < gpGlobals->curtime ) diff --git a/wscript b/wscript index 4c27415beb..1767bead9c 100644 --- a/wscript +++ b/wscript @@ -554,7 +554,13 @@ def configure(conf): flags += ['-sSHARED_MEMORY=1', '-msimd128', '-msse'] flags += ['-sUSE_BZIP2=1', '-sUSE_SDL=2', '-sUSE_FREETYPE=1', '-sUSE_LIBJPEG=1', '-sUSE_LIBPNG'] - linkflags += ['-sSIDE_MODULE=1', '-Wl,--allow-multiple-definition'] + # SIDE_MODULE=1 exports every symbol and keeps every function, so no dead + # code is ever dropped from a module. The tree already builds with + # -fvisibility=hidden and marks its real exports DLL_EXPORT (that is how the + # native .so files work), so SIDE_MODULE=2 can honour that and let wasm-ld + # garbage-collect the rest. Less code is less for iPhone Safari to compile + # and hold in memory, which is where the web build currently dies. + linkflags += ['-sSIDE_MODULE=2', '-Wl,--allow-multiple-definition'] flags += ['-sFULL_ES3', '-sMALLOC=mimalloc'] if conf.env.DEST_OS == 'freebsd':