From d74d1d924b670b57f8ab50d76f48716fc51c1f9f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 02:21:42 +0000 Subject: [PATCH 1/6] emscripten: make the web build run and be playable on iPhone The web build could not start on an iPhone, and even where it started there was no way to move: iOS has no keyboard and no Pointer Lock. Startup blockers: - INITIAL_MEMORY=2047mb is a single upfront WebAssembly.Memory reservation that mobile Safari refuses outright. Start at 512mb and grow to the same 2047mb ceiling instead. Memory limits and the thread pool size are now overridable via EM_* environment variables. - PTHREAD_POOL_SIZE=8 with POOL_SIZE_STRICT=2 hard-aborts on a device with fewer cores. Size the pool from navigator.hardwareConcurrency and downgrade strictness to a warning so extra threads spawn on demand. - The page must be cross-origin isolated for SharedArrayBuffer, and nothing in the tree provided those headers. Add serve.py (a dev server that sends COOP/COEP and the right MIME types) and a _headers file for static hosts, plus per-server config in README-hosting.md. Input and presentation: - shell.html had no viewport meta at all, so iPhone Safari laid the page out at 980px and every touch coordinate reaching the engine was wrong. Add the viewport and web-app meta tags, suppress pinch/double-tap zoom, rubber-band scrolling and the long-press callout, handle safe-area insets and orientation changes, and size the canvas to a pixel budget rather than an iPhone's native 3x grid. - Gate startup behind a "Tap to play" overlay. iOS only allows starting an AudioContext, entering fullscreen or locking orientation inside a real user gesture, so main() is held on a run dependency until then. - Detect touch devices and pass +touch_enable 1, so the engine's existing on-screen controls come up. They default to off everywhere but Android, which is why there was no way to move. Also pass a device-appropriate -w/-h and +mat_picmip 2. - Report failures on screen. A phone has no console, so unsupported browsers, missing cross-origin isolation, out-of-memory aborts and lost GL contexts now explain themselves instead of leaving a black page. - iPhone Safari has no Element.requestFullscreen; hide the button there and point at Add to Home Screen, which does give a fullscreen window. Bugs found along the way: - A failed map chunk download left the engine thread parked forever in memory.atomic.wait32, because only the success path stored and notified the lock. Release it on failure too. A 404 was worse than a hang: it fired onload, and the error page was parsed as chunk data. Check the status and bounds-check every entry. - touch.cpp built a zero-quad mesh from texture id 0 whenever no touch texture made it into the atlas, which is exactly what happens when the packed game data has no vgui/touch materials. Skip the atlas pass. - touch.cpp cleared an stbrp_rect array using sizeof(stbrp_node), leaving each entry half initialised, and kept a stale touchTextureID after deleting the texture. - An Error() call was missing the argument for its %s. - Only request pointer lock where the browser actually supports it. - repackage.js missed loose materials/vgui/touch/*.vtf, so touch buttons drew with the missing-material texture even when the files were there. Note: not build-tested. No emsdk in this environment, so the C++ changes are unverified by a compiler; the JS/HTML was exercised against a DOM harness covering iPhone, Android, desktop and each failure mode. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Qy5DKdwTqGQqScWvJyNcft --- README.md | 31 +- appframework/sdlmgr.cpp | 26 +- emscripten/README-hosting.md | 121 +++++++ emscripten/_headers | 12 + emscripten/build.sh | 25 +- emscripten/post.js | 48 ++- emscripten/pre.js | 157 +++++++-- emscripten/repackage.js | 14 +- emscripten/serve.py | 88 +++++ emscripten/shell.html | 658 +++++++++++++++++++++++++++++++---- game/client/touch.cpp | 76 ++-- 11 files changed, 1105 insertions(+), 151 deletions(-) create mode 100644 emscripten/README-hosting.md create mode 100644 emscripten/_headers create mode 100755 emscripten/serve.py diff --git a/README.md b/README.md index 3a50ceeabf..af776a32ae 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,30 @@ + 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 + +The page **must** be served cross-origin isolated (`Cross-Origin-Opener-Policy` ++ `Cross-Origin-Embedder-Policy`) over HTTPS or localhost, otherwise there is no +`SharedArrayBuffer` and the threaded build never starts: + +```sh +python3 emscripten/serve.py --dir build/install +# http://localhost:8080/hl2_launcher.html +``` + +## 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,10 +59,14 @@ 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 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..6a2dca75b9 --- /dev/null +++ b/emscripten/README-hosting.md @@ -0,0 +1,121 @@ +# 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 custom headers, so it cannot host this build directly. + +## 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..6c9ada2413 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,6 @@ 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/ +cp emscripten/serve.py build/install/ +cp emscripten/_headers build/install/ 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..6f52e7a672 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,31 @@ 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') + } +} + class DataLoader { mapsOrdered = [ 'background1', @@ -44,26 +73,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 +116,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}. ` + + `Did you put the packed chunks in ./chunks/?`)) + return + } + + if(!xhr.response || xhr.response.byteLength === 0) { + reject(new Error(`cannot load map ${mapName}: empty response`)) + return } - resolve() + 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', `chunks/${mapName}.data`, true) + xhr.send() + } catch(err) { + reject(err) } - xhr.open('GET', `chunks/${mapName}.data`, true) - xhr.send() return promise } @@ -123,8 +200,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..01414d1d1c 100644 --- a/emscripten/shell.html +++ b/emscripten/shell.html @@ -3,112 +3,622 @@ + + + + + + + + + + + + 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 ) From 9529c0162b746f2b527560f74f611b661c389a7e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 02:32:31 +0000 Subject: [PATCH 2/6] emscripten: make the built bundle runnable out of the box The CI artifact was the engine and nothing else, so "download release.zip and open it" could never work: there is no game data in it, and opening the page over file:// or any host that does not send COOP/COEP leaves it without SharedArrayBuffer. - Add fetch_chunks.py, which downloads the packed map chunks into /chunks. It reads the map list out of pre.js rather than keeping a second copy that can drift. - Ship serve.py, _headers, fetch_chunks.py and pre.js inside the bundle so the artifact is self-contained. - Generate an index.html that points at hl2_launcher.html, so opening the bundle root works. - Document the actual path from a push to a running game: download the artifact, fetch the chunks, serve it cross-origin isolated. Call out that GitHub Pages cannot host this, since it cannot set headers. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Qy5DKdwTqGQqScWvJyNcft --- README.md | 41 ++++++++++++-- emscripten/build.sh | 18 ++++++ emscripten/fetch_chunks.py | 113 +++++++++++++++++++++++++++++++++++++ 3 files changed, 167 insertions(+), 5 deletions(-) create mode 100755 emscripten/fetch_chunks.py diff --git a/README.md b/README.md index af776a32ae..edf994026f 100644 --- a/README.md +++ b/README.md @@ -11,15 +11,45 @@ ## running it -The page **must** be served cross-origin isolated (`Cross-Origin-Opener-Policy` -+ `Cross-Origin-Embedder-Policy`) over HTTPS or localhost, otherwise there is no -`SharedArrayBuffer` and the threaded build never starts: +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 -python3 emscripten/serve.py --dir build/install -# http://localhost:8080/hl2_launcher.html +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 not work, and **GitHub Pages cannot host this** -- it cannot set headers. + +```sh +python3 serve.py --dir . +# http://localhost:8080/ +``` + +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 @@ -68,6 +98,7 @@ then download packed game data (yikes.pw/portal/chunks/mapName.data for each map 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/emscripten/build.sh b/emscripten/build.sh index 6c9ada2413..543d29310a 100755 --- a/emscripten/build.sh +++ b/emscripten/build.sh @@ -55,5 +55,23 @@ emcc \ cp build/launcher_main/hl2_launcher.* build/install/ 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 + +# 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/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()) From 8f531865e61d91e25046339d8fa750ac56bf7f45 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 02:50:23 +0000 Subject: [PATCH 3/6] emscripten: support GitHub Pages via a cross-origin isolation service worker GitHub Pages cannot send Cross-Origin-Opener-Policy or Cross-Origin-Embedder-Policy, so it could not host a threaded build at all: no SharedArrayBuffer, no startup. A service worker can add those headers to responses as they come in, which makes the page cross-origin isolated on a host that has no header control. - Add coi-serviceworker.js and load it from shell.html's . The first visit registers it and reloads once; after that the page is isolated. It also sets Cross-Origin-Resource-Policy on what it passes through, so game data served from another origin is not rejected. - Refuse to reload unless the attempt can be recorded in sessionStorage. Reading it throws in Safari private browsing, and a reload that cannot be remembered repeats forever. Both reload paths now go through one guarded helper, and the page reports why it gave up. - Teach the capability gate the difference between "not isolated" and "isolation is one reload away", so a pending service worker shows progress instead of a fatal error, and a failed one says why. - Add a Deploy to GitHub Pages workflow. It builds, optionally pulls the chunks when a CHUNKS_BASE_URL variable is set, and fails with a clear message if the site exceeds the 1GB Pages limit instead of letting the deploy fail on its own. - Make the chunk location configurable with ?chunks= or window.CHUNK_BASE_URL, since GitHub blocks files over 100MB and caps a Pages site at 1GB, which the full Portal data does not fit under. The engine can now be on Pages with the data hosted anywhere. - Drop a .nojekyll into the bundle so Pages does not strip _headers. Verified with a harness covering both halves of the worker: header injection preserves body and content-type, only-if-cached passes through, and every reload path stops after one attempt. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Qy5DKdwTqGQqScWvJyNcft --- .github/workflows/pages.yml | 78 +++++++++++++++ README.md | 18 +++- emscripten/README-hosting.md | 53 +++++++++- emscripten/build.sh | 9 ++ emscripten/coi-serviceworker.js | 171 ++++++++++++++++++++++++++++++++ emscripten/pre.js | 29 +++++- emscripten/shell.html | 33 +++++- 7 files changed, 381 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/pages.yml create mode 100644 emscripten/coi-serviceworker.js 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 edf994026f..60fe10ad83 100644 --- a/README.md +++ b/README.md @@ -34,13 +34,29 @@ Or build the chunks from your own Portal install with `emscripten/repackage.js` 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 not work, and **GitHub Pages cannot host this** -- it cannot set headers. +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. diff --git a/emscripten/README-hosting.md b/emscripten/README-hosting.md index 6a2dca75b9..b912967ea7 100644 --- a/emscripten/README-hosting.md +++ b/emscripten/README-hosting.md @@ -63,7 +63,58 @@ AddType application/wasm .wasm ### GitHub Pages -GitHub Pages cannot set custom headers, so it cannot host this build directly. +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 diff --git a/emscripten/build.sh b/emscripten/build.sh index 543d29310a..eded1c2c85 100755 --- a/emscripten/build.sh +++ b/emscripten/build.sh @@ -64,6 +64,15 @@ 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' 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/pre.js b/emscripten/pre.js index 6f52e7a672..28a73a106a 100644 --- a/emscripten/pre.js +++ b/emscripten/pre.js @@ -37,6 +37,29 @@ if (isMainThread) { } } +// 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', @@ -135,8 +158,8 @@ class DataLoader { // 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}. ` + - `Did you put the packed chunks in ./chunks/?`)) + reject(new Error(`cannot load map ${mapName}: HTTP ${xhr.status} ` + + `from ${chunkBaseUrl()}/${mapName}.data`)) return } @@ -187,7 +210,7 @@ class DataLoader { } try { - xhr.open('GET', `chunks/${mapName}.data`, true) + xhr.open('GET', `${chunkBaseUrl()}/${mapName}.data`, true) xhr.send() } catch(err) { reject(err) diff --git a/emscripten/shell.html b/emscripten/shell.html index 01414d1d1c..1d428026d5 100644 --- a/emscripten/shell.html +++ b/emscripten/shell.html @@ -19,6 +19,12 @@ yikes! + + + +