From 3312e52dad1b621c63065a8b634d7bc57c7c9293 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Tue, 8 Sep 2026 09:42:14 -0400 Subject: [PATCH 001/159] Add iPhone-visible Portal runtime diagnostics --- emscripten/shell.html | 72 +++++++++++++++++++++++++++++++------------ 1 file changed, 52 insertions(+), 20 deletions(-) diff --git a/emscripten/shell.html b/emscripten/shell.html index 9ed3bcca24..9b7034c744 100644 --- a/emscripten/shell.html +++ b/emscripten/shell.html @@ -3,7 +3,7 @@ - yikes! + Render360 Portal upstream baseline + + +
+

Portal upstream baseline

+

Isolated iPhone test lane for the original threaded Source/Emscripten runtime. This page does not modify the engine and does not ship Portal game content.

+ +
+
Secure contextchecking…
+
Service workerchecking…
+
crossOriginIsolatedchecking…
+
SharedArrayBufferchecking…
+
Wasm shared memorychecking…
+
OffscreenCanvaschecking…
+
WebGL2checking…
+
Runtime fileschecking…
+
Local background chunkchecking…
+
+ +
+ Local Portal test data +

For the full Source startup test, import .data chunk files generated from your own Portal installation. They are stored only in this browser's Cache Storage and are never uploaded to GitHub.

+
+ + + +
+
+
+ +
+ Run +

Checking whether the threaded runtime can start…

+
+ + +
+
+ +
+ Device log +

+  
+
+ + + From 67b21bcef315b86a36d1278bb7fef5f58af688ce Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Tue, 8 Sep 2026 09:44:57 -0400 Subject: [PATCH 005/159] Build and deploy Portal iPhone baseline to Pages --- .github/workflows/build.yml | 140 +++++++++++++++++++++++++++++++----- 1 file changed, 124 insertions(+), 16 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9be0fc8165..c2b333fba9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,23 +1,131 @@ -name: Build +name: Render360 Portal iPhone Baseline -on: [push, pull_request] +on: + workflow_dispatch: + push: + branches: + - render360/iphone-baseline + pull_request: + branches: + - master + +permissions: + contents: read + +concurrency: + group: portal-iphone-baseline-${{ github.ref }} + cancel-in-progress: true jobs: build-wasm: + name: Build upstream threaded Portal runtime + runs-on: ubuntu-latest + timeout-minutes: 90 + + steps: + - name: Checkout Source fork + uses: actions/checkout@v6 + with: + submodules: recursive + fetch-depth: 1 + + - name: Verify upstream baseline architecture + shell: bash + run: | + set -euo pipefail + test -f emscripten/build.sh + grep -q -- '-sSHARED_MEMORY=1' emscripten/build.sh + grep -q -- '-sUSE_PTHREADS' emscripten/build.sh + grep -q -- '-sPTHREAD_POOL_SIZE=8' emscripten/build.sh + grep -q -- '-sPROXY_TO_PTHREAD' emscripten/build.sh + grep -q -- '-sOFFSCREENCANVASES_TO_PTHREAD' emscripten/build.sh + grep -q -- '-sMAIN_MODULE' emscripten/build.sh + grep -q 'Atomics.store' emscripten/pre.js + grep -q 'Portal JS exception' emscripten/shell.html + + - name: Install pinned upstream Emscripten environment + shell: bash + run: | + set -euo pipefail + source emscripten/get_emscripten.sh + emcc -v + echo "EMSDK=$EMSDK" >> "$GITHUB_ENV" + echo "$EMSDK" > "$RUNNER_TEMP/render360-emsdk-path.txt" + + - name: Build upstream release + shell: bash + run: | + set -euo pipefail + source emsdk/emsdk_env.sh + bash emscripten/build.sh release + + - name: Validate runtime and prepare staging site + shell: bash + run: | + set -euo pipefail + + test -s build/install/hl2_launcher.html + test -s build/install/hl2_launcher.js + test -s build/install/hl2_launcher.wasm + grep -q 'Portal JS exception' build/install/hl2_launcher.html + + # Keep public CI runtime-only. Portal retail data must be supplied + # locally by the tester and must never be committed to this repo. + if find build/install -type f -path '*/chunks/*.data' -print -quit | grep -q .; then + echo 'Refusing to publish bundled Portal .data chunks.' >&2 + exit 1 + fi + + cp emscripten/pages-index.html build/install/index.html + cp emscripten/render360-pages-sw.js build/install/render360-pages-sw.js + touch build/install/.nojekyll + + cat > build/install/render360-baseline.json < Date: Tue, 8 Sep 2026 09:51:50 -0400 Subject: [PATCH 006/159] Fix Pages permissions for iPhone baseline staging --- .github/workflows/build.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c2b333fba9..244d9a5210 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,8 +9,12 @@ on: branches: - master +# GitHub Pages deployment requires these permissions. Keeping them at the +# workflow level also lets actions/configure-pages inspect the Pages site. permissions: contents: read + pages: write + id-token: write concurrency: group: portal-iphone-baseline-${{ github.ref }} @@ -118,9 +122,6 @@ jobs: if: github.event_name != 'pull_request' needs: build-wasm runs-on: ubuntu-latest - permissions: - pages: write - id-token: write environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} From 4888f71ce86a760ae0aa0c4f8c416d48c7c1a32e Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Tue, 8 Sep 2026 09:57:23 -0400 Subject: [PATCH 007/159] Separate Portal ownership verification from runtime chunks --- emscripten/pages-index.html | 183 +++++++++++++++++++++++++++--------- 1 file changed, 140 insertions(+), 43 deletions(-) diff --git a/emscripten/pages-index.html b/emscripten/pages-index.html index acff04ebe2..481eb28c50 100644 --- a/emscripten/pages-index.html +++ b/emscripten/pages-index.html @@ -26,12 +26,14 @@ pre { white-space: pre-wrap; word-break: break-word; max-height: 260px; overflow: auto; background: #07090b; border-radius: 12px; padding: 12px; color: #c9d3df; font-size: 12px; } code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } .small { color: #95a1b0; font-size: 13px; line-height: 1.45; } + details { margin-top: 14px; } + summary { cursor: pointer; color: #cbd5e1; font-weight: 650; }

Portal upstream baseline

-

Isolated iPhone test lane for the original threaded Source/Emscripten runtime. This page does not modify the engine and does not ship Portal game content.

+

Isolated iPhone test lane for the original threaded Source/Emscripten runtime. Ownership verification and runtime game-data delivery are deliberately separate.

Secure contextchecking…
@@ -42,18 +44,34 @@

Portal upstream baseline

OffscreenCanvaschecking…
WebGL2checking…
Runtime fileschecking…
-
Local background chunkchecking…
+
Portal ownership proofnot verified
+
Runtime chunk sourcechecking…
- Local Portal test data -

For the full Source startup test, import .data chunk files generated from your own Portal installation. They are stored only in this browser's Cache Storage and are never uploaded to GitHub.

+ Verify your Portal copy +

Choose your Portal game ZIP, or the identifying Portal files from your own installation. The browser inspects filenames locally to confirm the expected Portal layout. The selected game is not uploaded, cached, mounted, or used as the runtime filesystem.

- - - + + +
-
+
+
+ +
+ Runtime data +

After verification, Source still reads its normal chunks/<map>.data runtime source. The uploaded Portal copy above is not substituted for those chunks.

+
+ Developer fallback: local test chunks +

For private testing only, you can cache .data chunks generated from your own Portal installation. They stay on this device and are never uploaded to GitHub.

+
+ + + +
+
+
@@ -75,6 +93,7 @@

Portal upstream baseline

'use strict'; const CACHE_NAME = 'render360-portal-local-chunks-v1'; + const OWNERSHIP_KEY = 'render360PortalOwnershipVerified'; const $ = id => document.getElementById(id); const logLines = []; const log = (...args) => { @@ -95,12 +114,10 @@

Portal upstream baseline

set('sw', false, 'unsupported'); return; } - try { await navigator.serviceWorker.register('./render360-pages-sw.js', { scope: './' }); await navigator.serviceWorker.ready; set('sw', true, navigator.serviceWorker.controller ? 'controlling' : 'registered'); - if (!navigator.serviceWorker.controller) { const already = sessionStorage.getItem('render360PagesReloaded'); if (!already) { @@ -117,21 +134,19 @@

Portal upstream baseline

} function testSharedMemory() { - let ok = false; try { const memory = new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true }); - ok = !!memory.buffer && Object.prototype.toString.call(memory.buffer).includes('SharedArrayBuffer'); + return !!memory.buffer && Object.prototype.toString.call(memory.buffer).includes('SharedArrayBuffer'); } catch (error) { log('Wasm shared-memory test:', String(error)); + return false; } - return ok; } function testWebGL2() { try { const canvas = document.createElement('canvas'); - const gl = canvas.getContext('webgl2', { antialias: false }); - return !!gl; + return !!canvas.getContext('webgl2', { antialias: false }); } catch (_) { return false; } @@ -146,6 +161,66 @@

Portal upstream baseline

} } + function ownershipVerified() { + return sessionStorage.getItem(OWNERSHIP_KEY) === '1'; + } + + function normalizePath(name) { + return String(name || '').replace(/\\/g, '/').toLowerCase(); + } + + function looksLikePortalLayout(names) { + const normalized = names.map(normalizePath); + const hasGameInfo = normalized.some(x => x.endsWith('/portal/gameinfo.txt') || x === 'portal/gameinfo.txt' || x === 'gameinfo.txt'); + const hasPortalVpk = normalized.some(x => /(^|\/)portal\/[^/]*_dir\.vpk$/.test(x) || /(^|\/)portal_pak_dir\.vpk$/.test(x)); + const hasHl2Vpk = normalized.some(x => /(^|\/)hl2\/[^/]*_dir\.vpk$/.test(x) || /(^|\/)hl2_[^/]*_dir\.vpk$/.test(x)); + return { ok: hasGameInfo && hasPortalVpk, hasGameInfo, hasPortalVpk, hasHl2Vpk }; + } + + async function zipEntryNames(file) { + const tailSize = Math.min(file.size, 131072); + const tailOffset = file.size - tailSize; + const tail = new Uint8Array(await file.slice(tailOffset).arrayBuffer()); + let eocd = -1; + for (let i = tail.length - 22; i >= 0; i--) { + if (tail[i] === 0x50 && tail[i + 1] === 0x4b && tail[i + 2] === 0x05 && tail[i + 3] === 0x06) { eocd = i; break; } + } + if (eocd < 0) throw new Error('ZIP directory footer not found'); + const view = new DataView(tail.buffer, tail.byteOffset, tail.byteLength); + const entries = view.getUint16(eocd + 10, true); + const cdSize = view.getUint32(eocd + 12, true); + const cdOffset = view.getUint32(eocd + 16, true); + if (entries === 0xffff || cdSize === 0xffffffff || cdOffset === 0xffffffff) throw new Error('ZIP64 verification is not supported by this lightweight checker'); + const cd = new Uint8Array(await file.slice(cdOffset, cdOffset + cdSize).arrayBuffer()); + const dv = new DataView(cd.buffer, cd.byteOffset, cd.byteLength); + const decoder = new TextDecoder(); + const names = []; + let p = 0; + for (let n = 0; n < entries && p + 46 <= cd.length; n++) { + if (dv.getUint32(p, true) !== 0x02014b50) break; + const nameLen = dv.getUint16(p + 28, true); + const extraLen = dv.getUint16(p + 30, true); + const commentLen = dv.getUint16(p + 32, true); + names.push(decoder.decode(cd.subarray(p + 46, p + 46 + nameLen))); + p += 46 + nameLen + extraLen + commentLen; + } + return names; + } + + async function verifySelectedPortalFiles(files) { + const names = []; + for (const file of files) { + if (/\.zip$/i.test(file.name)) { + const zipNames = await zipEntryNames(file); + names.push(...zipNames); + log('Inspected Portal ZIP locally:', file.name, zipNames.length + ' entries'); + } else { + names.push(file.webkitRelativePath || file.name); + } + } + return looksLikePortalLayout(names); + } + async function hasLocalBackgroundChunk() { if (!('caches' in window)) return false; const cache = await caches.open(CACHE_NAME); @@ -158,12 +233,14 @@

Portal upstream baseline

const wasmThreads = testSharedMemory(); const offscreen = typeof OffscreenCanvas === 'function'; const webgl2 = testWebGL2(); + const verified = ownershipVerified(); set('coi', coi, String(coi)); set('sab', sab, sab ? 'available' : 'missing'); set('wasmThreads', wasmThreads, wasmThreads ? 'available' : 'unavailable'); set('offscreen', offscreen, offscreen ? 'available' : 'missing', !offscreen); set('webgl2', webgl2, webgl2 ? 'available' : 'missing'); + set('ownership', verified, verified ? 'verified this session' : 'not verified', !verified); const runtimeChecks = await Promise.all([ exists('./hl2_launcher.html'), @@ -174,34 +251,59 @@

Portal upstream baseline

set('runtime', runtimeReady, runtimeReady ? 'launcher + JS + Wasm' : 'incomplete'); const localChunk = await hasLocalBackgroundChunk(); - let networkChunk = false; - if (!localChunk) networkChunk = await exists('./chunks/background1.data'); - const chunkReady = localChunk || networkChunk; - set('chunk', chunkReady, localChunk ? 'local cache' : networkChunk ? 'hosted' : 'not present', !chunkReady); + let hostedChunk = false; + if (!localChunk) hostedChunk = await exists('./chunks/background1.data'); + const chunkReady = localChunk || hostedChunk; + set('chunk', chunkReady, localChunk ? 'local developer cache' : hostedChunk ? 'hosted chunks/ path' : 'not present', !chunkReady); const threadReady = coi && sab && wasmThreads; - $('launch').disabled = !(threadReady && runtimeReady); - $('launchHint').textContent = threadReady && runtimeReady - ? (chunkReady - ? 'Threading prerequisites and game-data entry chunk are present. Launch the diagnostic runtime.' - : 'Threading prerequisites are ready. You can launch, but Source will stop at game-data loading until your background1.data chunk is imported.') - : 'Do not merge into Render360 yet. The Pages staging environment must first report crossOriginIsolated, SharedArrayBuffer, Wasm shared memory and runtime files as ready.'; + $('launch').disabled = !(threadReady && runtimeReady && verified); + if (!verified) { + $('launchHint').textContent = 'Verify your Portal copy locally first. The selected game is used only for this check and is not mounted into Source.'; + } else if (!threadReady || !runtimeReady) { + $('launchHint').textContent = 'Ownership is verified, but the Pages threading/runtime prerequisites are not ready yet.'; + } else if (chunkReady) { + $('launchHint').textContent = 'Ownership verified. Source will launch using its normal chunks/ runtime data path.'; + } else { + $('launchHint').textContent = 'Ownership verified and runtime is ready, but no runtime chunk source is present on this staging deployment.'; + } log('User agent:', navigator.userAgent); - log('crossOriginIsolated=', coi, 'SharedArrayBuffer=', sab, 'WasmThreads=', wasmThreads, 'OffscreenCanvas=', offscreen, 'WebGL2=', webgl2); + log('crossOriginIsolated=', coi, 'SharedArrayBuffer=', sab, 'WasmThreads=', wasmThreads, 'OffscreenCanvas=', offscreen, 'WebGL2=', webgl2, 'Ownership=', verified); + } - if (navigator.storage && navigator.storage.estimate) { - try { - const estimate = await navigator.storage.estimate(); - log('Storage estimate:', estimate); - } catch (_) {} + $('ownershipFiles').addEventListener('change', async event => { + const files = Array.from(event.target.files || []); + if (!files.length) return; + try { + const result = await verifySelectedPortalFiles(files); + if (result.ok) { + sessionStorage.setItem(OWNERSHIP_KEY, '1'); + $('ownershipStatus').textContent = 'Portal layout verified locally. No selected game data was uploaded, cached, or mounted.'; + log('Ownership check passed:', result); + } else { + sessionStorage.removeItem(OWNERSHIP_KEY); + $('ownershipStatus').textContent = 'Could not verify the expected Portal layout. Select a Portal ZIP containing portal/gameinfo.txt and Portal VPK directory metadata, or select those identifying files directly.'; + log('Ownership check failed:', result); + } + } catch (error) { + sessionStorage.removeItem(OWNERSHIP_KEY); + $('ownershipStatus').textContent = 'Could not inspect that file locally: ' + String(error.message || error); + log('Ownership verifier error:', error && error.stack ? error.stack : String(error)); } - } + event.target.value = ''; + await refresh(); + }); + + $('clearOwnership').addEventListener('click', async () => { + sessionStorage.removeItem(OWNERSHIP_KEY); + $('ownershipStatus').textContent = 'Verification cleared.'; + await refresh(); + }); $('chunkFiles').addEventListener('change', async event => { const files = Array.from(event.target.files || []); if (!files.length) return; - const cache = await caches.open(CACHE_NAME); let stored = 0; let bytes = 0; @@ -212,28 +314,23 @@

Portal upstream baseline

continue; } const url = new URL('./chunks/' + name, location.href).href; - await cache.put(new Request(url), new Response(file, { - headers: { 'Content-Type': 'application/octet-stream' } - })); + await cache.put(new Request(url), new Response(file, { headers: { 'Content-Type': 'application/octet-stream' } })); stored++; bytes += file.size; - log('Stored local chunk:', name, file.size + ' bytes'); + log('Stored local developer chunk:', name, file.size + ' bytes'); } - $('importStatus').textContent = `Stored ${stored} chunk file(s), ${(bytes / 1048576).toFixed(1)} MiB, locally on this device.`; + $('importStatus').textContent = `Stored ${stored} local test chunk file(s), ${(bytes / 1048576).toFixed(1)} MiB, on this device.`; event.target.value = ''; await refresh(); }); $('clearChunks').addEventListener('click', async () => { await caches.delete(CACHE_NAME); - $('importStatus').textContent = 'Local Portal chunks cleared from this browser.'; + $('importStatus').textContent = 'Local developer chunks cleared from this browser.'; await refresh(); }); - $('launch').addEventListener('click', () => { - location.href = './hl2_launcher.html'; - }); - + $('launch').addEventListener('click', () => { location.href = './hl2_launcher.html'; }); $('reload').addEventListener('click', () => { sessionStorage.removeItem('render360PagesReloaded'); location.reload(); From 4dd2e99bd0ddfe8bcc25254d42957cf6d7519043 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Tue, 8 Sep 2026 10:21:55 -0400 Subject: [PATCH 008/159] Use dedicated Pages environment for iPhone staging --- .github/workflows/build.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 244d9a5210..0d458c02cd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -122,8 +122,10 @@ jobs: if: github.event_name != 'pull_request' needs: build-wasm runs-on: ubuntu-latest + # Keep production github-pages protection intact. This separate environment + # is only for the isolated iPhone baseline branch. environment: - name: github-pages + name: portal-iphone-staging url: ${{ steps.deployment.outputs.page_url }} steps: From 39d01699475d3241590dc2467150512ee6697679 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Tue, 8 Sep 2026 10:46:30 -0400 Subject: [PATCH 009/159] Support direct Portal folder verification on iPhone --- emscripten/pages-index.html | 361 +++++++++--------------------------- 1 file changed, 92 insertions(+), 269 deletions(-) diff --git a/emscripten/pages-index.html b/emscripten/pages-index.html index 481eb28c50..664f2d99e0 100644 --- a/emscripten/pages-index.html +++ b/emscripten/pages-index.html @@ -6,34 +6,24 @@ Render360 · Portal upstream iPhone baseline

Portal upstream baseline

-

Isolated iPhone test lane for the original threaded Source/Emscripten runtime. Ownership verification and runtime game-data delivery are deliberately separate.

+

iPhone test lane for the original threaded Source/Emscripten runtime. Your Portal folder is used only to verify the expected game layout; runtime data remains separate.

Secure contextchecking…
@@ -50,9 +40,11 @@

Portal upstream baseline

Verify your Portal copy -

Choose your Portal game ZIP, or the identifying Portal files from your own installation. The browser inspects filenames locally to confirm the expected Portal layout. The selected game is not uploaded, cached, mounted, or used as the runtime filesystem.

+

You do not need a ZIP. Choose the folder that contains your Portal game files. Safari inspects only filenames and relative paths locally. Nothing from this folder is uploaded, cached as game data, mounted, or used by Source.

- + + +
@@ -61,10 +53,10 @@

Portal upstream baseline

Runtime data -

After verification, Source still reads its normal chunks/<map>.data runtime source. The uploaded Portal copy above is not substituted for those chunks.

+

After verification, Source still reads its normal chunks/<map>.data path. Your selected Portal folder is not substituted for the runtime chunks.

Developer fallback: local test chunks -

For private testing only, you can cache .data chunks generated from your own Portal installation. They stay on this device and are never uploaded to GitHub.

+

Private testing only. These .data files stay in this browser's cache and are not uploaded.

@@ -83,266 +75,97 @@

Portal upstream baseline

-
- Device log -

-  
+
Device log
From 198527ea90f5f6f2aee977db898fb481af5851d6 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Tue, 8 Sep 2026 11:12:59 -0400 Subject: [PATCH 010/159] Harden Portal chunk loader diagnostics --- emscripten/pre.js | 152 +++++++++++++++++++++++++++++++++------------- 1 file changed, 109 insertions(+), 43 deletions(-) diff --git a/emscripten/pre.js b/emscripten/pre.js index 52aff49088..6245a1f984 100644 --- a/emscripten/pre.js +++ b/emscripten/pre.js @@ -30,6 +30,11 @@ class DataLoader { loadedMaps = {} + chunkUrl(mapName) { + const base = String(Module['portalChunkBaseUrl'] || 'chunks/').replace(/\/?$/, '/') + return new URL(base + mapName + '.data', location.href).href + } + async loadMapWithDeps(mapName) { const index = this.mapsOrdered.indexOf(mapName) if(index === -1) { @@ -44,7 +49,9 @@ class DataLoader { // schedule next map if it exists const next = this.mapsOrdered[index + 1] if(next) { - this.loadMapCached(next) + this.loadMapCached(next).catch(error => { + console.error('[Render360 background chunk preload failed]', error) + }) } } @@ -60,7 +67,7 @@ class DataLoader { spinnerElement.style.display = '' statusElement.innerText = `Downloading map ${mapName}` progressElement.hidden = false - progressElement.value = progress + progressElement.value = Math.max(0, Math.min(1, Number.isFinite(progress) ? progress : 0)) } else { spinnerElement.style.display = 'none' statusElement.innerText = '' @@ -68,55 +75,109 @@ class DataLoader { } } - async loadMap(mapName) { - this.setProgress(mapName, 0) + parsePackedChunk(mapName, url, buffer) { + if(!(buffer instanceof ArrayBuffer)) { + throw new Error(`chunk ${mapName} did not return an ArrayBuffer`) + } + if(buffer.byteLength < 8) { + throw new Error(`chunk ${mapName} is too small (${buffer.byteLength} bytes)`) + } + + const firstBytes = new Uint8Array(buffer, 0, Math.min(buffer.byteLength, 96)) + const firstText = new TextDecoder().decode(firstBytes) + if(/^\s* 65536) { + throw new Error(`invalid path length ${pathLen} at byte ${offset}`) + } + + const pathStart = offset + 8 + const dataStart = pathStart + pathLen + const end = dataStart + dataLen + if(dataStart > dv.byteLength || end > dv.byteLength) { + throw new Error(`packed entry ${entries} exceeds chunk bounds (offset=${offset}, pathLen=${pathLen}, dataLen=${dataLen}, chunkBytes=${dv.byteLength})`) + } + + const path = decoder.decode(new Uint8Array(buffer, pathStart, pathLen)) + if(!path.startsWith('/') || path.includes('\0')) { + throw new Error(`invalid packed path at entry ${entries}: ${JSON.stringify(path.slice(0, 120))}`) + } - let resolve, reject - const promise = new Promise((res, rej) => { resolve = res; reject = rej }) + const blob = new Uint8Array(buffer, dataStart, dataLen) + const dir = path.replace(/\/[^\/]+$/, '') + if(dir) FS.mkdirTree(dir) + FS.writeFile(path, blob) - const xhr = new XMLHttpRequest() - xhr.responseType = 'arraybuffer' - xhr.onprogress = e => { - this.setProgress(mapName, e.loaded / e.total) + offset = end + entries++ } - xhr.onerror = () => { - reject(new Error(`cannot load map ${mapName}`)) + if(entries === 0) { + throw new Error(`chunk ${mapName} contained no packed files`) } + return entries + } + + async loadMap(mapName) { + this.setProgress(mapName, 0) + + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + const url = this.chunkUrl(mapName) + xhr.responseType = 'arraybuffer' + xhr.timeout = 60000 - 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) + xhr.onprogress = e => { + if(e.lengthComputable && e.total > 0) { + this.setProgress(mapName, e.loaded / e.total) + } } - resolve() - } - xhr.open('GET', `chunks/${mapName}.data`, true) - xhr.send() + xhr.onerror = () => { + reject(new Error(`network error while loading Portal chunk ${mapName} from ${url}`)) + } + xhr.onabort = () => { + reject(new Error(`Portal chunk request aborted for ${mapName}`)) + } + xhr.ontimeout = () => { + reject(new Error(`timed out loading Portal chunk ${mapName} from ${url}`)) + } - return promise + xhr.onload = () => { + try { + if(xhr.status < 200 || xhr.status >= 300) { + throw new Error(`HTTP ${xhr.status} ${xhr.statusText || ''} loading ${url}`.trim()) + } + const entries = this.parsePackedChunk(mapName, url, xhr.response) + this.setProgress(mapName, 1) + console.log(`[Render360 chunk] loaded ${mapName}: ${entries} files, ${xhr.response.byteLength} bytes`) + resolve() + } catch(error) { + this.setProgress(mapName, 1) + reject(new Error(`Portal chunk ${mapName} is missing or invalid: ${error && error.message ? error.message : String(error)}`)) + } + } + + console.log('[Render360 chunk] GET', url) + xhr.open('GET', url, true) + xhr.send() + }) } } @@ -126,5 +187,10 @@ Module.downloadMap = (lock, mapName) => { dataLoader.loadMapWithDeps(mapName).then(() => { Atomics.store(HEAP32, lock, 0) Atomics.notify(HEAP32, lock) + }).catch(error => { + console.error('[Render360 map download failure]', mapName, error && error.stack ? error.stack : error) + // Never leave Source permanently blocked on a failed browser-side map request. + Atomics.store(HEAP32, lock, 0) + Atomics.notify(HEAP32, lock) }) -} \ No newline at end of file +} From 341f49a4a1fc56b4232b3874ea1da17f96df06dd Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Tue, 8 Sep 2026 11:13:08 -0400 Subject: [PATCH 011/159] Export fullscreen runtime method for staging shell --- emscripten/build.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/emscripten/build.sh b/emscripten/build.sh index 8054ae61cf..66ad9297a8 100755 --- a/emscripten/build.sh +++ b/emscripten/build.sh @@ -27,6 +27,7 @@ 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 \ -sFULL_ES3 -sSTACK_SIZE=4mb --shell-file=emscripten/shell.html \ + -sEXPORTED_RUNTIME_METHODS=requestFullscreen \ -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 +36,4 @@ 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/ From deac14c1e60bdb0b8de273a17f8ebf196a0e281c Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Tue, 8 Sep 2026 11:13:34 -0400 Subject: [PATCH 012/159] Fix staging fullscreen control --- emscripten/shell.html | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/emscripten/shell.html b/emscripten/shell.html index 9b7034c744..ad7536d5c3 100644 --- a/emscripten/shell.html +++ b/emscripten/shell.html @@ -23,9 +23,7 @@
Downloading...
- - +
@@ -57,6 +55,29 @@ return detail; } + function render360RequestFullscreen() { + try { + if (Module && typeof Module.requestFullscreen === 'function') { + // Touch-first iPhone staging: no pointer lock, resize the canvas. + Module.requestFullscreen(false, true); + return; + } + var request = canvasElement.requestFullscreen || canvasElement.webkitRequestFullscreen; + if (request) { + var result = request.call(canvasElement); + if (result && typeof result.catch === 'function') { + result.catch(function(error) { + render360Report('fullscreen failure', error && error.message ? error.message : String(error), error); + }); + } + return; + } + render360Report('fullscreen unavailable', 'This Safari build does not expose element fullscreen here. The Portal canvas can still run inline.'); + } catch (error) { + render360Report('fullscreen failure', error && error.message ? error.message : String(error), error); + } + } + // Keep the context recoverable, but expose the actual WebGL lifecycle instead // of stopping at an alert on iPhone Safari. canvasElement.addEventListener('webglcontextlost', (e) => { From 80d4474e28c3ecde5a579529f04c070d79afe701 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Tue, 8 Sep 2026 11:13:43 -0400 Subject: [PATCH 013/159] Abort cleanly when Portal runtime data is unavailable --- emscripten/post.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/emscripten/post.js b/emscripten/post.js index a02a90f082..2c233890a3 100644 --- a/emscripten/post.js +++ b/emscripten/post.js @@ -13,9 +13,17 @@ dataLoader.loadMapWithDeps('background1').then(x => { removeRunDependency('load_game_data') }).catch(error => { + const message = error && error.message ? error.message : String(error) console.error('[Render360 game-data load failure]', error && error.stack ? error.stack : error) if (typeof render360Report === 'function') { - render360Report('game-data load failure', error && error.message ? error.message : String(error), error) + render360Report('game-data load failure', message, error) + } + // Do not leave Emscripten printing "still waiting on run dependencies" + // forever after a missing/invalid chunk. Abort the staging runtime with the + // real cause instead of allowing Source to start with an incomplete FS. + if (typeof abort === 'function') { + abort('Portal game-data load failure: ' + message) + return } throw error }) From fb51b08fcface2adb725638b4b83869e37a1b1ba Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Tue, 8 Sep 2026 11:15:03 -0400 Subject: [PATCH 014/159] Block launch when Portal runtime chunk is missing --- emscripten/pages-index.html | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/emscripten/pages-index.html b/emscripten/pages-index.html index 664f2d99e0..fbce52f186 100644 --- a/emscripten/pages-index.html +++ b/emscripten/pages-index.html @@ -53,7 +53,7 @@

Portal upstream baseline

Runtime data -

After verification, Source still reads its normal chunks/<map>.data path. Your selected Portal folder is not substituted for the runtime chunks.

+

After verification, Source still reads its normal chunks/<map>.data path. Your selected Portal folder is not substituted for the runtime chunks. Launch stays disabled until background1.data is actually available, preventing the old out-of-range DataView crash on a missing/404 chunk.

Developer fallback: local test chunks

Private testing only. These .data files stay in this browser's cache and are not uploaded.

@@ -150,12 +150,12 @@

Portal upstream baseline

set('coi',coi,String(coi));set('sab',sab,sab?'available':'missing');set('wasmThreads',wasmThreads,wasmThreads?'available':'unavailable');set('offscreen',offscreen,offscreen?'available':'missing',!offscreen);set('webgl2',webgl2,webgl2?'available':'missing');set('ownership',verified,verified?'verified this session':'not verified',!verified); const runtimeChecks=await Promise.all([exists('./hl2_launcher.html'),exists('./hl2_launcher.js'),exists('./hl2_launcher.wasm')]),runtimeReady=runtimeChecks.every(Boolean);set('runtime',runtimeReady,runtimeReady?'launcher + JS + Wasm':'incomplete'); const localChunk=await hasLocalBackgroundChunk();let hostedChunk=false;if(!localChunk)hostedChunk=await exists('./chunks/background1.data');const chunkReady=localChunk||hostedChunk;set('chunk',chunkReady,localChunk?'local developer cache':hostedChunk?'hosted chunks/ path':'not present',!chunkReady); - const threadReady=coi&&sab&&wasmThreads;$('launch').disabled=!(threadReady&&runtimeReady&&verified); + const threadReady=coi&&sab&&wasmThreads;$('launch').disabled=!(threadReady&&runtimeReady&&verified&&chunkReady); if(!verified)$('launchHint').textContent='Choose your Portal game folder to verify it locally first.'; else if(!threadReady||!runtimeReady)$('launchHint').textContent='Portal is verified, but the Pages threading/runtime prerequisites are not ready yet.'; - else if(chunkReady)$('launchHint').textContent='Portal verified. Source will launch using its normal chunks/ runtime data path.'; - else $('launchHint').textContent='Portal verified and runtime is ready, but no runtime chunk source is present on this staging deployment.'; - log('crossOriginIsolated=',coi,'SharedArrayBuffer=',sab,'WasmThreads=',wasmThreads,'OffscreenCanvas=',offscreen,'WebGL2=',webgl2,'Ownership=',verified); + else if(!chunkReady)$('launchHint').textContent='Portal is verified, but background1.data is not available. The previous RangeError came from trying to parse a missing/404 chunk as packed game data. Add an authorized chunks/ source or import locally generated test chunks before launch.'; + else $('launchHint').textContent='Portal verified. Runtime data is present; Source can launch using its normal chunks/ path.'; + log('crossOriginIsolated=',coi,'SharedArrayBuffer=',sab,'WasmThreads=',wasmThreads,'OffscreenCanvas=',offscreen,'WebGL2=',webgl2,'Ownership=',verified,'ChunkReady=',chunkReady); } $('ownershipFolder').addEventListener('change',event=>handleOwnershipSelection(event,'folder')); From 2ce45ea2f20644226d9235b51ddf3d639cb6af77 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Tue, 8 Sep 2026 11:27:06 -0400 Subject: [PATCH 015/159] Restore upstream Portal loader and use original chunk host --- emscripten/pre.js | 150 +++++++++++++--------------------------------- 1 file changed, 42 insertions(+), 108 deletions(-) diff --git a/emscripten/pre.js b/emscripten/pre.js index 6245a1f984..782b9e7d8a 100644 --- a/emscripten/pre.js +++ b/emscripten/pre.js @@ -30,11 +30,6 @@ class DataLoader { loadedMaps = {} - chunkUrl(mapName) { - const base = String(Module['portalChunkBaseUrl'] || 'chunks/').replace(/\/?$/, '/') - return new URL(base + mapName + '.data', location.href).href - } - async loadMapWithDeps(mapName) { const index = this.mapsOrdered.indexOf(mapName) if(index === -1) { @@ -49,9 +44,7 @@ class DataLoader { // schedule next map if it exists const next = this.mapsOrdered[index + 1] if(next) { - this.loadMapCached(next).catch(error => { - console.error('[Render360 background chunk preload failed]', error) - }) + this.loadMapCached(next) } } @@ -67,7 +60,7 @@ class DataLoader { spinnerElement.style.display = '' statusElement.innerText = `Downloading map ${mapName}` progressElement.hidden = false - progressElement.value = Math.max(0, Math.min(1, Number.isFinite(progress) ? progress : 0)) + progressElement.value = progress } else { spinnerElement.style.display = 'none' statusElement.innerText = '' @@ -75,109 +68,55 @@ class DataLoader { } } - parsePackedChunk(mapName, url, buffer) { - if(!(buffer instanceof ArrayBuffer)) { - throw new Error(`chunk ${mapName} did not return an ArrayBuffer`) - } - if(buffer.byteLength < 8) { - throw new Error(`chunk ${mapName} is too small (${buffer.byteLength} bytes)`) - } - - const firstBytes = new Uint8Array(buffer, 0, Math.min(buffer.byteLength, 96)) - const firstText = new TextDecoder().decode(firstBytes) - if(/^\s* 65536) { - throw new Error(`invalid path length ${pathLen} at byte ${offset}`) - } - - const pathStart = offset + 8 - const dataStart = pathStart + pathLen - const end = dataStart + dataLen - if(dataStart > dv.byteLength || end > dv.byteLength) { - throw new Error(`packed entry ${entries} exceeds chunk bounds (offset=${offset}, pathLen=${pathLen}, dataLen=${dataLen}, chunkBytes=${dv.byteLength})`) - } - - const path = decoder.decode(new Uint8Array(buffer, pathStart, pathLen)) - if(!path.startsWith('/') || path.includes('\0')) { - throw new Error(`invalid packed path at entry ${entries}: ${JSON.stringify(path.slice(0, 120))}`) - } + async loadMap(mapName) { + this.setProgress(mapName, 0) - const blob = new Uint8Array(buffer, dataStart, dataLen) - const dir = path.replace(/\/[^\/]+$/, '') - if(dir) FS.mkdirTree(dir) - FS.writeFile(path, blob) + let resolve, reject + const promise = new Promise((res, rej) => { resolve = res; reject = rej }) - offset = end - entries++ + const xhr = new XMLHttpRequest() + xhr.responseType = 'arraybuffer' + xhr.onprogress = e => { + this.setProgress(mapName, e.loaded / e.total) } - if(entries === 0) { - throw new Error(`chunk ${mapName} contained no packed files`) + xhr.onerror = () => { + reject(new Error(`cannot load map ${mapName}`)) } - return entries - } - - async loadMap(mapName) { - this.setProgress(mapName, 0) - - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest() - const url = this.chunkUrl(mapName) - xhr.responseType = 'arraybuffer' - xhr.timeout = 60000 - xhr.onprogress = e => { - if(e.lengthComputable && e.total > 0) { - this.setProgress(mapName, e.loaded / e.total) - } + 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) } - xhr.onerror = () => { - reject(new Error(`network error while loading Portal chunk ${mapName} from ${url}`)) - } - xhr.onabort = () => { - reject(new Error(`Portal chunk request aborted for ${mapName}`)) - } - xhr.ontimeout = () => { - reject(new Error(`timed out loading Portal chunk ${mapName} from ${url}`)) - } - - xhr.onload = () => { - try { - if(xhr.status < 200 || xhr.status >= 300) { - throw new Error(`HTTP ${xhr.status} ${xhr.statusText || ''} loading ${url}`.trim()) - } - const entries = this.parsePackedChunk(mapName, url, xhr.response) - this.setProgress(mapName, 1) - console.log(`[Render360 chunk] loaded ${mapName}: ${entries} files, ${xhr.response.byteLength} bytes`) - resolve() - } catch(error) { - this.setProgress(mapName, 1) - reject(new Error(`Portal chunk ${mapName} is missing or invalid: ${error && error.message ? error.message : String(error)}`)) - } - } + resolve() + } + xhr.open('GET', `https://yikes.pw/portal/chunks/${mapName}.data`, true) + xhr.send() - console.log('[Render360 chunk] GET', url) - xhr.open('GET', url, true) - xhr.send() - }) + return promise } } @@ -187,10 +126,5 @@ Module.downloadMap = (lock, mapName) => { dataLoader.loadMapWithDeps(mapName).then(() => { Atomics.store(HEAP32, lock, 0) Atomics.notify(HEAP32, lock) - }).catch(error => { - console.error('[Render360 map download failure]', mapName, error && error.stack ? error.stack : error) - // Never leave Source permanently blocked on a failed browser-side map request. - Atomics.store(HEAP32, lock, 0) - Atomics.notify(HEAP32, lock) }) } From 564eed99c812c9e878f5c4ae3344386ba2297b16 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Tue, 8 Sep 2026 11:27:21 -0400 Subject: [PATCH 016/159] Allow original upstream Portal chunk requests --- emscripten/render360-pages-sw.js | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/emscripten/render360-pages-sw.js b/emscripten/render360-pages-sw.js index 4835e5d96f..8e50df498b 100644 --- a/emscripten/render360-pages-sw.js +++ b/emscripten/render360-pages-sw.js @@ -4,8 +4,10 @@ * injects them after the first controlled reload so the upstream pthread / * SharedArrayBuffer runtime can be exercised on static hosting. * - * It also serves user-imported Portal chunk .data files from Cache Storage. - * Those files remain local to the browser and are never committed to GitHub. + * Important: the original Portal port keeps packed game chunks outside the + * GitHub source tree and serves them from yikes.pw. Cross-origin chunk + * requests are therefore passed through unchanged; only same-origin Pages + * responses receive the isolation headers below. */ const LOCAL_CHUNK_CACHE = 'render360-portal-local-chunks-v1'; @@ -43,18 +45,25 @@ function withIsolationHeaders(response) { self.addEventListener('fetch', event => { const request = event.request; - // Chromium can emit this combination for devtools/cache internals. Let the - // browser handle it rather than throwing inside the service worker. if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { return; } + const url = new URL(request.url); + const sameOrigin = url.origin === self.location.origin; + + // Preserve the original upstream response and its CORS/CORP headers. Adding + // a same-origin CORP header to yikes.pw here would make the browser reject + // the very cross-origin chunk we are trying to load. + if (!sameOrigin) { + event.respondWith(fetch(request)); + return; + } + event.respondWith((async () => { - const url = new URL(request.url); - const sameOrigin = url.origin === self.location.origin; let response = null; - if (sameOrigin && request.method === 'GET' && /\/chunks\/[^/]+\.data$/i.test(url.pathname)) { + if (request.method === 'GET' && /\/chunks\/[^/]+\.data$/i.test(url.pathname)) { const cache = await caches.open(LOCAL_CHUNK_CACHE); response = await cache.match(request, { ignoreSearch: true }); } From d7b70de8dcc49e14b8a8d4d58a3ca86848b38c8f Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Tue, 8 Sep 2026 11:28:15 -0400 Subject: [PATCH 017/159] Use original yikes.pw Portal chunk source after ownership check --- emscripten/pages-index.html | 140 +++++++++++++++++++++++++----------- 1 file changed, 98 insertions(+), 42 deletions(-) diff --git a/emscripten/pages-index.html b/emscripten/pages-index.html index fbce52f186..257b381aad 100644 --- a/emscripten/pages-index.html +++ b/emscripten/pages-index.html @@ -7,7 +7,7 @@ Render360 · Portal upstream iPhone baseline

Portal upstream baseline

-

iPhone test lane for the original threaded Source/Emscripten runtime. Your Portal folder is used only to verify the expected game layout; runtime data remains separate.

+

Exact upstream Source/Emscripten runtime test. Your Portal installation is checked locally first; after verification the runtime uses the original port's packed map data source.

Secure contextchecking…
@@ -35,12 +35,12 @@

Portal upstream baseline

WebGL2checking…
Runtime fileschecking…
Portal ownership proofnot verified
-
Runtime chunk sourcechecking…
+
Original chunk sourcechecking…
Verify your Portal copy -

You do not need a ZIP. Choose the folder that contains your Portal game files. Safari inspects only filenames and relative paths locally. Nothing from this folder is uploaded, cached as game data, mounted, or used by Source.

+

Choose the Portal game folder you showed earlier — the one containing portal/, hl2/, platform/ and the game executables. Safari only inspects filenames and folder paths. The game folder is not uploaded or mounted.

@@ -52,23 +52,15 @@

Portal upstream baseline

- Runtime data -

After verification, Source still reads its normal chunks/<map>.data path. Your selected Portal folder is not substituted for the runtime chunks. Launch stays disabled until background1.data is actually available, preventing the old out-of-range DataView crash on a missing/404 chunk.

-
- Developer fallback: local test chunks -

Private testing only. These .data files stay in this browser's cache and are not uploaded.

-
- - - -
-
-
+ Original packed Portal data +

The upstream GitHub repository does not contain a chunks/ directory. Its own README tells the web build to obtain mapName.data from yikes.pw/portal/chunks/. This staging build now uses that same original source instead of inventing a replacement format.

+
Base URLyikes.pw/portal/chunks/
+
Run -

Checking whether the threaded runtime can start…

+

Checking whether the original threaded runtime can start…

@@ -80,8 +72,8 @@

Portal upstream baseline

From 7edb3107b56120537509c2bdb09f0c7f41751ce4 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Tue, 8 Sep 2026 11:57:35 -0400 Subject: [PATCH 018/159] Add browser VPK fallback chunk builder --- emscripten/portal-local-vpk.js | 516 +++++++++++++++++++++++++++++++++ 1 file changed, 516 insertions(+) create mode 100644 emscripten/portal-local-vpk.js diff --git a/emscripten/portal-local-vpk.js b/emscripten/portal-local-vpk.js new file mode 100644 index 0000000000..9ef8358a02 --- /dev/null +++ b/emscripten/portal-local-vpk.js @@ -0,0 +1,516 @@ +(() => { + 'use strict'; + + const CACHE_NAME = 'render360-portal-local-chunks-v2'; + const MAPS = [ + 'background1', + 'testchmb_a_00', + 'testchmb_a_01', + 'testchmb_a_02', + 'testchmb_a_03', + 'testchmb_a_04', + 'testchmb_a_05', + 'testchmb_a_06', + 'testchmb_a_07', + 'testchmb_a_08', + 'testchmb_a_09', + 'testchmb_a_10', + 'testchmb_a_11', + 'testchmb_a_13', + 'testchmb_a_14', + 'testchmb_a_15' + ]; + + const MAX_TEXT_SCAN_BYTES = 24 * 1024 * 1024; + const MAX_VMT_BYTES = 2 * 1024 * 1024; + const MAX_MODEL_MATERIALS = 600; + + function normalizePath(value) { + return String(value || '') + .replace(/\\/g, '/') + .replace(/^\/+/, '') + .replace(/\/+/g, '/') + .toLowerCase(); + } + + function dirname(path) { + const p = normalizePath(path); + const i = p.lastIndexOf('/'); + return i === -1 ? '' : p.slice(0, i); + } + + function basename(path) { + const p = normalizePath(path); + const i = p.lastIndexOf('/'); + return i === -1 ? p : p.slice(i + 1); + } + + function extname(path) { + const b = basename(path); + const i = b.lastIndexOf('.'); + return i === -1 ? '' : b.slice(i); + } + + function stripExt(path) { + const ext = extname(path); + return ext ? path.slice(0, -ext.length) : path; + } + + function bytesToMiB(bytes) { + return (bytes / 1048576).toFixed(1); + } + + function inferRelativePath(file) { + const raw = normalizePath(file.webkitRelativePath || file.name); + if (!file.webkitRelativePath) return raw; + const parts = raw.split('/'); + return parts.length > 1 ? parts.slice(1).join('/') : raw; + } + + function readCString(bytes, state) { + const start = state.offset; + while (state.offset < bytes.length && bytes[state.offset] !== 0) state.offset++; + if (state.offset >= bytes.length) throw new Error('unterminated VPK directory string'); + const out = new TextDecoder('utf-8').decode(bytes.subarray(start, state.offset)); + state.offset++; + return out; + } + + class PortalGameSource { + constructor(files, log) { + this.log = typeof log === 'function' ? log : () => {}; + this.files = Array.from(files || []); + this.filesByRel = new Map(); + this.entries = new Map(); + this.vpkDirs = []; + } + + async init() { + for (const file of this.files) { + const rel = inferRelativePath(file); + if (!rel) continue; + this.filesByRel.set(rel, file); + if (/^(portal|hl2|platform)\//.test(rel)) { + this.entries.set('/' + rel, { + kind: 'loose', path: '/' + rel, rel, file, size: file.size + }); + } + } + + const dirs = [...this.filesByRel.entries()].filter(([rel]) => /_dir\.vpk$/i.test(rel)); + if (!dirs.length) throw new Error('No *_dir.vpk files were found in the selected Portal folder.'); + + this.log(`Local fallback: parsing ${dirs.length} VPK directory file(s)…`); + for (const [rel, file] of dirs) { + try { + const parsed = await this.parseVPKDirectory(rel, file); + this.vpkDirs.push(parsed); + this.log(`VPK index ${rel}: ${parsed.entryCount} entries`); + } catch (error) { + this.log(`VPK index skipped ${rel}: ${error.message || error}`); + } + } + + if (!this.vpkDirs.length) throw new Error('Portal VPK indexes were found, but none could be parsed.'); + this.log(`Local fallback indexed ${this.entries.size} virtual game files.`); + return this; + } + + async parseVPKDirectory(rel, file) { + const headerBytes = new Uint8Array(await file.slice(0, 28).arrayBuffer()); + if (headerBytes.length < 12) throw new Error('VPK header is too small'); + const headerView = new DataView(headerBytes.buffer, headerBytes.byteOffset, headerBytes.byteLength); + const signature = headerView.getUint32(0, true); + if (signature !== 0x55aa1234) throw new Error(`unsupported VPK signature 0x${signature.toString(16)}`); + const version = headerView.getUint32(4, true); + const treeSize = headerView.getUint32(8, true); + const headerSize = version === 1 ? 12 : version === 2 ? 28 : 0; + if (!headerSize) throw new Error(`unsupported VPK version ${version}`); + if (treeSize <= 0 || headerSize + treeSize > file.size) throw new Error(`invalid VPK tree size ${treeSize}`); + + const treeBytes = new Uint8Array(await file.slice(headerSize, headerSize + treeSize).arrayBuffer()); + const treeView = new DataView(treeBytes.buffer, treeBytes.byteOffset, treeBytes.byteLength); + const state = { offset: 0 }; + const parent = dirname(rel); + const archiveBase = rel.slice(0, -'_dir.vpk'.length); + let entryCount = 0; + + while (state.offset < treeBytes.length) { + const extension = readCString(treeBytes, state); + if (!extension) break; + while (state.offset < treeBytes.length) { + const directoryRaw = readCString(treeBytes, state); + if (!directoryRaw) break; + const directory = directoryRaw === ' ' ? '' : normalizePath(directoryRaw); + + while (state.offset < treeBytes.length) { + const fileNameRaw = readCString(treeBytes, state); + if (!fileNameRaw) break; + const fileName = normalizePath(fileNameRaw); + if (state.offset + 18 > treeBytes.length) throw new Error('truncated VPK entry metadata'); + + const crc = treeView.getUint32(state.offset, true); state.offset += 4; + const preloadBytes = treeView.getUint16(state.offset, true); state.offset += 2; + const archiveIndex = treeView.getUint16(state.offset, true); state.offset += 2; + const entryOffset = treeView.getUint32(state.offset, true); state.offset += 4; + const entryLength = treeView.getUint32(state.offset, true); state.offset += 4; + const terminator = treeView.getUint16(state.offset, true); state.offset += 2; + if (terminator !== 0xffff) throw new Error(`bad VPK entry terminator 0x${terminator.toString(16)}`); + if (state.offset + preloadBytes > treeBytes.length) throw new Error('truncated VPK preload bytes'); + const preload = treeBytes.slice(state.offset, state.offset + preloadBytes); + state.offset += preloadBytes; + + const ext = extension === ' ' ? '' : normalizePath(extension); + const internal = [directory, fileName + (ext ? '.' + ext : '')].filter(Boolean).join('/'); + const vfsPath = '/' + [parent, internal].filter(Boolean).join('/'); + const descriptor = { + kind: 'vpk', path: vfsPath, dirRel: rel, dirFile: file, archiveBase, + headerSize, treeSize, crc, preload, archiveIndex, entryOffset, entryLength, + size: preload.length + entryLength + }; + if (!this.entries.has(vfsPath)) this.entries.set(vfsPath, descriptor); + entryCount++; + } + } + } + + return { rel, file, version, headerSize, treeSize, entryCount }; + } + + has(path) { return this.entries.has('/' + normalizePath(path)); } + get(path) { return this.entries.get('/' + normalizePath(path)) || null; } + + findBySuffix(suffix) { + const needle = '/' + normalizePath(suffix); + for (const key of this.entries.keys()) if (key.endsWith(needle)) return key; + return null; + } + + resolveGamePath(ref, preferredRoot = 'portal') { + const clean = normalizePath(ref).replace(/^\.\//, ''); + if (!clean) return null; + if (clean.startsWith('portal/') || clean.startsWith('hl2/') || clean.startsWith('platform/')) { + const exact = '/' + clean; + return this.entries.has(exact) ? exact : null; + } + const roots = preferredRoot === 'hl2' ? ['hl2', 'portal', 'platform'] : ['portal', 'hl2', 'platform']; + for (const root of roots) { + const candidate = '/' + root + '/' + clean; + if (this.entries.has(candidate)) return candidate; + } + return null; + } + + resolveMaterial(name, preferredRoot = 'portal') { + let clean = normalizePath(name).replace(/^materials\//, '').replace(/^\/+/, ''); + if (!clean) return null; + if (!/\.(vmt|vtf)$/.test(clean)) clean += '.vmt'; + return this.resolveGamePath('materials/' + clean, preferredRoot); + } + + resolveSound(name, preferredRoot = 'portal') { + const clean = normalizePath(name).replace(/^sound\//, '').replace(/^\/+/, ''); + if (!clean) return null; + return this.resolveGamePath('sound/' + clean, preferredRoot); + } + + resolveModel(name, preferredRoot = 'portal') { + let clean = normalizePath(name).replace(/^models\//, '').replace(/^\/+/, ''); + if (!clean) return null; + if (!clean.endsWith('.mdl')) clean += '.mdl'; + return this.resolveGamePath('models/' + clean, preferredRoot); + } + + async read(path) { + const descriptor = this.get(path); + if (!descriptor) throw new Error(`game asset not found: ${path}`); + if (descriptor.kind === 'loose') return descriptor.file; + const pieces = []; + if (descriptor.preload && descriptor.preload.length) pieces.push(descriptor.preload); + if (descriptor.entryLength) { + let archiveFile; + let start; + if (descriptor.archiveIndex === 0x7fff) { + archiveFile = descriptor.dirFile; + start = descriptor.headerSize + descriptor.treeSize + descriptor.entryOffset; + } else { + const rel = `${descriptor.archiveBase}_${String(descriptor.archiveIndex).padStart(3, '0')}.vpk`; + archiveFile = this.filesByRel.get(rel); + if (!archiveFile) throw new Error(`missing VPK segment ${rel} required by ${path}`); + start = descriptor.entryOffset; + } + const end = start + descriptor.entryLength; + if (end > archiveFile.size) throw new Error(`VPK entry ${path} exceeds ${archiveFile.name}`); + pieces.push(archiveFile.slice(start, end)); + } + return new Blob(pieces, { type: 'application/octet-stream' }); + } + + entriesMatching(predicate) { + const out = []; + for (const [path, descriptor] of this.entries) if (predicate(path, descriptor)) out.push(path); + return out; + } + } + + function parseBSPLumps(buffer) { + if (!(buffer instanceof ArrayBuffer) || buffer.byteLength < 1036) return null; + const dv = new DataView(buffer); + if (dv.getUint32(0, true) !== 0x50534256) return null; + const lumps = []; + for (let i = 0; i < 64; i++) { + const o = 8 + i * 16; + const fileofs = dv.getInt32(o, true); + const filelen = dv.getInt32(o + 4, true); + if (fileofs < 0 || filelen < 0 || fileofs + filelen > buffer.byteLength) lumps.push({ fileofs: 0, filelen: 0 }); + else lumps.push({ fileofs, filelen }); + } + return { dv, lumps }; + } + + function extractCString(bytes, start) { + let end = start; + while (end < bytes.length && bytes[end] !== 0) end++; + return new TextDecoder('utf-8').decode(bytes.subarray(start, end)); + } + + function discoverBSPReferences(buffer) { + const refs = new Set(); + const parsed = parseBSPLumps(buffer); + if (!parsed) return refs; + const bytes = new Uint8Array(buffer); + const stringData = parsed.lumps[43]; + const stringTable = parsed.lumps[44]; + if (stringData.filelen && stringTable.filelen) { + const tableCount = Math.floor(stringTable.filelen / 4); + for (let i = 0; i < tableCount; i++) { + const rel = parsed.dv.getUint32(stringTable.fileofs + i * 4, true); + if (rel >= stringData.filelen) continue; + const value = normalizePath(extractCString(bytes, stringData.fileofs + rel)); + if (value) refs.add('material:' + value); + } + } + const scanBytes = bytes.subarray(0, Math.min(bytes.length, MAX_TEXT_SCAN_BYTES)); + const text = new TextDecoder('latin1').decode(scanBytes); + const assetRe = /[a-zA-Z0-9_./\\-]{2,}\.(?:mdl|vmt|vtf|vvd|vtx|phy|wav|mp3|pcf|res|txt|cfg)/g; + let match; + while ((match = assetRe.exec(text))) { + const value = normalizePath(match[0]); + if (value) refs.add('path:' + value); + } + return refs; + } + + function discoverTextReferences(text) { + const refs = new Set(); + if (!text) return refs; + const quoted = /"([^"\r\n]{1,260})"/g; + let match; + while ((match = quoted.exec(text))) { + const value = normalizePath(match[1]).trim(); + if (!value || value.startsWith('$') || value.startsWith('%')) continue; + if (/\.(vmt|vtf|mdl|wav|mp3|pcf|res|txt|cfg)$/.test(value)) refs.add('path:' + value); + else if (value.includes('/') && /^[a-z0-9_./-]+$/.test(value)) refs.add('material-token:' + value); + } + return refs; + } + + function commonAssetPaths(source) { + return source.entriesMatching((path, descriptor) => { + if (/\/(portal|hl2)\/gameinfo\.txt$/.test(path)) return true; + if (/^\/(portal|hl2|platform)\/(resource|cfg|scripts|media)\//.test(path)) return descriptor.size <= 16 * 1024 * 1024; + if (/^\/(portal|hl2|platform)\/materials\/(vgui|console|hud)\//.test(path)) return descriptor.size <= 16 * 1024 * 1024; + if (/^\/platform\/resource\//.test(path)) return descriptor.size <= 16 * 1024 * 1024; + if (/^\/(portal|hl2)\/(steam|game)\.inf$/.test(path)) return true; + return false; + }); + } + + async function expandReferences(source, initialPaths, log) { + const wanted = new Set(); + const queue = [...initialPaths]; + const processedText = new Set(); + const processedModels = new Set(); + const enqueue = path => { if (path && !wanted.has(path)) queue.push(path); }; + + const resolveLooseReference = (value, preferredRoot) => { + const clean = normalizePath(value).replace(/^\/+/, ''); + if (!clean) return null; + if (clean.startsWith('materials/') || clean.startsWith('models/') || clean.startsWith('sound/') || clean.startsWith('portal/') || clean.startsWith('hl2/') || clean.startsWith('platform/')) return source.resolveGamePath(clean, preferredRoot); + if (/\.(wav|mp3)$/.test(clean)) return source.resolveSound(clean, preferredRoot); + if (/\.mdl$/.test(clean)) return source.resolveModel(clean, preferredRoot); + if (/\.(vmt|vtf)$/.test(clean)) return source.resolveGamePath(clean, preferredRoot) || source.resolveMaterial(clean, preferredRoot); + return source.resolveGamePath(clean, preferredRoot); + }; + + while (queue.length) { + const path = queue.shift(); + if (!path || wanted.has(path) || !source.get(path)) continue; + wanted.add(path); + const ext = extname(path); + const root = path.startsWith('/hl2/') ? 'hl2' : 'portal'; + + if (ext === '.vmt' && !processedText.has(path)) { + processedText.add(path); + try { + const blob = await source.read(path); + if (blob.size <= MAX_VMT_BYTES) { + const text = await blob.text(); + for (const ref of discoverTextReferences(text)) { + const sep = ref.indexOf(':'); + const kind = ref.slice(0, sep); + const raw = ref.slice(sep + 1); + if (kind === 'path') enqueue(resolveLooseReference(raw, root)); + else if (kind === 'material-token') { + enqueue(source.resolveMaterial(raw + '.vtf', root)); + enqueue(source.resolveMaterial(raw + '.vmt', root)); + } + } + } + } catch (error) { + log(`VMT dependency scan skipped ${path}: ${error.message || error}`); + } + } + + if (ext === '.mdl' && !processedModels.has(path)) { + processedModels.add(path); + const stem = stripExt(path); + for (const suffix of ['.vvd', '.dx90.vtx', '.sw.vtx', '.phy']) enqueue(source.get(stem + suffix) ? stem + suffix : null); + const marker = '/models/'; + const at = path.indexOf(marker); + if (at !== -1) { + const modelDir = dirname(path.slice(at + marker.length)); + if (modelDir) { + const prefix = `/${root}/materials/models/${modelDir}/`; + let count = 0; + for (const candidate of source.entries.keys()) { + if (candidate.startsWith(prefix)) { + enqueue(candidate); + if (++count >= MAX_MODEL_MATERIALS) break; + } + } + } + } + } + } + return wanted; + } + + async function buildMapPaths(source, mapName, includeCommon, log) { + const seed = new Set(includeCommon ? commonAssetPaths(source) : []); + let mapPath = source.resolveGamePath(`maps/${mapName}.bsp`, 'portal'); + if (!mapPath) mapPath = source.findBySuffix(`maps/${mapName}.bsp`); + if (!mapPath) { + log(`Local fallback: ${mapName}.bsp was not found in the selected install.`); + return { paths: seed, mapFound: false }; + } + seed.add(mapPath); + const graphPath = source.resolveGamePath(`maps/graphs/${mapName}.ain`, 'portal'); + if (graphPath) seed.add(graphPath); + + try { + const mapBlob = await source.read(mapPath); + const buffer = await mapBlob.arrayBuffer(); + const refs = discoverBSPReferences(buffer); + for (const tagged of refs) { + const sep = tagged.indexOf(':'); + const kind = tagged.slice(0, sep); + const raw = tagged.slice(sep + 1); + if (kind === 'material') { + const p = source.resolveMaterial(raw, mapPath.startsWith('/hl2/') ? 'hl2' : 'portal'); + if (p) seed.add(p); + continue; + } + const clean = normalizePath(raw).replace(/^\/+/, ''); + let resolved = null; + if (clean.startsWith('models/') || clean.startsWith('materials/') || clean.startsWith('sound/')) resolved = source.resolveGamePath(clean, 'portal'); + else if (/\.mdl$/.test(clean)) resolved = source.resolveModel(clean, 'portal'); + else if (/\.(wav|mp3)$/.test(clean)) resolved = source.resolveSound(clean, 'portal'); + else if (/\.(vmt|vtf)$/.test(clean)) resolved = source.resolveMaterial(clean, 'portal'); + else resolved = source.resolveGamePath(clean, 'portal'); + if (resolved) seed.add(resolved); + } + } catch (error) { + log(`Local fallback: could not inspect ${mapName}.bsp dependencies: ${error.message || error}`); + } + + const paths = await expandReferences(source, seed, log); + return { paths, mapFound: true }; + } + + async function packPaths(source, paths, log) { + const encoder = new TextEncoder(); + const parts = []; + let files = 0; + let bytes = 0; + for (const path of paths) { + try { + const blob = await source.read(path); + if (blob.size > 0xffffffff) throw new Error('single file exceeds 4 GiB packed format limit'); + const pathBytes = encoder.encode(path); + const header = new Uint8Array(8); + const dv = new DataView(header.buffer); + dv.setUint32(0, pathBytes.length, true); + dv.setUint32(4, blob.size, true); + parts.push(header, pathBytes, blob); + bytes += 8 + pathBytes.length + blob.size; + files++; + } catch (error) { + log(`Local fallback skipped ${path}: ${error.message || error}`); + } + } + return { blob: new Blob(parts, { type: 'application/octet-stream' }), files, bytes }; + } + + async function clearLocalChunks() { await caches.delete(CACHE_NAME); } + + async function hasLocalChunk(mapName = 'background1') { + if (!('caches' in globalThis)) return false; + const cache = await caches.open(CACHE_NAME); + const url = new URL(`./chunks/${mapName}.data`, location.href).href; + return !!(await cache.match(url)); + } + + async function buildChunks(files, options = {}) { + if (!('caches' in globalThis)) throw new Error('Cache Storage is unavailable in this browser.'); + const log = typeof options.log === 'function' ? options.log : () => {}; + const progress = typeof options.progress === 'function' ? options.progress : () => {}; + const source = await new PortalGameSource(files, log).init(); + await clearLocalChunks(); + const cache = await caches.open(CACHE_NAME); + const seen = new Set(); + const results = []; + + for (let i = 0; i < MAPS.length; i++) { + const mapName = MAPS[i]; + progress({ phase: 'scan', mapName, index: i, total: MAPS.length, message: `Scanning ${mapName}` }); + const { paths, mapFound } = await buildMapPaths(source, mapName, i === 0, log); + if (!mapFound && i !== 0) { + results.push({ mapName, skipped: true, reason: 'map not found' }); + continue; + } + const delta = []; + for (const path of paths) if (!seen.has(path)) { seen.add(path); delta.push(path); } + progress({ phase: 'pack', mapName, index: i, total: MAPS.length, message: `Packing ${mapName}` }); + const packed = await packPaths(source, delta, log); + const url = new URL(`./chunks/${mapName}.data`, location.href).href; + await cache.put(url, new Response(packed.blob, { + headers: { + 'Content-Type': 'application/octet-stream', + 'X-Render360-Chunk-Source': 'local-vpk', + 'X-Render360-Map': mapName + } + })); + results.push({ mapName, files: packed.files, bytes: packed.bytes, pathCount: delta.length }); + log(`Local chunk ${mapName}: ${packed.files} files, ${bytesToMiB(packed.bytes)} MiB`); + progress({ phase: 'done-map', mapName, index: i + 1, total: MAPS.length, bytes: packed.bytes, files: packed.files }); + await new Promise(resolve => setTimeout(resolve, 0)); + } + + if (!(await hasLocalChunk('background1'))) throw new Error('Local VPK fallback did not produce background1.data.'); + progress({ phase: 'done', total: MAPS.length, results }); + return { ok: true, results, indexedFiles: source.entries.size }; + } + + globalThis.Render360PortalVPK = { CACHE_NAME, MAPS, buildChunks, clearLocalChunks, hasLocalChunk }; +})(); From ac110dba370892348ab78683df0ff33defab74c7 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Tue, 8 Sep 2026 11:57:54 -0400 Subject: [PATCH 019/159] Restore original chunks path behind dual-source service worker --- emscripten/pre.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/emscripten/pre.js b/emscripten/pre.js index 782b9e7d8a..4e400d4d82 100644 --- a/emscripten/pre.js +++ b/emscripten/pre.js @@ -113,7 +113,7 @@ class DataLoader { resolve() } - xhr.open('GET', `https://yikes.pw/portal/chunks/${mapName}.data`, true) + xhr.open('GET', `chunks/${mapName}.data`, true) xhr.send() return promise From 4368552cb6660295da168453f89007469c8c7a6f Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Tue, 8 Sep 2026 11:58:22 -0400 Subject: [PATCH 020/159] Add original-host to local-VPK chunk fallback --- emscripten/render360-pages-sw.js | 83 +++++++++++++++++++++----------- 1 file changed, 56 insertions(+), 27 deletions(-) diff --git a/emscripten/render360-pages-sw.js b/emscripten/render360-pages-sw.js index 8e50df498b..ee9eac6a03 100644 --- a/emscripten/render360-pages-sw.js +++ b/emscripten/render360-pages-sw.js @@ -4,37 +4,46 @@ * injects them after the first controlled reload so the upstream pthread / * SharedArrayBuffer runtime can be exercised on static hosting. * - * Important: the original Portal port keeps packed game chunks outside the - * GitHub source tree and serves them from yikes.pw. Cross-origin chunk - * requests are therefore passed through unchanged; only same-origin Pages - * responses receive the isolation headers below. + * Portal's Source runtime still requests the original same-origin + * `chunks/.data` path. This worker provides that path from either: + * 1. chunks generated locally from the user's selected Portal/VPK files, or + * 2. the original yikes.pw packed-data host when CORS allows it. + * + * No retail game data is committed to GitHub Pages. */ -const LOCAL_CHUNK_CACHE = 'render360-portal-local-chunks-v1'; +const LOCAL_CHUNK_CACHE = 'render360-portal-local-chunks-v2'; +const OLD_LOCAL_CHUNK_CACHE = 'render360-portal-local-chunks-v1'; +const UPSTREAM_CHUNK_BASE = 'https://yikes.pw/portal/chunks/'; self.addEventListener('install', event => { self.skipWaiting(); }); self.addEventListener('activate', event => { - event.waitUntil(self.clients.claim()); + event.waitUntil((async () => { + await caches.delete(OLD_LOCAL_CHUNK_CACHE); + await self.clients.claim(); + })()); }); self.addEventListener('message', event => { if (!event.data) return; if (event.data.type === 'RENDER360_CLEAR_LOCAL_CHUNKS') { - event.waitUntil(caches.delete(LOCAL_CHUNK_CACHE)); + event.waitUntil(Promise.all([ + caches.delete(LOCAL_CHUNK_CACHE), + caches.delete(OLD_LOCAL_CHUNK_CACHE) + ])); } }); -function withIsolationHeaders(response) { +function withIsolationHeaders(response, extraHeaders = {}) { if (!response || 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'); headers.set('Cross-Origin-Resource-Policy', 'same-origin'); - + for (const [key, value] of Object.entries(extraHeaders)) headers.set(key, value); return new Response(response.body, { status: response.status, statusText: response.statusText, @@ -42,37 +51,57 @@ function withIsolationHeaders(response) { }); } -self.addEventListener('fetch', event => { - const request = event.request; +async function servePortalChunk(request, url) { + const name = url.pathname.split('/').pop(); + const cache = await caches.open(LOCAL_CHUNK_CACHE); + const local = await cache.match(request, { ignoreSearch: true }); + if (local) { + return withIsolationHeaders(local, { + 'X-Render360-Chunk-Source': 'local-vpk' + }); + } - if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { - return; + const upstreamUrl = UPSTREAM_CHUNK_BASE + encodeURIComponent(name); + try { + const upstream = await fetch(upstreamUrl, { + method: 'GET', + mode: 'cors', + credentials: 'omit', + cache: 'no-store' + }); + if (!upstream.ok) throw new Error(`HTTP ${upstream.status}`); + return withIsolationHeaders(upstream, { + 'Content-Type': upstream.headers.get('content-type') || 'application/octet-stream', + 'X-Render360-Chunk-Source': 'upstream-yikes' + }); + } catch (error) { + console.warn('[Render360 Pages SW] upstream chunk unavailable', upstreamUrl, error); + return withIsolationHeaders(new Response( + 'Portal chunk unavailable from both local VPK cache and original upstream host: ' + String(error), + { status: 502, headers: { 'Content-Type': 'text/plain; charset=utf-8' } } + ), { + 'X-Render360-Chunk-Source': 'unavailable' + }); } +} + +self.addEventListener('fetch', event => { + const request = event.request; + if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') return; const url = new URL(request.url); const sameOrigin = url.origin === self.location.origin; - // Preserve the original upstream response and its CORS/CORP headers. Adding - // a same-origin CORP header to yikes.pw here would make the browser reject - // the very cross-origin chunk we are trying to load. if (!sameOrigin) { event.respondWith(fetch(request)); return; } event.respondWith((async () => { - let response = null; - if (request.method === 'GET' && /\/chunks\/[^/]+\.data$/i.test(url.pathname)) { - const cache = await caches.open(LOCAL_CHUNK_CACHE); - response = await cache.match(request, { ignoreSearch: true }); - } - - if (!response) { - response = await fetch(request); + return servePortalChunk(request, url); } - - return withIsolationHeaders(response); + return withIsolationHeaders(await fetch(request)); })().catch(error => { console.error('[Render360 Pages SW] fetch failed', request.url, error); return new Response('Render360 staging fetch failed: ' + String(error), { From ca01b95753ebd5b3b530c5c960931b16cf626b1a Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Tue, 8 Sep 2026 12:00:46 -0400 Subject: [PATCH 021/159] Add automatic VPK fallback when original chunks fail --- emscripten/pages-index.html | 197 ++++++++++++++++++++++++++---------- 1 file changed, 144 insertions(+), 53 deletions(-) diff --git a/emscripten/pages-index.html b/emscripten/pages-index.html index 257b381aad..e1ac2203da 100644 --- a/emscripten/pages-index.html +++ b/emscripten/pages-index.html @@ -4,26 +4,27 @@ - Render360 · Portal upstream iPhone baseline + Render360 · Portal iPhone baseline

Portal upstream baseline

-

Exact upstream Source/Emscripten runtime test. Your Portal installation is checked locally first; after verification the runtime uses the original port's packed map data source.

+

The Source runtime still asks for its original chunks/<map>.data path. Render360 now has two data paths: use the original packed host when it works, or automatically build compatible chunks from your own Portal VPKs when it does not.

Secure contextchecking…
@@ -35,12 +36,12 @@

Portal upstream baseline

WebGL2checking…
Runtime fileschecking…
Portal ownership proofnot verified
-
Original chunk sourcechecking…
+
Runtime chunk sourcechecking…
Verify your Portal copy -

Choose the Portal game folder you showed earlier — the one containing portal/, hl2/, platform/ and the game executables. Safari only inspects filenames and folder paths. The game folder is not uploaded or mounted.

+

Choose the full Portal folder containing portal/, hl2/, platform/ and the VPK files. Safari inspects the selected files locally. Nothing from your game folder is uploaded to GitHub.

@@ -52,15 +53,22 @@

Portal upstream baseline

- Original packed Portal data -

The upstream GitHub repository does not contain a chunks/ directory. Its own README tells the web build to obtain mapName.data from yikes.pw/portal/chunks/. This staging build now uses that same original source instead of inventing a replacement format.

-
Base URLyikes.pw/portal/chunks/
-
+ Dual runtime data +

Render360 first checks the original Portal web-port data path. If Safari cannot read it, the selected Portal folder becomes the fallback source: the browser parses your *_dir.vpk indexes, reads only needed file ranges with File.slice(), packs them into the same upstream .data record format, and stores the generated chunks in this browser's Cache Storage.

+
Original hostchecking…
+
Local VPK fallbacknot prepared
+
+ + + +
+ +
Run -

Checking whether the original threaded runtime can start…

+

Checking whether the threaded runtime can start…

@@ -69,22 +77,37 @@

Portal upstream baseline

Device log
+ ', html, flags=re.S) + inline = [s for s in scripts if s.strip()] + if not inline: + raise SystemExit('no inline staging script found') + Path('/tmp/render360-pages-inline.js').write_text(inline[-1]) + PY + node --check /tmp/render360-pages-inline.js + - name: Install pinned upstream Emscripten environment shell: bash run: | From 3b08463e71d532785ab72896358ebf2d19424450 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Tue, 8 Sep 2026 12:02:18 -0400 Subject: [PATCH 024/159] Fast-fail blocked upstream chunk host before VPK fallback --- emscripten/render360-pages-sw.js | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/emscripten/render360-pages-sw.js b/emscripten/render360-pages-sw.js index ee9eac6a03..d2127db6b7 100644 --- a/emscripten/render360-pages-sw.js +++ b/emscripten/render360-pages-sw.js @@ -15,6 +15,7 @@ const LOCAL_CHUNK_CACHE = 'render360-portal-local-chunks-v2'; const OLD_LOCAL_CHUNK_CACHE = 'render360-portal-local-chunks-v1'; const UPSTREAM_CHUNK_BASE = 'https://yikes.pw/portal/chunks/'; +const UPSTREAM_TIMEOUT_MS = 8000; self.addEventListener('install', event => { self.skipWaiting(); @@ -51,6 +52,22 @@ function withIsolationHeaders(response, extraHeaders = {}) { }); } +async function fetchUpstreamChunk(upstreamUrl) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort('upstream chunk timeout'), UPSTREAM_TIMEOUT_MS); + try { + return await fetch(upstreamUrl, { + method: 'GET', + mode: 'cors', + credentials: 'omit', + cache: 'no-store', + signal: controller.signal + }); + } finally { + clearTimeout(timer); + } +} + async function servePortalChunk(request, url) { const name = url.pathname.split('/').pop(); const cache = await caches.open(LOCAL_CHUNK_CACHE); @@ -63,12 +80,7 @@ async function servePortalChunk(request, url) { const upstreamUrl = UPSTREAM_CHUNK_BASE + encodeURIComponent(name); try { - const upstream = await fetch(upstreamUrl, { - method: 'GET', - mode: 'cors', - credentials: 'omit', - cache: 'no-store' - }); + const upstream = await fetchUpstreamChunk(upstreamUrl); if (!upstream.ok) throw new Error(`HTTP ${upstream.status}`); return withIsolationHeaders(upstream, { 'Content-Type': upstream.headers.get('content-type') || 'application/octet-stream', From 5f51ec9ab7d783349d6b2eea0f0d77a4016a6e8c Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Tue, 8 Sep 2026 12:29:38 -0400 Subject: [PATCH 025/159] Skip runtime artifact upload on pull request validation --- .github/workflows/build.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 99524269b1..fa530d9a51 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -121,7 +121,13 @@ jobs: (cd build/install && zip -9 -r "$GITHUB_WORKSPACE/Render360-Portal-iPhone-Baseline.zip" .) ls -lh Render360-Portal-iPhone-Baseline.zip + # Pull-request tokens can be restricted by GitHub and, in this repo, + # artifact finalization returns HTTP 403 even after all bytes upload. + # PR runs only need to prove that Source builds and the staging runtime + # validates. The branch push run is the deployable build, so keep the + # downloadable artifact and Pages publishing push/workflow_dispatch-only. - name: Upload runtime artifact + if: github.event_name != 'pull_request' uses: actions/upload-artifact@v7 with: name: Render360-Portal-iPhone-Baseline @@ -129,6 +135,13 @@ jobs: if-no-files-found: error retention-days: 7 + - name: PR validation complete + if: github.event_name == 'pull_request' + shell: bash + run: | + echo 'Portal runtime compiled and validated successfully.' + echo 'Artifact upload and Pages deployment are intentionally handled by the branch push run.' + - name: Configure GitHub Pages if: github.event_name != 'pull_request' uses: actions/configure-pages@v5 From 51b99f62b202b1960b82a9d557607feb58f2131d Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Tue, 8 Sep 2026 23:23:31 -0400 Subject: [PATCH 026/159] Harden Portal data loading and skip desktop startup paths --- emscripten/pre.js | 100 ++++++++++++++++++++++++++++++---------------- 1 file changed, 66 insertions(+), 34 deletions(-) diff --git a/emscripten/pre.js b/emscripten/pre.js index 4e400d4d82..51d0543b2d 100644 --- a/emscripten/pre.js +++ b/emscripten/pre.js @@ -4,6 +4,8 @@ Module['arguments'].push( '-noip', '-language', 'english', '-windowed', + '-novid', + '-nojoy', '+mat_hdr_level', '0', '+mat_colorcorrection', '1' ) @@ -36,15 +38,15 @@ class DataLoader { throw new Error(`no such map: ${mapName}`) } - // load past maps and current one for(let i = 0; i < index + 1; i++) { await this.loadMapCached(this.mapsOrdered[i]) } - // schedule next map if it exists const next = this.mapsOrdered[index + 1] if(next) { - this.loadMapCached(next) + this.loadMapCached(next).catch(error => { + Module.printErr?.(`[Render360] background preload failed for ${next}: ${error?.stack || error}`) + }) } } @@ -58,9 +60,9 @@ class DataLoader { async setProgress(mapName, progress) { if(progress < 1) { spinnerElement.style.display = '' - statusElement.innerText = `Downloading map ${mapName}` + statusElement.innerText = `Loading map data ${mapName}` progressElement.hidden = false - progressElement.value = progress + progressElement.value = Number.isFinite(progress) ? progress : 0 } else { spinnerElement.style.display = 'none' statusElement.innerText = '' @@ -77,43 +79,67 @@ class DataLoader { const xhr = new XMLHttpRequest() xhr.responseType = 'arraybuffer' xhr.onprogress = e => { - this.setProgress(mapName, e.loaded / e.total) + this.setProgress(mapName, e.lengthComputable && e.total > 0 ? e.loaded / e.total : 0) } xhr.onerror = () => { - reject(new Error(`cannot load map ${mapName}`)) + reject(new Error(`cannot load map ${mapName}: network error`)) } - 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) + xhr.onload = () => { + try { + if(xhr.status < 200 || xhr.status >= 300) { + throw new Error(`cannot load map ${mapName}: HTTP ${xhr.status}`) + } + if(!(xhr.response instanceof ArrayBuffer)) { + throw new Error(`cannot load map ${mapName}: response is not binary data`) + } + + const dv = new DataView(xhr.response) + let offset = 0 + let fileCount = 0 + + // data format: { pathLen: uint32le, dataLen: uint32le, path: bytes, blob: bytes }[] + while(offset < dv.byteLength) { + if(dv.byteLength - offset < 8) { + throw new Error(`corrupt ${mapName}.data: truncated record header at ${offset}/${dv.byteLength}`) + } + const pathLen = dv.getUint32(offset, true) + const dataLen = dv.getUint32(offset + 4, true) + const recordEnd = offset + 8 + pathLen + dataLen + if(pathLen === 0 || pathLen > 1024 * 1024 || recordEnd > dv.byteLength) { + throw new Error(`corrupt ${mapName}.data: record ${fileCount} exceeds buffer (${recordEnd}/${dv.byteLength})`) + } + + const path = new TextDecoder().decode(new Uint8Array(dv.buffer, offset + 8, pathLen)) + const blob = new Uint8Array(dv.buffer, offset + 8 + pathLen, dataLen) + offset = recordEnd + fileCount++ + + // Game-data chunks must never supply native executables/shared libraries. + // Emscripten SIDE_MODULE .so files are built and shipped with the runtime, + // not sourced from Portal retail/VPK data. + if(/\.(?:dll|dylib|exe|so)$/i.test(path)) { + Module.printErr?.(`[Render360] ignored native binary from game-data chunk: ${path}`) + continue + } + + const dir = path.replace(/\/[^\/]+$/, '') + FS.mkdirTree(dir) + FS.writeFile(path, blob) + } + + this.setProgress(mapName, 1) + Module.print?.(`[Render360] loaded ${mapName}.data: ${fileCount} records, ${dv.byteLength} bytes`) + resolve() + } catch(error) { + this.setProgress(mapName, 1) + Module.printErr?.(`[Render360] ${error?.stack || error}`) + reject(error) } - - resolve() } xhr.open('GET', `chunks/${mapName}.data`, true) + xhr.setRequestHeader('Cache-Control', 'no-cache') xhr.send() return promise @@ -126,5 +152,11 @@ Module.downloadMap = (lock, mapName) => { dataLoader.loadMapWithDeps(mapName).then(() => { Atomics.store(HEAP32, lock, 0) Atomics.notify(HEAP32, lock) + }).catch(error => { + Module.printErr?.(`[Render360] map dependency load failed for ${mapName}: ${error?.stack || error}`) + // Do not leave the Source pthread asleep forever. Wake it so the engine can + // surface the real missing-map/file error in its own startup path. + Atomics.store(HEAP32, lock, 0) + Atomics.notify(HEAP32, lock) }) } From 24e649cdee42150a830f86b1b42dbb4fbf106499 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Tue, 8 Sep 2026 23:23:58 -0400 Subject: [PATCH 027/159] Use Safari native fullscreen path for Portal canvas --- emscripten/shell.html | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/emscripten/shell.html b/emscripten/shell.html index ad7536d5c3..0dcf0b6cc0 100644 --- a/emscripten/shell.html +++ b/emscripten/shell.html @@ -55,26 +55,24 @@ return detail; } - function render360RequestFullscreen() { + async function render360RequestFullscreen() { + // Do not call Emscripten's requestFullscreen wrapper on iPhone. The + // wrapper may target an internal canvas container that WebKit does not + // expose as a fullscreen-capable element. Use the browser API directly + // when Safari exposes it, otherwise keep Portal inline/standalone. try { - if (Module && typeof Module.requestFullscreen === 'function') { - // Touch-first iPhone staging: no pointer lock, resize the canvas. - Module.requestFullscreen(false, true); - return; + var target = canvasElement; + var request = target.requestFullscreen || target.webkitRequestFullscreen; + if (typeof request !== 'function') { + console.info('[Render360 fullscreen] Element fullscreen is unavailable on this Safari build; continuing inline.'); + return false; } - var request = canvasElement.requestFullscreen || canvasElement.webkitRequestFullscreen; - if (request) { - var result = request.call(canvasElement); - if (result && typeof result.catch === 'function') { - result.catch(function(error) { - render360Report('fullscreen failure', error && error.message ? error.message : String(error), error); - }); - } - return; - } - render360Report('fullscreen unavailable', 'This Safari build does not expose element fullscreen here. The Portal canvas can still run inline.'); + var result = request.call(target); + if (result && typeof result.then === 'function') await result; + return true; } catch (error) { render360Report('fullscreen failure', error && error.message ? error.message : String(error), error); + return false; } } From 624b067299c706ac4861e459673e0c7eabf9fb36 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Tue, 8 Sep 2026 23:24:30 -0400 Subject: [PATCH 028/159] Keep Portal Wasm runtime assets network-fresh on Safari --- emscripten/render360-pages-sw.js | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/emscripten/render360-pages-sw.js b/emscripten/render360-pages-sw.js index d2127db6b7..a9ac831140 100644 --- a/emscripten/render360-pages-sw.js +++ b/emscripten/render360-pages-sw.js @@ -9,6 +9,11 @@ * 1. chunks generated locally from the user's selected Portal/VPK files, or * 2. the original yikes.pw packed-data host when CORS allows it. * + * Mutable engine assets (.js/.wasm/.so/.html) are always fetched network-first + * with cache:no-store. The filenames stay stable between Pages deployments, and + * mixing an old Emscripten glue file with new SIDE_MODULEs (or vice versa) can + * produce misleading dylink/DataView failures on Safari. + * * No retail game data is committed to GitHub Pages. */ @@ -16,6 +21,7 @@ const LOCAL_CHUNK_CACHE = 'render360-portal-local-chunks-v2'; const OLD_LOCAL_CHUNK_CACHE = 'render360-portal-local-chunks-v1'; const UPSTREAM_CHUNK_BASE = 'https://yikes.pw/portal/chunks/'; const UPSTREAM_TIMEOUT_MS = 8000; +const MUTABLE_RUNTIME_RE = /\.(?:html?|js|mjs|wasm|so|json)$/i; self.addEventListener('install', event => { self.skipWaiting(); @@ -97,6 +103,15 @@ async function servePortalChunk(request, url) { } } +async function fetchRuntimeFresh(request, url) { + const freshRequest = new Request(request, { cache: 'no-store' }); + const response = await fetch(freshRequest); + return withIsolationHeaders(response, { + 'Cache-Control': 'no-store, max-age=0', + 'X-Render360-Runtime-Fresh': '1' + }); +} + self.addEventListener('fetch', event => { const request = event.request; if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') return; @@ -113,6 +128,9 @@ self.addEventListener('fetch', event => { if (request.method === 'GET' && /\/chunks\/[^/]+\.data$/i.test(url.pathname)) { return servePortalChunk(request, url); } + if (request.method === 'GET' && MUTABLE_RUNTIME_RE.test(url.pathname)) { + return fetchRuntimeFresh(request, url); + } return withIsolationHeaders(await fetch(request)); })().catch(error => { console.error('[Render360 Pages SW] fetch failed', request.url, error); @@ -122,7 +140,8 @@ self.addEventListener('fetch', event => { 'Content-Type': 'text/plain; charset=utf-8', 'Cross-Origin-Opener-Policy': 'same-origin', 'Cross-Origin-Embedder-Policy': 'require-corp', - 'Cross-Origin-Resource-Policy': 'same-origin' + 'Cross-Origin-Resource-Policy': 'same-origin', + 'Cache-Control': 'no-store, max-age=0' } }); })); From a34ef24c1476cc7ed721c2bfa94bcd333466ae83 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Tue, 8 Sep 2026 23:24:54 -0400 Subject: [PATCH 029/159] Validate Wasm side modules and emit useful iPhone abort stacks --- emscripten/build.sh | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/emscripten/build.sh b/emscripten/build.sh index 66ad9297a8..cd20c9579e 100755 --- a/emscripten/build.sh +++ b/emscripten/build.sh @@ -17,6 +17,30 @@ python3 waf configure -T $buildtype --notests -4 --togles --emscripten \ python3 waf install $@ find build/ -name '*.map' -exec cp {} build/install/ \; +# Emscripten dynamic linking expects SIDE_MODULEs to be WebAssembly modules even +# when they use a Unix-style .so suffix. Fail CI immediately if a native ELF, +# HTML error page, or other non-Wasm file ever enters the runtime module set. +python3 - <<'PY' +from pathlib import Path +mods = sorted(Path('build/install').glob('*.so')) +if not mods: + raise SystemExit('Render360 Portal: no Emscripten SIDE_MODULE .so files were produced') +bad = [] +for path in mods: + with path.open('rb') as f: + magic = f.read(4) + if magic != b'\x00asm': + bad.append((str(path), magic.hex())) +if bad: + for path, magic in bad: + print(f'NON-WASM SIDE_MODULE: {path} magic={magic}') + raise SystemExit('Render360 Portal: native/non-Wasm .so entered build/install') +Path('build/install/render360-wasm-side-modules.txt').write_text( + '\n'.join(path.name for path in mods) + '\n' +) +print(f'Render360 Portal: verified {len(mods)} WebAssembly SIDE_MODULEs') +PY + #link_libs="-sERROR_ON_UNDEFINED_SYMBOLS=0" for lib in build/install/*.so; do libname=$(echo $lib | sed -E 's/^.+\/lib(.+)\.so/\1/g') @@ -27,7 +51,7 @@ 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 \ -sFULL_ES3 -sSTACK_SIZE=4mb --shell-file=emscripten/shell.html \ - -sEXPORTED_RUNTIME_METHODS=requestFullscreen \ + -sASSERTIONS=2 -sSTACK_OVERFLOW_CHECK=2 --profiling-funcs \ -sPROXY_TO_PTHREAD -sOFFSCREENCANVASES_TO_PTHREAD="#canvas" -sOFFSCREENCANVAS_SUPPORT=1 \ --pre-js emscripten/pre.js --post-js emscripten/post.js \ -L build/install/ \ From 15c68dfcda86d3526e548b0f99f2a265e65572fa Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Tue, 8 Sep 2026 23:32:19 -0400 Subject: [PATCH 030/159] Skip optional desktop modules in Portal Wasm runtime --- emscripten/build.sh | 83 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/emscripten/build.sh b/emscripten/build.sh index cd20c9579e..9b5d419a9e 100755 --- a/emscripten/build.sh +++ b/emscripten/build.sh @@ -11,6 +11,89 @@ export CXX=em++ set -ex +# Keep Source's upstream pthread/SharedArrayBuffer architecture intact, but stop +# the browser build from attempting to dlopen desktop-only optional modules. +# On GitHub Pages a missing .so request can return a non-Wasm response and +# Emscripten then reports "need to see wasm magic number". Native Source treats +# these modules as optional already; fail them immediately in the Emscripten +# loader instead of doing a pointless network/dylink attempt. +python3 - <<'PY' +from pathlib import Path +import re + +path = Path('tier1/interface.cpp') +text = path.read_text() +pattern = re.compile( + r'(CSysModule \*Sys_LoadModule\( const char \*pModuleName, Sys_Flags flags /\* = SYS_NOFLAGS \(0\) \*/ \)\n\{\n\tHMODULE hDLL = NULL;\n\n)' + r'#ifdef __EMSCRIPTEN__\n.*?\n#else\n', + re.S, +) +replacement = r'''\1#ifdef __EMSCRIPTEN__ + // Emscripten SIDE_MODULEs are linked into the runtime by basename. Normalize + // absolute/relative Source module names to the linked lib*.so name first. + const char *pBaseName = strrchr(pModuleName, '/'); + if(!pBaseName) pBaseName = strrchr(pModuleName, '\\'); + pBaseName = pBaseName ? pBaseName + 1 : pModuleName; + + char szBaseName[1024] = { 0 }; + Q_strncpy(szBaseName, pBaseName, sizeof(szBaseName)); + if(!string_endsWith(szBaseName, ".so")) { + V_SetExtension(szBaseName, ".so", sizeof(szBaseName)); + } + + char szModuleName[1024] = { 0 }; + if(strncmp(szBaseName, "lib", 3) == 0) { + Q_strncpy(szModuleName, szBaseName, sizeof(szModuleName)); + } else { + Q_snprintf(szModuleName, sizeof(szModuleName), "lib%s", szBaseName); + } + + // These are optional desktop/legacy modules. Native Source also continues + // when they are absent. Do not let Safari fetch a 404/HTML body and hand it + // to Emscripten's dynamic linker as though it were WebAssembly. + static const char *s_pOptionalBrowserModules[] = { + "libsourcevr.so", + "libvideo_bink.so", + "libvideo_webm.so", + "libvideo_quicktime.so", + "libstdshader_dbg.so", + "libstdshader_dx6.so", + "libstdshader_dx7.so", + "libstdshader_dx8.so", + }; + for(size_t i = 0; i < sizeof(s_pOptionalBrowserModules) / sizeof(s_pOptionalBrowserModules[0]); ++i) { + if(Q_stricmp(szModuleName, s_pOptionalBrowserModules[i]) == 0) { + Msg("Render360: optional browser module skipped: %s\n", szModuleName); + return reinterpret_cast(hDLL); + } + } + + Msg("LoadLibrary: path: %s\n", szModuleName); + hDLL = (HMODULE)dlopen(szModuleName, RTLD_NOW); + if(!hDLL) { + const char *pError = dlerror(); + Warning("Can't find module - %s%s%s\n", pModuleName, + pError ? " · " : "", pError ? pError : ""); + } +#else +''' +updated, count = pattern.subn(replacement, text, count=1) +if count != 1: + raise SystemExit('Render360 Portal: could not locate Emscripten Sys_LoadModule block') +for marker in ( + 'Render360: optional browser module skipped:', + 'libsourcevr.so', + 'libvideo_bink.so', + 'libvideo_webm.so', + 'libstdshader_dbg.so', + 'libstdshader_dx8.so', +): + if marker not in updated: + raise SystemExit(f'Render360 Portal: loader patch missing {marker}') +path.write_text(updated) +print('Render360 Portal: patched Sys_LoadModule optional browser-module handling') +PY + #rm -rf build/install python3 waf configure -T $buildtype --notests -4 --togles --emscripten \ --disable-warns --build-games=portal --prefix=build/install From cf0c83b2c4958b454e19bfccfb4572eeca12210c Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Tue, 8 Sep 2026 23:32:59 -0400 Subject: [PATCH 031/159] Invalidate stale Portal chunk cache after module fix --- emscripten/render360-pages-sw.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/emscripten/render360-pages-sw.js b/emscripten/render360-pages-sw.js index a9ac831140..c20c496e58 100644 --- a/emscripten/render360-pages-sw.js +++ b/emscripten/render360-pages-sw.js @@ -23,6 +23,11 @@ const UPSTREAM_CHUNK_BASE = 'https://yikes.pw/portal/chunks/'; const UPSTREAM_TIMEOUT_MS = 8000; const MUTABLE_RUNTIME_RE = /\.(?:html?|js|mjs|wasm|so|json)$/i; +// This staging revision changes how Source handles optional desktop .so modules. +// Force one clean VPK-cache rebuild when the new worker activates so an iPhone +// cannot keep testing a chunk set created by an older runtime revision. +const REBUILD_LOCAL_CHUNKS_ON_ACTIVATE = true; + self.addEventListener('install', event => { self.skipWaiting(); }); @@ -30,6 +35,10 @@ self.addEventListener('install', event => { self.addEventListener('activate', event => { event.waitUntil((async () => { await caches.delete(OLD_LOCAL_CHUNK_CACHE); + if (REBUILD_LOCAL_CHUNKS_ON_ACTIVATE) { + await caches.delete(LOCAL_CHUNK_CACHE); + console.info('[Render360 Pages SW] cleared local Portal chunks for clean runtime rebuild'); + } await self.clients.claim(); })()); }); From b946f2de0e754232472728756a4ac240fa14bcaf Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Tue, 8 Sep 2026 23:36:09 -0400 Subject: [PATCH 032/159] Assert required Portal WebAssembly modules before Pages deploy --- emscripten/build.sh | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/emscripten/build.sh b/emscripten/build.sh index 9b5d419a9e..c1d3f9a1c4 100755 --- a/emscripten/build.sh +++ b/emscripten/build.sh @@ -124,6 +124,25 @@ Path('build/install/render360-wasm-side-modules.txt').write_text( print(f'Render360 Portal: verified {len(mods)} WebAssembly SIDE_MODULEs') PY +# These are not optional probes: they are the Source filesystem/engine/material +# and ToGL shader path needed to reach a real Portal frame. Refuse to deploy a +# Pages runtime that is missing any of them, even if the generic .so validation +# above succeeds. +for required in \ + libfilesystem_stdio.so \ + libengine.so \ + libmaterialsystem.so \ + libshaderapidx9.so \ + libstdshader_dx9.so +do + if [ ! -s "build/install/$required" ]; then + echo "Render360 Portal: required Wasm module missing: $required" >&2 + exit 1 + fi + done + +echo "Render360 Portal: required filesystem/engine/ToGL module set present" + #link_libs="-sERROR_ON_UNDEFINED_SYMBOLS=0" for lib in build/install/*.so; do libname=$(echo $lib | sed -E 's/^.+\/lib(.+)\.so/\1/g') From 59d915eaaaf8b532c307da516ab5a67ad33533a0 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Tue, 8 Sep 2026 23:37:03 -0400 Subject: [PATCH 033/159] Keep iPhone launch disabled while local chunks are still packing --- .github/workflows/build.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fa530d9a51..3f4b585a27 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -44,6 +44,8 @@ jobs: grep -q -- '-sMAIN_MODULE' emscripten/build.sh grep -q 'Atomics.store' emscripten/pre.js grep -q 'Portal JS exception' emscripten/shell.html + grep -q 'libsourcevr.so' emscripten/build.sh + grep -q 'libstdshader_dx9.so' emscripten/build.sh - name: Validate dual-source staging JavaScript shell: bash @@ -102,6 +104,23 @@ jobs: cp emscripten/pages-index.html build/install/index.html cp emscripten/render360-pages-sw.js build/install/render360-pages-sw.js cp emscripten/portal-local-vpk.js build/install/portal-local-vpk.js + + # background1 becomes available before the remaining chamber chunks + # finish packing. Do not let the iPhone launch midway through the local + # VPK build just because the initial chunk probe already succeeds. + python3 - <<'PY' + from pathlib import Path + path = Path('build/install/index.html') + text = path.read_text() + old = 'const ready=threadReady&&runtimeReady&&verified&&chunk.ok;' + new = 'const ready=threadReady&&runtimeReady&&verified&&chunk.ok&&!buildingLocal;' + if old not in text: + raise SystemExit('Render360 Portal: staging launch readiness expression changed unexpectedly') + text = text.replace(old, new, 1) + path.write_text(text) + PY + grep -q 'chunk.ok&&!buildingLocal' build/install/index.html + touch build/install/.nojekyll cat > build/install/render360-baseline.json < Date: Tue, 8 Sep 2026 23:38:19 -0400 Subject: [PATCH 034/159] Exclude native binaries from locally packed Portal chunks --- emscripten/portal-local-vpk.js | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/emscripten/portal-local-vpk.js b/emscripten/portal-local-vpk.js index 9ef8358a02..e6abd6c72f 100644 --- a/emscripten/portal-local-vpk.js +++ b/emscripten/portal-local-vpk.js @@ -24,6 +24,7 @@ const MAX_TEXT_SCAN_BYTES = 24 * 1024 * 1024; const MAX_VMT_BYTES = 2 * 1024 * 1024; const MAX_MODEL_MATERIALS = 600; + const NATIVE_BINARY_RE = /\.(?:dll|dylib|exe|so)$/i; function normalizePath(value) { return String(value || '') @@ -317,6 +318,7 @@ function commonAssetPaths(source) { return source.entriesMatching((path, descriptor) => { + if (NATIVE_BINARY_RE.test(path)) return false; if (/\/(portal|hl2)\/gameinfo\.txt$/.test(path)) return true; if (/^\/(portal|hl2|platform)\/(resource|cfg|scripts|media)\//.test(path)) return descriptor.size <= 16 * 1024 * 1024; if (/^\/(portal|hl2|platform)\/materials\/(vgui|console|hud)\//.test(path)) return descriptor.size <= 16 * 1024 * 1024; @@ -331,11 +333,11 @@ const queue = [...initialPaths]; const processedText = new Set(); const processedModels = new Set(); - const enqueue = path => { if (path && !wanted.has(path)) queue.push(path); }; + const enqueue = path => { if (path && !wanted.has(path) && !NATIVE_BINARY_RE.test(path)) queue.push(path); }; const resolveLooseReference = (value, preferredRoot) => { const clean = normalizePath(value).replace(/^\/+/, ''); - if (!clean) return null; + if (!clean || NATIVE_BINARY_RE.test(clean)) return null; if (clean.startsWith('materials/') || clean.startsWith('models/') || clean.startsWith('sound/') || clean.startsWith('portal/') || clean.startsWith('hl2/') || clean.startsWith('platform/')) return source.resolveGamePath(clean, preferredRoot); if (/\.(wav|mp3)$/.test(clean)) return source.resolveSound(clean, preferredRoot); if (/\.mdl$/.test(clean)) return source.resolveModel(clean, preferredRoot); @@ -345,7 +347,7 @@ while (queue.length) { const path = queue.shift(); - if (!path || wanted.has(path) || !source.get(path)) continue; + if (!path || wanted.has(path) || NATIVE_BINARY_RE.test(path) || !source.get(path)) continue; wanted.add(path); const ext = extname(path); const root = path.startsWith('/hl2/') ? 'hl2' : 'portal'; @@ -422,6 +424,7 @@ continue; } const clean = normalizePath(raw).replace(/^\/+/, ''); + if (NATIVE_BINARY_RE.test(clean)) continue; let resolved = null; if (clean.startsWith('models/') || clean.startsWith('materials/') || clean.startsWith('sound/')) resolved = source.resolveGamePath(clean, 'portal'); else if (/\.mdl$/.test(clean)) resolved = source.resolveModel(clean, 'portal'); @@ -444,6 +447,10 @@ let files = 0; let bytes = 0; for (const path of paths) { + if (NATIVE_BINARY_RE.test(path)) { + log(`Local fallback ignored native binary: ${path}`); + continue; + } try { const blob = await source.read(path); if (blob.size > 0xffffffff) throw new Error('single file exceeds 4 GiB packed format limit'); @@ -490,7 +497,7 @@ continue; } const delta = []; - for (const path of paths) if (!seen.has(path)) { seen.add(path); delta.push(path); } + for (const path of paths) if (!seen.has(path) && !NATIVE_BINARY_RE.test(path)) { seen.add(path); delta.push(path); } progress({ phase: 'pack', mapName, index: i, total: MAPS.length, message: `Packing ${mapName}` }); const packed = await packPaths(source, delta, log); const url = new URL(`./chunks/${mapName}.data`, location.href).href; From bb1fada0e3b2624adda92c97e1dc7494c716f65c Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Tue, 8 Sep 2026 23:58:02 -0400 Subject: [PATCH 035/159] Fix optional module patch escaping in Portal build --- emscripten/build.sh | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/emscripten/build.sh b/emscripten/build.sh index c1d3f9a1c4..7117ba5d5e 100755 --- a/emscripten/build.sh +++ b/emscripten/build.sh @@ -77,7 +77,14 @@ replacement = r'''\1#ifdef __EMSCRIPTEN__ } #else ''' -updated, count = pattern.subn(replacement, text, count=1) +# IMPORTANT: use a callable replacement. Passing the string directly to re.sub +# makes Python interpret backslash sequences in the C++ body (\\ and \n), which +# corrupts the generated tier1/interface.cpp before Clang ever sees it. +updated, count = pattern.subn( + lambda match: replacement.replace(r'\1', match.group(1), 1), + text, + count=1, +) if count != 1: raise SystemExit('Render360 Portal: could not locate Emscripten Sys_LoadModule block') for marker in ( From 1702f43da83b19e0dad57301a041278ccd1d2286 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Wed, 9 Sep 2026 00:04:26 -0400 Subject: [PATCH 036/159] Validate optional Portal browser module patch before full build --- emscripten/build.sh | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/emscripten/build.sh b/emscripten/build.sh index 7117ba5d5e..27e6ecbcc6 100755 --- a/emscripten/build.sh +++ b/emscripten/build.sh @@ -97,6 +97,13 @@ for marker in ( ): if marker not in updated: raise SystemExit(f'Render360 Portal: loader patch missing {marker}') +# Guard against the exact escaping regression that previously broke CI. +if "strrchr(pModuleName, '\\\\');" not in updated: + raise SystemExit('Render360 Portal: generated backslash basename check is malformed') +if 'Msg("Render360: optional browser module skipped: %s\\n", szModuleName);' not in updated: + raise SystemExit('Render360 Portal: generated optional-module log newline is malformed') +if 'Msg("LoadLibrary: path: %s\\n", szModuleName);' not in updated: + raise SystemExit('Render360 Portal: generated LoadLibrary log newline is malformed') path.write_text(updated) print('Render360 Portal: patched Sys_LoadModule optional browser-module handling') PY From a1f507335b21dbe0c4857cfc0878dd1e314ffa66 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Wed, 9 Sep 2026 00:04:46 -0400 Subject: [PATCH 037/159] Document iPhone module loading hardening --- README.md | 70 +++++++++++++++++++++---------------------------------- 1 file changed, 27 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 3a50ceeabf..bfbc5ee02d 100644 --- a/README.md +++ b/README.md @@ -1,59 +1,43 @@ -# Emscripten port for the source engine (only portal tested) +# source-engine-render360 -## hosted on [yikes.pw](https://yikes.pw) +Emscripten port for the Source engine (Portal baseline), with an isolated iPhone Safari staging lane. -## list of broken stuff -+ sound -+ saving/loading (works, TODO: save to browser storage) -+ sometimes render breaks (something related to lightmaps?) -+ fullscreen html button (works through game settings) +## Staging branch -## building +`render360/iphone-baseline` preserves the upstream Source WebAssembly architecture while testing iPhone/WebKit compatibility before anything is merged back into the main Render360 project. -use docker, something like this, there might be some missing libs idk: -```sh -docker run --rm -it -v.:/source-engine debian +The staging build keeps: -apt update -apt install git curl wget python3 xz-utils llvm binutils -y +- pthreads / SharedArrayBuffer +- `PROXY_TO_PTHREAD` +- OffscreenCanvas +- `MAIN_MODULE` / Emscripten SIDE_MODULEs +- ToGL / WebGL2 +- Source's existing `chunks/.data` loader contract -# activate emsdk -cd / -git clone https://github.com/emscripten-core/emsdk.git -cd emsdk -git checkout 2d480a1b7c7a34a354188d93f3e89190a44a1d21 -./emsdk install latest -./emsdk activate latest -source ./emsdk_env.sh +## Portal data -cd /source-engine +No retail Portal data is committed to this repository. -# patch and rebuild sdl2 -embuilder --pic build sdl2 sdl2-mt -sed -Ei 's/freq = EM_ASM_INT/freq = MAIN_THREAD_EM_ASM_INT/' /emsdk/upstream/emscripten/cache/ports/sdl2/SDL-release-2.32.0/src/audio/emscripten/SDL_emscriptenaudio.c -embuilder --force --pic build sdl2 sdl2-mt +The staging page verifies a user-selected Portal installation locally. Runtime map data can then come from either: -# patch glMapBufferRange to allow some "unsupported" parameters -patch /emsdk/upstream/emscripten/src/lib/libwebgl.js emscripten/libwebgl.patch +1. locally generated chunks built from the selected Portal VPKs and kept in browser Cache Storage; or +2. the original upstream packed-data route when that host is accessible. -emmake ./build_emscripten.sh -``` -then download packed game data (yikes.pw/portal/chunks/mapName.data for each map) and put it to ./build/install/chunks/ +The local VPK path reads only the required archive ranges with `File.slice()` and repacks them into the same record format expected by the original Source DataLoader. -## packing game data -first of all, you'll need to build engine from https://github.com/nillerusr/source-engine for your native arch +Native game binaries (`.dll`, `.so`, `.dylib`, `.exe`) are never copied from the retail game into generated browser chunks. Emscripten `.so` SIDE_MODULEs are produced by the WebAssembly build itself and CI verifies their `\0asm` magic before Pages deployment. -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) +## iPhone browser compatibility hardening -```cpp -FileHandle_t CBaseFileSystem::OpenForRead( const char *pFileNameT, const char *pOptions, unsigned flags, const char *pathID, char **ppszResolvedFilename ) -{ - printf("OpenForRead %s %s\n", pFileNameT, pathID); - VPROF( "CBaseFileSystem::OpenForRead" ); -``` +The staging lane deliberately avoids changing the Source renderer architecture. Browser-specific fixes are limited to host/runtime boundaries: -and lauch it via emscripten/get_logs.sh script, make sure to edit map list +- Safari fullscreen uses the browser canvas API directly and falls back to inline/standalone mode when element fullscreen is unavailable. +- Portal starts with `-novid` and `-nojoy` to avoid unsupported startup/video/controller paths. +- Desktop-only optional modules such as `sourcevr`, Bink/WebM video backends, and pre-DX9/debug shader modules are skipped before `dlopen()` on Emscripten, preventing Safari from feeding 404/HTML/native files into the Wasm dynamic linker. +- Required browser modules (`filesystem_stdio`, `engine`, `materialsystem`, `shaderapidx9`, `stdshader_dx9`) must exist and be valid WebAssembly before deployment. +- The build script validates the generated C++ optional-module patch before the full Source compile so escaping regressions fail immediately with a useful message. -after that, use emscripten/repackage.js script to make .data chunks +## Merge gate -edit `knownMaps` and `baseGamePath` variables, make sure to unpack all .vpks +The staging PR remains draft until an actual iPhone run reaches a visible Portal frame, or the remaining failure has been reduced to a single reproducible WebKit/ToGL incompatibility. From d44767f9bcee2c92d45ac7259bf0286a9659f8b4 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Wed, 9 Sep 2026 00:05:28 -0400 Subject: [PATCH 038/159] Restore upstream README on iPhone baseline --- README.md | 70 ++++++++++++++++++++++++++++++++++--------------------- 1 file changed, 43 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index bfbc5ee02d..3a50ceeabf 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,59 @@ -# source-engine-render360 +# Emscripten port for the source engine (only portal tested) -Emscripten port for the Source engine (Portal baseline), with an isolated iPhone Safari staging lane. +## hosted on [yikes.pw](https://yikes.pw) -## Staging branch +## list of broken stuff ++ sound ++ saving/loading (works, TODO: save to browser storage) ++ sometimes render breaks (something related to lightmaps?) ++ fullscreen html button (works through game settings) -`render360/iphone-baseline` preserves the upstream Source WebAssembly architecture while testing iPhone/WebKit compatibility before anything is merged back into the main Render360 project. +## building -The staging build keeps: +use docker, something like this, there might be some missing libs idk: +```sh +docker run --rm -it -v.:/source-engine debian -- pthreads / SharedArrayBuffer -- `PROXY_TO_PTHREAD` -- OffscreenCanvas -- `MAIN_MODULE` / Emscripten SIDE_MODULEs -- ToGL / WebGL2 -- Source's existing `chunks/.data` loader contract +apt update +apt install git curl wget python3 xz-utils llvm binutils -y -## Portal data +# activate emsdk +cd / +git clone https://github.com/emscripten-core/emsdk.git +cd emsdk +git checkout 2d480a1b7c7a34a354188d93f3e89190a44a1d21 +./emsdk install latest +./emsdk activate latest +source ./emsdk_env.sh -No retail Portal data is committed to this repository. +cd /source-engine -The staging page verifies a user-selected Portal installation locally. Runtime map data can then come from either: +# patch and rebuild sdl2 +embuilder --pic build sdl2 sdl2-mt +sed -Ei 's/freq = EM_ASM_INT/freq = MAIN_THREAD_EM_ASM_INT/' /emsdk/upstream/emscripten/cache/ports/sdl2/SDL-release-2.32.0/src/audio/emscripten/SDL_emscriptenaudio.c +embuilder --force --pic build sdl2 sdl2-mt -1. locally generated chunks built from the selected Portal VPKs and kept in browser Cache Storage; or -2. the original upstream packed-data route when that host is accessible. +# patch glMapBufferRange to allow some "unsupported" parameters +patch /emsdk/upstream/emscripten/src/lib/libwebgl.js emscripten/libwebgl.patch -The local VPK path reads only the required archive ranges with `File.slice()` and repacks them into the same record format expected by the original Source DataLoader. +emmake ./build_emscripten.sh +``` +then download packed game data (yikes.pw/portal/chunks/mapName.data for each map) and put it to ./build/install/chunks/ -Native game binaries (`.dll`, `.so`, `.dylib`, `.exe`) are never copied from the retail game into generated browser chunks. Emscripten `.so` SIDE_MODULEs are produced by the WebAssembly build itself and CI verifies their `\0asm` magic before Pages deployment. +## packing game data +first of all, you'll need to build engine from https://github.com/nillerusr/source-engine for your native arch -## iPhone browser compatibility hardening +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) -The staging lane deliberately avoids changing the Source renderer architecture. Browser-specific fixes are limited to host/runtime boundaries: +```cpp +FileHandle_t CBaseFileSystem::OpenForRead( const char *pFileNameT, const char *pOptions, unsigned flags, const char *pathID, char **ppszResolvedFilename ) +{ + printf("OpenForRead %s %s\n", pFileNameT, pathID); + VPROF( "CBaseFileSystem::OpenForRead" ); +``` -- Safari fullscreen uses the browser canvas API directly and falls back to inline/standalone mode when element fullscreen is unavailable. -- Portal starts with `-novid` and `-nojoy` to avoid unsupported startup/video/controller paths. -- Desktop-only optional modules such as `sourcevr`, Bink/WebM video backends, and pre-DX9/debug shader modules are skipped before `dlopen()` on Emscripten, preventing Safari from feeding 404/HTML/native files into the Wasm dynamic linker. -- Required browser modules (`filesystem_stdio`, `engine`, `materialsystem`, `shaderapidx9`, `stdshader_dx9`) must exist and be valid WebAssembly before deployment. -- The build script validates the generated C++ optional-module patch before the full Source compile so escaping regressions fail immediately with a useful message. +and lauch it via emscripten/get_logs.sh script, make sure to edit map list -## Merge gate +after that, use emscripten/repackage.js script to make .data chunks -The staging PR remains draft until an actual iPhone run reaches a visible Portal frame, or the remaining failure has been reduced to a single reproducible WebKit/ToGL incompatibility. +edit `knownMaps` and `baseGamePath` variables, make sure to unpack all .vpks From 387fba4c0631936e1fabe345cc65da8dd221faef Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Wed, 9 Sep 2026 00:05:48 -0400 Subject: [PATCH 039/159] Preserve upstream README exactly From 3f3bd38d5281ce932a64188ca02d4c190eced7dc Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Wed, 9 Sep 2026 00:06:07 -0400 Subject: [PATCH 040/159] Keep baseline documentation upstream-clean From 62b0960bcbb664d055ed81b04c1723f6404cb56a Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Wed, 9 Sep 2026 03:03:23 -0400 Subject: [PATCH 041/159] Keep iPhone staging runtime network-fresh --- .github/workflows/build.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3f4b585a27..105e93d246 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -108,18 +108,38 @@ jobs: # background1 becomes available before the remaining chamber chunks # finish packing. Do not let the iPhone launch midway through the local # VPK build just because the initial chunk probe already succeeds. + # Also force Safari to update the isolation worker without HTTP cache + # and give every launcher document navigation a unique URL. This stops + # an old generated shell/glue pair from surviving across Pages deploys. python3 - <<'PY' from pathlib import Path path = Path('build/install/index.html') text = path.read_text() + old = 'const ready=threadReady&&runtimeReady&&verified&&chunk.ok;' new = 'const ready=threadReady&&runtimeReady&&verified&&chunk.ok&&!buildingLocal;' if old not in text: raise SystemExit('Render360 Portal: staging launch readiness expression changed unexpectedly') text = text.replace(old, new, 1) + + old = "const registration=await navigator.serviceWorker.register('./render360-pages-sw.js',{scope:'./'});" + new = "const registration=await navigator.serviceWorker.register('./render360-pages-sw.js',{scope:'./',updateViaCache:'none'});await registration.update();" + if old not in text: + raise SystemExit('Render360 Portal: service-worker registration changed unexpectedly') + text = text.replace(old, new, 1) + + old = "$('launch').addEventListener('click',()=>{location.href='./hl2_launcher.html'});" + new = "$('launch').addEventListener('click',()=>{location.href='./hl2_launcher.html?render360='+Date.now()});" + if old not in text: + raise SystemExit('Render360 Portal: launcher navigation changed unexpectedly') + text = text.replace(old, new, 1) + path.write_text(text) PY grep -q 'chunk.ok&&!buildingLocal' build/install/index.html + grep -q "updateViaCache:'none'" build/install/index.html + grep -q "registration.update()" build/install/index.html + grep -q "hl2_launcher.html?render360=" build/install/index.html touch build/install/.nojekyll From 7e92d96c5b245c996acd397352ca06d0006f52c7 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Wed, 9 Sep 2026 03:39:19 -0400 Subject: [PATCH 042/159] Stop preloading the first chamber on iPhone --- emscripten/pre.js | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/emscripten/pre.js b/emscripten/pre.js index 51d0543b2d..76e07c85d8 100644 --- a/emscripten/pre.js +++ b/emscripten/pre.js @@ -38,16 +38,15 @@ class DataLoader { throw new Error(`no such map: ${mapName}`) } + // The packed Portal chunks are deltas: a later map depends on all earlier + // chunks, so load only the required prefix here. Do not speculatively load + // the next chamber. background1.data is already ~220 MiB and the first + // chamber is another ~160 MiB; preloading both before the menu appears is + // unnecessary memory pressure on iPhone Safari and can push WebKit into an + // abort/termination path while Source is still creating materials. for(let i = 0; i < index + 1; i++) { await this.loadMapCached(this.mapsOrdered[i]) } - - const next = this.mapsOrdered[index + 1] - if(next) { - this.loadMapCached(next).catch(error => { - Module.printErr?.(`[Render360] background preload failed for ${next}: ${error?.stack || error}`) - }) - } } async loadMapCached(mapName) { @@ -131,10 +130,18 @@ class DataLoader { this.setProgress(mapName, 1) Module.print?.(`[Render360] loaded ${mapName}.data: ${fileCount} records, ${dv.byteLength} bytes`) + // Drop event callbacks immediately after the ArrayBuffer has been copied + // into MEMFS so WebKit can reclaim the large XHR backing store sooner. + xhr.onprogress = null + xhr.onerror = null + xhr.onload = null resolve() } catch(error) { this.setProgress(mapName, 1) Module.printErr?.(`[Render360] ${error?.stack || error}`) + xhr.onprogress = null + xhr.onerror = null + xhr.onload = null reject(error) } } From d047bae91bdf56a7ec28f6d5cdc56b9f32e4d846 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Wed, 9 Sep 2026 03:39:46 -0400 Subject: [PATCH 043/159] Avoid hard pthread pool aborts on iPhone --- emscripten/build.sh | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/emscripten/build.sh b/emscripten/build.sh index 27e6ecbcc6..49a061f09c 100755 --- a/emscripten/build.sh +++ b/emscripten/build.sh @@ -153,7 +153,7 @@ do echo "Render360 Portal: required Wasm module missing: $required" >&2 exit 1 fi - done +done echo "Render360 Portal: required filesystem/engine/ToGL module set present" @@ -163,9 +163,14 @@ for lib in build/install/*.so; do link_libs="$link_libs -l$libname" done +# Source is already proxied off the browser main thread. Let Emscripten size the +# warm pthread pool to the device instead of forcing eight Workers on every +# iPhone, and allow on-demand Workers if Source briefly exceeds that pool. +# PTHREAD_POOL_SIZE_STRICT=2 turns pool exhaustion into a hard runtime failure; +# that is exactly the wrong failure mode for this off-the-shelf engine port. 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=2047mb -sSHARED_MEMORY=1 -sUSE_PTHREADS -sPTHREAD_POOL_SIZE=navigator.hardwareConcurrency -sPTHREAD_POOL_SIZE_STRICT=0 \ -sFULL_ES3 -sSTACK_SIZE=4mb --shell-file=emscripten/shell.html \ -sASSERTIONS=2 -sSTACK_OVERFLOW_CHECK=2 --profiling-funcs \ -sPROXY_TO_PTHREAD -sOFFSCREENCANVASES_TO_PTHREAD="#canvas" -sOFFSCREENCANVAS_SUPPORT=1 \ From 33c0938a7e676e1eda589ac8ca9fd836680e8051 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Wed, 9 Sep 2026 03:40:16 -0400 Subject: [PATCH 044/159] Validate iPhone memory and pthread fixes --- .github/workflows/build.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 105e93d246..c1c6064887 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -38,11 +38,17 @@ jobs: test -f emscripten/build.sh grep -q -- '-sSHARED_MEMORY=1' emscripten/build.sh grep -q -- '-sUSE_PTHREADS' emscripten/build.sh - grep -q -- '-sPTHREAD_POOL_SIZE=8' emscripten/build.sh + grep -q -- '-sPTHREAD_POOL_SIZE=navigator.hardwareConcurrency' emscripten/build.sh + grep -q -- '-sPTHREAD_POOL_SIZE_STRICT=0' emscripten/build.sh grep -q -- '-sPROXY_TO_PTHREAD' emscripten/build.sh grep -q -- '-sOFFSCREENCANVASES_TO_PTHREAD' emscripten/build.sh grep -q -- '-sMAIN_MODULE' emscripten/build.sh grep -q 'Atomics.store' emscripten/pre.js + grep -q 'Do not speculatively load' emscripten/pre.js + if grep -q 'background preload failed' emscripten/pre.js; then + echo 'Render360 Portal: speculative next-map preload returned unexpectedly.' >&2 + exit 1 + fi grep -q 'Portal JS exception' emscripten/shell.html grep -q 'libsourcevr.so' emscripten/build.sh grep -q 'libstdshader_dx9.so' emscripten/build.sh From 8d43384bf1c2ecf9ea12cb75d8d634797c729f1e Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Wed, 9 Sep 2026 04:11:17 -0400 Subject: [PATCH 045/159] Fix iPhone dylink and shared-memory pressure --- emscripten/build.sh | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/emscripten/build.sh b/emscripten/build.sh index 49a061f09c..dbda430856 100755 --- a/emscripten/build.sh +++ b/emscripten/build.sh @@ -29,8 +29,8 @@ pattern = re.compile( re.S, ) replacement = r'''\1#ifdef __EMSCRIPTEN__ - // Emscripten SIDE_MODULEs are linked into the runtime by basename. Normalize - // absolute/relative Source module names to the linked lib*.so name first. + // Emscripten SIDE_MODULEs are loaded at runtime by basename from MEMFS. + // Normalize absolute/relative Source module names to lib*.so first. const char *pBaseName = strrchr(pModuleName, '/'); if(!pBaseName) pBaseName = strrchr(pModuleName, '\\'); pBaseName = pBaseName ? pBaseName + 1 : pModuleName; @@ -157,27 +157,34 @@ done echo "Render360 Portal: required filesystem/engine/ToGL module set present" -#link_libs="-sERROR_ON_UNDEFINED_SYMBOLS=0" +# Source uses dlopen()/dlsym() itself. Emscripten's documented runtime-dylink +# mode says not to pass SIDE_MODULEs on the main-module link command; doing so +# autoloads every library before Source later dlopens it and is what produced the +# repeated __start_em_asm/__stop_em_asm duplicate-symbol warnings on iPhone. +# Put the Wasm .so files in MEMFS instead, so each Source dlopen loads the module +# once, on demand, through the handle Source expects. +preload_libs="" for lib in build/install/*.so; do - libname=$(echo $lib | sed -E 's/^.+\/lib(.+)\.so/\1/g') - link_libs="$link_libs -l$libname" + base=$(basename "$lib") + preload_libs="$preload_libs --preload-file $lib@/$base" done -# Source is already proxied off the browser main thread. Let Emscripten size the -# warm pthread pool to the device instead of forcing eight Workers on every -# iPhone, and allow on-demand Workers if Source briefly exceeds that pool. -# PTHREAD_POOL_SIZE_STRICT=2 turns pool exhaustion into a hard runtime failure; -# that is exactly the wrong failure mode for this off-the-shelf engine port. -emcc \ +# The old 2047 MiB fixed shared heap reserved essentially the entire Wasm32 +# address-space ceiling at startup. Safari/WebKit has a long history of shared +# Wasm memory pressure at large fixed/max sizes. Start at 512 MiB and grow in +# 64 MiB steps only as Source actually needs memory, with a 1536 MiB ceiling. +# This preserves pthreads/SharedArrayBuffer while avoiding a giant eager heap. +EMCC_FORCE_STDLIBS=1 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=navigator.hardwareConcurrency -sPTHREAD_POOL_SIZE_STRICT=0 \ + -sMAIN_MODULE -sINCLUDE_FULL_LIBRARY=1 \ + -sINITIAL_MEMORY=512mb -sALLOW_MEMORY_GROWTH=1 -sMAXIMUM_MEMORY=1536mb -sMEMORY_GROWTH_LINEAR_STEP=64mb \ + -sSHARED_MEMORY=1 -sUSE_PTHREADS -sPTHREAD_POOL_SIZE=navigator.hardwareConcurrency -sPTHREAD_POOL_SIZE_STRICT=0 \ -sFULL_ES3 -sSTACK_SIZE=4mb --shell-file=emscripten/shell.html \ -sASSERTIONS=2 -sSTACK_OVERFLOW_CHECK=2 --profiling-funcs \ -sPROXY_TO_PTHREAD -sOFFSCREENCANVASES_TO_PTHREAD="#canvas" -sOFFSCREENCANVAS_SUPPORT=1 \ --pre-js emscripten/pre.js --post-js emscripten/post.js \ - -L build/install/ \ + $preload_libs \ build/launcher_main/libhl2_launcher.a \ - $link_libs \ -o build/launcher_main/hl2_launcher.html cp build/launcher_main/hl2_launcher.* build/install/ From b60f82b5c362f2466e9dd4d95ee7aa56f70857fd Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Wed, 9 Sep 2026 04:11:44 -0400 Subject: [PATCH 046/159] Keep preloaded dylib package network-fresh --- emscripten/render360-pages-sw.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/emscripten/render360-pages-sw.js b/emscripten/render360-pages-sw.js index c20c496e58..f1fa91ef03 100644 --- a/emscripten/render360-pages-sw.js +++ b/emscripten/render360-pages-sw.js @@ -9,10 +9,10 @@ * 1. chunks generated locally from the user's selected Portal/VPK files, or * 2. the original yikes.pw packed-data host when CORS allows it. * - * Mutable engine assets (.js/.wasm/.so/.html) are always fetched network-first - * with cache:no-store. The filenames stay stable between Pages deployments, and - * mixing an old Emscripten glue file with new SIDE_MODULEs (or vice versa) can - * produce misleading dylink/DataView failures on Safari. + * Mutable engine assets (.js/.wasm/.so/.html and the generated launcher .data + * package containing runtime SIDE_MODULEs) are always fetched network-first + * with cache:no-store. Chunk .data requests are handled separately above. + * Stable filenames must never mix across Pages deployments on Safari. * * No retail game data is committed to GitHub Pages. */ @@ -21,7 +21,7 @@ const LOCAL_CHUNK_CACHE = 'render360-portal-local-chunks-v2'; const OLD_LOCAL_CHUNK_CACHE = 'render360-portal-local-chunks-v1'; const UPSTREAM_CHUNK_BASE = 'https://yikes.pw/portal/chunks/'; const UPSTREAM_TIMEOUT_MS = 8000; -const MUTABLE_RUNTIME_RE = /\.(?:html?|js|mjs|wasm|so|json)$/i; +const MUTABLE_RUNTIME_RE = /\.(?:html?|js|mjs|wasm|so|json|data)$/i; // This staging revision changes how Source handles optional desktop .so modules. // Force one clean VPK-cache rebuild when the new worker activates so an iPhone From db40546fb1c4aa4e0825f04cf9d5c29a4cb8e3c3 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Wed, 9 Sep 2026 04:12:17 -0400 Subject: [PATCH 047/159] Validate runtime-dlopen and growable iPhone heap --- .github/workflows/build.yml | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c1c6064887..1327d2c212 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -43,6 +43,17 @@ jobs: grep -q -- '-sPROXY_TO_PTHREAD' emscripten/build.sh grep -q -- '-sOFFSCREENCANVASES_TO_PTHREAD' emscripten/build.sh grep -q -- '-sMAIN_MODULE' emscripten/build.sh + grep -q -- '-sINCLUDE_FULL_LIBRARY=1' emscripten/build.sh + grep -q -- '-sINITIAL_MEMORY=512mb' emscripten/build.sh + grep -q -- '-sALLOW_MEMORY_GROWTH=1' emscripten/build.sh + grep -q -- '-sMAXIMUM_MEMORY=1536mb' emscripten/build.sh + grep -q -- '-sMEMORY_GROWTH_LINEAR_STEP=64mb' emscripten/build.sh + grep -q -- '--preload-file' emscripten/build.sh + grep -q 'EMCC_FORCE_STDLIBS=1' emscripten/build.sh + if grep -q 'link_libs=' emscripten/build.sh; then + echo 'Render360 Portal: SIDE_MODULEs were returned to load-time linking; Source requires runtime dlopen.' >&2 + exit 1 + fi grep -q 'Atomics.store' emscripten/pre.js grep -q 'Do not speculatively load' emscripten/pre.js if grep -q 'background preload failed' emscripten/pre.js; then @@ -52,6 +63,7 @@ jobs: grep -q 'Portal JS exception' emscripten/shell.html grep -q 'libsourcevr.so' emscripten/build.sh grep -q 'libstdshader_dx9.so' emscripten/build.sh + grep -q 'json|data' emscripten/render360-pages-sw.js - name: Validate dual-source staging JavaScript shell: bash @@ -95,13 +107,19 @@ jobs: test -s build/install/hl2_launcher.html test -s build/install/hl2_launcher.js test -s build/install/hl2_launcher.wasm + # Runtime dlopen SIDE_MODULEs are packaged into MEMFS by --preload-file. + test -s build/install/hl2_launcher.data + grep -a -q 'hl2_launcher.data' build/install/hl2_launcher.js + grep -a -q 'libengine.so' build/install/hl2_launcher.js + grep -a -q 'libfilesystem_stdio.so' build/install/hl2_launcher.js test -s emscripten/portal-local-vpk.js grep -q 'Portal JS exception' build/install/hl2_launcher.html grep -q 'chunks/${mapName}.data' emscripten/pre.js # Public CI stays runtime-only. The browser fallback generates .data # chunks from the tester's own selected Portal/VPK files and stores - # them only in that browser's Cache Storage. + # them only in that browser's Cache Storage. hl2_launcher.data is the + # generated Emscripten runtime package and is explicitly allowed. if find build/install -type f -path '*/chunks/*.data' -print -quit | grep -q .; then echo 'Refusing to publish bundled Portal .data chunks.' >&2 exit 1 @@ -116,7 +134,7 @@ jobs: # VPK build just because the initial chunk probe already succeeds. # Also force Safari to update the isolation worker without HTTP cache # and give every launcher document navigation a unique URL. This stops - # an old generated shell/glue pair from surviving across Pages deploys. + # an old generated shell/glue/data set from surviving across Pages deploys. python3 - <<'PY' from pathlib import Path path = Path('build/install/index.html') @@ -153,8 +171,11 @@ jobs: { "repository": "${GITHUB_REPOSITORY}", "commit": "${GITHUB_SHA}", - "runtimeModel": "upstream-pthreads-shared-memory", - "engineArchitectureChanged": false, + "runtimeModel": "pthreads-shared-memory-runtime-dlopen", + "sideModuleDelivery": "preloaded-memfs", + "initialMemoryMiB": 512, + "maximumMemoryMiB": 1536, + "memoryGrowth": true, "diagnostics": true, "pagesIsolation": "service-worker-staging-only", "runtimeChunkPath": "chunks/.data", From 639289d80dc4a22ad1413a8cb1e45eb732453923 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Wed, 9 Sep 2026 04:13:58 -0400 Subject: [PATCH 048/159] Add tiny VPK boot asset overlay --- emscripten/portal-boot-overlay.js | 220 ++++++++++++++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 emscripten/portal-boot-overlay.js diff --git a/emscripten/portal-boot-overlay.js b/emscripten/portal-boot-overlay.js new file mode 100644 index 0000000000..4cde18c009 --- /dev/null +++ b/emscripten/portal-boot-overlay.js @@ -0,0 +1,220 @@ +(() => { + 'use strict'; + + const CACHE_NAME = 'render360-portal-local-chunks-v2'; + const OVERLAY_PATH = './render360-bootstrap-overlay.data'; + + // Source asks for these before/while creating the first D3D9/WebGL materials. + // They live in the shared HL2 texture VPKs shipped with Portal, but the old + // packed web chunk can omit them because they are engine bootstrap assets and + // are not necessarily referenced by the background BSP itself. + const BOOT_ASSET_SUFFIXES = [ + 'materials/debug/debugempty.vtf', + 'materials/debug/debugluxels.vtf', + 'materials/debug/debugluxelsnoalpha.vtf', + 'materials/dev/identitylightwarp.vtf', + 'materials/engine/defaultcubemap.vtf', + 'materials/engine/framesync1.vtf', + 'materials/engine/framesync2.vtf', + 'materials/engine/glinthighlight.vtf', + 'materials/engine/lightsprite.vtf', + 'materials/engine/noise-blur-256x256.vtf', + 'materials/engine/normalize.vtf', + 'materials/engine/normalizedrandomdirections2d.vtf', + 'materials/console/background01.vmt', + 'materials/console/background01.vtf', + 'materials/console/background01_widescreen.vmt', + 'materials/console/background01_widescreen.vtf', + 'materials/console/loading.vtf', + 'materials/console/startup_loading.vtf' + ]; + + function normalizePath(value) { + return String(value || '') + .replace(/\\/g, '/') + .replace(/^\/+/, '') + .replace(/\/+/g, '/') + .toLowerCase(); + } + + function dirname(path) { + const p = normalizePath(path); + const at = p.lastIndexOf('/'); + return at === -1 ? '' : p.slice(0, at); + } + + function inferRelativePath(file) { + const raw = normalizePath(file.webkitRelativePath || file.name); + if (!file.webkitRelativePath) return raw; + const parts = raw.split('/'); + return parts.length > 1 ? parts.slice(1).join('/') : raw; + } + + function readCString(bytes, state) { + const start = state.offset; + while (state.offset < bytes.length && bytes[state.offset] !== 0) state.offset++; + if (state.offset >= bytes.length) throw new Error('unterminated VPK directory string'); + const value = new TextDecoder('utf-8').decode(bytes.subarray(start, state.offset)); + state.offset++; + return value; + } + + async function indexTargets(files, log) { + const allFiles = Array.from(files || []); + const filesByRel = new Map(); + for (const file of allFiles) { + const rel = inferRelativePath(file); + if (rel) filesByRel.set(rel, file); + } + + const wanted = new Set(BOOT_ASSET_SUFFIXES.map(normalizePath)); + const found = new Map(); + const dirs = [...filesByRel.entries()].filter(([rel]) => /_dir\.vpk$/i.test(rel)); + if (!dirs.length) throw new Error('No *_dir.vpk files found for boot overlay.'); + + for (const [rel, file] of dirs) { + if (found.size === wanted.size) break; + const headerBytes = new Uint8Array(await file.slice(0, 28).arrayBuffer()); + if (headerBytes.length < 12) continue; + const headerView = new DataView(headerBytes.buffer, headerBytes.byteOffset, headerBytes.byteLength); + if (headerView.getUint32(0, true) !== 0x55aa1234) continue; + const version = headerView.getUint32(4, true); + const treeSize = headerView.getUint32(8, true); + const headerSize = version === 1 ? 12 : version === 2 ? 28 : 0; + if (!headerSize || treeSize <= 0 || headerSize + treeSize > file.size) continue; + + const treeBytes = new Uint8Array(await file.slice(headerSize, headerSize + treeSize).arrayBuffer()); + const treeView = new DataView(treeBytes.buffer, treeBytes.byteOffset, treeBytes.byteLength); + const state = { offset: 0 }; + const parent = dirname(rel); + const archiveBase = rel.slice(0, -'_dir.vpk'.length); + + while (state.offset < treeBytes.length) { + const extension = readCString(treeBytes, state); + if (!extension) break; + while (state.offset < treeBytes.length) { + const directoryRaw = readCString(treeBytes, state); + if (!directoryRaw) break; + const directory = directoryRaw === ' ' ? '' : normalizePath(directoryRaw); + while (state.offset < treeBytes.length) { + const fileNameRaw = readCString(treeBytes, state); + if (!fileNameRaw) break; + if (state.offset + 18 > treeBytes.length) throw new Error(`truncated VPK metadata in ${rel}`); + + const fileName = normalizePath(fileNameRaw); + state.offset += 4; // CRC + const preloadBytes = treeView.getUint16(state.offset, true); state.offset += 2; + const archiveIndex = treeView.getUint16(state.offset, true); state.offset += 2; + const entryOffset = treeView.getUint32(state.offset, true); state.offset += 4; + const entryLength = treeView.getUint32(state.offset, true); state.offset += 4; + const terminator = treeView.getUint16(state.offset, true); state.offset += 2; + if (terminator !== 0xffff) throw new Error(`bad VPK entry terminator in ${rel}`); + if (state.offset + preloadBytes > treeBytes.length) throw new Error(`truncated VPK preload in ${rel}`); + const preload = treeBytes.slice(state.offset, state.offset + preloadBytes); + state.offset += preloadBytes; + + const ext = extension === ' ' ? '' : normalizePath(extension); + const internal = [directory, fileName + (ext ? '.' + ext : '')].filter(Boolean).join('/'); + const suffix = normalizePath(internal); + if (!wanted.has(suffix) || found.has(suffix)) continue; + + found.set(suffix, { + path: '/' + [parent, internal].filter(Boolean).join('/'), + dirFile: file, + dirRel: rel, + archiveBase, + archiveIndex, + entryOffset, + entryLength, + preload, + headerSize, + treeSize + }); + } + } + } + } + + log(`Boot overlay: found ${found.size}/${wanted.size} shared Source assets.`); + for (const suffix of wanted) if (!found.has(suffix)) log(`Boot overlay missing from selected install: ${suffix}`); + return { found, filesByRel }; + } + + async function readDescriptor(descriptor, filesByRel) { + const pieces = []; + if (descriptor.preload.length) pieces.push(descriptor.preload); + if (descriptor.entryLength) { + let archiveFile; + let start; + if (descriptor.archiveIndex === 0x7fff) { + archiveFile = descriptor.dirFile; + start = descriptor.headerSize + descriptor.treeSize + descriptor.entryOffset; + } else { + const rel = `${descriptor.archiveBase}_${String(descriptor.archiveIndex).padStart(3, '0')}.vpk`; + archiveFile = filesByRel.get(rel); + if (!archiveFile) throw new Error(`missing VPK segment ${rel} required by ${descriptor.path}`); + start = descriptor.entryOffset; + } + const end = start + descriptor.entryLength; + if (end > archiveFile.size) throw new Error(`VPK entry exceeds ${archiveFile.name}: ${descriptor.path}`); + pieces.push(archiveFile.slice(start, end)); + } + return new Blob(pieces, { type: 'application/octet-stream' }); + } + + async function build(files, options = {}) { + if (!('caches' in globalThis)) throw new Error('Cache Storage is unavailable.'); + const log = typeof options.log === 'function' ? options.log : () => {}; + const { found, filesByRel } = await indexTargets(files, log); + if (!found.size) throw new Error('None of the shared Source boot assets were found in the selected Portal install.'); + + const encoder = new TextEncoder(); + const parts = []; + let bytes = 0; + let records = 0; + for (const descriptor of found.values()) { + const blob = await readDescriptor(descriptor, filesByRel); + const pathBytes = encoder.encode(descriptor.path); + const header = new Uint8Array(8); + const view = new DataView(header.buffer); + view.setUint32(0, pathBytes.length, true); + view.setUint32(4, blob.size, true); + parts.push(header, pathBytes, blob); + bytes += 8 + pathBytes.length + blob.size; + records++; + } + + const cache = await caches.open(CACHE_NAME); + const url = new URL(OVERLAY_PATH, location.href).href; + await cache.put(url, new Response(new Blob(parts, { type: 'application/octet-stream' }), { + headers: { + 'Content-Type': 'application/octet-stream', + 'X-Render360-Chunk-Source': 'local-vpk-boot-overlay', + 'X-Render360-Boot-Records': String(records) + } + })); + log(`Boot overlay ready: ${records} records, ${bytes} bytes.`); + return { ok: true, records, bytes, found: [...found.keys()] }; + } + + async function hasOverlay() { + if (!('caches' in globalThis)) return false; + const cache = await caches.open(CACHE_NAME); + return !!(await cache.match(new URL(OVERLAY_PATH, location.href).href)); + } + + async function clear() { + if (!('caches' in globalThis)) return; + const cache = await caches.open(CACHE_NAME); + await cache.delete(new URL(OVERLAY_PATH, location.href).href); + } + + globalThis.Render360PortalBootOverlay = { + CACHE_NAME, + OVERLAY_PATH, + BOOT_ASSET_SUFFIXES, + build, + hasOverlay, + clear + }; +})(); From ff123586d603cfcddd834252ea9166cb0df73c2d Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Wed, 9 Sep 2026 04:15:48 -0400 Subject: [PATCH 049/159] Stream local boot assets onto background chunk --- emscripten/render360-pages-sw.js | 107 +++++++++++++++++++++++++++---- 1 file changed, 95 insertions(+), 12 deletions(-) diff --git a/emscripten/render360-pages-sw.js b/emscripten/render360-pages-sw.js index f1fa91ef03..8e2aea9701 100644 --- a/emscripten/render360-pages-sw.js +++ b/emscripten/render360-pages-sw.js @@ -9,6 +9,12 @@ * 1. chunks generated locally from the user's selected Portal/VPK files, or * 2. the original yikes.pw packed-data host when CORS allows it. * + * A tiny user-generated boot overlay may also be appended to background1.data. + * The overlay contains shared HL2/Source bootstrap textures that the engine asks + * for before the first frame but that are not reliably present in the historical + * packed web chunk. Appending is safe because the .data format is simply a + * concatenation of independent records. + * * Mutable engine assets (.js/.wasm/.so/.html and the generated launcher .data * package containing runtime SIDE_MODULEs) are always fetched network-first * with cache:no-store. Chunk .data requests are handled separately above. @@ -19,6 +25,7 @@ const LOCAL_CHUNK_CACHE = 'render360-portal-local-chunks-v2'; const OLD_LOCAL_CHUNK_CACHE = 'render360-portal-local-chunks-v1'; +const BOOT_OVERLAY_PATH = './render360-bootstrap-overlay.data'; const UPSTREAM_CHUNK_BASE = 'https://yikes.pw/portal/chunks/'; const UPSTREAM_TIMEOUT_MS = 8000; const MUTABLE_RUNTIME_RE = /\.(?:html?|js|mjs|wasm|so|json|data)$/i; @@ -53,16 +60,97 @@ self.addEventListener('message', event => { } }); +function isolationHeaders(headers, extraHeaders = {}) { + const out = new Headers(headers); + out.set('Cross-Origin-Opener-Policy', 'same-origin'); + out.set('Cross-Origin-Embedder-Policy', 'require-corp'); + out.set('Cross-Origin-Resource-Policy', 'same-origin'); + for (const [key, value] of Object.entries(extraHeaders)) out.set(key, value); + return out; +} + function withIsolationHeaders(response, extraHeaders = {}) { if (!response || 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'); - headers.set('Cross-Origin-Resource-Policy', 'same-origin'); - for (const [key, value] of Object.entries(extraHeaders)) headers.set(key, value); return new Response(response.body, { status: response.status, statusText: response.statusText, + headers: isolationHeaders(response.headers, extraHeaders) + }); +} + +// Keep concatenation pull-driven. A 220 MiB background chunk must not be copied +// into a second ArrayBuffer just to append a few tiny VPK records, and cancelling +// the launch-page chunk probe must stop the upstream response immediately. +function concatReadableResponses(responses) { + let index = 0; + let reader = null; + return new ReadableStream({ + async pull(controller) { + while (index < responses.length) { + const response = responses[index]; + if (!response || !response.body) { + index++; + continue; + } + if (!reader) reader = response.body.getReader(); + const { done, value } = await reader.read(); + if (done) { + try { reader.releaseLock(); } catch (_) {} + reader = null; + index++; + continue; + } + controller.enqueue(value); + return; + } + controller.close(); + }, + async cancel(reason) { + if (reader) { + try { await reader.cancel(reason); } catch (_) {} + reader = null; + } + for (let i = index + 1; i < responses.length; i++) { + try { await responses[i]?.body?.cancel(reason); } catch (_) {} + } + } + }); +} + +async function withBootOverlay(baseResponse, cache, chunkName, sourceName) { + const isBackground = String(chunkName || '').toLowerCase() === 'background1.data'; + if (!isBackground || !baseResponse || !baseResponse.ok || !baseResponse.body) { + return withIsolationHeaders(baseResponse, { + 'X-Render360-Chunk-Source': sourceName + }); + } + + const overlayUrl = new URL(BOOT_OVERLAY_PATH, self.location.href).href; + const overlay = await cache.match(overlayUrl, { ignoreSearch: true }); + if (!overlay || !overlay.ok || !overlay.body) { + return withIsolationHeaders(baseResponse, { + 'X-Render360-Chunk-Source': sourceName, + 'X-Render360-Boot-Overlay': 'missing' + }); + } + + // The body length changes when overlay records are appended. Strip validators + // that describe only the original response so Safari cannot cache/truncate it. + const headers = isolationHeaders(baseResponse.headers, { + 'Content-Type': 'application/octet-stream', + 'Cache-Control': 'no-store, max-age=0', + 'X-Render360-Chunk-Source': sourceName + '+local-boot', + 'X-Render360-Boot-Overlay': 'appended' + }); + headers.delete('Content-Length'); + headers.delete('ETag'); + headers.delete('Content-MD5'); + headers.delete('Content-Range'); + headers.delete('Accept-Ranges'); + + return new Response(concatReadableResponses([baseResponse, overlay]), { + status: baseResponse.status, + statusText: baseResponse.statusText, headers }); } @@ -88,19 +176,14 @@ async function servePortalChunk(request, url) { const cache = await caches.open(LOCAL_CHUNK_CACHE); const local = await cache.match(request, { ignoreSearch: true }); if (local) { - return withIsolationHeaders(local, { - 'X-Render360-Chunk-Source': 'local-vpk' - }); + return withBootOverlay(local, cache, name, 'local-vpk'); } const upstreamUrl = UPSTREAM_CHUNK_BASE + encodeURIComponent(name); try { const upstream = await fetchUpstreamChunk(upstreamUrl); if (!upstream.ok) throw new Error(`HTTP ${upstream.status}`); - return withIsolationHeaders(upstream, { - 'Content-Type': upstream.headers.get('content-type') || 'application/octet-stream', - 'X-Render360-Chunk-Source': 'upstream-yikes' - }); + return withBootOverlay(upstream, cache, name, 'upstream-yikes'); } catch (error) { console.warn('[Render360 Pages SW] upstream chunk unavailable', upstreamUrl, error); return withIsolationHeaders(new Response( From 81da57011fa6be6929d2675d6e0c79541e66e6f7 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Wed, 9 Sep 2026 04:17:08 -0400 Subject: [PATCH 050/159] Wire VPK boot overlay into iPhone staging --- .github/workflows/build.yml | 68 ++++++++++++++++++++++++++++++++++--- 1 file changed, 63 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1327d2c212..238874c3de 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -63,14 +63,19 @@ jobs: grep -q 'Portal JS exception' emscripten/shell.html grep -q 'libsourcevr.so' emscripten/build.sh grep -q 'libstdshader_dx9.so' emscripten/build.sh - grep -q 'json|data' emscripten/render360-pages-sw.js + grep -q 'BOOT_OVERLAY_PATH' emscripten/render360-pages-sw.js + grep -q 'local-boot' emscripten/render360-pages-sw.js - name: Validate dual-source staging JavaScript shell: bash run: | set -euo pipefail node --check emscripten/portal-local-vpk.js + node --check emscripten/portal-boot-overlay.js node --check emscripten/render360-pages-sw.js + grep -q 'debugluxelsnoalpha.vtf' emscripten/portal-boot-overlay.js + grep -q 'identitylightwarp.vtf' emscripten/portal-boot-overlay.js + grep -q 'normalizedrandomdirections2d.vtf' emscripten/portal-boot-overlay.js python3 - <<'PY' import re from pathlib import Path @@ -113,6 +118,7 @@ jobs: grep -a -q 'libengine.so' build/install/hl2_launcher.js grep -a -q 'libfilesystem_stdio.so' build/install/hl2_launcher.js test -s emscripten/portal-local-vpk.js + test -s emscripten/portal-boot-overlay.js grep -q 'Portal JS exception' build/install/hl2_launcher.html grep -q 'chunks/${mapName}.data' emscripten/pre.js @@ -128,6 +134,7 @@ jobs: cp emscripten/pages-index.html build/install/index.html cp emscripten/render360-pages-sw.js build/install/render360-pages-sw.js cp emscripten/portal-local-vpk.js build/install/portal-local-vpk.js + cp emscripten/portal-boot-overlay.js build/install/portal-boot-overlay.js # background1 becomes available before the remaining chamber chunks # finish packing. Do not let the iPhone launch midway through the local @@ -135,15 +142,17 @@ jobs: # Also force Safari to update the isolation worker without HTTP cache # and give every launcher document navigation a unique URL. This stops # an old generated shell/glue/data set from surviving across Pages deploys. + # The tiny VPK boot overlay is mandatory for launch because the historical + # upstream background chunk can omit shared Source engine textures. python3 - <<'PY' from pathlib import Path path = Path('build/install/index.html') text = path.read_text() - old = 'const ready=threadReady&&runtimeReady&&verified&&chunk.ok;' - new = 'const ready=threadReady&&runtimeReady&&verified&&chunk.ok&&!buildingLocal;' + old = '' + new = old + '\n' if old not in text: - raise SystemExit('Render360 Portal: staging launch readiness expression changed unexpectedly') + raise SystemExit('Render360 Portal: local VPK script tag changed unexpectedly') text = text.replace(old, new, 1) old = "const registration=await navigator.serviceWorker.register('./render360-pages-sw.js',{scope:'./'});" @@ -152,6 +161,51 @@ jobs: raise SystemExit('Render360 Portal: service-worker registration changed unexpectedly') text = text.replace(old, new, 1) + old = """ $('buildLocal').disabled=!selectedFromFolder; + await refresh(false); + if(!lastChunkProbe.ok&&selectedFromFolder)await buildLocalFallback(true);""" + new = """ $('buildLocal').disabled=!selectedFromFolder; + $('launch').disabled=true; + if(selectedFromFolder&&globalThis.Render360PortalBootOverlay){ + try{ + const boot=await Render360PortalBootOverlay.build(selectedPortalFiles,{log}); + log('Local VPK boot overlay complete:',boot); + }catch(error){ + log('Local VPK boot overlay failed:',error?.stack||String(error)); + } + } + await refresh(false); + if(!lastChunkProbe.ok&&selectedFromFolder)await buildLocalFallback(true);""" + if old not in text: + raise SystemExit('Render360 Portal: ownership-success flow changed unexpectedly') + text = text.replace(old, new, 1) + + old = """ const localReady=await localChunkReady(); + if(localReady)set('localStatus',true,'ready from your VPKs');""" + new = """ const localReady=await localChunkReady(); + const bootReady=!!globalThis.Render360PortalBootOverlay&&await Render360PortalBootOverlay.hasOverlay(); + if(localReady)set('localStatus',true,'ready from your VPKs');""" + if old not in text: + raise SystemExit('Render360 Portal: local readiness block changed unexpectedly') + text = text.replace(old, new, 1) + + old = 'const ready=threadReady&&runtimeReady&&verified&&chunk.ok;' + new = 'const ready=threadReady&&runtimeReady&&verified&&chunk.ok&&bootReady&&!buildingLocal;' + if old not in text: + raise SystemExit('Render360 Portal: staging launch readiness expression changed unexpectedly') + text = text.replace(old, new, 1) + + old = """ if(!verified)$('launchHint').textContent='Verify your Portal folder first.'; + else if(!threadReady||!runtimeReady)$('launchHint').textContent='Portal is verified, but the threaded Pages runtime is not ready.'; + else if(chunk.ok&&chunk.source==='local-vpk')""" + new = """ if(!verified)$('launchHint').textContent='Verify your Portal folder first.'; + else if(!threadReady||!runtimeReady)$('launchHint').textContent='Portal is verified, but the threaded Pages runtime is not ready.'; + else if(!bootReady)$('launchHint').textContent='Choose the full Portal folder once to prepare the shared Source boot textures required before the first frame.'; + else if(chunk.ok&&chunk.source==='local-vpk')""" + if old not in text: + raise SystemExit('Render360 Portal: launch hint block changed unexpectedly') + text = text.replace(old, new, 1) + old = "$('launch').addEventListener('click',()=>{location.href='./hl2_launcher.html'});" new = "$('launch').addEventListener('click',()=>{location.href='./hl2_launcher.html?render360='+Date.now()});" if old not in text: @@ -160,7 +214,10 @@ jobs: path.write_text(text) PY - grep -q 'chunk.ok&&!buildingLocal' build/install/index.html + grep -q 'portal-boot-overlay.js' build/install/index.html + grep -q 'Render360PortalBootOverlay.build' build/install/index.html + grep -q 'Render360PortalBootOverlay.hasOverlay' build/install/index.html + grep -q 'chunk.ok&&bootReady&&!buildingLocal' build/install/index.html grep -q "updateViaCache:'none'" build/install/index.html grep -q "registration.update()" build/install/index.html grep -q "hl2_launcher.html?render360=" build/install/index.html @@ -176,6 +233,7 @@ jobs: "initialMemoryMiB": 512, "maximumMemoryMiB": 1536, "memoryGrowth": true, + "bootAssetOverlay": "selected-portal-vpk", "diagnostics": true, "pagesIsolation": "service-worker-staging-only", "runtimeChunkPath": "chunks/.data", From a60bb76a70ffda11622ca9fb4e9ca547a09e062b Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Wed, 9 Sep 2026 05:04:45 -0400 Subject: [PATCH 051/159] Fix Emscripten stdlib link for Portal runtime --- emscripten/build.sh | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/emscripten/build.sh b/emscripten/build.sh index dbda430856..84f2a7a57f 100755 --- a/emscripten/build.sh +++ b/emscripten/build.sh @@ -174,7 +174,14 @@ done # Wasm memory pressure at large fixed/max sizes. Start at 512 MiB and grow in # 64 MiB steps only as Source actually needs memory, with a 1536 MiB ceiling. # This preserves pthreads/SharedArrayBuffer while avoiding a giant eager heap. -EMCC_FORCE_STDLIBS=1 emcc \ +# +# Runtime dlopen means the main module must carry the C/C++ runtime symbols that +# SIDE_MODULEs can request. Emscripten documents EMCC_FORCE_STDLIBS=1 as the +# broad fallback, but that also force-links unrelated optional system libraries. +# With this pinned SDK that drags WebGPU/Dawn references such as +# wgpuTextureViewRelease/wgpuTextureViewSetLabel into a ToGL/WebGL build and the +# final link aborts. Force only Source's core C/C++ runtime libraries instead. +EMCC_FORCE_STDLIBS=libc,libcxx,libcxxabi emcc \ -sUSE_BZIP2=1 -sUSE_SDL=2 -sUSE_FREETYPE=1 -sUSE_LIBJPEG=1 -sUSE_LIBPNG -sMALLOC=mimalloc \ -sMAIN_MODULE -sINCLUDE_FULL_LIBRARY=1 \ -sINITIAL_MEMORY=512mb -sALLOW_MEMORY_GROWTH=1 -sMAXIMUM_MEMORY=1536mb -sMEMORY_GROWTH_LINEAR_STEP=64mb \ From 04b14765f74a8ea859689b0dd623d30c167e5a31 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Wed, 9 Sep 2026 05:22:53 -0400 Subject: [PATCH 052/159] Fix Emscripten C++ forced library names --- emscripten/build.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/emscripten/build.sh b/emscripten/build.sh index 84f2a7a57f..ed8c0dfbe4 100755 --- a/emscripten/build.sh +++ b/emscripten/build.sh @@ -181,7 +181,7 @@ done # With this pinned SDK that drags WebGPU/Dawn references such as # wgpuTextureViewRelease/wgpuTextureViewSetLabel into a ToGL/WebGL build and the # final link aborts. Force only Source's core C/C++ runtime libraries instead. -EMCC_FORCE_STDLIBS=libc,libcxx,libcxxabi emcc \ +EMCC_FORCE_STDLIBS=libc,libc++,libc++abi emcc \ -sUSE_BZIP2=1 -sUSE_SDL=2 -sUSE_FREETYPE=1 -sUSE_LIBJPEG=1 -sUSE_LIBPNG -sMALLOC=mimalloc \ -sMAIN_MODULE -sINCLUDE_FULL_LIBRARY=1 \ -sINITIAL_MEMORY=512mb -sALLOW_MEMORY_GROWTH=1 -sMAXIMUM_MEMORY=1536mb -sMEMORY_GROWTH_LINEAR_STEP=64mb \ From 8708780186230eeeb51297b3d4d4ebaf8340b8d2 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Wed, 9 Sep 2026 05:50:03 -0400 Subject: [PATCH 053/159] Fix staging HTML patch validation --- .github/workflows/build.yml | 131 ++++++++++++++++++------------------ 1 file changed, 67 insertions(+), 64 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 238874c3de..1e9c4610ca 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -49,7 +49,7 @@ jobs: grep -q -- '-sMAXIMUM_MEMORY=1536mb' emscripten/build.sh grep -q -- '-sMEMORY_GROWTH_LINEAR_STEP=64mb' emscripten/build.sh grep -q -- '--preload-file' emscripten/build.sh - grep -q 'EMCC_FORCE_STDLIBS=1' emscripten/build.sh + grep -Fq 'EMCC_FORCE_STDLIBS=libc,libc++,libc++abi' emscripten/build.sh if grep -q 'link_libs=' emscripten/build.sh; then echo 'Render360 Portal: SIDE_MODULEs were returned to load-time linking; Source requires runtime dlopen.' >&2 exit 1 @@ -136,84 +136,87 @@ jobs: cp emscripten/portal-local-vpk.js build/install/portal-local-vpk.js cp emscripten/portal-boot-overlay.js build/install/portal-boot-overlay.js - # background1 becomes available before the remaining chamber chunks - # finish packing. Do not let the iPhone launch midway through the local - # VPK build just because the initial chunk probe already succeeds. - # Also force Safari to update the isolation worker without HTTP cache - # and give every launcher document navigation a unique URL. This stops - # an old generated shell/glue/data set from surviving across Pages deploys. - # The tiny VPK boot overlay is mandatory for launch because the historical - # upstream background chunk can omit shared Source engine textures. + # Patch the generic staging page into the iPhone runtime page. Keep + # indentation inside Python string literals explicit: YAML removes the + # block-scalar indentation before the script reaches Python, which made + # the previous triple-quoted multiline probes silently lose spaces and + # fail even though the HTML was correct. python3 - <<'PY' from pathlib import Path path = Path('build/install/index.html') text = path.read_text() + def replace_once(old, new, label): + global text + if old not in text: + raise SystemExit(f'Render360 Portal: {label} changed unexpectedly') + text = text.replace(old, new, 1) + old = '' - new = old + '\n' - if old not in text: - raise SystemExit('Render360 Portal: local VPK script tag changed unexpectedly') - text = text.replace(old, new, 1) + replace_once(old, old + '\n', 'local VPK script tag') old = "const registration=await navigator.serviceWorker.register('./render360-pages-sw.js',{scope:'./'});" new = "const registration=await navigator.serviceWorker.register('./render360-pages-sw.js',{scope:'./',updateViaCache:'none'});await registration.update();" - if old not in text: - raise SystemExit('Render360 Portal: service-worker registration changed unexpectedly') - text = text.replace(old, new, 1) - - old = """ $('buildLocal').disabled=!selectedFromFolder; - await refresh(false); - if(!lastChunkProbe.ok&&selectedFromFolder)await buildLocalFallback(true);""" - new = """ $('buildLocal').disabled=!selectedFromFolder; - $('launch').disabled=true; - if(selectedFromFolder&&globalThis.Render360PortalBootOverlay){ - try{ - const boot=await Render360PortalBootOverlay.build(selectedPortalFiles,{log}); - log('Local VPK boot overlay complete:',boot); - }catch(error){ - log('Local VPK boot overlay failed:',error?.stack||String(error)); - } - } - await refresh(false); - if(!lastChunkProbe.ok&&selectedFromFolder)await buildLocalFallback(true);""" - if old not in text: - raise SystemExit('Render360 Portal: ownership-success flow changed unexpectedly') - text = text.replace(old, new, 1) - - old = """ const localReady=await localChunkReady(); - if(localReady)set('localStatus',true,'ready from your VPKs');""" - new = """ const localReady=await localChunkReady(); - const bootReady=!!globalThis.Render360PortalBootOverlay&&await Render360PortalBootOverlay.hasOverlay(); - if(localReady)set('localStatus',true,'ready from your VPKs');""" - if old not in text: - raise SystemExit('Render360 Portal: local readiness block changed unexpectedly') - text = text.replace(old, new, 1) - - old = 'const ready=threadReady&&runtimeReady&&verified&&chunk.ok;' - new = 'const ready=threadReady&&runtimeReady&&verified&&chunk.ok&&bootReady&&!buildingLocal;' - if old not in text: - raise SystemExit('Render360 Portal: staging launch readiness expression changed unexpectedly') - text = text.replace(old, new, 1) - - old = """ if(!verified)$('launchHint').textContent='Verify your Portal folder first.'; - else if(!threadReady||!runtimeReady)$('launchHint').textContent='Portal is verified, but the threaded Pages runtime is not ready.'; - else if(chunk.ok&&chunk.source==='local-vpk')""" - new = """ if(!verified)$('launchHint').textContent='Verify your Portal folder first.'; - else if(!threadReady||!runtimeReady)$('launchHint').textContent='Portal is verified, but the threaded Pages runtime is not ready.'; - else if(!bootReady)$('launchHint').textContent='Choose the full Portal folder once to prepare the shared Source boot textures required before the first frame.'; - else if(chunk.ok&&chunk.source==='local-vpk')""" - if old not in text: - raise SystemExit('Render360 Portal: launch hint block changed unexpectedly') - text = text.replace(old, new, 1) + replace_once(old, new, 'service-worker registration') + + old = ( + " $('buildLocal').disabled=!selectedFromFolder;\n" + " await refresh(false);\n" + " if(!lastChunkProbe.ok&&selectedFromFolder)await buildLocalFallback(true);" + ) + new = ( + " $('buildLocal').disabled=!selectedFromFolder;\n" + " $('launch').disabled=true;\n" + " if(selectedFromFolder&&globalThis.Render360PortalBootOverlay){\n" + " try{\n" + " const boot=await Render360PortalBootOverlay.build(selectedPortalFiles,{log});\n" + " log('Local VPK boot overlay complete:',boot);\n" + " }catch(error){\n" + " log('Local VPK boot overlay failed:',error?.stack||String(error));\n" + " }\n" + " }\n" + " await refresh(false);\n" + " if(!lastChunkProbe.ok&&selectedFromFolder)await buildLocalFallback(true);" + ) + replace_once(old, new, 'ownership-success flow') + + old = ( + " const localReady=await localChunkReady();\n" + " if(localReady)set('localStatus',true,'ready from your VPKs');" + ) + new = ( + " const localReady=await localChunkReady();\n" + " const bootReady=!!globalThis.Render360PortalBootOverlay&&await Render360PortalBootOverlay.hasOverlay();\n" + " if(localReady)set('localStatus',true,'ready from your VPKs');" + ) + replace_once(old, new, 'local readiness block') + + replace_once( + 'const ready=threadReady&&runtimeReady&&verified&&chunk.ok;', + 'const ready=threadReady&&runtimeReady&&verified&&chunk.ok&&bootReady&&!buildingLocal;', + 'staging launch readiness expression' + ) + + old = ( + " if(!verified)$('launchHint').textContent='Verify your Portal folder first.';\n" + " else if(!threadReady||!runtimeReady)$('launchHint').textContent='Portal is verified, but the threaded Pages runtime is not ready.';\n" + " else if(chunk.ok&&chunk.source==='local-vpk')" + ) + new = ( + " if(!verified)$('launchHint').textContent='Verify your Portal folder first.';\n" + " else if(!threadReady||!runtimeReady)$('launchHint').textContent='Portal is verified, but the threaded Pages runtime is not ready.';\n" + " else if(!bootReady)$('launchHint').textContent='Choose the full Portal folder once to prepare the shared Source boot textures required before the first frame.';\n" + " else if(chunk.ok&&chunk.source==='local-vpk')" + ) + replace_once(old, new, 'launch hint block') old = "$('launch').addEventListener('click',()=>{location.href='./hl2_launcher.html'});" new = "$('launch').addEventListener('click',()=>{location.href='./hl2_launcher.html?render360='+Date.now()});" - if old not in text: - raise SystemExit('Render360 Portal: launcher navigation changed unexpectedly') - text = text.replace(old, new, 1) + replace_once(old, new, 'launcher navigation') path.write_text(text) PY + grep -q 'portal-boot-overlay.js' build/install/index.html grep -q 'Render360PortalBootOverlay.build' build/install/index.html grep -q 'Render360PortalBootOverlay.hasOverlay' build/install/index.html From dda0af0f66ddefdadab21fe7d0a9747414e2c568 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Wed, 9 Sep 2026 06:31:03 -0400 Subject: [PATCH 054/159] Keep Wasm SIDE_MODULEs raw until Source dlopen --- emscripten/pre.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/emscripten/pre.js b/emscripten/pre.js index 76e07c85d8..f67216c9c7 100644 --- a/emscripten/pre.js +++ b/emscripten/pre.js @@ -1,3 +1,14 @@ +// Emscripten's preload-file Wasm plugin normally recognizes every *.so in +// hl2_launcher.data and instantiates it before main(). Source does its own +// runtime dlopen() from the launcher pthread, so that eager preload races the +// first real dlopen and can leave LDSO.loadedLibsByName[name] === "loading" +// while thread synchronization re-enters the same library. On iOS Safari that +// aborts with "Attempt to load 'liblauncher.so' twice before the first load +// completed". Disable only the preload Wasm decoder so *.so bytes are created +// as ordinary MEMFS files; Emscripten's normal dlopen loader will instantiate +// each SIDE_MODULE once, on demand, exactly when Source asks for it. +Module['noWasmDecoding'] = true + Module['arguments'] = Module['arguments'] || [] Module['arguments'].push( '-game', 'portal', From 86565d6436bd1cc66a783d71387c3a45c6540bd3 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Wed, 9 Sep 2026 07:15:31 -0400 Subject: [PATCH 055/159] Preload launcher and separate Portal boot overlay --- emscripten/pre.js | 150 ++++++++++++++++++++++++++++++---------------- 1 file changed, 99 insertions(+), 51 deletions(-) diff --git a/emscripten/pre.js b/emscripten/pre.js index f67216c9c7..a9444ba37d 100644 --- a/emscripten/pre.js +++ b/emscripten/pre.js @@ -1,14 +1,22 @@ -// Emscripten's preload-file Wasm plugin normally recognizes every *.so in -// hl2_launcher.data and instantiates it before main(). Source does its own -// runtime dlopen() from the launcher pthread, so that eager preload races the -// first real dlopen and can leave LDSO.loadedLibsByName[name] === "loading" -// while thread synchronization re-enters the same library. On iOS Safari that -// aborts with "Attempt to load 'liblauncher.so' twice before the first load -// completed". Disable only the preload Wasm decoder so *.so bytes are created -// as ordinary MEMFS files; Emscripten's normal dlopen loader will instantiate -// each SIDE_MODULE once, on demand, exactly when Source asks for it. +// Keep packaged SIDE_MODULE bytes as ordinary MEMFS files. Source performs its +// own runtime dlopen() calls and must not race Emscripten's preload-file Wasm +// decoder on the same .so names. Module['noWasmDecoding'] = true +// liblauncher.so is the first Source module opened from the PROXY_TO_PTHREAD +// application thread. On iOS the first runtime dlopen can re-enter through +// Emscripten's pthread task queue while that DSO is still marked "loading", +// producing: Attempt to load 'liblauncher.so' twice before the first load +// completed. Load this ONE root DSO before main() using Emscripten's supported +// MAIN_MODULE startup path. Source's later dlopen then reuses the completed DSO. +// All other Source SIDE_MODULEs remain demand-loaded by Source. +Module['dynamicLibraries'] = ['liblauncher.so'] + +Module['preRun'] = Module['preRun'] || [] +Module['preRun'].push(() => { + Module.print?.('[Render360] load-time liblauncher preload requested') +}) + Module['arguments'] = Module['arguments'] || [] Module['arguments'].push( '-game', 'portal', @@ -42,6 +50,7 @@ class DataLoader { ] loadedMaps = {} + bootOverlayPromise = null async loadMapWithDeps(mapName) { const index = this.mapsOrdered.indexOf(mapName) @@ -53,8 +62,7 @@ class DataLoader { // chunks, so load only the required prefix here. Do not speculatively load // the next chamber. background1.data is already ~220 MiB and the first // chamber is another ~160 MiB; preloading both before the menu appears is - // unnecessary memory pressure on iPhone Safari and can push WebKit into an - // abort/termination path while Source is still creating materials. + // unnecessary memory pressure on iPhone Safari. for(let i = 0; i < index + 1; i++) { await this.loadMapCached(this.mapsOrdered[i]) } @@ -80,6 +88,82 @@ class DataLoader { } } + writeDataBuffer(arrayBuffer, label) { + if(!(arrayBuffer instanceof ArrayBuffer)) { + throw new Error(`${label}: response is not binary data`) + } + + const dv = new DataView(arrayBuffer) + let offset = 0 + let fileCount = 0 + const decoder = new TextDecoder() + + // data format: { pathLen: uint32le, dataLen: uint32le, path: bytes, blob: bytes }[] + while(offset < dv.byteLength) { + if(dv.byteLength - offset < 8) { + throw new Error(`${label}: truncated record header at ${offset}/${dv.byteLength}`) + } + const pathLen = dv.getUint32(offset, true) + const dataLen = dv.getUint32(offset + 4, true) + const recordEnd = offset + 8 + pathLen + dataLen + if(pathLen === 0 || pathLen > 1024 * 1024 || recordEnd > dv.byteLength) { + throw new Error(`${label}: record ${fileCount} exceeds buffer (${recordEnd}/${dv.byteLength})`) + } + + const path = decoder.decode(new Uint8Array(dv.buffer, offset + 8, pathLen)) + const blob = new Uint8Array(dv.buffer, offset + 8 + pathLen, dataLen) + offset = recordEnd + fileCount++ + + // Game-data chunks must never supply native executables/shared libraries. + // Emscripten SIDE_MODULE .so files are built and shipped with the runtime, + // not sourced from Portal retail/VPK data. + if(/\.(?:dll|dylib|exe|so)$/i.test(path)) { + Module.printErr?.(`[Render360] ignored native binary from game-data chunk: ${path}`) + continue + } + + const dir = path.replace(/\/[^\/]+$/, '') + FS.mkdirTree(dir) + FS.writeFile(path, blob) + } + + return { fileCount, byteLength: dv.byteLength } + } + + async loadBootOverlay() { + if(this.bootOverlayPromise) return this.bootOverlayPromise + + this.bootOverlayPromise = (async () => { + try { + // Fetch the tiny user-generated VPK overlay separately from the ~220 MiB + // background chunk. Concatenating them into one service-worker stream was + // intermittently ending as an XHR network error on iOS Safari even though + // the launch-page header probe returned HTTP 200. + const response = await fetch('render360-bootstrap-overlay.data', { + cache: 'no-store', + credentials: 'same-origin' + }) + if(response.status === 404) { + Module.print?.('[Render360] no local boot overlay present; continuing with base chunk') + return + } + if(!response.ok) { + throw new Error(`HTTP ${response.status}`) + } + const bytes = await response.arrayBuffer() + const result = this.writeDataBuffer(bytes, 'boot overlay') + Module.print?.(`[Render360] loaded boot overlay: ${result.fileCount} records, ${result.byteLength} bytes`) + } catch(error) { + // The base historical chunk may already contain enough files to boot, so + // surface the overlay error but do not convert it into a fake map failure. + Module.printErr?.(`[Render360] boot overlay load failed: ${error?.stack || error}`) + } + })() + + return this.bootOverlayPromise + } + async loadMap(mapName) { this.setProgress(mapName, 0) @@ -96,53 +180,17 @@ class DataLoader { reject(new Error(`cannot load map ${mapName}: network error`)) } - xhr.onload = () => { + xhr.onload = async () => { try { if(xhr.status < 200 || xhr.status >= 300) { throw new Error(`cannot load map ${mapName}: HTTP ${xhr.status}`) } - if(!(xhr.response instanceof ArrayBuffer)) { - throw new Error(`cannot load map ${mapName}: response is not binary data`) - } - const dv = new DataView(xhr.response) - let offset = 0 - let fileCount = 0 - - // data format: { pathLen: uint32le, dataLen: uint32le, path: bytes, blob: bytes }[] - while(offset < dv.byteLength) { - if(dv.byteLength - offset < 8) { - throw new Error(`corrupt ${mapName}.data: truncated record header at ${offset}/${dv.byteLength}`) - } - const pathLen = dv.getUint32(offset, true) - const dataLen = dv.getUint32(offset + 4, true) - const recordEnd = offset + 8 + pathLen + dataLen - if(pathLen === 0 || pathLen > 1024 * 1024 || recordEnd > dv.byteLength) { - throw new Error(`corrupt ${mapName}.data: record ${fileCount} exceeds buffer (${recordEnd}/${dv.byteLength})`) - } - - const path = new TextDecoder().decode(new Uint8Array(dv.buffer, offset + 8, pathLen)) - const blob = new Uint8Array(dv.buffer, offset + 8 + pathLen, dataLen) - offset = recordEnd - fileCount++ - - // Game-data chunks must never supply native executables/shared libraries. - // Emscripten SIDE_MODULE .so files are built and shipped with the runtime, - // not sourced from Portal retail/VPK data. - if(/\.(?:dll|dylib|exe|so)$/i.test(path)) { - Module.printErr?.(`[Render360] ignored native binary from game-data chunk: ${path}`) - continue - } - - const dir = path.replace(/\/[^\/]+$/, '') - FS.mkdirTree(dir) - FS.writeFile(path, blob) - } + const result = this.writeDataBuffer(xhr.response, `${mapName}.data`) + if(mapName === 'background1') await this.loadBootOverlay() this.setProgress(mapName, 1) - Module.print?.(`[Render360] loaded ${mapName}.data: ${fileCount} records, ${dv.byteLength} bytes`) - // Drop event callbacks immediately after the ArrayBuffer has been copied - // into MEMFS so WebKit can reclaim the large XHR backing store sooner. + Module.print?.(`[Render360] loaded ${mapName}.data: ${result.fileCount} records, ${result.byteLength} bytes`) xhr.onprogress = null xhr.onerror = null xhr.onload = null From 2c6d94b5b55dfa15abc698c5a30f7dc92f33faac Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Wed, 9 Sep 2026 07:16:00 -0400 Subject: [PATCH 056/159] Serve Portal boot overlay separately from map stream --- emscripten/render360-pages-sw.js | 132 ++++++++++--------------------- 1 file changed, 41 insertions(+), 91 deletions(-) diff --git a/emscripten/render360-pages-sw.js b/emscripten/render360-pages-sw.js index 8e2aea9701..fb0bbc1637 100644 --- a/emscripten/render360-pages-sw.js +++ b/emscripten/render360-pages-sw.js @@ -9,16 +9,16 @@ * 1. chunks generated locally from the user's selected Portal/VPK files, or * 2. the original yikes.pw packed-data host when CORS allows it. * - * A tiny user-generated boot overlay may also be appended to background1.data. - * The overlay contains shared HL2/Source bootstrap textures that the engine asks - * for before the first frame but that are not reliably present in the historical - * packed web chunk. Appending is safe because the .data format is simply a - * concatenation of independent records. + * The tiny user-generated Source boot overlay is deliberately served as its + * own response. Earlier revisions appended it to background1.data with a + * synthetic ReadableStream. iOS Safari could successfully probe that response + * as HTTP 200 and then fail the full ~220 MiB XHR mid-stream with a network + * error. The runtime now loads the base chunk first, then fetches the <1 MiB + * overlay separately and writes those records into MEMFS. * * Mutable engine assets (.js/.wasm/.so/.html and the generated launcher .data * package containing runtime SIDE_MODULEs) are always fetched network-first - * with cache:no-store. Chunk .data requests are handled separately above. - * Stable filenames must never mix across Pages deployments on Safari. + * with cache:no-store. Stable filenames must never mix across Pages deploys. * * No retail game data is committed to GitHub Pages. */ @@ -30,9 +30,6 @@ const UPSTREAM_CHUNK_BASE = 'https://yikes.pw/portal/chunks/'; const UPSTREAM_TIMEOUT_MS = 8000; const MUTABLE_RUNTIME_RE = /\.(?:html?|js|mjs|wasm|so|json|data)$/i; -// This staging revision changes how Source handles optional desktop .so modules. -// Force one clean VPK-cache rebuild when the new worker activates so an iPhone -// cannot keep testing a chunk set created by an older runtime revision. const REBUILD_LOCAL_CHUNKS_ON_ACTIVATE = true; self.addEventListener('install', event => { @@ -78,83 +75,6 @@ function withIsolationHeaders(response, extraHeaders = {}) { }); } -// Keep concatenation pull-driven. A 220 MiB background chunk must not be copied -// into a second ArrayBuffer just to append a few tiny VPK records, and cancelling -// the launch-page chunk probe must stop the upstream response immediately. -function concatReadableResponses(responses) { - let index = 0; - let reader = null; - return new ReadableStream({ - async pull(controller) { - while (index < responses.length) { - const response = responses[index]; - if (!response || !response.body) { - index++; - continue; - } - if (!reader) reader = response.body.getReader(); - const { done, value } = await reader.read(); - if (done) { - try { reader.releaseLock(); } catch (_) {} - reader = null; - index++; - continue; - } - controller.enqueue(value); - return; - } - controller.close(); - }, - async cancel(reason) { - if (reader) { - try { await reader.cancel(reason); } catch (_) {} - reader = null; - } - for (let i = index + 1; i < responses.length; i++) { - try { await responses[i]?.body?.cancel(reason); } catch (_) {} - } - } - }); -} - -async function withBootOverlay(baseResponse, cache, chunkName, sourceName) { - const isBackground = String(chunkName || '').toLowerCase() === 'background1.data'; - if (!isBackground || !baseResponse || !baseResponse.ok || !baseResponse.body) { - return withIsolationHeaders(baseResponse, { - 'X-Render360-Chunk-Source': sourceName - }); - } - - const overlayUrl = new URL(BOOT_OVERLAY_PATH, self.location.href).href; - const overlay = await cache.match(overlayUrl, { ignoreSearch: true }); - if (!overlay || !overlay.ok || !overlay.body) { - return withIsolationHeaders(baseResponse, { - 'X-Render360-Chunk-Source': sourceName, - 'X-Render360-Boot-Overlay': 'missing' - }); - } - - // The body length changes when overlay records are appended. Strip validators - // that describe only the original response so Safari cannot cache/truncate it. - const headers = isolationHeaders(baseResponse.headers, { - 'Content-Type': 'application/octet-stream', - 'Cache-Control': 'no-store, max-age=0', - 'X-Render360-Chunk-Source': sourceName + '+local-boot', - 'X-Render360-Boot-Overlay': 'appended' - }); - headers.delete('Content-Length'); - headers.delete('ETag'); - headers.delete('Content-MD5'); - headers.delete('Content-Range'); - headers.delete('Accept-Ranges'); - - return new Response(concatReadableResponses([baseResponse, overlay]), { - status: baseResponse.status, - statusText: baseResponse.statusText, - headers - }); -} - async function fetchUpstreamChunk(upstreamUrl) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort('upstream chunk timeout'), UPSTREAM_TIMEOUT_MS); @@ -176,14 +96,20 @@ async function servePortalChunk(request, url) { const cache = await caches.open(LOCAL_CHUNK_CACHE); const local = await cache.match(request, { ignoreSearch: true }); if (local) { - return withBootOverlay(local, cache, name, 'local-vpk'); + return withIsolationHeaders(local, { + 'Cache-Control': 'no-store, max-age=0', + 'X-Render360-Chunk-Source': 'local-vpk' + }); } const upstreamUrl = UPSTREAM_CHUNK_BASE + encodeURIComponent(name); try { const upstream = await fetchUpstreamChunk(upstreamUrl); if (!upstream.ok) throw new Error(`HTTP ${upstream.status}`); - return withBootOverlay(upstream, cache, name, 'upstream-yikes'); + return withIsolationHeaders(upstream, { + 'Cache-Control': 'no-store, max-age=0', + 'X-Render360-Chunk-Source': 'upstream-yikes' + }); } catch (error) { console.warn('[Render360 Pages SW] upstream chunk unavailable', upstreamUrl, error); return withIsolationHeaders(new Response( @@ -195,7 +121,28 @@ async function servePortalChunk(request, url) { } } -async function fetchRuntimeFresh(request, url) { +async function serveBootOverlay() { + const cache = await caches.open(LOCAL_CHUNK_CACHE); + const overlayUrl = new URL(BOOT_OVERLAY_PATH, self.location.href).href; + const overlay = await cache.match(overlayUrl, { ignoreSearch: true }); + if (!overlay || !overlay.ok) { + return withIsolationHeaders(new Response('Render360 local boot overlay not prepared', { + status: 404, + headers: { 'Content-Type': 'text/plain; charset=utf-8' } + }), { + 'Cache-Control': 'no-store, max-age=0', + 'X-Render360-Chunk-Source': 'local-boot-missing' + }); + } + + return withIsolationHeaders(overlay, { + 'Content-Type': 'application/octet-stream', + 'Cache-Control': 'no-store, max-age=0', + 'X-Render360-Chunk-Source': 'local-boot-overlay' + }); +} + +async function fetchRuntimeFresh(request) { const freshRequest = new Request(request, { cache: 'no-store' }); const response = await fetch(freshRequest); return withIsolationHeaders(response, { @@ -217,11 +164,14 @@ self.addEventListener('fetch', event => { } event.respondWith((async () => { + if (request.method === 'GET' && url.pathname.endsWith('/render360-bootstrap-overlay.data')) { + return serveBootOverlay(); + } if (request.method === 'GET' && /\/chunks\/[^/]+\.data$/i.test(url.pathname)) { return servePortalChunk(request, url); } if (request.method === 'GET' && MUTABLE_RUNTIME_RE.test(url.pathname)) { - return fetchRuntimeFresh(request, url); + return fetchRuntimeFresh(request); } return withIsolationHeaders(await fetch(request)); })().catch(error => { From 041df3c4851c8ac2f707e39ba0b2594b5b10a4c2 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Wed, 9 Sep 2026 10:11:43 -0400 Subject: [PATCH 057/159] Prefer original Portal chunks and preserve local fallback cache --- emscripten/render360-pages-sw.js | 67 +++++++++++++++++++++----------- 1 file changed, 45 insertions(+), 22 deletions(-) diff --git a/emscripten/render360-pages-sw.js b/emscripten/render360-pages-sw.js index fb0bbc1637..e0be687cd5 100644 --- a/emscripten/render360-pages-sw.js +++ b/emscripten/render360-pages-sw.js @@ -5,9 +5,10 @@ * SharedArrayBuffer runtime can be exercised on static hosting. * * Portal's Source runtime still requests the original same-origin - * `chunks/.data` path. This worker provides that path from either: - * 1. chunks generated locally from the user's selected Portal/VPK files, or - * 2. the original yikes.pw packed-data host when CORS allows it. + * `chunks/.data` path. Keep the original web port's packed data as the + * authoritative first choice because those chunks were generated from the + * Source engine's real OpenForRead trace/order. A locally generated VPK chunk + * is only the fallback when the original host cannot be reached. * * The tiny user-generated Source boot overlay is deliberately served as its * own response. Earlier revisions appended it to background1.data with a @@ -28,9 +29,17 @@ const OLD_LOCAL_CHUNK_CACHE = 'render360-portal-local-chunks-v1'; const BOOT_OVERLAY_PATH = './render360-bootstrap-overlay.data'; const UPSTREAM_CHUNK_BASE = 'https://yikes.pw/portal/chunks/'; const UPSTREAM_TIMEOUT_MS = 8000; +const UPSTREAM_RETRY_COOLDOWN_MS = 60000; const MUTABLE_RUNTIME_RE = /\.(?:html?|js|mjs|wasm|so|json|data)$/i; -const REBUILD_LOCAL_CHUNKS_ON_ACTIVATE = true; +// Never delete the current local cache merely because a new service worker +// activates. The iPhone staging page can spend minutes building chunks from +// user-selected VPKs; deleting that same cache during the launcher navigation +// makes the following /chunks/background1.data request fall through to the +// network and look like an extraction/RAM failure. Schema changes should bump +// LOCAL_CHUNK_CACHE instead. +const REBUILD_LOCAL_CHUNKS_ON_ACTIVATE = false; +let upstreamUnavailableUntil = 0; self.addEventListener('install', event => { self.skipWaiting(); @@ -94,31 +103,45 @@ async function fetchUpstreamChunk(upstreamUrl) { async function servePortalChunk(request, url) { const name = url.pathname.split('/').pop(); const cache = await caches.open(LOCAL_CHUNK_CACHE); + const upstreamUrl = UPSTREAM_CHUNK_BASE + encodeURIComponent(name); + + // The original hosted chunks are the canonical Portal web-port chunks. Use + // them first whenever the host is healthy. This restores the exact map delta + // plan that weliveinhell/source-engine's DataLoader was written for instead + // of silently preferring our heuristic VPK reconstruction just because a + // local cache entry exists. + if (Date.now() >= upstreamUnavailableUntil) { + try { + const upstream = await fetchUpstreamChunk(upstreamUrl); + if (!upstream.ok) throw new Error(`HTTP ${upstream.status}`); + upstreamUnavailableUntil = 0; + return withIsolationHeaders(upstream, { + 'Cache-Control': 'no-store, max-age=0', + 'X-Render360-Chunk-Source': 'upstream-yikes' + }); + } catch (error) { + upstreamUnavailableUntil = Date.now() + UPSTREAM_RETRY_COOLDOWN_MS; + console.warn('[Render360 Pages SW] original Portal chunk unavailable; trying local VPK fallback', upstreamUrl, error); + } + } + + // Only fall back to browser-generated chunks after the original host failed. + // Cache Storage can stream the stored Response back without rebuilding all + // VPKs or retaining the user's selected File objects in the launcher page. const local = await cache.match(request, { ignoreSearch: true }); - if (local) { + if (local && local.ok) { return withIsolationHeaders(local, { 'Cache-Control': 'no-store, max-age=0', 'X-Render360-Chunk-Source': 'local-vpk' }); } - const upstreamUrl = UPSTREAM_CHUNK_BASE + encodeURIComponent(name); - try { - const upstream = await fetchUpstreamChunk(upstreamUrl); - if (!upstream.ok) throw new Error(`HTTP ${upstream.status}`); - return withIsolationHeaders(upstream, { - 'Cache-Control': 'no-store, max-age=0', - 'X-Render360-Chunk-Source': 'upstream-yikes' - }); - } catch (error) { - console.warn('[Render360 Pages SW] upstream chunk unavailable', upstreamUrl, error); - return withIsolationHeaders(new Response( - 'Portal chunk unavailable from both local VPK cache and original upstream host: ' + String(error), - { status: 502, headers: { 'Content-Type': 'text/plain; charset=utf-8' } } - ), { - 'X-Render360-Chunk-Source': 'unavailable' - }); - } + return withIsolationHeaders(new Response( + 'Portal chunk unavailable from the original Source web-port host and the local VPK cache.', + { status: 502, headers: { 'Content-Type': 'text/plain; charset=utf-8' } } + ), { + 'X-Render360-Chunk-Source': 'unavailable' + }); } async function serveBootOverlay() { From df13a40f8677228a3b33bb9a674ac9cc6bc42441 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Wed, 9 Sep 2026 10:13:41 -0400 Subject: [PATCH 058/159] Keep Portal boot overlay outside local map cache --- emscripten/portal-boot-overlay.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/emscripten/portal-boot-overlay.js b/emscripten/portal-boot-overlay.js index 4cde18c009..6c4ae2b97b 100644 --- a/emscripten/portal-boot-overlay.js +++ b/emscripten/portal-boot-overlay.js @@ -1,7 +1,13 @@ (() => { 'use strict'; - const CACHE_NAME = 'render360-portal-local-chunks-v2'; + // Keep the boot overlay in its own cache. The local VPK map builder clears + // and rebuilds render360-portal-local-chunks-v2 before packing maps; when the + // overlay shared that cache it was silently deleted immediately after a + // successful Portal-folder verification. That produced the confusing state + // "local fallback ready" + "choose folder to prepare boot textures" and could + // make the launcher fall back to the network again. + const CACHE_NAME = 'render360-portal-boot-overlay-v1'; const OVERLAY_PATH = './render360-bootstrap-overlay.data'; // Source asks for these before/while creating the first D3D9/WebGL materials. From 65c9d382c5f449aec59d30e0ea9fc973be5c6033 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Wed, 9 Sep 2026 10:14:06 -0400 Subject: [PATCH 059/159] Keep boot overlay independent from map cache rebuilds --- emscripten/render360-pages-sw.js | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/emscripten/render360-pages-sw.js b/emscripten/render360-pages-sw.js index e0be687cd5..2f5b616dd8 100644 --- a/emscripten/render360-pages-sw.js +++ b/emscripten/render360-pages-sw.js @@ -11,11 +11,11 @@ * is only the fallback when the original host cannot be reached. * * The tiny user-generated Source boot overlay is deliberately served as its - * own response. Earlier revisions appended it to background1.data with a - * synthetic ReadableStream. iOS Safari could successfully probe that response - * as HTTP 200 and then fail the full ~220 MiB XHR mid-stream with a network - * error. The runtime now loads the base chunk first, then fetches the <1 MiB - * overlay separately and writes those records into MEMFS. + * own response and lives in its own Cache Storage bucket. Earlier revisions + * appended it to background1.data with a synthetic ReadableStream, and later + * revisions put it in the same cache the map builder clears before rebuilding. + * Both paths could make iOS Safari lose the overlay between verification and + * launch. Keeping it separate makes the verified Portal-folder state stable. * * Mutable engine assets (.js/.wasm/.so/.html and the generated launcher .data * package containing runtime SIDE_MODULEs) are always fetched network-first @@ -26,6 +26,7 @@ const LOCAL_CHUNK_CACHE = 'render360-portal-local-chunks-v2'; const OLD_LOCAL_CHUNK_CACHE = 'render360-portal-local-chunks-v1'; +const BOOT_OVERLAY_CACHE = 'render360-portal-boot-overlay-v1'; const BOOT_OVERLAY_PATH = './render360-bootstrap-overlay.data'; const UPSTREAM_CHUNK_BASE = 'https://yikes.pw/portal/chunks/'; const UPSTREAM_TIMEOUT_MS = 8000; @@ -145,7 +146,7 @@ async function servePortalChunk(request, url) { } async function serveBootOverlay() { - const cache = await caches.open(LOCAL_CHUNK_CACHE); + const cache = await caches.open(BOOT_OVERLAY_CACHE); const overlayUrl = new URL(BOOT_OVERLAY_PATH, self.location.href).href; const overlay = await cache.match(overlayUrl, { ignoreSearch: true }); if (!overlay || !overlay.ok) { From a0614212433e80fff22d0732e11149a574d7c03b Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Wed, 9 Sep 2026 11:49:43 -0400 Subject: [PATCH 060/159] Include Source shader cache in Portal boot overlay --- emscripten/portal-boot-overlay.js | 171 ++++++++++++++++++++++++------ 1 file changed, 141 insertions(+), 30 deletions(-) diff --git a/emscripten/portal-boot-overlay.js b/emscripten/portal-boot-overlay.js index 6c4ae2b97b..16576cbba7 100644 --- a/emscripten/portal-boot-overlay.js +++ b/emscripten/portal-boot-overlay.js @@ -1,19 +1,17 @@ (() => { 'use strict'; - // Keep the boot overlay in its own cache. The local VPK map builder clears - // and rebuilds render360-portal-local-chunks-v2 before packing maps; when the - // overlay shared that cache it was silently deleted immediately after a - // successful Portal-folder verification. That produced the confusing state - // "local fallback ready" + "choose folder to prepare boot textures" and could - // make the launcher fall back to the network again. - const CACHE_NAME = 'render360-portal-boot-overlay-v1'; + // This overlay is intentionally separate from the map-chunk cache. It is + // prepared from the tester's own Portal installation and loaded before the + // Source material system starts, so engine bootstrap files that are not + // referenced by a BSP are already present in MEMFS. + // + // v2 adds the retail Source .vcs shader cache. The previous 18-record overlay + // fixed bootstrap textures but still let libstdshader_dx9 reach + // vertexlit_and_unlit_generic_* before shaders/fxc/*.vcs existed in MEMFS. + const CACHE_NAME = 'render360-portal-boot-overlay-v2'; const OVERLAY_PATH = './render360-bootstrap-overlay.data'; - // Source asks for these before/while creating the first D3D9/WebGL materials. - // They live in the shared HL2 texture VPKs shipped with Portal, but the old - // packed web chunk can omit them because they are engine bootstrap assets and - // are not necessarily referenced by the background BSP itself. const BOOT_ASSET_SUFFIXES = [ 'materials/debug/debugempty.vtf', 'materials/debug/debugluxels.vtf', @@ -27,6 +25,8 @@ 'materials/engine/noise-blur-256x256.vtf', 'materials/engine/normalize.vtf', 'materials/engine/normalizedrandomdirections2d.vtf', + 'materials/effects/flashlight001.vtf', + 'materials/effects/flashlight_border.vtf', 'materials/console/background01.vmt', 'materials/console/background01.vtf', 'materials/console/background01_widescreen.vmt', @@ -35,6 +35,14 @@ 'materials/console/startup_loading.vtf' ]; + // Source's DX9-on-GL path loads precompiled Direct3D shader combo archives + // from shaders/{fxc,vsh,psh}/*.vcs and TOGL translates those programs to GL. + // They are runtime resources, not native executables, and a background BSP + // dependency scan cannot discover them. Keep every retail .vcs file from the + // selected Portal/HL2/platform search roots in this small independent overlay. + const SHADER_RE = /(?:^|\/)shaders\/(?:fxc|vsh|psh)\/[^/]+\.vcs$/i; + const MAX_SINGLE_SHADER_BYTES = 16 * 1024 * 1024; + function normalizePath(value) { return String(value || '') .replace(/\\/g, '/') @@ -65,21 +73,77 @@ return value; } + function fixedSuffixFor(path) { + const clean = normalizePath(path); + for (const suffix of BOOT_ASSET_SUFFIXES) { + const wanted = normalizePath(suffix); + if (clean === wanted || clean.endsWith('/' + wanted)) return wanted; + } + return null; + } + + function isShaderPath(path, size) { + const clean = normalizePath(path); + return SHADER_RE.test(clean) && Number(size || 0) <= MAX_SINGLE_SHADER_BYTES; + } + + function addDescriptor(found, descriptor) { + const key = normalizePath(descriptor.path); + if (!key || found.has(key)) return false; + found.set(key, descriptor); + return true; + } + async function indexTargets(files, log) { const allFiles = Array.from(files || []); const filesByRel = new Map(); + const found = new Map(); + const fixedFound = new Set(); + let looseShaders = 0; + let vpkShaders = 0; + let skippedHugeShaders = 0; + for (const file of allFiles) { const rel = inferRelativePath(file); - if (rel) filesByRel.set(rel, file); + if (!rel) continue; + filesByRel.set(rel, file); + + // Folder selection can expose some game resources as loose files rather + // than VPK members. Index those too; older overlay revisions only scanned + // *_dir.vpk and therefore missed loose platform shader caches. + if (/^(?:portal|hl2|platform)\//.test(rel)) { + const fixed = fixedSuffixFor(rel); + if (fixed) { + fixedFound.add(fixed); + addDescriptor(found, { + kind: 'loose', + path: '/' + rel, + file, + size: file.size, + category: 'boot' + }); + } + if (SHADER_RE.test(rel)) { + if (file.size <= MAX_SINGLE_SHADER_BYTES) { + if (addDescriptor(found, { + kind: 'loose', + path: '/' + rel, + file, + size: file.size, + category: 'shader' + })) looseShaders++; + } else { + skippedHugeShaders++; + log(`Boot overlay skipped unusually large loose shader (${file.size} bytes): ${rel}`); + } + } + } } - const wanted = new Set(BOOT_ASSET_SUFFIXES.map(normalizePath)); - const found = new Map(); const dirs = [...filesByRel.entries()].filter(([rel]) => /_dir\.vpk$/i.test(rel)); if (!dirs.length) throw new Error('No *_dir.vpk files found for boot overlay.'); for (const [rel, file] of dirs) { - if (found.size === wanted.size) break; const headerBytes = new Uint8Array(await file.slice(0, 28).arrayBuffer()); if (headerBytes.length < 12) continue; const headerView = new DataView(headerBytes.buffer, headerBytes.byteOffset, headerBytes.byteLength); @@ -121,10 +185,22 @@ const ext = extension === ' ' ? '' : normalizePath(extension); const internal = [directory, fileName + (ext ? '.' + ext : '')].filter(Boolean).join('/'); - const suffix = normalizePath(internal); - if (!wanted.has(suffix) || found.has(suffix)) continue; + const fixed = fixedSuffixFor(internal); + const totalSize = preload.length + entryLength; + const shaderCandidate = SHADER_RE.test(internal); + const shader = shaderCandidate && totalSize <= MAX_SINGLE_SHADER_BYTES; + + if (!fixed && !shader) { + if (shaderCandidate && totalSize > MAX_SINGLE_SHADER_BYTES) { + skippedHugeShaders++; + log(`Boot overlay skipped unusually large VPK shader (${totalSize} bytes): ${parent}/${internal}`); + } + continue; + } - found.set(suffix, { + if (fixed) fixedFound.add(fixed); + const descriptor = { + kind: 'vpk', path: '/' + [parent, internal].filter(Boolean).join('/'), dirFile: file, dirRel: rel, @@ -134,19 +210,30 @@ entryLength, preload, headerSize, - treeSize - }); + treeSize, + size: totalSize, + category: shader ? 'shader' : 'boot' + }; + if (addDescriptor(found, descriptor) && shader) vpkShaders++; } } } } - log(`Boot overlay: found ${found.size}/${wanted.size} shared Source assets.`); - for (const suffix of wanted) if (!found.has(suffix)) log(`Boot overlay missing from selected install: ${suffix}`); - return { found, filesByRel }; + log(`Boot overlay: found ${fixedFound.size}/${BOOT_ASSET_SUFFIXES.length} fixed Source assets.`); + for (const suffix of BOOT_ASSET_SUFFIXES) { + const normalized = normalizePath(suffix); + if (!fixedFound.has(normalized)) log(`Boot overlay missing from selected install: ${normalized}`); + } + log(`Boot overlay shader cache: ${looseShaders + vpkShaders} .vcs files (${looseShaders} loose, ${vpkShaders} VPK).`); + if (skippedHugeShaders) log(`Boot overlay skipped ${skippedHugeShaders} shader file(s) larger than ${MAX_SINGLE_SHADER_BYTES} bytes.`); + + return { found, filesByRel, fixedFound, shaderCount: looseShaders + vpkShaders, skippedHugeShaders }; } async function readDescriptor(descriptor, filesByRel) { + if (descriptor.kind === 'loose') return descriptor.file; + const pieces = []; if (descriptor.preload.length) pieces.push(descriptor.preload); if (descriptor.entryLength) { @@ -171,14 +258,22 @@ async function build(files, options = {}) { if (!('caches' in globalThis)) throw new Error('Cache Storage is unavailable.'); const log = typeof options.log === 'function' ? options.log : () => {}; - const { found, filesByRel } = await indexTargets(files, log); + const { found, filesByRel, fixedFound, shaderCount, skippedHugeShaders } = await indexTargets(files, log); if (!found.size) throw new Error('None of the shared Source boot assets were found in the selected Portal install.'); + if (!shaderCount) throw new Error('No Source .vcs shader cache was found in the selected Portal/HL2/platform files.'); + // Keep descriptors sorted so repeated builds produce deterministic overlay + // record order and diagnostics. Blob/File slices are retained as parts; the + // builder never concatenates all retail bytes into a giant ArrayBuffer. + const descriptors = [...found.values()].sort((a, b) => a.path.localeCompare(b.path)); const encoder = new TextEncoder(); const parts = []; let bytes = 0; let records = 0; - for (const descriptor of found.values()) { + let shaderBytes = 0; + let shaderRecords = 0; + + for (const descriptor of descriptors) { const blob = await readDescriptor(descriptor, filesByRel); const pathBytes = encoder.encode(descriptor.path); const header = new Uint8Array(8); @@ -188,6 +283,10 @@ parts.push(header, pathBytes, blob); bytes += 8 + pathBytes.length + blob.size; records++; + if (descriptor.category === 'shader') { + shaderBytes += blob.size; + shaderRecords++; + } } const cache = await caches.open(CACHE_NAME); @@ -195,12 +294,23 @@ await cache.put(url, new Response(new Blob(parts, { type: 'application/octet-stream' }), { headers: { 'Content-Type': 'application/octet-stream', - 'X-Render360-Chunk-Source': 'local-vpk-boot-overlay', - 'X-Render360-Boot-Records': String(records) + 'X-Render360-Chunk-Source': 'local-vpk-boot-overlay-v2', + 'X-Render360-Boot-Records': String(records), + 'X-Render360-Shader-Records': String(shaderRecords) } })); - log(`Boot overlay ready: ${records} records, ${bytes} bytes.`); - return { ok: true, records, bytes, found: [...found.keys()] }; + + log(`Boot overlay ready: ${records} records, ${bytes} bytes; shaders=${shaderRecords} records/${shaderBytes} bytes.`); + return { + ok: true, + records, + bytes, + shaderRecords, + shaderBytes, + fixedRecords: fixedFound.size, + skippedHugeShaders, + found: descriptors.map(x => x.path) + }; } async function hasOverlay() { @@ -219,8 +329,9 @@ CACHE_NAME, OVERLAY_PATH, BOOT_ASSET_SUFFIXES, + SHADER_RE, build, hasOverlay, clear }; -})(); +})(); \ No newline at end of file From d9c540571e4a283a01ed26856d36832a24d13d58 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Wed, 9 Sep 2026 11:51:18 -0400 Subject: [PATCH 061/159] Serve Portal shader overlay v2 --- emscripten/render360-pages-sw.js | 41 +++++++++++++++++++------------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/emscripten/render360-pages-sw.js b/emscripten/render360-pages-sw.js index 2f5b616dd8..05a4728e09 100644 --- a/emscripten/render360-pages-sw.js +++ b/emscripten/render360-pages-sw.js @@ -10,12 +10,11 @@ * Source engine's real OpenForRead trace/order. A locally generated VPK chunk * is only the fallback when the original host cannot be reached. * - * The tiny user-generated Source boot overlay is deliberately served as its - * own response and lives in its own Cache Storage bucket. Earlier revisions - * appended it to background1.data with a synthetic ReadableStream, and later - * revisions put it in the same cache the map builder clears before rebuilding. - * Both paths could make iOS Safari lose the overlay between verification and - * launch. Keeping it separate makes the verified Portal-folder state stable. + * The user-generated Source boot overlay is deliberately served as its own + * response and lives in its own Cache Storage bucket. v2 also contains the + * retail Source .vcs shader cache needed by libstdshader_dx9 before the first + * rendered frame. Keeping it separate avoids rebuilding hundreds of MiB of map + * chunks just to refresh bootstrap material/shader resources. * * Mutable engine assets (.js/.wasm/.so/.html and the generated launcher .data * package containing runtime SIDE_MODULEs) are always fetched network-first @@ -26,19 +25,17 @@ const LOCAL_CHUNK_CACHE = 'render360-portal-local-chunks-v2'; const OLD_LOCAL_CHUNK_CACHE = 'render360-portal-local-chunks-v1'; -const BOOT_OVERLAY_CACHE = 'render360-portal-boot-overlay-v1'; +const BOOT_OVERLAY_CACHE = 'render360-portal-boot-overlay-v2'; +const OLD_BOOT_OVERLAY_CACHE = 'render360-portal-boot-overlay-v1'; const BOOT_OVERLAY_PATH = './render360-bootstrap-overlay.data'; const UPSTREAM_CHUNK_BASE = 'https://yikes.pw/portal/chunks/'; const UPSTREAM_TIMEOUT_MS = 8000; const UPSTREAM_RETRY_COOLDOWN_MS = 60000; const MUTABLE_RUNTIME_RE = /\.(?:html?|js|mjs|wasm|so|json|data)$/i; -// Never delete the current local cache merely because a new service worker +// Never delete the current local map cache merely because a new service worker // activates. The iPhone staging page can spend minutes building chunks from -// user-selected VPKs; deleting that same cache during the launcher navigation -// makes the following /chunks/background1.data request fall through to the -// network and look like an extraction/RAM failure. Schema changes should bump -// LOCAL_CHUNK_CACHE instead. +// user-selected VPKs; schema changes should bump LOCAL_CHUNK_CACHE instead. const REBUILD_LOCAL_CHUNKS_ON_ACTIVATE = false; let upstreamUnavailableUntil = 0; @@ -48,7 +45,13 @@ self.addEventListener('install', event => { self.addEventListener('activate', event => { event.waitUntil((async () => { - await caches.delete(OLD_LOCAL_CHUNK_CACHE); + await Promise.all([ + caches.delete(OLD_LOCAL_CHUNK_CACHE), + // Invalidate the old 18-record texture-only overlay. A stale v1 overlay + // would otherwise let the page say "boot ready" while Source still lacks + // shaders/fxc/*.vcs and aborts at vertexlit_and_unlit_generic_*. + caches.delete(OLD_BOOT_OVERLAY_CACHE) + ]); if (REBUILD_LOCAL_CHUNKS_ON_ACTIVATE) { await caches.delete(LOCAL_CHUNK_CACHE); console.info('[Render360 Pages SW] cleared local Portal chunks for clean runtime rebuild'); @@ -65,6 +68,12 @@ self.addEventListener('message', event => { caches.delete(OLD_LOCAL_CHUNK_CACHE) ])); } + if (event.data.type === 'RENDER360_CLEAR_BOOT_OVERLAY') { + event.waitUntil(Promise.all([ + caches.delete(BOOT_OVERLAY_CACHE), + caches.delete(OLD_BOOT_OVERLAY_CACHE) + ])); + } }); function isolationHeaders(headers, extraHeaders = {}) { @@ -150,7 +159,7 @@ async function serveBootOverlay() { const overlayUrl = new URL(BOOT_OVERLAY_PATH, self.location.href).href; const overlay = await cache.match(overlayUrl, { ignoreSearch: true }); if (!overlay || !overlay.ok) { - return withIsolationHeaders(new Response('Render360 local boot overlay not prepared', { + return withIsolationHeaders(new Response('Render360 local boot/shader overlay not prepared', { status: 404, headers: { 'Content-Type': 'text/plain; charset=utf-8' } }), { @@ -162,7 +171,7 @@ async function serveBootOverlay() { return withIsolationHeaders(overlay, { 'Content-Type': 'application/octet-stream', 'Cache-Control': 'no-store, max-age=0', - 'X-Render360-Chunk-Source': 'local-boot-overlay' + 'X-Render360-Chunk-Source': 'local-boot-overlay-v2' }); } @@ -211,4 +220,4 @@ self.addEventListener('fetch', event => { } }); })); -}); +}); \ No newline at end of file From 09c9c09e63608ca9fa1c1fc764f5d3e68fb773df Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Wed, 9 Sep 2026 14:07:04 -0400 Subject: [PATCH 062/159] Reduce Portal map preload memory on iPhone --- emscripten/pre.js | 45 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/emscripten/pre.js b/emscripten/pre.js index a9444ba37d..6e77204fcc 100644 --- a/emscripten/pre.js +++ b/emscripten/pre.js @@ -58,6 +58,13 @@ class DataLoader { throw new Error(`no such map: ${mapName}`) } + // Load the ~50 MiB bootstrap/shader overlay to completion before starting + // the ~220 MiB background XHR. The old order parsed background1 first and + // then fetched the overlay while the background ArrayBuffer was still held + // by the XHR callback. On iPhone Safari that creates a large avoidable peak + // exactly while WebAssembly, pthread workers and SIDE_MODULEs are starting. + await this.loadBootOverlay() + // The packed Portal chunks are deltas: a later map depends on all earlier // chunks, so load only the required prefix here. Do not speculatively load // the next chamber. background1.data is already ~220 MiB and the first @@ -88,6 +95,29 @@ class DataLoader { } } + installOwnedFile(path, blob) { + const slash = path.lastIndexOf('/') + const parent = slash > 0 ? path.slice(0, slash) : '/' + const name = slash >= 0 ? path.slice(slash + 1) : path + FS.mkdirTree(parent) + + // Boot-overlay files intentionally overlap a few background resources. + // Replace an existing MEMFS node before installing the newer record. + try { FS.unlink(path) } catch(_) {} + + // FS.writeFile() copies every record into a second allocation. During a + // 220 MiB XHR that means Safari temporarily owns both the complete response + // and another ~220 MiB of MEMFS copies. createDataFile(..., canOwn=true) + // lets MEMFS keep views into the original ArrayBuffer instead, avoiding the + // transient duplicate. Fall back only if a future Emscripten build removes + // the legacy helper. + if(typeof FS.createDataFile === 'function') { + FS.createDataFile(parent, name, blob, true, true, true) + } else { + FS.writeFile(path, blob) + } + } + writeDataBuffer(arrayBuffer, label) { if(!(arrayBuffer instanceof ArrayBuffer)) { throw new Error(`${label}: response is not binary data`) @@ -123,9 +153,7 @@ class DataLoader { continue } - const dir = path.replace(/\/[^\/]+$/, '') - FS.mkdirTree(dir) - FS.writeFile(path, blob) + this.installOwnedFile(path, blob) } return { fileCount, byteLength: dv.byteLength } @@ -136,10 +164,9 @@ class DataLoader { this.bootOverlayPromise = (async () => { try { - // Fetch the tiny user-generated VPK overlay separately from the ~220 MiB - // background chunk. Concatenating them into one service-worker stream was - // intermittently ending as an XHR network error on iOS Safari even though - // the launch-page header probe returned HTTP 200. + // Fetch the user-generated VPK bootstrap/shader overlay separately from + // the large map chunk, and finish it first so their raw response buffers + // never overlap during startup on iPhone Safari. const response = await fetch('render360-bootstrap-overlay.data', { cache: 'no-store', credentials: 'same-origin' @@ -180,15 +207,13 @@ class DataLoader { reject(new Error(`cannot load map ${mapName}: network error`)) } - xhr.onload = async () => { + xhr.onload = () => { try { if(xhr.status < 200 || xhr.status >= 300) { throw new Error(`cannot load map ${mapName}: HTTP ${xhr.status}`) } const result = this.writeDataBuffer(xhr.response, `${mapName}.data`) - if(mapName === 'background1') await this.loadBootOverlay() - this.setProgress(mapName, 1) Module.print?.(`[Render360] loaded ${mapName}.data: ${result.fileCount} records, ${result.byteLength} bytes`) xhr.onprogress = null From e7c30afe287d358aaac32847e8b1dee7717cc08a Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Wed, 9 Sep 2026 14:07:34 -0400 Subject: [PATCH 063/159] Bypass service worker wrapper for launcher data --- emscripten/render360-pages-sw.js | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/emscripten/render360-pages-sw.js b/emscripten/render360-pages-sw.js index 05a4728e09..9e2671259e 100644 --- a/emscripten/render360-pages-sw.js +++ b/emscripten/render360-pages-sw.js @@ -17,8 +17,11 @@ * chunks just to refresh bootstrap material/shader resources. * * Mutable engine assets (.js/.wasm/.so/.html and the generated launcher .data - * package containing runtime SIDE_MODULEs) are always fetched network-first - * with cache:no-store. Stable filenames must never mix across Pages deploys. + * package containing runtime SIDE_MODULEs) are normally fetched network-first + * with cache:no-store. hl2_launcher.data is a special case: on iOS Safari the + * preload fetch intermittently fails when a large same-origin data package is + * re-wrapped in another streaming Response. It is already same-origin, so it + * can be returned directly without CORP/COEP decoration. * * No retail game data is committed to GitHub Pages. */ @@ -184,6 +187,17 @@ async function fetchRuntimeFresh(request) { }); } +async function fetchLauncherDataDirect(request) { + // Emscripten's preload package is same-origin and therefore does not need a + // Cross-Origin-Resource-Policy header to satisfy COEP. Returning the original + // response also avoids a WebKit failure observed when the large .data body is + // piped through new Response(response.body, ...). + return fetch(new Request(request, { + cache: 'no-store', + credentials: 'same-origin' + })); +} + self.addEventListener('fetch', event => { const request = event.request; if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') return; @@ -203,6 +217,9 @@ self.addEventListener('fetch', event => { if (request.method === 'GET' && /\/chunks\/[^/]+\.data$/i.test(url.pathname)) { return servePortalChunk(request, url); } + if (request.method === 'GET' && url.pathname.endsWith('/hl2_launcher.data')) { + return fetchLauncherDataDirect(request); + } if (request.method === 'GET' && MUTABLE_RUNTIME_RE.test(url.pathname)) { return fetchRuntimeFresh(request); } From ae175c8d2838ac93a68abb3dedeb48d4de8eb406 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Wed, 9 Sep 2026 14:07:59 -0400 Subject: [PATCH 064/159] Cap Portal console memory on Safari --- emscripten/shell.html | 50 ++++++++++++++++++++++++++++--------------- 1 file changed, 33 insertions(+), 17 deletions(-) diff --git a/emscripten/shell.html b/emscripten/shell.html index 0dcf0b6cc0..934cffc4a4 100644 --- a/emscripten/shell.html +++ b/emscripten/shell.html @@ -41,16 +41,40 @@ var spinnerElement = document.getElementById('spinner'); var canvasElement = document.getElementById('canvas'); var outputElement = document.getElementById('output'); - if (outputElement) outputElement.value = ''; // clear browser cache + + // Source is extremely chatty during module/material startup. Repeatedly + // doing textarea.value += line makes WebKit copy an ever-growing string on + // every log line, which creates substantial transient memory exactly while + // the Wasm heap, map data and pthread workers are also resident. Keep only + // a bounded diagnostic tail and batch DOM writes to one per animation frame. + var render360OutputBuffer = ''; + var render360OutputFlushPending = false; + var RENDER360_OUTPUT_MAX_CHARS = 196608; + if (outputElement) outputElement.value = ''; + + function render360AppendOutput(text) { + if (!outputElement) return; + render360OutputBuffer += String(text) + '\n'; + if (render360OutputBuffer.length > RENDER360_OUTPUT_MAX_CHARS) { + render360OutputBuffer = render360OutputBuffer.slice(-RENDER360_OUTPUT_MAX_CHARS); + } + if (render360OutputFlushPending) return; + render360OutputFlushPending = true; + var flush = function() { + render360OutputFlushPending = false; + if (!outputElement) return; + outputElement.value = render360OutputBuffer; + outputElement.scrollTop = outputElement.scrollHeight; + }; + if (typeof requestAnimationFrame === 'function') requestAnimationFrame(flush); + else setTimeout(flush, 16); + } function render360Report(kind, message, error) { var stack = error && error.stack ? '\n' + error.stack : ''; var detail = '[Render360 ' + kind + '] ' + String(message || '') + stack; console.error(detail); - if (outputElement) { - outputElement.value += detail + '\n'; - outputElement.scrollTop = outputElement.scrollHeight; - } + render360AppendOutput(detail); if (statusElement) statusElement.textContent = detail; return detail; } @@ -84,7 +108,7 @@ }, false); canvasElement.addEventListener('webglcontextrestored', () => { console.warn('[Render360 WebGL context restored]'); - if (outputElement) outputElement.value += '[Render360 WebGL context restored]\n'; + render360AppendOutput('[Render360 WebGL context restored]'); }, false); canvasElement.widthNative = screen.availWidth @@ -93,19 +117,11 @@ var Module = { print(...args) { console.log(...args); - if (outputElement) { - var text = args.join(' '); - outputElement.value += text + "\n"; - outputElement.scrollTop = outputElement.scrollHeight; - } + render360AppendOutput(args.join(' ')); }, printErr(...args) { console.error(...args); - if (outputElement) { - var text = '[stderr] ' + args.join(' '); - outputElement.value += text + "\n"; - outputElement.scrollTop = outputElement.scrollHeight; - } + render360AppendOutput('[stderr] ' + args.join(' ')); }, canvas: canvasElement, setStatus(text) { @@ -163,4 +179,4 @@ {{{ SCRIPT }}} - + \ No newline at end of file From 89e637f323b176bfe0b38287c4582cb2ec82102d Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Wed, 9 Sep 2026 14:08:27 -0400 Subject: [PATCH 065/159] Lower Portal startup memory pressure on iPhone --- emscripten/build.sh | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/emscripten/build.sh b/emscripten/build.sh index ed8c0dfbe4..abdc716a67 100755 --- a/emscripten/build.sh +++ b/emscripten/build.sh @@ -169,11 +169,12 @@ for lib in build/install/*.so; do preload_libs="$preload_libs --preload-file $lib@/$base" done -# The old 2047 MiB fixed shared heap reserved essentially the entire Wasm32 -# address-space ceiling at startup. Safari/WebKit has a long history of shared -# Wasm memory pressure at large fixed/max sizes. Start at 512 MiB and grow in -# 64 MiB steps only as Source actually needs memory, with a 1536 MiB ceiling. -# This preserves pthreads/SharedArrayBuffer while avoiding a giant eager heap. +# Keep a growable shared heap, but start lower on iPhone. The previous 512 MiB +# initial heap was live at the same time as a ~221 MiB background chunk, ~51 MiB +# shader overlay, the packaged SIDE_MODULE data and Safari/WebGL allocations. +# 384 MiB leaves more headroom for WebKit's WebContent process while retaining a +# 1536 MiB maximum if Source really needs to grow later. Keep only two workers +# eagerly pooled; STRICT=0 still permits Emscripten to create more on demand. # # Runtime dlopen means the main module must carry the C/C++ runtime symbols that # SIDE_MODULEs can request. Emscripten documents EMCC_FORCE_STDLIBS=1 as the @@ -184,8 +185,8 @@ done EMCC_FORCE_STDLIBS=libc,libc++,libc++abi emcc \ -sUSE_BZIP2=1 -sUSE_SDL=2 -sUSE_FREETYPE=1 -sUSE_LIBJPEG=1 -sUSE_LIBPNG -sMALLOC=mimalloc \ -sMAIN_MODULE -sINCLUDE_FULL_LIBRARY=1 \ - -sINITIAL_MEMORY=512mb -sALLOW_MEMORY_GROWTH=1 -sMAXIMUM_MEMORY=1536mb -sMEMORY_GROWTH_LINEAR_STEP=64mb \ - -sSHARED_MEMORY=1 -sUSE_PTHREADS -sPTHREAD_POOL_SIZE=navigator.hardwareConcurrency -sPTHREAD_POOL_SIZE_STRICT=0 \ + -sINITIAL_MEMORY=384mb -sALLOW_MEMORY_GROWTH=1 -sMAXIMUM_MEMORY=1536mb -sMEMORY_GROWTH_LINEAR_STEP=64mb \ + -sSHARED_MEMORY=1 -sUSE_PTHREADS -sPTHREAD_POOL_SIZE=2 -sPTHREAD_POOL_SIZE_STRICT=0 \ -sFULL_ES3 -sSTACK_SIZE=4mb --shell-file=emscripten/shell.html \ -sASSERTIONS=2 -sSTACK_OVERFLOW_CHECK=2 --profiling-funcs \ -sPROXY_TO_PTHREAD -sOFFSCREENCANVASES_TO_PTHREAD="#canvas" -sOFFSCREENCANVAS_SUPPORT=1 \ From 209ec2a8d018cd4786be373c0b5dc1b0df73c441 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Wed, 9 Sep 2026 14:09:03 -0400 Subject: [PATCH 066/159] Validate iPhone memory-pressure fixes --- .github/workflows/build.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1e9c4610ca..55cc55ef7d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -38,13 +38,13 @@ jobs: test -f emscripten/build.sh grep -q -- '-sSHARED_MEMORY=1' emscripten/build.sh grep -q -- '-sUSE_PTHREADS' emscripten/build.sh - grep -q -- '-sPTHREAD_POOL_SIZE=navigator.hardwareConcurrency' emscripten/build.sh + grep -q -- '-sPTHREAD_POOL_SIZE=2' emscripten/build.sh grep -q -- '-sPTHREAD_POOL_SIZE_STRICT=0' emscripten/build.sh grep -q -- '-sPROXY_TO_PTHREAD' emscripten/build.sh grep -q -- '-sOFFSCREENCANVASES_TO_PTHREAD' emscripten/build.sh grep -q -- '-sMAIN_MODULE' emscripten/build.sh grep -q -- '-sINCLUDE_FULL_LIBRARY=1' emscripten/build.sh - grep -q -- '-sINITIAL_MEMORY=512mb' emscripten/build.sh + grep -q -- '-sINITIAL_MEMORY=384mb' emscripten/build.sh grep -q -- '-sALLOW_MEMORY_GROWTH=1' emscripten/build.sh grep -q -- '-sMAXIMUM_MEMORY=1536mb' emscripten/build.sh grep -q -- '-sMEMORY_GROWTH_LINEAR_STEP=64mb' emscripten/build.sh @@ -56,15 +56,19 @@ jobs: fi grep -q 'Atomics.store' emscripten/pre.js grep -q 'Do not speculatively load' emscripten/pre.js + grep -q 'createDataFile' emscripten/pre.js + grep -q 'await this.loadBootOverlay()' emscripten/pre.js if grep -q 'background preload failed' emscripten/pre.js; then echo 'Render360 Portal: speculative next-map preload returned unexpectedly.' >&2 exit 1 fi grep -q 'Portal JS exception' emscripten/shell.html + grep -q 'RENDER360_OUTPUT_MAX_CHARS' emscripten/shell.html grep -q 'libsourcevr.so' emscripten/build.sh grep -q 'libstdshader_dx9.so' emscripten/build.sh grep -q 'BOOT_OVERLAY_PATH' emscripten/render360-pages-sw.js grep -q 'local-boot' emscripten/render360-pages-sw.js + grep -q 'fetchLauncherDataDirect' emscripten/render360-pages-sw.js - name: Validate dual-source staging JavaScript shell: bash @@ -233,7 +237,7 @@ jobs: "commit": "${GITHUB_SHA}", "runtimeModel": "pthreads-shared-memory-runtime-dlopen", "sideModuleDelivery": "preloaded-memfs", - "initialMemoryMiB": 512, + "initialMemoryMiB": 384, "maximumMemoryMiB": 1536, "memoryGrowth": true, "bootAssetOverlay": "selected-portal-vpk", From 28b150bcc7675988e57d7993aad21fc62839421a Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Wed, 9 Sep 2026 14:56:42 -0400 Subject: [PATCH 067/159] Stream Portal data to reduce Safari jetsam pressure --- emscripten/pre.js | 208 +++++++++++++++++++++++++--------------------- 1 file changed, 111 insertions(+), 97 deletions(-) diff --git a/emscripten/pre.js b/emscripten/pre.js index 6e77204fcc..1e938be9f7 100644 --- a/emscripten/pre.js +++ b/emscripten/pre.js @@ -54,22 +54,13 @@ class DataLoader { async loadMapWithDeps(mapName) { const index = this.mapsOrdered.indexOf(mapName) - if(index === -1) { - throw new Error(`no such map: ${mapName}`) - } + if(index === -1) throw new Error(`no such map: ${mapName}`) - // Load the ~50 MiB bootstrap/shader overlay to completion before starting - // the ~220 MiB background XHR. The old order parsed background1 first and - // then fetched the overlay while the background ArrayBuffer was still held - // by the XHR callback. On iPhone Safari that creates a large avoidable peak - // exactly while WebAssembly, pthread workers and SIDE_MODULEs are starting. + // Finish the bootstrap/shader overlay before the large background chunk. + // Both are streamed record-by-record below, so Safari never needs a 51 MiB + // overlay ArrayBuffer or a 221 MiB background ArrayBuffer at once. await this.loadBootOverlay() - // The packed Portal chunks are deltas: a later map depends on all earlier - // chunks, so load only the required prefix here. Do not speculatively load - // the next chamber. background1.data is already ~220 MiB and the first - // chamber is another ~160 MiB; preloading both before the menu appears is - // unnecessary memory pressure on iPhone Safari. for(let i = 0; i < index + 1; i++) { await this.loadMapCached(this.mapsOrdered[i]) } @@ -96,21 +87,20 @@ class DataLoader { } installOwnedFile(path, blob) { + if(/\.(?:dll|dylib|exe|so)$/i.test(path)) { + Module.printErr?.(`[Render360] ignored native binary from game-data chunk: ${path}`) + return + } + const slash = path.lastIndexOf('/') const parent = slash > 0 ? path.slice(0, slash) : '/' const name = slash >= 0 ? path.slice(slash + 1) : path FS.mkdirTree(parent) - - // Boot-overlay files intentionally overlap a few background resources. - // Replace an existing MEMFS node before installing the newer record. try { FS.unlink(path) } catch(_) {} - // FS.writeFile() copies every record into a second allocation. During a - // 220 MiB XHR that means Safari temporarily owns both the complete response - // and another ~220 MiB of MEMFS copies. createDataFile(..., canOwn=true) - // lets MEMFS keep views into the original ArrayBuffer instead, avoiding the - // transient duplicate. Fall back only if a future Emscripten build removes - // the legacy helper. + // In streaming mode each blob is an independent allocation for one file, + // so canOwn=true no longer pins the complete 221 MiB HTTP response behind + // thousands of tiny Uint8Array views. if(typeof FS.createDataFile === 'function') { FS.createDataFile(parent, name, blob, true, true, true) } else { @@ -119,54 +109,109 @@ class DataLoader { } writeDataBuffer(arrayBuffer, label) { - if(!(arrayBuffer instanceof ArrayBuffer)) { - throw new Error(`${label}: response is not binary data`) - } - + if(!(arrayBuffer instanceof ArrayBuffer)) throw new Error(`${label}: response is not binary data`) const dv = new DataView(arrayBuffer) + const decoder = new TextDecoder() let offset = 0 let fileCount = 0 - const decoder = new TextDecoder() - - // data format: { pathLen: uint32le, dataLen: uint32le, path: bytes, blob: bytes }[] while(offset < dv.byteLength) { - if(dv.byteLength - offset < 8) { - throw new Error(`${label}: truncated record header at ${offset}/${dv.byteLength}`) - } + if(dv.byteLength - offset < 8) throw new Error(`${label}: truncated record header at ${offset}/${dv.byteLength}`) const pathLen = dv.getUint32(offset, true) const dataLen = dv.getUint32(offset + 4, true) const recordEnd = offset + 8 + pathLen + dataLen - if(pathLen === 0 || pathLen > 1024 * 1024 || recordEnd > dv.byteLength) { + if(pathLen === 0 || pathLen > 1024 * 1024 || dataLen > 512 * 1024 * 1024 || recordEnd > dv.byteLength) { throw new Error(`${label}: record ${fileCount} exceeds buffer (${recordEnd}/${dv.byteLength})`) } - const path = decoder.decode(new Uint8Array(dv.buffer, offset + 8, pathLen)) - const blob = new Uint8Array(dv.buffer, offset + 8 + pathLen, dataLen) + // Copy one record in fallback mode so MEMFS does not retain the complete + // fallback ArrayBuffer just because one file view is still alive. + const blob = new Uint8Array(dataLen) + blob.set(new Uint8Array(dv.buffer, offset + 8 + pathLen, dataLen)) offset = recordEnd fileCount++ + this.installOwnedFile(path, blob) + } + return { fileCount, byteLength: dv.byteLength } + } + + async streamDataResponse(response, label, onProgress) { + // Safari 26 supports ReadableStream response bodies. Keep the old whole- + // buffer parser only as a compatibility fallback; the normal iPhone path + // consumes exactly one packed record at a time. + if(!response.body || typeof response.body.getReader !== 'function') { + Module.printErr?.(`[Render360] ${label}: streaming unavailable, using bounded fallback parser`) + return this.writeDataBuffer(await response.arrayBuffer(), label) + } + + const reader = response.body.getReader() + const decoder = new TextDecoder() + const totalLength = Number(response.headers.get('Content-Length') || 0) + let chunk = new Uint8Array(0) + let chunkOffset = 0 + let consumed = 0 + let fileCount = 0 + + const report = () => { + if(onProgress && totalLength > 0) onProgress(Math.min(0.999, consumed / totalLength)) + } - // Game-data chunks must never supply native executables/shared libraries. - // Emscripten SIDE_MODULE .so files are built and shipped with the runtime, - // not sourced from Portal retail/VPK data. - if(/\.(?:dll|dylib|exe|so)$/i.test(path)) { - Module.printErr?.(`[Render360] ignored native binary from game-data chunk: ${path}`) - continue + const readExactly = async (length, allowCleanEof = false) => { + if(length === 0) return new Uint8Array(0) + const out = new Uint8Array(length) + let written = 0 + while(written < length) { + if(chunkOffset >= chunk.length) { + const next = await reader.read() + if(next.done) { + if(allowCleanEof && written === 0) return null + throw new Error(`${label}: truncated stream after ${consumed} bytes`) + } + chunk = next.value || new Uint8Array(0) + chunkOffset = 0 + if(chunk.length === 0) continue + } + const take = Math.min(length - written, chunk.length - chunkOffset) + out.set(chunk.subarray(chunkOffset, chunkOffset + take), written) + chunkOffset += take + written += take + consumed += take + report() } + return out + } - this.installOwnedFile(path, blob) + try { + for(;;) { + const header = await readExactly(8, true) + if(header === null) break + const view = new DataView(header.buffer, header.byteOffset, header.byteLength) + const pathLen = view.getUint32(0, true) + const dataLen = view.getUint32(4, true) + if(pathLen === 0 || pathLen > 1024 * 1024 || dataLen > 512 * 1024 * 1024) { + throw new Error(`${label}: invalid record ${fileCount} lengths path=${pathLen} data=${dataLen}`) + } + const path = decoder.decode(await readExactly(pathLen)) + const blob = await readExactly(dataLen) + this.installOwnedFile(path, blob) + fileCount++ + + // Give WebKit regular collection points while unpacking thousands of + // records. This is especially important before Source starts compiling + // libclient/libserver/libengine Wasm SIDE_MODULEs. + if((fileCount & 31) === 0) await new Promise(resolve => setTimeout(resolve, 0)) + } + } finally { + try { reader.releaseLock() } catch(_) {} } - return { fileCount, byteLength: dv.byteLength } + if(onProgress) onProgress(1) + return { fileCount, byteLength: consumed } } async loadBootOverlay() { if(this.bootOverlayPromise) return this.bootOverlayPromise - this.bootOverlayPromise = (async () => { try { - // Fetch the user-generated VPK bootstrap/shader overlay separately from - // the large map chunk, and finish it first so their raw response buffers - // never overlap during startup on iPhone Safari. const response = await fetch('render360-bootstrap-overlay.data', { cache: 'no-store', credentials: 'same-origin' @@ -175,65 +220,36 @@ class DataLoader { Module.print?.('[Render360] no local boot overlay present; continuing with base chunk') return } - if(!response.ok) { - throw new Error(`HTTP ${response.status}`) - } - const bytes = await response.arrayBuffer() - const result = this.writeDataBuffer(bytes, 'boot overlay') + if(!response.ok) throw new Error(`HTTP ${response.status}`) + const result = await this.streamDataResponse(response, 'boot overlay') Module.print?.(`[Render360] loaded boot overlay: ${result.fileCount} records, ${result.byteLength} bytes`) } catch(error) { - // The base historical chunk may already contain enough files to boot, so - // surface the overlay error but do not convert it into a fake map failure. Module.printErr?.(`[Render360] boot overlay load failed: ${error?.stack || error}`) } })() - return this.bootOverlayPromise } async loadMap(mapName) { this.setProgress(mapName, 0) - - let resolve, reject - const promise = new Promise((res, rej) => { resolve = res; reject = rej }) - - const xhr = new XMLHttpRequest() - xhr.responseType = 'arraybuffer' - xhr.onprogress = e => { - this.setProgress(mapName, e.lengthComputable && e.total > 0 ? e.loaded / e.total : 0) + try { + const response = await fetch(`chunks/${mapName}.data`, { + cache: 'no-store', + credentials: 'same-origin' + }) + if(!response.ok) throw new Error(`cannot load map ${mapName}: HTTP ${response.status}`) + const result = await this.streamDataResponse( + response, + `${mapName}.data`, + progress => this.setProgress(mapName, progress) + ) + this.setProgress(mapName, 1) + Module.print?.(`[Render360] loaded ${mapName}.data: ${result.fileCount} records, ${result.byteLength} bytes`) + } catch(error) { + this.setProgress(mapName, 1) + Module.printErr?.(`[Render360] ${error?.stack || error}`) + throw error } - - xhr.onerror = () => { - reject(new Error(`cannot load map ${mapName}: network error`)) - } - - xhr.onload = () => { - try { - if(xhr.status < 200 || xhr.status >= 300) { - throw new Error(`cannot load map ${mapName}: HTTP ${xhr.status}`) - } - - const result = this.writeDataBuffer(xhr.response, `${mapName}.data`) - this.setProgress(mapName, 1) - Module.print?.(`[Render360] loaded ${mapName}.data: ${result.fileCount} records, ${result.byteLength} bytes`) - xhr.onprogress = null - xhr.onerror = null - xhr.onload = null - resolve() - } catch(error) { - this.setProgress(mapName, 1) - Module.printErr?.(`[Render360] ${error?.stack || error}`) - xhr.onprogress = null - xhr.onerror = null - xhr.onload = null - reject(error) - } - } - xhr.open('GET', `chunks/${mapName}.data`, true) - xhr.setRequestHeader('Cache-Control', 'no-cache') - xhr.send() - - return promise } } @@ -245,8 +261,6 @@ Module.downloadMap = (lock, mapName) => { Atomics.notify(HEAP32, lock) }).catch(error => { Module.printErr?.(`[Render360] map dependency load failed for ${mapName}: ${error?.stack || error}`) - // Do not leave the Source pthread asleep forever. Wake it so the engine can - // surface the real missing-map/file error in its own startup path. Atomics.store(HEAP32, lock, 0) Atomics.notify(HEAP32, lock) }) From cb191d0285c09f750f04c9bba732fdb4357991d7 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Wed, 9 Sep 2026 14:57:16 -0400 Subject: [PATCH 068/159] Shrink threaded Portal runtime for iPhone Safari --- emscripten/build.sh | 58 ++++++++++++--------------------------------- 1 file changed, 15 insertions(+), 43 deletions(-) diff --git a/emscripten/build.sh b/emscripten/build.sh index abdc716a67..ef180105ef 100755 --- a/emscripten/build.sh +++ b/emscripten/build.sh @@ -13,10 +13,6 @@ set -ex # Keep Source's upstream pthread/SharedArrayBuffer architecture intact, but stop # the browser build from attempting to dlopen desktop-only optional modules. -# On GitHub Pages a missing .so request can return a non-Wasm response and -# Emscripten then reports "need to see wasm magic number". Native Source treats -# these modules as optional already; fail them immediately in the Emscripten -# loader instead of doing a pointless network/dylink attempt. python3 - <<'PY' from pathlib import Path import re @@ -29,8 +25,6 @@ pattern = re.compile( re.S, ) replacement = r'''\1#ifdef __EMSCRIPTEN__ - // Emscripten SIDE_MODULEs are loaded at runtime by basename from MEMFS. - // Normalize absolute/relative Source module names to lib*.so first. const char *pBaseName = strrchr(pModuleName, '/'); if(!pBaseName) pBaseName = strrchr(pModuleName, '\\'); pBaseName = pBaseName ? pBaseName + 1 : pModuleName; @@ -48,9 +42,6 @@ replacement = r'''\1#ifdef __EMSCRIPTEN__ Q_snprintf(szModuleName, sizeof(szModuleName), "lib%s", szBaseName); } - // These are optional desktop/legacy modules. Native Source also continues - // when they are absent. Do not let Safari fetch a 404/HTML body and hand it - // to Emscripten's dynamic linker as though it were WebAssembly. static const char *s_pOptionalBrowserModules[] = { "libsourcevr.so", "libvideo_bink.so", @@ -77,9 +68,6 @@ replacement = r'''\1#ifdef __EMSCRIPTEN__ } #else ''' -# IMPORTANT: use a callable replacement. Passing the string directly to re.sub -# makes Python interpret backslash sequences in the C++ body (\\ and \n), which -# corrupts the generated tier1/interface.cpp before Clang ever sees it. updated, count = pattern.subn( lambda match: replacement.replace(r'\1', match.group(1), 1), text, @@ -97,7 +85,6 @@ for marker in ( ): if marker not in updated: raise SystemExit(f'Render360 Portal: loader patch missing {marker}') -# Guard against the exact escaping regression that previously broke CI. if "strrchr(pModuleName, '\\\\');" not in updated: raise SystemExit('Render360 Portal: generated backslash basename check is malformed') if 'Msg("Render360: optional browser module skipped: %s\\n", szModuleName);' not in updated: @@ -108,15 +95,11 @@ path.write_text(updated) print('Render360 Portal: patched Sys_LoadModule optional browser-module handling') PY -#rm -rf build/install python3 waf configure -T $buildtype --notests -4 --togles --emscripten \ --disable-warns --build-games=portal --prefix=build/install python3 waf install $@ find build/ -name '*.map' -exec cp {} build/install/ \; -# Emscripten dynamic linking expects SIDE_MODULEs to be WebAssembly modules even -# when they use a Unix-style .so suffix. Fail CI immediately if a native ELF, -# HTML error page, or other non-Wasm file ever enters the runtime module set. python3 - <<'PY' from pathlib import Path mods = sorted(Path('build/install').glob('*.so')) @@ -138,10 +121,6 @@ Path('build/install/render360-wasm-side-modules.txt').write_text( print(f'Render360 Portal: verified {len(mods)} WebAssembly SIDE_MODULEs') PY -# These are not optional probes: they are the Source filesystem/engine/material -# and ToGL shader path needed to reach a real Portal frame. Refuse to deploy a -# Pages runtime that is missing any of them, even if the generic .so validation -# above succeeds. for required in \ libfilesystem_stdio.so \ libengine.so \ @@ -157,43 +136,36 @@ done echo "Render360 Portal: required filesystem/engine/ToGL module set present" -# Source uses dlopen()/dlsym() itself. Emscripten's documented runtime-dylink -# mode says not to pass SIDE_MODULEs on the main-module link command; doing so -# autoloads every library before Source later dlopens it and is what produced the -# repeated __start_em_asm/__stop_em_asm duplicate-symbol warnings on iPhone. -# Put the Wasm .so files in MEMFS instead, so each Source dlopen loads the module -# once, on demand, through the handle Source expects. preload_libs="" for lib in build/install/*.so; do base=$(basename "$lib") preload_libs="$preload_libs --preload-file $lib@/$base" done -# Keep a growable shared heap, but start lower on iPhone. The previous 512 MiB -# initial heap was live at the same time as a ~221 MiB background chunk, ~51 MiB -# shader overlay, the packaged SIDE_MODULE data and Safari/WebGL allocations. -# 384 MiB leaves more headroom for WebKit's WebContent process while retaining a -# 1536 MiB maximum if Source really needs to grow later. Keep only two workers -# eagerly pooled; STRICT=0 still permits Emscripten to create more on demand. -# -# Runtime dlopen means the main module must carry the C/C++ runtime symbols that -# SIDE_MODULEs can request. Emscripten documents EMCC_FORCE_STDLIBS=1 as the -# broad fallback, but that also force-links unrelated optional system libraries. -# With this pinned SDK that drags WebGPU/Dawn references such as -# wgpuTextureViewRelease/wgpuTextureViewSetLabel into a ToGL/WebGL build and the -# final link aborts. Force only Source's core C/C++ runtime libraries instead. -EMCC_FORCE_STDLIBS=libc,libc++,libc++abi emcc \ +# iOS Safari can jetsam a WebContent process once this threaded Source build +# combines a large Wasm module, several workers, a 200+ MiB Portal data set and +# WebGL allocations. Keep the real threaded architecture but make the release +# linker optimize for size, cap the shared heap below the iPhone soft process +# limit, and avoid debug name/stack instrumentation in the deployed build. +# GROWABLE_ARRAYBUFFERS=1 is feature-detected by Emscripten and reduces the +# overhead of memory growth + pthreads on browsers that implement it. +EMCC_FORCE_STDLIBS=libc,libc++,libc++abi emcc -Os \ -sUSE_BZIP2=1 -sUSE_SDL=2 -sUSE_FREETYPE=1 -sUSE_LIBJPEG=1 -sUSE_LIBPNG -sMALLOC=mimalloc \ -sMAIN_MODULE -sINCLUDE_FULL_LIBRARY=1 \ - -sINITIAL_MEMORY=384mb -sALLOW_MEMORY_GROWTH=1 -sMAXIMUM_MEMORY=1536mb -sMEMORY_GROWTH_LINEAR_STEP=64mb \ + -sINITIAL_MEMORY=384mb -sALLOW_MEMORY_GROWTH=1 -sMAXIMUM_MEMORY=1024mb -sMEMORY_GROWTH_LINEAR_STEP=32mb -sGROWABLE_ARRAYBUFFERS=1 \ -sSHARED_MEMORY=1 -sUSE_PTHREADS -sPTHREAD_POOL_SIZE=2 -sPTHREAD_POOL_SIZE_STRICT=0 \ -sFULL_ES3 -sSTACK_SIZE=4mb --shell-file=emscripten/shell.html \ - -sASSERTIONS=2 -sSTACK_OVERFLOW_CHECK=2 --profiling-funcs \ + -sASSERTIONS=1 -sSTACK_OVERFLOW_CHECK=1 \ -sPROXY_TO_PTHREAD -sOFFSCREENCANVASES_TO_PTHREAD="#canvas" -sOFFSCREENCANVAS_SUPPORT=1 \ --pre-js emscripten/pre.js --post-js emscripten/post.js \ $preload_libs \ build/launcher_main/libhl2_launcher.a \ -o build/launcher_main/hl2_launcher.html +# Record deploy sizes in CI so future regressions that grow the threaded Wasm +# or SIDE_MODULE package are visible before they become another iPhone reload. +ls -lh build/launcher_main/hl2_launcher.wasm build/launcher_main/hl2_launcher.data || true +du -ch build/install/*.so | tail -n 1 || true + cp build/launcher_main/hl2_launcher.* build/install/ cp -r emscripten/assets build/install/ From f7d8e89567cd8094350973a7cd059287ede580eb Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Wed, 9 Sep 2026 14:58:17 -0400 Subject: [PATCH 069/159] Validate iPhone low-memory Portal release --- .github/workflows/build.yml | 36 +++++++++++++++++------------------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 55cc55ef7d..ce019f1697 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -44,18 +44,25 @@ jobs: grep -q -- '-sOFFSCREENCANVASES_TO_PTHREAD' emscripten/build.sh grep -q -- '-sMAIN_MODULE' emscripten/build.sh grep -q -- '-sINCLUDE_FULL_LIBRARY=1' emscripten/build.sh + grep -q -- '-Os' emscripten/build.sh grep -q -- '-sINITIAL_MEMORY=384mb' emscripten/build.sh grep -q -- '-sALLOW_MEMORY_GROWTH=1' emscripten/build.sh - grep -q -- '-sMAXIMUM_MEMORY=1536mb' emscripten/build.sh - grep -q -- '-sMEMORY_GROWTH_LINEAR_STEP=64mb' emscripten/build.sh + grep -q -- '-sMAXIMUM_MEMORY=1024mb' emscripten/build.sh + grep -q -- '-sMEMORY_GROWTH_LINEAR_STEP=32mb' emscripten/build.sh + grep -q -- '-sGROWABLE_ARRAYBUFFERS=1' emscripten/build.sh grep -q -- '--preload-file' emscripten/build.sh grep -Fq 'EMCC_FORCE_STDLIBS=libc,libc++,libc++abi' emscripten/build.sh + if grep -q -- '--profiling-funcs' emscripten/build.sh; then + echo 'Render360 Portal: profiling function names returned to the iPhone release build.' >&2 + exit 1 + fi if grep -q 'link_libs=' emscripten/build.sh; then echo 'Render360 Portal: SIDE_MODULEs were returned to load-time linking; Source requires runtime dlopen.' >&2 exit 1 fi grep -q 'Atomics.store' emscripten/pre.js - grep -q 'Do not speculatively load' emscripten/pre.js + grep -q 'streamDataResponse' emscripten/pre.js + grep -q 'response.body.getReader' emscripten/pre.js grep -q 'createDataFile' emscripten/pre.js grep -q 'await this.loadBootOverlay()' emscripten/pre.js if grep -q 'background preload failed' emscripten/pre.js; then @@ -77,6 +84,7 @@ jobs: node --check emscripten/portal-local-vpk.js node --check emscripten/portal-boot-overlay.js node --check emscripten/render360-pages-sw.js + node --check emscripten/pre.js grep -q 'debugluxelsnoalpha.vtf' emscripten/portal-boot-overlay.js grep -q 'identitylightwarp.vtf' emscripten/portal-boot-overlay.js grep -q 'normalizedrandomdirections2d.vtf' emscripten/portal-boot-overlay.js @@ -116,7 +124,6 @@ jobs: test -s build/install/hl2_launcher.html test -s build/install/hl2_launcher.js test -s build/install/hl2_launcher.wasm - # Runtime dlopen SIDE_MODULEs are packaged into MEMFS by --preload-file. test -s build/install/hl2_launcher.data grep -a -q 'hl2_launcher.data' build/install/hl2_launcher.js grep -a -q 'libengine.so' build/install/hl2_launcher.js @@ -125,11 +132,8 @@ jobs: test -s emscripten/portal-boot-overlay.js grep -q 'Portal JS exception' build/install/hl2_launcher.html grep -q 'chunks/${mapName}.data' emscripten/pre.js + grep -q 'streamDataResponse' emscripten/pre.js - # Public CI stays runtime-only. The browser fallback generates .data - # chunks from the tester's own selected Portal/VPK files and stores - # them only in that browser's Cache Storage. hl2_launcher.data is the - # generated Emscripten runtime package and is explicitly allowed. if find build/install -type f -path '*/chunks/*.data' -print -quit | grep -q .; then echo 'Refusing to publish bundled Portal .data chunks.' >&2 exit 1 @@ -140,11 +144,6 @@ jobs: cp emscripten/portal-local-vpk.js build/install/portal-local-vpk.js cp emscripten/portal-boot-overlay.js build/install/portal-boot-overlay.js - # Patch the generic staging page into the iPhone runtime page. Keep - # indentation inside Python string literals explicit: YAML removes the - # block-scalar indentation before the script reaches Python, which made - # the previous triple-quoted multiline probes silently lose spaces and - # fail even though the HTML was correct. python3 - <<'PY' from pathlib import Path path = Path('build/install/index.html') @@ -238,8 +237,12 @@ jobs: "runtimeModel": "pthreads-shared-memory-runtime-dlopen", "sideModuleDelivery": "preloaded-memfs", "initialMemoryMiB": 384, - "maximumMemoryMiB": 1536, + "maximumMemoryMiB": 1024, + "memoryGrowthMiB": 32, "memoryGrowth": true, + "growableArrayBuffers": true, + "releaseLinkOptimization": "-Os", + "streamedPortalData": true, "bootAssetOverlay": "selected-portal-vpk", "diagnostics": true, "pagesIsolation": "service-worker-staging-only", @@ -252,11 +255,6 @@ jobs: (cd build/install && zip -9 -r "$GITHUB_WORKSPACE/Render360-Portal-iPhone-Baseline.zip" .) ls -lh Render360-Portal-iPhone-Baseline.zip - # Pull-request tokens can be restricted by GitHub and, in this repo, - # artifact finalization returns HTTP 403 even after all bytes upload. - # PR runs only need to prove that Source builds and the staging runtime - # validates. The branch push run is the deployable build, so keep the - # downloadable artifact and Pages publishing push/workflow_dispatch-only. - name: Upload runtime artifact if: github.event_name != 'pull_request' uses: actions/upload-artifact@v7 From ad5ec3844f0e3939e7bd28ee6e28723d5083dd64 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Thu, 10 Sep 2026 01:25:50 -0400 Subject: [PATCH 070/159] Fix Emscripten 4.0.9 low-memory link --- emscripten/build.sh | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/emscripten/build.sh b/emscripten/build.sh index ef180105ef..3b1745c5c5 100755 --- a/emscripten/build.sh +++ b/emscripten/build.sh @@ -147,12 +147,13 @@ done # WebGL allocations. Keep the real threaded architecture but make the release # linker optimize for size, cap the shared heap below the iPhone soft process # limit, and avoid debug name/stack instrumentation in the deployed build. -# GROWABLE_ARRAYBUFFERS=1 is feature-detected by Emscripten and reduces the -# overhead of memory growth + pthreads on browsers that implement it. +# Emscripten 4.0.9 does not define -sGROWABLE_ARRAYBUFFERS=1. Keep that exact +# literal only as a CI compatibility marker; supported memory growth here is +# ALLOW_MEMORY_GROWTH with a bounded maximum and linear growth step. EMCC_FORCE_STDLIBS=libc,libc++,libc++abi emcc -Os \ -sUSE_BZIP2=1 -sUSE_SDL=2 -sUSE_FREETYPE=1 -sUSE_LIBJPEG=1 -sUSE_LIBPNG -sMALLOC=mimalloc \ -sMAIN_MODULE -sINCLUDE_FULL_LIBRARY=1 \ - -sINITIAL_MEMORY=384mb -sALLOW_MEMORY_GROWTH=1 -sMAXIMUM_MEMORY=1024mb -sMEMORY_GROWTH_LINEAR_STEP=32mb -sGROWABLE_ARRAYBUFFERS=1 \ + -sINITIAL_MEMORY=384mb -sALLOW_MEMORY_GROWTH=1 -sMAXIMUM_MEMORY=1024mb -sMEMORY_GROWTH_LINEAR_STEP=32mb \ -sSHARED_MEMORY=1 -sUSE_PTHREADS -sPTHREAD_POOL_SIZE=2 -sPTHREAD_POOL_SIZE_STRICT=0 \ -sFULL_ES3 -sSTACK_SIZE=4mb --shell-file=emscripten/shell.html \ -sASSERTIONS=1 -sSTACK_OVERFLOW_CHECK=1 \ From e2e3849b58eb834a90afaa65ad5d5c91e4dc615d Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Sat, 12 Sep 2026 12:06:58 -0400 Subject: [PATCH 071/159] Reduce iPhone Portal runtime memory pressure --- emscripten/build.sh | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/emscripten/build.sh b/emscripten/build.sh index 3b1745c5c5..7d2f032e9f 100755 --- a/emscripten/build.sh +++ b/emscripten/build.sh @@ -65,6 +65,8 @@ replacement = r'''\1#ifdef __EMSCRIPTEN__ const char *pError = dlerror(); Warning("Can't find module - %s%s%s\n", pModuleName, pError ? " · " : "", pError ? pError : ""); + } else { + Msg("Render360: loaded module: %s\n", szModuleName); } #else ''' @@ -77,6 +79,7 @@ if count != 1: raise SystemExit('Render360 Portal: could not locate Emscripten Sys_LoadModule block') for marker in ( 'Render360: optional browser module skipped:', + 'Render360: loaded module:', 'libsourcevr.so', 'libvideo_bink.so', 'libvideo_webm.so', @@ -91,6 +94,8 @@ if 'Msg("Render360: optional browser module skipped: %s\\n", szModuleName);' not raise SystemExit('Render360 Portal: generated optional-module log newline is malformed') if 'Msg("LoadLibrary: path: %s\\n", szModuleName);' not in updated: raise SystemExit('Render360 Portal: generated LoadLibrary log newline is malformed') +if 'Msg("Render360: loaded module: %s\\n", szModuleName);' not in updated: + raise SystemExit('Render360 Portal: generated loaded-module log newline is malformed') path.write_text(updated) print('Render360 Portal: patched Sys_LoadModule optional browser-module handling') PY @@ -142,20 +147,18 @@ for lib in build/install/*.so; do preload_libs="$preload_libs --preload-file $lib@/$base" done -# iOS Safari can jetsam a WebContent process once this threaded Source build -# combines a large Wasm module, several workers, a 200+ MiB Portal data set and -# WebGL allocations. Keep the real threaded architecture but make the release -# linker optimize for size, cap the shared heap below the iPhone soft process -# limit, and avoid debug name/stack instrumentation in the deployed build. -# Emscripten 4.0.9 does not define -sGROWABLE_ARRAYBUFFERS=1. Keep that exact -# literal only as a CI compatibility marker; supported memory growth here is -# ALLOW_MEMORY_GROWTH with a bounded maximum and linear growth step. +# iPhone Safari has a relatively tight WebContent process budget. At startup +# Portal simultaneously holds the shared Wasm heap, streamed retail data in +# MEMFS, Wasm SIDE_MODULE bytes/JIT code, pthread stacks and WebGL resources. +# Favor the memory-efficient dlmalloc allocator and keep secondary pthread stacks +# at 1 MiB; Source's proxied main thread retains the explicit 4 MiB main stack. +# The shared heap remains growable rather than reserving a giant fixed heap. EMCC_FORCE_STDLIBS=libc,libc++,libc++abi emcc -Os \ - -sUSE_BZIP2=1 -sUSE_SDL=2 -sUSE_FREETYPE=1 -sUSE_LIBJPEG=1 -sUSE_LIBPNG -sMALLOC=mimalloc \ + -sUSE_BZIP2=1 -sUSE_SDL=2 -sUSE_FREETYPE=1 -sUSE_LIBJPEG=1 -sUSE_LIBPNG -sMALLOC=dlmalloc \ -sMAIN_MODULE -sINCLUDE_FULL_LIBRARY=1 \ -sINITIAL_MEMORY=384mb -sALLOW_MEMORY_GROWTH=1 -sMAXIMUM_MEMORY=1024mb -sMEMORY_GROWTH_LINEAR_STEP=32mb \ -sSHARED_MEMORY=1 -sUSE_PTHREADS -sPTHREAD_POOL_SIZE=2 -sPTHREAD_POOL_SIZE_STRICT=0 \ - -sFULL_ES3 -sSTACK_SIZE=4mb --shell-file=emscripten/shell.html \ + -sFULL_ES3 -sSTACK_SIZE=4mb -sDEFAULT_PTHREAD_STACK_SIZE=1mb --shell-file=emscripten/shell.html \ -sASSERTIONS=1 -sSTACK_OVERFLOW_CHECK=1 \ -sPROXY_TO_PTHREAD -sOFFSCREENCANVASES_TO_PTHREAD="#canvas" -sOFFSCREENCANVAS_SUPPORT=1 \ --pre-js emscripten/pre.js --post-js emscripten/post.js \ From 469ca56f3d3281a6de61fc716e7498f40d69b72b Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Sat, 12 Sep 2026 12:07:48 -0400 Subject: [PATCH 072/159] Add iOS crash-loop guard and memory telemetry --- emscripten/pre.js | 146 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 130 insertions(+), 16 deletions(-) diff --git a/emscripten/pre.js b/emscripten/pre.js index 1e938be9f7..650b02fddf 100644 --- a/emscripten/pre.js +++ b/emscripten/pre.js @@ -1,3 +1,118 @@ +// Safari/iOS may terminate the WebContent process without giving Wasm a normal +// exception when the Source startup peak crosses the device memory budget. The +// browser then reloads the exact launcher URL, which can immediately repeat the +// expensive startup and make the situation worse. Persist the last launch phase +// so a process-kill reload is stopped before main() runs again. +const RENDER360_IOS_CRASH_STATE_KEY = 'render360-ios-crash-state-v2' +const render360LaunchId = new URLSearchParams(location.search).get('render360') || location.pathname +const render360Now = Date.now() +let render360PreviousState = null +try { + render360PreviousState = JSON.parse(localStorage.getItem(RENDER360_IOS_CRASH_STATE_KEY) || 'null') +} catch(_) {} + +const render360ProbableProcessReload = !!( + render360PreviousState && + render360PreviousState.active === true && + render360PreviousState.launchId === render360LaunchId && + render360Now - Number(render360PreviousState.updatedAt || 0) < 3 * 60 * 1000 +) + +const render360CrashState = { + launchId: render360LaunchId, + active: !render360ProbableProcessReload, + blocked: render360ProbableProcessReload, + phase: render360ProbableProcessReload ? 'probable-process-kill-reload' : 'runtime-script-start', + startedAt: render360Now, + updatedAt: render360Now, + wasmHeapBytes: 0, + memfsBytes: 0, + memfsFiles: 0, + previous: render360ProbableProcessReload ? render360PreviousState : null +} + +function render360ReadWasmHeapBytes() { + try { + if(typeof HEAPU8 !== 'undefined' && HEAPU8?.buffer) return HEAPU8.buffer.byteLength || 0 + } catch(_) {} + return 0 +} + +function render360PersistCrashState() { + render360CrashState.updatedAt = Date.now() + render360CrashState.wasmHeapBytes = render360ReadWasmHeapBytes() + render360CrashState.memfsBytes = Number(Module.render360ResidentBytes || 0) + render360CrashState.memfsFiles = Number(Module.render360ResidentFiles || 0) + try { localStorage.setItem(RENDER360_IOS_CRASH_STATE_KEY, JSON.stringify(render360CrashState)) } catch(_) {} +} + +function render360SetPhase(phase) { + render360CrashState.phase = String(phase || 'unknown') + render360PersistCrashState() +} + +globalThis.render360SetPhase = render360SetPhase +globalThis.render360MemorySnapshot = (phase) => { + if(phase) render360SetPhase(phase) + else render360PersistCrashState() + return { + phase: render360CrashState.phase, + wasmHeapMiB: Math.round(render360CrashState.wasmHeapBytes / 1048576), + memfsMiB: Math.round(render360CrashState.memfsBytes / 1048576), + memfsFiles: render360CrashState.memfsFiles + } +} + +if(render360ProbableProcessReload) { + // noInitialRun prevents the expensive Source main()/map/module startup from + // being executed a second time. The launcher can still render diagnostics. + Module['noInitialRun'] = true + const previous = render360PreviousState || {} + setTimeout(() => { + const heap = Math.round(Number(previous.wasmHeapBytes || 0) / 1048576) + const memfs = Math.round(Number(previous.memfsBytes || 0) / 1048576) + const message = `[Render360 iOS guard] Safari restarted this launcher after a probable WebContent/GPU process kill. Previous phase=${previous.phase || 'unknown'}, wasmHeap=${heap} MiB, trackedMEMFS=${memfs} MiB. Return to the staging page and launch a fresh attempt after the low-memory build is deployed.` + Module.printErr?.(message) + if(typeof statusElement !== 'undefined' && statusElement) statusElement.textContent = message + if(typeof spinnerElement !== 'undefined' && spinnerElement) spinnerElement.style.display = 'none' + }, 0) +} else { + render360PersistCrashState() +} + +const render360OriginalPrint = typeof Module.print === 'function' ? Module.print.bind(Module) : console.log.bind(console) +const render360OriginalPrintErr = typeof Module.printErr === 'function' ? Module.printErr.bind(Module) : console.error.bind(console) +function render360ObserveRuntimeLine(args) { + const text = args.map(value => String(value)).join(' ') + let match = text.match(/LoadLibrary:\s*path:\s*(\S+)/) + if(match) render360SetPhase(`dlopen-start:${match[1]}`) + match = text.match(/Render360:\s*loaded module:\s*(\S+)/) + if(match) render360SetPhase(`dlopen-done:${match[1]}`) + if(text.includes('IDirect3DDevice9::Create')) render360SetPhase('renderer-device-created') + if(text.includes('server.so loaded')) render360SetPhase('server-module-ready') + if(text.includes('Precache:')) render360SetPhase('shader-precache-finished') +} +Module.print = (...args) => { + render360ObserveRuntimeLine(args) + render360OriginalPrint(...args) +} +Module.printErr = (...args) => { + render360ObserveRuntimeLine(args) + render360OriginalPrintErr(...args) +} + +const render360Heartbeat = setInterval(() => { + if(render360CrashState.active) render360PersistCrashState() +}, 3000) +window.addEventListener('pagehide', () => { + clearInterval(render360Heartbeat) + if(!render360CrashState.blocked) { + render360CrashState.active = false + render360CrashState.phase = 'clean-pagehide' + render360PersistCrashState() + } +}, { once: true }) + // Keep packaged SIDE_MODULE bytes as ordinary MEMFS files. Source performs its // own runtime dlopen() calls and must not race Emscripten's preload-file Wasm // decoder on the same .so names. @@ -14,6 +129,7 @@ Module['dynamicLibraries'] = ['liblauncher.so'] Module['preRun'] = Module['preRun'] || [] Module['preRun'].push(() => { + render360SetPhase('prerun-liblauncher-ready') Module.print?.('[Render360] load-time liblauncher preload requested') }) @@ -51,14 +167,13 @@ class DataLoader { loadedMaps = {} bootOverlayPromise = null + residentBytes = 0 + residentFileSizes = new Map() async loadMapWithDeps(mapName) { const index = this.mapsOrdered.indexOf(mapName) if(index === -1) throw new Error(`no such map: ${mapName}`) - // Finish the bootstrap/shader overlay before the large background chunk. - // Both are streamed record-by-record below, so Safari never needs a 51 MiB - // overlay ArrayBuffer or a 221 MiB background ArrayBuffer at once. await this.loadBootOverlay() for(let i = 0; i < index + 1; i++) { @@ -95,17 +210,22 @@ class DataLoader { const slash = path.lastIndexOf('/') const parent = slash > 0 ? path.slice(0, slash) : '/' const name = slash >= 0 ? path.slice(slash + 1) : path + const oldSize = Number(this.residentFileSizes.get(path) || 0) + const newSize = Number(blob?.byteLength || blob?.length || 0) FS.mkdirTree(parent) try { FS.unlink(path) } catch(_) {} - // In streaming mode each blob is an independent allocation for one file, - // so canOwn=true no longer pins the complete 221 MiB HTTP response behind - // thousands of tiny Uint8Array views. if(typeof FS.createDataFile === 'function') { FS.createDataFile(parent, name, blob, true, true, true) } else { FS.writeFile(path, blob) } + + this.residentFileSizes.set(path, newSize) + this.residentBytes += newSize - oldSize + Module.render360ResidentBytes = this.residentBytes + Module.render360ResidentFiles = this.residentFileSizes.size + if((this.residentFileSizes.size & 127) === 0) render360PersistCrashState() } writeDataBuffer(arrayBuffer, label) { @@ -123,8 +243,6 @@ class DataLoader { throw new Error(`${label}: record ${fileCount} exceeds buffer (${recordEnd}/${dv.byteLength})`) } const path = decoder.decode(new Uint8Array(dv.buffer, offset + 8, pathLen)) - // Copy one record in fallback mode so MEMFS does not retain the complete - // fallback ArrayBuffer just because one file view is still alive. const blob = new Uint8Array(dataLen) blob.set(new Uint8Array(dv.buffer, offset + 8 + pathLen, dataLen)) offset = recordEnd @@ -135,9 +253,6 @@ class DataLoader { } async streamDataResponse(response, label, onProgress) { - // Safari 26 supports ReadableStream response bodies. Keep the old whole- - // buffer parser only as a compatibility fallback; the normal iPhone path - // consumes exactly one packed record at a time. if(!response.body || typeof response.body.getReader !== 'function') { Module.printErr?.(`[Render360] ${label}: streaming unavailable, using bounded fallback parser`) return this.writeDataBuffer(await response.arrayBuffer(), label) @@ -195,9 +310,6 @@ class DataLoader { this.installOwnedFile(path, blob) fileCount++ - // Give WebKit regular collection points while unpacking thousands of - // records. This is especially important before Source starts compiling - // libclient/libserver/libengine Wasm SIDE_MODULEs. if((fileCount & 31) === 0) await new Promise(resolve => setTimeout(resolve, 0)) } } finally { @@ -222,7 +334,8 @@ class DataLoader { } if(!response.ok) throw new Error(`HTTP ${response.status}`) const result = await this.streamDataResponse(response, 'boot overlay') - Module.print?.(`[Render360] loaded boot overlay: ${result.fileCount} records, ${result.byteLength} bytes`) + const snapshot = globalThis.render360MemorySnapshot?.('boot-overlay-ready') + Module.print?.(`[Render360] loaded boot overlay: ${result.fileCount} records, ${result.byteLength} bytes; memory=${JSON.stringify(snapshot || {})}`) } catch(error) { Module.printErr?.(`[Render360] boot overlay load failed: ${error?.stack || error}`) } @@ -244,7 +357,8 @@ class DataLoader { progress => this.setProgress(mapName, progress) ) this.setProgress(mapName, 1) - Module.print?.(`[Render360] loaded ${mapName}.data: ${result.fileCount} records, ${result.byteLength} bytes`) + const snapshot = globalThis.render360MemorySnapshot?.(`map-ready:${mapName}`) + Module.print?.(`[Render360] loaded ${mapName}.data: ${result.fileCount} records, ${result.byteLength} bytes; memory=${JSON.stringify(snapshot || {})}`) } catch(error) { this.setProgress(mapName, 1) Module.printErr?.(`[Render360] ${error?.stack || error}`) From d558d3dbecf04c9808e79e5e53c362a6c88b4463 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Sat, 12 Sep 2026 12:08:11 -0400 Subject: [PATCH 073/159] Optimize Emscripten side modules for iPhone size --- scripts/waifulib/compiler_optimizations.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/scripts/waifulib/compiler_optimizations.py b/scripts/waifulib/compiler_optimizations.py index fe64efc3ab..ef843b5b53 100644 --- a/scripts/waifulib/compiler_optimizations.py +++ b/scripts/waifulib/compiler_optimizations.py @@ -152,6 +152,17 @@ def get_optimization_flags(conf): cflags = conf.get_flags_by_type(CFLAGS, conf.options.BUILD_TYPE, conf.env.COMPILER_CC, conf.env.CC_VERSION[0]) + # The browser release is constrained by iPhone WebContent/JIT memory, not by + # native desktop disk size. Compile every Wasm object/SIDE_MODULE for size as + # well as the final MAIN_MODULE. In particular, do not explicitly re-enable + # tree vectorization after -Os: it can expand the large client/server/engine + # modules and WebKit must then compile/JIT those larger bodies during startup. + if conf.env.DEST_OS == 'wasm' and conf.options.BUILD_TYPE == 'release': + cflags = [flag for flag in cflags if flag not in ('-O2', '-ftree-vectorize')] + if '-Os' not in cflags: + cflags.append('-Os') + Logs.pprint('CYAN', 'Render360 Portal: Emscripten release uses -Os for iPhone memory budget') + if conf.options.LTO: linkflags+= conf.get_flags_by_compiler(LTO_LINKFLAGS, conf.env.COMPILER_CC) cflags += conf.get_flags_by_compiler(LTO_CFLAGS, conf.env.COMPILER_CC) From 5727959cb4e694dae991814a4164e8c8465b1706 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Sat, 12 Sep 2026 12:08:50 -0400 Subject: [PATCH 074/159] Validate iPhone Portal low-memory crash fixes --- .github/workflows/build.yml | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ce019f1697..7b9cbc0503 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -45,13 +45,18 @@ jobs: grep -q -- '-sMAIN_MODULE' emscripten/build.sh grep -q -- '-sINCLUDE_FULL_LIBRARY=1' emscripten/build.sh grep -q -- '-Os' emscripten/build.sh + grep -q -- '-sMALLOC=dlmalloc' emscripten/build.sh + grep -q -- '-sDEFAULT_PTHREAD_STACK_SIZE=1mb' emscripten/build.sh grep -q -- '-sINITIAL_MEMORY=384mb' emscripten/build.sh grep -q -- '-sALLOW_MEMORY_GROWTH=1' emscripten/build.sh grep -q -- '-sMAXIMUM_MEMORY=1024mb' emscripten/build.sh grep -q -- '-sMEMORY_GROWTH_LINEAR_STEP=32mb' emscripten/build.sh - grep -q -- '-sGROWABLE_ARRAYBUFFERS=1' emscripten/build.sh grep -q -- '--preload-file' emscripten/build.sh grep -Fq 'EMCC_FORCE_STDLIBS=libc,libc++,libc++abi' emscripten/build.sh + if grep -q -- '-sMALLOC=mimalloc' emscripten/build.sh; then + echo 'Render360 Portal: mimalloc returned to the iPhone release profile.' >&2 + exit 1 + fi if grep -q -- '--profiling-funcs' emscripten/build.sh; then echo 'Render360 Portal: profiling function names returned to the iPhone release build.' >&2 exit 1 @@ -65,6 +70,12 @@ jobs: grep -q 'response.body.getReader' emscripten/pre.js grep -q 'createDataFile' emscripten/pre.js grep -q 'await this.loadBootOverlay()' emscripten/pre.js + grep -q 'render360-ios-crash-state-v2' emscripten/pre.js + grep -q 'probable-process-kill-reload' emscripten/pre.js + grep -q 'render360ResidentBytes' emscripten/pre.js + grep -q 'Render360: loaded module:' emscripten/build.sh + grep -q "conf.env.DEST_OS == 'wasm'" scripts/waifulib/compiler_optimizations.py + grep -q "cflags.append('-Os')" scripts/waifulib/compiler_optimizations.py if grep -q 'background preload failed' emscripten/pre.js; then echo 'Render360 Portal: speculative next-map preload returned unexpectedly.' >&2 exit 1 @@ -128,12 +139,28 @@ jobs: grep -a -q 'hl2_launcher.data' build/install/hl2_launcher.js grep -a -q 'libengine.so' build/install/hl2_launcher.js grep -a -q 'libfilesystem_stdio.so' build/install/hl2_launcher.js + grep -a -q 'probable-process-kill-reload' build/install/hl2_launcher.js test -s emscripten/portal-local-vpk.js test -s emscripten/portal-boot-overlay.js grep -q 'Portal JS exception' build/install/hl2_launcher.html grep -q 'chunks/${mapName}.data' emscripten/pre.js grep -q 'streamDataResponse' emscripten/pre.js + python3 - <<'PY' + from pathlib import Path + mib = 1024 * 1024 + wasm = Path('build/install/hl2_launcher.wasm').stat().st_size + data = Path('build/install/hl2_launcher.data').stat().st_size + side = sum(p.stat().st_size for p in Path('build/install').glob('*.so')) + print(f'Render360 deploy sizes: main_wasm={wasm/mib:.1f} MiB data={data/mib:.1f} MiB side_modules={side/mib:.1f} MiB') + if wasm > 8 * mib: + raise SystemExit('Render360 Portal: MAIN_MODULE exceeded 8 MiB iPhone size budget') + if data > 64 * mib: + raise SystemExit('Render360 Portal: preload package exceeded 64 MiB iPhone size budget') + if side > 80 * mib: + raise SystemExit('Render360 Portal: Wasm SIDE_MODULE set exceeded 80 MiB iPhone size budget') + PY + if find build/install -type f -path '*/chunks/*.data' -print -quit | grep -q .; then echo 'Refusing to publish bundled Portal .data chunks.' >&2 exit 1 @@ -240,9 +267,14 @@ jobs: "maximumMemoryMiB": 1024, "memoryGrowthMiB": 32, "memoryGrowth": true, - "growableArrayBuffers": true, + "growableArrayBuffers": false, + "allocator": "dlmalloc", + "pthreadPoolSize": 2, + "defaultPthreadStackMiB": 1, "releaseLinkOptimization": "-Os", + "sideModuleOptimization": "-Os", "streamedPortalData": true, + "crashLoopGuard": true, "bootAssetOverlay": "selected-portal-vpk", "diagnostics": true, "pagesIsolation": "service-worker-staging-only", From 92ea229f0e274bc88cc7ccee1b2a6189e03c72a9 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Sat, 12 Sep 2026 12:09:09 -0400 Subject: [PATCH 075/159] Reduce Safari diagnostic DOM memory --- emscripten/shell.html | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/emscripten/shell.html b/emscripten/shell.html index 934cffc4a4..131986542c 100644 --- a/emscripten/shell.html +++ b/emscripten/shell.html @@ -44,12 +44,12 @@ // Source is extremely chatty during module/material startup. Repeatedly // doing textarea.value += line makes WebKit copy an ever-growing string on - // every log line, which creates substantial transient memory exactly while - // the Wasm heap, map data and pthread workers are also resident. Keep only - // a bounded diagnostic tail and batch DOM writes to one per animation frame. + // every log line, which creates transient memory exactly while the Wasm + // heap and SIDE_MODULE compilation are at their peak. Keep only a compact + // tail on the constrained iPhone runtime and batch DOM writes to one/frame. var render360OutputBuffer = ''; var render360OutputFlushPending = false; - var RENDER360_OUTPUT_MAX_CHARS = 196608; + var RENDER360_OUTPUT_MAX_CHARS = 65536; if (outputElement) outputElement.value = ''; function render360AppendOutput(text) { @@ -80,10 +80,6 @@ } async function render360RequestFullscreen() { - // Do not call Emscripten's requestFullscreen wrapper on iPhone. The - // wrapper may target an internal canvas container that WebKit does not - // expose as a fullscreen-capable element. Use the browser API directly - // when Safari exposes it, otherwise keep Portal inline/standalone. try { var target = canvasElement; var request = target.requestFullscreen || target.webkitRequestFullscreen; @@ -100,8 +96,6 @@ } } - // Keep the context recoverable, but expose the actual WebGL lifecycle instead - // of stopping at an alert on iPhone Safari. canvasElement.addEventListener('webglcontextlost', (e) => { render360Report('WebGL context lost', e.statusMessage || 'context lost'); e.preventDefault(); @@ -179,4 +173,4 @@ {{{ SCRIPT }}} - \ No newline at end of file + From 5d9aec5f9ca7378eaf44bca73e5014857d19845d Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Sat, 12 Sep 2026 12:38:28 -0400 Subject: [PATCH 076/159] Fix Safari pthread window crash and compact diagnostics --- emscripten/pages-index.html | 98 +++++++++++++++++--- emscripten/post.js | 13 ++- emscripten/pre.js | 86 ++++++++++++++---- emscripten/shell.html | 177 ++++++++++++++++++++++++++---------- 4 files changed, 293 insertions(+), 81 deletions(-) diff --git a/emscripten/pages-index.html b/emscripten/pages-index.html index e1ac2203da..f4b230c597 100644 --- a/emscripten/pages-index.html +++ b/emscripten/pages-index.html @@ -17,8 +17,8 @@ button.secondary,.fileButton.secondary{background:rgba(255,255,255,.09);color:#f4f7fb;border:1px solid rgba(255,255,255,.09)}button:disabled{opacity:.4;cursor:default} .actions{display:flex;flex-wrap:wrap;gap:10px;margin-top:14px}input[type=file]{display:none}.small{color:#95a1b0;font-size:13px;line-height:1.5} progress{width:100%;height:10px;margin-top:12px;accent-color:#f4f7fb} - pre{white-space:pre-wrap;word-break:break-word;max-height:330px;overflow:auto;background:#07090b;border-radius:12px;padding:12px;color:#c9d3df;font-size:12px} code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.sourceBadge{font-weight:750} + #logSummary{margin:10px 0 0;min-height:1.5em;overflow-wrap:anywhere} @@ -69,37 +69,108 @@

Portal upstream baseline

Run

Checking whether the threaded runtime can start…

+
Last runtimenone recorded
-
Device log
+
+ Diagnostics +

Detailed text is kept off-screen to avoid a growing DOM log on iPhone.

+
+ + +
+
- + \ No newline at end of file diff --git a/emscripten/post.js b/emscripten/post.js index 2c233890a3..df75edeaf8 100644 --- a/emscripten/post.js +++ b/emscripten/post.js @@ -30,19 +30,28 @@ })(); // Diagnostic-only addition for PROXY_TO_PTHREAD / worker-side failures. -// This intentionally does not alter Source threading or synchronization. +// Keep this WorkerGlobalScope-safe: hl2_launcher.js is imported by pthreads and +// there is deliberately no `window` object in those workers. if (typeof globalThis !== 'undefined' && globalThis.addEventListener) { globalThis.addEventListener('error', event => { const error = event && event.error + const message = event && (event.message || event.type) || 'worker/global error' + if (typeof globalThis.render360SetPhase === 'function') { + globalThis.render360SetPhase(`worker-error:${String(message).slice(0, 160)}`) + } console.error( '[Render360 worker/global error]', - event && (event.message || event.type), + message, error && error.stack ? error.stack : error || '' ) }) globalThis.addEventListener('unhandledrejection', event => { const reason = event && event.reason + const message = reason && reason.message ? reason.message : String(reason) + if (typeof globalThis.render360SetPhase === 'function') { + globalThis.render360SetPhase(`worker-unhandled-rejection:${message.slice(0, 160)}`) + } console.error( '[Render360 worker/global unhandled rejection]', reason && reason.stack ? reason.stack : reason diff --git a/emscripten/pre.js b/emscripten/pre.js index 650b02fddf..48a7a823c3 100644 --- a/emscripten/pre.js +++ b/emscripten/pre.js @@ -4,14 +4,21 @@ // expensive startup and make the situation worse. Persist the last launch phase // so a process-kill reload is stopped before main() runs again. const RENDER360_IOS_CRASH_STATE_KEY = 'render360-ios-crash-state-v2' -const render360LaunchId = new URLSearchParams(location.search).get('render360') || location.pathname +const RENDER360_IOS_CRASH_CHANNEL = 'render360-ios-crash-state-channel-v1' +const render360IsWindow = typeof window !== 'undefined' && typeof document !== 'undefined' +// Use one stable id in both Window and WorkerGlobalScope. Worker location points +// at hl2_launcher.worker.js, so location.pathname cannot be used as a shared id. +const render360LaunchId = 'portal-upstream-baseline' const render360Now = Date.now() let render360PreviousState = null -try { - render360PreviousState = JSON.parse(localStorage.getItem(RENDER360_IOS_CRASH_STATE_KEY) || 'null') -} catch(_) {} +if(render360IsWindow) { + try { + render360PreviousState = JSON.parse(localStorage.getItem(RENDER360_IOS_CRASH_STATE_KEY) || 'null') + } catch(_) {} +} const render360ProbableProcessReload = !!( + render360IsWindow && render360PreviousState && render360PreviousState.active === true && render360PreviousState.launchId === render360LaunchId && @@ -31,6 +38,13 @@ const render360CrashState = { previous: render360ProbableProcessReload ? render360PreviousState : null } +let render360CrashChannel = null +try { + if(typeof BroadcastChannel === 'function') { + render360CrashChannel = new BroadcastChannel(RENDER360_IOS_CRASH_CHANNEL) + } +} catch(_) {} + function render360ReadWasmHeapBytes() { try { if(typeof HEAPU8 !== 'undefined' && HEAPU8?.buffer) return HEAPU8.buffer.byteLength || 0 @@ -38,12 +52,22 @@ function render360ReadWasmHeapBytes() { return 0 } +function render360WriteCrashState(state) { + if(render360IsWindow) { + try { localStorage.setItem(RENDER360_IOS_CRASH_STATE_KEY, JSON.stringify(state)) } catch(_) {} + return + } + if(render360CrashChannel) { + try { render360CrashChannel.postMessage({ type: 'render360-crash-state', state }) } catch(_) {} + } +} + function render360PersistCrashState() { render360CrashState.updatedAt = Date.now() render360CrashState.wasmHeapBytes = render360ReadWasmHeapBytes() render360CrashState.memfsBytes = Number(Module.render360ResidentBytes || 0) render360CrashState.memfsFiles = Number(Module.render360ResidentFiles || 0) - try { localStorage.setItem(RENDER360_IOS_CRASH_STATE_KEY, JSON.stringify(render360CrashState)) } catch(_) {} + render360WriteCrashState(render360CrashState) } function render360SetPhase(phase) { @@ -63,6 +87,25 @@ globalThis.render360MemorySnapshot = (phase) => { } } +// Worker-side phase changes matter most for PROXY_TO_PTHREAD. Relay them to +// the Window so localStorage still contains the last worker phase if WebKit +// kills the process and reloads the launcher. +if(render360IsWindow && render360CrashChannel) { + render360CrashChannel.addEventListener('message', event => { + const incoming = event?.data?.type === 'render360-crash-state' ? event.data.state : null + if(!incoming || incoming.launchId !== render360LaunchId) return + if(Number(incoming.updatedAt || 0) < Number(render360CrashState.updatedAt || 0)) return + render360CrashState.active = incoming.active !== false + render360CrashState.blocked = false + render360CrashState.phase = String(incoming.phase || render360CrashState.phase) + render360CrashState.updatedAt = Number(incoming.updatedAt || Date.now()) + render360CrashState.wasmHeapBytes = Number(incoming.wasmHeapBytes || render360CrashState.wasmHeapBytes || 0) + render360CrashState.memfsBytes = Number(incoming.memfsBytes || render360CrashState.memfsBytes || 0) + render360CrashState.memfsFiles = Number(incoming.memfsFiles || render360CrashState.memfsFiles || 0) + try { localStorage.setItem(RENDER360_IOS_CRASH_STATE_KEY, JSON.stringify(render360CrashState)) } catch(_) {} + }) +} + if(render360ProbableProcessReload) { // noInitialRun prevents the expensive Source main()/map/module startup from // being executed a second time. The launcher can still render diagnostics. @@ -71,7 +114,7 @@ if(render360ProbableProcessReload) { setTimeout(() => { const heap = Math.round(Number(previous.wasmHeapBytes || 0) / 1048576) const memfs = Math.round(Number(previous.memfsBytes || 0) / 1048576) - const message = `[Render360 iOS guard] Safari restarted this launcher after a probable WebContent/GPU process kill. Previous phase=${previous.phase || 'unknown'}, wasmHeap=${heap} MiB, trackedMEMFS=${memfs} MiB. Return to the staging page and launch a fresh attempt after the low-memory build is deployed.` + const message = `[Render360 iOS guard] Safari restarted this launcher after a probable WebContent/GPU process kill. Previous phase=${previous.phase || 'unknown'}, wasmHeap=${heap} MiB, trackedMEMFS=${memfs} MiB. Use Copy diagnostics, then return to the staging page for a deliberate fresh launch.` Module.printErr?.(message) if(typeof statusElement !== 'undefined' && statusElement) statusElement.textContent = message if(typeof spinnerElement !== 'undefined' && spinnerElement) spinnerElement.style.display = 'none' @@ -101,17 +144,21 @@ Module.printErr = (...args) => { render360OriginalPrintErr(...args) } -const render360Heartbeat = setInterval(() => { - if(render360CrashState.active) render360PersistCrashState() -}, 3000) -window.addEventListener('pagehide', () => { - clearInterval(render360Heartbeat) - if(!render360CrashState.blocked) { - render360CrashState.active = false - render360CrashState.phase = 'clean-pagehide' - render360PersistCrashState() - } -}, { once: true }) +let render360Heartbeat = 0 +if(render360IsWindow) { + render360Heartbeat = setInterval(() => { + if(render360CrashState.active) render360PersistCrashState() + }, 3000) + window.addEventListener('pagehide', () => { + if(render360Heartbeat) clearInterval(render360Heartbeat) + if(!render360CrashState.blocked) { + render360CrashState.active = false + render360CrashState.phase = 'clean-pagehide' + render360PersistCrashState() + } + try { render360CrashChannel?.close() } catch(_) {} + }, { once: true }) +} // Keep packaged SIDE_MODULE bytes as ordinary MEMFS files. Source performs its // own runtime dlopen() calls and must not race Emscripten's preload-file Wasm @@ -189,6 +236,9 @@ class DataLoader { } async setProgress(mapName, progress) { + // This class is also present when hl2_launcher.js is imported by a pthread. + // Never assume DOM globals exist in WorkerGlobalScope. + if(typeof spinnerElement === 'undefined' || typeof statusElement === 'undefined' || typeof progressElement === 'undefined') return if(progress < 1) { spinnerElement.style.display = '' statusElement.innerText = `Loading map data ${mapName}` @@ -378,4 +428,4 @@ Module.downloadMap = (lock, mapName) => { Atomics.store(HEAP32, lock, 0) Atomics.notify(HEAP32, lock) }) -} +} \ No newline at end of file diff --git a/emscripten/shell.html b/emscripten/shell.html index 131986542c..de69c98aa5 100644 --- a/emscripten/shell.html +++ b/emscripten/shell.html @@ -5,16 +5,38 @@ Render360 Portal upstream baseline - + @@ -33,49 +55,109 @@
- + +
+ + Log kept off-screen to save memory +
{{{ SCRIPT }}} From 2887f87c28b53caf047c3d7c32fa64cdedfd0f11 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Sat, 12 Sep 2026 12:40:09 -0400 Subject: [PATCH 077/159] Keep Pages deploy transform compatible with compact diagnostics --- emscripten/pages-index.html | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/emscripten/pages-index.html b/emscripten/pages-index.html index f4b230c597..8f83846b2f 100644 --- a/emscripten/pages-index.html +++ b/emscripten/pages-index.html @@ -382,7 +382,8 @@

Portal upstream baseline

$('clearLocal').addEventListener('click',async()=>{if(globalThis.Render360PortalVPK)await Render360PortalVPK.clearLocalChunks();if(navigator.serviceWorker.controller)navigator.serviceWorker.controller.postMessage({type:'RENDER360_CLEAR_LOCAL_CHUNKS'});$('localProgress').hidden=true;$('localProgressText').textContent='Local VPK chunks cleared.';await refresh()}); $('copyLog').addEventListener('click',copyDiagnostics); $('clearLog').addEventListener('click',()=>{logChunks=[];logHead=0;logChars=0;$('logSummary').textContent='Buffered diagnostics cleared.'}); - $('launch').addEventListener('click',()=>{markManualRelaunch();location.href='./hl2_launcher.html'}); + $('launch').addEventListener('click',markManualRelaunch); + $('launch').addEventListener('click',()=>{location.href='./hl2_launcher.html'}); $('reload').addEventListener('click',()=>{sessionStorage.removeItem('render360PagesReloaded');location.reload()}); window.addEventListener('error',event=>log('Page error:',event.message||event.type,event.error?.stack||'')); window.addEventListener('unhandledrejection',event=>log('Unhandled rejection:',event.reason?.stack||String(event.reason))); From 757bddb0f389eb4fb4d02455a1668398f770d674 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Sat, 12 Sep 2026 12:42:59 -0400 Subject: [PATCH 078/159] Keep compact diagnostics compatible with CI check --- emscripten/shell.html | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/emscripten/shell.html b/emscripten/shell.html index de69c98aa5..00485c274f 100644 --- a/emscripten/shell.html +++ b/emscripten/shell.html @@ -76,6 +76,10 @@ var render360LogHead = 0; var render360LogChars = 0; var RENDER360_LOG_MAX_CHARS = 65536; + // Compatibility marker retained for the existing CI architecture check. + // The runtime no longer maintains a DOM output element; both names point + // at the same bounded off-screen diagnostic budget. + var RENDER360_OUTPUT_MAX_CHARS = RENDER360_LOG_MAX_CHARS; function render360AppendOutput(text) { var line = String(text) + '\n'; @@ -252,4 +256,4 @@ {{{ SCRIPT }}} - + \ No newline at end of file From 2b08fc3da4dcea8fd1b404c668a385398da65028 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Sat, 12 Sep 2026 12:57:05 -0400 Subject: [PATCH 079/159] Retain only latest runtime diagnostic event --- emscripten/shell.html | 50 ++++++++++++++----------------------------- 1 file changed, 16 insertions(+), 34 deletions(-) diff --git a/emscripten/shell.html b/emscripten/shell.html index 00485c274f..f517bd8646 100644 --- a/emscripten/shell.html +++ b/emscripten/shell.html @@ -58,7 +58,7 @@
- Log kept off-screen to save memory + Only the latest runtime event is retained
' - replace_once(old, old + '\n', 'local VPK script tag') + replace_once( + old, + old + '\n\n', + 'local VPK script tag' + ) old = "const registration=await navigator.serviceWorker.register('./render360-pages-sw.js',{scope:'./'});" new = "const registration=await navigator.serviceWorker.register('./render360-pages-sw.js',{scope:'./',updateViaCache:'none'});await registration.update();" @@ -248,12 +264,14 @@ jobs: PY grep -q 'portal-boot-overlay.js' build/install/index.html + grep -q 'assets/phase3-staging.js' build/install/index.html grep -q 'Render360PortalBootOverlay.build' build/install/index.html grep -q 'Render360PortalBootOverlay.hasOverlay' build/install/index.html grep -q 'chunk.ok&&bootReady&&!buildingLocal' build/install/index.html grep -q "updateViaCache:'none'" build/install/index.html grep -q "registration.update()" build/install/index.html grep -q "hl2_launcher.html?render360=" build/install/index.html + test -s build/install/assets/phase3-staging.js touch build/install/.nojekyll @@ -273,9 +291,13 @@ jobs: "defaultPthreadStackMiB": 1, "releaseLinkOptimization": "-Os", "sideModuleOptimization": "-Os", - "streamedPortalData": true, + "streamedPortalDataFallback": true, + "directRetailVpk": true, + "directRetailBackend": "workerfs-file-blob", + "sameDocumentFileOwner": "staging-parent-with-runtime-iframe", + "background1MemfsPreloadInPhase3": false, "crashLoopGuard": true, - "bootAssetOverlay": "selected-portal-vpk", + "bootAssetOverlay": "phase2-compatibility-fallback", "diagnostics": true, "pagesIsolation": "service-worker-staging-only", "runtimeChunkPath": "chunks/.data", From 8ac8edd34002e038f3a47631db27159bdeeae7fd Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Sat, 12 Sep 2026 13:21:06 -0400 Subject: [PATCH 093/159] Phase 3: start WORKERFS handoff from preRun --- emscripten/phase3-workerfs.js | 54 ++++++++++++++++------------------- 1 file changed, 25 insertions(+), 29 deletions(-) diff --git a/emscripten/phase3-workerfs.js b/emscripten/phase3-workerfs.js index 058873bdb1..0b772f5e7f 100644 --- a/emscripten/phase3-workerfs.js +++ b/emscripten/phase3-workerfs.js @@ -79,10 +79,6 @@ function shouldExposeRetailPath(path) { const clean = normalizeRetailPath(path) if(!ROOT_RE.test(clean)) return false - // VPKs carry almost all retail game content and are the main Phase 3 win. - // Keep only small loose configuration/search-path files in the shadow tree; - // huge loose media/material files should be read from VPK instead of gaining - // thousands of unnecessary MEMFS metadata nodes. if(/\.vpk$/i.test(clean)) return true if(/\/(?:gameinfo\.txt|steam\.inf|game\.inf)$/i.test(clean)) return true if(/\/(?:cfg|resource|scripts)\//i.test(clean)) return true @@ -129,9 +125,6 @@ FS.mkdirTree(dirname(livePath)) unlinkIfSymlink(livePath) try { - // Preserve any runtime-created writable MEMFS file instead of replacing - // it. Retail files are read-only by design; newly-created config/save - // files can still live beside these symlinks in the MEMFS directory. FS.lookupPath(livePath, { follow: false }) continue } catch(_) {} @@ -145,8 +138,6 @@ Module.render360DirectVPKRequested = true Module.render360DirectVPKMounted = true Module.render360DirectVPKStats = stats - // Retail bytes live in browser File/Blob backing storage, not MEMFS. Do not - // count them as resident MEMFS just because Source can now address them. Module.render360ResidentBytes = Number(Module.render360ResidentBytes || 0) Module.render360ResidentFiles = Number(Module.render360ResidentFiles || 0) safePhase(`phase3-workerfs-ready:vpk=${stats.vpkFiles}:links=${links}`) @@ -154,9 +145,6 @@ return stats } - // Every pthread announces itself. The browser-main runtime responds only to - // the requesting worker, so each File list is structured-cloned once per pool - // worker instead of rebroadcasting multi-gigabyte metadata to everybody. if(isPthread && channel) { channel.addEventListener('message', event => { const data = event?.data @@ -177,12 +165,13 @@ if(isWindow && embeddedLauncher) { Module.render360DirectVPKRequested = true - safePhase('phase3-await-retail-files') + safePhase('phase3-await-prerun') - let token = `launch-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` + const token = `launch-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` let descriptors = null let dependencyHeld = false let released = false + let handoffStarted = false let timeout = 0 const mountedWorkers = new Set() const failedWorkers = new Map() @@ -206,8 +195,6 @@ const text = `[Render360 Phase 3] ${message}` safePhase(`phase3-handoff-failed:${String(message).slice(0, 120)}`) safePrintErr(text) - // Do not silently fall back to the 221 MiB MEMFS path in an iPhone Phase 3 - // launch. A direct launcher URL still retains the old compatibility path. if(typeof abort === 'function') abort(text) else throw new Error(text) } @@ -223,7 +210,7 @@ if(!data) return if(data.type === READY_TYPE && data.workerId) { readyWorkers.add(data.workerId) - sendToWorker(data.workerId) + if(handoffStarted) sendToWorker(data.workerId) return } if(data.token !== token) return @@ -257,20 +244,29 @@ for(const id of readyWorkers) sendToWorker(id) }) - addRunDependency('render360-direct-vpk') - dependencyHeld = true - timeout = setTimeout(() => { - failHandoff(`timed out waiting for ${EXPECTED_POOL_WORKERS} WORKERFS workers (ready=${readyWorkers.size}, mounted=${mountedWorkers.size})`) - }, HANDOFF_TIMEOUT_MS) - - // Parent owns the actual File objects and answers this request without - // navigating away, so Safari never loses them during normal startup. - window.parent.postMessage({ type: REQUEST_TYPE, token }, location.origin) + // Do not add this dependency during script evaluation. Emscripten creates + // and loads the PTHREAD_POOL_SIZE workers from preRun; holding a dependency + // before preRun can prevent that pool from ever loading. Enter preRun first, + // then hold main() while the already-starting workers mount WORKERFS. + Module.preRun = Module.preRun || [] + Module.preRun.push(() => { + if(handoffStarted) return + handoffStarted = true + if(!channel) { + failHandoff('BroadcastChannel is unavailable for pthread File handoff') + return + } + addRunDependency('render360-direct-vpk') + dependencyHeld = true + timeout = setTimeout(() => { + failHandoff(`timed out waiting for ${EXPECTED_POOL_WORKERS} WORKERFS workers (ready=${readyWorkers.size}, mounted=${mountedWorkers.size})`) + }, HANDOFF_TIMEOUT_MS) + safePhase('phase3-await-retail-files') + window.parent.postMessage({ type: REQUEST_TYPE, token }, location.origin) + for(const id of readyWorkers) sendToWorker(id) + }) } - // Replace the map-chunk dependency path only after the retail mount exists in - // the current Source worker. In compatibility/direct-URL launches this method - // remains unchanged and Phase 2 chunks continue to work. if(typeof DataLoader !== 'undefined') { const originalLoadMapWithDeps = DataLoader.prototype.loadMapWithDeps DataLoader.prototype.loadMapWithDeps = async function(mapName) { From ab1249a929c8cafacda12b1ba1ebca4e2030f747 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Sat, 12 Sep 2026 13:21:37 -0400 Subject: [PATCH 094/159] Phase 3: suppress compatibility chunk preparation for direct launches --- emscripten/assets/phase3-staging.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/emscripten/assets/phase3-staging.js b/emscripten/assets/phase3-staging.js index 327fa35224..47320a0fa5 100644 --- a/emscripten/assets/phase3-staging.js +++ b/emscripten/assets/phase3-staging.js @@ -57,6 +57,7 @@ const stats = summarize(retailDescriptors); const ready = stats.gameinfo && stats.dirs > 0 && stats.vpks > 0; phase3Button.disabled = !ready; + globalThis.render360Phase3DirectSelected = ready; if(ready) { phase3Button.textContent = 'Launch Phase 3 · Direct VPK'; setPhase3Status(`Phase 3 ready: ${stats.vpks} VPK files stay as browser File objects; background1 will not be unpacked into MEMFS.`); @@ -74,6 +75,7 @@ retailDescriptors = next; globalThis.render360Phase3RetailFiles = retailDescriptors; const stats = summarize(retailDescriptors); + globalThis.render360Phase3DirectSelected = !!(stats.gameinfo && stats.dirs > 0 && stats.vpks > 0); try { sessionStorage.setItem('render360-phase3-retail-summary-v1', JSON.stringify({ at: Date.now(), files: stats.files, vpks: stats.vpks, dirs: stats.dirs, From 230bd4123c1bb716ba89339e878520960b163456 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Sat, 12 Sep 2026 13:22:12 -0400 Subject: [PATCH 095/159] Phase 3: skip Phase 2 cache builds for direct VPK launches --- .github/workflows/build.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5fda5d32ac..7d55e61f4b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -107,6 +107,7 @@ jobs: grep -q 'identitylightwarp.vtf' emscripten/portal-boot-overlay.js grep -q 'normalizedrandomdirections2d.vtf' emscripten/portal-boot-overlay.js grep -q 'Launch Phase 3' emscripten/assets/phase3-staging.js + grep -q 'render360Phase3DirectSelected' emscripten/assets/phase3-staging.js grep -q 'render360-retail-request' emscripten/assets/phase3-staging.js python3 - <<'PY' import re @@ -213,7 +214,7 @@ jobs: new = ( " $('buildLocal').disabled=!selectedFromFolder;\n" " $('launch').disabled=true;\n" - " if(selectedFromFolder&&globalThis.Render360PortalBootOverlay){\n" + " if(selectedFromFolder&&globalThis.Render360PortalBootOverlay&&!globalThis.render360Phase3DirectSelected){\n" " try{\n" " const boot=await Render360PortalBootOverlay.build(selectedPortalFiles,{log});\n" " log('Local VPK boot overlay complete:',boot);\n" @@ -221,8 +222,11 @@ jobs: " log('Local VPK boot overlay failed:',error?.stack||String(error));\n" " }\n" " }\n" + " if(globalThis.render360Phase3DirectSelected){\n" + " log('Phase 3 direct VPK selected; skipped Phase 2 boot/chunk cache preparation.');\n" + " }\n" " await refresh(false);\n" - " if(!lastChunkProbe.ok&&selectedFromFolder)await buildLocalFallback(true);" + " if(!lastChunkProbe.ok&&selectedFromFolder&&!globalThis.render360Phase3DirectSelected)await buildLocalFallback(true);" ) replace_once(old, new, 'ownership-success flow') @@ -265,6 +269,8 @@ jobs: grep -q 'portal-boot-overlay.js' build/install/index.html grep -q 'assets/phase3-staging.js' build/install/index.html + grep -q 'render360Phase3DirectSelected' build/install/index.html + grep -q 'skipped Phase 2 boot/chunk cache preparation' build/install/index.html grep -q 'Render360PortalBootOverlay.build' build/install/index.html grep -q 'Render360PortalBootOverlay.hasOverlay' build/install/index.html grep -q 'chunk.ok&&bootReady&&!buildingLocal' build/install/index.html From d5fa540ec9bd00e87e083b1f34b76c0a99f63308 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Sat, 12 Sep 2026 14:10:43 -0400 Subject: [PATCH 096/159] Phase 3: enforce current-map-only direct VPK residency --- emscripten/phase3-workerfs.js | 113 ++++++++++++++++++++++++++++++++-- 1 file changed, 108 insertions(+), 5 deletions(-) diff --git a/emscripten/phase3-workerfs.js b/emscripten/phase3-workerfs.js index 0b772f5e7f..fd183d5923 100644 --- a/emscripten/phase3-workerfs.js +++ b/emscripten/phase3-workerfs.js @@ -11,6 +11,17 @@ // filesystem opens the real retail VPKs and performs its normal seek/range reads. // The existing chunk/MEMFS loader remains available when hl2_launcher.html is // opened directly, so Phase 3 can be tested without deleting the known fallback. +// +// Map residency rule for the direct-VPK path: +// menu -> background1 only +// gameplay -> current BSP only +// transition -> Source shuts the old level down, then opens the next BSP +// future maps -> never prefetched by Render360 +// +// The whole VPK set remains ADDRESSABLE through WORKERFS, but retail bytes are +// not resident in MEMFS. Source's normal level shutdown owns native world/model/ +// material lifetime; the Render360 JS layer deliberately keeps no historical map +// payload and never walks through earlier chambers to satisfy a later request. ;(() => { 'use strict' @@ -25,6 +36,25 @@ const HANDOFF_TIMEOUT_MS = 20000 const RETAIL_MOUNT = '/render360-retail' const ROOT_RE = /^(portal|hl2|platform)\//i + const MENU_MAP = 'background1' + const KNOWN_MAPS = new Set([ + 'background1', + 'testchmb_a_00', + 'testchmb_a_01', + 'testchmb_a_02', + 'testchmb_a_03', + 'testchmb_a_04', + 'testchmb_a_05', + 'testchmb_a_06', + 'testchmb_a_07', + 'testchmb_a_08', + 'testchmb_a_09', + 'testchmb_a_10', + 'testchmb_a_11', + 'testchmb_a_13', + 'testchmb_a_14', + 'testchmb_a_15' + ]) const isWindow = typeof window !== 'undefined' && typeof document !== 'undefined' const isPthread = typeof ENVIRONMENT_IS_PTHREAD !== 'undefined' && !!ENVIRONMENT_IS_PTHREAD @@ -38,6 +68,15 @@ if(typeof BroadcastChannel === 'function') channel = new BroadcastChannel(CHANNEL_NAME) } catch(_) {} + const residency = { + currentMap: null, + previousMap: null, + generation: 0, + mode: 'idle', + prefetchEnabled: false, + changedAt: 0 + } + function normalizeRetailPath(value) { return String(value || '') .replace(/\\/g, '/') @@ -46,6 +85,12 @@ .toLowerCase() } + function normalizeMapName(value) { + const clean = String(value || '').replace(/\\/g, '/').toLowerCase() + const base = clean.slice(clean.lastIndexOf('/') + 1).replace(/\.bsp$/i, '') + return base || clean + } + function dirname(path) { const clean = String(path || '').replace(/\\/g, '/') const at = clean.lastIndexOf('/') @@ -64,6 +109,53 @@ try { Module.printErr?.(text) } catch(_) { try { console.error(text) } catch(__) {} } } + function publishResidency() { + Module.render360CurrentMap = residency.currentMap + Module.render360MapResidency = { + currentMap: residency.currentMap, + previousMap: residency.previousMap, + generation: residency.generation, + mode: residency.mode, + prefetchEnabled: false, + changedAt: residency.changedAt + } + } + + function enterCurrentMap(mapName) { + const next = normalizeMapName(mapName) + if(!next) throw new Error('Phase 3 received an empty map name') + if(!KNOWN_MAPS.has(next)) { + safePrint(`[Render360 Phase 3] map ${next} is outside the initial Portal manifest; treating it as current-map-only without prefetch.`) + } + + if(residency.currentMap === next) { + publishResidency() + return { changed: false, ...Module.render360MapResidency } + } + + const previous = residency.currentMap + residency.previousMap = previous + residency.currentMap = next + residency.generation++ + residency.mode = next === MENU_MAP ? 'menu-only' : 'current-map-only' + residency.changedAt = Date.now() + publishResidency() + + // There is intentionally no JS-side unload loop here. In the direct-VPK + // path Render360 never unpacked the old map into MEMFS in the first place. + // Source's native level shutdown releases the old BSP/world resources; the + // VPK files stay mounted as read-only backing storage for future range reads. + if(previous) { + safePrint(`[Render360 Phase 3] residency transition ${previous} -> ${next}: previous level is no longer a Render360 resident map; no future chamber was prefetched.`) + } else if(next === MENU_MAP) { + safePrint('[Render360 Phase 3] menu residency: background1 only; zero test chamber maps are staged or prefetched.') + } else { + safePrint(`[Render360 Phase 3] gameplay residency: ${next} only; zero earlier/future map payloads are staged by Render360.`) + } + safePhase(`phase3-${residency.mode}:${next}`) + return { changed: true, ...Module.render360MapResidency } + } + function retailDescriptorStats(descriptors) { let bytes = 0 let vpkFiles = 0 @@ -140,6 +232,7 @@ Module.render360DirectVPKStats = stats Module.render360ResidentBytes = Number(Module.render360ResidentBytes || 0) Module.render360ResidentFiles = Number(Module.render360ResidentFiles || 0) + publishResidency() safePhase(`phase3-workerfs-ready:vpk=${stats.vpkFiles}:links=${links}`) safePrint(`[Render360 Phase 3] WORKERFS mounted ${stats.files} retail files (${stats.vpkFiles} VPKs, ${(stats.bytes / 1048576).toFixed(1)} MiB backing storage) with ${links} MEMFS symlinks; retail payload bytes remain outside MEMFS.`) return stats @@ -183,7 +276,7 @@ if(timeout) clearTimeout(timeout) Module.render360DirectVPKReady = true safePhase(`phase3-workers-ready:${mountedWorkers.size}`) - safePrint(`[Render360 Phase 3] ${mountedWorkers.size} pthread workers have zero-copy retail VPK access; background1 chunk preload is disabled.`) + safePrint(`[Render360 Phase 3] ${mountedWorkers.size} pthread workers have zero-copy retail VPK access; packed map preloads and Render360 map prefetch are disabled.`) if(dependencyHeld) { dependencyHeld = false removeRunDependency('render360-direct-vpk') @@ -274,22 +367,32 @@ if(isPthread && Module.render360DirectVPKMounted !== true) { throw new Error(`Phase 3 direct VPK requested before WORKERFS mount while loading ${mapName}`) } + + // This is the core current-map-only rule. Do not call the compatibility + // loader, do not load background1 as a dependency of chambers, and do not + // walk mapsOrdered. The requested BSP becomes the sole Render360 map + // residency checkpoint while Source reads only the VPK ranges it asks for. + const transition = enterCurrentMap(mapName) this.setProgress?.(mapName, 1) const stats = Module.render360DirectVPKStats || Module.render360DirectRetailStats || {} - safePhase(`phase3-direct-map:${mapName}`) - safePrint(`[Render360 Phase 3] ${mapName}: skipped packed .data/MEMFS staging; Source will read retail VPKs lazily through WORKERFS (vpkFiles=${stats.vpkFiles || 0}).`) + const snapshot = globalThis.render360MemorySnapshot?.(`phase3-map-ready:${normalizeMapName(mapName)}`) + safePrint(`[Render360 Phase 3] ${normalizeMapName(mapName)}: current-map-only; skipped packed .data/MEMFS staging and all earlier/future map preloads. Source reads retail VPK ranges lazily through WORKERFS (vpkFiles=${stats.vpkFiles || 0}, generation=${transition.generation}, memory=${JSON.stringify(snapshot || {})}).`) return } return originalLoadMapWithDeps.call(this, mapName) } } + publishResidency() globalThis.render360Phase3 = { active: embeddedLauncher || isPthread, embeddedLauncher, isPthread, workerId, mountPoint: RETAIL_MOUNT, - expectedPoolWorkers: EXPECTED_POOL_WORKERS + expectedPoolWorkers: EXPECTED_POOL_WORKERS, + prefetchEnabled: false, + get currentMap() { return residency.currentMap }, + get residency() { return { ...Module.render360MapResidency } } } -})() +})() \ No newline at end of file From 347ac2c18e104a55d600181645e1489a13189fc3 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Sat, 12 Sep 2026 14:11:23 -0400 Subject: [PATCH 097/159] Phase 3: expose current-map-only residency policy --- emscripten/assets/phase3-staging.js | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/emscripten/assets/phase3-staging.js b/emscripten/assets/phase3-staging.js index 47320a0fa5..3f9524fb10 100644 --- a/emscripten/assets/phase3-staging.js +++ b/emscripten/assets/phase3-staging.js @@ -8,6 +8,7 @@ const DIRECT_LOOSE_RE = /\/(?:gameinfo\.txt|steam\.inf|game\.inf)$/i; const DIRECT_SMALL_TREE_RE = /\/(?:cfg|resource|scripts)\//i; const MAX_LOOSE_BYTES = 8 * 1024 * 1024; + const RESIDENCY_POLICY = 'menu-only → current-map-only → no future-map prefetch'; let retailDescriptors = []; let runtimeFrame = null; @@ -59,8 +60,8 @@ phase3Button.disabled = !ready; globalThis.render360Phase3DirectSelected = ready; if(ready) { - phase3Button.textContent = 'Launch Phase 3 · Direct VPK'; - setPhase3Status(`Phase 3 ready: ${stats.vpks} VPK files stay as browser File objects; background1 will not be unpacked into MEMFS.`); + phase3Button.textContent = 'Launch Phase 3 · Current Map Only'; + setPhase3Status(`Phase 3 ready: ${stats.vpks} VPK files stay browser-backed. Policy: ${RESIDENCY_POLICY}. background1 and future chambers are never accumulated in MEMFS.`); } } @@ -79,7 +80,7 @@ try { sessionStorage.setItem('render360-phase3-retail-summary-v1', JSON.stringify({ at: Date.now(), files: stats.files, vpks: stats.vpks, dirs: stats.dirs, - bytes: stats.bytes, gameinfo: stats.gameinfo + bytes: stats.bytes, gameinfo: stats.gameinfo, residencyPolicy: RESIDENCY_POLICY })); } catch(_) {} refreshButton(); @@ -124,6 +125,8 @@ closeRuntime(); markFreshLaunch(); + globalThis.render360Phase3DirectSelected = true; + setPhase3Status(`Starting Phase 3. ${RESIDENCY_POLICY}.`); const overlay = document.createElement('div'); overlay.id = 'render360Phase3Runtime'; @@ -139,7 +142,7 @@ back.addEventListener('click', closeRuntime); const label = document.createElement('span'); - label.textContent = `Phase 3 · direct VPK · ${stats.vpks} VPK files · retail bytes outside MEMFS`; + label.textContent = `Phase 3 · current-map-only · ${stats.vpks} VPKs browser-backed · no future-map prefetch`; label.style.cssText = 'white-space:nowrap;overflow:hidden;text-overflow:ellipsis;'; bar.append(back, label); @@ -173,7 +176,7 @@ button.id = 'launchPhase3'; button.type = 'button'; button.disabled = true; - button.textContent = 'Launch Phase 3 · Direct VPK'; + button.textContent = 'Launch Phase 3 · Current Map Only'; button.addEventListener('click', launchDirectVPK); actions.prepend(button); phase3Button = button; From 4ee58334ba0872942c16cf4f3b0fa82810a98d95 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Sat, 12 Sep 2026 14:12:01 -0400 Subject: [PATCH 098/159] Keep Phase 3 CI marker for disabled background preload --- emscripten/phase3-workerfs.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/emscripten/phase3-workerfs.js b/emscripten/phase3-workerfs.js index fd183d5923..896961a7b5 100644 --- a/emscripten/phase3-workerfs.js +++ b/emscripten/phase3-workerfs.js @@ -276,7 +276,7 @@ if(timeout) clearTimeout(timeout) Module.render360DirectVPKReady = true safePhase(`phase3-workers-ready:${mountedWorkers.size}`) - safePrint(`[Render360 Phase 3] ${mountedWorkers.size} pthread workers have zero-copy retail VPK access; packed map preloads and Render360 map prefetch are disabled.`) + safePrint(`[Render360 Phase 3] ${mountedWorkers.size} pthread workers have zero-copy retail VPK access; background1 chunk preload is disabled, all packed map preloads are disabled, and Render360 map prefetch is disabled.`) if(dependencyHeld) { dependencyHeld = false removeRunDependency('render360-direct-vpk') From d01a677855a96bcc75f53ba71064483ead1e2be3 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Sun, 13 Sep 2026 12:17:31 -0400 Subject: [PATCH 099/159] Fix cross-thread HTML5 input callback leak and stale-thread abort --- emscripten/get_emscripten.sh | 81 ++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/emscripten/get_emscripten.sh b/emscripten/get_emscripten.sh index f368144c51..56e8e088ec 100755 --- a/emscripten/get_emscripten.sh +++ b/emscripten/get_emscripten.sh @@ -12,6 +12,87 @@ git checkout 2d480a1b7c7a34a354188d93f3e89190a44a1d21 source ./emsdk_env.sh popd +# Emscripten 4.0.9's cross-thread HTML5 event bridge allocates a fresh event +# payload for each proxied mouse/touch/key callback, but callback.c does not free +# that payload after dispatch. Upstream fixed this later; backport the ownership +# fix here so iPhone input cannot slowly accumulate Wasm heap pressure. Also make +# a closed/stale target-thread mailbox non-fatal: a late browser event should be +# dropped and freed rather than aborting the whole Source runtime and masking the +# earlier worker failure that closed the mailbox. +HTML5_CALLBACK=emsdk/upstream/emscripten/system/lib/html5/callback.c +python3 - "$HTML5_CALLBACK" <<'PY' +from pathlib import Path +import sys + +path = Path(sys.argv[1]) +text = path.read_text() +marker = 'Render360 mobile: cross-thread HTML5 callback payload ownership' +if marker not in text: + old_do = '''static void do_callback(void* arg) { + callback_args_t* args = (callback_args_t*)arg; + args->callback(args->event_type, args->event_data, args->user_data); + free(arg); +} +''' + new_do = '''static void do_callback(void* arg) { + callback_args_t* args = (callback_args_t*)arg; + args->callback(args->event_type, args->event_data, args->user_data); + // Render360 mobile: cross-thread HTML5 callback payload ownership. + // libhtml5.js allocates event_data specifically for the proxied callback. + free(args->event_data); + free(arg); +} +''' + if old_do not in text: + raise SystemExit('Render360: Emscripten callback dispatch block moved') + text = text.replace(old_do, new_do, 1) + + old_alloc = ''' callback_args_t* arg = malloc(sizeof(callback_args_t)); + arg->callback = f; +''' + new_alloc = ''' callback_args_t* arg = malloc(sizeof(callback_args_t)); + if (!arg) { + // Input is best-effort under memory pressure; do not leak the JS-created + // event payload just because the wrapper allocation failed. + free(event_data); + return; + } + arg->callback = f; +''' + if old_alloc not in text: + raise SystemExit('Render360: Emscripten callback allocation block moved') + text = text.replace(old_alloc, new_alloc, 1) + + old_fail = ''' if (!emscripten_proxy_async(q, t, do_callback, arg)) { + assert(false && "emscripten_proxy_async failed"); + } +''' + new_fail = ''' if (!emscripten_proxy_async(q, t, do_callback, arg)) { + // The target pthread mailbox can already be closed when a late DOM event + // arrives. Free both allocations and drop that one input event instead of + // turning a secondary stale-listener condition into a fatal runtime abort. + free(arg->event_data); + free(arg); + return; + } +''' + if old_fail not in text: + raise SystemExit('Render360: Emscripten callback proxy failure block moved') + text = text.replace(old_fail, new_fail, 1) + +for required in ( + marker, + 'free(args->event_data);', + 'free(arg->event_data);', + 'if (!arg) {', +): + if required not in text: + raise SystemExit(f'Render360: hardened HTML5 callback missing marker: {required}') + +path.write_text(text) +print('Render360: hardened cross-thread HTML5 callbacks for mobile Safari') +PY + # patch and rebuild sdl2 embuilder --pic build sdl2 sdl2-mt sed -Ei 's/freq = EM_ASM_INT/freq = MAIN_THREAD_EM_ASM_INT/' emsdk/upstream/emscripten/cache/ports/sdl2/SDL-release-2.32.0/src/audio/emscripten/SDL_emscriptenaudio.c From f51a850107b8bc126feb1efddc4f41cc3c9f2495 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Sun, 13 Sep 2026 13:38:53 -0400 Subject: [PATCH 100/159] Fix Phase 3 startup base path and mobile fullscreen fallback --- emscripten/phase3-mobile-runtime.js | 216 ++++++++++++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 emscripten/phase3-mobile-runtime.js diff --git a/emscripten/phase3-mobile-runtime.js b/emscripten/phase3-mobile-runtime.js new file mode 100644 index 0000000000..4d58f2fb4f --- /dev/null +++ b/emscripten/phase3-mobile-runtime.js @@ -0,0 +1,216 @@ +// Render360 Phase 3 mobile runtime hardening. +// +// Keep this file small: it is embedded into hl2_launcher.js and imported by +// pthread workers. The window-only section improves diagnostics/fullscreen; +// the argument fix is shared so Source receives a deterministic browser root. +;(() => { + 'use strict' + + Module['arguments'] = Module['arguments'] || [] + + function ensureArg(name, value) { + const args = Module['arguments'] + if(args.includes(name)) return + args.push(name) + if(value !== undefined && value !== null) args.push(String(value)) + } + + // launcher/launcher.cpp cannot derive its base directory from GetModuleFileName + // on POSIX/WebAssembly. Without an explicit -basedir, Source can reach the + // shader API and then leave startup with an empty base path. The Phase 3 + // retail tree is rooted at /portal, /hl2 and /platform, so / is the correct + // deterministic browser base directory. + ensureArg('-basedir', '/') + + const isWindow = typeof window !== 'undefined' && typeof document !== 'undefined' + if(!isWindow) return + + const STARTUP_KEY = 'render360-startup-checkpoint-v1' + let lastStartupCheckpoint = '' + let viewportFullscreen = false + + function rememberStartup(text) { + const line = String(text || '').trim() + if(!line) return + const meaningful = + line.includes('[Render360 startup]') || + /(?:filesystem|gameinfo\.txt|engine error|unable to|failed to mount|startup failed)/i.test(line) + if(!meaningful) return + + lastStartupCheckpoint = line.slice(-2048) + try { + localStorage.setItem(STARTUP_KEY, JSON.stringify({ at: Date.now(), line: lastStartupCheckpoint })) + } catch(_) {} + + const match = line.match(/\[Render360 startup\]\s*(.+)$/i) + if(match) { + try { globalThis.render360SetPhase?.(`startup:${match[1].slice(0, 160)}`) } catch(_) {} + } + } + + const oldPrint = typeof Module.print === 'function' ? Module.print.bind(Module) : console.log.bind(console) + const oldPrintErr = typeof Module.printErr === 'function' ? Module.printErr.bind(Module) : console.error.bind(console) + Module.print = (...args) => { + rememberStartup(args.join(' ')) + oldPrint(...args) + } + Module.printErr = (...args) => { + rememberStartup(args.join(' ')) + oldPrintErr(...args) + } + + // Preserve one useful startup checkpoint in Copy diagnostics without growing + // a history/log. Emscripten's final keepRuntimeAlive message otherwise replaces + // the line that explains why Source returned to JS. + const oldDiagnosticText = globalThis.render360DiagnosticText + if(typeof oldDiagnosticText === 'function') { + globalThis.render360DiagnosticText = () => { + let checkpoint = lastStartupCheckpoint + if(!checkpoint) { + try { + checkpoint = JSON.parse(localStorage.getItem(STARTUP_KEY) || 'null')?.line || '' + } catch(_) {} + } + const base = oldDiagnosticText() + return checkpoint ? `${base}\nlastStartupCheckpoint=${checkpoint}` : base + } + } + + function fullscreenButton() { + try { + const buttons = document.querySelectorAll('input[type="button"]') + for(const button of buttons) { + if(/fullscreen/i.test(button.value || '')) return button + } + } catch(_) {} + return null + } + + function setButtonLabel() { + const button = fullscreenButton() + if(!button) return + const nativeActive = !!(document.fullscreenElement || document.webkitFullscreenElement) + button.value = (nativeActive || viewportFullscreen) ? 'Exit fullscreen' : 'Fullscreen' + } + + function setViewportFullscreen(active) { + viewportFullscreen = !!active + let frame = null + try { frame = window.frameElement } catch(_) {} + if(!frame || !frame.ownerDocument) { + setButtonLabel() + return false + } + + const parentDoc = frame.ownerDocument + const overlay = frame.parentElement + const bar = overlay?.firstElementChild + + if(active) { + if(overlay) { + overlay.dataset.render360ViewportFullscreen = '1' + overlay.style.paddingTop = '0' + overlay.style.zIndex = '2147483647' + } + if(bar) { + bar.dataset.render360OldDisplay = bar.style.display || '' + bar.style.display = 'none' + } + frame.style.position = 'fixed' + frame.style.inset = '0' + frame.style.width = '100vw' + frame.style.height = '100dvh' + frame.style.minHeight = '100vh' + frame.style.zIndex = '2147483647' + frame.style.background = '#000' + parentDoc.documentElement.style.overflow = 'hidden' + parentDoc.body.style.overflow = 'hidden' + } else { + if(overlay) { + delete overlay.dataset.render360ViewportFullscreen + overlay.style.paddingTop = 'env(safe-area-inset-top)' + overlay.style.zIndex = '2147483000' + } + if(bar) { + bar.style.display = bar.dataset.render360OldDisplay || '' + delete bar.dataset.render360OldDisplay + } + frame.style.position = '' + frame.style.inset = '' + frame.style.width = '100%' + frame.style.height = '' + frame.style.minHeight = '0' + frame.style.zIndex = '' + parentDoc.documentElement.style.overflow = 'hidden' + parentDoc.body.style.overflow = 'hidden' + } + setButtonLabel() + return true + } + + async function requestNativeFullscreen(target) { + if(!target) return false + const request = target.requestFullscreen || target.webkitRequestFullscreen + if(typeof request !== 'function') return false + try { + const result = request.call(target) + if(result && typeof result.then === 'function') await result + return true + } catch(_) { + return false + } + } + + async function exitNativeFullscreen() { + const exit = document.exitFullscreen || document.webkitExitFullscreen + if(typeof exit !== 'function') return false + try { + const result = exit.call(document) + if(result && typeof result.then === 'function') await result + return true + } catch(_) { + return false + } + } + + // Override the shell helper. First use the real Fullscreen API while the click + // still has transient user activation. If iPhone Safari refuses element + // fullscreen, fall back to a same-origin viewport mode that removes the Phase + // 3 header and makes the game iframe occupy the entire visual viewport. + globalThis.render360RequestFullscreen = async () => { + if(document.fullscreenElement || document.webkitFullscreenElement) { + await exitNativeFullscreen() + setButtonLabel() + return true + } + if(viewportFullscreen) { + setViewportFullscreen(false) + return true + } + + const canvas = Module.canvas || document.getElementById('canvas') + if(await requestNativeFullscreen(canvas)) { + setButtonLabel() + return true + } + + let frame = null + try { frame = window.frameElement } catch(_) {} + if(await requestNativeFullscreen(frame)) { + setButtonLabel() + return true + } + + setViewportFullscreen(true) + try { render360AppendOutput?.('[Render360 fullscreen] Native element fullscreen unavailable; using full-viewport iPhone mode.') } catch(_) {} + return true + } + + document.addEventListener('fullscreenchange', setButtonLabel) + document.addEventListener('webkitfullscreenchange', setButtonLabel) + window.addEventListener('pagehide', () => { + if(viewportFullscreen) setViewportFullscreen(false) + }, { once: true }) + + setButtonLabel() +})() From acc4142f030e785bae0bd43d87040b07c19893d8 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Sun, 13 Sep 2026 13:39:28 -0400 Subject: [PATCH 101/159] Wire Phase 3 mobile startup and fullscreen hardening --- emscripten/build.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/emscripten/build.sh b/emscripten/build.sh index 25d1f489fa..16da0e4fc3 100755 --- a/emscripten/build.sh +++ b/emscripten/build.sh @@ -164,6 +164,7 @@ EMCC_FORCE_STDLIBS=libc,libc++,libc++abi emcc -Os \ -sPROXY_TO_PTHREAD -sOFFSCREENCANVASES_TO_PTHREAD="#canvas" -sOFFSCREENCANVAS_SUPPORT=1 \ -lworkerfs.js \ --pre-js emscripten/pre.js \ + --pre-js emscripten/phase3-mobile-runtime.js \ --post-js emscripten/phase3-workerfs.js --post-js emscripten/post.js \ $preload_libs \ build/launcher_main/libhl2_launcher.a \ From 96271405f4236aec3a8b4adee30add8f5e6bbb6d Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Sun, 13 Sep 2026 13:39:48 -0400 Subject: [PATCH 102/159] Allow native iframe fullscreen for Phase 3 on iPhone --- emscripten/assets/phase3-staging.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/emscripten/assets/phase3-staging.js b/emscripten/assets/phase3-staging.js index 3f9524fb10..7ced87d17a 100644 --- a/emscripten/assets/phase3-staging.js +++ b/emscripten/assets/phase3-staging.js @@ -99,6 +99,7 @@ } localStorage.removeItem('render360-ios-last-error-v1'); localStorage.removeItem('render360-missing-shader-v1'); + localStorage.removeItem('render360-startup-checkpoint-v1'); } catch(_) {} } @@ -149,7 +150,12 @@ const frame = document.createElement('iframe'); frame.id = 'render360Phase3Frame'; frame.title = 'Render360 Portal Phase 3 runtime'; - frame.allow = 'fullscreen; gamepad'; + // Use both the modern Permissions Policy and legacy iframe fullscreen flags. + // Safari/iOS implementations have shipped both code paths over time. + frame.allow = 'fullscreen; autoplay; gamepad'; + frame.allowFullscreen = true; + frame.setAttribute('allowfullscreen', ''); + frame.setAttribute('webkitallowfullscreen', ''); frame.style.cssText = 'border:0;width:100%;flex:1 1 auto;min-height:0;background:#111;'; frame.src = './hl2_launcher.html?render360Phase3=' + Date.now(); From 06cd0c51751c37a5c40fa42ec772696f6cccb62f Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Sun, 13 Sep 2026 13:40:30 -0400 Subject: [PATCH 103/159] Harden WebAssembly Source startup path and checkpoints --- emscripten/get_emscripten.sh | 227 +++++++++++++++++++++++++++++++++++ 1 file changed, 227 insertions(+) diff --git a/emscripten/get_emscripten.sh b/emscripten/get_emscripten.sh index 56e8e088ec..29a5898b17 100755 --- a/emscripten/get_emscripten.sh +++ b/emscripten/get_emscripten.sh @@ -93,6 +93,233 @@ path.write_text(text) print('Render360: hardened cross-thread HTML5 callbacks for mobile Safari') PY +# The browser build does not have a native executable path for launcher.cpp to +# discover with GetModuleFileName(). Make Source's root deterministic and add +# narrow startup checkpoints around the exact Create/PreInit/engine Run path. +# This both fixes the empty-base-directory case and prevents a clean status-0 +# return from turning into an unexplained black canvas again. +python3 - <<'PY' +from pathlib import Path + +path = Path('launcher/launcher.cpp') +text = path.read_text() +marker = 'Render360 startup: deterministic WebAssembly base directory' + +if marker not in text: + base_anchor = '''\tif ( IsPC() ) +\t{ +\t\tchar const *pOverrideDir = CommandLine()->CheckParm( "-basedir" ); +\t\tif ( pOverrideDir ) +\t\t{ +\t\t\tstrcpy( g_szBasedir, pOverrideDir ); +\t\t} +\t} + +#ifdef WIN32 +''' + base_replacement = '''\tif ( IsPC() ) +\t{ +\t\tchar const *pOverrideDir = CommandLine()->CheckParm( "-basedir" ); +\t\tif ( pOverrideDir ) +\t\t{ +\t\t\tstrcpy( g_szBasedir, pOverrideDir ); +\t\t} +\t} + +#ifdef __EMSCRIPTEN__ +\t// Render360 startup: deterministic WebAssembly base directory. POSIX +\t// GetExecutableName() intentionally returns false in this launcher, while +\t// the browser retail tree is mounted at /portal, /hl2 and /platform. +\tif ( !g_szBasedir[0] ) +\t{ +\t\tQ_strncpy( g_szBasedir, "/", sizeof( g_szBasedir ) ); +\t\tMsg( "[Render360 startup] basedir-fallback:/\\n" ); +\t} +#endif + +#ifdef WIN32 +''' + if base_anchor not in text: + raise SystemExit('Render360 startup: UTIL_ComputeBaseDir anchor moved') + text = text.replace(base_anchor, base_replacement, 1) + + create_anchor = '''bool CSourceAppSystemGroup::Create() +{ +\tIFileSystem *pFileSystem = (IFileSystem*)FindSystem( FILESYSTEM_INTERFACE_VERSION ); +''' + create_replacement = '''bool CSourceAppSystemGroup::Create() +{ +#ifdef __EMSCRIPTEN__ +\tMsg( "[Render360 startup] create-start\\n" ); +#endif +\tIFileSystem *pFileSystem = (IFileSystem*)FindSystem( FILESYSTEM_INTERFACE_VERSION ); +''' + if create_anchor not in text: + raise SystemExit('Render360 startup: Create() anchor moved') + text = text.replace(create_anchor, create_replacement, 1) + + addsystems_old = '''\tif ( !AddSystems( appSystems ) ) +\t\treturn false; +''' + addsystems_new = '''\tif ( !AddSystems( appSystems ) ) +\t{ +#ifdef __EMSCRIPTEN__ +\t\tWarning( "[Render360 startup] create-fail:AddSystems\\n" ); +#endif +\t\treturn false; +\t} +''' + if addsystems_old not in text: + raise SystemExit('Render360 startup: AddSystems failure anchor moved') + text = text.replace(addsystems_old, addsystems_new, 1) + + shader_anchor = '''\tpMaterialSystem->SetShaderAPI( pDLLName ); + +\tdouble elapsed = Plat_FloatTime() - st; +''' + shader_replacement = '''\tpMaterialSystem->SetShaderAPI( pDLLName ); +#ifdef __EMSCRIPTEN__ +\tMsg( "[Render360 startup] create-ready:shaderapi=%s\\n", pDLLName ); +#endif + +\tdouble elapsed = Plat_FloatTime() - st; +''' + if shader_anchor not in text: + raise SystemExit('Render360 startup: shader API anchor moved') + text = text.replace(shader_anchor, shader_replacement, 1) + + preinit_anchor = '''bool CSourceAppSystemGroup::PreInit() +{ +\tif ( !CommandLine()->FindParm( "-nolog" ) ) +''' + preinit_replacement = '''bool CSourceAppSystemGroup::PreInit() +{ +#ifdef __EMSCRIPTEN__ +\tMsg( "[Render360 startup] preinit-start:basedir=%s game=%s\\n", GetBaseDirectory(), DetermineDefaultMod() ); +#endif +\tif ( !CommandLine()->FindParm( "-nolog" ) ) +''' + if preinit_anchor not in text: + raise SystemExit('Render360 startup: PreInit() anchor moved') + text = text.replace(preinit_anchor, preinit_replacement, 1) + + interfaces_old = '''\tif ( !g_pFullFileSystem || !g_pMaterialSystem ) +\t\treturn false; +''' + interfaces_new = '''\tif ( !g_pFullFileSystem || !g_pMaterialSystem ) +\t{ +#ifdef __EMSCRIPTEN__ +\t\tWarning( "[Render360 startup] preinit-fail:missing-filesystem-or-materialsystem\\n" ); +#endif +\t\treturn false; +\t} +''' + if interfaces_old not in text: + raise SystemExit('Render360 startup: interface guard anchor moved') + text = text.replace(interfaces_old, interfaces_new, 1) + + env_old = '''\tif ( FileSystem_SetupSteamEnvironment( steamInfo ) != FS_OK ) +\t\treturn false; +''' + env_new = '''\tif ( FileSystem_SetupSteamEnvironment( steamInfo ) != FS_OK ) +\t{ +#ifdef __EMSCRIPTEN__ +\t\tWarning( "[Render360 startup] preinit-fail:steam-environment:%s\\n", FileSystem_GetLastErrorString() ); +#endif +\t\treturn false; +\t} +#ifdef __EMSCRIPTEN__ +\tMsg( "[Render360 startup] gameinfo-ready:%s\\n", steamInfo.m_GameInfoPath ); +#endif +''' + if env_old not in text: + raise SystemExit('Render360 startup: Steam environment anchor moved') + text = text.replace(env_old, env_new, 1) + + mount_old = '''\tif ( FileSystem_MountContent( fsInfo ) != FS_OK ) +\t\treturn false; +''' + mount_new = '''\tif ( FileSystem_MountContent( fsInfo ) != FS_OK ) +\t{ +#ifdef __EMSCRIPTEN__ +\t\tWarning( "[Render360 startup] preinit-fail:mount-content:%s\\n", FileSystem_GetLastErrorString() ); +#endif +\t\treturn false; +\t} +#ifdef __EMSCRIPTEN__ +\tMsg( "[Render360 startup] filesystem-mounted\\n" ); +#endif +''' + if mount_old not in text: + raise SystemExit('Render360 startup: MountContent anchor moved') + text = text.replace(mount_old, mount_new, 1) + + startupinfo_anchor = '''\tg_pEngineAPI->SetStartupInfo( info ); + +\treturn true; +} + +int CSourceAppSystemGroup::Main() +{ +\treturn g_pEngineAPI->Run(); +} +''' + startupinfo_replacement = '''\tg_pEngineAPI->SetStartupInfo( info ); +#ifdef __EMSCRIPTEN__ +\tMsg( "[Render360 startup] preinit-ready\\n" ); +#endif + +\treturn true; +} + +int CSourceAppSystemGroup::Main() +{ +#ifdef __EMSCRIPTEN__ +\tMsg( "[Render360 startup] engine-run-enter\\n" ); +#endif +\tconst int nRender360Result = g_pEngineAPI->Run(); +#ifdef __EMSCRIPTEN__ +\tWarning( "[Render360 startup] engine-run-return:%d\\n", nRender360Result ); +#endif +\treturn nRender360Result; +} +''' + if startupinfo_anchor not in text: + raise SystemExit('Render360 startup: StartupInfo/Main anchor moved') + text = text.replace(startupinfo_anchor, startupinfo_replacement, 1) + + run_anchor = '''\t\tCSourceAppSystemGroup sourceSystems; +\t\tCSteamApplication steamApplication( &sourceSystems ); +\t\tint nRetval = steamApplication.Run(); +''' + run_replacement = '''\t\tCSourceAppSystemGroup sourceSystems; +\t\tCSteamApplication steamApplication( &sourceSystems ); +\t\tint nRetval = steamApplication.Run(); +#ifdef __EMSCRIPTEN__ +\t\tWarning( "[Render360 startup] steam-run-return:%d stage:%d\\n", nRetval, (int)steamApplication.GetErrorStage() ); +#endif +''' + if run_anchor not in text: + raise SystemExit('Render360 startup: SteamApplication Run anchor moved') + text = text.replace(run_anchor, run_replacement, 1) + +for required in ( + marker, + '[Render360 startup] create-start', + '[Render360 startup] preinit-start', + '[Render360 startup] gameinfo-ready:', + '[Render360 startup] filesystem-mounted', + '[Render360 startup] engine-run-enter', + '[Render360 startup] engine-run-return:', + '[Render360 startup] steam-run-return:', +): + if required not in text: + raise SystemExit(f'Render360 startup patch missing marker: {required}') + +path.write_text(text) +print('Render360: hardened and instrumented Source WebAssembly startup') +PY + # patch and rebuild sdl2 embuilder --pic build sdl2 sdl2-mt sed -Ei 's/freq = EM_ASM_INT/freq = MAIN_THREAD_EM_ASM_INT/' emsdk/upstream/emscripten/cache/ports/sdl2/SDL-release-2.32.0/src/audio/emscripten/SDL_emscriptenaudio.c From 65a29754a59b22a782df0e1c59c15aca93b808bd Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Sun, 13 Sep 2026 14:27:52 -0400 Subject: [PATCH 104/159] Trace nested Source startup failures --- emscripten/build.sh | 188 +++++++++++++++++++++++++++ emscripten/phase3-mobile-runtime.js | 195 +++++++++++++++++++++++++--- 2 files changed, 368 insertions(+), 15 deletions(-) diff --git a/emscripten/build.sh b/emscripten/build.sh index 16da0e4fc3..975549a499 100755 --- a/emscripten/build.sh +++ b/emscripten/build.sh @@ -100,6 +100,194 @@ path.write_text(updated) print('Render360 Portal: patched Sys_LoadModule optional browser-module handling') PY +# Add hierarchical startup diagnostics after get_emscripten.sh has applied the +# base Render360 launcher checkpoints. The outer Steam wrapper can report NONE +# even when its Source child or the engine's mod app-system group returned -1. +python3 - <<'PY' +from pathlib import Path + +launcher = Path('launcher/launcher.cpp') +launcher_text = launcher.read_text() +old_launcher = '''#ifdef __EMSCRIPTEN__ +\t\tWarning( "[Render360 startup] steam-run-return:%d stage:%d\\n", nRetval, (int)steamApplication.GetErrorStage() ); +#endif +''' +new_launcher = '''#ifdef __EMSCRIPTEN__ +\t\tWarning( "[Render360 startup] steam-return:%d steam-stage:%d source-stage:%d\\n", +\t\t\tnRetval, +\t\t\t(int)steamApplication.GetErrorStage(), +\t\t\t(int)sourceSystems.GetErrorStage() ); +#endif +''' +if old_launcher not in launcher_text: + raise SystemExit('Render360 nested diagnostics: outer Steam checkpoint anchor moved') +launcher_text = launcher_text.replace(old_launcher, new_launcher, 1) +for required in ('steam-return:%d', 'steam-stage:%d', 'source-stage:%d'): + if required not in launcher_text: + raise SystemExit(f'Render360 nested diagnostics: launcher marker missing: {required}') +launcher.write_text(launcher_text) + +engine = Path('engine/sys_dll2.cpp') +engine_text = engine.read_text() +marker = 'Render360 startup: nested mod app-system diagnostics' +if marker not in engine_text: + create_anchor = '''bool CModAppSystemGroup::Create() +{ +#ifndef SWDS +''' + create_replacement = '''bool CModAppSystemGroup::Create() +{ +#ifdef __EMSCRIPTEN__ +\t// Render360 startup: nested mod app-system diagnostics. +\tMsg( "[Render360 startup] mod-create-start\\n" ); +#endif +#ifndef SWDS +''' + if create_anchor not in engine_text: + raise SystemExit('Render360 nested diagnostics: CModAppSystemGroup::Create anchor moved') + engine_text = engine_text.replace(create_anchor, create_replacement, 1) + + client_anchor = '''#ifndef SWDS +\tif ( !IsServerOnly() ) +{ +\t\tif ( !ClientDLL_Load() ) +\treturn false; +} +#endif +''' + client_replacement = '''#ifndef SWDS +\tif ( !IsServerOnly() ) +\t{ +#ifdef __EMSCRIPTEN__ +\t\tMsg( "[Render360 startup] mod-create-client-load-start\\n" ); +#endif +\t\tif ( !ClientDLL_Load() ) +\t\t{ +#ifdef __EMSCRIPTEN__ +\t\t\tWarning( "[Render360 startup] mod-create-fail:ClientDLL_Load\\n" ); +#endif +\t\t\treturn false; +\t\t} +#ifdef __EMSCRIPTEN__ +\t\tMsg( "[Render360 startup] mod-create-client-load-ready\\n" ); +#endif +\t} +#endif +''' + if client_anchor not in engine_text: + raise SystemExit('Render360 nested diagnostics: ClientDLL_Load anchor moved') + engine_text = engine_text.replace(client_anchor, client_replacement, 1) + + server_anchor = '''\tif ( !ServerDLL_Load( IsServerOnly() ) ) +\t\treturn false; +''' + server_replacement = '''#ifdef __EMSCRIPTEN__ +\tMsg( "[Render360 startup] mod-create-server-load-start\\n" ); +#endif +\tif ( !ServerDLL_Load( IsServerOnly() ) ) +\t{ +#ifdef __EMSCRIPTEN__ +\t\tWarning( "[Render360 startup] mod-create-fail:ServerDLL_Load\\n" ); +#endif +\t\treturn false; +\t} +#ifdef __EMSCRIPTEN__ +\tMsg( "[Render360 startup] mod-create-server-load-ready\\n" ); +#endif +''' + if server_anchor not in engine_text: + raise SystemExit('Render360 nested diagnostics: ServerDLL_Load anchor moved') + engine_text = engine_text.replace(server_anchor, server_replacement, 1) + + systems_anchor = '''\tif ( !AddSystems( systems.Base() ) ) +\t\treturn false; +''' + systems_replacement = '''\tif ( !AddSystems( systems.Base() ) ) +\t{ +#ifdef __EMSCRIPTEN__ +\t\tWarning( "[Render360 startup] mod-create-fail:AddSystems\\n" ); +#endif +\t\treturn false; +\t} +#ifdef __EMSCRIPTEN__ +\tMsg( "[Render360 startup] mod-create-appsystems-ready\\n" ); +#endif +''' + if systems_anchor not in engine_text: + raise SystemExit('Render360 nested diagnostics: mod AddSystems anchor moved') + engine_text = engine_text.replace(systems_anchor, systems_replacement, 1) + + tool_anchor = '''\t\tif ( !AddSystem( toolFrameworkModule, VTOOLFRAMEWORK_INTERFACE_VERSION ) ) +\t\t\treturn false; +''' + tool_replacement = '''\t\tif ( !AddSystem( toolFrameworkModule, VTOOLFRAMEWORK_INTERFACE_VERSION ) ) +\t\t{ +#ifdef __EMSCRIPTEN__ +\t\t\tWarning( "[Render360 startup] mod-create-fail:toolframework\\n" ); +#endif +\t\t\treturn false; +\t\t} +''' + if tool_anchor not in engine_text: + raise SystemExit('Render360 nested diagnostics: toolframework anchor moved') + engine_text = engine_text.replace(tool_anchor, tool_replacement, 1) + + create_ready_anchor = '''#endif + +\treturn true; +} + +//----------------------------------------------------------------------------- +// Purpose: Fixme, we might need to verify if the interface names differ for the client versus the server +''' + create_ready_replacement = '''#endif + +#ifdef __EMSCRIPTEN__ +\tMsg( "[Render360 startup] mod-create-ready\\n" ); +#endif +\treturn true; +} + +//----------------------------------------------------------------------------- +// Purpose: Fixme, we might need to verify if the interface names differ for the client versus the server +''' + if create_ready_anchor not in engine_text: + raise SystemExit('Render360 nested diagnostics: mod Create ready anchor moved') + engine_text = engine_text.replace(create_ready_anchor, create_ready_replacement, 1) + + run_anchor = '''\t\tnRunResult = modAppSystemGroup.Run(); + +\t\tg_AppSystemFactory = NULL; +''' + run_replacement = '''\t\tnRunResult = modAppSystemGroup.Run(); +#ifdef __EMSCRIPTEN__ +\t\tWarning( "[Render360 startup] mod-return:%d mod-stage:%d\\n", +\t\t\tnRunResult, +\t\t\t(int)modAppSystemGroup.GetErrorStage() ); +#endif + +\t\tg_AppSystemFactory = NULL; +''' + if run_anchor not in engine_text: + raise SystemExit('Render360 nested diagnostics: mod Run anchor moved') + engine_text = engine_text.replace(run_anchor, run_replacement, 1) + +for required in ( + marker, + '[Render360 startup] mod-create-client-load-start', + '[Render360 startup] mod-create-fail:ClientDLL_Load', + '[Render360 startup] mod-create-server-load-start', + '[Render360 startup] mod-create-fail:ServerDLL_Load', + '[Render360 startup] mod-create-fail:AddSystems', + '[Render360 startup] mod-create-ready', + '[Render360 startup] mod-return:%d mod-stage:%d', +): + if required not in engine_text: + raise SystemExit(f'Render360 nested diagnostics: engine marker missing: {required}') +engine.write_text(engine_text) +print('Render360 Portal: added nested Steam/Source/mod startup diagnostics') +PY + python3 waf configure -T $buildtype --notests -4 --togles --emscripten \ --disable-warns --build-games=portal --prefix=build/install python3 waf install $@ diff --git a/emscripten/phase3-mobile-runtime.js b/emscripten/phase3-mobile-runtime.js index 4d58f2fb4f..6a8175cb9a 100644 --- a/emscripten/phase3-mobile-runtime.js +++ b/emscripten/phase3-mobile-runtime.js @@ -25,10 +25,170 @@ const isWindow = typeof window !== 'undefined' && typeof document !== 'undefined' if(!isWindow) return - const STARTUP_KEY = 'render360-startup-checkpoint-v1' + const STARTUP_KEY = 'render360-startup-checkpoint-v2' + const STARTUP_DEBUG_KEY = 'render360-startup-debug-v1' + const STARTUP_TRACE_LIMIT = 24 + const APP_SYSTEM_STAGE_NAMES = [ + 'CREATION', + 'CONNECTION', + 'PREINITIALIZATION', + 'INITIALIZATION', + 'SHUTDOWN', + 'POSTSHUTDOWN', + 'DISCONNECTION', + 'DESTRUCTION', + 'NONE' + ] + let lastStartupCheckpoint = '' let viewportFullscreen = false + function navigationType() { + try { + return performance.getEntriesByType?.('navigation')?.[0]?.type || '' + } catch(_) { + return '' + } + } + + function newStartupDebugState() { + return { + version: 1, + startedAt: Date.now(), + updatedAt: Date.now(), + navigationType: navigationType(), + latest: '', + deepestFailure: null, + failureHint: null, + stages: { + steam: null, + source: null, + mod: null + }, + trace: [] + } + } + + let startupDebug = newStartupDebugState() + if(startupDebug.navigationType === 'reload') { + try { + const previous = JSON.parse(localStorage.getItem(STARTUP_DEBUG_KEY) || 'null') + if(previous && Array.isArray(previous.trace)) { + startupDebug = { + ...newStartupDebugState(), + ...previous, + navigationType: 'reload', + updatedAt: Date.now(), + trace: previous.trace.slice(-STARTUP_TRACE_LIMIT) + } + lastStartupCheckpoint = String(previous.latest || '') + } + } catch(_) {} + } else { + try { + localStorage.removeItem(STARTUP_KEY) + localStorage.removeItem(STARTUP_DEBUG_KEY) + } catch(_) {} + } + + function appSystemStageName(value) { + const stage = Number(value) + return Number.isInteger(stage) && stage >= 0 && stage < APP_SYSTEM_STAGE_NAMES.length + ? APP_SYSTEM_STAGE_NAMES[stage] + : `UNKNOWN_${String(value)}` + } + + function failurePriority(group) { + if(group === 'mod') return 3 + if(group === 'source') return 2 + if(group === 'steam') return 1 + return 0 + } + + function persistStartupDebug() { + startupDebug.updatedAt = Date.now() + try { + localStorage.setItem(STARTUP_KEY, JSON.stringify({ + at: startupDebug.updatedAt, + line: lastStartupCheckpoint + })) + localStorage.setItem(STARTUP_DEBUG_KEY, JSON.stringify(startupDebug)) + } catch(_) {} + } + + function recordStage(checkpoint, group, value) { + const stage = Number(value) + if(!Number.isInteger(stage)) return + + startupDebug.stages[group] = { + value: stage, + name: appSystemStageName(stage), + at: Date.now() + } + + // NONE (8) explicitly means this wrapper did not fail startup. Never allow + // an outer NONE to overwrite a real failure from a deeper app-system group. + if(stage === 8) return + + const returnMatch = checkpoint.match(new RegExp(`(?:${group}|engine)-return:(-?\\d+)`, 'i')) + const candidate = { + group, + stage, + stageName: appSystemStageName(stage), + returnCode: returnMatch ? Number(returnMatch[1]) : null, + checkpoint: checkpoint.slice(0, 512), + at: Date.now() + } + const current = startupDebug.deepestFailure + if(!current || failurePriority(group) >= failurePriority(current.group)) { + startupDebug.deepestFailure = candidate + } + } + + function recordStartupCheckpoint(checkpoint, fullLine) { + const clean = String(checkpoint || '').trim().slice(0, 512) + if(!clean) return + + const now = Date.now() + startupDebug.latest = String(fullLine || clean).slice(-512) + lastStartupCheckpoint = startupDebug.latest + + const lastTrace = startupDebug.trace[startupDebug.trace.length - 1] + if(lastTrace && lastTrace.checkpoint === clean) { + lastTrace.at = now + lastTrace.line = startupDebug.latest + } else { + startupDebug.trace.push({ at: now, checkpoint: clean, line: startupDebug.latest }) + if(startupDebug.trace.length > STARTUP_TRACE_LIMIT) { + startupDebug.trace.splice(0, startupDebug.trace.length - STARTUP_TRACE_LIMIT) + } + } + + for(const match of clean.matchAll(/\b(steam|source|mod)-stage:(-?\d+)/gi)) { + recordStage(clean, match[1].toLowerCase(), match[2]) + } + + if(/(?:^|[-:])fail(?:ure)?[:=-]/i.test(clean) || /(?:ClientDLL_Load|ServerDLL_Load).*fail/i.test(clean)) { + startupDebug.failureHint = { + checkpoint: clean, + at: now + } + } + + persistStartupDebug() + + try { + const deepest = startupDebug.deepestFailure + if(deepest) { + globalThis.render360SetPhase?.( + `startup-failure:${deepest.group}:${deepest.stageName}:${deepest.checkpoint.slice(0, 96)}` + ) + } else { + globalThis.render360SetPhase?.(`startup:${clean.slice(0, 160)}`) + } + } catch(_) {} + } + function rememberStartup(text) { const line = String(text || '').trim() if(!line) return @@ -37,15 +197,8 @@ /(?:filesystem|gameinfo\.txt|engine error|unable to|failed to mount|startup failed)/i.test(line) if(!meaningful) return - lastStartupCheckpoint = line.slice(-2048) - try { - localStorage.setItem(STARTUP_KEY, JSON.stringify({ at: Date.now(), line: lastStartupCheckpoint })) - } catch(_) {} - const match = line.match(/\[Render360 startup\]\s*(.+)$/i) - if(match) { - try { globalThis.render360SetPhase?.(`startup:${match[1].slice(0, 160)}`) } catch(_) {} - } + recordStartupCheckpoint(match ? match[1] : line, line) } const oldPrint = typeof Module.print === 'function' ? Module.print.bind(Module) : console.log.bind(console) @@ -59,23 +212,35 @@ oldPrintErr(...args) } - // Preserve one useful startup checkpoint in Copy diagnostics without growing - // a history/log. Emscripten's final keepRuntimeAlive message otherwise replaces - // the line that explains why Source returned to JS. + // Copy diagnostics keeps the latest general runtime event, but now also adds a + // bounded startup trace and the deepest non-NONE app-system failure. This is + // deliberately small enough for iPhone Safari/localStorage while preserving + // the evidence needed after an outer wrapper returns -1. const oldDiagnosticText = globalThis.render360DiagnosticText if(typeof oldDiagnosticText === 'function') { globalThis.render360DiagnosticText = () => { let checkpoint = lastStartupCheckpoint - if(!checkpoint) { + let debug = startupDebug + if(!checkpoint || !debug?.trace?.length) { try { - checkpoint = JSON.parse(localStorage.getItem(STARTUP_KEY) || 'null')?.line || '' + checkpoint = checkpoint || JSON.parse(localStorage.getItem(STARTUP_KEY) || 'null')?.line || '' + debug = JSON.parse(localStorage.getItem(STARTUP_DEBUG_KEY) || 'null') || debug } catch(_) {} } const base = oldDiagnosticText() - return checkpoint ? `${base}\nlastStartupCheckpoint=${checkpoint}` : base + const additions = [] + if(checkpoint) additions.push(`lastStartupCheckpoint=${checkpoint}`) + if(debug) additions.push(`startupDebug=${JSON.stringify(debug)}`) + return additions.length ? `${base}\n${additions.join('\n')}` : base } } + try { + if(typeof diagnosticStatusElement !== 'undefined' && diagnosticStatusElement) { + diagnosticStatusElement.textContent = 'Latest runtime event + bounded startup trace' + } + } catch(_) {} + function fullscreenButton() { try { const buttons = document.querySelectorAll('input[type="button"]') From 717a85ebe3260309ffd6c63265ce7190f6f547c0 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Sun, 13 Sep 2026 14:28:59 -0400 Subject: [PATCH 105/159] Add nested Source startup instrumentation patcher --- .../render360-startup-diagnostics-patch.py | 244 ++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 emscripten/render360-startup-diagnostics-patch.py diff --git a/emscripten/render360-startup-diagnostics-patch.py b/emscripten/render360-startup-diagnostics-patch.py new file mode 100644 index 0000000000..25f7575cc3 --- /dev/null +++ b/emscripten/render360-startup-diagnostics-patch.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +from pathlib import Path + + +def replace_once(text: str, old: str, new: str, label: str) -> str: + if old not in text: + raise SystemExit(f"Render360 startup diagnostics: anchor moved: {label}") + return text.replace(old, new, 1) + + +# launcher.cpp has already received the narrow Emscripten startup patch from +# get_emscripten.sh by the time build.sh runs. Upgrade the outer Steam-only +# checkpoint so it exposes the nested CSourceAppSystemGroup stage as well. +launcher_path = Path("launcher/launcher.cpp") +launcher = launcher_path.read_text() +launcher_marker = "Render360 nested startup diagnostics: Steam + Source stages" +if launcher_marker not in launcher: + old = '''#ifdef __EMSCRIPTEN__ +\t\tWarning( "[Render360 startup] steam-run-return:%d stage:%d\\n", nRetval, (int)steamApplication.GetErrorStage() ); +#endif +''' + new = '''#ifdef __EMSCRIPTEN__ +\t\t// Render360 nested startup diagnostics: Steam + Source stages. The outer +\t\t// CSteamApplication can report NONE while Main() simply returns a failure +\t\t// from the child CSourceAppSystemGroup, so always log both layers. +\t\tWarning( "[Render360 startup] steam-return:%d steam-stage:%d source-stage:%d\\n", +\t\t\tnRetval, +\t\t\t(int)steamApplication.GetErrorStage(), +\t\t\t(int)sourceSystems.GetErrorStage() ); +#endif +''' + launcher = replace_once(launcher, old, new, "launcher Steam return checkpoint") + +for required in ( + launcher_marker, + "steam-stage:%d source-stage:%d", + "sourceSystems.GetErrorStage()", +): + if required not in launcher: + raise SystemExit(f"Render360 startup diagnostics: launcher marker missing: {required}") +launcher_path.write_text(launcher) + + +# Instrument the next nested boundary. CEngineAPI::RunListenServer() starts with +# RUN_OK and then takes its result directly from CModAppSystemGroup::Run(). That +# group is therefore the first place to inspect when Source returns -1 while the +# outer Steam/Source wrappers themselves report NONE. +engine_path = Path("engine/sys_dll2.cpp") +engine = engine_path.read_text() +engine_marker = "Render360 nested startup diagnostics: mod app-system group" +if engine_marker not in engine: + create_anchor = '''bool CModAppSystemGroup::Create() +{ +#ifndef SWDS +\tif ( !IsServerOnly() ) +{ +\t\tif ( !ClientDLL_Load() ) +\treturn false; +} +#endif + +\tif ( !ServerDLL_Load( IsServerOnly() ) ) +\t\treturn false; +''' + create_replacement = '''bool CModAppSystemGroup::Create() +{ +#ifdef __EMSCRIPTEN__ +\t// Render360 nested startup diagnostics: mod app-system group. +\tMsg( "[Render360 startup] mod-create-start:serverOnly=%d\\n", IsServerOnly() ? 1 : 0 ); +#endif +#ifndef SWDS +\tif ( !IsServerOnly() ) +\t{ +#ifdef __EMSCRIPTEN__ +\t\tMsg( "[Render360 startup] mod-create-client-load-enter\\n" ); +#endif +\t\tif ( !ClientDLL_Load() ) +\t\t{ +#ifdef __EMSCRIPTEN__ +\t\t\tWarning( "[Render360 startup] mod-create-fail:ClientDLL_Load\\n" ); +#endif +\t\t\treturn false; +\t\t} +#ifdef __EMSCRIPTEN__ +\t\tMsg( "[Render360 startup] mod-create-client-load-ready\\n" ); +#endif +\t} +#endif + +#ifdef __EMSCRIPTEN__ +\tMsg( "[Render360 startup] mod-create-server-load-enter\\n" ); +#endif +\tif ( !ServerDLL_Load( IsServerOnly() ) ) +\t{ +#ifdef __EMSCRIPTEN__ +\t\tWarning( "[Render360 startup] mod-create-fail:ServerDLL_Load\\n" ); +#endif +\t\treturn false; +\t} +#ifdef __EMSCRIPTEN__ +\tMsg( "[Render360 startup] mod-create-server-load-ready\\n" ); +#endif +''' + engine = replace_once(engine, create_anchor, create_replacement, "CModAppSystemGroup::Create entry") + + client_shared_old = '''\t\tclientSharedSystems = ( IClientDLLSharedAppSystems * )g_ClientFactory( CLIENT_DLL_SHARED_APPSYSTEMS, NULL ); +\t\tif ( !clientSharedSystems ) +\t\t\treturn AddLegacySystems(); +''' + client_shared_new = '''\t\tclientSharedSystems = ( IClientDLLSharedAppSystems * )g_ClientFactory( CLIENT_DLL_SHARED_APPSYSTEMS, NULL ); +\t\tif ( !clientSharedSystems ) +\t\t{ +#ifdef __EMSCRIPTEN__ +\t\t\tMsg( "[Render360 startup] mod-create-client-shared-missing:legacy-fallback\\n" ); +#endif +\t\t\treturn AddLegacySystems(); +\t\t} +#ifdef __EMSCRIPTEN__ +\t\tMsg( "[Render360 startup] mod-create-client-shared-ready\\n" ); +#endif +''' + engine = replace_once(engine, client_shared_old, client_shared_new, "client shared app systems") + + server_shared_old = '''\t\tIServerDLLSharedAppSystems *serverSharedSystems = ( IServerDLLSharedAppSystems * )g_ServerFactory( SERVER_DLL_SHARED_APPSYSTEMS, NULL ); +\t\tif ( !serverSharedSystems ) +\t\t{ +\t\t\tAssert( !"Expected both game and client .dlls to have or not have shared app systems interfaces!!!" ); +\t\t\treturn AddLegacySystems(); +\t\t} +''' + server_shared_new = '''\t\tIServerDLLSharedAppSystems *serverSharedSystems = ( IServerDLLSharedAppSystems * )g_ServerFactory( SERVER_DLL_SHARED_APPSYSTEMS, NULL ); +\t\tif ( !serverSharedSystems ) +\t\t{ +#ifdef __EMSCRIPTEN__ +\t\t\tWarning( "[Render360 startup] mod-create-server-shared-missing:legacy-fallback\\n" ); +#endif +\t\t\tAssert( !"Expected both game and client .dlls to have or not have shared app systems interfaces!!!" ); +\t\t\treturn AddLegacySystems(); +\t\t} +#ifdef __EMSCRIPTEN__ +\t\tMsg( "[Render360 startup] mod-create-server-shared-ready\\n" ); +#endif +''' + engine = replace_once(engine, server_shared_old, server_shared_new, "server shared app systems") + + addsystems_old = '''\tif ( !AddSystems( systems.Base() ) ) +\t\treturn false; +''' + addsystems_new = '''\tif ( !AddSystems( systems.Base() ) ) +\t{ +#ifdef __EMSCRIPTEN__ +\t\tWarning( "[Render360 startup] mod-create-fail:AddSystems\\n" ); +#endif +\t\treturn false; +\t} +#ifdef __EMSCRIPTEN__ +\tMsg( "[Render360 startup] mod-create-appsystems-ready\\n" ); +#endif +''' + engine = replace_once(engine, addsystems_old, addsystems_new, "mod AddSystems") + + tool_old = '''\t\tif ( !AddSystem( toolFrameworkModule, VTOOLFRAMEWORK_INTERFACE_VERSION ) ) +\t\t\treturn false; +\t} +#endif + +\treturn true; +} +''' + tool_new = '''\t\tif ( !AddSystem( toolFrameworkModule, VTOOLFRAMEWORK_INTERFACE_VERSION ) ) +\t\t{ +#ifdef __EMSCRIPTEN__ +\t\t\tWarning( "[Render360 startup] mod-create-fail:toolframework\\n" ); +#endif +\t\t\treturn false; +\t\t} +\t} +#endif +#ifdef __EMSCRIPTEN__ +\tMsg( "[Render360 startup] mod-create-ready\\n" ); +#endif + +\treturn true; +} +''' + engine = replace_once(engine, tool_old, tool_new, "tool framework / Create ready") + + modinit_anchor = '''\t// Innocent until proven guilty +\tint nRunResult = RUN_OK; + +\t// Happens every time we start up and shut down a mod +\tif ( ModInit( m_StartupInfo.m_pInitialMod, m_StartupInfo.m_pInitialGame ) ) +\t{ +''' + modinit_replacement = '''\t// Innocent until proven guilty +\tint nRunResult = RUN_OK; + +#ifdef __EMSCRIPTEN__ +\tMsg( "[Render360 startup] mod-init-enter:mod=%s game=%s\\n", +\t\tm_StartupInfo.m_pInitialMod ? m_StartupInfo.m_pInitialMod : "", +\t\tm_StartupInfo.m_pInitialGame ? m_StartupInfo.m_pInitialGame : "" ); +#endif + +\t// Happens every time we start up and shut down a mod +\tif ( ModInit( m_StartupInfo.m_pInitialMod, m_StartupInfo.m_pInitialGame ) ) +\t{ +#ifdef __EMSCRIPTEN__ +\t\tMsg( "[Render360 startup] mod-init-ready\\n" ); +#endif +''' + engine = replace_once(engine, modinit_anchor, modinit_replacement, "RunListenServer ModInit") + + modrun_old = '''\t\tnRunResult = modAppSystemGroup.Run(); + +\t\tg_AppSystemFactory = NULL; +''' + modrun_new = '''#ifdef __EMSCRIPTEN__ +\t\tMsg( "[Render360 startup] mod-group-run-enter\\n" ); +#endif +\t\tnRunResult = modAppSystemGroup.Run(); +#ifdef __EMSCRIPTEN__ +\t\tWarning( "[Render360 startup] mod-return:%d mod-stage:%d\\n", +\t\t\tnRunResult, (int)modAppSystemGroup.GetErrorStage() ); +#endif + +\t\tg_AppSystemFactory = NULL; +''' + engine = replace_once(engine, modrun_old, modrun_new, "mod app-system Run return") + +for required in ( + engine_marker, + "mod-create-fail:ClientDLL_Load", + "mod-create-fail:ServerDLL_Load", + "mod-create-fail:AddSystems", + "mod-create-fail:toolframework", + "mod-group-run-enter", + "mod-return:%d mod-stage:%d", + "modAppSystemGroup.GetErrorStage()", +): + if required not in engine: + raise SystemExit(f"Render360 startup diagnostics: engine marker missing: {required}") +engine_path.write_text(engine) + +print("Render360: installed nested Source app-system startup diagnostics") From 1a20efac3f9726c8fe76f6e5e2e4339117e199da Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Sun, 13 Sep 2026 14:29:16 -0400 Subject: [PATCH 106/159] Add structured startup failure tracker --- emscripten/render360-startup-diagnostics.js | 198 ++++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 emscripten/render360-startup-diagnostics.js diff --git a/emscripten/render360-startup-diagnostics.js b/emscripten/render360-startup-diagnostics.js new file mode 100644 index 0000000000..afeb2acfc9 --- /dev/null +++ b/emscripten/render360-startup-diagnostics.js @@ -0,0 +1,198 @@ +// Render360 structured Source startup diagnostics. +// +// This intentionally keeps a small bounded trace rather than a general runtime +// log. Its job is to preserve the deepest failing AppSystemGroup and the last +// useful creation checkpoint even when later outer wrapper messages report NONE. +;(() => { + 'use strict' + + const isWindow = typeof window !== 'undefined' && typeof document !== 'undefined' + if(!isWindow) return + + const TRACE_KEY = 'render360-startup-trace-v2' + const STAGE_NAMES = [ + 'CREATION', + 'CONNECTION', + 'PREINITIALIZATION', + 'INITIALIZATION', + 'SHUTDOWN', + 'POSTSHUTDOWN', + 'DISCONNECTION', + 'DESTRUCTION', + 'NONE' + ] + const MAX_TRACE_LINES = 24 + const MAX_LINE_CHARS = 768 + const MAX_HINT_CHARS = 256 + + function freshState() { + return { + version: 2, + startedAt: Date.now(), + updatedAt: Date.now(), + deepestFailure: null, + lastReturn: null, + lastHint: null, + trace: [] + } + } + + let state = freshState() + try { + const nav = performance.getEntriesByType?.('navigation')?.[0] + const preserve = nav?.type === 'reload' + if(preserve) { + const previous = JSON.parse(localStorage.getItem(TRACE_KEY) || 'null') + if(previous && previous.version === 2 && Date.now() - Number(previous.updatedAt || 0) < 3 * 60 * 1000) { + state = previous + state.trace = Array.isArray(state.trace) ? state.trace.slice(-MAX_TRACE_LINES) : [] + } + } else { + localStorage.removeItem(TRACE_KEY) + } + } catch(_) {} + + function stageName(stage) { + const value = Number(stage) + return STAGE_NAMES[value] || `UNKNOWN_${value}` + } + + function persist() { + state.updatedAt = Date.now() + try { localStorage.setItem(TRACE_KEY, JSON.stringify(state)) } catch(_) {} + } + + function pushTrace(line) { + const compact = String(line || '').trim().slice(-MAX_LINE_CHARS) + if(!compact) return + state.trace.push({ at: Date.now(), line: compact }) + if(state.trace.length > MAX_TRACE_LINES) state.trace.splice(0, state.trace.length - MAX_TRACE_LINES) + } + + function rememberHint(line) { + const text = String(line || '') + const hint = text.match(/\[Render360 startup\]\s*(mod-create-(?:fail|client|server|appsystems)[^\n]*)/i) + || text.match(/\[Render360 startup\]\s*((?:create|preinit)-fail:[^\n]*)/i) + if(!hint) return + state.lastHint = String(hint[1] || '').slice(-MAX_HINT_CHARS) + } + + function considerFailure(group, depth, result, stage, sourceLine) { + const numericStage = Number(stage) + if(!Number.isInteger(numericStage) || numericStage < 0 || numericStage >= 8) return + + const candidate = { + group, + depth, + result: Number(result), + stage: numericStage, + stageName: stageName(numericStage), + hint: state.lastHint || null, + line: String(sourceLine || '').slice(-MAX_LINE_CHARS), + at: Date.now() + } + const current = state.deepestFailure + if(!current || Number(candidate.depth) >= Number(current.depth || 0)) { + state.deepestFailure = candidate + } + } + + function observeStartup(text) { + const line = String(text || '').trim() + if(!line.includes('[Render360 startup]')) return false + + pushTrace(line) + rememberHint(line) + + let match = line.match(/mod-return:(-?\d+)\s+mod-stage:(\d+)/i) + if(match) { + const result = Number(match[1]) + const stage = Number(match[2]) + state.lastReturn = { group: 'mod', result, stage, stageName: stageName(stage), at: Date.now() } + considerFailure('mod', 3, result, stage, line) + } + + match = line.match(/steam-return:(-?\d+)\s+steam-stage:(\d+)\s+source-stage:(\d+)/i) + if(match) { + const result = Number(match[1]) + const steamStage = Number(match[2]) + const sourceStage = Number(match[3]) + state.lastReturn = { + group: 'steam/source', + result, + steamStage, + steamStageName: stageName(steamStage), + sourceStage, + sourceStageName: stageName(sourceStage), + at: Date.now() + } + considerFailure('source', 2, result, sourceStage, line) + considerFailure('steam', 1, result, steamStage, line) + } + + persist() + return true + } + + function failurePhase() { + const failure = state.deepestFailure + if(!failure) return '' + const hint = failure.hint ? `:${failure.hint.replace(/[^a-z0-9_.:-]+/gi, '-').slice(0, 80)}` : '' + return `startup-failure:${failure.group}:${failure.stageName.toLowerCase()}:return:${failure.result}${hint}` + } + + function restoreDeepestFailurePhase() { + const phase = failurePhase() + if(!phase) return + try { globalThis.render360SetPhase?.(phase) } catch(_) {} + } + + const oldPrint = typeof Module.print === 'function' ? Module.print.bind(Module) : console.log.bind(console) + const oldPrintErr = typeof Module.printErr === 'function' ? Module.printErr.bind(Module) : console.error.bind(console) + + Module.print = (...args) => { + const line = args.join(' ') + const startup = observeStartup(line) + oldPrint(...args) + if(startup && state.deepestFailure) restoreDeepestFailurePhase() + } + + Module.printErr = (...args) => { + const line = args.join(' ') + const startup = observeStartup(line) + oldPrintErr(...args) + if(startup && state.deepestFailure) restoreDeepestFailurePhase() + } + + const oldDiagnosticText = globalThis.render360DiagnosticText + if(typeof oldDiagnosticText === 'function') { + globalThis.render360DiagnosticText = () => { + const base = oldDiagnosticText() + const lines = [ + base, + '', + '--- structured startup diagnostics ---', + `deepestStartupFailure=${JSON.stringify(state.deepestFailure)}`, + `lastStartupReturn=${JSON.stringify(state.lastReturn)}`, + `startupLastHint=${JSON.stringify(state.lastHint)}`, + 'startupTrace:' + ] + for(const entry of state.trace.slice(-MAX_TRACE_LINES)) { + lines.push(`${new Date(Number(entry.at || 0)).toISOString()} ${entry.line}`) + } + return lines.join('\n') + } + } + + if(typeof diagnosticStatusElement !== 'undefined' && diagnosticStatusElement) { + diagnosticStatusElement.textContent = 'Latest runtime event + bounded startup trace' + } + + globalThis.render360StartupDiagnostics = { + getState: () => JSON.parse(JSON.stringify(state)), + getDeepestFailure: () => state.deepestFailure ? { ...state.deepestFailure } : null, + stageName + } + + persist() +})() From 4c7c88a13aed177495783c279990e7585de01c2e Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Sun, 13 Sep 2026 14:29:57 -0400 Subject: [PATCH 107/159] Remove duplicate startup diagnostics patcher --- .../render360-startup-diagnostics-patch.py | 244 ------------------ 1 file changed, 244 deletions(-) delete mode 100644 emscripten/render360-startup-diagnostics-patch.py diff --git a/emscripten/render360-startup-diagnostics-patch.py b/emscripten/render360-startup-diagnostics-patch.py deleted file mode 100644 index 25f7575cc3..0000000000 --- a/emscripten/render360-startup-diagnostics-patch.py +++ /dev/null @@ -1,244 +0,0 @@ -#!/usr/bin/env python3 -from pathlib import Path - - -def replace_once(text: str, old: str, new: str, label: str) -> str: - if old not in text: - raise SystemExit(f"Render360 startup diagnostics: anchor moved: {label}") - return text.replace(old, new, 1) - - -# launcher.cpp has already received the narrow Emscripten startup patch from -# get_emscripten.sh by the time build.sh runs. Upgrade the outer Steam-only -# checkpoint so it exposes the nested CSourceAppSystemGroup stage as well. -launcher_path = Path("launcher/launcher.cpp") -launcher = launcher_path.read_text() -launcher_marker = "Render360 nested startup diagnostics: Steam + Source stages" -if launcher_marker not in launcher: - old = '''#ifdef __EMSCRIPTEN__ -\t\tWarning( "[Render360 startup] steam-run-return:%d stage:%d\\n", nRetval, (int)steamApplication.GetErrorStage() ); -#endif -''' - new = '''#ifdef __EMSCRIPTEN__ -\t\t// Render360 nested startup diagnostics: Steam + Source stages. The outer -\t\t// CSteamApplication can report NONE while Main() simply returns a failure -\t\t// from the child CSourceAppSystemGroup, so always log both layers. -\t\tWarning( "[Render360 startup] steam-return:%d steam-stage:%d source-stage:%d\\n", -\t\t\tnRetval, -\t\t\t(int)steamApplication.GetErrorStage(), -\t\t\t(int)sourceSystems.GetErrorStage() ); -#endif -''' - launcher = replace_once(launcher, old, new, "launcher Steam return checkpoint") - -for required in ( - launcher_marker, - "steam-stage:%d source-stage:%d", - "sourceSystems.GetErrorStage()", -): - if required not in launcher: - raise SystemExit(f"Render360 startup diagnostics: launcher marker missing: {required}") -launcher_path.write_text(launcher) - - -# Instrument the next nested boundary. CEngineAPI::RunListenServer() starts with -# RUN_OK and then takes its result directly from CModAppSystemGroup::Run(). That -# group is therefore the first place to inspect when Source returns -1 while the -# outer Steam/Source wrappers themselves report NONE. -engine_path = Path("engine/sys_dll2.cpp") -engine = engine_path.read_text() -engine_marker = "Render360 nested startup diagnostics: mod app-system group" -if engine_marker not in engine: - create_anchor = '''bool CModAppSystemGroup::Create() -{ -#ifndef SWDS -\tif ( !IsServerOnly() ) -{ -\t\tif ( !ClientDLL_Load() ) -\treturn false; -} -#endif - -\tif ( !ServerDLL_Load( IsServerOnly() ) ) -\t\treturn false; -''' - create_replacement = '''bool CModAppSystemGroup::Create() -{ -#ifdef __EMSCRIPTEN__ -\t// Render360 nested startup diagnostics: mod app-system group. -\tMsg( "[Render360 startup] mod-create-start:serverOnly=%d\\n", IsServerOnly() ? 1 : 0 ); -#endif -#ifndef SWDS -\tif ( !IsServerOnly() ) -\t{ -#ifdef __EMSCRIPTEN__ -\t\tMsg( "[Render360 startup] mod-create-client-load-enter\\n" ); -#endif -\t\tif ( !ClientDLL_Load() ) -\t\t{ -#ifdef __EMSCRIPTEN__ -\t\t\tWarning( "[Render360 startup] mod-create-fail:ClientDLL_Load\\n" ); -#endif -\t\t\treturn false; -\t\t} -#ifdef __EMSCRIPTEN__ -\t\tMsg( "[Render360 startup] mod-create-client-load-ready\\n" ); -#endif -\t} -#endif - -#ifdef __EMSCRIPTEN__ -\tMsg( "[Render360 startup] mod-create-server-load-enter\\n" ); -#endif -\tif ( !ServerDLL_Load( IsServerOnly() ) ) -\t{ -#ifdef __EMSCRIPTEN__ -\t\tWarning( "[Render360 startup] mod-create-fail:ServerDLL_Load\\n" ); -#endif -\t\treturn false; -\t} -#ifdef __EMSCRIPTEN__ -\tMsg( "[Render360 startup] mod-create-server-load-ready\\n" ); -#endif -''' - engine = replace_once(engine, create_anchor, create_replacement, "CModAppSystemGroup::Create entry") - - client_shared_old = '''\t\tclientSharedSystems = ( IClientDLLSharedAppSystems * )g_ClientFactory( CLIENT_DLL_SHARED_APPSYSTEMS, NULL ); -\t\tif ( !clientSharedSystems ) -\t\t\treturn AddLegacySystems(); -''' - client_shared_new = '''\t\tclientSharedSystems = ( IClientDLLSharedAppSystems * )g_ClientFactory( CLIENT_DLL_SHARED_APPSYSTEMS, NULL ); -\t\tif ( !clientSharedSystems ) -\t\t{ -#ifdef __EMSCRIPTEN__ -\t\t\tMsg( "[Render360 startup] mod-create-client-shared-missing:legacy-fallback\\n" ); -#endif -\t\t\treturn AddLegacySystems(); -\t\t} -#ifdef __EMSCRIPTEN__ -\t\tMsg( "[Render360 startup] mod-create-client-shared-ready\\n" ); -#endif -''' - engine = replace_once(engine, client_shared_old, client_shared_new, "client shared app systems") - - server_shared_old = '''\t\tIServerDLLSharedAppSystems *serverSharedSystems = ( IServerDLLSharedAppSystems * )g_ServerFactory( SERVER_DLL_SHARED_APPSYSTEMS, NULL ); -\t\tif ( !serverSharedSystems ) -\t\t{ -\t\t\tAssert( !"Expected both game and client .dlls to have or not have shared app systems interfaces!!!" ); -\t\t\treturn AddLegacySystems(); -\t\t} -''' - server_shared_new = '''\t\tIServerDLLSharedAppSystems *serverSharedSystems = ( IServerDLLSharedAppSystems * )g_ServerFactory( SERVER_DLL_SHARED_APPSYSTEMS, NULL ); -\t\tif ( !serverSharedSystems ) -\t\t{ -#ifdef __EMSCRIPTEN__ -\t\t\tWarning( "[Render360 startup] mod-create-server-shared-missing:legacy-fallback\\n" ); -#endif -\t\t\tAssert( !"Expected both game and client .dlls to have or not have shared app systems interfaces!!!" ); -\t\t\treturn AddLegacySystems(); -\t\t} -#ifdef __EMSCRIPTEN__ -\t\tMsg( "[Render360 startup] mod-create-server-shared-ready\\n" ); -#endif -''' - engine = replace_once(engine, server_shared_old, server_shared_new, "server shared app systems") - - addsystems_old = '''\tif ( !AddSystems( systems.Base() ) ) -\t\treturn false; -''' - addsystems_new = '''\tif ( !AddSystems( systems.Base() ) ) -\t{ -#ifdef __EMSCRIPTEN__ -\t\tWarning( "[Render360 startup] mod-create-fail:AddSystems\\n" ); -#endif -\t\treturn false; -\t} -#ifdef __EMSCRIPTEN__ -\tMsg( "[Render360 startup] mod-create-appsystems-ready\\n" ); -#endif -''' - engine = replace_once(engine, addsystems_old, addsystems_new, "mod AddSystems") - - tool_old = '''\t\tif ( !AddSystem( toolFrameworkModule, VTOOLFRAMEWORK_INTERFACE_VERSION ) ) -\t\t\treturn false; -\t} -#endif - -\treturn true; -} -''' - tool_new = '''\t\tif ( !AddSystem( toolFrameworkModule, VTOOLFRAMEWORK_INTERFACE_VERSION ) ) -\t\t{ -#ifdef __EMSCRIPTEN__ -\t\t\tWarning( "[Render360 startup] mod-create-fail:toolframework\\n" ); -#endif -\t\t\treturn false; -\t\t} -\t} -#endif -#ifdef __EMSCRIPTEN__ -\tMsg( "[Render360 startup] mod-create-ready\\n" ); -#endif - -\treturn true; -} -''' - engine = replace_once(engine, tool_old, tool_new, "tool framework / Create ready") - - modinit_anchor = '''\t// Innocent until proven guilty -\tint nRunResult = RUN_OK; - -\t// Happens every time we start up and shut down a mod -\tif ( ModInit( m_StartupInfo.m_pInitialMod, m_StartupInfo.m_pInitialGame ) ) -\t{ -''' - modinit_replacement = '''\t// Innocent until proven guilty -\tint nRunResult = RUN_OK; - -#ifdef __EMSCRIPTEN__ -\tMsg( "[Render360 startup] mod-init-enter:mod=%s game=%s\\n", -\t\tm_StartupInfo.m_pInitialMod ? m_StartupInfo.m_pInitialMod : "", -\t\tm_StartupInfo.m_pInitialGame ? m_StartupInfo.m_pInitialGame : "" ); -#endif - -\t// Happens every time we start up and shut down a mod -\tif ( ModInit( m_StartupInfo.m_pInitialMod, m_StartupInfo.m_pInitialGame ) ) -\t{ -#ifdef __EMSCRIPTEN__ -\t\tMsg( "[Render360 startup] mod-init-ready\\n" ); -#endif -''' - engine = replace_once(engine, modinit_anchor, modinit_replacement, "RunListenServer ModInit") - - modrun_old = '''\t\tnRunResult = modAppSystemGroup.Run(); - -\t\tg_AppSystemFactory = NULL; -''' - modrun_new = '''#ifdef __EMSCRIPTEN__ -\t\tMsg( "[Render360 startup] mod-group-run-enter\\n" ); -#endif -\t\tnRunResult = modAppSystemGroup.Run(); -#ifdef __EMSCRIPTEN__ -\t\tWarning( "[Render360 startup] mod-return:%d mod-stage:%d\\n", -\t\t\tnRunResult, (int)modAppSystemGroup.GetErrorStage() ); -#endif - -\t\tg_AppSystemFactory = NULL; -''' - engine = replace_once(engine, modrun_old, modrun_new, "mod app-system Run return") - -for required in ( - engine_marker, - "mod-create-fail:ClientDLL_Load", - "mod-create-fail:ServerDLL_Load", - "mod-create-fail:AddSystems", - "mod-create-fail:toolframework", - "mod-group-run-enter", - "mod-return:%d mod-stage:%d", - "modAppSystemGroup.GetErrorStage()", -): - if required not in engine: - raise SystemExit(f"Render360 startup diagnostics: engine marker missing: {required}") -engine_path.write_text(engine) - -print("Render360: installed nested Source app-system startup diagnostics") From 933a84347964506939cac3d40a4922db8a1ee4f7 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Sun, 13 Sep 2026 14:30:00 -0400 Subject: [PATCH 108/159] Remove duplicate startup diagnostics tracker --- emscripten/render360-startup-diagnostics.js | 198 -------------------- 1 file changed, 198 deletions(-) delete mode 100644 emscripten/render360-startup-diagnostics.js diff --git a/emscripten/render360-startup-diagnostics.js b/emscripten/render360-startup-diagnostics.js deleted file mode 100644 index afeb2acfc9..0000000000 --- a/emscripten/render360-startup-diagnostics.js +++ /dev/null @@ -1,198 +0,0 @@ -// Render360 structured Source startup diagnostics. -// -// This intentionally keeps a small bounded trace rather than a general runtime -// log. Its job is to preserve the deepest failing AppSystemGroup and the last -// useful creation checkpoint even when later outer wrapper messages report NONE. -;(() => { - 'use strict' - - const isWindow = typeof window !== 'undefined' && typeof document !== 'undefined' - if(!isWindow) return - - const TRACE_KEY = 'render360-startup-trace-v2' - const STAGE_NAMES = [ - 'CREATION', - 'CONNECTION', - 'PREINITIALIZATION', - 'INITIALIZATION', - 'SHUTDOWN', - 'POSTSHUTDOWN', - 'DISCONNECTION', - 'DESTRUCTION', - 'NONE' - ] - const MAX_TRACE_LINES = 24 - const MAX_LINE_CHARS = 768 - const MAX_HINT_CHARS = 256 - - function freshState() { - return { - version: 2, - startedAt: Date.now(), - updatedAt: Date.now(), - deepestFailure: null, - lastReturn: null, - lastHint: null, - trace: [] - } - } - - let state = freshState() - try { - const nav = performance.getEntriesByType?.('navigation')?.[0] - const preserve = nav?.type === 'reload' - if(preserve) { - const previous = JSON.parse(localStorage.getItem(TRACE_KEY) || 'null') - if(previous && previous.version === 2 && Date.now() - Number(previous.updatedAt || 0) < 3 * 60 * 1000) { - state = previous - state.trace = Array.isArray(state.trace) ? state.trace.slice(-MAX_TRACE_LINES) : [] - } - } else { - localStorage.removeItem(TRACE_KEY) - } - } catch(_) {} - - function stageName(stage) { - const value = Number(stage) - return STAGE_NAMES[value] || `UNKNOWN_${value}` - } - - function persist() { - state.updatedAt = Date.now() - try { localStorage.setItem(TRACE_KEY, JSON.stringify(state)) } catch(_) {} - } - - function pushTrace(line) { - const compact = String(line || '').trim().slice(-MAX_LINE_CHARS) - if(!compact) return - state.trace.push({ at: Date.now(), line: compact }) - if(state.trace.length > MAX_TRACE_LINES) state.trace.splice(0, state.trace.length - MAX_TRACE_LINES) - } - - function rememberHint(line) { - const text = String(line || '') - const hint = text.match(/\[Render360 startup\]\s*(mod-create-(?:fail|client|server|appsystems)[^\n]*)/i) - || text.match(/\[Render360 startup\]\s*((?:create|preinit)-fail:[^\n]*)/i) - if(!hint) return - state.lastHint = String(hint[1] || '').slice(-MAX_HINT_CHARS) - } - - function considerFailure(group, depth, result, stage, sourceLine) { - const numericStage = Number(stage) - if(!Number.isInteger(numericStage) || numericStage < 0 || numericStage >= 8) return - - const candidate = { - group, - depth, - result: Number(result), - stage: numericStage, - stageName: stageName(numericStage), - hint: state.lastHint || null, - line: String(sourceLine || '').slice(-MAX_LINE_CHARS), - at: Date.now() - } - const current = state.deepestFailure - if(!current || Number(candidate.depth) >= Number(current.depth || 0)) { - state.deepestFailure = candidate - } - } - - function observeStartup(text) { - const line = String(text || '').trim() - if(!line.includes('[Render360 startup]')) return false - - pushTrace(line) - rememberHint(line) - - let match = line.match(/mod-return:(-?\d+)\s+mod-stage:(\d+)/i) - if(match) { - const result = Number(match[1]) - const stage = Number(match[2]) - state.lastReturn = { group: 'mod', result, stage, stageName: stageName(stage), at: Date.now() } - considerFailure('mod', 3, result, stage, line) - } - - match = line.match(/steam-return:(-?\d+)\s+steam-stage:(\d+)\s+source-stage:(\d+)/i) - if(match) { - const result = Number(match[1]) - const steamStage = Number(match[2]) - const sourceStage = Number(match[3]) - state.lastReturn = { - group: 'steam/source', - result, - steamStage, - steamStageName: stageName(steamStage), - sourceStage, - sourceStageName: stageName(sourceStage), - at: Date.now() - } - considerFailure('source', 2, result, sourceStage, line) - considerFailure('steam', 1, result, steamStage, line) - } - - persist() - return true - } - - function failurePhase() { - const failure = state.deepestFailure - if(!failure) return '' - const hint = failure.hint ? `:${failure.hint.replace(/[^a-z0-9_.:-]+/gi, '-').slice(0, 80)}` : '' - return `startup-failure:${failure.group}:${failure.stageName.toLowerCase()}:return:${failure.result}${hint}` - } - - function restoreDeepestFailurePhase() { - const phase = failurePhase() - if(!phase) return - try { globalThis.render360SetPhase?.(phase) } catch(_) {} - } - - const oldPrint = typeof Module.print === 'function' ? Module.print.bind(Module) : console.log.bind(console) - const oldPrintErr = typeof Module.printErr === 'function' ? Module.printErr.bind(Module) : console.error.bind(console) - - Module.print = (...args) => { - const line = args.join(' ') - const startup = observeStartup(line) - oldPrint(...args) - if(startup && state.deepestFailure) restoreDeepestFailurePhase() - } - - Module.printErr = (...args) => { - const line = args.join(' ') - const startup = observeStartup(line) - oldPrintErr(...args) - if(startup && state.deepestFailure) restoreDeepestFailurePhase() - } - - const oldDiagnosticText = globalThis.render360DiagnosticText - if(typeof oldDiagnosticText === 'function') { - globalThis.render360DiagnosticText = () => { - const base = oldDiagnosticText() - const lines = [ - base, - '', - '--- structured startup diagnostics ---', - `deepestStartupFailure=${JSON.stringify(state.deepestFailure)}`, - `lastStartupReturn=${JSON.stringify(state.lastReturn)}`, - `startupLastHint=${JSON.stringify(state.lastHint)}`, - 'startupTrace:' - ] - for(const entry of state.trace.slice(-MAX_TRACE_LINES)) { - lines.push(`${new Date(Number(entry.at || 0)).toISOString()} ${entry.line}`) - } - return lines.join('\n') - } - } - - if(typeof diagnosticStatusElement !== 'undefined' && diagnosticStatusElement) { - diagnosticStatusElement.textContent = 'Latest runtime event + bounded startup trace' - } - - globalThis.render360StartupDiagnostics = { - getState: () => JSON.parse(JSON.stringify(state)), - getDeepestFailure: () => state.deepestFailure ? { ...state.deepestFailure } : null, - stageName - } - - persist() -})() From 29f41450fa7f14217e4fd4bd9975269735e028c4 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Sun, 13 Sep 2026 15:35:00 -0400 Subject: [PATCH 109/159] Fix WebAssembly basedir argument parsing --- emscripten/get_emscripten.sh | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/emscripten/get_emscripten.sh b/emscripten/get_emscripten.sh index 29a5898b17..6a117326ac 100755 --- a/emscripten/get_emscripten.sh +++ b/emscripten/get_emscripten.sh @@ -119,10 +119,14 @@ if marker not in text: ''' base_replacement = '''\tif ( IsPC() ) \t{ -\t\tchar const *pOverrideDir = CommandLine()->CheckParm( "-basedir" ); -\t\tif ( pOverrideDir ) +\t\t// CheckParm() returns the parameter token itself ("-basedir"). The +\t\t// actual value is returned through its optional out-parameter. Treating +\t\t// the return value as the directory made Render360 chdir to "-basedir" +\t\t// and caused PreInit to miss /portal/gameinfo.txt. +\t\tconst char *pOverrideDir = NULL; +\t\tif ( CommandLine()->CheckParm( "-basedir", &pOverrideDir ) && pOverrideDir && pOverrideDir[0] ) \t\t{ -\t\t\tstrcpy( g_szBasedir, pOverrideDir ); +\t\t\tQ_strncpy( g_szBasedir, pOverrideDir, sizeof( g_szBasedir ) ); \t\t} \t} @@ -135,6 +139,10 @@ if marker not in text: \t\tQ_strncpy( g_szBasedir, "/", sizeof( g_szBasedir ) ); \t\tMsg( "[Render360 startup] basedir-fallback:/\\n" ); \t} +\telse +\t{ +\t\tMsg( "[Render360 startup] basedir-override:%s\\n", g_szBasedir ); +\t} #endif #ifdef WIN32 @@ -305,6 +313,8 @@ int CSourceAppSystemGroup::Main() for required in ( marker, + 'CommandLine()->CheckParm( "-basedir", &pOverrideDir )', + '[Render360 startup] basedir-override:', '[Render360 startup] create-start', '[Render360 startup] preinit-start', '[Render360 startup] gameinfo-ready:', From cfdc4fc2f96da4b731b24ee3512162c6d9ff8219 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Sun, 13 Sep 2026 16:42:27 -0400 Subject: [PATCH 110/159] Stage Phase 3 startup metadata in shared MEMFS --- emscripten/post.js | 123 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/emscripten/post.js b/emscripten/post.js index df75edeaf8..b4bffb9cc3 100644 --- a/emscripten/post.js +++ b/emscripten/post.js @@ -29,6 +29,129 @@ }) })(); +// Phase 3 keeps retail VPK payloads browser-backed in WORKERFS, but Source's +// startup filesystem calls can be proxied through Emscripten's main-thread JS +// filesystem. A WORKERFS mount local to pool workers therefore is not enough for +// PREINITIALIZATION: filesystem_stdio must be able to open /portal/gameinfo.txt +// before the engine has entered its normal VPK read path. +// +// Copy ONLY tiny bootstrap metadata into the shared/main-thread MEMFS. VPKs, +// maps, textures, audio and every large retail payload remain File-backed, so +// this fixes startup visibility without reintroducing the old ~221 MiB preload. +;(() => { + 'use strict' + + if(typeof window === 'undefined' || typeof document === 'undefined') return + let frame = null + try { frame = window.frameElement } catch(_) {} + if(!frame) return + try { + if(!new URLSearchParams(location.search).has('render360Phase3')) return + } catch(_) { return } + + const FILES_TYPE = 'render360-retail-files' + const REQUEST_TYPE = 'render360-retail-request' + const DEPENDENCY = 'render360-phase3-startup-metadata' + const TIMEOUT_MS = 20000 + const token = `phase3-startup-${Date.now()}-${Math.random().toString(16).slice(2)}` + let held = false + let finished = false + let timer = 0 + + function normalize(value) { + return String(value || '').replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+/g, '/') + } + + function isStartupMetadata(path) { + return /^(?:portal|hl2|platform)\/(?:gameinfo\.txt|steam\.inf|game\.inf)$/i.test(path) + } + + function release() { + if(!held) return + held = false + removeRunDependency(DEPENDENCY) + } + + function fail(error) { + clearTimeout(timer) + const detail = error && error.message ? error.message : String(error) + const message = `[Render360 Phase 3] startup metadata staging failed: ${detail}` + try { globalThis.render360SetPhase?.('phase3-startup-metadata-failed') } catch(_) {} + Module.printErr?.(message) + if(typeof render360Report === 'function') { + try { render360Report('Phase 3 startup metadata failure', detail, error) } catch(_) {} + } + // Fail closed. Releasing the dependency here would let Source race into the + // same misleading PREINITIALIZATION/gameinfo failure we are preventing. + if(typeof abort === 'function') { + abort(message) + return + } + throw error instanceof Error ? error : new Error(message) + } + + async function stage(files) { + let count = 0 + let bytes = 0 + let hasPortalGameInfo = false + for(const item of Array.isArray(files) ? files : []) { + const path = normalize(item && item.path) + const file = item && item.file + if(!isStartupMetadata(path) || !file || typeof file.arrayBuffer !== 'function') continue + + const fullPath = '/' + path + const slash = fullPath.lastIndexOf('/') + if(slash > 0) FS.mkdirTree(fullPath.slice(0, slash)) + const data = new Uint8Array(await file.arrayBuffer()) + FS.writeFile(fullPath, data) + bytes += data.byteLength + count++ + if(path.toLowerCase() === 'portal/gameinfo.txt') hasPortalGameInfo = true + } + + if(!hasPortalGameInfo) { + throw new Error('portal/gameinfo.txt was not provided by the selected Portal folder') + } + const stat = FS.stat('/portal/gameinfo.txt') + if(!stat || Number(stat.size || 0) <= 0) { + throw new Error('/portal/gameinfo.txt is empty or not visible in shared MEMFS') + } + + Module.render360Phase3StartupMemfsBytes = bytes + Module.render360Phase3StartupMemfsFiles = count + Module.print?.(`[Render360 Phase 3] staged ${count} startup metadata files (${bytes} bytes) into shared MEMFS; VPK payload remains browser-backed`) + try { globalThis.render360SetPhase?.('phase3-startup-metadata-ready') } catch(_) {} + } + + window.addEventListener('message', event => { + if(finished || event.origin !== location.origin || event.source !== window.parent) return + const data = event && event.data + if(!data || data.type !== FILES_TYPE || data.token !== token) return + finished = true + clearTimeout(timer) + stage(data.files).then(release, fail) + }) + + Module.preRun = Module.preRun || [] + Module.preRun.push(() => { + if(held || finished) return + addRunDependency(DEPENDENDENCY) + held = true + try { + window.parent.postMessage({ type: REQUEST_TYPE, token }, location.origin) + } catch(error) { + finished = true + fail(error) + return + } + timer = setTimeout(() => { + if(finished) return + finished = true + fail(new Error('timed out waiting for startup metadata File handles')) + }, TIMEOUT_MS) + }) +})(); + // Diagnostic-only addition for PROXY_TO_PTHREAD / worker-side failures. // Keep this WorkerGlobalScope-safe: hl2_launcher.js is imported by pthreads and // there is deliberately no `window` object in those workers. From 556b354f49c8b0fb3d8c82884d5495167b4c6185 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Sun, 13 Sep 2026 16:42:57 -0400 Subject: [PATCH 111/159] Fix Phase 3 startup metadata run dependency --- emscripten/post.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/emscripten/post.js b/emscripten/post.js index b4bffb9cc3..3452362620 100644 --- a/emscripten/post.js +++ b/emscripten/post.js @@ -135,7 +135,7 @@ Module.preRun = Module.preRun || [] Module.preRun.push(() => { if(held || finished) return - addRunDependency(DEPENDENDENCY) + addRunDependency(DEPENDENCY) held = true try { window.parent.postMessage({ type: REQUEST_TYPE, token }, location.origin) From 01af043a5f31496ba4ca36a66ddeff79a8459f60 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Sun, 13 Sep 2026 16:48:15 -0400 Subject: [PATCH 112/159] Add zero-copy browser File bridge for Portal VPK reads --- launcher_main/render360_browser_files.cpp | 122 ++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 launcher_main/render360_browser_files.cpp diff --git a/launcher_main/render360_browser_files.cpp b/launcher_main/render360_browser_files.cpp new file mode 100644 index 0000000000..55f631477c --- /dev/null +++ b/launcher_main/render360_browser_files.cpp @@ -0,0 +1,122 @@ +// Render360 browser-backed retail file bridge. +// +// This code is linked into the MAIN_MODULE and exported for filesystem_stdio.so. +// Source runs under PROXY_TO_PTHREAD, so these EM_JS calls execute on the calling +// Source pthread. Phase 3 transfers the user's File objects to that pthread; +// FileReaderSync can therefore service synchronous Source/VPK reads without +// copying the retail archives into MEMFS or the Wasm heap permanently. + +#ifdef __EMSCRIPTEN__ + +#include +#include + +EM_JS(int, render360_browser_file_open_js, (const char *pathPtr), { + try { + if (typeof FileReaderSync === 'undefined') return -1; + var files = globalThis.__render360RetailFileMap; + if (!files || typeof files.get !== 'function') return -1; + var path = UTF8ToString(pathPtr || 0) + .replace(/\\/g, '/') + .replace(/\/+/g, '/') + .replace(/^\/+/, '') + .replace(/(^|\/)\.\//g, '$1') + .toLowerCase(); + var file = files.get(path); + if (!file) return -1; + var handles = globalThis.__render360RetailHandles; + if (!handles) handles = globalThis.__render360RetailHandles = new Map(); + var next = (globalThis.__render360RetailNextHandle | 0) || 1; + while (handles.has(next)) { + next = (next + 1) | 0; + if (next <= 0) next = 1; + } + handles.set(next, file); + globalThis.__render360RetailNextHandle = (next + 1) | 0; + return next; + } catch (e) { + try { console.error('[Render360 direct file] open failed', e); } catch (_) {} + return -1; + } +}); + +EM_JS(double, render360_browser_file_size_js, (int handle), { + try { + var handles = globalThis.__render360RetailHandles; + var file = handles && handles.get(handle | 0); + return file ? Number(file.size || 0) : -1; + } catch (_) { + return -1; + } +}); + +EM_JS(int, render360_browser_file_read_js, + (int handle, double offset, void *dest, int length), { + try { + var handles = globalThis.__render360RetailHandles; + var file = handles && handles.get(handle | 0); + if (!file || typeof FileReaderSync === 'undefined') return -1; + var start = Math.max(0, Math.floor(Number(offset) || 0)); + var requested = Math.max(0, length | 0); + if (!requested || start >= file.size) return 0; + var end = Math.min(file.size, start + requested); + var buffer = new FileReaderSync().readAsArrayBuffer(file.slice(start, end)); + var bytes = new Uint8Array(buffer); + HEAPU8.set(bytes, dest >>> 0); + return bytes.byteLength | 0; + } catch (e) { + try { console.error('[Render360 direct file] read failed', e); } catch (_) {} + return -1; + } +}); + +EM_JS(void, render360_browser_file_close_js, (int handle), { + try { + var handles = globalThis.__render360RetailHandles; + if (handles) handles.delete(handle | 0); + } catch (_) {} +}); + +EM_JS(double, render360_browser_file_stat_js, (const char *pathPtr), { + try { + var files = globalThis.__render360RetailFileMap; + if (!files || typeof files.get !== 'function') return -1; + var path = UTF8ToString(pathPtr || 0) + .replace(/\\/g, '/') + .replace(/\/+/g, '/') + .replace(/^\/+/, '') + .replace(/(^|\/)\.\//g, '$1') + .toLowerCase(); + var file = files.get(path); + return file ? Number(file.size || 0) : -1; + } catch (_) { + return -1; + } +}); + +extern "C" EMSCRIPTEN_KEEPALIVE int render360_browser_file_open(const char *path) +{ + return render360_browser_file_open_js(path); +} + +extern "C" EMSCRIPTEN_KEEPALIVE double render360_browser_file_size(int handle) +{ + return render360_browser_file_size_js(handle); +} + +extern "C" EMSCRIPTEN_KEEPALIVE int render360_browser_file_read(int handle, double offset, void *dest, int length) +{ + return render360_browser_file_read_js(handle, offset, dest, length); +} + +extern "C" EMSCRIPTEN_KEEPALIVE void render360_browser_file_close(int handle) +{ + render360_browser_file_close_js(handle); +} + +extern "C" EMSCRIPTEN_KEEPALIVE double render360_browser_file_stat(const char *path) +{ + return render360_browser_file_stat_js(path); +} + +#endif // __EMSCRIPTEN__ From 4dd8a90871bff3082b41f37d7614fe10e3f741b1 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Sun, 13 Sep 2026 16:48:24 -0400 Subject: [PATCH 113/159] Link browser-backed retail bridge into Portal main module --- launcher_main/wscript | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/launcher_main/wscript b/launcher_main/wscript index 32985f5bcb..8cda4f7593 100755 --- a/launcher_main/wscript +++ b/launcher_main/wscript @@ -32,6 +32,11 @@ def build(bld): install_path = bld.env.BINDIR if bld.env.DEST_OS == 'wasm': + # Export a tiny synchronous FileReaderSync bridge from the MAIN_MODULE. + # filesystem_stdio.so uses it to read browser-backed Portal VPK ranges + # directly on the Source pthread instead of routing retail bytes through + # Emscripten's main-thread JS filesystem/MEMFS. + source += ['render360_browser_files.cpp'] bld.stlib( source = source, target = PROJECT_NAME, From a207ced5c74a4aa8ed823c022e4d23d36b0513da Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Sun, 13 Sep 2026 16:48:52 -0400 Subject: [PATCH 114/159] Patch Source stdio for browser-backed Portal retail reads --- filesystem/render360_browser_file_patch.py | 173 +++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 filesystem/render360_browser_file_patch.py diff --git a/filesystem/render360_browser_file_patch.py b/filesystem/render360_browser_file_patch.py new file mode 100644 index 0000000000..47d802cbf6 --- /dev/null +++ b/filesystem/render360_browser_file_patch.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +"""Inject Render360's browser-backed read-only file into filesystem_stdio.cpp. + +The repository keeps the upstream Source file readable. For Wasm builds the +filesystem wscript calls this idempotent patch before compilation. The bridge +bypasses Emscripten's legacy JS filesystem for retail Portal files: Source's +pthread reads File/Blob ranges synchronously through FileReaderSync instead of +copying VPK archives into MEMFS. +""" + +from pathlib import Path + +path = Path(__file__).with_name("filesystem_stdio.cpp") +text = path.read_text() +marker = "Render360 browser-backed retail file" +if marker in text: + print("Render360 Portal: browser-backed filesystem patch already applied") + raise SystemExit(0) + +anchor = """ASSERT_INVARIANT( SEEK_END == FILESYSTEM_SEEK_TAIL );\n\n//-----------------------------------------------------------------------------\n""" +insert = """ASSERT_INVARIANT( SEEK_END == FILESYSTEM_SEEK_TAIL );\n\n#ifdef __EMSCRIPTEN__\nextern \"C\" int render360_browser_file_open( const char *path );\nextern \"C\" double render360_browser_file_size( int handle );\nextern \"C\" int render360_browser_file_read( int handle, double offset, void *dest, int length );\nextern \"C\" void render360_browser_file_close( int handle );\nextern \"C\" double render360_browser_file_stat( const char *path );\n#endif\n\n//-----------------------------------------------------------------------------\n""" +if anchor not in text: + raise SystemExit("Render360 Portal: stdio declaration anchor moved") +text = text.replace(anchor, insert, 1) + +anchor = """\tFILE *m_pFile;\n\tbool m_bWriteable;\n};\n\n#ifdef POSIX\n""" +insert = """\tFILE *m_pFile;\n\tbool m_bWriteable;\n};\n\n#ifdef __EMSCRIPTEN__\n// Render360 browser-backed retail file. This object deliberately implements\n// only read operations. VPK bytes stay in the user's browser File objects;\n// each read copies only the requested range into the caller's Wasm buffer.\nclass CRender360BrowserFile : public CStdFilesystemFile\n{\npublic:\n\tstatic bool CanOpen( const char *filename, const char *options );\n\tstatic CRender360BrowserFile *FS_fopen( const char *filename, const char *options, int64 *size );\n\n\tvirtual void FS_setbufsize( unsigned nBytes ) {}\n\tvirtual void FS_fclose();\n\tvirtual void FS_fseek( int64 pos, int seekType );\n\tvirtual long FS_ftell();\n\tvirtual int FS_feof();\n\tvirtual size_t FS_fread( void *dest, size_t destSize, size_t size );\n\tvirtual size_t FS_fwrite( const void *src, size_t size ) { return 0; }\n\tvirtual bool FS_setmode( FileMode_t mode ) { return true; }\n\tvirtual size_t FS_vfprintf( const char *fmt, va_list list ) { return 0; }\n\tvirtual int FS_ferror() { return m_bError ? 1 : 0; }\n\tvirtual int FS_fflush() { return 0; }\n\tvirtual char *FS_fgets( char *dest, int destSize );\n\nprivate:\n\tCRender360BrowserFile( int handle, int64 fileSize )\n\t\t: m_nHandle( handle ), m_nSize( fileSize ), m_nPosition( 0 ), m_bError( false ) {}\n\n\tint m_nHandle;\n\tint64 m_nSize;\n\tint64 m_nPosition;\n\tbool m_bError;\n};\n#endif\n\n#ifdef POSIX\n""" +if anchor not in text: + raise SystemExit("Render360 Portal: CStdioFile class anchor moved") +text = text.replace(anchor, insert, 1) + +anchor = """\tCBaseFileSystem::FixUpPath ( filenameT, filename, sizeof( filename ) );\n\n#ifdef _WIN32\n""" +insert = """\tCBaseFileSystem::FixUpPath ( filenameT, filename, sizeof( filename ) );\n\n#ifdef __EMSCRIPTEN__\n\tif ( CRender360BrowserFile::CanOpen( filename, options ) )\n\t{\n\t\tpFile = CRender360BrowserFile::FS_fopen( filename, options, size );\n\t\tif ( pFile )\n\t\t\treturn (FILE *)pFile;\n\t}\n#endif\n\n#ifdef _WIN32\n""" +if anchor not in text: + raise SystemExit("Render360 Portal: FS_fopen anchor moved") +text = text.replace(anchor, insert, 1) + +anchor = """\tCBaseFileSystem::FixUpPath ( pathT, path, sizeof( path ) );\n\n\tint rt = _stat( path, buf );\n""" +insert = """\tCBaseFileSystem::FixUpPath ( pathT, path, sizeof( path ) );\n\n#ifdef __EMSCRIPTEN__\n\tconst double render360Size = render360_browser_file_stat( path );\n\tif ( render360Size >= 0.0 )\n\t{\n\t\tmemset( buf, 0, sizeof( *buf ) );\n\t\tbuf->st_mode = S_IFREG | S_IRUSR | S_IRGRP | S_IROTH;\n\t\tbuf->st_nlink = 1;\n\t\tbuf->st_size = (int64)render360Size;\n\t\treturn 0;\n\t}\n#endif\n\n\tint rt = _stat( path, buf );\n""" +if anchor not in text: + raise SystemExit("Render360 Portal: FS_stat anchor moved") +text = text.replace(anchor, insert, 1) + +anchor = """//-----------------------------------------------------------------------------\n// Purpose: low-level filesystem wrapper\n//-----------------------------------------------------------------------------\nCStdioFile *CStdioFile::FS_fopen( const char *filenameT, const char *options, int64 *size )\n""" +implementation = r'''#ifdef __EMSCRIPTEN__ +bool CRender360BrowserFile::CanOpen( const char *filename, const char *options ) +{ + if ( !filename || !options ) + return false; + if ( strchr( options, 'w' ) || strchr( options, 'a' ) || strchr( options, '+' ) ) + return false; + return render360_browser_file_stat( filename ) >= 0.0; +} + +CRender360BrowserFile *CRender360BrowserFile::FS_fopen( const char *filename, const char *options, int64 *size ) +{ + if ( !CanOpen( filename, options ) ) + return NULL; + const int handle = render360_browser_file_open( filename ); + if ( handle < 0 ) + return NULL; + const double fileSize = render360_browser_file_size( handle ); + if ( fileSize < 0.0 ) + { + render360_browser_file_close( handle ); + return NULL; + } + const int64 nFileSize = (int64)fileSize; + if ( size ) + *size = nFileSize; + return new CRender360BrowserFile( handle, nFileSize ); +} + +void CRender360BrowserFile::FS_fclose() +{ + if ( m_nHandle >= 0 ) + render360_browser_file_close( m_nHandle ); + m_nHandle = -1; +} + +void CRender360BrowserFile::FS_fseek( int64 pos, int seekType ) +{ + int64 next = pos; + if ( seekType == SEEK_CUR ) + next = m_nPosition + pos; + else if ( seekType == SEEK_END ) + next = m_nSize + pos; + if ( next < 0 ) + next = 0; + m_nPosition = next; +} + +long CRender360BrowserFile::FS_ftell() +{ + return (long)m_nPosition; +} + +int CRender360BrowserFile::FS_feof() +{ + return m_nPosition >= m_nSize; +} + +size_t CRender360BrowserFile::FS_fread( void *dest, size_t destSize, size_t size ) +{ + if ( !dest || !size || m_nHandle < 0 || m_nPosition >= m_nSize ) + return 0; + + const int64 available = m_nSize - m_nPosition; + size_t wanted = size; + if ( (int64)wanted > available ) + wanted = (size_t)available; + + // Keep temporary FileReaderSync ArrayBuffers small on iPhone. Only this + // transient chunk is copied into the Wasm heap; the VPK itself stays outside. + const size_t kReadChunk = 2 * 1024 * 1024; + size_t total = 0; + byte *out = reinterpret_cast( dest ); + while ( total < wanted ) + { + const size_t remain = wanted - total; + const int request = (int)( remain > kReadChunk ? kReadChunk : remain ); + const int got = render360_browser_file_read( m_nHandle, (double)m_nPosition, out + total, request ); + if ( got <= 0 ) + { + if ( got < 0 ) m_bError = true; + break; + } + m_nPosition += got; + total += (size_t)got; + if ( got < request ) break; + } + return total; +} + +char *CRender360BrowserFile::FS_fgets( char *dest, int destSize ) +{ + if ( !dest || destSize <= 1 || FS_feof() ) + return NULL; + int written = 0; + while ( written < destSize - 1 && !FS_feof() ) + { + char c = 0; + if ( FS_fread( &c, 1, 1 ) != 1 ) break; + dest[written++] = c; + if ( c == '\n' ) break; + } + if ( !written ) return NULL; + dest[written] = '\0'; + return dest; +} +#endif + +//----------------------------------------------------------------------------- +// Purpose: low-level filesystem wrapper +//----------------------------------------------------------------------------- +CStdioFile *CStdioFile::FS_fopen( const char *filenameT, const char *options, int64 *size ) +''' +if anchor not in text: + raise SystemExit("Render360 Portal: CStdioFile implementation anchor moved") +text = text.replace(anchor, implementation, 1) + +for required in ( + marker, + "render360_browser_file_open", + "CRender360BrowserFile::FS_fread", + "render360_browser_file_stat( path )", + "kReadChunk = 2 * 1024 * 1024", +): + if required not in text: + raise SystemExit(f"Render360 Portal: generated stdio patch missing {required}") + +path.write_text(text) +print("Render360 Portal: patched filesystem_stdio for browser-backed retail File reads") From 6430d06848ab18de6788fadfa5b6b3aa26fc7030 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Sun, 13 Sep 2026 16:49:12 -0400 Subject: [PATCH 115/159] Apply browser-backed stdio patch in Wasm filesystem build --- filesystem/wscript | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/filesystem/wscript b/filesystem/wscript index 52d3a5f99e..ba42143b8e 100755 --- a/filesystem/wscript +++ b/filesystem/wscript @@ -3,6 +3,8 @@ from waflib import Utils import os +import subprocess +import sys top = '.' PROJECT_NAME = 'filesystem_stdio' @@ -19,6 +21,10 @@ def configure(conf): conf.define('SUPPORT_PACKED_STORE',1) def build(bld): + if bld.env.DEST_OS == 'wasm': + patch = os.path.join(bld.path.abspath(), 'render360_browser_file_patch.py') + subprocess.check_call([sys.executable, patch]) + source = [ 'basefilesystem.cpp', 'packfile.cpp', From 96f063ae470de353303b5d060639f112ef790d08 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Sun, 13 Sep 2026 16:49:47 -0400 Subject: [PATCH 116/159] Resolve direct files from the local WORKERFS mount --- launcher_main/render360_browser_files.cpp | 44 ++++++++++++++++++----- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/launcher_main/render360_browser_files.cpp b/launcher_main/render360_browser_files.cpp index 55f631477c..a0c0eb146f 100644 --- a/launcher_main/render360_browser_files.cpp +++ b/launcher_main/render360_browser_files.cpp @@ -2,9 +2,9 @@ // // This code is linked into the MAIN_MODULE and exported for filesystem_stdio.so. // Source runs under PROXY_TO_PTHREAD, so these EM_JS calls execute on the calling -// Source pthread. Phase 3 transfers the user's File objects to that pthread; -// FileReaderSync can therefore service synchronous Source/VPK reads without -// copying the retail archives into MEMFS or the Wasm heap permanently. +// Source pthread. Phase 3 mounts the user's File objects in that pthread through +// WORKERFS; FileReaderSync can therefore service synchronous Source/VPK reads +// without copying the retail archives into MEMFS or the Wasm heap permanently. #ifdef __EMSCRIPTEN__ @@ -14,16 +14,32 @@ EM_JS(int, render360_browser_file_open_js, (const char *pathPtr), { try { if (typeof FileReaderSync === 'undefined') return -1; - var files = globalThis.__render360RetailFileMap; - if (!files || typeof files.get !== 'function') return -1; var path = UTF8ToString(pathPtr || 0) .replace(/\\/g, '/') .replace(/\/+/g, '/') .replace(/^\/+/, '') .replace(/(^|\/)\.\//g, '$1') .toLowerCase(); - var file = files.get(path); + + var file = null; + var files = globalThis.__render360RetailFileMap; + if (files && typeof files.get === 'function') file = files.get(path) || null; + + // The Phase 3 handoff already mounted these File/Blob objects in this + // pthread's WORKERFS. Resolve the backing Blob directly instead of entering + // libc/legacy JS FS, whose pthread syscalls are normally proxied to the + // browser main thread and cannot use FileReaderSync. + if (!file && typeof FS !== 'undefined') { + try { + var resolved = FS.lookupPath('/render360-retail/' + path, { follow: true }); + var node = resolved && resolved.node; + if (node && node.contents && typeof node.contents.slice === 'function') { + file = node.contents; + } + } catch (_) {} + } if (!file) return -1; + var handles = globalThis.__render360RetailHandles; if (!handles) handles = globalThis.__render360RetailHandles = new Map(); var next = (globalThis.__render360RetailNextHandle | 0) || 1; @@ -79,15 +95,25 @@ EM_JS(void, render360_browser_file_close_js, (int handle), { EM_JS(double, render360_browser_file_stat_js, (const char *pathPtr), { try { - var files = globalThis.__render360RetailFileMap; - if (!files || typeof files.get !== 'function') return -1; var path = UTF8ToString(pathPtr || 0) .replace(/\\/g, '/') .replace(/\/+/g, '/') .replace(/^\/+/, '') .replace(/(^|\/)\.\//g, '$1') .toLowerCase(); - var file = files.get(path); + + var file = null; + var files = globalThis.__render360RetailFileMap; + if (files && typeof files.get === 'function') file = files.get(path) || null; + if (!file && typeof FS !== 'undefined') { + try { + var resolved = FS.lookupPath('/render360-retail/' + path, { follow: true }); + var node = resolved && resolved.node; + if (node && node.contents && typeof node.contents.slice === 'function') { + file = node.contents; + } + } catch (_) {} + } return file ? Number(file.size || 0) : -1; } catch (_) { return -1; From 788820087d68e1dbc6bd5ba957fc354efe8ea42d Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Sun, 13 Sep 2026 16:50:11 -0400 Subject: [PATCH 117/159] Expose zero-byte VPK namespace while keeping retail bytes browser-backed --- emscripten/post.js | 54 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 41 insertions(+), 13 deletions(-) diff --git a/emscripten/post.js b/emscripten/post.js index 3452362620..349b79180b 100644 --- a/emscripten/post.js +++ b/emscripten/post.js @@ -35,9 +35,11 @@ // PREINITIALIZATION: filesystem_stdio must be able to open /portal/gameinfo.txt // before the engine has entered its normal VPK read path. // -// Copy ONLY tiny bootstrap metadata into the shared/main-thread MEMFS. VPKs, -// maps, textures, audio and every large retail payload remain File-backed, so -// this fixes startup visibility without reintroducing the old ~221 MiB preload. +// Copy ONLY tiny bootstrap metadata into the shared/main-thread MEMFS. For VPKs +// create zero-byte namespace placeholders so Source can enumerate familiar file +// names from the shared FS; filesystem_stdio intercepts the actual opens/stats +// and reads the real File/Blob ranges on the Source pthread. No VPK payload, +// maps, textures or audio are copied into MEMFS. ;(() => { 'use strict' @@ -66,6 +68,15 @@ return /^(?:portal|hl2|platform)\/(?:gameinfo\.txt|steam\.inf|game\.inf)$/i.test(path) } + function isRetailVpk(path) { + return /^(?:portal|hl2|platform)\/.+\.vpk$/i.test(path) + } + + function ensureParent(fullPath) { + const slash = fullPath.lastIndexOf('/') + if(slash > 0) FS.mkdirTree(fullPath.slice(0, slash)) + } + function release() { if(!held) return held = false @@ -93,20 +104,33 @@ async function stage(files) { let count = 0 let bytes = 0 + let vpkPlaceholders = 0 let hasPortalGameInfo = false for(const item of Array.isArray(files) ? files : []) { const path = normalize(item && item.path) const file = item && item.file - if(!isStartupMetadata(path) || !file || typeof file.arrayBuffer !== 'function') continue + if(!path || !file) continue const fullPath = '/' + path - const slash = fullPath.lastIndexOf('/') - if(slash > 0) FS.mkdirTree(fullPath.slice(0, slash)) - const data = new Uint8Array(await file.arrayBuffer()) - FS.writeFile(fullPath, data) - bytes += data.byteLength - count++ - if(path.toLowerCase() === 'portal/gameinfo.txt') hasPortalGameInfo = true + if(isStartupMetadata(path) && typeof file.arrayBuffer === 'function') { + ensureParent(fullPath) + const data = new Uint8Array(await file.arrayBuffer()) + FS.writeFile(fullPath, data) + bytes += data.byteLength + count++ + if(path.toLowerCase() === 'portal/gameinfo.txt') hasPortalGameInfo = true + continue + } + + if(isRetailVpk(path)) { + ensureParent(fullPath) + try { + FS.lookupPath(fullPath, { follow: false }) + } catch(_) { + FS.writeFile(fullPath, new Uint8Array(0)) + } + vpkPlaceholders++ + } } if(!hasPortalGameInfo) { @@ -116,11 +140,15 @@ if(!stat || Number(stat.size || 0) <= 0) { throw new Error('/portal/gameinfo.txt is empty or not visible in shared MEMFS') } + if(vpkPlaceholders <= 0) { + throw new Error('no Portal VPK names were exposed to the shared Source namespace') + } Module.render360Phase3StartupMemfsBytes = bytes Module.render360Phase3StartupMemfsFiles = count - Module.print?.(`[Render360 Phase 3] staged ${count} startup metadata files (${bytes} bytes) into shared MEMFS; VPK payload remains browser-backed`) - try { globalThis.render360SetPhase?.('phase3-startup-metadata-ready') } catch(_) {} + Module.render360Phase3VpkPlaceholders = vpkPlaceholders + Module.print?.(`[Render360 Phase 3] staged ${count} startup metadata files (${bytes} bytes) plus ${vpkPlaceholders} zero-byte VPK namespace placeholders; retail VPK payload remains browser-backed`) + try { globalThis.render360SetPhase?.(`phase3-startup-metadata-ready:vpk=${vpkPlaceholders}`) } catch(_) {} } window.addEventListener('message', event => { From 2c2d6a28a2a9ddfd4323de841752d1b3b78307ef Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Sun, 13 Sep 2026 16:54:55 -0400 Subject: [PATCH 118/159] Keep browser retail bridge in the linked Wasm launcher object --- launcher_main/render360_wasm_main.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 launcher_main/render360_wasm_main.cpp diff --git a/launcher_main/render360_wasm_main.cpp b/launcher_main/render360_wasm_main.cpp new file mode 100644 index 0000000000..b0dbf7f8bb --- /dev/null +++ b/launcher_main/render360_wasm_main.cpp @@ -0,0 +1,10 @@ +// Render360 Wasm launcher unity translation unit. +// +// The launcher is archived as libhl2_launcher.a before the final Emscripten +// MAIN_MODULE link. Keeping main.cpp and the browser-backed retail bridge in +// one archive member guarantees the bridge is extracted with main(), rather +// than leaving its runtime-dlopen symbols stranded in an otherwise-unreferenced +// static-library object. + +#include "main.cpp" +#include "render360_browser_files.cpp" From 87ae3b9c95d8632c2372d3577c3c0aa9bcd3dbb3 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Sun, 13 Sep 2026 16:55:19 -0400 Subject: [PATCH 119/159] Guarantee retail bridge is linked into Wasm MAIN_MODULE --- launcher_main/wscript | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/launcher_main/wscript b/launcher_main/wscript index 8cda4f7593..69c26b346d 100755 --- a/launcher_main/wscript +++ b/launcher_main/wscript @@ -32,11 +32,10 @@ def build(bld): install_path = bld.env.BINDIR if bld.env.DEST_OS == 'wasm': - # Export a tiny synchronous FileReaderSync bridge from the MAIN_MODULE. - # filesystem_stdio.so uses it to read browser-backed Portal VPK ranges - # directly on the Source pthread instead of routing retail bytes through - # Emscripten's main-thread JS filesystem/MEMFS. - source += ['render360_browser_files.cpp'] + # main.cpp must pull the browser-backed retail bridge into the final + # MAIN_MODULE. Keep both in one archive member so runtime-dlopen symbols + # cannot be discarded as an otherwise-unreferenced static-library object. + source = ['render360_wasm_main.cpp'] bld.stlib( source = source, target = PROJECT_NAME, From ee06bf656087f31c39665b686830021e1bdc0d01 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Mon, 14 Sep 2026 13:01:22 -0400 Subject: [PATCH 120/159] Fix browser file bridge EM_JS preprocessing --- launcher_main/render360_browser_files.cpp | 30 ++++++++++++++--------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/launcher_main/render360_browser_files.cpp b/launcher_main/render360_browser_files.cpp index a0c0eb146f..ad07f05e60 100644 --- a/launcher_main/render360_browser_files.cpp +++ b/launcher_main/render360_browser_files.cpp @@ -11,15 +11,20 @@ #include #include +// Keep path normalization free of JavaScript regex literals whose escaped +// trailing slash can become a C/C++ // token while EM_JS is being preprocessed. +// String operations are tiny here and make this bridge stable across Clang/ +// Emscripten versions. EM_JS(int, render360_browser_file_open_js, (const char *pathPtr), { try { if (typeof FileReaderSync === 'undefined') return -1; - var path = UTF8ToString(pathPtr || 0) - .replace(/\\/g, '/') - .replace(/\/+/g, '/') - .replace(/^\/+/, '') - .replace(/(^|\/)\.\//g, '$1') - .toLowerCase(); + var path = UTF8ToString(pathPtr || 0); + path = path.split('\\').join('/'); + while (path.indexOf('//') >= 0) path = path.split('//').join('/'); + while (path.charAt(0) === '/') path = path.slice(1); + while (path.indexOf('/./') >= 0) path = path.split('/./').join('/'); + while (path.slice(0, 2) === './') path = path.slice(2); + path = path.toLowerCase(); var file = null; var files = globalThis.__render360RetailFileMap; @@ -95,12 +100,13 @@ EM_JS(void, render360_browser_file_close_js, (int handle), { EM_JS(double, render360_browser_file_stat_js, (const char *pathPtr), { try { - var path = UTF8ToString(pathPtr || 0) - .replace(/\\/g, '/') - .replace(/\/+/g, '/') - .replace(/^\/+/, '') - .replace(/(^|\/)\.\//g, '$1') - .toLowerCase(); + var path = UTF8ToString(pathPtr || 0); + path = path.split('\\').join('/'); + while (path.indexOf('//') >= 0) path = path.split('//').join('/'); + while (path.charAt(0) === '/') path = path.slice(1); + while (path.indexOf('/./') >= 0) path = path.split('/./').join('/'); + while (path.slice(0, 2) === './') path = path.slice(2); + path = path.toLowerCase(); var file = null; var files = globalThis.__render360RetailFileMap; From 7b917f0807f0870d01e7522eb02895447cff2460 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Mon, 14 Sep 2026 13:02:03 -0400 Subject: [PATCH 121/159] Expose zero-copy retail File handles to Source bridge --- emscripten/phase3-workerfs.js | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/emscripten/phase3-workerfs.js b/emscripten/phase3-workerfs.js index 896961a7b5..7c20cb11bf 100644 --- a/emscripten/phase3-workerfs.js +++ b/emscripten/phase3-workerfs.js @@ -196,15 +196,25 @@ const blobs = [] const exposed = [] + const directFiles = new Map() for(const descriptor of descriptors) { const path = normalizeRetailPath(descriptor?.path) const file = descriptor?.file if(!path || !ROOT_RE.test(path) || !(file instanceof Blob)) continue blobs.push({ name: path, data: file }) + directFiles.set(path, file) if(shouldExposeRetailPath(path)) exposed.push(path) } if(!blobs.length) throw new Error('Portal transfer contained no portal/, hl2/ or platform/ retail files') + // Keep direct File references on the Source pthread. This is a reference + // map only: it does not copy a single VPK byte. filesystem_stdio can now + // resolve retail files without depending on WORKERFS node internals or a + // main-thread-proxied JS FS lookup. + globalThis.__render360RetailFileMap = directFiles + globalThis.__render360RetailHandles = new Map() + globalThis.__render360RetailNextHandle = 1 + safePhase('phase3-workerfs-mount-start') FS.mkdirTree(RETAIL_MOUNT) try { FS.unmount(RETAIL_MOUNT) } catch(_) {} @@ -226,6 +236,7 @@ const stats = retailDescriptorStats(blobs.map(x => ({ path: x.name, file: x.data }))) stats.links = links + stats.directHandles = directFiles.size stats.token = token Module.render360DirectVPKRequested = true Module.render360DirectVPKMounted = true @@ -234,7 +245,7 @@ Module.render360ResidentFiles = Number(Module.render360ResidentFiles || 0) publishResidency() safePhase(`phase3-workerfs-ready:vpk=${stats.vpkFiles}:links=${links}`) - safePrint(`[Render360 Phase 3] WORKERFS mounted ${stats.files} retail files (${stats.vpkFiles} VPKs, ${(stats.bytes / 1048576).toFixed(1)} MiB backing storage) with ${links} MEMFS symlinks; retail payload bytes remain outside MEMFS.`) + safePrint(`[Render360 Phase 3] WORKERFS mounted ${stats.files} retail files (${stats.vpkFiles} VPKs, ${(stats.bytes / 1048576).toFixed(1)} MiB backing storage) with ${links} MEMFS symlinks and ${stats.directHandles} zero-copy direct File handles; retail payload bytes remain outside MEMFS.`) return stats } @@ -395,4 +406,4 @@ get currentMap() { return residency.currentMap }, get residency() { return { ...Module.render360MapResidency } } } -})() \ No newline at end of file +})() From 578351e30d4197a4bda4496f0b957db087f2c188 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Mon, 14 Sep 2026 14:20:06 -0400 Subject: [PATCH 122/159] Fix Phase 3 gameinfo bootstrap before Source preinit --- emscripten/phase3-mobile-runtime.js | 142 ++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) diff --git a/emscripten/phase3-mobile-runtime.js b/emscripten/phase3-mobile-runtime.js index 6a8175cb9a..0b30401767 100644 --- a/emscripten/phase3-mobile-runtime.js +++ b/emscripten/phase3-mobile-runtime.js @@ -25,6 +25,148 @@ const isWindow = typeof window !== 'undefined' && typeof document !== 'undefined' if(!isWindow) return + // Source checks portal/gameinfo.txt during PREINITIALIZATION, before the normal + // VPK search path is established. Phase 3 intentionally keeps the retail VPKs + // browser-backed, but that earliest libc/FS check must see a real path in the + // runtime's primary MEMFS. Copy only tiny bootstrap metadata; never VPK/map + // payloads. The worker-side WORKERFS/direct-File bridge remains authoritative + // for large retail content. + const DIRECT_REQUEST_TYPE = 'render360-retail-request' + const DIRECT_FILES_TYPE = 'render360-retail-files' + const BOOTSTRAP_DEPENDENCY = 'render360-phase3-bootstrap-metadata' + const BOOTSTRAP_MAX_FILE_BYTES = 1024 * 1024 + const BOOTSTRAP_MAX_TOTAL_BYTES = 2 * 1024 * 1024 + const BOOTSTRAP_TIMEOUT_MS = 20000 + const embeddedPhase3 = !!( + window.parent && + window.parent !== window && + new URLSearchParams(location.search).has('render360Phase3') + ) + + function normalizeRetailPath(value) { + return String(value || '') + .replace(/\\/g, '/') + .replace(/^\/+/, '') + .replace(/\/+/g, '/') + .toLowerCase() + } + + function bootstrapMetadataPath(path) { + const clean = normalizeRetailPath(path) + if(!/^(portal|hl2|platform)\//.test(clean)) return false + return /\/(?:gameinfo\.txt|steam\.inf|game\.inf)$/.test(clean) + } + + function dirname(path) { + const at = String(path || '').lastIndexOf('/') + return at <= 0 ? '/' : path.slice(0, at) + } + + if(embeddedPhase3) { + const token = `bootstrap-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` + let dependencyHeld = false + let settled = false + let timeout = 0 + + function finishBootstrap() { + if(settled) return + settled = true + if(timeout) clearTimeout(timeout) + try { globalThis.render360SetPhase?.('phase3-bootstrap-ready') } catch(_) {} + if(dependencyHeld) { + dependencyHeld = false + removeRunDependency(BOOTSTRAP_DEPENDENCY) + } + } + + function failBootstrap(message) { + if(settled) return + settled = true + if(timeout) clearTimeout(timeout) + const text = `[Render360 Phase 3] bootstrap metadata failed: ${message}` + try { globalThis.render360SetPhase?.(`phase3-bootstrap-failed:${String(message).slice(0, 120)}`) } catch(_) {} + try { Module.printErr?.(text) } catch(_) { try { console.error(text) } catch(__) {} } + if(typeof abort === 'function') abort(text) + else throw new Error(text) + } + + async function stageBootstrapMetadata(descriptors) { + const selected = [] + let declaredBytes = 0 + for(const descriptor of descriptors || []) { + const path = normalizeRetailPath(descriptor?.path) + const file = descriptor?.file + if(!bootstrapMetadataPath(path) || !(file instanceof Blob)) continue + const size = Number(file.size || 0) + if(size <= 0 || size > BOOTSTRAP_MAX_FILE_BYTES) { + throw new Error(`${path} has invalid bootstrap size ${size}`) + } + declaredBytes += size + if(declaredBytes > BOOTSTRAP_MAX_TOTAL_BYTES) { + throw new Error(`bootstrap metadata exceeds ${BOOTSTRAP_MAX_TOTAL_BYTES} bytes`) + } + selected.push({ path, file, size }) + } + + if(!selected.some(item => item.path === 'portal/gameinfo.txt')) { + throw new Error('portal/gameinfo.txt was not supplied by the verified Portal folder') + } + + let writtenBytes = 0 + for(const item of selected) { + const buffer = await item.file.arrayBuffer() + if(buffer.byteLength !== item.size || buffer.byteLength > BOOTSTRAP_MAX_FILE_BYTES) { + throw new Error(`${item.path} changed size while staging`) + } + const livePath = '/' + item.path + FS.mkdirTree(dirname(livePath)) + try { FS.unlink(livePath) } catch(_) {} + FS.writeFile(livePath, new Uint8Array(buffer)) + writtenBytes += buffer.byteLength + } + + let gameinfoStat = null + try { gameinfoStat = FS.stat('/portal/gameinfo.txt') } catch(_) {} + if(!gameinfoStat || Number(gameinfoStat.size || 0) <= 0) { + throw new Error('/portal/gameinfo.txt was not visible after MEMFS bootstrap staging') + } + + Module.render360BootstrapMetadata = { + files: selected.length, + bytes: writtenBytes, + gameinfoBytes: Number(gameinfoStat.size || 0) + } + try { + Module.print?.(`[Render360 Phase 3] bootstrap metadata ready: ${selected.length} files, ${writtenBytes} bytes; /portal/gameinfo.txt is visible before Source PREINITIALIZATION.`) + } catch(_) {} + } + + window.addEventListener('message', event => { + if(event.origin !== location.origin || event.source !== window.parent) return + const data = event?.data + if(!data || data.type !== DIRECT_FILES_TYPE || data.token !== token || settled) return + if(!Array.isArray(data.files) || !data.files.length) { + failBootstrap('staging page did not provide retail File handles') + return + } + stageBootstrapMetadata(data.files).then(finishBootstrap).catch(error => { + failBootstrap(String(error?.stack || error?.message || error)) + }) + }) + + Module.preRun = Module.preRun || [] + Module.preRun.push(() => { + if(dependencyHeld || settled) return + addRunDependency(BOOTSTRAP_DEPENDENCY) + dependencyHeld = true + timeout = setTimeout(() => { + failBootstrap('timed out waiting for Portal bootstrap metadata') + }, BOOTSTRAP_TIMEOUT_MS) + try { globalThis.render360SetPhase?.('phase3-bootstrap-await-files') } catch(_) {} + window.parent.postMessage({ type: DIRECT_REQUEST_TYPE, token }, location.origin) + }) + } + const STARTUP_KEY = 'render360-startup-checkpoint-v2' const STARTUP_DEBUG_KEY = 'render360-startup-debug-v1' const STARTUP_TRACE_LIMIT = 24 From 5947a22a662a13336b8d40b7e324ef97cc35291d Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Mon, 14 Sep 2026 15:16:54 -0400 Subject: [PATCH 123/159] fix(wasm): avoid EM_JS optimizer failure in retail bridge --- launcher_main/render360_browser_files.cpp | 231 ++++++++++------------ 1 file changed, 106 insertions(+), 125 deletions(-) diff --git a/launcher_main/render360_browser_files.cpp b/launcher_main/render360_browser_files.cpp index ad07f05e60..827fda5072 100644 --- a/launcher_main/render360_browser_files.cpp +++ b/launcher_main/render360_browser_files.cpp @@ -1,154 +1,135 @@ // Render360 browser-backed retail file bridge. // // This code is linked into the MAIN_MODULE and exported for filesystem_stdio.so. -// Source runs under PROXY_TO_PTHREAD, so these EM_JS calls execute on the calling -// Source pthread. Phase 3 mounts the user's File objects in that pthread through -// WORKERFS; FileReaderSync can therefore service synchronous Source/VPK reads +// Source runs under PROXY_TO_PTHREAD, so the EM_ASM calls below execute on the +// calling Source pthread. Phase 3/4 transfers the user's File objects into that +// worker; FileReaderSync can therefore service synchronous Source/VPK reads // without copying the retail archives into MEMFS or the Wasm heap permanently. +// +// Keep this bridge on EM_ASM rather than EM_JS. Emscripten emits generated `.sig` +// metadata for EM_JS helpers; in the baseline 4.0.9 MAIN_MODULE + pthread build +// that metadata can land in an invalid optimizer context. Inline asm-const calls +// avoid that generated helper layer while preserving synchronous worker reads. #ifdef __EMSCRIPTEN__ #include #include -// Keep path normalization free of JavaScript regex literals whose escaped -// trailing slash can become a C/C++ // token while EM_JS is being preprocessed. -// String operations are tiny here and make this bridge stable across Clang/ -// Emscripten versions. -EM_JS(int, render360_browser_file_open_js, (const char *pathPtr), { - try { - if (typeof FileReaderSync === 'undefined') return -1; - var path = UTF8ToString(pathPtr || 0); - path = path.split('\\').join('/'); - while (path.indexOf('//') >= 0) path = path.split('//').join('/'); - while (path.charAt(0) === '/') path = path.slice(1); - while (path.indexOf('/./') >= 0) path = path.split('/./').join('/'); - while (path.slice(0, 2) === './') path = path.slice(2); - path = path.toLowerCase(); - - var file = null; - var files = globalThis.__render360RetailFileMap; - if (files && typeof files.get === 'function') file = files.get(path) || null; - - // The Phase 3 handoff already mounted these File/Blob objects in this - // pthread's WORKERFS. Resolve the backing Blob directly instead of entering - // libc/legacy JS FS, whose pthread syscalls are normally proxied to the - // browser main thread and cannot use FileReaderSync. - if (!file && typeof FS !== 'undefined') { - try { - var resolved = FS.lookupPath('/render360-retail/' + path, { follow: true }); - var node = resolved && resolved.node; - if (node && node.contents && typeof node.contents.slice === 'function') { - file = node.contents; - } - } catch (_) {} - } - if (!file) return -1; - - var handles = globalThis.__render360RetailHandles; - if (!handles) handles = globalThis.__render360RetailHandles = new Map(); - var next = (globalThis.__render360RetailNextHandle | 0) || 1; - while (handles.has(next)) { - next = (next + 1) | 0; - if (next <= 0) next = 1; - } - handles.set(next, file); - globalThis.__render360RetailNextHandle = (next + 1) | 0; - return next; - } catch (e) { - try { console.error('[Render360 direct file] open failed', e); } catch (_) {} - return -1; - } -}); - -EM_JS(double, render360_browser_file_size_js, (int handle), { - try { - var handles = globalThis.__render360RetailHandles; - var file = handles && handles.get(handle | 0); - return file ? Number(file.size || 0) : -1; - } catch (_) { - return -1; - } -}); - -EM_JS(int, render360_browser_file_read_js, - (int handle, double offset, void *dest, int length), { - try { - var handles = globalThis.__render360RetailHandles; - var file = handles && handles.get(handle | 0); - if (!file || typeof FileReaderSync === 'undefined') return -1; - var start = Math.max(0, Math.floor(Number(offset) || 0)); - var requested = Math.max(0, length | 0); - if (!requested || start >= file.size) return 0; - var end = Math.min(file.size, start + requested); - var buffer = new FileReaderSync().readAsArrayBuffer(file.slice(start, end)); - var bytes = new Uint8Array(buffer); - HEAPU8.set(bytes, dest >>> 0); - return bytes.byteLength | 0; - } catch (e) { - try { console.error('[Render360 direct file] read failed', e); } catch (_) {} - return -1; - } -}); - -EM_JS(void, render360_browser_file_close_js, (int handle), { - try { - var handles = globalThis.__render360RetailHandles; - if (handles) handles.delete(handle | 0); - } catch (_) {} -}); - -EM_JS(double, render360_browser_file_stat_js, (const char *pathPtr), { - try { - var path = UTF8ToString(pathPtr || 0); - path = path.split('\\').join('/'); - while (path.indexOf('//') >= 0) path = path.split('//').join('/'); - while (path.charAt(0) === '/') path = path.slice(1); - while (path.indexOf('/./') >= 0) path = path.split('/./').join('/'); - while (path.slice(0, 2) === './') path = path.slice(2); - path = path.toLowerCase(); - - var file = null; - var files = globalThis.__render360RetailFileMap; - if (files && typeof files.get === 'function') file = files.get(path) || null; - if (!file && typeof FS !== 'undefined') { - try { - var resolved = FS.lookupPath('/render360-retail/' + path, { follow: true }); - var node = resolved && resolved.node; - if (node && node.contents && typeof node.contents.slice === 'function') { - file = node.contents; - } - } catch (_) {} - } - return file ? Number(file.size || 0) : -1; - } catch (_) { - return -1; - } -}); - -extern "C" EMSCRIPTEN_KEEPALIVE int render360_browser_file_open(const char *path) +extern "C" EMSCRIPTEN_KEEPALIVE int render360_browser_file_open(const char *pathPtr) { - return render360_browser_file_open_js(path); + return EM_ASM_INT({ + try { + if (typeof FileReaderSync === 'undefined') return -1; + var path = UTF8ToString($0 || 0); + path = path.split('\\\\').join('/'); + while (path.indexOf('//') >= 0) path = path.split('//').join('/'); + while (path.charAt(0) === '/') path = path.slice(1); + while (path.indexOf('/./') >= 0) path = path.split('/./').join('/'); + while (path.slice(0, 2) === './') path = path.slice(2); + path = path.toLowerCase(); + + var file = null; + var files = globalThis.__render360RetailFileMap; + if (files && typeof files.get === 'function') file = files.get(path) || null; + if (!file && typeof FS !== 'undefined') { + try { + var resolved = FS.lookupPath('/render360-retail/' + path, { follow: true }); + var node = resolved && resolved.node; + if (node && node.contents && typeof node.contents.slice === 'function') file = node.contents; + } catch (_) {} + } + if (!file) return -1; + + var handles = globalThis.__render360RetailHandles; + if (!handles) handles = globalThis.__render360RetailHandles = new Map(); + var next = (globalThis.__render360RetailNextHandle | 0) || 1; + while (handles.has(next)) { + next = (next + 1) | 0; + if (next <= 0) next = 1; + } + handles.set(next, file); + globalThis.__render360RetailNextHandle = (next + 1) | 0; + return next; + } catch (e) { + try { console.error('[Render360 direct file] open failed', e); } catch (_) {} + return -1; + } + }, pathPtr); } extern "C" EMSCRIPTEN_KEEPALIVE double render360_browser_file_size(int handle) { - return render360_browser_file_size_js(handle); + return EM_ASM_DOUBLE({ + try { + var handles = globalThis.__render360RetailHandles; + var file = handles && handles.get($0 | 0); + return file ? Number(file.size || 0) : -1; + } catch (_) { + return -1; + } + }, handle); } extern "C" EMSCRIPTEN_KEEPALIVE int render360_browser_file_read(int handle, double offset, void *dest, int length) { - return render360_browser_file_read_js(handle, offset, dest, length); + return EM_ASM_INT({ + try { + var handles = globalThis.__render360RetailHandles; + var file = handles && handles.get($0 | 0); + if (!file || typeof FileReaderSync === 'undefined') return -1; + var start = Math.max(0, Math.floor(Number($1) || 0)); + var requested = Math.max(0, $3 | 0); + if (!requested || start >= file.size) return 0; + var end = Math.min(file.size, start + requested); + var buffer = new FileReaderSync().readAsArrayBuffer(file.slice(start, end)); + var bytes = new Uint8Array(buffer); + HEAPU8.set(bytes, $2 >>> 0); + return bytes.byteLength | 0; + } catch (e) { + try { console.error('[Render360 direct file] read failed', e); } catch (_) {} + return -1; + } + }, handle, offset, dest, length); } extern "C" EMSCRIPTEN_KEEPALIVE void render360_browser_file_close(int handle) { - render360_browser_file_close_js(handle); + EM_ASM({ + try { + var handles = globalThis.__render360RetailHandles; + if (handles) handles.delete($0 | 0); + } catch (_) {} + }, handle); } -extern "C" EMSCRIPTEN_KEEPALIVE double render360_browser_file_stat(const char *path) +extern "C" EMSCRIPTEN_KEEPALIVE double render360_browser_file_stat(const char *pathPtr) { - return render360_browser_file_stat_js(path); + return EM_ASM_DOUBLE({ + try { + var path = UTF8ToString($0 || 0); + path = path.split('\\\\').join('/'); + while (path.indexOf('//') >= 0) path = path.split('//').join('/'); + while (path.charAt(0) === '/') path = path.slice(1); + while (path.indexOf('/./') >= 0) path = path.split('/./').join('/'); + while (path.slice(0, 2) === './') path = path.slice(2); + path = path.toLowerCase(); + + var file = null; + var files = globalThis.__render360RetailFileMap; + if (files && typeof files.get === 'function') file = files.get(path) || null; + if (!file && typeof FS !== 'undefined') { + try { + var resolved = FS.lookupPath('/render360-retail/' + path, { follow: true }); + var node = resolved && resolved.node; + if (node && node.contents && typeof node.contents.slice === 'function') file = node.contents; + } catch (_) {} + } + return file ? Number(file.size || 0) : -1; + } catch (_) { + return -1; + } + }, pathPtr); } #endif // __EMSCRIPTEN__ From 2003340df8c19d06f54d65635bd5b43b4f9c9710 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Mon, 14 Sep 2026 16:19:10 -0400 Subject: [PATCH 124/159] fix(wasm): retain loose Portal BSP maps for direct streaming --- emscripten/assets/phase3-staging.js | 39 +++++++++++++++++++---------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/emscripten/assets/phase3-staging.js b/emscripten/assets/phase3-staging.js index 7ced87d17a..c759ae3c8c 100644 --- a/emscripten/assets/phase3-staging.js +++ b/emscripten/assets/phase3-staging.js @@ -6,6 +6,7 @@ const CRASH_STATE_KEY = 'render360-ios-crash-state-v2'; const DIRECT_ROOT_RE = /^(portal|hl2|platform)\//i; const DIRECT_LOOSE_RE = /\/(?:gameinfo\.txt|steam\.inf|game\.inf)$/i; + const DIRECT_MAP_TREE_RE = /^(?:portal|hl2)\/maps\//i; const DIRECT_SMALL_TREE_RE = /\/(?:cfg|resource|scripts)\//i; const MAX_LOOSE_BYTES = 8 * 1024 * 1024; const RESIDENCY_POLICY = 'menu-only → current-map-only → no future-map prefetch'; @@ -29,6 +30,10 @@ function keepForDirectVPK(path, file) { if(!DIRECT_ROOT_RE.test(path)) return false; if(/\.vpk$/i.test(path)) return true; + // Portal ships its BSPs as loose files (for example + // portal/maps/background1.bsp). Keep File handles for the entire maps tree, + // including graphs, but never copy their payload into MEMFS. + if(DIRECT_MAP_TREE_RE.test(path)) return true; if(DIRECT_LOOSE_RE.test(path)) return true; if(DIRECT_SMALL_TREE_RE.test(path) && Number(file?.size || 0) <= MAX_LOOSE_BYTES) return true; return false; @@ -38,14 +43,19 @@ let bytes = 0; let vpks = 0; let dirs = 0; + let maps = 0; let gameinfo = false; + let background1 = false; for(const item of descriptors) { + const path = normalize(item.path); bytes += Number(item.file?.size || 0); - if(/\.vpk$/i.test(item.path)) vpks++; - if(/_dir\.vpk$/i.test(item.path)) dirs++; - if(/(^|\/)portal\/gameinfo\.txt$/i.test(item.path)) gameinfo = true; + if(/\.vpk$/i.test(path)) vpks++; + if(/_dir\.vpk$/i.test(path)) dirs++; + if(DIRECT_MAP_TREE_RE.test(path)) maps++; + if(path === 'portal/gameinfo.txt') gameinfo = true; + if(path === 'portal/maps/background1.bsp') background1 = true; } - return { files: descriptors.length, bytes, vpks, dirs, gameinfo }; + return { files: descriptors.length, bytes, vpks, dirs, maps, gameinfo, background1 }; } function setPhase3Status(text) { @@ -56,12 +66,14 @@ function refreshButton() { if(!phase3Button) return; const stats = summarize(retailDescriptors); - const ready = stats.gameinfo && stats.dirs > 0 && stats.vpks > 0; + const ready = stats.gameinfo && stats.background1 && stats.dirs > 0 && stats.vpks > 0; phase3Button.disabled = !ready; globalThis.render360Phase3DirectSelected = ready; if(ready) { phase3Button.textContent = 'Launch Phase 3 · Current Map Only'; - setPhase3Status(`Phase 3 ready: ${stats.vpks} VPK files stay browser-backed. Policy: ${RESIDENCY_POLICY}. background1 and future chambers are never accumulated in MEMFS.`); + setPhase3Status(`Phase 3 ready: ${stats.vpks} VPKs + ${stats.maps} loose map files stay browser-backed. background1.bsp verified. Policy: ${RESIDENCY_POLICY}.`); + } else if(stats.gameinfo && stats.vpks > 0 && !stats.background1) { + setPhase3Status('Portal files were found, but portal/maps/background1.bsp is missing from the selected folder. Choose the full Portal installation folder so the real menu BSP can be streamed.'); } } @@ -76,11 +88,12 @@ retailDescriptors = next; globalThis.render360Phase3RetailFiles = retailDescriptors; const stats = summarize(retailDescriptors); - globalThis.render360Phase3DirectSelected = !!(stats.gameinfo && stats.dirs > 0 && stats.vpks > 0); + globalThis.render360Phase3DirectSelected = !!(stats.gameinfo && stats.background1 && stats.dirs > 0 && stats.vpks > 0); try { sessionStorage.setItem('render360-phase3-retail-summary-v1', JSON.stringify({ at: Date.now(), files: stats.files, vpks: stats.vpks, dirs: stats.dirs, - bytes: stats.bytes, gameinfo: stats.gameinfo, residencyPolicy: RESIDENCY_POLICY + maps: stats.maps, bytes: stats.bytes, gameinfo: stats.gameinfo, + background1: stats.background1, residencyPolicy: RESIDENCY_POLICY })); } catch(_) {} refreshButton(); @@ -119,8 +132,8 @@ function launchDirectVPK() { const stats = summarize(retailDescriptors); - if(!stats.gameinfo || !stats.dirs || !stats.vpks) { - setPhase3Status('Choose the full Portal folder again before launching Phase 3. File objects cannot survive a page reload.'); + if(!stats.gameinfo || !stats.background1 || !stats.dirs || !stats.vpks) { + setPhase3Status('Choose the full Portal folder again before launching Phase 3. It must include portal/gameinfo.txt, portal/maps/background1.bsp and the retail VPKs. File objects cannot survive a page reload.'); return; } @@ -143,7 +156,7 @@ back.addEventListener('click', closeRuntime); const label = document.createElement('span'); - label.textContent = `Phase 3 · current-map-only · ${stats.vpks} VPKs browser-backed · no future-map prefetch`; + label.textContent = `Phase 3 · current-map-only · ${stats.vpks} VPKs + ${stats.maps} map files browser-backed · no future-map prefetch`; label.style.cssText = 'white-space:nowrap;overflow:hidden;text-overflow:ellipsis;'; bar.append(back, label); @@ -196,7 +209,7 @@ const data = event?.data; if(!data || data.type !== REQUEST_TYPE || !data.token) return; const stats = summarize(retailDescriptors); - if(!stats.gameinfo || !stats.dirs || !stats.vpks) return; + if(!stats.gameinfo || !stats.background1 || !stats.dirs || !stats.vpks) return; runtimeFrame.contentWindow.postMessage({ type: FILES_TYPE, token: data.token, @@ -206,4 +219,4 @@ if(document.readyState === 'loading') document.addEventListener('DOMContentLoaded', installUI, { once: true }); else installUI(); -})(); +})(); \ No newline at end of file From cfa1be614e27c96d3686701b8fc3efa71cc19ffd Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Mon, 14 Sep 2026 16:20:02 -0400 Subject: [PATCH 125/159] fix(wasm): expose loose Portal maps through WORKERFS --- emscripten/phase3-workerfs.js | 70 ++++++++++++++++++++++++----------- 1 file changed, 49 insertions(+), 21 deletions(-) diff --git a/emscripten/phase3-workerfs.js b/emscripten/phase3-workerfs.js index 7c20cb11bf..4a2104ea42 100644 --- a/emscripten/phase3-workerfs.js +++ b/emscripten/phase3-workerfs.js @@ -8,9 +8,10 @@ // // WORKERFS reads Blob/File slices with FileReaderSync inside the worker. The VPK // bytes therefore do not become 221+ MiB of individual MEMFS files. Source's own -// filesystem opens the real retail VPKs and performs its normal seek/range reads. -// The existing chunk/MEMFS loader remains available when hl2_launcher.html is -// opened directly, so Phase 3 can be tested without deleting the known fallback. +// filesystem opens the real retail VPKs and loose BSP files and performs normal +// seek/range reads. The existing chunk/MEMFS loader remains available when +// hl2_launcher.html is opened directly, so Phase 3 can be tested without +// deleting the known fallback. // // Map residency rule for the direct-VPK path: // menu -> background1 only @@ -18,10 +19,11 @@ // transition -> Source shuts the old level down, then opens the next BSP // future maps -> never prefetched by Render360 // -// The whole VPK set remains ADDRESSABLE through WORKERFS, but retail bytes are -// not resident in MEMFS. Source's normal level shutdown owns native world/model/ -// material lifetime; the Render360 JS layer deliberately keeps no historical map -// payload and never walks through earlier chambers to satisfy a later request. +// The VPK set and loose map tree remain ADDRESSABLE through WORKERFS, but retail +// bytes are not resident in MEMFS. Source's normal level shutdown owns native +// world/model/material lifetime; the Render360 JS layer deliberately keeps no +// historical map payload and never walks through earlier chambers to satisfy a +// later request. ;(() => { 'use strict' @@ -36,9 +38,12 @@ const HANDOFF_TIMEOUT_MS = 20000 const RETAIL_MOUNT = '/render360-retail' const ROOT_RE = /^(portal|hl2|platform)\//i + const MAP_TREE_RE = /^(?:portal|hl2)\/maps\//i + const MENU_MAP_PATH = 'portal/maps/background1.bsp' const MENU_MAP = 'background1' const KNOWN_MAPS = new Set([ 'background1', + 'background2', 'testchmb_a_00', 'testchmb_a_01', 'testchmb_a_02', @@ -53,7 +58,10 @@ 'testchmb_a_11', 'testchmb_a_13', 'testchmb_a_14', - 'testchmb_a_15' + 'testchmb_a_15', + 'escape_00', + 'escape_01', + 'escape_02' ]) const isWindow = typeof window !== 'undefined' && typeof document !== 'undefined' @@ -144,7 +152,8 @@ // There is intentionally no JS-side unload loop here. In the direct-VPK // path Render360 never unpacked the old map into MEMFS in the first place. // Source's native level shutdown releases the old BSP/world resources; the - // VPK files stay mounted as read-only backing storage for future range reads. + // browser File objects stay mounted as read-only backing storage for future + // range reads without becoming resident map payloads. if(previous) { safePrint(`[Render360 Phase 3] residency transition ${previous} -> ${next}: previous level is no longer a Render360 resident map; no future chamber was prefetched.`) } else if(next === MENU_MAP) { @@ -160,18 +169,26 @@ let bytes = 0 let vpkFiles = 0 let looseFiles = 0 + let mapFiles = 0 + let background1 = false for(const descriptor of descriptors || []) { + const path = normalizeRetailPath(descriptor?.path) bytes += Number(descriptor?.file?.size || 0) - if(/\.vpk$/i.test(descriptor?.path || '')) vpkFiles++ + if(/\.vpk$/i.test(path)) vpkFiles++ else looseFiles++ + if(MAP_TREE_RE.test(path)) mapFiles++ + if(path === MENU_MAP_PATH) background1 = true } - return { files: (descriptors || []).length, bytes, vpkFiles, looseFiles } + return { files: (descriptors || []).length, bytes, vpkFiles, looseFiles, mapFiles, background1 } } function shouldExposeRetailPath(path) { const clean = normalizeRetailPath(path) if(!ROOT_RE.test(clean)) return false if(/\.vpk$/i.test(clean)) return true + // Portal's BSPs are loose retail files. Expose the entire maps tree through + // WORKERFS so Source can open background1 and later chambers without MEMFS. + if(MAP_TREE_RE.test(clean)) return true if(/\/(?:gameinfo\.txt|steam\.inf|game\.inf)$/i.test(clean)) return true if(/\/(?:cfg|resource|scripts)\//i.test(clean)) return true return false @@ -207,9 +224,14 @@ } if(!blobs.length) throw new Error('Portal transfer contained no portal/, hl2/ or platform/ retail files') - // Keep direct File references on the Source pthread. This is a reference - // map only: it does not copy a single VPK byte. filesystem_stdio can now - // resolve retail files without depending on WORKERFS node internals or a + const menuMapFile = directFiles.get(MENU_MAP_PATH) + if(!(menuMapFile instanceof Blob) || Number(menuMapFile.size || 0) <= 0) { + throw new Error('portal/maps/background1.bsp was not transferred; the real Portal menu map cannot start') + } + + // Keep direct File references on the Source pthread. This is a reference + // map only: it does not copy a VPK or BSP byte. filesystem_stdio can resolve + // retail files without depending on WORKERFS node internals or a // main-thread-proxied JS FS lookup. globalThis.__render360RetailFileMap = directFiles globalThis.__render360RetailHandles = new Map() @@ -244,8 +266,8 @@ Module.render360ResidentBytes = Number(Module.render360ResidentBytes || 0) Module.render360ResidentFiles = Number(Module.render360ResidentFiles || 0) publishResidency() - safePhase(`phase3-workerfs-ready:vpk=${stats.vpkFiles}:links=${links}`) - safePrint(`[Render360 Phase 3] WORKERFS mounted ${stats.files} retail files (${stats.vpkFiles} VPKs, ${(stats.bytes / 1048576).toFixed(1)} MiB backing storage) with ${links} MEMFS symlinks and ${stats.directHandles} zero-copy direct File handles; retail payload bytes remain outside MEMFS.`) + safePhase(`phase3-workerfs-ready:vpk=${stats.vpkFiles}:maps=${stats.mapFiles}:links=${links}`) + safePrint(`[Render360 Phase 3] WORKERFS mounted ${stats.files} retail files (${stats.vpkFiles} VPKs, ${stats.mapFiles} loose map files, ${(stats.bytes / 1048576).toFixed(1)} MiB browser backing storage) with ${links} live symlinks and ${stats.directHandles} zero-copy direct File handles; retail payload bytes remain outside MEMFS.`) return stats } @@ -287,7 +309,8 @@ if(timeout) clearTimeout(timeout) Module.render360DirectVPKReady = true safePhase(`phase3-workers-ready:${mountedWorkers.size}`) - safePrint(`[Render360 Phase 3] ${mountedWorkers.size} pthread workers have zero-copy retail VPK access; background1 chunk preload is disabled, all packed map preloads are disabled, and Render360 map prefetch is disabled.`) + const stats = Module.render360DirectRetailStats || {} + safePrint(`[Render360 Phase 3] ${mountedWorkers.size} pthread workers have zero-copy retail access (${stats.vpkFiles || 0} VPKs, ${stats.mapFiles || 0} map files); background1 packed preload is disabled, all packed map preloads are disabled, and Render360 map prefetch is disabled.`) if(dependencyHeld) { dependencyHeld = false removeRunDependency('render360-direct-vpk') @@ -342,9 +365,13 @@ } descriptors = data.files const stats = retailDescriptorStats(descriptors) + if(!stats.background1) { + failHandoff('portal/maps/background1.bsp was not retained by the staging page') + return + } Module.render360DirectRetailStats = stats - safePhase(`phase3-retail-received:vpk=${stats.vpkFiles}`) - safePrint(`[Render360 Phase 3] received ${stats.files} zero-copy retail File handles (${stats.vpkFiles} VPKs); waiting for pthread WORKERFS mounts.`) + safePhase(`phase3-retail-received:vpk=${stats.vpkFiles}:maps=${stats.mapFiles}`) + safePrint(`[Render360 Phase 3] received ${stats.files} zero-copy retail File handles (${stats.vpkFiles} VPKs, ${stats.mapFiles} loose map files); waiting for pthread WORKERFS mounts.`) for(const id of readyWorkers) sendToWorker(id) }) @@ -382,12 +409,13 @@ // This is the core current-map-only rule. Do not call the compatibility // loader, do not load background1 as a dependency of chambers, and do not // walk mapsOrdered. The requested BSP becomes the sole Render360 map - // residency checkpoint while Source reads only the VPK ranges it asks for. + // residency checkpoint while Source reads its real loose BSP and VPK + // dependencies lazily from the browser File objects. const transition = enterCurrentMap(mapName) this.setProgress?.(mapName, 1) const stats = Module.render360DirectVPKStats || Module.render360DirectRetailStats || {} const snapshot = globalThis.render360MemorySnapshot?.(`phase3-map-ready:${normalizeMapName(mapName)}`) - safePrint(`[Render360 Phase 3] ${normalizeMapName(mapName)}: current-map-only; skipped packed .data/MEMFS staging and all earlier/future map preloads. Source reads retail VPK ranges lazily through WORKERFS (vpkFiles=${stats.vpkFiles || 0}, generation=${transition.generation}, memory=${JSON.stringify(snapshot || {})}).`) + safePrint(`[Render360 Phase 3] ${normalizeMapName(mapName)}: current-map-only; skipped packed .data/MEMFS staging and all earlier/future map preloads. Source reads the loose BSP plus retail VPK ranges lazily through WORKERFS/direct File reads (vpkFiles=${stats.vpkFiles || 0}, mapFiles=${stats.mapFiles || 0}, generation=${transition.generation}, memory=${JSON.stringify(snapshot || {})}).`) return } return originalLoadMapWithDeps.call(this, mapName) From 30ad6b1836a97756943854f52c43cd9c3c842090 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Mon, 14 Sep 2026 16:23:17 -0400 Subject: [PATCH 126/159] fix(ci): preserve Phase 3 no-preload architecture guard --- emscripten/phase3-workerfs.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/emscripten/phase3-workerfs.js b/emscripten/phase3-workerfs.js index 4a2104ea42..ac292dbcec 100644 --- a/emscripten/phase3-workerfs.js +++ b/emscripten/phase3-workerfs.js @@ -310,7 +310,8 @@ Module.render360DirectVPKReady = true safePhase(`phase3-workers-ready:${mountedWorkers.size}`) const stats = Module.render360DirectRetailStats || {} - safePrint(`[Render360 Phase 3] ${mountedWorkers.size} pthread workers have zero-copy retail access (${stats.vpkFiles || 0} VPKs, ${stats.mapFiles || 0} map files); background1 packed preload is disabled, all packed map preloads are disabled, and Render360 map prefetch is disabled.`) + // CI guard wording intentionally retained: background1 chunk preload is disabled. + safePrint(`[Render360 Phase 3] ${mountedWorkers.size} pthread workers have zero-copy retail access (${stats.vpkFiles || 0} VPKs, ${stats.mapFiles || 0} map files); background1 chunk preload is disabled, all packed map preloads are disabled, and Render360 map prefetch is disabled.`) if(dependencyHeld) { dependencyHeld = false removeRunDependency('render360-direct-vpk') From b9818d37c394b36bbaf91f01bbb73587b025fe45 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Mon, 14 Sep 2026 22:03:49 -0400 Subject: [PATCH 127/159] ios-native: add native iOS bootstrap overview --- ios-native/README.md | 111 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 ios-native/README.md diff --git a/ios-native/README.md b/ios-native/README.md new file mode 100644 index 0000000000..a1639fb3da --- /dev/null +++ b/ios-native/README.md @@ -0,0 +1,111 @@ +# Render360 Portal — Native iOS + +This directory is the primary iPhone port target. The browser/WebAssembly work remains preserved on the existing Render360 branches, but native iOS no longer depends on Safari, WebAssembly, MEMFS/WORKERFS, SharedArrayBuffer, COOP/COEP, service workers, or browser fullscreen behavior. + +## Scope + +The target is a real arm64 iPhone application, not a web wrapper and not a mock renderer. + +Target bring-up order: + +1. Native arm64 app boots on iPhone. +2. SDL2 iOS window/input/audio host. +3. Source tier0/tier1/mathlib/filesystem compile natively. +4. Static Source module registry replaces browser SIDE_MODULE/dlopen startup. +5. User imports/verifies a legally owned Portal installation from Files. +6. Source PreInit and VPK reads work through normal iOS filesystem I/O. +7. Bring up the existing ToGL path against OpenGL ES 3.0 as a compatibility milestone. +8. Render `background1`. +9. Render `testchmb_a_00` and support touch/controller input. +10. Preserve current-map-only residency and release old map resources at transitions. +11. Hide unavoidable level transitions behind Aperture/elevator-style transition presentation. +12. Profile/optimize for iPhone 11. +13. Migrate expensive/deprecated graphics paths to Metal incrementally. +14. Produce unsigned and optionally signed IPA artifacts in GitHub Actions. + +## Retail data policy + +**Do not commit Portal retail data, VPKs, maps, textures, sounds, Valve binaries, or copied game assets to this repository or package them in the IPA.** + +The app must ask the user to import/authorize files from their own Portal installation. Build-time tests use synthetic fixtures only. + +## Memory policy carried over from the web work + +The useful memory architecture survives the native pivot: + +- Menu: `background1` only. +- Gameplay: current BSP + shared engine assets + bounded reusable caches. +- No future-map prefetch on the iPhone 11 profile until measurements prove there is safe headroom. +- After a successful level transition, release old world/model references and uncache only unused materials/resources. +- Never keep a chain of `background1 + chamber00 + chamber01 + ...` resident. +- Prefer ordinary file reads/range reads from VPKs instead of unpacking whole maps into RAM. + +## Native filesystem layout + +At runtime the host will resolve a Source-style game root under iOS-accessible storage: + +```text +/Render360Portal/Game/ + portal/ + gameinfo.txt + portal_pak_dir.vpk + ... + hl2/ + ... + platform/ + ... +``` + +Initial import may use a Files document/folder picker. The final implementation must either copy the required user-owned files into Application Support or preserve a valid security-scoped access mechanism; it must never rely on JavaScript File objects. + +## Module strategy + +On iOS, Source modules should be linked into the application and resolved through a native registry instead of arbitrary runtime-loaded executable modules. + +Example logical mapping: + +```text +engine -> Engine_CreateInterface +filesystem_stdio -> FileSystem_CreateInterface +materialsystem -> MaterialSystem_CreateInterface +shaderapidx9 -> IOSShaderAPI_CreateInterface +client -> Client_CreateInterface +server -> Server_CreateInterface +``` + +`Sys_LoadModule`/`Sys_GetFactory` receive an iOS implementation that first checks the built-in registry. Only Apple-supported dynamic frameworks should remain dynamic. + +## Graphics strategy + +OpenGL ES 3.0 is a bring-up compatibility milestone only. Apple deprecates OpenGL ES and recommends Metal. The port should therefore isolate Source/ToGL from the platform backend so we can reach first pixels quickly, then migrate hot paths to Metal without rewriting gameplay/engine code. + +## IPA outputs + +The branch contains two CI paths: + +- `ios-native.yml`: creates an **unsigned bootstrap IPA**. This proves the arm64 iPhone host compiles and packages. It must be signed later before installation on a normal device. +- `ios-native-signed.yml`: manual signed build using repository secrets/variables for an Apple certificate and provisioning profile. + +The first IPA is intentionally a native bootstrap host. It is not considered a playable Portal build until the roadmap gates in `docs/IOS_NATIVE_MASTER_PLAN.md` are satisfied. + +## Build locally + +```bash +cmake -S ios-native -B build/ios -G Xcode \ + -DCMAKE_SYSTEM_NAME=iOS \ + -DCMAKE_OSX_SYSROOT=iphoneos \ + -DCMAKE_OSX_ARCHITECTURES=arm64 \ + -DCMAKE_OSX_DEPLOYMENT_TARGET=15.0 + +xcodebuild \ + -project build/ios/Render360PortalIOS.xcodeproj \ + -scheme Render360Portal \ + -configuration Release \ + -sdk iphoneos \ + -destination 'generic/platform=iOS' \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + build +``` + +See `docs/IOS_NATIVE_AI_PROMPTS.md` for the zero-to-complete implementation prompts. \ No newline at end of file From 05896a4ed56efef16cfb72ae6a9fa31e7e0bfd0d Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Mon, 14 Sep 2026 22:03:59 -0400 Subject: [PATCH 128/159] ios-native: add CMake Xcode bootstrap target --- ios-native/CMakeLists.txt | 47 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 ios-native/CMakeLists.txt diff --git a/ios-native/CMakeLists.txt b/ios-native/CMakeLists.txt new file mode 100644 index 0000000000..d6cc974b03 --- /dev/null +++ b/ios-native/CMakeLists.txt @@ -0,0 +1,47 @@ +cmake_minimum_required(VERSION 3.25) + +project(Render360PortalIOS LANGUAGES C CXX OBJC OBJCXX) + +if(NOT IOS) + message(FATAL_ERROR "Render360PortalIOS must be configured with -DCMAKE_SYSTEM_NAME=iOS") +endif() + +set(CMAKE_C_STANDARD 11) +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_POSITION_INDEPENDENT_CODE ON) + +set(RENDER360_BUNDLE_ID "com.render360.portal" CACHE STRING "iOS bundle identifier") +set(RENDER360_DEPLOYMENT_TARGET "15.0" CACHE STRING "Minimum iOS deployment target") + +add_executable(Render360Portal MACOSX_BUNDLE + Sources/main.mm +) + +target_compile_definitions(Render360Portal PRIVATE + RENDER360_IOS_NATIVE=1 + RENDER360_PORTAL_NATIVE_BOOTSTRAP=1 +) + +target_link_libraries(Render360Portal PRIVATE + "-framework UIKit" + "-framework Foundation" + "-framework UniformTypeIdentifiers" +) + +set_target_properties(Render360Portal PROPERTIES + MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/Info.plist" + XCODE_ATTRIBUTE_PRODUCT_BUNDLE_IDENTIFIER "${RENDER360_BUNDLE_ID}" + XCODE_ATTRIBUTE_IPHONEOS_DEPLOYMENT_TARGET "${RENDER360_DEPLOYMENT_TARGET}" + XCODE_ATTRIBUTE_TARGETED_DEVICE_FAMILY "1,2" + XCODE_ATTRIBUTE_ONLY_ACTIVE_ARCH "NO" + XCODE_ATTRIBUTE_ENABLE_BITCODE "NO" + XCODE_ATTRIBUTE_CLANG_ENABLE_OBJC_ARC "YES" + XCODE_ATTRIBUTE_SUPPORTED_PLATFORMS "iphoneos iphonesimulator" + XCODE_ATTRIBUTE_SUPPORTS_MACCATALYST "NO" + XCODE_ATTRIBUTE_INFOPLIST_FILE "${CMAKE_CURRENT_SOURCE_DIR}/Info.plist" +) + +# This target intentionally contains only the native host bootstrap. Source engine +# libraries are added incrementally by the N2+ roadmap gates. Keeping N0 tiny gives +# CI a deterministic arm64/IPA proof before the engine port is layered on top. From c640610f2b7109771bb3b18ef2e7d2dbe4f00163 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Mon, 14 Sep 2026 22:04:08 -0400 Subject: [PATCH 129/159] ios-native: add application Info.plist --- ios-native/Info.plist | 50 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 ios-native/Info.plist diff --git a/ios-native/Info.plist b/ios-native/Info.plist new file mode 100644 index 0000000000..e0a7b59972 --- /dev/null +++ b/ios-native/Info.plist @@ -0,0 +1,50 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Render360 Portal + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.0.1 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UIRequiredDeviceCapabilities + + arm64 + + UIApplicationSupportsIndirectInputEvents + + UIFileSharingEnabled + + LSSupportsOpeningDocumentsInPlace + + UISupportedInterfaceOrientations + + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UILaunchScreen + + UIViewControllerBasedStatusBarAppearance + + + From 7158da3b1d18e9841cf0f5f24e39a67dc9c56f53 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Mon, 14 Sep 2026 22:04:39 -0400 Subject: [PATCH 130/159] ios-native: add native UIKit bootstrap and Portal folder verifier --- ios-native/Sources/main.mm | 203 +++++++++++++++++++++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 ios-native/Sources/main.mm diff --git a/ios-native/Sources/main.mm b/ios-native/Sources/main.mm new file mode 100644 index 0000000000..c9c1617ef9 --- /dev/null +++ b/ios-native/Sources/main.mm @@ -0,0 +1,203 @@ +#import +#import + +static UIColor *R360Background(void) { + return [UIColor colorWithRed:0.035 green:0.043 blue:0.055 alpha:1.0]; +} + +static UIColor *R360Panel(void) { + return [UIColor colorWithRed:0.075 green:0.086 blue:0.105 alpha:1.0]; +} + +@interface R360ViewController : UIViewController +@property(nonatomic, strong) UILabel *statusLabel; +@property(nonatomic, strong) UILabel *detailLabel; +@end + +@implementation R360ViewController + +- (void)viewDidLoad { + [super viewDidLoad]; + self.view.backgroundColor = R360Background(); + + UILabel *title = [[UILabel alloc] init]; + title.translatesAutoresizingMaskIntoConstraints = NO; + title.text = @"Render360 Portal"; + title.textColor = UIColor.whiteColor; + title.font = [UIFont systemFontOfSize:34 weight:UIFontWeightBold]; + + UILabel *subtitle = [[UILabel alloc] init]; + subtitle.translatesAutoresizingMaskIntoConstraints = NO; + subtitle.text = @"Native iOS bootstrap • arm64 • no WebAssembly"; + subtitle.textColor = [UIColor colorWithWhite:0.72 alpha:1.0]; + subtitle.font = [UIFont monospacedSystemFontOfSize:15 weight:UIFontWeightRegular]; + + UIView *panel = [[UIView alloc] init]; + panel.translatesAutoresizingMaskIntoConstraints = NO; + panel.backgroundColor = R360Panel(); + panel.layer.cornerRadius = 18; + + UILabel *status = [[UILabel alloc] init]; + status.translatesAutoresizingMaskIntoConstraints = NO; + status.text = @"N0 native host is running."; + status.textColor = UIColor.whiteColor; + status.font = [UIFont systemFontOfSize:20 weight:UIFontWeightSemibold]; + self.statusLabel = status; + + UILabel *detail = [[UILabel alloc] init]; + detail.translatesAutoresizingMaskIntoConstraints = NO; + detail.numberOfLines = 0; + detail.text = @"Next gate: choose a legally owned Portal folder. This bootstrap verifies portal/gameinfo.txt and counts VPKs. Retail game data is never bundled into the IPA."; + detail.textColor = [UIColor colorWithWhite:0.78 alpha:1.0]; + detail.font = [UIFont systemFontOfSize:15 weight:UIFontWeightRegular]; + self.detailLabel = detail; + + UIButton *importButton = [UIButton buttonWithType:UIButtonTypeSystem]; + importButton.translatesAutoresizingMaskIntoConstraints = NO; + [importButton setTitle:@"Choose Portal Folder" forState:UIControlStateNormal]; + importButton.titleLabel.font = [UIFont systemFontOfSize:18 weight:UIFontWeightSemibold]; + importButton.backgroundColor = UIColor.whiteColor; + [importButton setTitleColor:[UIColor colorWithRed:0.05 green:0.08 blue:0.12 alpha:1.0] forState:UIControlStateNormal]; + importButton.layer.cornerRadius = 12; + importButton.contentEdgeInsets = UIEdgeInsetsMake(13, 18, 13, 18); + [importButton addTarget:self action:@selector(importPortalFolder:) forControlEvents:UIControlEventTouchUpInside]; + + UILabel *footer = [[UILabel alloc] init]; + footer.translatesAutoresizingMaskIntoConstraints = NO; + footer.numberOfLines = 0; + footer.text = @"Roadmap: SDL2 → native Source libraries → static module registry → VPK I/O → renderer → background1 → chamber 00 → touch/controller → current-map-only transitions."; + footer.textColor = [UIColor colorWithWhite:0.55 alpha:1.0]; + footer.font = [UIFont monospacedSystemFontOfSize:13 weight:UIFontWeightRegular]; + + [self.view addSubview:title]; + [self.view addSubview:subtitle]; + [self.view addSubview:panel]; + [panel addSubview:status]; + [panel addSubview:detail]; + [panel addSubview:importButton]; + [self.view addSubview:footer]; + + UILayoutGuide *safe = self.view.safeAreaLayoutGuide; + [NSLayoutConstraint activateConstraints:@[ + [title.leadingAnchor constraintEqualToAnchor:safe.leadingAnchor constant:28], + [title.topAnchor constraintEqualToAnchor:safe.topAnchor constant:24], + [subtitle.leadingAnchor constraintEqualToAnchor:title.leadingAnchor], + [subtitle.topAnchor constraintEqualToAnchor:title.bottomAnchor constant:6], + + [panel.leadingAnchor constraintEqualToAnchor:safe.leadingAnchor constant:28], + [panel.trailingAnchor constraintEqualToAnchor:safe.trailingAnchor constant:-28], + [panel.topAnchor constraintEqualToAnchor:subtitle.bottomAnchor constant:22], + + [status.leadingAnchor constraintEqualToAnchor:panel.leadingAnchor constant:22], + [status.trailingAnchor constraintEqualToAnchor:panel.trailingAnchor constant:-22], + [status.topAnchor constraintEqualToAnchor:panel.topAnchor constant:20], + [detail.leadingAnchor constraintEqualToAnchor:status.leadingAnchor], + [detail.trailingAnchor constraintEqualToAnchor:status.trailingAnchor], + [detail.topAnchor constraintEqualToAnchor:status.bottomAnchor constant:10], + [importButton.leadingAnchor constraintEqualToAnchor:status.leadingAnchor], + [importButton.topAnchor constraintEqualToAnchor:detail.bottomAnchor constant:18], + [importButton.bottomAnchor constraintEqualToAnchor:panel.bottomAnchor constant:-20], + + [footer.leadingAnchor constraintEqualToAnchor:panel.leadingAnchor], + [footer.trailingAnchor constraintEqualToAnchor:panel.trailingAnchor], + [footer.topAnchor constraintEqualToAnchor:panel.bottomAnchor constant:18], + [footer.bottomAnchor constraintLessThanOrEqualToAnchor:safe.bottomAnchor constant:-16] + ]]; +} + +- (void)importPortalFolder:(id)sender { + UIDocumentPickerViewController *picker = [[UIDocumentPickerViewController alloc] + initForOpeningContentTypes:@[UTTypeFolder] + asCopy:NO]; + picker.delegate = self; + picker.allowsMultipleSelection = NO; + [self presentViewController:picker animated:YES completion:nil]; +} + +- (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentsAtURLs:(NSArray *)urls { + NSURL *root = urls.firstObject; + if (!root) { + return; + } + + BOOL scoped = [root startAccessingSecurityScopedResource]; + @try { + NSFileManager *fm = NSFileManager.defaultManager; + NSURL *gameInfo = [root URLByAppendingPathComponent:@"portal/gameinfo.txt"]; + NSURL *portalDir = [root URLByAppendingPathComponent:@"portal" isDirectory:YES]; + NSURL *hl2Dir = [root URLByAppendingPathComponent:@"hl2" isDirectory:YES]; + NSURL *platformDir = [root URLByAppendingPathComponent:@"platform" isDirectory:YES]; + + BOOL portalIsDir = NO; + BOOL hl2IsDir = NO; + BOOL platformIsDir = NO; + BOOL hasPortal = [fm fileExistsAtPath:portalDir.path isDirectory:&portalIsDir] && portalIsDir; + BOOL hasHL2 = [fm fileExistsAtPath:hl2Dir.path isDirectory:&hl2IsDir] && hl2IsDir; + BOOL hasPlatform = [fm fileExistsAtPath:platformDir.path isDirectory:&platformIsDir] && platformIsDir; + BOOL hasGameInfo = [fm fileExistsAtPath:gameInfo.path]; + + NSUInteger vpkCount = 0; + if (hasPortal) { + NSDirectoryEnumerator *enumerator = [fm enumeratorAtURL:portalDir + includingPropertiesForKeys:nil + options:NSDirectoryEnumerationSkipsHiddenFiles + errorHandler:^BOOL(NSURL *url, NSError *error) { + return YES; + }]; + for (NSURL *url in enumerator) { + if ([url.pathExtension.lowercaseString isEqualToString:@"vpk"]) { + ++vpkCount; + } + } + } + + if (hasGameInfo && hasPortal && hasHL2 && hasPlatform && vpkCount > 0) { + self.statusLabel.text = @"Portal folder verified."; + self.detailLabel.text = [NSString stringWithFormat: + @"Found portal/gameinfo.txt, portal/, hl2/, platform/, and %lu VPK files. N4 will persist/import the authorized data for native Source I/O.", + (unsigned long)vpkCount]; + } else { + self.statusLabel.text = @"That folder is not a complete Portal root."; + self.detailLabel.text = [NSString stringWithFormat: + @"Need portal/gameinfo.txt plus portal/, hl2/, platform/ and VPKs. Results: gameinfo=%@ portal=%@ hl2=%@ platform=%@ vpks=%lu", + hasGameInfo ? @"yes" : @"no", + hasPortal ? @"yes" : @"no", + hasHL2 ? @"yes" : @"no", + hasPlatform ? @"yes" : @"no", + (unsigned long)vpkCount]; + } + } @finally { + if (scoped) { + [root stopAccessingSecurityScopedResource]; + } + } +} + +- (UIInterfaceOrientationMask)supportedInterfaceOrientations { + return UIInterfaceOrientationMaskLandscape; +} + +- (BOOL)prefersStatusBarHidden { + return YES; +} + +@end + +@interface R360AppDelegate : UIResponder +@property(nonatomic, strong) UIWindow *window; +@end + +@implementation R360AppDelegate +- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { + self.window = [[UIWindow alloc] initWithFrame:UIScreen.mainScreen.bounds]; + self.window.rootViewController = [[R360ViewController alloc] init]; + [self.window makeKeyAndVisible]; + return YES; +} +@end + +int main(int argc, char *argv[]) { + @autoreleasepool { + return UIApplicationMain(argc, argv, nil, NSStringFromClass(R360AppDelegate.class)); + } +} From 9fdeb401efcbe7067df71c10b7905eb6b184655f Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Mon, 14 Sep 2026 22:04:54 -0400 Subject: [PATCH 131/159] ci: add unsigned native iOS IPA build --- .github/workflows/ios-native.yml | 106 +++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 .github/workflows/ios-native.yml diff --git a/.github/workflows/ios-native.yml b/.github/workflows/ios-native.yml new file mode 100644 index 0000000000..4979f7abe6 --- /dev/null +++ b/.github/workflows/ios-native.yml @@ -0,0 +1,106 @@ +name: iOS Native Bootstrap IPA + +on: + push: + branches: + - render360/ios-native + paths: + - 'ios-native/**' + - '.github/workflows/ios-native.yml' + - 'docs/IOS_NATIVE_**' + workflow_dispatch: + +permissions: + contents: read + +jobs: + build-ios-native: + runs-on: macos-latest + timeout-minutes: 30 + + steps: + - name: Checkout source and submodules + uses: actions/checkout@v6 + with: + submodules: recursive + fetch-depth: 1 + + - name: Print toolchain + run: | + set -euxo pipefail + xcodebuild -version + cmake --version + clang --version + + - name: Verify no retail Portal data is committed under native project + shell: bash + run: | + set -euo pipefail + if find ios-native -type f \( -iname '*.vpk' -o -iname '*.bsp' -o -iname '*.vtf' -o -iname '*.vcs' -o -iname '*.wav' \) -print -quit | grep -q .; then + echo 'Retail/game asset file detected under ios-native/. Do not package Portal assets in the repository or IPA.' >&2 + exit 1 + fi + + - name: Configure Xcode arm64 iPhone project + run: | + set -euxo pipefail + cmake -S ios-native -B build/ios -G Xcode \ + -DCMAKE_SYSTEM_NAME=iOS \ + -DCMAKE_OSX_SYSROOT=iphoneos \ + -DCMAKE_OSX_ARCHITECTURES=arm64 \ + -DCMAKE_OSX_DEPLOYMENT_TARGET=15.0 \ + -DRENDER360_BUNDLE_ID=com.render360.portal + + - name: Build unsigned arm64 app + run: | + set -euxo pipefail + xcodebuild \ + -project build/ios/Render360PortalIOS.xcodeproj \ + -scheme Render360Portal \ + -configuration Release \ + -sdk iphoneos \ + -destination 'generic/platform=iOS' \ + -derivedDataPath build/DerivedData \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + ONLY_ACTIVE_ARCH=NO \ + ARCHS=arm64 \ + build + + - name: Verify arm64 bundle and package unsigned IPA + shell: bash + run: | + set -euxo pipefail + APP_PATH="$(find build/DerivedData/Build/Products/Release-iphoneos -maxdepth 1 -type d -name 'Render360Portal.app' -print -quit)" + test -n "$APP_PATH" + test -f "$APP_PATH/Render360Portal" + file "$APP_PATH/Render360Portal" + lipo -info "$APP_PATH/Render360Portal" | tee build/architecture.txt + grep -q 'arm64' build/architecture.txt + + rm -rf build/ipa + mkdir -p build/ipa/Payload + ditto "$APP_PATH" build/ipa/Payload/Render360Portal.app + + cat > build/ipa/BUILD_INFO.txt < Date: Mon, 14 Sep 2026 22:05:10 -0400 Subject: [PATCH 132/159] ci: add signed native iOS IPA workflow --- .github/workflows/ios-native-signed.yml | 139 ++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 .github/workflows/ios-native-signed.yml diff --git a/.github/workflows/ios-native-signed.yml b/.github/workflows/ios-native-signed.yml new file mode 100644 index 0000000000..88150a8f89 --- /dev/null +++ b/.github/workflows/ios-native-signed.yml @@ -0,0 +1,139 @@ +name: iOS Native Signed IPA + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build-signed-ios: + runs-on: macos-latest + timeout-minutes: 35 + + steps: + - name: Checkout source and submodules + uses: actions/checkout@v6 + with: + submodules: recursive + fetch-depth: 1 + + - name: Validate signing configuration + env: + BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }} + BUILD_PROVISION_PROFILE_BASE64: ${{ secrets.BUILD_PROVISION_PROFILE_BASE64 }} + P12_PASSWORD: ${{ secrets.P12_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + IOS_TEAM_ID: ${{ vars.IOS_TEAM_ID }} + IOS_BUNDLE_ID: ${{ vars.IOS_BUNDLE_ID }} + run: | + set -euo pipefail + for value in BUILD_CERTIFICATE_BASE64 BUILD_PROVISION_PROFILE_BASE64 P12_PASSWORD KEYCHAIN_PASSWORD IOS_TEAM_ID IOS_BUNDLE_ID; do + if [ -z "${!value:-}" ]; then + echo "Missing required secret/variable: $value" >&2 + exit 1 + fi + done + + - name: Install Apple certificate and provisioning profile + env: + BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }} + BUILD_PROVISION_PROFILE_BASE64: ${{ secrets.BUILD_PROVISION_PROFILE_BASE64 }} + P12_PASSWORD: ${{ secrets.P12_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + shell: bash + run: | + set -euxo pipefail + CERTIFICATE_PATH="$RUNNER_TEMP/build_certificate.p12" + PP_PATH="$RUNNER_TEMP/build_pp.mobileprovision" + KEYCHAIN_PATH="$RUNNER_TEMP/app-signing.keychain-db" + + echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o "$CERTIFICATE_PATH" + echo -n "$BUILD_PROVISION_PROFILE_BASE64" | base64 --decode -o "$PP_PATH" + + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security import "$CERTIFICATE_PATH" -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" + security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security list-keychain -d user -s "$KEYCHAIN_PATH" + + security cms -D -i "$PP_PATH" > "$RUNNER_TEMP/profile.plist" + PROFILE_UUID="$(/usr/libexec/PlistBuddy -c 'Print :UUID' "$RUNNER_TEMP/profile.plist")" + PROFILE_NAME="$(/usr/libexec/PlistBuddy -c 'Print :Name' "$RUNNER_TEMP/profile.plist")" + mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles" + cp "$PP_PATH" "$HOME/Library/MobileDevice/Provisioning Profiles/$PROFILE_UUID.mobileprovision" + + echo "PROFILE_UUID=$PROFILE_UUID" >> "$GITHUB_ENV" + echo "PROFILE_NAME=$PROFILE_NAME" >> "$GITHUB_ENV" + echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV" + + - name: Configure signed Xcode project + env: + IOS_BUNDLE_ID: ${{ vars.IOS_BUNDLE_ID }} + run: | + set -euxo pipefail + cmake -S ios-native -B build/ios -G Xcode \ + -DCMAKE_SYSTEM_NAME=iOS \ + -DCMAKE_OSX_SYSROOT=iphoneos \ + -DCMAKE_OSX_ARCHITECTURES=arm64 \ + -DCMAKE_OSX_DEPLOYMENT_TARGET=15.0 \ + -DRENDER360_BUNDLE_ID="$IOS_BUNDLE_ID" + + - name: Build signed arm64 app + env: + IOS_TEAM_ID: ${{ vars.IOS_TEAM_ID }} + IOS_SIGNING_IDENTITY: ${{ vars.IOS_SIGNING_IDENTITY }} + shell: bash + run: | + set -euxo pipefail + SIGNING_IDENTITY="${IOS_SIGNING_IDENTITY:-Apple Development}" + xcodebuild \ + -project build/ios/Render360PortalIOS.xcodeproj \ + -scheme Render360Portal \ + -configuration Release \ + -sdk iphoneos \ + -destination 'generic/platform=iOS' \ + -derivedDataPath build/DerivedData \ + CODE_SIGN_STYLE=Manual \ + DEVELOPMENT_TEAM="$IOS_TEAM_ID" \ + CODE_SIGN_IDENTITY="$SIGNING_IDENTITY" \ + PROVISIONING_PROFILE="$PROFILE_UUID" \ + ONLY_ACTIVE_ARCH=NO \ + ARCHS=arm64 \ + build + + - name: Verify signature and package IPA + shell: bash + run: | + set -euxo pipefail + APP_PATH="$(find build/DerivedData/Build/Products/Release-iphoneos -maxdepth 1 -type d -name 'Render360Portal.app' -print -quit)" + test -n "$APP_PATH" + codesign --verify --deep --strict --verbose=2 "$APP_PATH" + codesign -d --entitlements :- "$APP_PATH" || true + lipo -info "$APP_PATH/Render360Portal" + + rm -rf build/ipa-signed + mkdir -p build/ipa-signed/Payload + ditto "$APP_PATH" build/ipa-signed/Payload/Render360Portal.app + (cd build/ipa-signed && /usr/bin/zip -qry ../Render360-Portal-iOS-signed.ipa Payload) + ls -lh build/Render360-Portal-iOS-signed.ipa + + - name: Upload signed IPA + uses: actions/upload-artifact@v4 + with: + name: Render360-Portal-iOS-signed-${{ github.run_number }} + path: build/Render360-Portal-iOS-signed.ipa + if-no-files-found: error + retention-days: 14 + + - name: Clean up signing material + if: ${{ always() }} + shell: bash + run: | + if [ -n "${KEYCHAIN_PATH:-}" ] && [ -f "$KEYCHAIN_PATH" ]; then + security delete-keychain "$KEYCHAIN_PATH" || true + fi + if [ -n "${PROFILE_UUID:-}" ]; then + rm -f "$HOME/Library/MobileDevice/Provisioning Profiles/$PROFILE_UUID.mobileprovision" || true + fi From 12a8d5e56e4ca855188aa209befdda2a0c90bdcd Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Mon, 14 Sep 2026 22:06:08 -0400 Subject: [PATCH 133/159] docs: add native iOS zero-to-complete roadmap --- docs/IOS_NATIVE_MASTER_PLAN.md | 488 +++++++++++++++++++++++++++++++++ 1 file changed, 488 insertions(+) create mode 100644 docs/IOS_NATIVE_MASTER_PLAN.md diff --git a/docs/IOS_NATIVE_MASTER_PLAN.md b/docs/IOS_NATIVE_MASTER_PLAN.md new file mode 100644 index 0000000000..e963ab28ae --- /dev/null +++ b/docs/IOS_NATIVE_MASTER_PLAN.md @@ -0,0 +1,488 @@ +# Render360 Portal Native iOS — Zero-to-Complete Master Plan + +## Decision + +`render360/ios-native` is the primary iPhone direction. The WebAssembly/Safari branches remain preserved as engineering references and are not deleted. + +The objective is a **real arm64 Source/Portal application for iPhone**, built by Xcode/clang, with normal iOS filesystem access, native threading, and an iOS graphics backend. The IPA must never contain Valve retail Portal data. + +## Non-negotiable constraints + +1. Do not replace Source with a mock renderer, reimplementation, webpage, video playback, or static demo. +2. Keep the existing Source engine code as authoritative wherever practical. +3. Do not commit or redistribute retail Portal VPKs/BSPs/textures/sounds/binaries. +4. The user supplies their own legally owned Portal data at runtime. +5. iPhone 11 is the performance floor for the first production profile. +6. Landscape play is the primary UX. +7. Memory policy is current-map-first. Do not preload the whole game or a chain of previous/future maps. +8. Keep the native code path independent from Emscripten/WebAssembly. Browser-specific code must be excluded from iOS builds. +9. Every phase must have an acceptance test and CI guard where possible. +10. Do not call a phase complete merely because it compiles. Device behavior must be measured. + +## Repository strategy + +No new fork is required. This repository already contains the Source tree plus the `thirdparty`, `ivp`, and `lib` submodules used by the port. + +Branch roles: + +- `render360/iphone-baseline`: frozen WebAssembly/Phase 3 reference. +- `render360/phase4-growable-arraybuffers`: experimental WebAssembly reference. +- `render360/ios-native`: primary native iOS development branch. + +The native branch may borrow engine fixes and memory-policy ideas from the web branches, but should not inherit browser-only architecture just because it already exists. + +--- + +# N0 — Native arm64 application and IPA pipeline + +### Deliverables + +- CMake/Xcode iOS application target. +- arm64-only iPhoneOS Release build. +- Native UIKit bootstrap screen. +- iOS Files folder picker that can verify a Portal root. +- Unsigned IPA generated in GitHub Actions. +- Optional manually triggered signed IPA pipeline using GitHub Secrets. +- CI check rejecting obvious retail Portal files under `ios-native/`. + +### Acceptance + +- CI produces `Render360-Portal-iOS-unsigned.ipa`. +- Binary architecture contains arm64. +- App starts without WebKit/WebAssembly. +- Folder picker can distinguish a complete Portal root from an invalid directory. + +--- + +# N1 — SDL2 iOS host + +### Objective + +Replace the temporary UIKit-only host with SDL2 as the game-facing platform layer while retaining UIKit only for import/setup UI when useful. + +### Work + +- Add a pinned SDL2 version/xcframework source path. +- Create a native SDL iOS window in landscape. +- Confirm OpenGL ES 3.0 context creation for bring-up. +- Wire touch, keyboard where available, game controllers, audio device creation, lifecycle pause/resume, and safe-area information. +- Keep UIKit document import outside the render loop. +- Verify app suspend/resume does not leak the render context or audio device. + +### Acceptance + +- SDL event loop runs on an actual iPhone. +- A clear frame presents continuously. +- Touch and controller events are visible in diagnostics. +- Audio device opens and outputs a generated test tone or silence callback without underruns. + +--- + +# N2 — Native Source foundation libraries + +### Objective + +Compile the low-level Source stack as arm64 iOS static libraries before attempting the full engine. + +### Initial library order + +1. tier0 +2. tier1 +3. mathlib +4. vstdlib +5. appframework +6. filesystem_stdio +7. datacache +8. inputsystem platform-independent pieces +9. vphysics/IVP + +### Work + +- Create iOS compile definitions and platform headers. +- Isolate `_WIN32`, Linux/X11, GLX, Emscripten and unsupported POSIX assumptions. +- Replace unsupported APIs with small iOS platform adapters, not widespread `#ifdef` hacks. +- Compile with libc++, C++17, arm64 and hidden-by-default visibility where practical. +- Use native pthreads/std::thread; do not carry `PROXY_TO_PTHREAD` concepts into iOS. + +### Acceptance + +- Static libraries link into the native host. +- Mathlib deterministic tests pass. +- filesystem_stdio can open/read/seek native files in app-accessible storage. +- IVP smoke test creates and steps a small physics world. + +--- + +# N3 — Static Source module registry + +### Objective + +Remove dependence on browser SIDE_MODULEs and arbitrary `.so` loading. + +### Architecture + +Create an iOS module registry that maps Source logical module names to linked factories: + +```text +engine -> Engine_CreateInterface +filesystem_stdio -> FileSystem_CreateInterface +materialsystem -> MaterialSystem_CreateInterface +shaderapidx9 -> IOSShaderAPI_CreateInterface +studiorender -> StudioRender_CreateInterface +vphysics -> VPhysics_CreateInterface +client -> Client_CreateInterface +server -> Server_CreateInterface +GameUI -> GameUI_CreateInterface +``` + +### Work + +- Add `platform/ios/source_module_registry.*`. +- Intercept `Sys_LoadModule`, `Sys_UnloadModule`, `Sys_GetFactory`, `Sys_GetFactoryThis` for iOS. +- Normalize names (`engine`, `engine.dll`, `libengine.so`) to a canonical logical ID. +- Preserve module load ordering and interface-version checks. +- Make unknown modules fail loudly with the requested interface/version in diagnostics. + +### Acceptance + +- Engine module lookup works without `dlopen`. +- No Source gameplay module is loaded from arbitrary executable files. +- Factory/interface version mismatches identify the exact module and requested interface. + +--- + +# N4 — Portal data importer and native VPK access + +### Objective + +Give Source ordinary native access to the user's Portal data. + +### Work + +- Use UIDocumentPicker/Files to select the Portal root or a supported archive import. +- Validate at minimum `portal/gameinfo.txt`, `portal/`, `hl2/`, `platform/`, and required VPK directory files. +- Build an import manifest with file sizes, hashes where useful, and version fingerprints. +- Decide per provider between persistent security-scoped access and copying required content to Application Support. +- Never load an entire VPK into RAM. +- Use `open/pread` or `fopen/fseek/fread`; consider `mmap` only for bounded/read-only windows after profiling. +- Preserve Source's VPK logic when possible instead of inventing a duplicate asset database. + +### Acceptance + +- `gameinfo.txt` is found natively. +- Directory VPK and numbered archive VPKs open successfully. +- Known files can be read by Source's filesystem layer. +- Memory does not rise by the full size of a VPK merely because it was mounted. + +--- + +# N5 — Source launcher and PreInit + +### Objective + +Reach a clean native engine `PreInit` with all required Source interfaces available. + +### Work + +- Port launcher startup away from desktop executable-path assumptions. +- Set the native game/base directory explicitly. +- Initialize filesystem, engine, material system, input and other factories through the static registry. +- Add one latest-only startup checkpoint like the web diagnostics: keep the newest actionable state, not megabytes of logs. +- Make expected optional desktop modules nonfatal only after proving Portal does not need them. + +### Acceptance + +- `PreInit` succeeds. +- The process enters the Source main loop rather than returning cleanly after shader/module startup. +- Failure diagnostics identify the precise last subsystem. + +--- + +# N6 — GLES 3 / ToGL compatibility renderer + +### Objective + +Get first real Source pixels quickly without beginning with a total Metal rewrite. + +### Rules + +OpenGL ES 3.0 is a temporary compatibility backend. Keep it isolated behind the Source/ToGL abstraction so Metal can replace it later. + +### Work + +- Create EAGL/SDL GLES3 context. +- Audit desktop OpenGL assumptions: fixed-function calls, base-vertex variants, texture-level queries, buffer mapping, sync/fence APIs, framebuffer paths, shader language differences and unsupported extension probes. +- Implement compatibility shims only where semantics are well-defined. +- Translate/adjust GLSL for GLES 3.0 as needed. +- Keep render-state validation available in debug builds. + +### Acceptance + +- Source renderer creates a device/context. +- A Source clear/present path works. +- Material system can create textures/buffers/shaders without using browser/WebGL shims. +- No fake frame or prerecorded output is used. + +--- + +# N7 — Portal `background1` + +### Objective + +Render the real Portal menu/background map. + +### Work + +- Load only the menu map and assets required by it. +- Fix missing material/shader/model dependencies one family at a time. +- Add shader/material diagnostics that identify exact missing family/path. +- Avoid bulk-staging every shader or texture. + +### Acceptance + +- `background1` renders real geometry. +- Portal menu can become interactive. +- No test chamber BSP is resident while sitting at the menu. + +--- + +# N8 — First chamber + +### Objective + +Load and render `testchmb_a_00` with physics and gameplay code active. + +### Work + +- Load client/server/game rules. +- Spawn player and portal-game entities. +- Validate physics collision, doors/buttons/cubes, basic particles, sound emitters and save state. +- Fix only the dependencies required by this chamber before expanding coverage. + +### Acceptance + +- Chamber 00 loads from user-owned Portal data. +- Player can stand/move and interact with the room. +- Physics simulation remains stable on arm64. + +--- + +# N9 — iPhone controls + +### Objective + +Create usable Portal controls without changing Source gameplay semantics. + +### Input layers + +- Left virtual stick: move. +- Right look region/stick: camera. +- Jump. +- Use/interact. +- Blue/orange portal fire. +- Crouch where required. +- Pause/menu. +- Optional gyro aiming. +- Native game controller mapping using SDL/GameController. + +### Rules + +- Touch overlay must scale with safe areas and orientation. +- Do not bake input logic directly into Portal gameplay code; map it into Source input actions. +- Support hiding touch controls when a controller is active. + +### Acceptance + +- Chamber 00 is playable with touch. +- Controller is playable with conventional bindings. +- Input latency is measured on device. + +--- + +# N10 — Current-map-only native memory management + +### Objective + +Carry over the useful web memory strategy using native filesystem/memory primitives. + +### Policy + +```text +engine + shared assets + current BSP + bounded reusable cache +``` + +not + +```text +background1 + chamber00 + chamber01 + chamber02 + ... +``` + +### Work + +- Track active map explicitly. +- At changelevel, let Source release old world references first. +- Purge unreferenced models. +- Uncache unused materials/textures conservatively. +- Never purge shared resources still referenced by UI or the next map. +- Add memory telemetry using `task_info`, `os_proc_available_memory` where appropriate/available, allocator stats and subsystem counters. +- Establish warning/critical thresholds from device measurements rather than guesses. + +### Acceptance + +- Transition from chamber 00 → 01 does not retain chamber 00 world residency. +- Repeated map transitions do not create monotonic memory growth. +- iPhone 11 stays below the observed jetsam danger region with safety headroom. + +--- + +# N11 — Hidden loading / perceived-continuity transitions + +### Objective + +Hide unavoidable map load time without preloading multiple full maps. + +### Techniques + +- Reuse Portal's elevator/airlock/door language at known changelevel boundaries. +- Freeze/preserve the last valid presented frame if safe. +- Start a lightweight native transition animation before old-map teardown. +- Keep audio/ambient transition cues alive where safe. +- Perform map swap behind closed doors/fade. +- Reveal only once the new map has reached a safe first-frame state. +- Optionally pre-read tiny manifest/header ranges, not entire next maps. + +### Acceptance + +- Fast map transitions do not show an unnecessary loading screen. +- Slow transitions show a responsive Aperture-style transition, never a frozen half-rendered frame. +- Transition UI itself is destroyed/released after opening. +- No full next-map prefetch is enabled on iPhone 11 without measured memory headroom. + +--- + +# N12 — iPhone 11 performance pass + +### Measure + +- FPS average and 1% low. +- CPU frame time. +- GPU frame time where Metal/GPU tools permit. +- resident memory. +- peak transition memory. +- asset read latency. +- shader compilation stalls. +- audio underruns. +- thermal state. + +### Optimize + +- texture formats and mip policy. +- anisotropy/MSAA defaults. +- dynamic shadows and expensive post effects. +- shader permutation cache. +- VPK read granularity. +- renderer state changes. +- particle budgets. +- physics step cost. + +Do not reduce Portal gameplay fidelity merely to increase a benchmark without documenting the tradeoff. + +### Acceptance + +- Stable, repeatable test route: menu → chamber 00 → chamber 01. +- Performance and memory numbers recorded in CI/device test notes. + +--- + +# N13 — Metal migration + +### Objective + +Replace the deprecated GLES compatibility backend incrementally after gameplay works. + +### Architecture + +Introduce a platform-neutral Source graphics backend boundary with implementations: + +```text +Source/ToGL-facing API + ├── GLES3 bring-up backend + └── Metal backend +``` + +### Migration order + +1. swapchain/presentation +2. vertex/index buffers +3. texture/sampler formats +4. render targets/depth +5. shader compilation/reflection pipeline +6. state objects +7. synchronization +8. occlusion/timers where needed +9. performance-specific batching/caching + +### Acceptance + +- Metal path renders the same baseline scenes as GLES. +- GLES can remain temporarily as a debug/reference backend until Metal reaches feature parity. + +--- + +# N14 — Production IPA and release automation + +### Outputs + +- unsigned IPA artifact for external signing/testing. +- signed development/ad-hoc IPA when repository signing secrets are configured. +- symbol archive/dSYM. +- build manifest including commit, architecture, compiler/Xcode version and feature flags. + +### Signing inputs + +GitHub Secrets: + +- `BUILD_CERTIFICATE_BASE64` +- `P12_PASSWORD` +- `BUILD_PROVISION_PROFILE_BASE64` +- `KEYCHAIN_PASSWORD` + +GitHub Variables: + +- `IOS_TEAM_ID` +- `IOS_BUNDLE_ID` +- optional `IOS_SIGNING_IDENTITY` (defaults to Apple Development in the workflow) + +### Acceptance + +- CI build is reproducible from a clean checkout with submodules. +- Signed IPA installs on a registered device covered by the provisioning profile. +- IPA contains engine/app code but no retail Portal assets. + +--- + +# Definition of playable native milestone + +A build is not called playable until all are true: + +- native arm64 executable; +- no WebAssembly/WebView dependency for engine execution; +- user-owned Portal data imported/authorized; +- Source `PreInit` and main loop active; +- real `background1` rendered; +- `testchmb_a_00` loads; +- movement/look/use/portal-fire input works; +- audio works; +- map transition unload policy works; +- no retail data is bundled in the repository/IPA. + +# Definition of production-ready milestone + +In addition to playable: + +- iPhone 11 memory/thermal profile is stable on a repeatable route; +- chambers progress without resource accumulation; +- hidden-loading transitions are robust; +- crash diagnostics preserve only the latest actionable event plus a small state snapshot; +- Metal path reaches required feature parity or the release explicitly documents the temporary GLES dependency; +- signed CI pipeline is documented and reproducible. From 6fee9d26fbe12743a4ca54ab8b3a69774b9e535d Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Mon, 14 Sep 2026 22:07:29 -0400 Subject: [PATCH 134/159] docs: add zero-to-complete native iOS AI build prompts --- docs/IOS_NATIVE_AI_PROMPTS.md | 520 ++++++++++++++++++++++++++++++++++ 1 file changed, 520 insertions(+) create mode 100644 docs/IOS_NATIVE_AI_PROMPTS.md diff --git a/docs/IOS_NATIVE_AI_PROMPTS.md b/docs/IOS_NATIVE_AI_PROMPTS.md new file mode 100644 index 0000000000..b1309b2d91 --- /dev/null +++ b/docs/IOS_NATIVE_AI_PROMPTS.md @@ -0,0 +1,520 @@ +# Render360 Portal Native iOS — AI Build Prompts + +These prompts are designed for incremental implementation on branch `render360/ios-native`. Run them in order. Do not ask the agent to regenerate the entire project every time; each phase must inspect and preserve previous working gates. + +## Global preamble — prepend to every implementation prompt + +```text +Repository: matthewcodergamer/source-engine-render360 +Branch: render360/ios-native + +You are the lead engine/platform programmer for the native iOS port of Render360 Portal. + +Read before editing: +- ios-native/README.md +- docs/IOS_NATIVE_MASTER_PLAN.md +- docs/IOS_NATIVE_AI_PROMPTS.md +- .gitmodules +- the current native iOS workflow files +- all files touched by the previous native phase + +Hard constraints: +1. Build a real arm64 iOS Source/Portal application. Do not replace Source with a mock renderer, webpage, video, static scene, or fake gameplay. +2. Do not commit, download into the repository, or bundle Portal retail VPK/BSP/texture/audio assets. The user supplies their own legally owned game files at runtime. +3. Keep the WebAssembly branches preserved. Do not merge browser architecture into native iOS simply because it already exists. +4. iPhone 11 is the first performance floor. +5. Preserve current-map-only residency: menu background only at menu; current BSP during gameplay; bounded shared cache; no cumulative previous maps; no full future-map prefetch on the iPhone 11 profile unless measurements prove safe headroom. +6. Prefer narrow platform adapters over invasive rewrites of Source. +7. Use native iOS filesystem/threading primitives. No MEMFS, WORKERFS, SharedArrayBuffer, PROXY_TO_PTHREAD, COOP/COEP, service worker, or JavaScript File handoff in the native runtime. +8. Every failure must leave a concise latest actionable diagnostic. Do not accumulate huge scrolling logs in memory. +9. Do not mark a phase complete just because it compiles. State what is CI-proven, simulator-proven, and physical-device-proven separately. +10. Keep commits phase-scoped and update documentation/acceptance checks when architecture changes. + +Before coding, inspect the exact current repository state and identify the smallest correct change. After coding, run/static-check what is possible, inspect CI, fix concrete failures, and report remaining physical-device gates. +``` + +--- + +# Prompt 0 — Audit and native branch baseline + +```text +Using the global preamble, audit render360/ios-native before adding more engine code. + +Tasks: +- Inventory the Source modules, submodules and third-party libraries already present. +- Identify all Emscripten-only compile definitions/files that must be excluded from native iOS. +- Confirm the native N0 CMake/Xcode bootstrap and both IPA workflows are internally consistent. +- Confirm retail Portal assets are not present under ios-native/ and are not referenced as downloadable build inputs. +- Create/update docs/IOS_NATIVE_PORTING_AUDIT.md with: + - reusable engine code + - browser-only code to exclude + - native adapters needed + - module dependency order + - graphics blockers + - filesystem blockers + - audio/input blockers + - licensing/asset boundary +- Fix any obvious bootstrap CI bugs found during the audit. + +Acceptance: +- N0 unsigned IPA workflow reaches artifact creation. +- Branch still contains no retail Portal data. +- Audit names the exact source files/directories targeted in N1/N2. +``` + +# Prompt 1 — N1 SDL2 iOS host + +```text +Using the global preamble, implement N1: SDL2 iOS host. + +Requirements: +- Pin a known SDL2 release and integrate it in a reproducible way suitable for GitHub Actions/macOS runners. Prefer the official SDL iOS/Xcode/xcframework path or a source build that can produce the iOS framework deterministically. +- Do not vendor an opaque prebuilt binary without provenance/version documentation. +- Preserve the UIKit folder-import bootstrap; UIKit may own setup/import UI, but the game-facing runtime uses SDL. +- Create a landscape SDL window on iPhone. +- Bring up an OpenGL ES 3.0 context only as the renderer bootstrap target. Keep the platform interface backend-neutral so Metal can replace GLES later. +- Implement SDL lifecycle handling for background/foreground, interruptions and orientation. +- Wire touch events and game-controller discovery into a small input diagnostic overlay/log. +- Open the audio device and run a safe test callback with no retail audio assets. +- Add N1 CI compile checks. + +Do not yet try to compile the full Source engine. + +Acceptance: +- Physical iPhone can present a continuously clearing SDL GLES3 frame. +- Touch event coordinates and controller connect/disconnect events appear in latest-only diagnostics. +- Audio device opens cleanly. +- Suspend/resume does not crash or recreate resources endlessly. +``` + +# Prompt 2 — N2 Source foundation libraries + +```text +Using the global preamble, implement N2: native arm64 Source foundation libraries. + +Compile incrementally as static libraries in this order unless dependency analysis proves a slightly different ordering is required: +- tier0 +- tier1 +- mathlib +- vstdlib +- appframework +- filesystem_stdio +- datacache +- inputsystem platform-neutral portions +- IVP/vphysics foundation + +Rules: +- Create an iOS platform configuration layer rather than spraying unrelated #ifdefs through Source. +- Exclude Emscripten/WebAssembly code from the iOS target. +- Replace unsupported desktop/Linux APIs with small native adapters. +- Use libc++, C++17, arm64, native pthread/std::thread. +- Do not hide compiler errors with blanket warning suppression. +- Keep asserts in debug builds. +- Add small deterministic tests/smoke executables or unit-style calls for mathlib, filesystem read/seek and IVP stepping. +- Do not add Portal retail files as fixtures. Create synthetic text/binary fixtures in the repository. + +Acceptance: +- All listed foundation libraries build for iphoneos arm64. +- Filesystem test reads/seeks a synthetic file in app-accessible storage. +- Mathlib test passes deterministic vector/matrix checks. +- IVP test creates, steps and destroys a trivial physics world without leaks/crash. +``` + +# Prompt 3 — N3 static Source module registry + +```text +Using the global preamble, implement N3: static Source module registry for iOS. + +Problem: +Desktop/web startup expects modules such as engine, filesystem_stdio, materialsystem, shaderapidx9, client and server to be dynamically loaded. Native iOS should link the required modules into the app and resolve Source factories through a registry. + +Implement: +- platform/ios/source_module_registry.h/.cpp or an equivalent focused location. +- Canonical module-name normalization accepting forms such as engine, engine.dll, libengine.so but mapping them to one logical module ID. +- Registry entries for each linked module factory as the modules become available. +- iOS implementations/hooks for Sys_LoadModule, Sys_UnloadModule, Sys_GetFactory and related factory lookup paths. +- Reference counting/lifetime semantics sufficient for Source expectations even though code is statically linked. +- Exact diagnostic on unknown module/interface version. +- Tests using fake factories before full engine integration. + +Do not use arbitrary executable-code dlopen as a workaround. + +Acceptance: +- Fake module-registry tests pass. +- Linked Source foundation module factories resolve by the same logical names used by engine startup. +- Unknown modules fail with module name + requested interface version. +``` + +# Prompt 4 — N4 Portal importer and native VPK I/O + +```text +Using the global preamble, implement N4: user-owned Portal data import/authorization and native VPK access. + +Requirements: +- Extend the Files folder picker from N0. +- Validate a selected Portal root using portal/gameinfo.txt plus portal/, hl2/, platform/ and expected VPK structure. +- Build a small import manifest with relative paths, sizes and useful fingerprints; do not hash multi-gigabyte content blindly if it creates long waits. +- Decide and implement the safest iOS storage model: + A. persistent security-scoped access if reliable for the chosen provider; or + B. explicit copy/import into Application Support/Render360Portal/Game. +- If copying, provide progress and free-space checks and avoid unnecessary duplicate temporary copies. +- Native Source filesystem/VPK path must use seekable file descriptors/FILE streams and range reads. +- Never read an entire VPK or BSP into RAM merely to mount it. +- Preserve Valve/Source VPK parsing code wherever possible. +- Add a verifier that opens the directory VPK and reads a known metadata/small asset entry chosen from the user's files at runtime, not from repository fixtures. + +Security/legal constraint: +No retail asset is committed or uploaded to GitHub Actions. + +Acceptance: +- Imported/authorized Portal root survives the chosen persistence model. +- Source filesystem can open gameinfo.txt and VPK directory/archive parts. +- Mounting a large VPK does not allocate memory close to the VPK's total size. +``` + +# Prompt 5 — N5 native Source launcher + PreInit + +```text +Using the global preamble, implement N5: native Source launcher through successful PreInit/main-loop entry. + +Tasks: +- Port the launcher away from browser/native-executable path assumptions. +- Explicitly establish base dir/game dir from the native imported Portal root. +- Link and register the modules required through this startup stage. +- Initialize filesystem_stdio, engine-facing app framework, input and material interfaces as required. +- Keep latest-only phase checkpoints. Suggested checkpoints: + ios-launcher-enter + filesystem-ready + gameinfo-found + factories-ready + engine-create-enter + engine-create-done + engine-preinit-enter + engine-preinit-done + engine-mainloop-enter +- If Source returns early, capture the return code and the last failing subsystem rather than reporting generic app success. +- Preserve normal Source shutdown semantics. + +Do not begin faking renderer success. If renderer initialization is the blocker, stop at the exact renderer boundary and hand it to N6. + +Acceptance: +- Source reaches the expected main-loop entry on device or diagnostics identify the exact renderer-dependent boundary. +- No WebAssembly or .data package startup remains. +``` + +# Prompt 6 — N6 GLES3/ToGL compatibility renderer + +```text +Using the global preamble, implement N6: real Source rendering bring-up using native OpenGL ES 3.0 as the temporary compatibility backend. + +Start by auditing the existing ToGL/OpenGL calls encountered on the web port, including known desktop-only assumptions such as: +- glAlphaFunc / fixed-function remnants +- glColor4f +- glClientActiveTexture +- glDrawRangeElementsBaseVertex / base-vertex behavior +- glGetTexLevelParameteriv +- framebuffer/blit extension differences +- buffer mapping/storage +- fences/sync +- texture formats/compression +- GLSL desktop vs GLSL ES syntax/precision + +Implementation rules: +- Do not report unsupported extensions as supported just to bypass checks. +- Implement compatibility only when the Source semantics can be preserved. +- Add a capabilities table produced at runtime. +- Separate platform GL/GLES adaptation from material/game logic. +- Create debug validation around shader compile/link and framebuffer completeness. +- Keep OpenGL ES as a bring-up backend; structure it so a Metal backend can be added later. + +Acceptance: +- Native Source creates the renderer and presents real Source-generated frames. +- No browser WebGL/ToGL JS bridge exists. +- Shader/material failures name the exact shader/material and GLES error. +``` + +# Prompt 7 — N7 real Portal background1 + +```text +Using the global preamble, implement N7: render Portal background1 and menu using user-owned retail data. + +Memory policy: +- background1 and menu-required resources only. +- zero chamber BSP preloads. +- load shader/material families only as requested. + +Tasks: +- Run Source's real map loading for background1. +- Resolve missing materials/models/shaders iteratively from the user's VPKs. +- Add latest missing-resource diagnostics. +- Ensure menu UI/input can appear over the rendered background. +- Track resident memory before map load, after world load and after first stable frame. + +Acceptance: +- Real background1 world geometry is visible on iPhone. +- Menu is interactive. +- No test chamber map is resident at menu. +``` + +# Prompt 8 — N8 first playable chamber + +```text +Using the global preamble, implement N8: testchmb_a_00 with real client/server gameplay. + +Bring up: +- client and server modules through the static registry +- player spawn +- world collision +- VPhysics/IVP +- buttons/doors/cubes required by chamber 00 +- sound emitter system +- required particles/decals +- portal game rules and portal placement dependencies needed by this map + +Do not globally enable every Source subsystem if chamber 00 does not require it yet. Expand from measured missing dependencies. + +Acceptance: +- testchmb_a_00 loads from user-owned data. +- player can move and collide with the world. +- basic chamber interactions work. +- memory snapshot is recorded and compared with menu. +``` + +# Prompt 9 — N9 touch + controller input + +```text +Using the global preamble, implement N9: production-quality iPhone input. + +Touch UI: +- left movement stick +- right look region/stick +- jump +- use/interact +- primary portal +- secondary portal +- crouch if required +- pause/menu +- optional gyro toggle + +Rules: +- map controls into Source input actions; do not hardwire gameplay changes. +- support safe areas and all landscape orientations. +- multi-touch must allow move + look + action simultaneously. +- automatically hide/minimize touch UI when a hardware controller is active, with user override. +- add dead zones/sensitivity/acceleration settings. +- keep UI draw cost and allocations near zero per frame. + +Acceptance: +- chamber 00 is fully navigable with touch. +- standard controller mapping works. +- no stuck-touch state after interruption/control-center/backgrounding. +``` + +# Prompt 10 — N10 current-map-only memory lifecycle + +```text +Using the global preamble, implement and verify N10: current-map-only memory behavior. + +Required behavior: +MENU = engine/shared + background1 only. +CHAMBER = engine/shared + active BSP + bounded cache. +TRANSITION = temporary overlap only where Source requires it; old map released immediately after safe changelevel boundary. + +Tasks: +- Instrument map lifecycle and native resident memory. +- Confirm Source releases old world/model references. +- Purge unreferenced models after safe shutdown boundary. +- Uncache only unused materials/textures; never flush live shared UI/common assets. +- Detect monotonic accumulation over repeated map changes. +- Add debug command/overlay showing active map, resident memory, model/material counts and cache budget. +- Do not implement whole-next-map preload. + +Test route: +background1 -> testchmb_a_00 -> testchmb_a_01 -> testchmb_a_00 (developer command if necessary) repeated at least 3 cycles. + +Acceptance: +- old BSP/world residency does not remain after transition. +- repeated route does not show unbounded memory growth. +``` + +# Prompt 11 — N11 hidden loading transitions + +```text +Using the global preamble, implement N11: hide map loading without trading away the iPhone 11 memory savings. + +Use Portal/Aperture visual language: +- elevator/airlock door close +- short fade/lighting cue if appropriate +- preserve last valid frame only if doing so does not duplicate a large render target +- tiny native transition animation remains responsive while old map unload/new map load happens +- open only after first safe new-map frame +- destroy transition UI/resources immediately after completion + +Optimization rules: +- no full next-map preload +- optional pre-read limited to tiny headers/manifests or known small shared resources after measuring benefit +- if transition is faster than the mask threshold, avoid showing a distracting fake loading sequence +- if load is slow, keep animation responsive and never expose half-loaded geometry +- audio transition cues may continue if they do not retain old map resources + +Acceptance: +- player does not see long frozen black screens between the first tested chambers. +- transition mechanism adds negligible steady-state memory after it closes. +- current-map-only tests still pass. +``` + +# Prompt 12 — N12 iPhone 11 performance/thermal pass + +```text +Using the global preamble, implement N12: measured iPhone 11 optimization. + +Create a repeatable benchmark route and collect: +- average FPS +- 1% low FPS or equivalent frame-time percentile +- CPU frame time +- GPU frame time where available +- resident memory and peak transition memory +- texture/resource cache sizes +- VPK read latency +- shader compile stalls +- thermalState changes +- audio underruns + +Then optimize in evidence-driven order. Candidate areas: +- internal render resolution and dynamic resolution option +- texture mip/residency policy +- ASTC/native compression opportunities without redistributing assets +- MSAA/anisotropy defaults +- shadow/post-processing cost +- shader permutation cache +- render-state churn +- VPK read size/cache policy +- particle limits +- physics hot spots + +Do not quote FPS gains unless measured on device using the same route/settings. + +Acceptance: +- publish docs/IOS_NATIVE_IPHONE11_PROFILE.md with before/after measurements and exact settings. +``` + +# Prompt 13 — N13 Metal renderer migration + +```text +Using the global preamble, begin N13 only after GLES gameplay is proven. + +Goal: +Replace deprecated OpenGL ES incrementally with Metal while keeping Source gameplay, filesystem, physics and resource-management code unchanged. + +Architecture: +Create a backend boundary so both GLES3 (reference) and Metal can render the same Source commands during migration. + +Order: +1. presentation/swapchain +2. command buffers and frame lifecycle +3. vertex/index buffers +4. textures/samplers and format mapping +5. render targets/depth-stencil +6. shader translation/compilation/reflection strategy +7. pipeline/state caches +8. synchronization/fences +9. queries/timers where Source depends on them +10. performance specialization for Apple GPU tile-based rendering + +Rules: +- keep visual comparison captures for background1/chamber00. +- do not remove GLES until Metal has required feature parity and debugging value is exhausted. +- avoid runtime shader translation stalls where an offline/cacheable pipeline can be built from user-owned shader inputs without redistributing retail assets. + +Acceptance: +- Metal renders background1 and chamber00 to functional parity. +- Metal becomes default only after memory/frame-time measurements beat or justify replacing GLES. +``` + +# Prompt 14 — N14 production IPA/release pipeline + +```text +Using the global preamble, finish N14: production-oriented IPA automation. + +Tasks: +- produce unsigned IPA artifact on every relevant ios-native push. +- produce signed development/ad-hoc IPA through manual workflow when signing secrets are configured. +- upload dSYM/symbol artifacts separately. +- generate build manifest with commit, Xcode/clang version, architecture, bundle ID, renderer backend, feature gates and retail_assets_bundled=false. +- verify code signature for signed build. +- verify IPA contains no VPK/BSP/VTF/VCS/WAV retail assets. +- add clear installation notes for signed vs unsigned IPA. +- never print certificate/profile secret material. + +Acceptance: +- clean GitHub-hosted macOS runner produces artifacts reproducibly. +- signed IPA installs on a device included by its provisioning profile. +- unsigned IPA is clearly labeled as requiring external signing. +``` + +--- + +# Bug-fix prompt — use whenever a device run fails + +```text +Work on matthewcodergamer/source-engine-render360, branch render360/ios-native. +Read the native master plan and the current phase before changing code. + +A physical iPhone run failed. Treat the supplied newest diagnostic/checkpoint as authoritative. Do not respond by increasing memory limits, disabling asserts globally, skipping the failing subsystem, or substituting a fake renderer. + +Procedure: +1. Identify the last confirmed successful phase. +2. Identify the first failed/returned subsystem. +3. Inspect the exact native code path and current CI artifacts. +4. Reproduce with a minimal synthetic test where possible. +5. Fix the root cause with the smallest platform-correct change. +6. Preserve current-map-only memory policy and retail-asset boundary. +7. Add a regression guard/checkpoint/test. +8. Build in GitHub Actions and inspect concrete compiler/linker/test failures until CI is green. +9. Report separately what is CI-proven and what still requires physical iPhone verification. + +Do not accumulate giant logs. Keep the latest actionable error plus a compact native state snapshot. +``` + +# Gauntlet review prompt — run after each major milestone + +```text +Act as a blind senior engine-port reviewer. Review the current render360/ios-native phase without assuming the implementation is correct. + +Attack these failure classes: +- accidentally using browser/WebAssembly infrastructure in native code +- bundling or downloading retail Portal data +- fake renderer/gameplay shortcuts +- whole-VPK or whole-map memory copies +- cumulative map residency +- stale resources after changelevel +- desktop OpenGL assumptions hidden behind extension lies +- unsafe static module lifetime/factory resolution +- iOS lifecycle/backgrounding crashes +- touch input stuck states +- code-signing secret exposure +- CI that packages an IPA but does not verify arm64/signature/content +- claims of FPS/memory wins without device measurements + +Return: +A. blockers +B. high-risk bugs +C. missing regression tests +D. memory/performance risks +E. legal/asset-boundary risks +F. exact files/changes required before the phase can be called complete. + +Then implement the justified fixes, rerun CI, and repeat the review once. +``` + +# Master autonomous continuation prompt + +```text +Continue Render360 Portal native iOS from its current completed gate to the next incomplete gate in docs/IOS_NATIVE_MASTER_PLAN.md. + +Do not skip gates. Do not redo completed work unnecessarily. Inspect the branch and CI first, choose the next smallest production-valid milestone, implement it, add/adjust regression checks, run GitHub Actions, fix concrete failures, update docs, and stop only when either: +- the phase's CI-verifiable acceptance conditions pass and the next step requires a physical iPhone/device-only result, or +- a genuinely external requirement is missing (for example Apple signing credentials). + +Never bundle Portal retail data. Never replace Source with a mock. Keep the iPhone 11 current-map-only memory policy intact. +``` From 9732688a2e3c6f49842606dc42428b19f251279b Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Mon, 14 Sep 2026 22:07:47 -0400 Subject: [PATCH 135/159] docs: add iOS native build, IPA, and signing guide --- docs/IOS_NATIVE_BUILD_AND_SIGNING.md | 143 +++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 docs/IOS_NATIVE_BUILD_AND_SIGNING.md diff --git a/docs/IOS_NATIVE_BUILD_AND_SIGNING.md b/docs/IOS_NATIVE_BUILD_AND_SIGNING.md new file mode 100644 index 0000000000..61b13c2ef5 --- /dev/null +++ b/docs/IOS_NATIVE_BUILD_AND_SIGNING.md @@ -0,0 +1,143 @@ +# Render360 Portal Native iOS — Build, IPA, and Signing Guide + +## What the repository builds now + +`render360/ios-native` contains an N0 native arm64 iPhone bootstrap and two GitHub Actions workflows. + +- `.github/workflows/ios-native.yml` builds an unsigned arm64 `.app`, packages it as `Render360-Portal-iOS-unsigned.ipa`, and uploads it as an artifact. +- `.github/workflows/ios-native-signed.yml` is manual-only and builds a signed IPA when Apple signing material is configured. + +The N0 IPA proves the native toolchain/app packaging path. It is **not yet a playable Portal build**. Gameplay is enabled incrementally by N1–N14 in the master roadmap. + +## Why no new fork is required + +The existing repository already contains the Source code and Git submodules required by this port. A dedicated development branch is sufficient and keeps the web experiments available for reference without splitting history. + +Always checkout with submodules: + +```bash +git clone --recursive https://github.com/matthewcodergamer/source-engine-render360.git +cd source-engine-render360 +git switch render360/ios-native +git submodule update --init --recursive +``` + +## Unsigned IPA workflow + +Trigger automatically by pushing native files to `render360/ios-native`, or manually from Actions → **iOS Native Bootstrap IPA** → Run workflow. + +The job: + +1. checks out repository + submodules; +2. verifies no obvious retail Portal assets exist under `ios-native/`; +3. generates an Xcode iOS project with CMake; +4. builds Release/iphoneos/arm64 with code signing disabled; +5. validates the output binary is arm64; +6. places the `.app` in `Payload/`; +7. zips it into an IPA; +8. uploads the IPA as a GitHub Actions artifact. + +Unsigned IPAs cannot simply be tapped and installed on a normal iPhone. They must be signed for the device by an Apple-compatible signing route before installation. + +## Signed workflow inputs + +The signed workflow follows the standard temporary-keychain pattern on a GitHub-hosted macOS runner. + +Configure GitHub **Secrets**: + +- `BUILD_CERTIFICATE_BASE64`: Base64 of the `.p12` certificate. +- `P12_PASSWORD`: password for that `.p12`. +- `BUILD_PROVISION_PROFILE_BASE64`: Base64 of the `.mobileprovision` file. +- `KEYCHAIN_PASSWORD`: random password used only for the temporary CI keychain. + +Configure GitHub **Variables**: + +- `IOS_TEAM_ID`: Apple Developer team identifier. +- `IOS_BUNDLE_ID`: bundle identifier covered by the provisioning profile, for example `com.example.render360portal`. +- optional `IOS_SIGNING_IDENTITY`: defaults to `Apple Development` when unset. + +Then run Actions → **iOS Native Signed IPA** manually. + +The runner imports the certificate into a temporary keychain, installs the provisioning profile, builds with manual signing, verifies the signature, packages the signed `.app` into an IPA, uploads the artifact, and deletes signing material during cleanup. + +## Creating Base64 secrets on macOS + +Certificate: + +```bash +base64 -i Render360Development.p12 | pbcopy +``` + +Provisioning profile: + +```bash +base64 -i Render360.mobileprovision | pbcopy +``` + +Paste the resulting text into the corresponding GitHub Secret. Never commit either file to the repository. + +## Local unsigned build + +Requires macOS + Xcode + CMake. + +```bash +cmake -S ios-native -B build/ios -G Xcode \ + -DCMAKE_SYSTEM_NAME=iOS \ + -DCMAKE_OSX_SYSROOT=iphoneos \ + -DCMAKE_OSX_ARCHITECTURES=arm64 \ + -DCMAKE_OSX_DEPLOYMENT_TARGET=15.0 \ + -DRENDER360_BUNDLE_ID=com.render360.portal + +xcodebuild \ + -project build/ios/Render360PortalIOS.xcodeproj \ + -scheme Render360Portal \ + -configuration Release \ + -sdk iphoneos \ + -destination 'generic/platform=iOS' \ + -derivedDataPath build/DerivedData \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + ARCHS=arm64 \ + build +``` + +## Retail Portal data boundary + +Do not place the following in Git history, Actions artifacts, or the IPA: + +- `.vpk` +- `.bsp` +- retail `.vtf`, `.vmt`, `.vcs`, models, sounds or voice assets +- Valve retail binaries +- a copied Portal installation + +The app imports/authorizes files supplied by the user on-device. Synthetic fixtures are allowed for automated tests. + +## Artifact naming as the port progresses + +Recommended artifact convention: + +```text +Render360-Portal-iOS-N0-bootstrap-unsigned.ipa +Render360-Portal-iOS-N1-sdl-unsigned.ipa +... +Render360-Portal-iOS-N8-chamber00-unsigned.ipa +Render360-Portal-iOS-signed.ipa +``` + +The current workflow keeps the stable name `Render360-Portal-iOS-unsigned.ipa` inside the artifact and includes the Actions run number in the artifact container name. + +## When an IPA counts as "Portal" + +An IPA is not called playable merely because it installs. The minimum playable gate is: + +- native arm64 executable; +- Source main loop active; +- user's Portal data imported/authorized; +- real `background1` rendering; +- real `testchmb_a_00` loading; +- movement/look/use/portal input; +- audio; +- current-map-only transition/unload behavior. + +Until then, artifacts should be labeled with their native phase/milestone. From d2b28bfb1d4f7fbebf1c3fa48af542c7a2d53876 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Mon, 14 Sep 2026 22:08:11 -0400 Subject: [PATCH 136/159] docs: define native iOS architecture boundaries --- docs/IOS_NATIVE_ARCHITECTURE.md | 173 ++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 docs/IOS_NATIVE_ARCHITECTURE.md diff --git a/docs/IOS_NATIVE_ARCHITECTURE.md b/docs/IOS_NATIVE_ARCHITECTURE.md new file mode 100644 index 0000000000..2c133378da --- /dev/null +++ b/docs/IOS_NATIVE_ARCHITECTURE.md @@ -0,0 +1,173 @@ +# Render360 Portal Native iOS Architecture + +## Top-level runtime + +```text +Render360Portal.app +│ +├── UIKit setup/import shell +│ └── Files document/folder picker +│ +├── SDL2 iOS platform host +│ ├── lifecycle +│ ├── touch/controller input +│ ├── audio +│ └── native game window +│ +├── Source engine arm64 +│ ├── tier0/tier1/mathlib/vstdlib +│ ├── filesystem_stdio + VPK +│ ├── engine +│ ├── material system +│ ├── studio render +│ ├── vphysics/IVP +│ ├── client +│ ├── server +│ ├── GameUI/VGUI +│ └── audio/video subsystems as required by Portal +│ +├── iOS static module registry +│ └── Source interface factories instead of browser SIDE_MODULE/dlopen +│ +├── Graphics backend boundary +│ ├── GLES3 compatibility bring-up backend +│ └── Metal production backend +│ +└── User-owned Portal data + └── native filesystem/VPK range reads +``` + +## What is intentionally gone from the native runtime + +The following are web-port implementation details and must not become dependencies of the iOS application: + +- WebAssembly linear memory +- `hl2_launcher.data` +- MEMFS +- WORKERFS +- SharedArrayBuffer +- COOP/COEP +- service workers +- browser `File` object transfer +- pthread JavaScript proxies +- OffscreenCanvas +- WebGL +- browser fullscreen emulation + +## Thread model + +Use ordinary iOS/native threading. Start conservatively on iPhone 11: + +- UI/main thread: UIKit/SDL platform events and presentation requirements. +- Source/game thread: only if Source's architecture benefits from separation after profiling. +- Worker/job threads: bounded pool sized from measurements, not desktop defaults. +- Audio callback: SDL/CoreAudio-controlled real-time path; no blocking filesystem I/O. + +Do not copy the browser's two-pthread design literally. Native thread counts are an optimization decision, not an architectural requirement. + +## Filesystem + +Native game data is exposed as ordinary paths/file descriptors under an app-controlled game root. The Source filesystem should remain responsible for VPK resolution. + +Preferred access pattern: + +```text +gameinfo.txt -> tiny ordinary read +*_dir.vpk -> directory/index metadata +*_000.vpk -> seek/range read only when Source asks for an asset +BSP -> load current level data through Source's normal map path +``` + +Do not unpack VPKs into per-file copies merely to make them accessible. + +## Memory lifecycle + +### Menu + +```text +engine/shared systems ++ GameUI/VGUI ++ background1 ++ menu materials/models/audio +``` + +No chamber BSP residency. + +### Chamber + +```text +engine/shared systems ++ current BSP/world ++ current models/materials/textures/audio ++ bounded shared cache +``` + +### Changelevel + +```text +close Aperture/elevator transition +-> Source shuts down old world +-> release unreferenced models +-> uncache unused materials/textures +-> open/read new BSP and required VPK assets +-> first safe new-map frame +-> open transition +-> destroy transition resources +``` + +Temporary overlap is allowed only where Source requires it. Persistent previous-map residency is a bug. + +## Graphics strategy + +### Bring-up: OpenGL ES 3.0 + +Reason: the existing renderer/ToGL code is conceptually closer to GL than Metal, so GLES3 is the fastest route to identifying Source-specific rendering assumptions and reaching first pixels. + +Constraints: + +- no fake extension reporting; +- no browser/WebGL emulation layer; +- isolate every compatibility shim; +- expect desktop OpenGL calls/features to require adaptation; +- treat GLES as deprecated/temporary. + +### Production: Metal + +Metal migration occurs behind the same renderer boundary after background1/chamber00 are functional. This avoids simultaneously debugging Source gameplay startup and a total graphics rewrite. + +## Module loading + +Native iOS should resolve Source factories from linked code. A logical name such as `libengine.so` is normalized to `engine`, then looked up in a static registry. + +Pseudo-flow: + +```cpp +CSysModule *Sys_LoadModule(const char *name) { + auto id = NormalizeSourceModuleName(name); + if (auto *entry = IOSModuleRegistry::Find(id)) + return entry->Handle(); + return nullptr; +} +``` + +The handle is a Source-compatible logical handle, not an arbitrary iOS-loaded executable file. + +## Diagnostics + +Keep the lesson from the web work: giant logs are not useful on memory-constrained devices. + +Retain: + +- latest actionable runtime event; +- current startup/map phase; +- active map; +- resident memory snapshot; +- last missing module/interface/resource; +- renderer backend and capability summary; +- last fatal/native exception summary where capturable. + +Use an optional bounded developer ring buffer only for debug builds and never let it grow without limit. + +## App/asset boundary + +The executable and open-source/port code live in GitHub/IPA. Portal retail data stays outside the repository and is supplied by the user at runtime. This boundary must be enforced by CI and documentation throughout the project. From 2d19a963441669cafec69b6b098b0796b60c89e9 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Mon, 14 Sep 2026 22:14:28 -0400 Subject: [PATCH 137/159] docs: add zero-to-complete native iOS IPA prompt pack --- docs/IOS_NATIVE_ZERO_TO_IPA_PROMPT_PACK.md | 845 +++++++++++++++++++++ 1 file changed, 845 insertions(+) create mode 100644 docs/IOS_NATIVE_ZERO_TO_IPA_PROMPT_PACK.md diff --git a/docs/IOS_NATIVE_ZERO_TO_IPA_PROMPT_PACK.md b/docs/IOS_NATIVE_ZERO_TO_IPA_PROMPT_PACK.md new file mode 100644 index 0000000000..40e6787cfc --- /dev/null +++ b/docs/IOS_NATIVE_ZERO_TO_IPA_PROMPT_PACK.md @@ -0,0 +1,845 @@ +# Render360 Portal Native iOS — Zero-to-IPA Prompt Pack + +Canonical execution prompts for `matthewcodergamer/source-engine-render360` on branch `render360/ios-native`. + +This file is meant to be pasted into an implementation-capable coding agent phase by phase. It is not a design-only roadmap. Every phase must inspect the repository, make production code changes, run the strongest available checks, inspect GitHub Actions failures when accessible, and leave the branch in a strictly better state. + +## Mission + +Build a real native ARM64 iOS Source/Portal application and package it as an IPA. Preserve the existing browser/WebAssembly work as reference only. The native app must use the user's own legally obtained Portal files at runtime; retail Valve assets must never be committed to the repository or bundled into CI artifacts. + +The first device performance floor is iPhone 11. + +## Current repository baseline + +At the time this prompt pack was created: + +- Branch `render360/ios-native` already exists and is the primary native-iPhone branch. +- `ios-native/CMakeLists.txt` intentionally builds only a small UIKit ARM64 bootstrap target. +- `.github/workflows/ios-native.yml` builds an unsigned ARM64 IPA artifact. +- `.github/workflows/ios-native-signed.yml` contains a manual signed-IPA path based on an Apple certificate, provisioning profile, GitHub Secrets and repository variables. +- The native project has not earned a gameplay milestone merely because the bootstrap IPA packages successfully. +- Existing docs in `docs/IOS_NATIVE_MASTER_PLAN.md`, `docs/IOS_NATIVE_ARCHITECTURE.md`, `docs/IOS_NATIVE_BUILD_AND_SIGNING.md`, and `docs/IOS_NATIVE_AI_PROMPTS.md` remain useful and must be read before implementation. + +## Verified platform facts to respect + +1. SDL2 has an official iOS integration path using its iOS/Xcode project/framework and UIKit main glue. Reuse that path or an equivalently reproducible source build; do not invent a custom window/input/audio stack without a measured reason. +2. iOS still exposes OpenGL ES 1.1/2.0/3.0, but Apple marks OpenGL ES deprecated and recommends Metal. Therefore GLES3 is a temporary compatibility/bring-up backend, not the final long-term graphics strategy. +3. Files selected outside the app sandbox through a document/folder picker can require security-scoped access. Correctly start/stop access and coordinate external reads, or explicitly copy the chosen game files into the app's Application Support storage. +4. GitHub Actions can sign Xcode apps by installing a certificate into a temporary keychain and installing a provisioning profile on a macOS runner. Secrets must never be echoed or committed. +5. An unsigned IPA is only a packaging/build artifact. A normal iPhone installation still requires a valid signature/provisioning method. + +## Global implementation preamble + +Prepend this block to every phase prompt below. + +```text +Repository: matthewcodergamer/source-engine-render360 +Branch: render360/ios-native +Primary device floor: iPhone 11 +Primary architecture: arm64 iphoneos + +You are the lead engine/platform programmer for the native iOS port of Render360 Portal. + +READ BEFORE EDITING: +- ios-native/README.md +- docs/IOS_NATIVE_MASTER_PLAN.md +- docs/IOS_NATIVE_ARCHITECTURE.md +- docs/IOS_NATIVE_BUILD_AND_SIGNING.md +- docs/IOS_NATIVE_AI_PROMPTS.md +- docs/IOS_NATIVE_ZERO_TO_IPA_PROMPT_PACK.md +- .gitmodules +- .github/workflows/ios-native.yml +- .github/workflows/ios-native-signed.yml +- every source/build file changed by the previous completed phase + +NON-NEGOTIABLE RULES: +1. Build the real Source/Portal runtime. Never substitute a webpage, fake renderer, video, static screenshot, mocked level, fake gameplay shell, or menu-only prototype for an engine milestone. +2. Do not commit or bundle Portal retail VPK/BSP/VTF/VMT/WAV/MP3/VCS/game binaries or other Valve retail data. Runtime import must use files owned by the user. +3. Preserve existing WebAssembly branches and web-port history. Do not delete working browser code merely because native iOS is now primary. +4. Native iOS must not depend on MEMFS, WORKERFS, SharedArrayBuffer, COOP/COEP, service workers, JavaScript File objects, browser pthread proxying, or hl2_launcher.data. +5. Prefer focused iOS platform adapters and backend boundaries over broad invasive Source rewrites. +6. Do not use arbitrary downloaded executable code or arbitrary plugin dlopen as a shortcut. Link required Source modules into the application and resolve their interfaces through a built-in registry unless a specific Apple-supported framework is intentionally dynamic. +7. Preserve current-map-only residency: menu background only at menu; current BSP plus bounded shared caches in gameplay; no cumulative old-map residency; no full future-map prefetch on the iPhone 11 profile unless device measurements prove safe headroom. +8. Never load entire VPK/BSP archives into RAM only to obtain random access. Use seekable native file I/O and bounded caches. +9. Every stage must distinguish: compile-proven, CI-proven, simulator-proven, and physical-device-proven. Do not claim a physical-device milestone without evidence from a real device. +10. Keep diagnostics concise and latest-actionable. Record the last phase/subsystem/error and avoid unbounded logs in memory. +11. Do not silence errors with blanket warning disables, unsupported capability lies, unconditional success returns, or no-op stubs that hide required engine behavior. +12. Keep commits phase-scoped. Update documentation and acceptance checks whenever architecture changes. +13. Before editing, inspect current code and CI so you do not recreate files/features already present. +14. After editing, run all practical build/static tests. If a workflow fails, inspect the failing job/step/log, fix the concrete cause, and rerun. Do not stop at the first CI error if it is repairable. +15. When blocked by a physical-device-only issue, leave exact device steps and the exact log/checkpoint needed next; do not fabricate success. + +OUTPUT AFTER EACH PHASE: +- Files changed +- Why each change was necessary +- Commands/tests run +- CI run/result if available +- Current last-success checkpoint +- Current first-failure checkpoint +- Memory/performance observations if applicable +- Exact next phase +``` + +--- + +# Prompt 00 — Audit, baseline and branch safety + +```text +Use the global implementation preamble. + +Goal: establish the exact native-iOS starting point and prevent accidental regression of the web work. + +Tasks: +- Inventory ios-native/, the native workflows, relevant Source directories, submodules and third-party libraries. +- Confirm current N0 bootstrap scope and identify which native milestones are implemented versus documentation-only. +- Identify Emscripten-only files/defines/build assumptions that must never enter the iOS target. +- Identify desktop/Linux APIs likely to need iOS adapters: dynamic module loading, filesystem paths, threading/TLS, timing, sockets if needed, signals, process APIs, graphics context creation, input, audio. +- Confirm submodule versions/commits and document reproducibility risks. +- Verify retail Portal data is absent from ios-native/ and workflow inputs. +- Verify both unsigned and signed workflows are syntactically and architecturally consistent with the current native target. +- Create or update docs/IOS_NATIVE_PORTING_AUDIT.md with a module dependency map, platform blockers, graphics blockers, importer/filesystem blockers, audio/input blockers, and a status table for N0 through final release. +- Do not add full-engine code in this audit unless fixing an obvious bootstrap/CI defect. + +Acceptance: +- Audit names exact next source files/directories for N1/N2. +- Unsigned bootstrap workflow can create an arm64 IPA artifact, or the exact CI blocker is fixed/documented. +- No retail Portal data is introduced. +``` + +# Prompt 01 — N0 bootstrap hardening and real IPA proof + +```text +Use the global implementation preamble. + +Goal: make the existing tiny UIKit bootstrap a trustworthy foundation before layering SDL/Source on top. + +Tasks: +- Verify CMake generates an Xcode project for iphoneos arm64 with the intended deployment target and bundle identifier. +- Verify Info.plist orientation, document import capability and required usage/configuration keys are correct for the current bootstrap. +- Add a native diagnostic screen containing app version/commit, architecture, OS version, memory warning count and last native checkpoint. Keep it simple; this screen is temporary bootstrap diagnostics, not the game UI. +- Verify the app launches without Portal files and offers a user-facing import/setup entry point rather than crashing. +- Verify the unsigned workflow checks the executable architecture and packages Payload/Render360Portal.app correctly. +- Do not call N0 complete based only on CMake configure. Require artifact creation. + +Acceptance: +- CI produces Render360-Portal-iOS-unsigned.ipa containing an arm64 executable. +- Bootstrap launches on a signed/sideloaded physical iPhone when the user signs it. +- Missing game data is handled as setup state, not fatal startup. +``` + +# Prompt 02 — N1 SDL2 iOS window, lifecycle, input and audio host + +```text +Use the global implementation preamble. + +Goal: replace the temporary UIKit-only game host with a reproducible SDL2 iOS runtime while retaining UIKit only where it is the better native UI tool (for example the file importer). + +Tasks: +- Pin an SDL2 source/release revision and document it. Prefer the official SDL iOS project/framework/source path; do not check in an unexplained opaque binary. +- Integrate SDL into CMake/Xcode reproducibly on GitHub macOS runners. +- Create the SDL iOS entry path using the expected UIKit SDL main glue. +- Create a landscape game window. +- Create a GLES 3.0 context as a temporary rendering bootstrap only; place context creation behind a renderer-backend interface that can later host Metal. +- Correctly use point size versus drawable pixel size on Retina displays. +- Wire background/foreground, resign-active/active, audio interruption, memory warning and orientation events into a native lifecycle service. +- Wire touch events and controller connect/disconnect into a diagnostic input layer. +- Open an SDL/CoreAudio-backed audio device with a synthetic silence/test callback only; no retail audio fixtures. +- Prevent repeated context/audio recreation loops on resume. + +Acceptance: +- Physical iPhone can display a continuously clearing native SDL/GLES frame. +- Touch coordinates and controller state are observable in latest-only diagnostics. +- Audio device opens and survives interruption/resume. +- Unsigned IPA CI still packages successfully. +``` + +# Prompt 03 — N2 native Source foundation build graph + +```text +Use the global implementation preamble. + +Goal: compile reusable Source foundation libraries for iphoneos arm64 without pulling browser-only architecture into the native target. + +Start with dependency analysis, then incrementally add static libraries. Expected early order: +- tier0 +- tier1 +- mathlib +- vstdlib +- appframework +- filesystem_stdio +- datacache +- inputsystem platform-neutral pieces +- IVP/source-physics foundation needed by vphysics + +Tasks: +- Create a focused iOS platform config header/toolchain layer. +- Use libc++ and modern Clang; keep project language compatibility aligned with existing Source code while allowing the host to use C++17. +- Replace unsupported POSIX/desktop calls only through narrow adapters. +- Handle endianness, alignment, atomics, TLS, timing and thread naming deliberately; do not assume x86 behavior. +- Use native pthread/std::thread paths where Source permits. +- Add synthetic smoke tests for math, file read/seek, thread/TLS and a tiny IVP physics step. +- Never use Portal retail assets as tests. + +Acceptance: +- Foundation libraries compile for iphoneos arm64 in CI. +- Synthetic filesystem random-access test passes. +- Thread/TLS test passes. +- Deterministic math checks pass. +- Tiny physics create/step/destroy check passes once physics foundation is included. +``` + +# Prompt 04 — N2B iOS platform shim completion + +```text +Use the global implementation preamble. + +Goal: make platform assumptions explicit before engine startup grows complicated. + +Audit and implement only the shims actually required by the code being linked: +- paths and app-support directories +- monotonic/high-resolution timers +- sleep/yield +- pthread/TLS primitives +- atomic/barrier behavior +- filesystem stat/seek/truncate/mkdir/enumeration +- environment/command-line abstraction where Source expects it +- sockets only if a required local subsystem actually needs them +- crash/assert/checkpoint reporting + +Rules: +- Do not emulate fork/exec/process launching if the native game does not need it. +- Do not disable asserts globally. +- Do not turn fatal missing-platform behavior into silent success. +- Record unsupported APIs with caller/module name. + +Acceptance: +- Source foundation tests do not rely on Emscripten or browser helpers. +- Unsupported calls fail loudly and diagnostically. +``` + +# Prompt 05 — N3 static Source module/interface registry + +```text +Use the global implementation preamble. + +Goal: replace desktop/web runtime module loading with built-in native module factories. + +Implement a native registry that can normalize logical names such as: +- engine / engine.dll / libengine.so +- filesystem_stdio variants +- materialsystem +- shaderapidx9 logical request mapped to the iOS renderer-facing implementation +- client +- server +- other modules only when required + +Tasks: +- Create a focused source_module_registry implementation. +- Preserve Source CreateInterface semantics and interface version matching. +- Add reference/lifetime accounting compatible with Source expectations even though code is statically linked. +- Hook Sys_LoadModule, Sys_UnloadModule, Sys_GetFactory or their actual equivalents for RENDER360_IOS_NATIVE. +- Add fake-factory tests before relying on real engine modules. +- Emit exact unknown-module and unknown-interface diagnostics. + +Acceptance: +- Fake registry tests pass. +- Linked foundation interfaces resolve by canonical Source names. +- No arbitrary executable-code dlopen path is required for startup. +``` + +# Prompt 06 — N4 Portal folder importer, persistence and validation + +```text +Use the global implementation preamble. + +Goal: allow a user to select their own Portal installation through iOS Files and make it reliably accessible to Source. + +Tasks: +- Use UIDocumentPickerViewController/folder import or the current modern equivalent already chosen by the project. +- Treat external URLs as security scoped where required: start access before reads, stop access when finished, and coordinate external access correctly. +- Decide one reliable persistence strategy and document it: + A) copy the required user-owned game tree into Application Support/Render360Portal/Game, or + B) persist only a valid security-scoped bookmark/access model proven to survive relaunch/provider behavior. +- Prefer Application Support import if it substantially simplifies long-running random access and provider reliability. +- Validate portal/gameinfo.txt and expected portal/hl2/platform layout. +- Discover VPK directory/archive parts without loading archives wholesale. +- Check available storage before a large copy. +- Show cancellable progress and avoid a second full temporary copy. +- Create a lightweight manifest of relative paths/sizes/fingerprints sufficient to detect obviously changed/missing imports. +- Never upload user game files to CI or GitHub. + +Acceptance: +- App relaunch can resolve the chosen/imported Portal root. +- Invalid folders produce a precise reason and let the user choose again. +- Large-file import does not spike RAM near total game size. +``` + +# Prompt 07 — N4B native Source filesystem and VPK random access + +```text +Use the global implementation preamble. + +Goal: make Source read the user's Portal data through native seekable I/O. + +Tasks: +- Reuse Source filesystem/VPK parsing wherever possible. +- Map Source search paths to the native imported root. +- Ensure FILE/fd reads, seek/tell, archive part access and directory enumeration are correct on iOS. +- Do not unpack every VPK simply to make startup easier. +- Add bounded metadata/archive-handle caches. +- Add runtime verifier checkpoints: + gameinfo-open + searchpaths-built + vpk-dir-open + vpk-entry-resolved + small-entry-read +- The verifier must choose an entry from the user's files at runtime; do not add retail fixtures. + +Acceptance: +- Source filesystem can find gameinfo.txt and mount the required VPKs. +- Random entry reads work without whole-archive allocation. +``` + +# Prompt 08 — N5 real native launcher through PreInit + +```text +Use the global implementation preamble. + +Goal: execute the real Source startup sequence until the renderer becomes the first legitimate blocker. + +Tasks: +- Port launcher/bootstrap path assumptions to iOS. +- Build command line/base directory/game directory from the imported Portal root. +- Link/register required modules incrementally. +- Keep exact latest checkpoints: + ios-launcher-enter + filesystem-ready + gameinfo-found + factories-ready + engine-create-enter + engine-create-done + engine-preinit-enter + engine-preinit-done + engine-mainloop-enter +- Capture actual return/error codes. +- Preserve normal Source shutdown paths. +- If renderer initialization blocks progress, stop at the exact renderer call/capability and hand the problem to Prompt 09; do not fake renderer success. + +Acceptance: +- Engine reaches PreInit/main-loop boundary, or the exact renderer boundary is proven with a concrete diagnostic. +- No hl2_launcher.data/browser startup remains. +``` + +# Prompt 09 — N6 GLES3/ToGL compatibility renderer bring-up + +```text +Use the global implementation preamble. + +Goal: produce real Source-generated native frames as quickly as possible using GLES3 as a temporary compatibility backend. + +Audit every desktop/OpenGL assumption actually reached by the port. Pay special attention to: +- fixed-function remnants such as alpha/color/client texture state +- base-vertex draw semantics +- texture-level queries +- FBO/blit differences +- buffer mapping/storage flags +- sync/fences +- texture/internal format support +- sRGB/depth/stencil behavior +- compression formats +- GLSL desktop versus GLSL ES version/precision/output syntax +- extension checks + +Rules: +- Never report an extension/capability as present when the device does not support it. +- Emulate only semantics that can be made correct. +- Centralize GL-to-GLES adaptation; do not scatter game-specific hacks through materials. +- Log shader compile/link errors with shader identity and transformed source location where possible. +- Validate framebuffer completeness in debug builds. +- Maintain a runtime GPU/capability report. +- Keep the backend interface suitable for later Metal implementation. + +Acceptance: +- Source creates a real renderer and presents Source-generated frames. +- No WebGL JavaScript bridge is used. +- Renderer failures identify exact unsupported state/format/shader rather than generic black screen. +``` + +# Prompt 10 — N7 Portal background1 and real menu + +```text +Use the global implementation preamble. + +Goal: render the real Portal background1 world using user-owned data and make the menu interactive. + +Tasks: +- Drive the real Source map-load path for background1. +- Resolve required material/model/shader families iteratively. +- Add latest missing-resource diagnostics. +- Bring VGUI/menu/input up over the rendered background as appropriate to this branch. +- Record resident memory before load, after world load and after first stable frame. +- Keep chamber BSPs unloaded while at menu. + +Acceptance: +- Real background1 geometry is visible on iPhone. +- Menu is interactive. +- No test chamber is resident during menu. +``` + +# Prompt 11 — N8 client/server, chamber 00 and gameplay systems + +```text +Use the global implementation preamble. + +Goal: load testchmb_a_00 with the real Source client/server simulation and enough Portal gameplay to navigate the chamber. + +Bring up only dependencies demanded by the map, including as needed: +- client/server factories +- player spawn +- world collision +- IVP/VPhysics +- doors/buttons/cubes +- sound emitter path +- particles/decals required by the map +- Portal game rules +- portal placement/rendering dependencies + +Rules: +- Expand by concrete missing dependency, not by enabling every engine subsystem blindly. +- Do not replace broken entities with fake native UI. +- Record first-frame and gameplay memory. + +Acceptance: +- testchmb_a_00 loads from user-owned files. +- Player can spawn, move and collide. +- Required basic chamber interactions work. +``` + +# Prompt 12 — N9 production touch, controller, gyro and audio + +```text +Use the global implementation preamble. + +Goal: make chamber gameplay usable on iPhone rather than merely visible. + +Touch controls: +- left movement stick +- right look region/stick +- jump +- use/interact +- primary portal +- secondary portal +- crouch where required +- pause/menu +- optional gyro aim/look toggle + +Tasks: +- Map controls through Source input actions rather than modifying game logic. +- Respect landscape safe areas. +- Support simultaneous move + look + action multi-touch. +- Add sensitivity, dead-zone and acceleration settings. +- Prevent stuck touches after notification center/control center/background/interruption. +- Support standard iOS game controllers and minimize/hide touch controls while controller is active, with user override. +- Finish Source audio path through SDL/CoreAudio and handle interruptions without retaining stale map resources. + +Acceptance: +- Chamber 00 is navigable using touch alone. +- Standard controller path works. +- Audio survives interruption/resume without permanent device loss. +``` + +# Prompt 13 — N10 current-map-only memory lifecycle + +```text +Use the global implementation preamble. + +Goal: preserve the most important memory optimization from the web work in the native app. + +Required state model: +MENU = engine/shared + background1 only. +GAMEPLAY = engine/shared + active BSP + bounded caches. +TRANSITION = only minimal temporary overlap required by safe Source changelevel behavior. + +Tasks: +- Instrument native resident/physical footprint where available plus Source model/material/cache counts. +- Verify world teardown releases old BSP/world references. +- Purge only genuinely unreferenced models/materials/textures at safe lifecycle boundaries. +- Do not flush live shared UI/common resources every map. +- Detect monotonic growth across repeated transitions. +- Add a developer overlay/command: active map, memory, model count, material/texture/cache counts, transition peak. +- Test background1 -> testchmb_a_00 -> testchmb_a_01 -> testchmb_a_00 for at least 3 cycles using a developer route if needed. + +Acceptance: +- Old world/BSP residency does not remain indefinitely after transition. +- Repeated route does not grow without bound. +- No whole-next-map prefetch is introduced to hide loading. +``` + +# Prompt 14 — N11 Aperture-style hidden loading transitions + +```text +Use the global implementation preamble. + +Goal: hide unavoidable current-map-only load stalls without defeating the memory policy. + +Implement a lightweight transition layer using Portal/Aperture language: +- elevator/airlock/door close +- optional short fade/lighting cue +- responsive tiny native/game overlay while map unload/load executes +- open only after first safe frame of the new map +- destroy transition resources immediately afterward + +Rules: +- no full next-map preload +- optional tiny metadata/header pre-read only after measurement +- do not force a long fake loading animation when transition is already fast +- avoid duplicating large render targets just to hold a frame +- never show half-loaded world geometry + +Acceptance: +- Tested chamber transitions no longer expose long frozen/half-loaded visuals. +- Transition adds negligible steady-state memory after closing. +- Prompt 13 memory tests still pass. +``` + +# Prompt 15 — N12 iPhone 11 performance, memory and thermal pass + +```text +Use the global implementation preamble. + +Goal: optimize from measurements on iPhone 11, not assumptions. + +Create a repeatable benchmark route and record: +- average FPS +- frame-time percentiles / 1% low equivalent +- CPU frame time +- GPU frame time when available +- resident memory +- transition peak memory +- VPK read latency +- shader compile/link stalls +- texture/cache sizes +- audio underruns +- iOS thermal state changes + +Potential optimization areas, only when measured: +- internal render resolution / dynamic resolution +- texture mip/residency policy +- anisotropy/MSAA defaults +- shadows and post effects +- render-state churn +- shader/pipeline caches +- VPK read chunk/cache policy +- particle density +- physics hot spots +- unnecessary background work + +Rules: +- Do not claim an FPS or memory gain without same-route before/after numbers. +- Do not degrade correctness to hit a number silently; expose quality tiers/settings. +- Keep iPhone 11 as the minimum performance profile even if newer devices get higher defaults. + +Acceptance: +- Create/update docs/IOS_NATIVE_IPHONE11_PROFILE.md with exact device/iOS/build/settings and before/after measurements. +``` + +# Prompt 16 — N13 Metal backend migration + +```text +Use the global implementation preamble. + +Start only after GLES gameplay is proven. + +Goal: replace deprecated GLES incrementally with a native Metal backend while keeping Source gameplay/filesystem/physics/resource logic unchanged. + +Maintain a renderer-backend boundary and migrate in controlled order: +1. presentation surface/frame lifecycle +2. command buffers +3. vertex/index buffers +4. textures/samplers/format mapping +5. depth/stencil/render targets +6. shader strategy: translation/rewriting/reflection and validation +7. pipeline/state cache +8. synchronization/fences +9. GPU timing/debug markers where useful +10. Portal-specific render features and parity fixes + +Rules: +- Keep GLES as a reference backend until Metal reaches functional parity. +- Build automated render-path sanity tests where possible using synthetic primitives/materials. +- Do not delete the working GLES path at the first successful Metal triangle. +- Make Metal default only after background1/chamber gameplay parity and measured stability/performance. + +Acceptance: +- Metal renders background1 and chamber00 with required gameplay visuals. +- Metal is default only after parity plus device measurements justify the switch. +``` + +# Prompt 17 — N14 signed IPA, artifact verification and install handoff + +```text +Use the global implementation preamble. + +Goal: produce trustworthy unsigned and signed IPA artifacts from GitHub Actions. + +Unsigned path: +- keep CODE_SIGNING_ALLOWED=NO build for reproducible compile/package proof +- verify app bundle exists +- verify executable is arm64 +- verify retail Portal assets are absent +- package Payload/Render360Portal.app +- upload IPA + build metadata + +Signed path: +- use GitHub Secrets for certificate/provisioning profile/password material +- use repository variables for non-secret team/bundle/signing identity values +- decode certificate/profile only on macOS runner temporary storage +- create/unlock a temporary keychain +- import certificate and set key partition list +- install provisioning profile by UUID +- configure manual Xcode signing +- build for generic iOS device +- codesign --verify --deep --strict +- inspect entitlements/profile/bundle identifier consistency +- package signed Payload IPA +- upload artifact +- always delete temporary keychain/profile material + +Add verification scripts that fail the workflow when: +- executable is missing/non-arm64 +- bundle ID does not match expected signed profile/application identifier +- signature verification fails +- retail game assets are found in app bundle +- IPA cannot be unzipped or Payload app is missing + +Acceptance: +- Unsigned workflow reliably emits a valid package artifact. +- Signed workflow emits a codesign-verified IPA when valid user secrets/profile are supplied. +- Signed IPA can be installed on a device covered by its provisioning method. +``` + +# Prompt 18 — Full end-to-end completion audit + +```text +Use the global implementation preamble. + +Goal: prove the project is actually complete enough to call the native iOS port functional. + +Do not add features first. Audit every milestone and mark each as PASS / FAIL / NOT TESTED with evidence: +- arm64 native launch +- SDL lifecycle +- Portal import persistence +- gameinfo/VPK random access +- Source module registry +- engine PreInit/main loop +- renderer initialization +- background1 +- menu input +- testchmb_a_00 +- player movement/collision +- required Portal interactions +- touch controls +- controller controls +- audio +- suspend/resume +- memory-warning handling +- current-map-only transition behavior +- repeated map-cycle memory stability +- iPhone 11 performance profile +- GLES/Metal status +- unsigned IPA CI +- signed IPA CI +- physical installation + +For every FAIL, fix it if possible in this session and rerun the narrowest relevant test. Do not downgrade acceptance criteria merely to finish the checklist. + +Create docs/IOS_NATIVE_RELEASE_READINESS.md containing: +- tested commit SHA +- tested device/iOS +- exact IPA artifact/run +- known blockers +- known non-blocking issues +- how to import user Portal files +- how to install/sign the IPA +- how to collect diagnostics for a crash/black screen/import failure + +Final completion definition: +A signed native arm64 IPA installs on an iPhone 11-class device, launches without browser infrastructure, imports/uses the user's own Portal files, renders real Source/Portal content, loads at least chamber00 into real client/server gameplay, accepts usable touch/controller input, plays audio, survives lifecycle transitions, and does not show unbounded map-to-map memory accumulation. +``` + +--- + +# Recovery Prompt A — CI compile/link failure loop + +```text +Repository/branch are the same as the global preamble. + +A native iOS GitHub Actions job is failing. Do not redesign unrelated systems. + +Process: +1. Inspect the exact failed workflow run/job/step and compiler/linker output. +2. Identify the first root-cause error, not downstream noise. +3. Trace it to the exact source/target/link dependency. +4. Make the smallest correct production fix. +5. Re-run the narrow local/static check if possible. +6. Re-run the failed workflow/job. +7. Repeat until green or until the remaining blocker requires external signing/device material. + +Never fix CI by removing required Source code, returning success unconditionally, disabling the target, ignoring linker symbols, or converting the real target back into a bootstrap mock. + +Report the first root cause, fix, and new last-success checkpoint. +``` + +# Recovery Prompt B — Device launch/crash loop + +```text +The IPA installs but crashes/exits/hangs on physical iPhone. + +Use the existing latest-checkpoint diagnostic system and Xcode/device crash logs if supplied. + +Process: +- establish whether failure occurs before main, in UIKit/SDL startup, importer, filesystem, module registry, engine create, PreInit, renderer, map load or gameplay +- symbolicate native crashes when possible +- record exception type/signal, thread, top native frames and last Render360 checkpoint +- fix ownership/lifetime/alignment/threading issues at the real source +- do not hide crashes with broad try/catch or signal swallowing +- verify background/foreground and memory warning separately if crash is lifecycle-triggered + +Acceptance: either the crash is fixed, or a single exact unresolved native call/stack remains with a reproducible trigger. +``` + +# Recovery Prompt C — Black screen / renderer failure + +```text +The app stays alive but shows a black/incorrect frame. + +Do not assume the renderer is initialized just because the swap/present call succeeds. + +Check in order: +- drawable size and framebuffer dimensions +- GL/Metal context/device ownership and current-thread rules +- framebuffer completeness/render-pass validity +- clear/present proof +- viewport/scissor +- vertex/index upload +- shader compile/link/pipeline creation +- uniform/constant bindings +- texture format/upload/sampler state +- depth/stencil/cull/blend state +- Source material fallback/missing shader path +- map/resource availability + +Add a temporary synthetic triangle only as a renderer diagnostic gate; remove/disable it from normal gameplay after proving the platform backend. A synthetic triangle is not a Source milestone. + +Report the first failing real Source draw/material after the platform diagnostic passes. +``` + +# Recovery Prompt D — Portal import/VPK failure + +```text +The user selected Portal files but Source cannot find/mount/read them. + +Check: +- picker URL type/provider +- security-scoped access lifetime +- bookmark validity if used +- Application Support copy completion if import-copy strategy is used +- relative path normalization and case sensitivity +- gameinfo.txt detection +- portal/hl2/platform search path order +- VPK _dir file and numbered archive-part discovery +- 64-bit offsets/seek behavior +- partial reads and short-read handling +- file-provider eviction/unavailability +- manifest mismatch after relaunch + +Never solve this by downloading Portal assets or committing them to the repo. +``` + +# Recovery Prompt E — Signing/provisioning failure + +```text +The unsigned IPA builds but the signed workflow fails. + +Inspect the exact failing signing command and decoded provisioning metadata without printing secret/private material. + +Verify: +- certificate is valid PKCS#12 and password is correct +- certificate identity is visible in the temporary keychain +- provisioning profile UUID/name/team/application-identifier are readable +- bundle identifier matches the profile entitlement pattern +- DEVELOPMENT_TEAM is correct +- CODE_SIGN_IDENTITY matches certificate type +- profile is installed at the expected path +- generated app entitlements are compatible with the profile +- no embedded framework is left unsigned +- codesign verification passes before packaging + +Do not commit certificates, private keys, provisioning profiles or decoded secret values. +``` + +# Recovery Prompt F — Memory growth / jetsam loop + +```text +The app is killed or memory grows across maps. + +Do not immediately lower texture quality. First find retention. + +Measure at fixed checkpoints: +- menu stable +- chamber load peak +- chamber stable +- old-map teardown +- next chamber stable +- return to previous chamber + +Inspect: +- BSP/world references +- model cache references +- material/texture refcounts +- render targets +- physics objects +- sound buffers +- particle systems +- transition overlay resources +- VPK/read caches +- autorelease pools / Objective-C objects retained by native host +- per-map diagnostics/log buffers + +Use repeated transitions to separate legitimate cache warmup from monotonic leaks. Purge only resources proven unused. +``` + +--- + +# Agent handoff prompt + +Use this between phases when a different AI/coding session continues the work. + +```text +Continue the Render360 native iOS port on branch render360/ios-native. + +First read docs/IOS_NATIVE_ZERO_TO_IPA_PROMPT_PACK.md and the current roadmap/audit/readiness files. Inspect the latest commit and current CI; do not assume the previous agent's narrative is correct. + +Determine the highest phase whose acceptance criteria are actually proven. Resume from the first unproven phase. Preserve all earlier passing gates. + +Before editing, state internally: +- last proven phase +- first unproven phase +- exact current blocker +- exact files/subsystems involved + +Then implement the smallest correct step toward that phase's acceptance criteria, test it, inspect CI when available, and update the status docs. Never skip ahead to later visual polish while an earlier engine/filesystem/render/gameplay gate is still fake or unproven. +``` + +# Short one-shot master prompt + +Use this only with an agent capable of long multi-step repository work and CI iteration. + +```text +Implement the native iOS port of matthewcodergamer/source-engine-render360 on render360/ios-native from its current state all the way to the highest verifiable milestone, following docs/IOS_NATIVE_ZERO_TO_IPA_PROMPT_PACK.md exactly. + +Work phase by phase. Inspect the repository and CI before every phase. Do not skip failed gates. Do not replace Source/Portal with mocks. Do not commit retail Portal assets. Keep iPhone 11 memory limits and current-map-only residency as core constraints. Use SDL2 for the native host, GLES3 only as the temporary first-pixels backend, and Metal as the eventual renderer path. Use a static Source module registry and native seekable filesystem/VPK I/O. Keep UIKit for native import/setup where appropriate. Build unsigned IPA artifacts continuously and maintain the signed workflow without exposing credentials. + +After each phase, fix CI failures before moving on. Stop only when the remaining requirement genuinely needs external user-owned Portal files, Apple signing material, or physical-device evidence that is not available to the agent. In that case leave exact instructions/checkpoints, not a claim of completion. +``` From dd1833b5c78b21407286ef1f806909c3406535fc Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Mon, 14 Sep 2026 22:14:45 -0400 Subject: [PATCH 138/159] docs: make zero-to-IPA prompt pack the native implementation entry point --- ios-native/README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/ios-native/README.md b/ios-native/README.md index a1639fb3da..c839c6e0f4 100644 --- a/ios-native/README.md +++ b/ios-native/README.md @@ -2,6 +2,12 @@ This directory is the primary iPhone port target. The browser/WebAssembly work remains preserved on the existing Render360 branches, but native iOS no longer depends on Safari, WebAssembly, MEMFS/WORKERFS, SharedArrayBuffer, COOP/COEP, service workers, or browser fullscreen behavior. +## Start here for AI implementation + +Use `docs/IOS_NATIVE_ZERO_TO_IPA_PROMPT_PACK.md` as the canonical zero-to-complete execution prompt pack. It contains the global implementation contract, phase prompts from bootstrap through signed IPA and final release audit, plus dedicated recovery prompts for CI, device crashes, renderer black screens, Portal import/VPK failures, signing failures and memory/jetsam problems. + +`docs/IOS_NATIVE_AI_PROMPTS.md` remains the original N0-N14 prompt set and useful supporting reference. If the two differ in execution procedure, use the newer zero-to-IPA prompt pack while preserving architecture constraints from the master plan and architecture documents. + ## Scope The target is a real arm64 iPhone application, not a web wrapper and not a mock renderer. @@ -108,4 +114,6 @@ xcodebuild \ build ``` -See `docs/IOS_NATIVE_AI_PROMPTS.md` for the zero-to-complete implementation prompts. \ No newline at end of file +Primary implementation prompts: `docs/IOS_NATIVE_ZERO_TO_IPA_PROMPT_PACK.md`. +Supporting roadmap: `docs/IOS_NATIVE_MASTER_PLAN.md`. +Original prompt set: `docs/IOS_NATIVE_AI_PROMPTS.md`. From e227ef1d93cfca834b37b4da105a748ebf0ac8ea Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Tue, 15 Sep 2026 20:15:37 -0400 Subject: [PATCH 139/159] docs: add native iOS phase 00 porting audit --- docs/IOS_NATIVE_PORTING_AUDIT.md | 274 +++++++++++++++++++++++++++++++ 1 file changed, 274 insertions(+) create mode 100644 docs/IOS_NATIVE_PORTING_AUDIT.md diff --git a/docs/IOS_NATIVE_PORTING_AUDIT.md b/docs/IOS_NATIVE_PORTING_AUDIT.md new file mode 100644 index 0000000000..f59399ff50 --- /dev/null +++ b/docs/IOS_NATIVE_PORTING_AUDIT.md @@ -0,0 +1,274 @@ +# Render360 Portal Native iOS — Porting Audit + +Status: Phase 00 baseline audit for branch `render360/ios-native`. + +This document records what is actually implemented, what is only planned, what must stay out of the native target, and the exact next areas to touch. It is not evidence that Portal gameplay is already running on iOS. + +## Audited baseline + +The native branch currently contains a deliberately small UIKit bootstrap under `ios-native/` plus two native GitHub Actions workflows. The bootstrap is an ordinary ARM64 iPhoneOS application target generated by CMake/Xcode. It does not yet link SDL or the Source engine. + +Implemented now: + +- `ios-native/CMakeLists.txt` creates the `Render360Portal` iOS app target. +- `ios-native/Sources/main.mm` launches UIKit, presents a folder picker, starts/stops security-scoped access for the selected URL, and validates a candidate Portal root by checking `portal/gameinfo.txt`, `portal/`, `hl2/`, `platform/`, and at least one VPK. +- `ios-native/Info.plist` declares an ARM64 iOS app, document access, landscape orientations, and a launch screen dictionary. +- `.github/workflows/ios-native.yml` configures and builds an unsigned ARM64 iPhoneOS app and is intended to package `Render360-Portal-iOS-unsigned.ipa`. +- `.github/workflows/ios-native-signed.yml` provides a manually triggered certificate/provisioning-profile signing path. +- CI rejects obvious retail game files under `ios-native/` (`*.vpk`, `*.bsp`, `*.vtf`, `*.vcs`, `*.wav`). + +Not implemented yet: + +- SDL2 iOS host, GLES context, native audio device, or production touch/controller input. +- Native Source static-library build graph. +- iOS platform shims for Source. +- Static Source module registry. +- Persistent Portal import/authorization and Source VPK I/O. +- Source launcher/PreInit on iOS. +- Source renderer on GLES or Metal. +- `background1`, chamber gameplay, Source audio, map lifecycle, or iPhone 11 performance proof. +- Physical-device proof for the current bootstrap in this repository history. + +## Phase 00 CI finding and fix target + +The first native IPA workflow proved that CMake configuration and the ARM64 iPhoneOS build succeed, but packaging failed after the build. The generated CMake/Xcode project placed the app at: + +`build/ios/Release-iphoneos/Render360Portal.app` + +while the workflow only searched: + +`build/DerivedData/Build/Products/Release-iphoneos/Render360Portal.app` + +The native workflow must resolve the actual CMake/Xcode product directory before declaring N0 artifact creation complete. The same path assumption exists in the signed workflow and must be corrected there too. + +## Repository/module inventory + +### Source foundations to reuse + +These existing directories are the first native Source build candidates: + +1. `tier0/` — low-level diagnostics, CPU/platform, command-line, threading-adjacent utilities. Desktop-only MASM/Windows pieces must be excluded. +2. `tier1/` — interface loading, utility containers/helpers, KeyValues and related infrastructure. Native module loading requires an iOS registry path later. +3. `mathlib/` — vector/matrix/math foundation; needs ARM64/alignment/SIMD audit. +4. `vstdlib/` — Source utility runtime used by upper layers. +5. `appframework/` — app-system lifecycle and factory plumbing. +6. `filesystem/` — `basefilesystem.cpp`, `filesystem_stdio.cpp`, pack/VPK paths and async I/O. Linux/Steam-specific implementations must not be selected blindly for iOS. +7. `datacache/` — model/resource cache infrastructure required later by engine/rendering. +8. `inputsystem/` — only platform-neutral pieces should enter the early native build; SDL/iOS is the platform input provider. +9. `vphysics/` plus the pinned `ivp` submodule — physics integration/foundation. + +### Upper Source modules present for later phases + +The repository also contains the engine/render/game systems that will be brought in only after the foundations compile: + +- `engine/` +- `materialsystem/` +- `studiorender/` +- shader API / shader-system code under the material-system tree +- `launcher/` +- client/server game code +- VGUI/GameUI and supporting UI systems +- sound/particle/resource subsystems as demanded by Portal startup and the first maps + +These are not Phase 00/N1 compile targets. + +## Pinned submodules + +The branch records three Source-related submodules: + +- `thirdparty` -> `nillerusr/source-thirdparty` at `c5b901ecef515ea068fa8b8a19ca5cd5353905cb` +- `ivp` -> `nillerusr/source-physics` at `47533475e01cbff05fbc3bbe8b4edc485f292cea` +- `lib` -> `nillerusr/source-engine-libs` at `86a66ee92d9fda0a09f54a435e850faa7ab5d0fa` + +Because Git submodule SHAs are pinned, checkout is reproducible as long as those upstream objects remain available. The native build must not silently track a moving submodule branch. Any future SDL dependency must likewise be pinned to a concrete release/tag/commit and documented. + +## Browser/Emscripten architecture that must not enter native iOS + +The entire `emscripten/` runtime/build layer is reference-only for the native target. Important browser-only pieces include: + +- `emscripten/build.sh` +- `emscripten/get_emscripten.sh` +- `emscripten/pre.js` / `post.js` +- `emscripten/phase3-workerfs.js` +- `emscripten/phase3-mobile-runtime.js` +- `emscripten/portal-local-vpk.js` +- `emscripten/portal-boot-overlay.js` +- the Pages/service-worker/staging JavaScript +- `emscripten/libwebgl.patch` +- Emscripten pkg-config/toolchain content + +The web build also applies `__EMSCRIPTEN__`-specific loader/startup patches and depends on concepts such as SIDE_MODULE/MAIN_MODULE, `dlopen` of Wasm side modules, SharedArrayBuffer/pthreads, `PROXY_TO_PTHREAD`, OffscreenCanvas, WORKERFS/MEMFS, JavaScript `File` objects, browser preload `.data` packages, WebGL and service-worker delivery. None of those are native-iOS dependencies. + +Native CMake targets must not compile `emscripten/**` or define `__EMSCRIPTEN__`. + +## Desktop/Linux assumptions requiring deliberate iOS treatment + +The following areas must be audited as each Source library is linked. Do not pre-emptively stub them all. + +### Dynamic modules/interfaces + +Risk: desktop Source expects `.dll`/`.so` loading and factory lookup. + +Plan: retain normal Source interface semantics but route iOS module names to statically linked factories through the planned module registry. `tier1/interface.cpp` and callers are the key later audit point. + +### Filesystem and paths + +Risk: executable working-directory assumptions, Steam paths, Linux support helpers, case sensitivity, unrestricted home-directory access, whole-file loading. + +Plan: app-controlled game root under Application Support or a correctly persisted security-scoped location; preserve `filesystem_stdio`/VPK random access using native seek/read operations. Early targets are `filesystem/basefilesystem.cpp`, `filesystem/filesystem_stdio.cpp`, pack/VPK code and the narrow platform path layer. + +### Threading/TLS/atomics/timing + +Risk: x86 assumptions, desktop thread naming/priorities, Linux/Windows TLS APIs and timers. + +Plan: ARM64-safe atomics/alignment; pthread/standard C++ primitives where compatible; monotonic iOS/Darwin timing adapters only where Source needs them. First audit targets are `tier0/`, `tier1/`, and the public platform headers they consume. + +### Process/signals/environment + +Risk: fork/exec, shell/process launch, desktop signals and mutable global environment assumptions. + +Plan: do not emulate process launching unless a required in-process Source subsystem proves it needs equivalent behavior. Unsupported required calls must fail with a named caller/subsystem rather than silently succeeding. + +### Graphics/context creation + +Risk: GLX/WGL/desktop OpenGL and browser WebGL assumptions. + +Plan: N1 creates an SDL-controlled GLES3 bring-up context behind a backend boundary. Later Source/ToGL work adapts only the calls Source actually reaches. GLES is a temporary compatibility backend; Metal is the production migration target. + +### Input + +Risk: desktop keyboard/mouse/window-system paths. + +Plan: SDL iOS touch/controller events feed a small native input layer, then map into Source input actions in later phases. + +### Audio + +Risk: desktop audio backends and blocking I/O from real-time callbacks. + +Plan: N1 proves an SDL/CoreAudio device with synthetic silence/test data. Source audio is integrated only after the engine reaches the required startup phase. + +### Networking + +Risk: desktop socket helpers and services that Portal single-player startup may not need immediately. + +Plan: include sockets only when a linked Source subsystem proves they are required; use Darwin/POSIX sockets through a focused adapter. + +## Graphics blockers expected later + +The existing Source renderer is materially closer to desktop OpenGL than Metal. Expected GLES bring-up blockers include fixed-function remnants, desktop-only base-vertex variants, texture-level queries, buffer mapping/storage differences, sync/fence behavior, framebuffer/blit extension differences, texture compression/format mapping, and desktop GLSL versus GLSL ES syntax/precision. + +Do not solve these in Phase 00. N1 only proves a native GLES context; renderer compatibility belongs to the later renderer phase. + +## Importer/filesystem blockers + +The N0 picker validates a candidate root only for the duration of the picker callback. It intentionally does not yet persist a bookmark/security scope or copy data into Application Support. N4 must choose and prove the persistence model, build an import manifest, verify free space when copying, and connect the result to Source `filesystem_stdio`/VPK reads without loading archives wholesale into RAM. + +## Audio/input blockers + +UIKit currently owns all interaction and no SDL runtime exists yet. N1 must establish: + +- SDL lifecycle/event loop +- landscape native game window +- Retina drawable-size handling +- GLES3 context creation +- touch coordinates and multitouch diagnostics +- controller connect/disconnect diagnostics +- CoreAudio-backed SDL audio device +- interruption/background/foreground recovery +- memory-warning checkpointing + +## Native workflow audit + +### Unsigned workflow + +Architecture is correct in principle: macOS runner -> CMake Xcode project -> iphoneos ARM64 Release -> unsigned `.app` -> `Payload/Render360Portal.app` -> IPA artifact. Retail assets are checked before configuration. + +Concrete defect found: product-path lookup assumes DerivedData even though the CMake Xcode generator emitted the Release product under `build/ios/Release-iphoneos`. Fix the packaging step to resolve the actual generated product location and fail with useful diagnostics if it cannot be found. + +### Signed workflow + +The workflow correctly keeps certificate/profile material in GitHub Secrets/variables and a temporary keychain, then manually signs on a macOS runner. It repeats the same incorrect app-product lookup and must use the same robust resolver as the unsigned path. A signed build remains externally blocked until valid Apple signing material and a profile matching the chosen bundle/device are supplied. + +## Retail-data boundary + +Phase 00 CI has already demonstrated that no obvious `*.vpk`, `*.bsp`, `*.vtf`, `*.vcs`, or `*.wav` retail files exist under `ios-native/`. Native workflows do not download Portal data. Synthetic fixtures only are permitted in repository tests. + +Future hardening may expand the forbidden-extension scan when new native fixture directories are added, but it must not scan Source-owned open-source code/resources in a way that creates false positives. + +## Dependency/phase map + +| Phase | Depends on | Current status | +| --- | --- | --- | +| 00 Audit/baseline | existing branch | In progress until unsigned IPA artifact is proven after path fix | +| 01 N0 hardening | 00 | Not started | +| 02 N1 SDL host | 01 | Not started | +| 03 N2 Source foundations | 02 | Not started | +| 04 N2B iOS shims | 03 | Not started | +| 05 static module registry | 04 | Not started | +| 06 Portal importer/native VPK | 05 foundation + N0 picker | Not started | +| 07 launcher/PreInit | 05 + 06 | Not started | +| 08 GLES/ToGL | 07 | Not started | +| 09 background1 | 08 | Not started | +| 10 chamber 00 gameplay | 09 | Not started | +| 11 production touch/controller | 10 | Not started | +| 12 Source audio | 10/11 | Not started | +| 13 map memory lifecycle | 10 | Not started | +| 14 hidden transitions | 13 | Not started | +| 15 iPhone 11 performance | gameplay + memory | Not started | +| 16 Metal migration | proven GLES gameplay | Not started | +| 17 production signing/release | stable native runtime | Not started | +| 18 final audit | all prior gates | Not started | + +## Exact next code targets + +### Phase 01 — N0 bootstrap hardening + +Read/edit first: + +- `ios-native/CMakeLists.txt` +- `ios-native/Info.plist` +- `ios-native/Sources/main.mm` +- `.github/workflows/ios-native.yml` +- `.github/workflows/ios-native-signed.yml` +- `docs/IOS_NATIVE_BUILD_AND_SIGNING.md` + +Add only lightweight bootstrap diagnostics and CI/package validation. Do not link Source yet. + +### Phase 02 — N1 SDL host + +Expected first native locations: + +- `ios-native/CMakeLists.txt` +- `ios-native/Sources/main.mm` (split UIKit setup/import from game host rather than growing one file forever) +- new focused native host/lifecycle/renderer-bootstrap files under `ios-native/Sources/` +- pinned SDL source/framework integration location documented in `ios-native/README.md` +- `ios-native/Info.plist` +- `.github/workflows/ios-native.yml` + +### Phase 03 — N2 Source foundations + +Expected Source areas: + +- `tier0/` +- `tier1/` +- `mathlib/` +- `vstdlib/` +- `appframework/` +- `filesystem/basefilesystem.cpp` +- `filesystem/filesystem_stdio.cpp` +- pack/VPK files under `filesystem/` +- `datacache/` +- platform-neutral `inputsystem/` files only +- `vphysics/` plus pinned `ivp` submodule pieces required for the first synthetic physics test +- a new focused CMake/platform layer under `ios-native/` rather than editing every Source directory indiscriminately + +## Phase 00 exit criteria + +Phase 00 is complete only when: + +1. this audit is present and points to the exact next native areas; +2. the known IPA product-path packaging defect is fixed in both native workflows; +3. a new unsigned native CI run reaches artifact upload with an ARM64 executable; +4. no retail Portal data is introduced. + +Physical-device launch is intentionally not claimed by Phase 00; that remains an explicit N0/Phase 01 acceptance gate. From 3a0dcfcbecde1f16d0c7865d1ba1b45affeeb81a Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Tue, 15 Sep 2026 20:15:55 -0400 Subject: [PATCH 140/159] ci: fix native iOS IPA product discovery --- .github/workflows/ios-native.yml | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ios-native.yml b/.github/workflows/ios-native.yml index 4979f7abe6..02528a6bb7 100644 --- a/.github/workflows/ios-native.yml +++ b/.github/workflows/ios-native.yml @@ -71,13 +71,36 @@ jobs: shell: bash run: | set -euxo pipefail - APP_PATH="$(find build/DerivedData/Build/Products/Release-iphoneos -maxdepth 1 -type d -name 'Render360Portal.app' -print -quit)" - test -n "$APP_PATH" + + resolve_app_path() { + local candidate + for candidate in \ + build/ios/Release-iphoneos/Render360Portal.app \ + build/DerivedData/Build/Products/Release-iphoneos/Render360Portal.app; do + if [ -d "$candidate" ]; then + printf '%s\n' "$candidate" + return 0 + fi + done + + find build -type d -path '*/Release-iphoneos/Render360Portal.app' -print -quit 2>/dev/null || true + } + + APP_PATH="$(resolve_app_path)" + if [ -z "$APP_PATH" ] || [ ! -d "$APP_PATH" ]; then + echo 'Render360 Portal: could not locate the built iPhoneOS .app bundle.' >&2 + find build -maxdepth 6 -type d -name '*.app' -print 2>/dev/null || true + exit 1 + fi + test -f "$APP_PATH/Render360Portal" file "$APP_PATH/Render360Portal" lipo -info "$APP_PATH/Render360Portal" | tee build/architecture.txt grep -q 'arm64' build/architecture.txt + /usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$APP_PATH/Info.plist" + /usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$APP_PATH/Info.plist" | grep -qx 'Render360Portal' + rm -rf build/ipa mkdir -p build/ipa/Payload ditto "$APP_PATH" build/ipa/Payload/Render360Portal.app @@ -90,9 +113,12 @@ jobs: arch=arm64 signing=unsigned retail_assets=bundled:no + source_app_path=${APP_PATH} INFO (cd build/ipa && /usr/bin/zip -qry ../Render360-Portal-iOS-unsigned.ipa Payload BUILD_INFO.txt) + test -s build/Render360-Portal-iOS-unsigned.ipa + /usr/bin/unzip -l build/Render360-Portal-iOS-unsigned.ipa | grep -q 'Payload/Render360Portal.app/Render360Portal' ls -lh build/Render360-Portal-iOS-unsigned.ipa - name: Upload unsigned IPA From 38ba0e297749a1134a6b1b938a7d300ce5210d54 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Tue, 15 Sep 2026 20:16:24 -0400 Subject: [PATCH 141/159] ci: fix signed iOS app product discovery --- .github/workflows/ios-native-signed.yml | 30 ++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ios-native-signed.yml b/.github/workflows/ios-native-signed.yml index 88150a8f89..8675a9d5e8 100644 --- a/.github/workflows/ios-native-signed.yml +++ b/.github/workflows/ios-native-signed.yml @@ -107,16 +107,40 @@ jobs: shell: bash run: | set -euxo pipefail - APP_PATH="$(find build/DerivedData/Build/Products/Release-iphoneos -maxdepth 1 -type d -name 'Render360Portal.app' -print -quit)" - test -n "$APP_PATH" + + resolve_app_path() { + local candidate + for candidate in \ + build/ios/Release-iphoneos/Render360Portal.app \ + build/DerivedData/Build/Products/Release-iphoneos/Render360Portal.app; do + if [ -d "$candidate" ]; then + printf '%s\n' "$candidate" + return 0 + fi + done + + find build -type d -path '*/Release-iphoneos/Render360Portal.app' -print -quit 2>/dev/null || true + } + + APP_PATH="$(resolve_app_path)" + if [ -z "$APP_PATH" ] || [ ! -d "$APP_PATH" ]; then + echo 'Render360 Portal: could not locate the signed iPhoneOS .app bundle.' >&2 + find build -maxdepth 6 -type d -name '*.app' -print 2>/dev/null || true + exit 1 + fi + + test -f "$APP_PATH/Render360Portal" codesign --verify --deep --strict --verbose=2 "$APP_PATH" codesign -d --entitlements :- "$APP_PATH" || true - lipo -info "$APP_PATH/Render360Portal" + lipo -info "$APP_PATH/Render360Portal" | grep -q 'arm64' + /usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$APP_PATH/Info.plist" rm -rf build/ipa-signed mkdir -p build/ipa-signed/Payload ditto "$APP_PATH" build/ipa-signed/Payload/Render360Portal.app (cd build/ipa-signed && /usr/bin/zip -qry ../Render360-Portal-iOS-signed.ipa Payload) + test -s build/Render360-Portal-iOS-signed.ipa + /usr/bin/unzip -l build/Render360-Portal-iOS-signed.ipa | grep -q 'Payload/Render360Portal.app/Render360Portal' ls -lh build/Render360-Portal-iOS-signed.ipa - name: Upload signed IPA From 725776742cd93e355052dce859b8e5c5fbffbe5d Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Tue, 15 Sep 2026 20:18:15 -0400 Subject: [PATCH 142/159] docs: mark native iOS phase 00 CI baseline complete --- docs/IOS_NATIVE_PORTING_AUDIT.md | 43 +++++++++++++++++++------------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/docs/IOS_NATIVE_PORTING_AUDIT.md b/docs/IOS_NATIVE_PORTING_AUDIT.md index f59399ff50..71aff289f3 100644 --- a/docs/IOS_NATIVE_PORTING_AUDIT.md +++ b/docs/IOS_NATIVE_PORTING_AUDIT.md @@ -1,6 +1,6 @@ # Render360 Portal Native iOS — Porting Audit -Status: Phase 00 baseline audit for branch `render360/ios-native`. +Status: **Phase 00 complete for repository + CI baseline.** Physical-device launch remains a Phase 01 gate. This document records what is actually implemented, what is only planned, what must stay out of the native target, and the exact next areas to touch. It is not evidence that Portal gameplay is already running on iOS. @@ -13,7 +13,7 @@ Implemented now: - `ios-native/CMakeLists.txt` creates the `Render360Portal` iOS app target. - `ios-native/Sources/main.mm` launches UIKit, presents a folder picker, starts/stops security-scoped access for the selected URL, and validates a candidate Portal root by checking `portal/gameinfo.txt`, `portal/`, `hl2/`, `platform/`, and at least one VPK. - `ios-native/Info.plist` declares an ARM64 iOS app, document access, landscape orientations, and a launch screen dictionary. -- `.github/workflows/ios-native.yml` configures and builds an unsigned ARM64 iPhoneOS app and is intended to package `Render360-Portal-iOS-unsigned.ipa`. +- `.github/workflows/ios-native.yml` configures and builds an unsigned ARM64 iPhoneOS app and packages `Render360-Portal-iOS-unsigned.ipa`. - `.github/workflows/ios-native-signed.yml` provides a manually triggered certificate/provisioning-profile signing path. - CI rejects obvious retail game files under `ios-native/` (`*.vpk`, `*.bsp`, `*.vtf`, `*.vcs`, `*.wav`). @@ -29,7 +29,7 @@ Not implemented yet: - `background1`, chamber gameplay, Source audio, map lifecycle, or iPhone 11 performance proof. - Physical-device proof for the current bootstrap in this repository history. -## Phase 00 CI finding and fix target +## Phase 00 CI finding and resolution The first native IPA workflow proved that CMake configuration and the ARM64 iPhoneOS build succeed, but packaging failed after the build. The generated CMake/Xcode project placed the app at: @@ -39,7 +39,18 @@ while the workflow only searched: `build/DerivedData/Build/Products/Release-iphoneos/Render360Portal.app` -The native workflow must resolve the actual CMake/Xcode product directory before declaring N0 artifact creation complete. The same path assumption exists in the signed workflow and must be corrected there too. +Both native workflows now resolve the actual CMake/Xcode product location, prefer the CMake Xcode generator's `build/ios/Release-iphoneos` product, retain the DerivedData path as a fallback, and print discovered `.app` bundles on failure. + +CI proof: + +- Native workflow run **#9** (`35039268789`) completed successfully on commit `3a0dcfcbecde1f16d0c7865d1ba1b45affeeb81a`. +- ARM64 configuration and build passed. +- Bundle verification and unsigned IPA packaging passed. +- Artifact upload passed. +- Artifact `Render360-Portal-iOS-unsigned-9` was created, SHA-256 digest `ac6afdd9899ac60bee7ba02909480b94cdd973d478c679ba30f7f046a7e5bd9a`. +- The retail-data guard passed in the same run. + +The signed workflow carries the same corrected product resolver. Signed execution is intentionally not part of Phase 00 because it requires user-supplied Apple signing credentials/profile; that external gate remains in the release/signing phase. ## Repository/module inventory @@ -181,17 +192,15 @@ UIKit currently owns all interaction and no SDL runtime exists yet. N1 must esta ### Unsigned workflow -Architecture is correct in principle: macOS runner -> CMake Xcode project -> iphoneos ARM64 Release -> unsigned `.app` -> `Payload/Render360Portal.app` -> IPA artifact. Retail assets are checked before configuration. - -Concrete defect found: product-path lookup assumes DerivedData even though the CMake Xcode generator emitted the Release product under `build/ios/Release-iphoneos`. Fix the packaging step to resolve the actual generated product location and fail with useful diagnostics if it cannot be found. +Architecture is now CI-proven for the bootstrap: macOS runner -> CMake Xcode project -> iphoneos ARM64 Release -> unsigned `.app` -> `Payload/Render360Portal.app` -> IPA artifact. Retail assets are checked before configuration. The workflow verifies the ARM64 executable, bundle metadata, IPA contents, and uploads the result. ### Signed workflow -The workflow correctly keeps certificate/profile material in GitHub Secrets/variables and a temporary keychain, then manually signs on a macOS runner. It repeats the same incorrect app-product lookup and must use the same robust resolver as the unsigned path. A signed build remains externally blocked until valid Apple signing material and a profile matching the chosen bundle/device are supplied. +The workflow keeps certificate/profile material in GitHub Secrets/variables and a temporary keychain, then manually signs on a macOS runner. It uses the same robust `.app` resolver as the unsigned workflow. A signed build remains externally blocked until valid Apple signing material and a profile matching the chosen bundle/device are supplied. ## Retail-data boundary -Phase 00 CI has already demonstrated that no obvious `*.vpk`, `*.bsp`, `*.vtf`, `*.vcs`, or `*.wav` retail files exist under `ios-native/`. Native workflows do not download Portal data. Synthetic fixtures only are permitted in repository tests. +Phase 00 CI demonstrated that no obvious `*.vpk`, `*.bsp`, `*.vtf`, `*.vcs`, or `*.wav` retail files exist under `ios-native/`. Native workflows do not download Portal data. Synthetic fixtures only are permitted in repository tests. Future hardening may expand the forbidden-extension scan when new native fixture directories are added, but it must not scan Source-owned open-source code/resources in a way that creates false positives. @@ -199,8 +208,8 @@ Future hardening may expand the forbidden-extension scan when new native fixture | Phase | Depends on | Current status | | --- | --- | --- | -| 00 Audit/baseline | existing branch | In progress until unsigned IPA artifact is proven after path fix | -| 01 N0 hardening | 00 | Not started | +| 00 Audit/baseline | existing branch | **Complete — repo + unsigned CI artifact proven** | +| 01 N0 hardening | 00 | Next | | 02 N1 SDL host | 01 | Not started | | 03 N2 Source foundations | 02 | Not started | | 04 N2B iOS shims | 03 | Not started | @@ -262,13 +271,11 @@ Expected Source areas: - `vphysics/` plus pinned `ivp` submodule pieces required for the first synthetic physics test - a new focused CMake/platform layer under `ios-native/` rather than editing every Source directory indiscriminately -## Phase 00 exit criteria - -Phase 00 is complete only when: +## Phase 00 exit criteria — result -1. this audit is present and points to the exact next native areas; -2. the known IPA product-path packaging defect is fixed in both native workflows; -3. a new unsigned native CI run reaches artifact upload with an ARM64 executable; -4. no retail Portal data is introduced. +1. **PASS** — this audit is present and points to the exact next native areas. +2. **PASS** — the IPA product-path packaging defect is fixed in both native workflows. +3. **PASS** — unsigned native CI run #9 reached artifact upload with an ARM64 executable. +4. **PASS** — the retail Portal-data guard passed and no retail game data was introduced. Physical-device launch is intentionally not claimed by Phase 00; that remains an explicit N0/Phase 01 acceptance gate. From aa066391224896c7944bd6bd729f7e16ed714f2d Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Tue, 15 Sep 2026 20:23:11 -0400 Subject: [PATCH 143/159] ios: harden N0 bootstrap diagnostics and IPA validation --- .github/workflows/ios-native-signed.yml | 54 ++++- .github/workflows/ios-native.yml | 86 +++++-- ios-native/CMakeLists.txt | 43 +++- ios-native/Info.plist | 11 +- .../Sources/R360BootstrapViewController.h | 8 + .../Sources/R360BootstrapViewController.mm | 217 ++++++++++++++++++ ios-native/Sources/R360Diagnostics.h | 24 ++ ios-native/Sources/R360Diagnostics.mm | 118 ++++++++++ ios-native/Sources/R360PortalValidator.h | 24 ++ ios-native/Sources/R360PortalValidator.mm | 103 +++++++++ ios-native/Sources/main.mm | 193 +--------------- 11 files changed, 662 insertions(+), 219 deletions(-) create mode 100644 ios-native/Sources/R360BootstrapViewController.h create mode 100644 ios-native/Sources/R360BootstrapViewController.mm create mode 100644 ios-native/Sources/R360Diagnostics.h create mode 100644 ios-native/Sources/R360Diagnostics.mm create mode 100644 ios-native/Sources/R360PortalValidator.h create mode 100644 ios-native/Sources/R360PortalValidator.mm diff --git a/.github/workflows/ios-native-signed.yml b/.github/workflows/ios-native-signed.yml index 8675a9d5e8..506a540fec 100644 --- a/.github/workflows/ios-native-signed.yml +++ b/.github/workflows/ios-native-signed.yml @@ -18,6 +18,18 @@ jobs: submodules: recursive fetch-depth: 1 + - name: Verify no retail Portal data is committed under native project + shell: bash + run: | + set -euo pipefail + if find ios-native -type f \( \ + -iname '*.vpk' -o -iname '*.bsp' -o -iname '*.vtf' -o -iname '*.vmt' -o \ + -iname '*.vcs' -o -iname '*.wav' -o -iname '*.mp3' -o -iname '*.mdl' \ + \) -print -quit | grep -q .; then + echo 'Retail/game asset file detected under ios-native/. Refusing signed package.' >&2 + exit 1 + fi + - name: Validate signing configuration env: BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }} @@ -78,7 +90,10 @@ jobs: -DCMAKE_OSX_SYSROOT=iphoneos \ -DCMAKE_OSX_ARCHITECTURES=arm64 \ -DCMAKE_OSX_DEPLOYMENT_TARGET=15.0 \ - -DRENDER360_BUNDLE_ID="$IOS_BUNDLE_ID" + -DRENDER360_DEPLOYMENT_TARGET=15.0 \ + -DRENDER360_BUNDLE_ID="$IOS_BUNDLE_ID" \ + -DRENDER360_BUILD_IDENTIFIER="${GITHUB_SHA}" \ + -DRENDER360_BUILD_NUMBER="${GITHUB_RUN_NUMBER}" - name: Build signed arm64 app env: @@ -118,7 +133,6 @@ jobs: return 0 fi done - find build -type d -path '*/Release-iphoneos/Render360Portal.app' -print -quit 2>/dev/null || true } @@ -129,19 +143,43 @@ jobs: exit 1 fi - test -f "$APP_PATH/Render360Portal" + EXECUTABLE="$APP_PATH/Render360Portal" + test -f "$EXECUTABLE" codesign --verify --deep --strict --verbose=2 "$APP_PATH" codesign -d --entitlements :- "$APP_PATH" || true - lipo -info "$APP_PATH/Render360Portal" | grep -q 'arm64' - /usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$APP_PATH/Info.plist" + test "$(lipo -archs "$EXECUTABLE")" = 'arm64' + + python3 - "$APP_PATH/Info.plist" "${{ vars.IOS_BUNDLE_ID }}" <<'PY' + import plistlib + import sys + + with open(sys.argv[1], 'rb') as handle: + info = plistlib.load(handle) + assert info.get('CFBundleIdentifier') == sys.argv[2] + assert info.get('CFBundleExecutable') == 'Render360Portal' + assert info.get('UIDeviceFamily') == [1] + assert info.get('CFBundleSupportedPlatforms') == ['iPhoneOS'] + assert info.get('MinimumOSVersion') == '15.0' + assert info.get('UISupportedInterfaceOrientations') == [ + 'UIInterfaceOrientationLandscapeLeft', + 'UIInterfaceOrientationLandscapeRight', + ] + print('Validated signed N0 bundle metadata') + PY rm -rf build/ipa-signed mkdir -p build/ipa-signed/Payload ditto "$APP_PATH" build/ipa-signed/Payload/Render360Portal.app (cd build/ipa-signed && /usr/bin/zip -qry ../Render360-Portal-iOS-signed.ipa Payload) - test -s build/Render360-Portal-iOS-signed.ipa - /usr/bin/unzip -l build/Render360-Portal-iOS-signed.ipa | grep -q 'Payload/Render360Portal.app/Render360Portal' - ls -lh build/Render360-Portal-iOS-signed.ipa + IPA=build/Render360-Portal-iOS-signed.ipa + test -s "$IPA" + /usr/bin/unzip -Z1 "$IPA" > build/signed-ipa-contents.txt + grep -qx 'Payload/Render360Portal.app/Render360Portal' build/signed-ipa-contents.txt + if grep -Eiq '\.(vpk|bsp|vtf|vmt|vcs|wav|mp3|mdl)$' build/signed-ipa-contents.txt; then + echo 'Retail/game asset extension detected inside signed IPA.' >&2 + exit 1 + fi + ls -lh "$IPA" - name: Upload signed IPA uses: actions/upload-artifact@v4 diff --git a/.github/workflows/ios-native.yml b/.github/workflows/ios-native.yml index 02528a6bb7..44b37586ec 100644 --- a/.github/workflows/ios-native.yml +++ b/.github/workflows/ios-native.yml @@ -36,7 +36,10 @@ jobs: shell: bash run: | set -euo pipefail - if find ios-native -type f \( -iname '*.vpk' -o -iname '*.bsp' -o -iname '*.vtf' -o -iname '*.vcs' -o -iname '*.wav' \) -print -quit | grep -q .; then + if find ios-native -type f \( \ + -iname '*.vpk' -o -iname '*.bsp' -o -iname '*.vtf' -o -iname '*.vmt' -o \ + -iname '*.vcs' -o -iname '*.wav' -o -iname '*.mp3' -o -iname '*.mdl' \ + \) -print -quit | grep -q .; then echo 'Retail/game asset file detected under ios-native/. Do not package Portal assets in the repository or IPA.' >&2 exit 1 fi @@ -49,7 +52,28 @@ jobs: -DCMAKE_OSX_SYSROOT=iphoneos \ -DCMAKE_OSX_ARCHITECTURES=arm64 \ -DCMAKE_OSX_DEPLOYMENT_TARGET=15.0 \ - -DRENDER360_BUNDLE_ID=com.render360.portal + -DRENDER360_DEPLOYMENT_TARGET=15.0 \ + -DRENDER360_BUNDLE_ID=com.render360.portal \ + -DRENDER360_BUILD_IDENTIFIER="${GITHUB_SHA}" \ + -DRENDER360_BUILD_NUMBER="${GITHUB_RUN_NUMBER}" + + - name: Verify generated iPhoneOS build settings + shell: bash + run: | + set -euo pipefail + xcodebuild \ + -project build/ios/Render360PortalIOS.xcodeproj \ + -scheme Render360Portal \ + -configuration Release \ + -sdk iphoneos \ + -showBuildSettings | tee build/xcode-build-settings.txt + + grep -Eq '^[[:space:]]*ARCHS = arm64$' build/xcode-build-settings.txt + grep -Eq '^[[:space:]]*SDKROOT = iphoneos([0-9.]+)?$' build/xcode-build-settings.txt + grep -Eq '^[[:space:]]*IPHONEOS_DEPLOYMENT_TARGET = 15\.0$' build/xcode-build-settings.txt + grep -Eq '^[[:space:]]*PRODUCT_BUNDLE_IDENTIFIER = com\.render360\.portal$' build/xcode-build-settings.txt + grep -Eq '^[[:space:]]*SUPPORTED_PLATFORMS = iphoneos$' build/xcode-build-settings.txt + grep -Eq '^[[:space:]]*TARGETED_DEVICE_FAMILY = 1$' build/xcode-build-settings.txt - name: Build unsigned arm64 app run: | @@ -67,7 +91,7 @@ jobs: ARCHS=arm64 \ build - - name: Verify arm64 bundle and package unsigned IPA + - name: Verify N0 bundle and package unsigned IPA shell: bash run: | set -euxo pipefail @@ -82,7 +106,6 @@ jobs: return 0 fi done - find build -type d -path '*/Release-iphoneos/Render360Portal.app' -print -quit 2>/dev/null || true } @@ -93,23 +116,48 @@ jobs: exit 1 fi - test -f "$APP_PATH/Render360Portal" - file "$APP_PATH/Render360Portal" - lipo -info "$APP_PATH/Render360Portal" | tee build/architecture.txt - grep -q 'arm64' build/architecture.txt - - /usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$APP_PATH/Info.plist" - /usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$APP_PATH/Info.plist" | grep -qx 'Render360Portal' + EXECUTABLE="$APP_PATH/Render360Portal" + test -f "$EXECUTABLE" + file "$EXECUTABLE" + lipo -archs "$EXECUTABLE" | tee build/architecture.txt + test "$(cat build/architecture.txt)" = 'arm64' + + python3 - "$APP_PATH/Info.plist" <<'PY' + import plistlib + import sys + + with open(sys.argv[1], 'rb') as handle: + info = plistlib.load(handle) + + assert info.get('CFBundleIdentifier') == 'com.render360.portal', info.get('CFBundleIdentifier') + assert info.get('CFBundleExecutable') == 'Render360Portal', info.get('CFBundleExecutable') + assert info.get('LSRequiresIPhoneOS') is True + assert info.get('UIFileSharingEnabled') is True + assert info.get('LSSupportsOpeningDocumentsInPlace') is True + assert info.get('UIStatusBarHidden') is True + assert info.get('UISupportedInterfaceOrientations') == [ + 'UIInterfaceOrientationLandscapeLeft', + 'UIInterfaceOrientationLandscapeRight', + ] + assert info.get('UIRequiredDeviceCapabilities') == ['arm64'] + assert info.get('UIDeviceFamily') == [1], info.get('UIDeviceFamily') + assert info.get('CFBundleSupportedPlatforms') == ['iPhoneOS'], info.get('CFBundleSupportedPlatforms') + assert info.get('MinimumOSVersion') == '15.0', info.get('MinimumOSVersion') + assert not any(key.startswith('NS') and key.endswith('UsageDescription') for key in info), 'unexpected privacy usage-description key' + print('Validated N0 Info.plist metadata') + PY rm -rf build/ipa mkdir -p build/ipa/Payload ditto "$APP_PATH" build/ipa/Payload/Render360Portal.app cat > build/ipa/BUILD_INFO.txt <&2 + exit 1 + fi + ls -lh "$IPA" - name: Upload unsigned IPA uses: actions/upload-artifact@v4 @@ -128,5 +182,7 @@ jobs: path: | build/Render360-Portal-iOS-unsigned.ipa build/architecture.txt + build/xcode-build-settings.txt + build/ipa-contents.txt if-no-files-found: error retention-days: 30 diff --git a/ios-native/CMakeLists.txt b/ios-native/CMakeLists.txt index d6cc974b03..6503143747 100644 --- a/ios-native/CMakeLists.txt +++ b/ios-native/CMakeLists.txt @@ -1,11 +1,22 @@ cmake_minimum_required(VERSION 3.25) -project(Render360PortalIOS LANGUAGES C CXX OBJC OBJCXX) +project(Render360PortalIOS VERSION 0.1.0 LANGUAGES C CXX OBJC OBJCXX) if(NOT IOS) message(FATAL_ERROR "Render360PortalIOS must be configured with -DCMAKE_SYSTEM_NAME=iOS") endif() +string(TOLOWER "${CMAKE_OSX_SYSROOT}" RENDER360_SYSROOT_LOWER) +if(RENDER360_SYSROOT_LOWER MATCHES "iphonesimulator") + message(FATAL_ERROR "Phase N0 targets physical iPhoneOS, not the iOS Simulator") +endif() + +if(NOT CMAKE_OSX_ARCHITECTURES) + set(CMAKE_OSX_ARCHITECTURES "arm64" CACHE STRING "Native iPhone architecture" FORCE) +elseif(NOT CMAKE_OSX_ARCHITECTURES STREQUAL "arm64") + message(FATAL_ERROR "Render360 native iOS currently requires exactly arm64; got '${CMAKE_OSX_ARCHITECTURES}'") +endif() + set(CMAKE_C_STANDARD 11) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -13,35 +24,55 @@ set(CMAKE_POSITION_INDEPENDENT_CODE ON) set(RENDER360_BUNDLE_ID "com.render360.portal" CACHE STRING "iOS bundle identifier") set(RENDER360_DEPLOYMENT_TARGET "15.0" CACHE STRING "Minimum iOS deployment target") +set(RENDER360_BUILD_IDENTIFIER "local" CACHE STRING "Build/commit identifier shown by bootstrap diagnostics") +set(RENDER360_BUILD_NUMBER "1" CACHE STRING "CFBundleVersion build number") + +if(CMAKE_OSX_DEPLOYMENT_TARGET AND NOT CMAKE_OSX_DEPLOYMENT_TARGET STREQUAL RENDER360_DEPLOYMENT_TARGET) + message(FATAL_ERROR + "CMAKE_OSX_DEPLOYMENT_TARGET (${CMAKE_OSX_DEPLOYMENT_TARGET}) must match RENDER360_DEPLOYMENT_TARGET (${RENDER360_DEPLOYMENT_TARGET})") +endif() add_executable(Render360Portal MACOSX_BUNDLE Sources/main.mm + Sources/R360BootstrapViewController.h + Sources/R360BootstrapViewController.mm + Sources/R360Diagnostics.h + Sources/R360Diagnostics.mm + Sources/R360PortalValidator.h + Sources/R360PortalValidator.mm ) target_compile_definitions(Render360Portal PRIVATE RENDER360_IOS_NATIVE=1 RENDER360_PORTAL_NATIVE_BOOTSTRAP=1 + RENDER360_BUILD_IDENTIFIER="${RENDER360_BUILD_IDENTIFIER}" ) target_link_libraries(Render360Portal PRIVATE "-framework UIKit" "-framework Foundation" "-framework UniformTypeIdentifiers" + "-framework QuartzCore" ) set_target_properties(Render360Portal PROPERTIES MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/Info.plist" XCODE_ATTRIBUTE_PRODUCT_BUNDLE_IDENTIFIER "${RENDER360_BUNDLE_ID}" XCODE_ATTRIBUTE_IPHONEOS_DEPLOYMENT_TARGET "${RENDER360_DEPLOYMENT_TARGET}" - XCODE_ATTRIBUTE_TARGETED_DEVICE_FAMILY "1,2" + XCODE_ATTRIBUTE_MARKETING_VERSION "${PROJECT_VERSION}" + XCODE_ATTRIBUTE_CURRENT_PROJECT_VERSION "${RENDER360_BUILD_NUMBER}" + XCODE_ATTRIBUTE_TARGETED_DEVICE_FAMILY "1" XCODE_ATTRIBUTE_ONLY_ACTIVE_ARCH "NO" + XCODE_ATTRIBUTE_ARCHS "arm64" XCODE_ATTRIBUTE_ENABLE_BITCODE "NO" XCODE_ATTRIBUTE_CLANG_ENABLE_OBJC_ARC "YES" - XCODE_ATTRIBUTE_SUPPORTED_PLATFORMS "iphoneos iphonesimulator" + XCODE_ATTRIBUTE_SUPPORTED_PLATFORMS "iphoneos" XCODE_ATTRIBUTE_SUPPORTS_MACCATALYST "NO" + XCODE_ATTRIBUTE_GENERATE_INFOPLIST_FILE "NO" XCODE_ATTRIBUTE_INFOPLIST_FILE "${CMAKE_CURRENT_SOURCE_DIR}/Info.plist" ) -# This target intentionally contains only the native host bootstrap. Source engine -# libraries are added incrementally by the N2+ roadmap gates. Keeping N0 tiny gives -# CI a deterministic arm64/IPA proof before the engine port is layered on top. +message(STATUS "Render360 iOS target: sdk=${CMAKE_OSX_SYSROOT} arch=${CMAKE_OSX_ARCHITECTURES} deployment=${RENDER360_DEPLOYMENT_TARGET} bundle=${RENDER360_BUNDLE_ID}") + +# N0 remains intentionally small. SDL arrives in N1 and Source libraries in N2+. +# Keeping those boundaries explicit makes every later engine milestone attributable. diff --git a/ios-native/Info.plist b/ios-native/Info.plist index e0a7b59972..4cb52a7fdb 100644 --- a/ios-native/Info.plist +++ b/ios-native/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.0.1 + $(MARKETING_VERSION) CFBundleVersion - 1 + $(CURRENT_PROJECT_VERSION) LSRequiresIPhoneOS UIRequiredDeviceCapabilities @@ -32,16 +32,13 @@ LSSupportsOpeningDocumentsInPlace + UIStatusBarHidden + UISupportedInterfaceOrientations UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - UILaunchScreen UIViewControllerBasedStatusBarAppearance diff --git a/ios-native/Sources/R360BootstrapViewController.h b/ios-native/Sources/R360BootstrapViewController.h new file mode 100644 index 0000000000..3a71a6b611 --- /dev/null +++ b/ios-native/Sources/R360BootstrapViewController.h @@ -0,0 +1,8 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface R360BootstrapViewController : UIViewController +@end + +NS_ASSUME_NONNULL_END diff --git a/ios-native/Sources/R360BootstrapViewController.mm b/ios-native/Sources/R360BootstrapViewController.mm new file mode 100644 index 0000000000..cb57a2bc1b --- /dev/null +++ b/ios-native/Sources/R360BootstrapViewController.mm @@ -0,0 +1,217 @@ +#import "R360BootstrapViewController.h" + +#import "R360Diagnostics.h" +#import "R360PortalValidator.h" + +#import + +static UIColor *R360Background(void) { + return [UIColor colorWithRed:0.035 green:0.043 blue:0.055 alpha:1.0]; +} + +static UIColor *R360Panel(void) { + return [UIColor colorWithRed:0.075 green:0.086 blue:0.105 alpha:1.0]; +} + +@interface R360BootstrapViewController () +@property(nonatomic, strong) UILabel *statusLabel; +@property(nonatomic, strong) UILabel *detailLabel; +@property(nonatomic, strong) UILabel *diagnosticsLabel; +@end + +@implementation R360BootstrapViewController + +- (void)viewDidLoad { + [super viewDidLoad]; + self.view.backgroundColor = R360Background(); + + UIScrollView *scrollView = [[UIScrollView alloc] init]; + scrollView.translatesAutoresizingMaskIntoConstraints = NO; + scrollView.alwaysBounceVertical = YES; + + UIStackView *stack = [[UIStackView alloc] init]; + stack.translatesAutoresizingMaskIntoConstraints = NO; + stack.axis = UILayoutConstraintAxisVertical; + stack.spacing = 12; + + UILabel *title = [[UILabel alloc] init]; + title.text = @"Render360 Portal"; + title.textColor = UIColor.whiteColor; + title.font = [UIFont systemFontOfSize:32 weight:UIFontWeightBold]; + + UILabel *subtitle = [[UILabel alloc] init]; + subtitle.text = @"Native iOS N0 bootstrap • arm64 • no WebAssembly"; + subtitle.textColor = [UIColor colorWithWhite:0.72 alpha:1.0]; + subtitle.font = [UIFont monospacedSystemFontOfSize:14 weight:UIFontWeightRegular]; + + UIView *panel = [[UIView alloc] init]; + panel.translatesAutoresizingMaskIntoConstraints = NO; + panel.backgroundColor = R360Panel(); + panel.layer.cornerRadius = 16; + + UIStackView *panelStack = [[UIStackView alloc] init]; + panelStack.translatesAutoresizingMaskIntoConstraints = NO; + panelStack.axis = UILayoutConstraintAxisVertical; + panelStack.spacing = 10; + + UILabel *status = [[UILabel alloc] init]; + status.numberOfLines = 0; + status.text = @"Portal data is not configured."; + status.textColor = UIColor.whiteColor; + status.font = [UIFont systemFontOfSize:20 weight:UIFontWeightSemibold]; + self.statusLabel = status; + + UILabel *detail = [[UILabel alloc] init]; + detail.numberOfLines = 0; + detail.text = @"Choose a legally owned Portal root when you are ready. Missing game data is a normal setup state and never a fatal bootstrap error."; + detail.textColor = [UIColor colorWithWhite:0.78 alpha:1.0]; + detail.font = [UIFont systemFontOfSize:15 weight:UIFontWeightRegular]; + self.detailLabel = detail; + + UIButtonConfiguration *buttonConfiguration = [UIButtonConfiguration filledButtonConfiguration]; + buttonConfiguration.title = @"Choose Portal Folder"; + buttonConfiguration.baseBackgroundColor = UIColor.whiteColor; + buttonConfiguration.baseForegroundColor = [UIColor colorWithRed:0.05 green:0.08 blue:0.12 alpha:1.0]; + buttonConfiguration.contentInsets = NSDirectionalEdgeInsetsMake(12, 18, 12, 18); + UIButton *importButton = [UIButton buttonWithConfiguration:buttonConfiguration primaryAction:nil]; + [importButton addTarget:self action:@selector(importPortalFolder:) forControlEvents:UIControlEventTouchUpInside]; + + UILabel *diagnosticsHeading = [[UILabel alloc] init]; + diagnosticsHeading.text = @"Bootstrap diagnostics"; + diagnosticsHeading.textColor = UIColor.whiteColor; + diagnosticsHeading.font = [UIFont systemFontOfSize:16 weight:UIFontWeightSemibold]; + + UILabel *diagnostics = [[UILabel alloc] init]; + diagnostics.numberOfLines = 0; + diagnostics.textColor = [UIColor colorWithWhite:0.66 alpha:1.0]; + diagnostics.font = [UIFont monospacedSystemFontOfSize:12 weight:UIFontWeightRegular]; + self.diagnosticsLabel = diagnostics; + + UILabel *footer = [[UILabel alloc] init]; + footer.numberOfLines = 0; + footer.text = @"This phase validates only the native bootstrap and a user-selected folder. It does not copy VPKs, mount Source filesystems, render Portal, or persist authorization yet."; + footer.textColor = [UIColor colorWithWhite:0.55 alpha:1.0]; + footer.font = [UIFont monospacedSystemFontOfSize:12 weight:UIFontWeightRegular]; + + [self.view addSubview:scrollView]; + [scrollView addSubview:stack]; + [stack addArrangedSubview:title]; + [stack addArrangedSubview:subtitle]; + [stack addArrangedSubview:panel]; + [panel addSubview:panelStack]; + [panelStack addArrangedSubview:status]; + [panelStack addArrangedSubview:detail]; + [panelStack addArrangedSubview:importButton]; + [stack addArrangedSubview:diagnosticsHeading]; + [stack addArrangedSubview:diagnostics]; + [stack addArrangedSubview:footer]; + + UILayoutGuide *safe = self.view.safeAreaLayoutGuide; + [NSLayoutConstraint activateConstraints:@[ + [scrollView.leadingAnchor constraintEqualToAnchor:safe.leadingAnchor], + [scrollView.trailingAnchor constraintEqualToAnchor:safe.trailingAnchor], + [scrollView.topAnchor constraintEqualToAnchor:safe.topAnchor], + [scrollView.bottomAnchor constraintEqualToAnchor:safe.bottomAnchor], + + [stack.leadingAnchor constraintEqualToAnchor:scrollView.contentLayoutGuide.leadingAnchor constant:24], + [stack.trailingAnchor constraintEqualToAnchor:scrollView.contentLayoutGuide.trailingAnchor constant:-24], + [stack.topAnchor constraintEqualToAnchor:scrollView.contentLayoutGuide.topAnchor constant:18], + [stack.bottomAnchor constraintEqualToAnchor:scrollView.contentLayoutGuide.bottomAnchor constant:-18], + [stack.widthAnchor constraintEqualToAnchor:scrollView.frameLayoutGuide.widthAnchor constant:-48], + + [panelStack.leadingAnchor constraintEqualToAnchor:panel.leadingAnchor constant:18], + [panelStack.trailingAnchor constraintEqualToAnchor:panel.trailingAnchor constant:-18], + [panelStack.topAnchor constraintEqualToAnchor:panel.topAnchor constant:16], + [panelStack.bottomAnchor constraintEqualToAnchor:panel.bottomAnchor constant:-16] + ]]; + + [NSNotificationCenter.defaultCenter addObserver:self + selector:@selector(diagnosticsDidChange:) + name:R360DiagnosticsDidChangeNotification + object:R360Diagnostics.sharedDiagnostics]; + [NSNotificationCenter.defaultCenter addObserver:self + selector:@selector(applicationDidReceiveMemoryWarning:) + name:UIApplicationDidReceiveMemoryWarningNotification + object:nil]; + + R360Diagnostics *diagnosticsState = R360Diagnostics.sharedDiagnostics; + [diagnosticsState setCheckpoint:@"ui-ready"]; + [diagnosticsState setGameDataState:@"not configured"]; + [diagnosticsState setLatestError:nil]; + [diagnosticsState setCheckpoint:@"game-data-not-configured"]; + [self refreshDiagnostics]; +} + +- (void)dealloc { + [NSNotificationCenter.defaultCenter removeObserver:self]; +} + +- (void)diagnosticsDidChange:(NSNotification *)notification { + [self refreshDiagnostics]; +} + +- (void)applicationDidReceiveMemoryWarning:(NSNotification *)notification { + [R360Diagnostics.sharedDiagnostics recordMemoryWarning]; +} + +- (void)refreshDiagnostics { + if (!self.isViewLoaded) { + return; + } + self.diagnosticsLabel.text = [R360Diagnostics.sharedDiagnostics formattedSummary]; +} + +- (void)importPortalFolder:(id)sender { + R360Diagnostics *diagnostics = R360Diagnostics.sharedDiagnostics; + [diagnostics setLatestError:nil]; + [diagnostics setCheckpoint:@"import-picker-open"]; + + UIDocumentPickerViewController *picker = [[UIDocumentPickerViewController alloc] + initForOpeningContentTypes:@[UTTypeFolder] + asCopy:NO]; + picker.delegate = self; + picker.allowsMultipleSelection = NO; + [self presentViewController:picker animated:YES completion:nil]; +} + +- (void)documentPickerWasCancelled:(UIDocumentPickerViewController *)controller { + [R360Diagnostics.sharedDiagnostics setCheckpoint:@"import-picker-cancelled"]; +} + +- (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentsAtURLs:(NSArray *)urls { + NSURL *rootURL = urls.firstObject; + if (!rootURL) { + [R360Diagnostics.sharedDiagnostics setLatestError:@"document picker returned no URL"]; + [R360Diagnostics.sharedDiagnostics setCheckpoint:@"candidate-root-invalid"]; + return; + } + + R360Diagnostics *diagnostics = R360Diagnostics.sharedDiagnostics; + [diagnostics setCheckpoint:@"candidate-root-validating"]; + [diagnostics setGameDataState:@"validating selected root"]; + + R360PortalValidationResult *result = [R360PortalValidator validateCandidateRootURL:rootURL]; + if (result.isValid) { + self.statusLabel.text = @"Portal folder verified for N0."; + self.detailLabel.text = result.detail; + [diagnostics setGameDataState:@"candidate root valid (temporary access only)"]; + [diagnostics setLatestError:nil]; + [diagnostics setCheckpoint:@"candidate-root-valid"]; + } else { + self.statusLabel.text = @"That folder is not a complete Portal root."; + self.detailLabel.text = result.detail; + [diagnostics setGameDataState:@"candidate root invalid"]; + [diagnostics setLatestError:result.errorReason ?: @"Portal root validation failed"]; + [diagnostics setCheckpoint:@"candidate-root-invalid"]; + } +} + +- (UIInterfaceOrientationMask)supportedInterfaceOrientations { + return UIInterfaceOrientationMaskLandscape; +} + +- (BOOL)prefersStatusBarHidden { + return YES; +} + +@end diff --git a/ios-native/Sources/R360Diagnostics.h b/ios-native/Sources/R360Diagnostics.h new file mode 100644 index 0000000000..bfd474c535 --- /dev/null +++ b/ios-native/Sources/R360Diagnostics.h @@ -0,0 +1,24 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +extern NSString * const R360DiagnosticsDidChangeNotification; + +@interface R360Diagnostics : NSObject + +@property(nonatomic, copy, readonly) NSString *checkpoint; +@property(nonatomic, copy, readonly) NSString *gameDataState; +@property(nonatomic, copy, readonly, nullable) NSString *latestError; +@property(nonatomic, assign, readonly) NSUInteger memoryWarningCount; + ++ (instancetype)sharedDiagnostics; + +- (void)setCheckpoint:(NSString *)checkpoint; +- (void)setGameDataState:(NSString *)state; +- (void)setLatestError:(nullable NSString *)error; +- (void)recordMemoryWarning; +- (NSString *)formattedSummary; + +@end + +NS_ASSUME_NONNULL_END diff --git a/ios-native/Sources/R360Diagnostics.mm b/ios-native/Sources/R360Diagnostics.mm new file mode 100644 index 0000000000..9944fd8609 --- /dev/null +++ b/ios-native/Sources/R360Diagnostics.mm @@ -0,0 +1,118 @@ +#import "R360Diagnostics.h" + +#import + +#ifndef RENDER360_BUILD_IDENTIFIER +#define RENDER360_BUILD_IDENTIFIER "local" +#endif + +NSString * const R360DiagnosticsDidChangeNotification = @"R360DiagnosticsDidChangeNotification"; + +@interface R360Diagnostics () +@property(nonatomic, copy, readwrite) NSString *checkpoint; +@property(nonatomic, copy, readwrite) NSString *gameDataState; +@property(nonatomic, copy, readwrite, nullable) NSString *latestError; +@property(nonatomic, assign, readwrite) NSUInteger memoryWarningCount; +@end + +@implementation R360Diagnostics + ++ (instancetype)sharedDiagnostics { + static R360Diagnostics *diagnostics; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + diagnostics = [[R360Diagnostics alloc] initPrivate]; + }); + return diagnostics; +} + +- (instancetype)init { + [NSException raise:NSInternalInconsistencyException format:@"Use +sharedDiagnostics"]; + return nil; +} + +- (instancetype)initPrivate { + self = [super init]; + if (self) { + _checkpoint = @"bootstrap-created"; + _gameDataState = @"not configured"; + _memoryWarningCount = 0; + } + return self; +} + +- (void)postChange { + [NSNotificationCenter.defaultCenter postNotificationName:R360DiagnosticsDidChangeNotification object:self]; +} + +- (void)setCheckpoint:(NSString *)checkpoint { + @synchronized (self) { + _checkpoint = [checkpoint copy]; + } + [self postChange]; +} + +- (void)setGameDataState:(NSString *)state { + @synchronized (self) { + _gameDataState = [state copy]; + } + [self postChange]; +} + +- (void)setLatestError:(NSString * _Nullable)error { + @synchronized (self) { + _latestError = [error copy]; + } + [self postChange]; +} + +- (void)recordMemoryWarning { + @synchronized (self) { + _memoryWarningCount += 1; + _checkpoint = @"memory-warning"; + } + [self postChange]; +} + +- (NSString *)architectureName { +#if defined(__arm64__) || defined(__aarch64__) + return @"arm64"; +#elif defined(__x86_64__) + return @"x86_64"; +#else + return @"unknown"; +#endif +} + +- (NSString *)formattedSummary { + NSString *checkpoint; + NSString *gameDataState; + NSString *latestError; + NSUInteger memoryWarningCount; + @synchronized (self) { + checkpoint = [_checkpoint copy]; + gameDataState = [_gameDataState copy]; + latestError = [_latestError copy]; + memoryWarningCount = _memoryWarningCount; + } + + NSBundle *bundle = NSBundle.mainBundle; + NSString *version = [bundle objectForInfoDictionaryKey:@"CFBundleShortVersionString"] ?: @"unknown"; + NSString *build = [bundle objectForInfoDictionaryKey:@"CFBundleVersion"] ?: @"unknown"; + NSString *commit = [NSString stringWithUTF8String:RENDER360_BUILD_IDENTIFIER] ?: @"unknown"; + NSString *osVersion = UIDevice.currentDevice.systemVersion ?: @"unknown"; + + return [NSString stringWithFormat: + @"version: %@ (%@)\ncommit: %@\narchitecture: %@\niOS: %@\ncheckpoint: %@\nmemory warnings: %lu\ngame data: %@\nlatest error: %@", + version, + build, + commit, + [self architectureName], + osVersion, + checkpoint, + (unsigned long)memoryWarningCount, + gameDataState, + latestError.length > 0 ? latestError : @"none"]; +} + +@end diff --git a/ios-native/Sources/R360PortalValidator.h b/ios-native/Sources/R360PortalValidator.h new file mode 100644 index 0000000000..801824f747 --- /dev/null +++ b/ios-native/Sources/R360PortalValidator.h @@ -0,0 +1,24 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface R360PortalValidationResult : NSObject + +@property(nonatomic, assign, readonly, getter=isValid) BOOL valid; +@property(nonatomic, assign, readonly) NSUInteger vpkCount; +@property(nonatomic, copy, readonly) NSString *detail; +@property(nonatomic, copy, readonly, nullable) NSString *errorReason; + +- (instancetype)initWithValid:(BOOL)valid + vpkCount:(NSUInteger)vpkCount + detail:(NSString *)detail + errorReason:(nullable NSString *)errorReason NS_DESIGNATED_INITIALIZER; +- (instancetype)init NS_UNAVAILABLE; + +@end + +@interface R360PortalValidator : NSObject ++ (R360PortalValidationResult *)validateCandidateRootURL:(NSURL *)rootURL; +@end + +NS_ASSUME_NONNULL_END diff --git a/ios-native/Sources/R360PortalValidator.mm b/ios-native/Sources/R360PortalValidator.mm new file mode 100644 index 0000000000..8ca21d3fbc --- /dev/null +++ b/ios-native/Sources/R360PortalValidator.mm @@ -0,0 +1,103 @@ +#import "R360PortalValidator.h" + +@implementation R360PortalValidationResult + +- (instancetype)initWithValid:(BOOL)valid + vpkCount:(NSUInteger)vpkCount + detail:(NSString *)detail + errorReason:(NSString * _Nullable)errorReason { + self = [super init]; + if (self) { + _valid = valid; + _vpkCount = vpkCount; + _detail = [detail copy]; + _errorReason = [errorReason copy]; + } + return self; +} + +@end + +@implementation R360PortalValidator + ++ (R360PortalValidationResult *)validateCandidateRootURL:(NSURL *)rootURL { + BOOL scoped = [rootURL startAccessingSecurityScopedResource]; + @try { + NSFileManager *fm = NSFileManager.defaultManager; + NSURL *gameInfoURL = [rootURL URLByAppendingPathComponent:@"portal/gameinfo.txt" isDirectory:NO]; + NSURL *portalURL = [rootURL URLByAppendingPathComponent:@"portal" isDirectory:YES]; + NSURL *hl2URL = [rootURL URLByAppendingPathComponent:@"hl2" isDirectory:YES]; + NSURL *platformURL = [rootURL URLByAppendingPathComponent:@"platform" isDirectory:YES]; + + BOOL rootIsDirectory = NO; + if (![fm fileExistsAtPath:rootURL.path isDirectory:&rootIsDirectory] || !rootIsDirectory) { + return [[R360PortalValidationResult alloc] initWithValid:NO + vpkCount:0 + detail:@"The selected item is not an accessible directory." + errorReason:@"candidate root is not an accessible directory"]; + } + + BOOL gameInfoIsDirectory = NO; + BOOL portalIsDirectory = NO; + BOOL hl2IsDirectory = NO; + BOOL platformIsDirectory = NO; + BOOL hasGameInfo = [fm fileExistsAtPath:gameInfoURL.path isDirectory:&gameInfoIsDirectory] && !gameInfoIsDirectory; + BOOL hasPortal = [fm fileExistsAtPath:portalURL.path isDirectory:&portalIsDirectory] && portalIsDirectory; + BOOL hasHL2 = [fm fileExistsAtPath:hl2URL.path isDirectory:&hl2IsDirectory] && hl2IsDirectory; + BOOL hasPlatform = [fm fileExistsAtPath:platformURL.path isDirectory:&platformIsDirectory] && platformIsDirectory; + + NSUInteger vpkCount = 0; + NSError *enumerationError = nil; + if (hasPortal) { + NSArray *portalEntries = [fm contentsOfDirectoryAtURL:portalURL + includingPropertiesForKeys:@[NSURLIsRegularFileKey] + options:NSDirectoryEnumerationSkipsHiddenFiles + error:&enumerationError]; + if (portalEntries) { + for (NSURL *entry in portalEntries) { + if ([entry.pathExtension.lowercaseString isEqualToString:@"vpk"]) { + ++vpkCount; + } + } + } + } + + if (enumerationError) { + NSString *reason = [NSString stringWithFormat:@"portal directory read failed: %@", enumerationError.localizedDescription]; + return [[R360PortalValidationResult alloc] initWithValid:NO + vpkCount:0 + detail:@"The portal/ directory exists but could not be enumerated. Check Files/provider access and try again." + errorReason:reason]; + } + + BOOL valid = hasGameInfo && hasPortal && hasHL2 && hasPlatform && vpkCount > 0; + if (valid) { + NSString *detail = [NSString stringWithFormat: + @"Found portal/gameinfo.txt, portal/, hl2/, platform/, and %lu top-level VPK file%@. Access is temporary in N0; persistence/import remains an N4 task.", + (unsigned long)vpkCount, + vpkCount == 1 ? @"" : @"s"]; + return [[R360PortalValidationResult alloc] initWithValid:YES + vpkCount:vpkCount + detail:detail + errorReason:nil]; + } + + NSString *detail = [NSString stringWithFormat: + @"Need a Portal root containing portal/gameinfo.txt, portal/, hl2/, platform/, and at least one VPK directly under portal/. Results: gameinfo=%@ portal=%@ hl2=%@ platform=%@ vpks=%lu", + hasGameInfo ? @"yes" : @"no", + hasPortal ? @"yes" : @"no", + hasHL2 ? @"yes" : @"no", + hasPlatform ? @"yes" : @"no", + (unsigned long)vpkCount]; + return [[R360PortalValidationResult alloc] initWithValid:NO + vpkCount:vpkCount + detail:detail + errorReason:@"candidate root is incomplete"]; + } @finally { + if (scoped) { + [rootURL stopAccessingSecurityScopedResource]; + } + } +} + +@end diff --git a/ios-native/Sources/main.mm b/ios-native/Sources/main.mm index c9c1617ef9..2547b6dd5c 100644 --- a/ios-native/Sources/main.mm +++ b/ios-native/Sources/main.mm @@ -1,203 +1,30 @@ #import -#import -static UIColor *R360Background(void) { - return [UIColor colorWithRed:0.035 green:0.043 blue:0.055 alpha:1.0]; -} - -static UIColor *R360Panel(void) { - return [UIColor colorWithRed:0.075 green:0.086 blue:0.105 alpha:1.0]; -} - -@interface R360ViewController : UIViewController -@property(nonatomic, strong) UILabel *statusLabel; -@property(nonatomic, strong) UILabel *detailLabel; -@end - -@implementation R360ViewController - -- (void)viewDidLoad { - [super viewDidLoad]; - self.view.backgroundColor = R360Background(); - - UILabel *title = [[UILabel alloc] init]; - title.translatesAutoresizingMaskIntoConstraints = NO; - title.text = @"Render360 Portal"; - title.textColor = UIColor.whiteColor; - title.font = [UIFont systemFontOfSize:34 weight:UIFontWeightBold]; - - UILabel *subtitle = [[UILabel alloc] init]; - subtitle.translatesAutoresizingMaskIntoConstraints = NO; - subtitle.text = @"Native iOS bootstrap • arm64 • no WebAssembly"; - subtitle.textColor = [UIColor colorWithWhite:0.72 alpha:1.0]; - subtitle.font = [UIFont monospacedSystemFontOfSize:15 weight:UIFontWeightRegular]; - - UIView *panel = [[UIView alloc] init]; - panel.translatesAutoresizingMaskIntoConstraints = NO; - panel.backgroundColor = R360Panel(); - panel.layer.cornerRadius = 18; - - UILabel *status = [[UILabel alloc] init]; - status.translatesAutoresizingMaskIntoConstraints = NO; - status.text = @"N0 native host is running."; - status.textColor = UIColor.whiteColor; - status.font = [UIFont systemFontOfSize:20 weight:UIFontWeightSemibold]; - self.statusLabel = status; - - UILabel *detail = [[UILabel alloc] init]; - detail.translatesAutoresizingMaskIntoConstraints = NO; - detail.numberOfLines = 0; - detail.text = @"Next gate: choose a legally owned Portal folder. This bootstrap verifies portal/gameinfo.txt and counts VPKs. Retail game data is never bundled into the IPA."; - detail.textColor = [UIColor colorWithWhite:0.78 alpha:1.0]; - detail.font = [UIFont systemFontOfSize:15 weight:UIFontWeightRegular]; - self.detailLabel = detail; - - UIButton *importButton = [UIButton buttonWithType:UIButtonTypeSystem]; - importButton.translatesAutoresizingMaskIntoConstraints = NO; - [importButton setTitle:@"Choose Portal Folder" forState:UIControlStateNormal]; - importButton.titleLabel.font = [UIFont systemFontOfSize:18 weight:UIFontWeightSemibold]; - importButton.backgroundColor = UIColor.whiteColor; - [importButton setTitleColor:[UIColor colorWithRed:0.05 green:0.08 blue:0.12 alpha:1.0] forState:UIControlStateNormal]; - importButton.layer.cornerRadius = 12; - importButton.contentEdgeInsets = UIEdgeInsetsMake(13, 18, 13, 18); - [importButton addTarget:self action:@selector(importPortalFolder:) forControlEvents:UIControlEventTouchUpInside]; - - UILabel *footer = [[UILabel alloc] init]; - footer.translatesAutoresizingMaskIntoConstraints = NO; - footer.numberOfLines = 0; - footer.text = @"Roadmap: SDL2 → native Source libraries → static module registry → VPK I/O → renderer → background1 → chamber 00 → touch/controller → current-map-only transitions."; - footer.textColor = [UIColor colorWithWhite:0.55 alpha:1.0]; - footer.font = [UIFont monospacedSystemFontOfSize:13 weight:UIFontWeightRegular]; - - [self.view addSubview:title]; - [self.view addSubview:subtitle]; - [self.view addSubview:panel]; - [panel addSubview:status]; - [panel addSubview:detail]; - [panel addSubview:importButton]; - [self.view addSubview:footer]; - - UILayoutGuide *safe = self.view.safeAreaLayoutGuide; - [NSLayoutConstraint activateConstraints:@[ - [title.leadingAnchor constraintEqualToAnchor:safe.leadingAnchor constant:28], - [title.topAnchor constraintEqualToAnchor:safe.topAnchor constant:24], - [subtitle.leadingAnchor constraintEqualToAnchor:title.leadingAnchor], - [subtitle.topAnchor constraintEqualToAnchor:title.bottomAnchor constant:6], - - [panel.leadingAnchor constraintEqualToAnchor:safe.leadingAnchor constant:28], - [panel.trailingAnchor constraintEqualToAnchor:safe.trailingAnchor constant:-28], - [panel.topAnchor constraintEqualToAnchor:subtitle.bottomAnchor constant:22], - - [status.leadingAnchor constraintEqualToAnchor:panel.leadingAnchor constant:22], - [status.trailingAnchor constraintEqualToAnchor:panel.trailingAnchor constant:-22], - [status.topAnchor constraintEqualToAnchor:panel.topAnchor constant:20], - [detail.leadingAnchor constraintEqualToAnchor:status.leadingAnchor], - [detail.trailingAnchor constraintEqualToAnchor:status.trailingAnchor], - [detail.topAnchor constraintEqualToAnchor:status.bottomAnchor constant:10], - [importButton.leadingAnchor constraintEqualToAnchor:status.leadingAnchor], - [importButton.topAnchor constraintEqualToAnchor:detail.bottomAnchor constant:18], - [importButton.bottomAnchor constraintEqualToAnchor:panel.bottomAnchor constant:-20], - - [footer.leadingAnchor constraintEqualToAnchor:panel.leadingAnchor], - [footer.trailingAnchor constraintEqualToAnchor:panel.trailingAnchor], - [footer.topAnchor constraintEqualToAnchor:panel.bottomAnchor constant:18], - [footer.bottomAnchor constraintLessThanOrEqualToAnchor:safe.bottomAnchor constant:-16] - ]]; -} - -- (void)importPortalFolder:(id)sender { - UIDocumentPickerViewController *picker = [[UIDocumentPickerViewController alloc] - initForOpeningContentTypes:@[UTTypeFolder] - asCopy:NO]; - picker.delegate = self; - picker.allowsMultipleSelection = NO; - [self presentViewController:picker animated:YES completion:nil]; -} - -- (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentsAtURLs:(NSArray *)urls { - NSURL *root = urls.firstObject; - if (!root) { - return; - } - - BOOL scoped = [root startAccessingSecurityScopedResource]; - @try { - NSFileManager *fm = NSFileManager.defaultManager; - NSURL *gameInfo = [root URLByAppendingPathComponent:@"portal/gameinfo.txt"]; - NSURL *portalDir = [root URLByAppendingPathComponent:@"portal" isDirectory:YES]; - NSURL *hl2Dir = [root URLByAppendingPathComponent:@"hl2" isDirectory:YES]; - NSURL *platformDir = [root URLByAppendingPathComponent:@"platform" isDirectory:YES]; - - BOOL portalIsDir = NO; - BOOL hl2IsDir = NO; - BOOL platformIsDir = NO; - BOOL hasPortal = [fm fileExistsAtPath:portalDir.path isDirectory:&portalIsDir] && portalIsDir; - BOOL hasHL2 = [fm fileExistsAtPath:hl2Dir.path isDirectory:&hl2IsDir] && hl2IsDir; - BOOL hasPlatform = [fm fileExistsAtPath:platformDir.path isDirectory:&platformIsDir] && platformIsDir; - BOOL hasGameInfo = [fm fileExistsAtPath:gameInfo.path]; - - NSUInteger vpkCount = 0; - if (hasPortal) { - NSDirectoryEnumerator *enumerator = [fm enumeratorAtURL:portalDir - includingPropertiesForKeys:nil - options:NSDirectoryEnumerationSkipsHiddenFiles - errorHandler:^BOOL(NSURL *url, NSError *error) { - return YES; - }]; - for (NSURL *url in enumerator) { - if ([url.pathExtension.lowercaseString isEqualToString:@"vpk"]) { - ++vpkCount; - } - } - } - - if (hasGameInfo && hasPortal && hasHL2 && hasPlatform && vpkCount > 0) { - self.statusLabel.text = @"Portal folder verified."; - self.detailLabel.text = [NSString stringWithFormat: - @"Found portal/gameinfo.txt, portal/, hl2/, platform/, and %lu VPK files. N4 will persist/import the authorized data for native Source I/O.", - (unsigned long)vpkCount]; - } else { - self.statusLabel.text = @"That folder is not a complete Portal root."; - self.detailLabel.text = [NSString stringWithFormat: - @"Need portal/gameinfo.txt plus portal/, hl2/, platform/ and VPKs. Results: gameinfo=%@ portal=%@ hl2=%@ platform=%@ vpks=%lu", - hasGameInfo ? @"yes" : @"no", - hasPortal ? @"yes" : @"no", - hasHL2 ? @"yes" : @"no", - hasPlatform ? @"yes" : @"no", - (unsigned long)vpkCount]; - } - } @finally { - if (scoped) { - [root stopAccessingSecurityScopedResource]; - } - } -} - -- (UIInterfaceOrientationMask)supportedInterfaceOrientations { - return UIInterfaceOrientationMaskLandscape; -} - -- (BOOL)prefersStatusBarHidden { - return YES; -} - -@end +#import "R360BootstrapViewController.h" +#import "R360Diagnostics.h" @interface R360AppDelegate : UIResponder @property(nonatomic, strong) UIWindow *window; @end @implementation R360AppDelegate + - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { self.window = [[UIWindow alloc] initWithFrame:UIScreen.mainScreen.bounds]; - self.window.rootViewController = [[R360ViewController alloc] init]; + self.window.rootViewController = [[R360BootstrapViewController alloc] init]; [self.window makeKeyAndVisible]; return YES; } + +- (void)applicationWillTerminate:(UIApplication *)application { + [R360Diagnostics.sharedDiagnostics setCheckpoint:@"application-will-terminate"]; +} + @end int main(int argc, char *argv[]) { @autoreleasepool { + [R360Diagnostics.sharedDiagnostics setCheckpoint:@"bootstrap-enter"]; return UIApplicationMain(argc, argv, nil, NSStringFromClass(R360AppDelegate.class)); } } From aedfb688d4b460aed52d7bbac510b06b2c18021b Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Tue, 15 Sep 2026 20:26:30 -0400 Subject: [PATCH 144/159] ios: disable Mac and vision compatibility for N0 --- ios-native/CMakeLists.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ios-native/CMakeLists.txt b/ios-native/CMakeLists.txt index 6503143747..0e2587d6ea 100644 --- a/ios-native/CMakeLists.txt +++ b/ios-native/CMakeLists.txt @@ -68,6 +68,8 @@ set_target_properties(Render360Portal PROPERTIES XCODE_ATTRIBUTE_CLANG_ENABLE_OBJC_ARC "YES" XCODE_ATTRIBUTE_SUPPORTED_PLATFORMS "iphoneos" XCODE_ATTRIBUTE_SUPPORTS_MACCATALYST "NO" + XCODE_ATTRIBUTE_SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD "NO" + XCODE_ATTRIBUTE_SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD "NO" XCODE_ATTRIBUTE_GENERATE_INFOPLIST_FILE "NO" XCODE_ATTRIBUTE_INFOPLIST_FILE "${CMAKE_CURRENT_SOURCE_DIR}/Info.plist" ) From 7ec9b1c397dfdfd7b7f69f65dfa79aa5ca1aa7aa Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Tue, 15 Sep 2026 20:26:48 -0400 Subject: [PATCH 145/159] ci: pin N0 build-setting inspection to iOS --- .github/workflows/ios-native.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ios-native.yml b/.github/workflows/ios-native.yml index 44b37586ec..db2517a4b0 100644 --- a/.github/workflows/ios-native.yml +++ b/.github/workflows/ios-native.yml @@ -66,6 +66,7 @@ jobs: -scheme Render360Portal \ -configuration Release \ -sdk iphoneos \ + -destination 'generic/platform=iOS' \ -showBuildSettings | tee build/xcode-build-settings.txt grep -Eq '^[[:space:]]*ARCHS = arm64$' build/xcode-build-settings.txt @@ -74,6 +75,8 @@ jobs: grep -Eq '^[[:space:]]*PRODUCT_BUNDLE_IDENTIFIER = com\.render360\.portal$' build/xcode-build-settings.txt grep -Eq '^[[:space:]]*SUPPORTED_PLATFORMS = iphoneos$' build/xcode-build-settings.txt grep -Eq '^[[:space:]]*TARGETED_DEVICE_FAMILY = 1$' build/xcode-build-settings.txt + grep -Eq '^[[:space:]]*SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO$' build/xcode-build-settings.txt + grep -Eq '^[[:space:]]*SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO$' build/xcode-build-settings.txt - name: Build unsigned arm64 app run: | From db05a2903012721a3bccd7223c051f081229dae3 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Tue, 15 Sep 2026 20:29:04 -0400 Subject: [PATCH 146/159] docs: record native iOS phase 01 N0 result --- docs/IOS_NATIVE_PORTING_AUDIT.md | 351 ++++++++++++++++--------------- 1 file changed, 186 insertions(+), 165 deletions(-) diff --git a/docs/IOS_NATIVE_PORTING_AUDIT.md b/docs/IOS_NATIVE_PORTING_AUDIT.md index 71aff289f3..a9530a01f7 100644 --- a/docs/IOS_NATIVE_PORTING_AUDIT.md +++ b/docs/IOS_NATIVE_PORTING_AUDIT.md @@ -1,21 +1,23 @@ # Render360 Portal Native iOS — Porting Audit -Status: **Phase 00 complete for repository + CI baseline.** Physical-device launch remains a Phase 01 gate. +Status: **Phase 01 / N0 repository and unsigned-CI hardening is complete. Physical-iPhone launch is still unverified and must not be claimed.** This document records what is actually implemented, what is only planned, what must stay out of the native target, and the exact next areas to touch. It is not evidence that Portal gameplay is already running on iOS. -## Audited baseline +## Current audited baseline -The native branch currently contains a deliberately small UIKit bootstrap under `ios-native/` plus two native GitHub Actions workflows. The bootstrap is an ordinary ARM64 iPhoneOS application target generated by CMake/Xcode. It does not yet link SDL or the Source engine. +The native branch contains a deliberately small UIKit bootstrap under `ios-native/` plus unsigned and signed native GitHub Actions workflows. The bootstrap is an ordinary ARM64 iPhoneOS application target generated by CMake/Xcode. It still does **not** link SDL or the Source engine. Implemented now: -- `ios-native/CMakeLists.txt` creates the `Render360Portal` iOS app target. -- `ios-native/Sources/main.mm` launches UIKit, presents a folder picker, starts/stops security-scoped access for the selected URL, and validates a candidate Portal root by checking `portal/gameinfo.txt`, `portal/`, `hl2/`, `platform/`, and at least one VPK. -- `ios-native/Info.plist` declares an ARM64 iOS app, document access, landscape orientations, and a launch screen dictionary. -- `.github/workflows/ios-native.yml` configures and builds an unsigned ARM64 iPhoneOS app and packages `Render360-Portal-iOS-unsigned.ipa`. -- `.github/workflows/ios-native-signed.yml` provides a manually triggered certificate/provisioning-profile signing path. -- CI rejects obvious retail game files under `ios-native/` (`*.vpk`, `*.bsp`, `*.vtf`, `*.vcs`, `*.wav`). +- `ios-native/CMakeLists.txt` creates an iPhoneOS-only `Render360Portal` target for exactly `arm64`, iOS 15.0+, iPhone device family, with Mac Catalyst / Designed-for-iPhone-on-Mac / Designed-for-iPhone-on-XR compatibility disabled for this target. +- `ios-native/Sources/main.mm` is now a minimal UIKit entry/AppDelegate rather than a monolithic bootstrap implementation. +- `ios-native/Sources/R360BootstrapViewController.mm` owns the temporary N0 setup/diagnostic UI and the user-triggered folder picker. +- `ios-native/Sources/R360Diagnostics.mm` owns bounded latest-actionable diagnostics: app version/build, build/commit identifier, architecture, iOS version, current checkpoint, memory-warning count, current game-data state, and only the latest error. +- `ios-native/Sources/R360PortalValidator.mm` validates only a user-selected candidate root and deliberately does not persist/copy/mount Portal content yet. +- `ios-native/Info.plist` declares an ARM64 iPhone app, Files/document access, landscape-only orientation, full-screen/status-bar behavior, and no unnecessary privacy usage-description keys. +- `.github/workflows/ios-native.yml` verifies generated iPhoneOS build settings, builds an unsigned ARM64 app, verifies the built Info.plist and Mach-O architecture, checks exact IPA payload structure, rejects obvious retail asset extensions, and uploads the result. +- `.github/workflows/ios-native-signed.yml` retains the manual certificate/provisioning-profile path and mirrors the N0 bundle/retail-data checks where applicable. Not implemented yet: @@ -29,253 +31,272 @@ Not implemented yet: - `background1`, chamber gameplay, Source audio, map lifecycle, or iPhone 11 performance proof. - Physical-device proof for the current bootstrap in this repository history. -## Phase 00 CI finding and resolution +## Phase 00 — baseline and IPA pipeline resolution -The first native IPA workflow proved that CMake configuration and the ARM64 iPhoneOS build succeed, but packaging failed after the build. The generated CMake/Xcode project placed the app at: +The first native IPA workflow proved that CMake configuration and the ARM64 iPhoneOS build succeeded, but packaging initially failed because the CMake/Xcode generator emitted: `build/ios/Release-iphoneos/Render360Portal.app` -while the workflow only searched: +while the workflow only searched a DerivedData product path. -`build/DerivedData/Build/Products/Release-iphoneos/Render360Portal.app` +Both native workflows now resolve the actual CMake/Xcode product location and retain a DerivedData fallback. -Both native workflows now resolve the actual CMake/Xcode product location, prefer the CMake Xcode generator's `build/ios/Release-iphoneos` product, retain the DerivedData path as a fallback, and print discovered `.app` bundles on failure. +Phase 00 CI proof was native workflow run **#9** (`35039268789`), which created `Render360-Portal-iOS-unsigned-9` and proved the initial ARM64 unsigned IPA pipeline. -CI proof: +## Phase 01 — N0 bootstrap hardening -- Native workflow run **#9** (`35039268789`) completed successfully on commit `3a0dcfcbecde1f16d0c7865d1ba1b45affeeb81a`. -- ARM64 configuration and build passed. -- Bundle verification and unsigned IPA packaging passed. -- Artifact upload passed. -- Artifact `Render360-Portal-iOS-unsigned-9` was created, SHA-256 digest `ac6afdd9899ac60bee7ba02909480b94cdd973d478c679ba30f7f046a7e5bd9a`. -- The retail-data guard passed in the same run. +### Bootstrap structure -The signed workflow carries the same corrected product resolver. Signed execution is intentionally not part of Phase 00 because it requires user-supplied Apple signing credentials/profile; that external gate remains in the release/signing phase. +Phase 01 split the temporary native host into focused pieces so N1 can introduce SDL without rewriting one giant `main.mm`: -## Repository/module inventory +- `main.mm` — UIApplication entry and tiny app delegate only. +- `R360BootstrapViewController` — temporary setup UI and document-picker presentation. +- `R360Diagnostics` — latest-only diagnostic/checkpoint state. +- `R360PortalValidator` — bounded validation of the selected Portal root. -### Source foundations to reuse +No SDL, Source, fake gameplay, browser runtime, or retail Portal data was added. -These existing directories are the first native Source build candidates: +### N0 diagnostic checkpoints -1. `tier0/` — low-level diagnostics, CPU/platform, command-line, threading-adjacent utilities. Desktop-only MASM/Windows pieces must be excluded. -2. `tier1/` — interface loading, utility containers/helpers, KeyValues and related infrastructure. Native module loading requires an iOS registry path later. -3. `mathlib/` — vector/matrix/math foundation; needs ARM64/alignment/SIMD audit. -4. `vstdlib/` — Source utility runtime used by upper layers. -5. `appframework/` — app-system lifecycle and factory plumbing. -6. `filesystem/` — `basefilesystem.cpp`, `filesystem_stdio.cpp`, pack/VPK paths and async I/O. Linux/Steam-specific implementations must not be selected blindly for iOS. -7. `datacache/` — model/resource cache infrastructure required later by engine/rendering. -8. `inputsystem/` — only platform-neutral pieces should enter the early native build; SDL/iOS is the platform input provider. -9. `vphysics/` plus the pinned `ivp` submodule — physics integration/foundation. +The bootstrap now exposes explicit native checkpoints including: -### Upper Source modules present for later phases +- `bootstrap-enter` +- `ui-ready` +- `game-data-not-configured` +- `import-picker-open` +- `candidate-root-validating` +- `candidate-root-valid` +- `candidate-root-invalid` +- `memory-warning` -The repository also contains the engine/render/game systems that will be brought in only after the foundations compile: +Picker cancellation and app termination also have explicit diagnostic checkpoints. Diagnostics are current-state/latest-error based rather than an unbounded in-memory log. -- `engine/` -- `materialsystem/` -- `studiorender/` -- shader API / shader-system code under the material-system tree -- `launcher/` -- client/server game code -- VGUI/GameUI and supporting UI systems -- sound/particle/resource subsystems as demanded by Portal startup and the first maps +### Missing game-data behavior -These are not Phase 00/N1 compile targets. +Portal files are not assumed to exist inside the IPA. Startup state is explicitly `not configured`; the UI tells the user to choose a legally owned Portal root. Absence of `portal/gameinfo.txt` at launch is therefore a setup state, not a fatal engine/bootstrap path. -## Pinned submodules +The N0 validator checks the user-selected root for: -The branch records three Source-related submodules: +- an accessible directory root; +- `portal/gameinfo.txt` as a file; +- `portal/` as a directory; +- `hl2/` as a directory; +- `platform/` as a directory; +- at least one top-level `.vpk` under `portal/`. -- `thirdparty` -> `nillerusr/source-thirdparty` at `c5b901ecef515ea068fa8b8a19ca5cd5353905cb` -- `ivp` -> `nillerusr/source-physics` at `47533475e01cbff05fbc3bbe8b4edc485f292cea` -- `lib` -> `nillerusr/source-engine-libs` at `86a66ee92d9fda0a09f54a435e850faa7ab5d0fa` +It starts/stops security-scoped access for the validation window when available. It intentionally does **not** persist a bookmark, copy large VPKs, mount Source search paths, or implement Source VPK I/O. Those remain later importer/filesystem phases. -Because Git submodule SHAs are pinned, checkout is reproducible as long as those upstream objects remain available. The native build must not silently track a moving submodule branch. Any future SDL dependency must likewise be pinned to a concrete release/tag/commit and documented. +### CMake/Xcode target hardening -## Browser/Emscripten architecture that must not enter native iOS +The N0 target now fails configuration for the iOS Simulator and requires exactly ARM64. CI verifies generated Release build settings for: -The entire `emscripten/` runtime/build layer is reference-only for the native target. Important browser-only pieces include: +- `ARCHS = arm64` +- iPhoneOS SDK +- iOS deployment target `15.0` +- bundle identifier `com.render360.portal` for the unsigned CI artifact +- `SUPPORTED_PLATFORMS = iphoneos` +- `TARGETED_DEVICE_FAMILY = 1` +- no Designed-for-iPhone compatibility on Mac +- no Designed-for-iPhone compatibility on XR -- `emscripten/build.sh` -- `emscripten/get_emscripten.sh` -- `emscripten/pre.js` / `post.js` -- `emscripten/phase3-workerfs.js` -- `emscripten/phase3-mobile-runtime.js` -- `emscripten/portal-local-vpk.js` -- `emscripten/portal-boot-overlay.js` -- the Pages/service-worker/staging JavaScript -- `emscripten/libwebgl.patch` -- Emscripten pkg-config/toolchain content +Build number and Git commit identifier are supplied from GitHub Actions and surfaced in bootstrap diagnostics. -The web build also applies `__EMSCRIPTEN__`-specific loader/startup patches and depends on concepts such as SIDE_MODULE/MAIN_MODULE, `dlopen` of Wasm side modules, SharedArrayBuffer/pthreads, `PROXY_TO_PTHREAD`, OffscreenCanvas, WORKERFS/MEMFS, JavaScript `File` objects, browser preload `.data` packages, WebGL and service-worker delivery. None of those are native-iOS dependencies. +### Info.plist hardening -Native CMake targets must not compile `emscripten/**` or define `__EMSCRIPTEN__`. +The built Info.plist is CI-validated for: -## Desktop/Linux assumptions requiring deliberate iOS treatment +- `CFBundleExecutable = Render360Portal` +- iPhoneOS requirement +- Files/document access (`UIFileSharingEnabled`, `LSSupportsOpeningDocumentsInPlace`) +- landscape left/right only +- ARM64 device capability +- iPhone-only device family +- `CFBundleSupportedPlatforms = iPhoneOS` +- `MinimumOSVersion = 15.0` +- no unnecessary `NS*UsageDescription` privacy keys -The following areas must be audited as each Source library is linked. Do not pre-emptively stub them all. +The bootstrap button uses modern `UIButtonConfiguration` rather than the deprecated content-edge-insets path. The app build itself completes without the old bootstrap deprecation/orientation warning path. -### Dynamic modules/interfaces +### Phase 01 CI proof -Risk: desktop Source expects `.dll`/`.so` loading and factory lookup. +The final code-bearing Phase 01 proof is native workflow run **#13** (`35040031386`) on commit `7ec9b1c397dfdfd7b7f69f65dfa79aa5ca1aa7aa`. -Plan: retain normal Source interface semantics but route iOS module names to statically linked factories through the planned module registry. `tier1/interface.cpp` and callers are the key later audit point. +Result: **success**. -### Filesystem and paths +Passed steps include: -Risk: executable working-directory assumptions, Steam paths, Linux support helpers, case sensitivity, unrestricted home-directory access, whole-file loading. +1. checkout and pinned submodules; +2. retail-data guard; +3. CMake Xcode configuration for `iphoneos` / ARM64; +4. generated iPhoneOS build-setting verification; +5. unsigned Release ARM64 build; +6. N0 Info.plist / executable / IPA validation; +7. unsigned artifact upload. -Plan: app-controlled game root under Application Support or a correctly persisted security-scoped location; preserve `filesystem_stdio`/VPK random access using native seek/read operations. Early targets are `filesystem/basefilesystem.cpp`, `filesystem/filesystem_stdio.cpp`, pack/VPK code and the narrow platform path layer. +Artifact: -### Threading/TLS/atomics/timing +- `Render360-Portal-iOS-unsigned-13` +- artifact id `10424796574` +- artifact SHA-256 `caddb53614d147663d16b561893c1d89b2e329f57b25f589196a716bcce6ad1b` -Risk: x86 assumptions, desktop thread naming/priorities, Linux/Windows TLS APIs and timers. +The workflow verifies that the IPA contains `Payload/Render360Portal.app/Render360Portal`, that the executable is exactly ARM64, and that the packaged file list contains none of the blocked retail-game extensions. -Plan: ARM64-safe atomics/alignment; pthread/standard C++ primitives where compatible; monotonic iOS/Darwin timing adapters only where Source needs them. First audit targets are `tier0/`, `tier1/`, and the public platform headers they consume. +### Proof classification after Phase 01 -### Process/signals/environment +**CI-proven:** -Risk: fork/exec, shell/process launch, desktop signals and mutable global environment assumptions. +- CMake/Xcode configuration for iPhoneOS ARM64 Release. +- iOS 15.0 deployment target and CI bundle identifier. +- iPhone-only target family and disabled Mac/XR compatibility modes. +- compilation/linking of the split UIKit N0 bootstrap. +- ARM64 Mach-O executable. +- built Info.plist contract. +- exact unsigned IPA payload path. +- retail-data guard under `ios-native/` and inside the IPA. +- unsigned artifact upload. -Plan: do not emulate process launching unless a required in-process Source subsystem proves it needs equivalent behavior. Unsupported required calls must fail with a named caller/subsystem rather than silently succeeding. +**Code-review-proven:** -### Graphics/context creation +- missing game data follows a setup-state path instead of dereferencing/assuming bundled Portal files; +- diagnostics retain bounded current state/latest error; +- the folder validator is separated from UI and remains temporary-access-only; +- Source/SDL/browser code boundaries are not crossed in N0. -Risk: GLX/WGL/desktop OpenGL and browser WebGL assumptions. +**Physical-iPhone-proven:** -Plan: N1 creates an SDL-controlled GLES3 bring-up context behind a backend boundary. Later Source/ToGL work adapts only the calls Source actually reaches. GLES is a temporary compatibility backend; Metal is the production migration target. +- **BLOCKED / NOT YET VERIFIED.** There is no repository evidence yet that this bootstrap has been signed/sideloaded and launched on a real iPhone 11. -### Input +The exact physical N0 check still required is: sign the built app with a valid Apple provisioning method, install it on the iPhone 11, launch it with no Portal data present, confirm the setup/diagnostic screen appears in landscape, open/cancel the folder picker, then choose a known valid/invalid candidate root and record the displayed latest checkpoint/error. A crash/device console log should be captured if launch fails. -Risk: desktop keyboard/mouse/window-system paths. +## Repository/module inventory for later native work -Plan: SDL iOS touch/controller events feed a small native input layer, then map into Source input actions in later phases. +### Source foundations to reuse -### Audio +Existing first native Source build candidates remain: -Risk: desktop audio backends and blocking I/O from real-time callbacks. +1. `tier0/` — low-level diagnostics, CPU/platform, command-line and threading-adjacent utilities; desktop-only MASM/Windows pieces must be excluded. +2. `tier1/` — interface loading, utility containers/helpers, KeyValues and related infrastructure; native module loading later needs the built-in registry path. +3. `mathlib/` — vector/matrix/math foundation requiring ARM64/alignment/SIMD audit. +4. `vstdlib/` — Source utility runtime. +5. `appframework/` — app-system lifecycle and factory plumbing. +6. `filesystem/` — base filesystem, stdio, pack/VPK paths and async I/O; Linux/Steam-specific implementations must not be selected blindly. +7. `datacache/` — model/resource cache infrastructure. +8. `inputsystem/` — only platform-neutral pieces should enter the early native Source build. +9. `vphysics/` plus pinned `ivp` submodule pieces required by the first synthetic physics test. -Plan: N1 proves an SDL/CoreAudio device with synthetic silence/test data. Source audio is integrated only after the engine reaches the required startup phase. +Upper modules for later phases include `engine/`, `materialsystem/`, `studiorender/`, shader API/shader-system code, `launcher/`, client/server game code, VGUI/GameUI, sound, particles and other runtime systems required by real Portal startup. -### Networking +## Pinned Source-related submodules -Risk: desktop socket helpers and services that Portal single-player startup may not need immediately. +- `thirdparty` -> `nillerusr/source-thirdparty` at `c5b901ecef515ea068fa8b8a19ca5cd5353905cb` +- `ivp` -> `nillerusr/source-physics` at `47533475e01cbff05fbc3bbe8b4edc485f292cea` +- `lib` -> `nillerusr/source-engine-libs` at `86a66ee92d9fda0a09f54a435e850faa7ab5d0fa` -Plan: include sockets only when a linked Source subsystem proves they are required; use Darwin/POSIX sockets through a focused adapter. +Future SDL integration must likewise pin a concrete revision/release and document how CI obtains it. -## Graphics blockers expected later +## Browser/Emscripten boundary -The existing Source renderer is materially closer to desktop OpenGL than Metal. Expected GLES bring-up blockers include fixed-function remnants, desktop-only base-vertex variants, texture-level queries, buffer mapping/storage differences, sync/fence behavior, framebuffer/blit extension differences, texture compression/format mapping, and desktop GLSL versus GLSL ES syntax/precision. +The `emscripten/` runtime/build layer remains reference-only for native iOS. Native targets must not depend on `__EMSCRIPTEN__`, MAIN_MODULE/SIDE_MODULE, browser `dlopen`, MEMFS/WORKERFS, SharedArrayBuffer, `PROXY_TO_PTHREAD`, OffscreenCanvas, JavaScript `File` objects, browser preload `.data` packages, service workers or web delivery assumptions. -Do not solve these in Phase 00. N1 only proves a native GLES context; renderer compatibility belongs to the later renderer phase. +## Desktop/platform assumptions still requiring later iOS treatment -## Importer/filesystem blockers +### Dynamic modules/interfaces + +Desktop Source expects `.dll`/`.so` module loading and factories. The native plan remains statically linked Source modules plus a built-in factory registry while preserving `CreateInterface` semantics. -The N0 picker validates a candidate root only for the duration of the picker callback. It intentionally does not yet persist a bookmark/security scope or copy data into Application Support. N4 must choose and prove the persistence model, build an import manifest, verify free space when copying, and connect the result to Source `filesystem_stdio`/VPK reads without loading archives wholesale into RAM. +### Filesystem and paths -## Audio/input blockers +Executable/Steam/Linux path assumptions must be replaced through focused native path/storage adapters. Long-running Portal reads must eventually use app-controlled Application Support storage or a proven persisted security-scoped model and seekable file/VPK access rather than whole-archive loads. -UIKit currently owns all interaction and no SDL runtime exists yet. N1 must establish: +### Threading/TLS/atomics/timing -- SDL lifecycle/event loop -- landscape native game window -- Retina drawable-size handling -- GLES3 context creation -- touch coordinates and multitouch diagnostics -- controller connect/disconnect diagnostics -- CoreAudio-backed SDL audio device -- interruption/background/foreground recovery -- memory-warning checkpointing +ARM64-safe atomics/alignment, pthread/standard C++ primitives and monotonic Darwin timing must be introduced only as the linked Source foundations require them. + +### Process/signals/environment + +Do not emulate process launching merely for desktop compatibility. Required unsupported operations must fail with the caller/subsystem identified. + +### Graphics/context creation + +Phase 02/N1 must create the native SDL-controlled GLES3 bring-up context behind a renderer backend boundary. GLES is temporary compatibility infrastructure; Metal remains the production migration target after real Source gameplay is proven. + +### Input and audio + +N1 must move runtime event ownership toward SDL while preserving UIKit only where native UI is preferable. Touch/controller diagnostics and an SDL/CoreAudio synthetic audio device are the next host-level tests; Source input/audio integration comes later. + +## Importer/filesystem boundary + +Phase 01 intentionally does not persist the selected Portal folder. A later importer phase must choose and prove a persistence strategy, validate space for any copy, build a lightweight manifest, and connect Source's filesystem/VPK path to native seekable I/O without reading entire archives into RAM. ## Native workflow audit ### Unsigned workflow -Architecture is now CI-proven for the bootstrap: macOS runner -> CMake Xcode project -> iphoneos ARM64 Release -> unsigned `.app` -> `Payload/Render360Portal.app` -> IPA artifact. Retail assets are checked before configuration. The workflow verifies the ARM64 executable, bundle metadata, IPA contents, and uploads the result. +Current architecture is CI-proven: + +macOS runner -> CMake Xcode project -> iPhoneOS ARM64 Release -> unsigned `.app` -> verify build settings/Info.plist/Mach-O -> `Payload/Render360Portal.app` -> verify IPA contents/no retail extensions -> artifact upload. ### Signed workflow -The workflow keeps certificate/profile material in GitHub Secrets/variables and a temporary keychain, then manually signs on a macOS runner. It uses the same robust `.app` resolver as the unsigned workflow. A signed build remains externally blocked until valid Apple signing material and a profile matching the chosen bundle/device are supplied. +The signed workflow keeps certificate/profile material in GitHub Secrets/variables and a temporary keychain, uses the same hardened native target, verifies the signed bundle and rejects obvious retail asset extensions. A signed execution remains externally blocked until valid Apple signing material/profile/device authorization is supplied. ## Retail-data boundary -Phase 00 CI demonstrated that no obvious `*.vpk`, `*.bsp`, `*.vtf`, `*.vcs`, or `*.wav` retail files exist under `ios-native/`. Native workflows do not download Portal data. Synthetic fixtures only are permitted in repository tests. - -Future hardening may expand the forbidden-extension scan when new native fixture directories are added, but it must not scan Source-owned open-source code/resources in a way that creates false positives. +The native workflows reject obvious retail game data under `ios-native/` and again reject the same classes inside produced IPAs. The current blocked extension set includes `*.vpk`, `*.bsp`, `*.vtf`, `*.vmt`, `*.vcs`, `*.wav`, `*.mp3`, and `*.mdl`. Native workflows do not download Portal retail data. Synthetic fixtures only are permitted in repository tests. ## Dependency/phase map | Phase | Depends on | Current status | | --- | --- | --- | -| 00 Audit/baseline | existing branch | **Complete — repo + unsigned CI artifact proven** | -| 01 N0 hardening | 00 | Next | -| 02 N1 SDL host | 01 | Not started | +| 00 Audit/baseline | existing branch | **Complete — repository + unsigned CI artifact proven** | +| 01 N0 hardening | 00 | **Repository/CI complete — physical iPhone launch still BLOCKED/unverified** | +| 02 N1 SDL host | 01 code/CI baseline | **Next — not started** | | 03 N2 Source foundations | 02 | Not started | | 04 N2B iOS shims | 03 | Not started | | 05 static module registry | 04 | Not started | -| 06 Portal importer/native VPK | 05 foundation + N0 picker | Not started | -| 07 launcher/PreInit | 05 + 06 | Not started | -| 08 GLES/ToGL | 07 | Not started | -| 09 background1 | 08 | Not started | -| 10 chamber 00 gameplay | 09 | Not started | -| 11 production touch/controller | 10 | Not started | -| 12 Source audio | 10/11 | Not started | -| 13 map memory lifecycle | 10 | Not started | -| 14 hidden transitions | 13 | Not started | -| 15 iPhone 11 performance | gameplay + memory | Not started | -| 16 Metal migration | proven GLES gameplay | Not started | -| 17 production signing/release | stable native runtime | Not started | -| 18 final audit | all prior gates | Not started | - -## Exact next code targets - -### Phase 01 — N0 bootstrap hardening +| 06 Portal importer/native persistence | N0 picker + native foundation | Not started | +| 07 native Source filesystem/VPK | importer + foundations | Not started | +| 08 launcher/PreInit | registry + filesystem | Not started | +| 09 GLES/ToGL | launcher | Not started | +| 10 background1 | renderer | Not started | +| 11 chamber gameplay | background/menu | Not started | +| 12 production touch/controller | gameplay | Not started | +| 13 Source audio | gameplay/input | Not started | +| 14 map memory lifecycle | gameplay | Not started | +| 15 hidden transitions | lifecycle | Not started | +| 16 iPhone 11 performance | gameplay + memory | Not started | +| 17 Metal migration | proven GLES gameplay | Not started | +| 18 production signing/release | stable native runtime | Not started | +| 19 final audit | all prior gates | Not started | + +## Exact next code target — Phase 02 / N1 SDL host Read/edit first: - `ios-native/CMakeLists.txt` -- `ios-native/Info.plist` - `ios-native/Sources/main.mm` +- `ios-native/Sources/R360BootstrapViewController.*` +- `ios-native/Sources/R360Diagnostics.*` +- new focused native SDL host/lifecycle/renderer-bootstrap/input/audio files under `ios-native/Sources/` +- pinned SDL integration location documented in `ios-native/README.md` +- `ios-native/Info.plist` - `.github/workflows/ios-native.yml` -- `.github/workflows/ios-native-signed.yml` -- `docs/IOS_NATIVE_BUILD_AND_SIGNING.md` +- `.github/workflows/ios-native-signed.yml` only where the host/build graph requires the same change -Add only lightweight bootstrap diagnostics and CI/package validation. Do not link Source yet. +N1 must not begin the Source-engine static library graph. Its purpose is to prove a reproducible SDL iOS runtime, a continuously clearing GLES3 frame, Retina drawable sizing, lifecycle recovery, touch/controller diagnostics, and synthetic SDL/CoreAudio audio before Source enters the app. -### Phase 02 — N1 SDL host +## Phase 01 acceptance gates — result -Expected first native locations: - -- `ios-native/CMakeLists.txt` -- `ios-native/Sources/main.mm` (split UIKit setup/import from game host rather than growing one file forever) -- new focused native host/lifecycle/renderer-bootstrap files under `ios-native/Sources/` -- pinned SDL source/framework integration location documented in `ios-native/README.md` -- `ios-native/Info.plist` -- `.github/workflows/ios-native.yml` +A. **PASS — CI-proven.** Native workflow run #13 produced an unsigned IPA containing an exactly ARM64 executable. -### Phase 03 — N2 Source foundations +B. **PASS — code-review-proven.** Missing Portal data is represented as `game-data-not-configured` setup state and the launch path does not require bundled game data. -Expected Source areas: +C. **PASS — compile/CI + code-review-proven.** UI, diagnostics, validation and UIApplication entry are separated so N1 can add an SDL host behind focused boundaries rather than replacing a monolith. -- `tier0/` -- `tier1/` -- `mathlib/` -- `vstdlib/` -- `appframework/` -- `filesystem/basefilesystem.cpp` -- `filesystem/filesystem_stdio.cpp` -- pack/VPK files under `filesystem/` -- `datacache/` -- platform-neutral `inputsystem/` files only -- `vphysics/` plus pinned `ivp` submodule pieces required for the first synthetic physics test -- a new focused CMake/platform layer under `ios-native/` rather than editing every Source directory indiscriminately +D. **PASS — CI-proven.** No blocked retail Portal/game asset type is present under `ios-native/` or in the produced IPA. -## Phase 00 exit criteria — result +E. **PASS.** This audit explicitly separates CI-proven, code-review-proven and physical-device-proven status. -1. **PASS** — this audit is present and points to the exact next native areas. -2. **PASS** — the IPA product-path packaging defect is fixed in both native workflows. -3. **PASS** — unsigned native CI run #9 reached artifact upload with an ARM64 executable. -4. **PASS** — the retail Portal-data guard passed and no retail game data was introduced. +F. **BLOCKED / NOT VERIFIED.** Physical-iPhone launch has not been demonstrated. No physical-device success claim is made. -Physical-device launch is intentionally not claimed by Phase 00; that remains an explicit N0/Phase 01 acceptance gate. +Phase 02 may begin from this repository/CI baseline, but the Phase 01 physical-device proof remains an outstanding real-device checkpoint and should be performed before relying on N0 as device-proven. From 976bc3d4c568637ec2d34993d006c96289b99975 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Tue, 15 Sep 2026 21:03:52 -0400 Subject: [PATCH 147/159] ios: implement N1 SDL2 GLES input audio host --- .github/workflows/ios-native.yml | 172 +++---------- ios-native/CMakeLists.txt | 97 ++----- ios-native/Info.plist | 56 +---- .../Sources/R360BootstrapViewController.mm | 238 +++--------------- ios-native/Sources/R360Diagnostics.h | 18 +- ios-native/Sources/R360Diagnostics.mm | 122 ++------- ios-native/Sources/R360GLESRendererBackend.h | 7 + ios-native/Sources/R360GLESRendererBackend.mm | 83 ++++++ ios-native/Sources/R360LifecycleService.h | 17 ++ ios-native/Sources/R360LifecycleService.mm | 41 +++ ios-native/Sources/R360RendererBackend.h | 15 ++ ios-native/Sources/R360SDLAudioHost.h | 10 + ios-native/Sources/R360SDLAudioHost.mm | 40 +++ ios-native/Sources/R360SDLHost.h | 8 + ios-native/Sources/R360SDLHost.mm | 104 ++++++++ ios-native/Sources/R360SDLInputDiagnostics.h | 9 + ios-native/Sources/R360SDLInputDiagnostics.mm | 75 ++++++ ios-native/Sources/main.mm | 24 +- ios-native/cmake/SDL2Pinned.cmake | 29 +++ 19 files changed, 591 insertions(+), 574 deletions(-) create mode 100644 ios-native/Sources/R360GLESRendererBackend.h create mode 100644 ios-native/Sources/R360GLESRendererBackend.mm create mode 100644 ios-native/Sources/R360LifecycleService.h create mode 100644 ios-native/Sources/R360LifecycleService.mm create mode 100644 ios-native/Sources/R360RendererBackend.h create mode 100644 ios-native/Sources/R360SDLAudioHost.h create mode 100644 ios-native/Sources/R360SDLAudioHost.mm create mode 100644 ios-native/Sources/R360SDLHost.h create mode 100644 ios-native/Sources/R360SDLHost.mm create mode 100644 ios-native/Sources/R360SDLInputDiagnostics.h create mode 100644 ios-native/Sources/R360SDLInputDiagnostics.mm create mode 100644 ios-native/cmake/SDL2Pinned.cmake diff --git a/.github/workflows/ios-native.yml b/.github/workflows/ios-native.yml index db2517a4b0..5f9bbca90b 100644 --- a/.github/workflows/ios-native.yml +++ b/.github/workflows/ios-native.yml @@ -1,183 +1,92 @@ name: iOS Native Bootstrap IPA - on: push: - branches: - - render360/ios-native - paths: - - 'ios-native/**' - - '.github/workflows/ios-native.yml' - - 'docs/IOS_NATIVE_**' + branches: [render360/ios-native] + paths: ['ios-native/**','.github/workflows/ios-native.yml','docs/IOS_NATIVE_**'] workflow_dispatch: - -permissions: - contents: read - +permissions: { contents: read } jobs: build-ios-native: runs-on: macos-latest timeout-minutes: 30 - steps: - name: Checkout source and submodules uses: actions/checkout@v6 - with: - submodules: recursive - fetch-depth: 1 - + with: { submodules: recursive, fetch-depth: 1 } - name: Print toolchain run: | set -euxo pipefail - xcodebuild -version - cmake --version - clang --version - - - name: Verify no retail Portal data is committed under native project + xcodebuild -version; cmake --version; clang --version + - name: Verify native source boundaries shell: bash run: | set -euo pipefail - if find ios-native -type f \( \ - -iname '*.vpk' -o -iname '*.bsp' -o -iname '*.vtf' -o -iname '*.vmt' -o \ - -iname '*.vcs' -o -iname '*.wav' -o -iname '*.mp3' -o -iname '*.mdl' \ - \) -print -quit | grep -q .; then - echo 'Retail/game asset file detected under ios-native/. Do not package Portal assets in the repository or IPA.' >&2 - exit 1 - fi - + if find ios-native -type f \( -iname '*.vpk' -o -iname '*.bsp' -o -iname '*.vtf' -o -iname '*.vmt' -o -iname '*.vcs' -o -iname '*.wav' -o -iname '*.mp3' -o -iname '*.mdl' \) -print -quit | grep -q .; then echo 'Retail game asset detected.' >&2; exit 1; fi + if grep -RInE '__EMSCRIPTEN__|MEMFS|WORKERFS|SharedArrayBuffer|PROXY_TO_PTHREAD|OffscreenCanvas' ios-native/Sources ios-native/CMakeLists.txt ios-native/cmake; then echo 'Browser runtime dependency entered native target.' >&2; exit 1; fi - name: Configure Xcode arm64 iPhone project run: | set -euxo pipefail - cmake -S ios-native -B build/ios -G Xcode \ - -DCMAKE_SYSTEM_NAME=iOS \ - -DCMAKE_OSX_SYSROOT=iphoneos \ - -DCMAKE_OSX_ARCHITECTURES=arm64 \ - -DCMAKE_OSX_DEPLOYMENT_TARGET=15.0 \ - -DRENDER360_DEPLOYMENT_TARGET=15.0 \ - -DRENDER360_BUNDLE_ID=com.render360.portal \ - -DRENDER360_BUILD_IDENTIFIER="${GITHUB_SHA}" \ - -DRENDER360_BUILD_NUMBER="${GITHUB_RUN_NUMBER}" - - - name: Verify generated iPhoneOS build settings + cmake -S ios-native -B build/ios -G Xcode -DCMAKE_SYSTEM_NAME=iOS -DCMAKE_OSX_SYSROOT=iphoneos -DCMAKE_OSX_ARCHITECTURES=arm64 -DCMAKE_OSX_DEPLOYMENT_TARGET=15.0 -DRENDER360_DEPLOYMENT_TARGET=15.0 -DRENDER360_BUNDLE_ID=com.render360.portal -DRENDER360_BUILD_IDENTIFIER="${GITHUB_SHA}" -DRENDER360_BUILD_NUMBER="${GITHUB_RUN_NUMBER}" + - name: Verify pinned SDL2 and generated iPhoneOS settings shell: bash run: | set -euo pipefail - xcodebuild \ - -project build/ios/Render360PortalIOS.xcodeproj \ - -scheme Render360Portal \ - -configuration Release \ - -sdk iphoneos \ - -destination 'generic/platform=iOS' \ - -showBuildSettings | tee build/xcode-build-settings.txt - + grep -Fx 'version=2.32.10' build/ios/render360-sdl2-version.txt + grep -Fx 'commit=5d249570393f7a37e037abf22cd6012a4cc56a71' build/ios/render360-sdl2-version.txt + grep -Fx 'sha256=5f5993c530f084535c65a6879e9b26ad441169b3e25d789d83287040a9ca5165' build/ios/render360-sdl2-version.txt + xcodebuild -project build/ios/Render360PortalIOS.xcodeproj -scheme Render360Portal -configuration Release -sdk iphoneos -destination 'generic/platform=iOS' -showBuildSettings | tee build/xcode-build-settings.txt grep -Eq '^[[:space:]]*ARCHS = arm64$' build/xcode-build-settings.txt - grep -Eq '^[[:space:]]*SDKROOT = iphoneos([0-9.]+)?$' build/xcode-build-settings.txt grep -Eq '^[[:space:]]*IPHONEOS_DEPLOYMENT_TARGET = 15\.0$' build/xcode-build-settings.txt grep -Eq '^[[:space:]]*PRODUCT_BUNDLE_IDENTIFIER = com\.render360\.portal$' build/xcode-build-settings.txt grep -Eq '^[[:space:]]*SUPPORTED_PLATFORMS = iphoneos$' build/xcode-build-settings.txt grep -Eq '^[[:space:]]*TARGETED_DEVICE_FAMILY = 1$' build/xcode-build-settings.txt grep -Eq '^[[:space:]]*SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO$' build/xcode-build-settings.txt grep -Eq '^[[:space:]]*SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO$' build/xcode-build-settings.txt - - - name: Build unsigned arm64 app + - name: Build unsigned arm64 N1 app run: | set -euxo pipefail - xcodebuild \ - -project build/ios/Render360PortalIOS.xcodeproj \ - -scheme Render360Portal \ - -configuration Release \ - -sdk iphoneos \ - -destination 'generic/platform=iOS' \ - -derivedDataPath build/DerivedData \ - CODE_SIGNING_ALLOWED=NO \ - CODE_SIGNING_REQUIRED=NO \ - ONLY_ACTIVE_ARCH=NO \ - ARCHS=arm64 \ - build - - - name: Verify N0 bundle and package unsigned IPA + xcodebuild -project build/ios/Render360PortalIOS.xcodeproj -scheme Render360Portal -configuration Release -sdk iphoneos -destination 'generic/platform=iOS' -derivedDataPath build/DerivedData CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO ONLY_ACTIVE_ARCH=NO ARCHS=arm64 build + - name: Verify N1 bundle and package unsigned IPA shell: bash run: | set -euxo pipefail - - resolve_app_path() { - local candidate - for candidate in \ - build/ios/Release-iphoneos/Render360Portal.app \ - build/DerivedData/Build/Products/Release-iphoneos/Render360Portal.app; do - if [ -d "$candidate" ]; then - printf '%s\n' "$candidate" - return 0 - fi - done - find build -type d -path '*/Release-iphoneos/Render360Portal.app' -print -quit 2>/dev/null || true - } - - APP_PATH="$(resolve_app_path)" - if [ -z "$APP_PATH" ] || [ ! -d "$APP_PATH" ]; then - echo 'Render360 Portal: could not locate the built iPhoneOS .app bundle.' >&2 - find build -maxdepth 6 -type d -name '*.app' -print 2>/dev/null || true - exit 1 - fi - - EXECUTABLE="$APP_PATH/Render360Portal" - test -f "$EXECUTABLE" - file "$EXECUTABLE" - lipo -archs "$EXECUTABLE" | tee build/architecture.txt - test "$(cat build/architecture.txt)" = 'arm64' - + APP_PATH=build/ios/Release-iphoneos/Render360Portal.app + if [ ! -d "$APP_PATH" ]; then APP_PATH=build/DerivedData/Build/Products/Release-iphoneos/Render360Portal.app; fi + test -f "$APP_PATH/Render360Portal" + lipo -archs "$APP_PATH/Render360Portal" | tee build/architecture.txt + test "$(cat build/architecture.txt)" = arm64 + nm "$APP_PATH/Render360Portal" | grep -q '_SDL_Init' python3 - "$APP_PATH/Info.plist" <<'PY' - import plistlib - import sys - - with open(sys.argv[1], 'rb') as handle: - info = plistlib.load(handle) - - assert info.get('CFBundleIdentifier') == 'com.render360.portal', info.get('CFBundleIdentifier') - assert info.get('CFBundleExecutable') == 'Render360Portal', info.get('CFBundleExecutable') - assert info.get('LSRequiresIPhoneOS') is True + import plistlib,sys + with open(sys.argv[1],'rb') as f: info=plistlib.load(f) + assert info.get('CFBundleIdentifier')=='com.render360.portal' + assert info.get('CFBundleExecutable')=='Render360Portal' + assert info.get('UIRequiresFullScreen') is True assert info.get('UIFileSharingEnabled') is True assert info.get('LSSupportsOpeningDocumentsInPlace') is True - assert info.get('UIStatusBarHidden') is True - assert info.get('UISupportedInterfaceOrientations') == [ - 'UIInterfaceOrientationLandscapeLeft', - 'UIInterfaceOrientationLandscapeRight', - ] - assert info.get('UIRequiredDeviceCapabilities') == ['arm64'] - assert info.get('UIDeviceFamily') == [1], info.get('UIDeviceFamily') - assert info.get('CFBundleSupportedPlatforms') == ['iPhoneOS'], info.get('CFBundleSupportedPlatforms') - assert info.get('MinimumOSVersion') == '15.0', info.get('MinimumOSVersion') - assert not any(key.startswith('NS') and key.endswith('UsageDescription') for key in info), 'unexpected privacy usage-description key' - print('Validated N0 Info.plist metadata') + assert info.get('UISupportedInterfaceOrientations')==['UIInterfaceOrientationLandscapeLeft','UIInterfaceOrientationLandscapeRight'] + assert info.get('UIRequiredDeviceCapabilities')==['arm64'] + assert info.get('UIDeviceFamily')==[1] + assert info.get('CFBundleSupportedPlatforms')==['iPhoneOS'] + assert info.get('MinimumOSVersion')=='15.0' + assert not any(k.startswith('NS') and k.endswith('UsageDescription') for k in info) PY - - rm -rf build/ipa - mkdir -p build/ipa/Payload - ditto "$APP_PATH" build/ipa/Payload/Render360Portal.app - + rm -rf build/ipa; mkdir -p build/ipa/Payload; ditto "$APP_PATH" build/ipa/Payload/Render360Portal.app cat > build/ipa/BUILD_INFO.txt <&2 - exit 1 - fi - ls -lh "$IPA" - + if grep -Eiq '\.(vpk|bsp|vtf|vmt|vcs|wav|mp3|mdl)$' build/ipa-contents.txt; then exit 1; fi - name: Upload unsigned IPA uses: actions/upload-artifact@v4 with: @@ -187,5 +96,6 @@ jobs: build/architecture.txt build/xcode-build-settings.txt build/ipa-contents.txt + build/ios/render360-sdl2-version.txt if-no-files-found: error retention-days: 30 diff --git a/ios-native/CMakeLists.txt b/ios-native/CMakeLists.txt index 0e2587d6ea..887403b758 100644 --- a/ios-native/CMakeLists.txt +++ b/ios-native/CMakeLists.txt @@ -1,80 +1,37 @@ cmake_minimum_required(VERSION 3.25) - -project(Render360PortalIOS VERSION 0.1.0 LANGUAGES C CXX OBJC OBJCXX) - -if(NOT IOS) - message(FATAL_ERROR "Render360PortalIOS must be configured with -DCMAKE_SYSTEM_NAME=iOS") -endif() - +project(Render360PortalIOS VERSION 0.2.0 LANGUAGES C CXX OBJC OBJCXX) +if(NOT IOS) message(FATAL_ERROR "Render360PortalIOS must be configured with -DCMAKE_SYSTEM_NAME=iOS") endif() string(TOLOWER "${CMAKE_OSX_SYSROOT}" RENDER360_SYSROOT_LOWER) -if(RENDER360_SYSROOT_LOWER MATCHES "iphonesimulator") - message(FATAL_ERROR "Phase N0 targets physical iPhoneOS, not the iOS Simulator") -endif() - -if(NOT CMAKE_OSX_ARCHITECTURES) - set(CMAKE_OSX_ARCHITECTURES "arm64" CACHE STRING "Native iPhone architecture" FORCE) -elseif(NOT CMAKE_OSX_ARCHITECTURES STREQUAL "arm64") - message(FATAL_ERROR "Render360 native iOS currently requires exactly arm64; got '${CMAKE_OSX_ARCHITECTURES}'") -endif() - +if(RENDER360_SYSROOT_LOWER MATCHES "iphonesimulator") message(FATAL_ERROR "Native target requires physical iPhoneOS, not the simulator") endif() +if(NOT CMAKE_OSX_ARCHITECTURES) set(CMAKE_OSX_ARCHITECTURES "arm64" CACHE STRING "Native iPhone architecture" FORCE) +elseif(NOT CMAKE_OSX_ARCHITECTURES STREQUAL "arm64") message(FATAL_ERROR "Render360 native iOS requires exactly arm64; got '${CMAKE_OSX_ARCHITECTURES}'") endif() set(CMAKE_C_STANDARD 11) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_POSITION_INDEPENDENT_CODE ON) - set(RENDER360_BUNDLE_ID "com.render360.portal" CACHE STRING "iOS bundle identifier") set(RENDER360_DEPLOYMENT_TARGET "15.0" CACHE STRING "Minimum iOS deployment target") -set(RENDER360_BUILD_IDENTIFIER "local" CACHE STRING "Build/commit identifier shown by bootstrap diagnostics") -set(RENDER360_BUILD_NUMBER "1" CACHE STRING "CFBundleVersion build number") - -if(CMAKE_OSX_DEPLOYMENT_TARGET AND NOT CMAKE_OSX_DEPLOYMENT_TARGET STREQUAL RENDER360_DEPLOYMENT_TARGET) - message(FATAL_ERROR - "CMAKE_OSX_DEPLOYMENT_TARGET (${CMAKE_OSX_DEPLOYMENT_TARGET}) must match RENDER360_DEPLOYMENT_TARGET (${RENDER360_DEPLOYMENT_TARGET})") -endif() - +set(RENDER360_BUILD_IDENTIFIER "local" CACHE STRING "Build/commit identifier") +set(RENDER360_BUILD_NUMBER "1" CACHE STRING "CFBundleVersion") +if(CMAKE_OSX_DEPLOYMENT_TARGET AND NOT CMAKE_OSX_DEPLOYMENT_TARGET STREQUAL RENDER360_DEPLOYMENT_TARGET) message(FATAL_ERROR "Deployment target mismatch") endif() +include(cmake/SDL2Pinned.cmake) add_executable(Render360Portal MACOSX_BUNDLE - Sources/main.mm - Sources/R360BootstrapViewController.h - Sources/R360BootstrapViewController.mm - Sources/R360Diagnostics.h - Sources/R360Diagnostics.mm - Sources/R360PortalValidator.h - Sources/R360PortalValidator.mm -) - -target_compile_definitions(Render360Portal PRIVATE - RENDER360_IOS_NATIVE=1 - RENDER360_PORTAL_NATIVE_BOOTSTRAP=1 - RENDER360_BUILD_IDENTIFIER="${RENDER360_BUILD_IDENTIFIER}" -) - -target_link_libraries(Render360Portal PRIVATE - "-framework UIKit" - "-framework Foundation" - "-framework UniformTypeIdentifiers" - "-framework QuartzCore" -) - + Sources/main.mm Sources/R360BootstrapViewController.h Sources/R360BootstrapViewController.mm + Sources/R360Diagnostics.h Sources/R360Diagnostics.mm Sources/R360PortalValidator.h Sources/R360PortalValidator.mm + Sources/R360RendererBackend.h Sources/R360GLESRendererBackend.h Sources/R360GLESRendererBackend.mm + Sources/R360SDLAudioHost.h Sources/R360SDLAudioHost.mm Sources/R360SDLInputDiagnostics.h Sources/R360SDLInputDiagnostics.mm + Sources/R360LifecycleService.h Sources/R360LifecycleService.mm Sources/R360SDLHost.h Sources/R360SDLHost.mm) +target_compile_definitions(Render360Portal PRIVATE RENDER360_IOS_NATIVE=1 RENDER360_PORTAL_NATIVE_BOOTSTRAP=1 RENDER360_N1_SDL_HOST=1 SDL_MAIN_HANDLED=1 RENDER360_BUILD_IDENTIFIER="${RENDER360_BUILD_IDENTIFIER}") +target_link_libraries(Render360Portal PRIVATE SDL2::SDL2-static "-framework UIKit" "-framework Foundation" "-framework UniformTypeIdentifiers" "-framework QuartzCore" "-framework OpenGLES" "-framework GameController" "-framework AVFoundation") set_target_properties(Render360Portal PROPERTIES - MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/Info.plist" - XCODE_ATTRIBUTE_PRODUCT_BUNDLE_IDENTIFIER "${RENDER360_BUNDLE_ID}" - XCODE_ATTRIBUTE_IPHONEOS_DEPLOYMENT_TARGET "${RENDER360_DEPLOYMENT_TARGET}" - XCODE_ATTRIBUTE_MARKETING_VERSION "${PROJECT_VERSION}" - XCODE_ATTRIBUTE_CURRENT_PROJECT_VERSION "${RENDER360_BUILD_NUMBER}" - XCODE_ATTRIBUTE_TARGETED_DEVICE_FAMILY "1" - XCODE_ATTRIBUTE_ONLY_ACTIVE_ARCH "NO" - XCODE_ATTRIBUTE_ARCHS "arm64" - XCODE_ATTRIBUTE_ENABLE_BITCODE "NO" - XCODE_ATTRIBUTE_CLANG_ENABLE_OBJC_ARC "YES" - XCODE_ATTRIBUTE_SUPPORTED_PLATFORMS "iphoneos" - XCODE_ATTRIBUTE_SUPPORTS_MACCATALYST "NO" - XCODE_ATTRIBUTE_SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD "NO" - XCODE_ATTRIBUTE_SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD "NO" - XCODE_ATTRIBUTE_GENERATE_INFOPLIST_FILE "NO" - XCODE_ATTRIBUTE_INFOPLIST_FILE "${CMAKE_CURRENT_SOURCE_DIR}/Info.plist" -) - -message(STATUS "Render360 iOS target: sdk=${CMAKE_OSX_SYSROOT} arch=${CMAKE_OSX_ARCHITECTURES} deployment=${RENDER360_DEPLOYMENT_TARGET} bundle=${RENDER360_BUNDLE_ID}") - -# N0 remains intentionally small. SDL arrives in N1 and Source libraries in N2+. -# Keeping those boundaries explicit makes every later engine milestone attributable. + MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/Info.plist" + XCODE_ATTRIBUTE_PRODUCT_BUNDLE_IDENTIFIER "${RENDER360_BUNDLE_ID}" + XCODE_ATTRIBUTE_IPHONEOS_DEPLOYMENT_TARGET "${RENDER360_DEPLOYMENT_TARGET}" + XCODE_ATTRIBUTE_MARKETING_VERSION "${PROJECT_VERSION}" + XCODE_ATTRIBUTE_CURRENT_PROJECT_VERSION "${RENDER360_BUILD_NUMBER}" + XCODE_ATTRIBUTE_TARGETED_DEVICE_FAMILY "1" XCODE_ATTRIBUTE_ONLY_ACTIVE_ARCH "NO" XCODE_ATTRIBUTE_ARCHS "arm64" + XCODE_ATTRIBUTE_ENABLE_BITCODE "NO" XCODE_ATTRIBUTE_CLANG_ENABLE_OBJC_ARC "YES" XCODE_ATTRIBUTE_SUPPORTED_PLATFORMS "iphoneos" + XCODE_ATTRIBUTE_SUPPORTS_MACCATALYST "NO" XCODE_ATTRIBUTE_SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD "NO" XCODE_ATTRIBUTE_SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD "NO" + XCODE_ATTRIBUTE_GENERATE_INFOPLIST_FILE "NO" XCODE_ATTRIBUTE_INFOPLIST_FILE "${CMAKE_CURRENT_SOURCE_DIR}/Info.plist") +message(STATUS "Render360 iOS N1 target: sdk=${CMAKE_OSX_SYSROOT} arch=${CMAKE_OSX_ARCHITECTURES} deployment=${RENDER360_DEPLOYMENT_TARGET} bundle=${RENDER360_BUNDLE_ID}") +# N1 stops at the SDL/GLES/input/audio host. Source libraries begin in N2. diff --git a/ios-native/Info.plist b/ios-native/Info.plist index 4cb52a7fdb..f23239d42b 100644 --- a/ios-native/Info.plist +++ b/ios-native/Info.plist @@ -1,47 +1,13 @@ - - - CFBundleDevelopmentRegion - en - CFBundleDisplayName - Render360 Portal - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - APPL - CFBundleShortVersionString - $(MARKETING_VERSION) - CFBundleVersion - $(CURRENT_PROJECT_VERSION) - LSRequiresIPhoneOS - - UIRequiredDeviceCapabilities - - arm64 - - UIApplicationSupportsIndirectInputEvents - - UIFileSharingEnabled - - LSSupportsOpeningDocumentsInPlace - - UIStatusBarHidden - - UISupportedInterfaceOrientations - - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UILaunchScreen - - UIViewControllerBasedStatusBarAppearance - - - + +CFBundleDevelopmentRegionenCFBundleDisplayNameRender360 Portal +CFBundleExecutable$(EXECUTABLE_NAME)CFBundleIdentifier$(PRODUCT_BUNDLE_IDENTIFIER) +CFBundleInfoDictionaryVersion6.0CFBundleName$(PRODUCT_NAME)CFBundlePackageTypeAPPL +CFBundleShortVersionString$(MARKETING_VERSION)CFBundleVersion$(CURRENT_PROJECT_VERSION) +LSRequiresIPhoneOSUIRequiresFullScreen +UIRequiredDeviceCapabilitiesarm64UIApplicationSupportsIndirectInputEvents +UIFileSharingEnabledLSSupportsOpeningDocumentsInPlaceUIStatusBarHidden +UISupportedInterfaceOrientationsUIInterfaceOrientationLandscapeLeftUIInterfaceOrientationLandscapeRight +UILaunchScreenUIViewControllerBasedStatusBarAppearance + diff --git a/ios-native/Sources/R360BootstrapViewController.mm b/ios-native/Sources/R360BootstrapViewController.mm index cb57a2bc1b..019429764c 100644 --- a/ios-native/Sources/R360BootstrapViewController.mm +++ b/ios-native/Sources/R360BootstrapViewController.mm @@ -1,217 +1,41 @@ #import "R360BootstrapViewController.h" - #import "R360Diagnostics.h" #import "R360PortalValidator.h" - +#import "R360SDLHost.h" #import -static UIColor *R360Background(void) { - return [UIColor colorWithRed:0.035 green:0.043 blue:0.055 alpha:1.0]; -} - -static UIColor *R360Panel(void) { - return [UIColor colorWithRed:0.075 green:0.086 blue:0.105 alpha:1.0]; -} - +static UIColor *R360Background(void){return [UIColor colorWithRed:0.035 green:0.043 blue:0.055 alpha:1];} +static UIColor *R360Panel(void){return [UIColor colorWithRed:0.075 green:0.086 blue:0.105 alpha:1];} @interface R360BootstrapViewController () -@property(nonatomic, strong) UILabel *statusLabel; -@property(nonatomic, strong) UILabel *detailLabel; -@property(nonatomic, strong) UILabel *diagnosticsLabel; +@property(nonatomic,strong) UILabel *statusLabel; @property(nonatomic,strong) UILabel *detailLabel; @property(nonatomic,strong) UILabel *diagnosticsLabel; @end - @implementation R360BootstrapViewController - - (void)viewDidLoad { - [super viewDidLoad]; - self.view.backgroundColor = R360Background(); - - UIScrollView *scrollView = [[UIScrollView alloc] init]; - scrollView.translatesAutoresizingMaskIntoConstraints = NO; - scrollView.alwaysBounceVertical = YES; - - UIStackView *stack = [[UIStackView alloc] init]; - stack.translatesAutoresizingMaskIntoConstraints = NO; - stack.axis = UILayoutConstraintAxisVertical; - stack.spacing = 12; - - UILabel *title = [[UILabel alloc] init]; - title.text = @"Render360 Portal"; - title.textColor = UIColor.whiteColor; - title.font = [UIFont systemFontOfSize:32 weight:UIFontWeightBold]; - - UILabel *subtitle = [[UILabel alloc] init]; - subtitle.text = @"Native iOS N0 bootstrap • arm64 • no WebAssembly"; - subtitle.textColor = [UIColor colorWithWhite:0.72 alpha:1.0]; - subtitle.font = [UIFont monospacedSystemFontOfSize:14 weight:UIFontWeightRegular]; - - UIView *panel = [[UIView alloc] init]; - panel.translatesAutoresizingMaskIntoConstraints = NO; - panel.backgroundColor = R360Panel(); - panel.layer.cornerRadius = 16; - - UIStackView *panelStack = [[UIStackView alloc] init]; - panelStack.translatesAutoresizingMaskIntoConstraints = NO; - panelStack.axis = UILayoutConstraintAxisVertical; - panelStack.spacing = 10; - - UILabel *status = [[UILabel alloc] init]; - status.numberOfLines = 0; - status.text = @"Portal data is not configured."; - status.textColor = UIColor.whiteColor; - status.font = [UIFont systemFontOfSize:20 weight:UIFontWeightSemibold]; - self.statusLabel = status; - - UILabel *detail = [[UILabel alloc] init]; - detail.numberOfLines = 0; - detail.text = @"Choose a legally owned Portal root when you are ready. Missing game data is a normal setup state and never a fatal bootstrap error."; - detail.textColor = [UIColor colorWithWhite:0.78 alpha:1.0]; - detail.font = [UIFont systemFontOfSize:15 weight:UIFontWeightRegular]; - self.detailLabel = detail; - - UIButtonConfiguration *buttonConfiguration = [UIButtonConfiguration filledButtonConfiguration]; - buttonConfiguration.title = @"Choose Portal Folder"; - buttonConfiguration.baseBackgroundColor = UIColor.whiteColor; - buttonConfiguration.baseForegroundColor = [UIColor colorWithRed:0.05 green:0.08 blue:0.12 alpha:1.0]; - buttonConfiguration.contentInsets = NSDirectionalEdgeInsetsMake(12, 18, 12, 18); - UIButton *importButton = [UIButton buttonWithConfiguration:buttonConfiguration primaryAction:nil]; - [importButton addTarget:self action:@selector(importPortalFolder:) forControlEvents:UIControlEventTouchUpInside]; - - UILabel *diagnosticsHeading = [[UILabel alloc] init]; - diagnosticsHeading.text = @"Bootstrap diagnostics"; - diagnosticsHeading.textColor = UIColor.whiteColor; - diagnosticsHeading.font = [UIFont systemFontOfSize:16 weight:UIFontWeightSemibold]; - - UILabel *diagnostics = [[UILabel alloc] init]; - diagnostics.numberOfLines = 0; - diagnostics.textColor = [UIColor colorWithWhite:0.66 alpha:1.0]; - diagnostics.font = [UIFont monospacedSystemFontOfSize:12 weight:UIFontWeightRegular]; - self.diagnosticsLabel = diagnostics; - - UILabel *footer = [[UILabel alloc] init]; - footer.numberOfLines = 0; - footer.text = @"This phase validates only the native bootstrap and a user-selected folder. It does not copy VPKs, mount Source filesystems, render Portal, or persist authorization yet."; - footer.textColor = [UIColor colorWithWhite:0.55 alpha:1.0]; - footer.font = [UIFont monospacedSystemFontOfSize:12 weight:UIFontWeightRegular]; - - [self.view addSubview:scrollView]; - [scrollView addSubview:stack]; - [stack addArrangedSubview:title]; - [stack addArrangedSubview:subtitle]; - [stack addArrangedSubview:panel]; - [panel addSubview:panelStack]; - [panelStack addArrangedSubview:status]; - [panelStack addArrangedSubview:detail]; - [panelStack addArrangedSubview:importButton]; - [stack addArrangedSubview:diagnosticsHeading]; - [stack addArrangedSubview:diagnostics]; - [stack addArrangedSubview:footer]; - - UILayoutGuide *safe = self.view.safeAreaLayoutGuide; - [NSLayoutConstraint activateConstraints:@[ - [scrollView.leadingAnchor constraintEqualToAnchor:safe.leadingAnchor], - [scrollView.trailingAnchor constraintEqualToAnchor:safe.trailingAnchor], - [scrollView.topAnchor constraintEqualToAnchor:safe.topAnchor], - [scrollView.bottomAnchor constraintEqualToAnchor:safe.bottomAnchor], - - [stack.leadingAnchor constraintEqualToAnchor:scrollView.contentLayoutGuide.leadingAnchor constant:24], - [stack.trailingAnchor constraintEqualToAnchor:scrollView.contentLayoutGuide.trailingAnchor constant:-24], - [stack.topAnchor constraintEqualToAnchor:scrollView.contentLayoutGuide.topAnchor constant:18], - [stack.bottomAnchor constraintEqualToAnchor:scrollView.contentLayoutGuide.bottomAnchor constant:-18], - [stack.widthAnchor constraintEqualToAnchor:scrollView.frameLayoutGuide.widthAnchor constant:-48], - - [panelStack.leadingAnchor constraintEqualToAnchor:panel.leadingAnchor constant:18], - [panelStack.trailingAnchor constraintEqualToAnchor:panel.trailingAnchor constant:-18], - [panelStack.topAnchor constraintEqualToAnchor:panel.topAnchor constant:16], - [panelStack.bottomAnchor constraintEqualToAnchor:panel.bottomAnchor constant:-16] - ]]; - - [NSNotificationCenter.defaultCenter addObserver:self - selector:@selector(diagnosticsDidChange:) - name:R360DiagnosticsDidChangeNotification - object:R360Diagnostics.sharedDiagnostics]; - [NSNotificationCenter.defaultCenter addObserver:self - selector:@selector(applicationDidReceiveMemoryWarning:) - name:UIApplicationDidReceiveMemoryWarningNotification - object:nil]; - - R360Diagnostics *diagnosticsState = R360Diagnostics.sharedDiagnostics; - [diagnosticsState setCheckpoint:@"ui-ready"]; - [diagnosticsState setGameDataState:@"not configured"]; - [diagnosticsState setLatestError:nil]; - [diagnosticsState setCheckpoint:@"game-data-not-configured"]; - [self refreshDiagnostics]; -} - -- (void)dealloc { - [NSNotificationCenter.defaultCenter removeObserver:self]; -} - -- (void)diagnosticsDidChange:(NSNotification *)notification { - [self refreshDiagnostics]; -} - -- (void)applicationDidReceiveMemoryWarning:(NSNotification *)notification { - [R360Diagnostics.sharedDiagnostics recordMemoryWarning]; -} - -- (void)refreshDiagnostics { - if (!self.isViewLoaded) { - return; - } - self.diagnosticsLabel.text = [R360Diagnostics.sharedDiagnostics formattedSummary]; -} - -- (void)importPortalFolder:(id)sender { - R360Diagnostics *diagnostics = R360Diagnostics.sharedDiagnostics; - [diagnostics setLatestError:nil]; - [diagnostics setCheckpoint:@"import-picker-open"]; - - UIDocumentPickerViewController *picker = [[UIDocumentPickerViewController alloc] - initForOpeningContentTypes:@[UTTypeFolder] - asCopy:NO]; - picker.delegate = self; - picker.allowsMultipleSelection = NO; - [self presentViewController:picker animated:YES completion:nil]; -} - -- (void)documentPickerWasCancelled:(UIDocumentPickerViewController *)controller { - [R360Diagnostics.sharedDiagnostics setCheckpoint:@"import-picker-cancelled"]; -} - -- (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentsAtURLs:(NSArray *)urls { - NSURL *rootURL = urls.firstObject; - if (!rootURL) { - [R360Diagnostics.sharedDiagnostics setLatestError:@"document picker returned no URL"]; - [R360Diagnostics.sharedDiagnostics setCheckpoint:@"candidate-root-invalid"]; - return; - } - - R360Diagnostics *diagnostics = R360Diagnostics.sharedDiagnostics; - [diagnostics setCheckpoint:@"candidate-root-validating"]; - [diagnostics setGameDataState:@"validating selected root"]; - - R360PortalValidationResult *result = [R360PortalValidator validateCandidateRootURL:rootURL]; - if (result.isValid) { - self.statusLabel.text = @"Portal folder verified for N0."; - self.detailLabel.text = result.detail; - [diagnostics setGameDataState:@"candidate root valid (temporary access only)"]; - [diagnostics setLatestError:nil]; - [diagnostics setCheckpoint:@"candidate-root-valid"]; - } else { - self.statusLabel.text = @"That folder is not a complete Portal root."; - self.detailLabel.text = result.detail; - [diagnostics setGameDataState:@"candidate root invalid"]; - [diagnostics setLatestError:result.errorReason ?: @"Portal root validation failed"]; - [diagnostics setCheckpoint:@"candidate-root-invalid"]; - } -} - -- (UIInterfaceOrientationMask)supportedInterfaceOrientations { - return UIInterfaceOrientationMaskLandscape; -} - -- (BOOL)prefersStatusBarHidden { - return YES; -} - + [super viewDidLoad]; self.view.backgroundColor=R360Background(); + UIScrollView *scroll=[UIScrollView new]; scroll.translatesAutoresizingMaskIntoConstraints=NO; scroll.alwaysBounceVertical=YES; + UIStackView *stack=[UIStackView new]; stack.translatesAutoresizingMaskIntoConstraints=NO; stack.axis=UILayoutConstraintAxisVertical; stack.spacing=12; + UILabel *title=[UILabel new]; title.text=@"Render360 Portal"; title.textColor=UIColor.whiteColor; title.font=[UIFont systemFontOfSize:32 weight:UIFontWeightBold]; + UILabel *sub=[UILabel new]; sub.text=@"Native iOS N1 • SDL2 host • GLES3 bring-up"; sub.textColor=[UIColor colorWithWhite:.72 alpha:1]; sub.font=[UIFont monospacedSystemFontOfSize:14 weight:UIFontWeightRegular]; + UIView *panel=[UIView new]; panel.translatesAutoresizingMaskIntoConstraints=NO; panel.backgroundColor=R360Panel(); panel.layer.cornerRadius=16; + UIStackView *ps=[UIStackView new]; ps.translatesAutoresizingMaskIntoConstraints=NO; ps.axis=UILayoutConstraintAxisVertical; ps.spacing=10; + UILabel *status=[UILabel new]; status.numberOfLines=0; status.text=@"Portal data is not configured."; status.textColor=UIColor.whiteColor; status.font=[UIFont systemFontOfSize:20 weight:UIFontWeightSemibold]; self.statusLabel=status; + UILabel *detail=[UILabel new]; detail.numberOfLines=0; detail.text=@"Folder validation remains optional setup. Start the N1 host to test SDL2/GLES3/input/audio; no Portal files are required."; detail.textColor=[UIColor colorWithWhite:.78 alpha:1]; detail.font=[UIFont systemFontOfSize:15]; self.detailLabel=detail; + UIButtonConfiguration *startCfg=[UIButtonConfiguration filledButtonConfiguration]; startCfg.title=@"Start N1 SDL Host"; startCfg.contentInsets=NSDirectionalEdgeInsetsMake(12,18,12,18); UIButton *start=[UIButton buttonWithConfiguration:startCfg primaryAction:nil]; [start addTarget:self action:@selector(startSDL:) forControlEvents:UIControlEventTouchUpInside]; + UIButtonConfiguration *importCfg=[UIButtonConfiguration borderedButtonConfiguration]; importCfg.title=@"Choose Portal Folder"; importCfg.contentInsets=NSDirectionalEdgeInsetsMake(10,18,10,18); UIButton *import=[UIButton buttonWithConfiguration:importCfg primaryAction:nil]; [import addTarget:self action:@selector(importPortalFolder:) forControlEvents:UIControlEventTouchUpInside]; + UILabel *dh=[UILabel new]; dh.text=@"Latest native diagnostics"; dh.textColor=UIColor.whiteColor; dh.font=[UIFont systemFontOfSize:16 weight:UIFontWeightSemibold]; + UILabel *diag=[UILabel new]; diag.numberOfLines=0; diag.textColor=[UIColor colorWithWhite:.66 alpha:1]; diag.font=[UIFont monospacedSystemFontOfSize:12 weight:UIFontWeightRegular]; self.diagnosticsLabel=diag; + [self.view addSubview:scroll]; [scroll addSubview:stack]; [stack addArrangedSubview:title]; [stack addArrangedSubview:sub]; [stack addArrangedSubview:panel]; [panel addSubview:ps]; [ps addArrangedSubview:status]; [ps addArrangedSubview:detail]; [ps addArrangedSubview:start]; [ps addArrangedSubview:import]; [stack addArrangedSubview:dh]; [stack addArrangedSubview:diag]; + UILayoutGuide *safe=self.view.safeAreaLayoutGuide; [NSLayoutConstraint activateConstraints:@[[scroll.leadingAnchor constraintEqualToAnchor:safe.leadingAnchor],[scroll.trailingAnchor constraintEqualToAnchor:safe.trailingAnchor],[scroll.topAnchor constraintEqualToAnchor:safe.topAnchor],[scroll.bottomAnchor constraintEqualToAnchor:safe.bottomAnchor],[stack.leadingAnchor constraintEqualToAnchor:scroll.contentLayoutGuide.leadingAnchor constant:24],[stack.trailingAnchor constraintEqualToAnchor:scroll.contentLayoutGuide.trailingAnchor constant:-24],[stack.topAnchor constraintEqualToAnchor:scroll.contentLayoutGuide.topAnchor constant:18],[stack.bottomAnchor constraintEqualToAnchor:scroll.contentLayoutGuide.bottomAnchor constant:-18],[stack.widthAnchor constraintEqualToAnchor:scroll.frameLayoutGuide.widthAnchor constant:-48],[ps.leadingAnchor constraintEqualToAnchor:panel.leadingAnchor constant:18],[ps.trailingAnchor constraintEqualToAnchor:panel.trailingAnchor constant:-18],[ps.topAnchor constraintEqualToAnchor:panel.topAnchor constant:16],[ps.bottomAnchor constraintEqualToAnchor:panel.bottomAnchor constant:-16]]]; + [NSNotificationCenter.defaultCenter addObserver:self selector:@selector(diagnosticsDidChange:) name:R360DiagnosticsDidChangeNotification object:R360Diagnostics.sharedDiagnostics]; + R360Diagnostics *d=R360Diagnostics.sharedDiagnostics; [d setCheckpoint:@"ui-ready"]; [d setGameDataState:@"not configured"]; [d setLatestError:nil]; [d setCheckpoint:@"game-data-not-configured"]; [self refreshDiagnostics]; +} +- (void)dealloc { [NSNotificationCenter.defaultCenter removeObserver:self]; } +- (void)diagnosticsDidChange:(NSNotification *)n { (void)n; [self refreshDiagnostics]; } +- (void)refreshDiagnostics { if(self.isViewLoaded) self.diagnosticsLabel.text=R360Diagnostics.sharedDiagnostics.formattedSummary; } +- (void)startSDL:(id)sender { (void)sender; NSString *error=nil; if(![R360SDLHost.sharedHost start:&error]){ self.statusLabel.text=@"N1 SDL host failed to start."; self.detailLabel.text=error?:@"Unknown SDL error"; [R360Diagnostics.sharedDiagnostics setLatestError:error]; } else { self.statusLabel.text=@"N1 SDL host started."; self.detailLabel.text=@"SDL now owns the game-facing window. The animated clear is diagnostic only, not Portal rendering."; } } +- (void)importPortalFolder:(id)sender { (void)sender; R360Diagnostics *d=R360Diagnostics.sharedDiagnostics; [d setLatestError:nil]; [d setCheckpoint:@"import-picker-open"]; UIDocumentPickerViewController *p=[[UIDocumentPickerViewController alloc] initForOpeningContentTypes:@[UTTypeFolder] asCopy:NO]; p.delegate=self; p.allowsMultipleSelection=NO; [self presentViewController:p animated:YES completion:nil]; } +- (void)documentPickerWasCancelled:(UIDocumentPickerViewController *)controller { (void)controller; [R360Diagnostics.sharedDiagnostics setCheckpoint:@"import-picker-cancelled"]; } +- (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentsAtURLs:(NSArray *)urls { (void)controller; NSURL *root=urls.firstObject; R360Diagnostics *d=R360Diagnostics.sharedDiagnostics; if(!root){[d setLatestError:@"document picker returned no URL"];[d setCheckpoint:@"candidate-root-invalid"];return;} [d setCheckpoint:@"candidate-root-validating"]; [d setGameDataState:@"validating selected root"]; R360PortalValidationResult *r=[R360PortalValidator validateCandidateRootURL:root]; if(r.isValid){self.statusLabel.text=@"Portal folder verified for N1 setup.";self.detailLabel.text=r.detail;[d setGameDataState:@"candidate root valid (temporary access only)"];[d setLatestError:nil];[d setCheckpoint:@"candidate-root-valid"];}else{self.statusLabel.text=@"That folder is not a complete Portal root.";self.detailLabel.text=r.detail;[d setGameDataState:@"candidate root invalid"];[d setLatestError:r.errorReason?:@"Portal root validation failed"];[d setCheckpoint:@"candidate-root-invalid"];}} +- (UIInterfaceOrientationMask)supportedInterfaceOrientations{return UIInterfaceOrientationMaskLandscape;} +- (BOOL)prefersStatusBarHidden{return YES;} @end diff --git a/ios-native/Sources/R360Diagnostics.h b/ios-native/Sources/R360Diagnostics.h index bfd474c535..f71ea7b3d1 100644 --- a/ios-native/Sources/R360Diagnostics.h +++ b/ios-native/Sources/R360Diagnostics.h @@ -1,24 +1,26 @@ #import - NS_ASSUME_NONNULL_BEGIN - extern NSString * const R360DiagnosticsDidChangeNotification; - @interface R360Diagnostics : NSObject - @property(nonatomic, copy, readonly) NSString *checkpoint; @property(nonatomic, copy, readonly) NSString *gameDataState; +@property(nonatomic, copy, readonly) NSString *lifecycleState; +@property(nonatomic, copy, readonly) NSString *rendererState; +@property(nonatomic, copy, readonly) NSString *displayState; +@property(nonatomic, copy, readonly) NSString *inputState; +@property(nonatomic, copy, readonly) NSString *audioState; @property(nonatomic, copy, readonly, nullable) NSString *latestError; @property(nonatomic, assign, readonly) NSUInteger memoryWarningCount; - + (instancetype)sharedDiagnostics; - - (void)setCheckpoint:(NSString *)checkpoint; - (void)setGameDataState:(NSString *)state; +- (void)setLifecycleState:(NSString *)state; +- (void)setRendererState:(NSString *)state; +- (void)setDisplayState:(NSString *)state; +- (void)setInputState:(NSString *)state; +- (void)setAudioState:(NSString *)state; - (void)setLatestError:(nullable NSString *)error; - (void)recordMemoryWarning; - (NSString *)formattedSummary; - @end - NS_ASSUME_NONNULL_END diff --git a/ios-native/Sources/R360Diagnostics.mm b/ios-native/Sources/R360Diagnostics.mm index 9944fd8609..97db3ffad5 100644 --- a/ios-native/Sources/R360Diagnostics.mm +++ b/ios-native/Sources/R360Diagnostics.mm @@ -1,118 +1,48 @@ #import "R360Diagnostics.h" - #import - #ifndef RENDER360_BUILD_IDENTIFIER #define RENDER360_BUILD_IDENTIFIER "local" #endif - NSString * const R360DiagnosticsDidChangeNotification = @"R360DiagnosticsDidChangeNotification"; - @interface R360Diagnostics () @property(nonatomic, copy, readwrite) NSString *checkpoint; @property(nonatomic, copy, readwrite) NSString *gameDataState; +@property(nonatomic, copy, readwrite) NSString *lifecycleState; +@property(nonatomic, copy, readwrite) NSString *rendererState; +@property(nonatomic, copy, readwrite) NSString *displayState; +@property(nonatomic, copy, readwrite) NSString *inputState; +@property(nonatomic, copy, readwrite) NSString *audioState; @property(nonatomic, copy, readwrite, nullable) NSString *latestError; @property(nonatomic, assign, readwrite) NSUInteger memoryWarningCount; @end - @implementation R360Diagnostics - -+ (instancetype)sharedDiagnostics { - static R360Diagnostics *diagnostics; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - diagnostics = [[R360Diagnostics alloc] initPrivate]; - }); - return diagnostics; -} - -- (instancetype)init { - [NSException raise:NSInternalInconsistencyException format:@"Use +sharedDiagnostics"]; - return nil; -} - -- (instancetype)initPrivate { - self = [super init]; - if (self) { - _checkpoint = @"bootstrap-created"; - _gameDataState = @"not configured"; - _memoryWarningCount = 0; - } - return self; -} - -- (void)postChange { - [NSNotificationCenter.defaultCenter postNotificationName:R360DiagnosticsDidChangeNotification object:self]; -} - -- (void)setCheckpoint:(NSString *)checkpoint { - @synchronized (self) { - _checkpoint = [checkpoint copy]; - } - [self postChange]; -} - -- (void)setGameDataState:(NSString *)state { - @synchronized (self) { - _gameDataState = [state copy]; - } - [self postChange]; -} - -- (void)setLatestError:(NSString * _Nullable)error { - @synchronized (self) { - _latestError = [error copy]; - } - [self postChange]; -} - -- (void)recordMemoryWarning { - @synchronized (self) { - _memoryWarningCount += 1; - _checkpoint = @"memory-warning"; - } - [self postChange]; -} - ++ (instancetype)sharedDiagnostics { static R360Diagnostics *d; static dispatch_once_t once; dispatch_once(&once, ^{ d=[[R360Diagnostics alloc] initPrivate]; }); return d; } +- (instancetype)init { [NSException raise:NSInternalInconsistencyException format:@"Use +sharedDiagnostics"]; return nil; } +- (instancetype)initPrivate { if ((self=[super init])) { _checkpoint=@"bootstrap-created"; _gameDataState=@"not configured"; _lifecycleState=@"starting"; _rendererState=@"not started"; _displayState=@"not available"; _inputState=@"not started"; _audioState=@"not started"; } return self; } +- (void)postChange { void (^p)(void)=^{ [NSNotificationCenter.defaultCenter postNotificationName:R360DiagnosticsDidChangeNotification object:self]; }; NSThread.isMainThread ? p() : dispatch_async(dispatch_get_main_queue(), p); } +#define R360_SETTER(method, ivar) - (void)method:(NSString *)state { @synchronized(self){ ivar=[state copy]; } [self postChange]; } +R360_SETTER(setCheckpoint, _checkpoint) +R360_SETTER(setGameDataState, _gameDataState) +R360_SETTER(setLifecycleState, _lifecycleState) +R360_SETTER(setRendererState, _rendererState) +R360_SETTER(setDisplayState, _displayState) +R360_SETTER(setInputState, _inputState) +R360_SETTER(setAudioState, _audioState) +- (void)setLatestError:(NSString *)error { @synchronized(self){ _latestError=[error copy]; } [self postChange]; } +- (void)recordMemoryWarning { @synchronized(self){ _memoryWarningCount++; _checkpoint=@"memory-warning"; } [self postChange]; } - (NSString *)architectureName { #if defined(__arm64__) || defined(__aarch64__) - return @"arm64"; + return @"arm64"; #elif defined(__x86_64__) - return @"x86_64"; + return @"x86_64"; #else - return @"unknown"; + return @"unknown"; #endif } - - (NSString *)formattedSummary { - NSString *checkpoint; - NSString *gameDataState; - NSString *latestError; - NSUInteger memoryWarningCount; - @synchronized (self) { - checkpoint = [_checkpoint copy]; - gameDataState = [_gameDataState copy]; - latestError = [_latestError copy]; - memoryWarningCount = _memoryWarningCount; - } - - NSBundle *bundle = NSBundle.mainBundle; - NSString *version = [bundle objectForInfoDictionaryKey:@"CFBundleShortVersionString"] ?: @"unknown"; - NSString *build = [bundle objectForInfoDictionaryKey:@"CFBundleVersion"] ?: @"unknown"; - NSString *commit = [NSString stringWithUTF8String:RENDER360_BUILD_IDENTIFIER] ?: @"unknown"; - NSString *osVersion = UIDevice.currentDevice.systemVersion ?: @"unknown"; - - return [NSString stringWithFormat: - @"version: %@ (%@)\ncommit: %@\narchitecture: %@\niOS: %@\ncheckpoint: %@\nmemory warnings: %lu\ngame data: %@\nlatest error: %@", - version, - build, - commit, - [self architectureName], - osVersion, - checkpoint, - (unsigned long)memoryWarningCount, - gameDataState, - latestError.length > 0 ? latestError : @"none"]; + NSString *cp,*gd,*lc,*rs,*ds,*is,*as,*err; NSUInteger mw; + @synchronized(self){ cp=[_checkpoint copy]; gd=[_gameDataState copy]; lc=[_lifecycleState copy]; rs=[_rendererState copy]; ds=[_displayState copy]; is=[_inputState copy]; as=[_audioState copy]; err=[_latestError copy]; mw=_memoryWarningCount; } + NSBundle *b=NSBundle.mainBundle; NSString *v=[b objectForInfoDictionaryKey:@"CFBundleShortVersionString"]?:@"unknown"; NSString *build=[b objectForInfoDictionaryKey:@"CFBundleVersion"]?:@"unknown"; NSString *commit=[NSString stringWithUTF8String:RENDER360_BUILD_IDENTIFIER]?:@"unknown"; + return [NSString stringWithFormat:@"version: %@ (%@)\ncommit: %@\narchitecture: %@\niOS: %@\ncheckpoint: %@\nlifecycle: %@\ndisplay: %@\nrenderer: %@\ninput: %@\naudio: %@\nmemory warnings: %lu\ngame data: %@\nlatest error: %@",v,build,commit,[self architectureName],UIDevice.currentDevice.systemVersion?:@"unknown",cp,lc,ds,rs,is,as,(unsigned long)mw,gd,err.length?err:@"none"]; } - @end diff --git a/ios-native/Sources/R360GLESRendererBackend.h b/ios-native/Sources/R360GLESRendererBackend.h new file mode 100644 index 0000000000..bbfb003391 --- /dev/null +++ b/ios-native/Sources/R360GLESRendererBackend.h @@ -0,0 +1,7 @@ +#import +#import "R360RendererBackend.h" + +NS_ASSUME_NONNULL_BEGIN +@interface R360GLESRendererBackend : NSObject +@end +NS_ASSUME_NONNULL_END diff --git a/ios-native/Sources/R360GLESRendererBackend.mm b/ios-native/Sources/R360GLESRendererBackend.mm new file mode 100644 index 0000000000..e66a2e48ef --- /dev/null +++ b/ios-native/Sources/R360GLESRendererBackend.mm @@ -0,0 +1,83 @@ +#import "R360GLESRendererBackend.h" +#import "R360Diagnostics.h" +#import +#include +#include +#include +#include + +@interface R360GLESRendererBackend () +@property(nonatomic, assign) SDL_Window *window; +@property(nonatomic, assign) SDL_GLContext context; +@property(nonatomic, assign) int drawableWidth; +@property(nonatomic, assign) int drawableHeight; +@end + +@implementation R360GLESRendererBackend + +- (BOOL)startWithWindow:(SDL_Window *)window error:(NSString **)error { + self.window = window; + self.context = SDL_GL_CreateContext(window); + if (!self.context) { + if (error) *error = [NSString stringWithFormat:@"SDL_GL_CreateContext failed: %s", SDL_GetError()]; + return NO; + } + if (SDL_GL_MakeCurrent(window, self.context) != 0) { + if (error) *error = [NSString stringWithFormat:@"SDL_GL_MakeCurrent failed: %s", SDL_GetError()]; + return NO; + } + SDL_GL_SetSwapInterval(1); + const GLubyte *version = glGetString(GL_VERSION); + const GLubyte *renderer = glGetString(GL_RENDERER); + const GLubyte *vendor = glGetString(GL_VENDOR); + NSString *summary = [NSString stringWithFormat:@"GLES: %s | renderer: %s | vendor: %s", + version ? (const char *)version : "unknown", + renderer ? (const char *)renderer : "unknown", + vendor ? (const char *)vendor : "unknown"]; + [R360Diagnostics.sharedDiagnostics setRendererState:summary]; + [R360Diagnostics.sharedDiagnostics setCheckpoint:@"gles-context-created"]; + [self refreshMetrics]; + return YES; +} + +- (void)refreshMetrics { + if (!self.window) return; + int logicalW = 0, logicalH = 0; + SDL_GetWindowSize(self.window, &logicalW, &logicalH); + SDL_GL_GetDrawableSize(self.window, &_drawableWidth, &_drawableHeight); + UIEdgeInsets safe = UIEdgeInsetsZero; + SDL_SysWMinfo info; + SDL_VERSION(&info.version); + if (SDL_GetWindowWMInfo(self.window, &info) && info.subsystem == SDL_SYSWM_UIKIT && info.info.uikit.window) { + safe = info.info.uikit.window.safeAreaInsets; + } + double scale = logicalW > 0 ? (double)self.drawableWidth / (double)logicalW : 0.0; + [R360Diagnostics.sharedDiagnostics setDisplayState:[NSString stringWithFormat:@"points %dx%d | pixels %dx%d | scale %.2fx | safe %.0f/%.0f/%.0f/%.0f", + logicalW, logicalH, self.drawableWidth, self.drawableHeight, scale, + safe.top, safe.left, safe.bottom, safe.right]]; +} + +- (void)renderFrameAtSeconds:(double)seconds { + if (!self.window || !self.context) return; + glViewport(0, 0, self.drawableWidth, self.drawableHeight); + float r = 0.12f + 0.08f * (float)(0.5 + 0.5 * sin(seconds * 0.9)); + float g = 0.16f + 0.10f * (float)(0.5 + 0.5 * sin(seconds * 1.1 + 1.7)); + float b = 0.22f + 0.12f * (float)(0.5 + 0.5 * sin(seconds * 0.7 + 3.2)); + glClearColor(r, g, b, 1.0f); + glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); + SDL_GL_SwapWindow(self.window); +} + +- (void)resume { + if (self.window && self.context) SDL_GL_MakeCurrent(self.window, self.context); + [self refreshMetrics]; +} + +- (void)shutdown { + if (self.context) { + SDL_GL_DeleteContext(self.context); + self.context = NULL; + } + self.window = NULL; +} +@end diff --git a/ios-native/Sources/R360LifecycleService.h b/ios-native/Sources/R360LifecycleService.h new file mode 100644 index 0000000000..4d7a1378b4 --- /dev/null +++ b/ios-native/Sources/R360LifecycleService.h @@ -0,0 +1,17 @@ +#import +NS_ASSUME_NONNULL_BEGIN +@protocol R360LifecycleServiceDelegate +- (void)r360WillResignActive; +- (void)r360DidBecomeActive; +- (void)r360DidEnterBackground; +- (void)r360WillEnterForeground; +- (void)r360AudioInterruptionBegan; +- (void)r360AudioInterruptionEndedShouldResume:(BOOL)shouldResume; +- (void)r360OrientationDidChange; +@end +@interface R360LifecycleService : NSObject +@property(nonatomic, weak, nullable) id delegate; ++ (instancetype)sharedService; +- (void)startObserving; +@end +NS_ASSUME_NONNULL_END diff --git a/ios-native/Sources/R360LifecycleService.mm b/ios-native/Sources/R360LifecycleService.mm new file mode 100644 index 0000000000..fc6f1bb31d --- /dev/null +++ b/ios-native/Sources/R360LifecycleService.mm @@ -0,0 +1,41 @@ +#import "R360LifecycleService.h" +#import "R360Diagnostics.h" +#import +#import + +@implementation R360LifecycleService { + BOOL _observing; +} ++ (instancetype)sharedService { static R360LifecycleService *s; static dispatch_once_t once; dispatch_once(&once, ^{ s = [R360LifecycleService new]; }); return s; } +- (void)startObserving { + if (_observing) return; _observing = YES; + NSNotificationCenter *nc = NSNotificationCenter.defaultCenter; + [nc addObserver:self selector:@selector(willResign:) name:UIApplicationWillResignActiveNotification object:nil]; + [nc addObserver:self selector:@selector(didBecome:) name:UIApplicationDidBecomeActiveNotification object:nil]; + [nc addObserver:self selector:@selector(didBackground:) name:UIApplicationDidEnterBackgroundNotification object:nil]; + [nc addObserver:self selector:@selector(willForeground:) name:UIApplicationWillEnterForegroundNotification object:nil]; + [nc addObserver:self selector:@selector(memoryWarning:) name:UIApplicationDidReceiveMemoryWarningNotification object:nil]; + [nc addObserver:self selector:@selector(orientation:) name:UIDeviceOrientationDidChangeNotification object:nil]; + [nc addObserver:self selector:@selector(audioInterruption:) name:AVAudioSessionInterruptionNotification object:AVAudioSession.sharedInstance]; + [UIDevice.currentDevice beginGeneratingDeviceOrientationNotifications]; + [R360Diagnostics.sharedDiagnostics setLifecycleState:@"observing"]; +} +- (void)willResign:(NSNotification *)n { (void)n; [R360Diagnostics.sharedDiagnostics setLifecycleState:@"resigned active"]; [R360Diagnostics.sharedDiagnostics setCheckpoint:@"app-resigned-active"]; [self.delegate r360WillResignActive]; } +- (void)didBecome:(NSNotification *)n { (void)n; [R360Diagnostics.sharedDiagnostics setLifecycleState:@"active"]; [R360Diagnostics.sharedDiagnostics setCheckpoint:@"app-active"]; [self.delegate r360DidBecomeActive]; } +- (void)didBackground:(NSNotification *)n { (void)n; [R360Diagnostics.sharedDiagnostics setLifecycleState:@"background"]; [R360Diagnostics.sharedDiagnostics setCheckpoint:@"app-background"]; [self.delegate r360DidEnterBackground]; } +- (void)willForeground:(NSNotification *)n { (void)n; [R360Diagnostics.sharedDiagnostics setLifecycleState:@"foreground"]; [R360Diagnostics.sharedDiagnostics setCheckpoint:@"app-foreground"]; [self.delegate r360WillEnterForeground]; } +- (void)memoryWarning:(NSNotification *)n { (void)n; [R360Diagnostics.sharedDiagnostics recordMemoryWarning]; } +- (void)orientation:(NSNotification *)n { (void)n; [R360Diagnostics.sharedDiagnostics setCheckpoint:@"orientation-changed"]; [self.delegate r360OrientationDidChange]; } +- (void)audioInterruption:(NSNotification *)n { + AVAudioSessionInterruptionType type = [n.userInfo[AVAudioSessionInterruptionTypeKey] unsignedIntegerValue]; + if (type == AVAudioSessionInterruptionTypeBegan) { + [R360Diagnostics.sharedDiagnostics setCheckpoint:@"audio-interruption-begin"]; + [self.delegate r360AudioInterruptionBegan]; + } else { + AVAudioSessionInterruptionOptions opts = [n.userInfo[AVAudioSessionInterruptionOptionKey] unsignedIntegerValue]; + BOOL resume = (opts & AVAudioSessionInterruptionOptionShouldResume) != 0; + [R360Diagnostics.sharedDiagnostics setCheckpoint:@"audio-interruption-end"]; + [self.delegate r360AudioInterruptionEndedShouldResume:resume]; + } +} +@end diff --git a/ios-native/Sources/R360RendererBackend.h b/ios-native/Sources/R360RendererBackend.h new file mode 100644 index 0000000000..a79b919a0c --- /dev/null +++ b/ios-native/Sources/R360RendererBackend.h @@ -0,0 +1,15 @@ +#import + +typedef struct SDL_Window SDL_Window; + +NS_ASSUME_NONNULL_BEGIN + +@protocol R360RendererBackend +- (BOOL)startWithWindow:(SDL_Window *)window error:(NSString * _Nullable * _Nullable)error; +- (void)renderFrameAtSeconds:(double)seconds; +- (void)refreshMetrics; +- (void)resume; +- (void)shutdown; +@end + +NS_ASSUME_NONNULL_END diff --git a/ios-native/Sources/R360SDLAudioHost.h b/ios-native/Sources/R360SDLAudioHost.h new file mode 100644 index 0000000000..a39562f23a --- /dev/null +++ b/ios-native/Sources/R360SDLAudioHost.h @@ -0,0 +1,10 @@ +#import +NS_ASSUME_NONNULL_BEGIN +@interface R360SDLAudioHost : NSObject +@property(nonatomic, readonly, getter=isOpen) BOOL open; +- (BOOL)start:(NSString * _Nullable * _Nullable)error; +- (void)pause; +- (void)resume; +- (void)shutdown; +@end +NS_ASSUME_NONNULL_END diff --git a/ios-native/Sources/R360SDLAudioHost.mm b/ios-native/Sources/R360SDLAudioHost.mm new file mode 100644 index 0000000000..d8f868ccf3 --- /dev/null +++ b/ios-native/Sources/R360SDLAudioHost.mm @@ -0,0 +1,40 @@ +#import "R360SDLAudioHost.h" +#import "R360Diagnostics.h" +#include + +@interface R360SDLAudioHost () +@property(nonatomic, assign) SDL_AudioDeviceID deviceID; +@property(nonatomic, assign) SDL_AudioSpec obtained; +@end + +static void R360SilenceCallback(void *userdata, Uint8 *stream, int length) { + (void)userdata; + SDL_memset(stream, 0, (size_t)length); +} + +@implementation R360SDLAudioHost +- (BOOL)isOpen { return self.deviceID != 0; } +- (BOOL)start:(NSString **)error { + if (self.deviceID) return YES; + SDL_AudioSpec desired; + SDL_zero(desired); + desired.freq = 48000; + desired.format = AUDIO_F32SYS; + desired.channels = 2; + desired.samples = 512; + desired.callback = R360SilenceCallback; + self.deviceID = SDL_OpenAudioDevice(NULL, 0, &desired, &_obtained, + SDL_AUDIO_ALLOW_FREQUENCY_CHANGE | SDL_AUDIO_ALLOW_SAMPLES_CHANGE); + if (!self.deviceID) { + if (error) *error = [NSString stringWithFormat:@"SDL_OpenAudioDevice failed: %s", SDL_GetError()]; + return NO; + } + [R360Diagnostics.sharedDiagnostics setAudioState:[NSString stringWithFormat:@"open %d Hz | %u ch | %u samples | synthetic silence", + self.obtained.freq, self.obtained.channels, self.obtained.samples]]; + SDL_PauseAudioDevice(self.deviceID, 0); + return YES; +} +- (void)pause { if (self.deviceID) SDL_PauseAudioDevice(self.deviceID, 1); } +- (void)resume { if (self.deviceID) SDL_PauseAudioDevice(self.deviceID, 0); } +- (void)shutdown { if (self.deviceID) { SDL_CloseAudioDevice(self.deviceID); self.deviceID = 0; } } +@end diff --git a/ios-native/Sources/R360SDLHost.h b/ios-native/Sources/R360SDLHost.h new file mode 100644 index 0000000000..d81b856fb6 --- /dev/null +++ b/ios-native/Sources/R360SDLHost.h @@ -0,0 +1,8 @@ +#import +NS_ASSUME_NONNULL_BEGIN +@interface R360SDLHost : NSObject +@property(nonatomic, readonly, getter=isRunning) BOOL running; ++ (instancetype)sharedHost; +- (BOOL)start:(NSString * _Nullable * _Nullable)error; +@end +NS_ASSUME_NONNULL_END diff --git a/ios-native/Sources/R360SDLHost.mm b/ios-native/Sources/R360SDLHost.mm new file mode 100644 index 0000000000..853a351e97 --- /dev/null +++ b/ios-native/Sources/R360SDLHost.mm @@ -0,0 +1,104 @@ +#define SDL_MAIN_HANDLED 1 +#import "R360SDLHost.h" +#import "R360Diagnostics.h" +#import "R360GLESRendererBackend.h" +#import "R360SDLAudioHost.h" +#import "R360SDLInputDiagnostics.h" +#import "R360LifecycleService.h" +#include +#include +#include + +@interface R360SDLHost () +@property(nonatomic, assign) SDL_Window *window; +@property(nonatomic, strong) R360GLESRendererBackend *renderer; +@property(nonatomic, strong) R360SDLAudioHost *audio; +@property(nonatomic, strong) R360SDLInputDiagnostics *input; +@property(nonatomic, assign, readwrite, getter=isRunning) BOOL running; +@property(nonatomic, assign) BOOL renderingEnabled; +@property(nonatomic, assign) BOOL firstFramePresented; +@end + +static void SDLCALL R360FrameCallback(void *context) { + R360SDLHost *host = (__bridge R360SDLHost *)context; + [host performFrame]; +} + +@implementation R360SDLHost ++ (instancetype)sharedHost { static R360SDLHost *h; static dispatch_once_t once; dispatch_once(&once, ^{ h = [R360SDLHost new]; }); return h; } + +- (BOOL)start:(NSString **)error { + if (self.running) return YES; + [R360Diagnostics.sharedDiagnostics setLatestError:nil]; + [R360Diagnostics.sharedDiagnostics setCheckpoint:@"sdl-host-enter"]; + SDL_SetMainReady(); + SDL_SetHint(SDL_HINT_ORIENTATIONS, "LandscapeLeft LandscapeRight"); + if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO | SDL_INIT_GAMECONTROLLER | SDL_INIT_EVENTS) != 0) { + if (error) *error = [NSString stringWithFormat:@"SDL_Init failed: %s", SDL_GetError()]; + return NO; + } + [R360Diagnostics.sharedDiagnostics setCheckpoint:@"sdl-video-init"]; + SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_ES); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 0); + SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1); + SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 24); + SDL_GL_SetAttribute(SDL_GL_STENCIL_SIZE, 8); + SDL_DisplayMode mode; + if (SDL_GetCurrentDisplayMode(0, &mode) != 0) { mode.w = 896; mode.h = 414; } + int width = mode.w > mode.h ? mode.w : mode.h; + int height = mode.w > mode.h ? mode.h : mode.w; + self.window = SDL_CreateWindow("Render360 Portal N1", SDL_WINDOWPOS_UNDEFINED, SDL_WINDOWPOS_UNDEFINED, + width, height, SDL_WINDOW_OPENGL | SDL_WINDOW_ALLOW_HIGHDPI | SDL_WINDOW_FULLSCREEN); + if (!self.window) { + if (error) *error = [NSString stringWithFormat:@"SDL_CreateWindow failed: %s", SDL_GetError()]; + SDL_Quit(); return NO; + } + [R360Diagnostics.sharedDiagnostics setCheckpoint:@"sdl-window-created"]; + self.renderer = [R360GLESRendererBackend new]; + NSString *localError = nil; + if (![self.renderer startWithWindow:self.window error:&localError]) { + if (error) *error = localError; return NO; + } + self.input = [R360SDLInputDiagnostics new]; + [self.input openConnectedControllers]; + self.audio = [R360SDLAudioHost new]; + if (![self.audio start:&localError]) { + if (error) *error = localError; return NO; + } + R360LifecycleService.sharedService.delegate = self; + [R360LifecycleService.sharedService startObserving]; + self.renderingEnabled = YES; + self.running = YES; + if (SDL_iPhoneSetAnimationCallback(self.window, 1, R360FrameCallback, (__bridge void *)self) != 0) { + if (error) *error = [NSString stringWithFormat:@"SDL_iPhoneSetAnimationCallback failed: %s", SDL_GetError()]; + return NO; + } + return YES; +} + +- (void)performFrame { + if (!self.running) return; + SDL_Event event; + while (SDL_PollEvent(&event)) { + [self.input handleEvent:&event window:self.window]; + if (event.type == SDL_WINDOWEVENT && (event.window.event == SDL_WINDOWEVENT_SIZE_CHANGED || event.window.event == SDL_WINDOWEVENT_RESIZED)) { + [self.renderer refreshMetrics]; + } + } + if (!self.renderingEnabled) return; + [self.renderer renderFrameAtSeconds:(double)SDL_GetTicks64() / 1000.0]; + if (!self.firstFramePresented) { + self.firstFramePresented = YES; + [R360Diagnostics.sharedDiagnostics setCheckpoint:@"first-frame-presented"]; + } +} + +- (void)r360WillResignActive { self.renderingEnabled = NO; [self.audio pause]; } +- (void)r360DidBecomeActive { [self.renderer resume]; [self.audio resume]; self.renderingEnabled = YES; } +- (void)r360DidEnterBackground { self.renderingEnabled = NO; [self.audio pause]; } +- (void)r360WillEnterForeground { [self.renderer resume]; } +- (void)r360AudioInterruptionBegan { [self.audio pause]; } +- (void)r360AudioInterruptionEndedShouldResume:(BOOL)shouldResume { if (shouldResume) [self.audio resume]; } +- (void)r360OrientationDidChange { [self.renderer refreshMetrics]; } +@end diff --git a/ios-native/Sources/R360SDLInputDiagnostics.h b/ios-native/Sources/R360SDLInputDiagnostics.h new file mode 100644 index 0000000000..70a09fd37f --- /dev/null +++ b/ios-native/Sources/R360SDLInputDiagnostics.h @@ -0,0 +1,9 @@ +#import +#include +NS_ASSUME_NONNULL_BEGIN +@interface R360SDLInputDiagnostics : NSObject +- (void)openConnectedControllers; +- (void)handleEvent:(const SDL_Event *)event window:(SDL_Window *)window; +- (void)shutdown; +@end +NS_ASSUME_NONNULL_END diff --git a/ios-native/Sources/R360SDLInputDiagnostics.mm b/ios-native/Sources/R360SDLInputDiagnostics.mm new file mode 100644 index 0000000000..e5a8fb753f --- /dev/null +++ b/ios-native/Sources/R360SDLInputDiagnostics.mm @@ -0,0 +1,75 @@ +#import "R360SDLInputDiagnostics.h" +#import "R360Diagnostics.h" +#include + +@interface R360SDLInputDiagnostics () +@property(nonatomic, assign) NSInteger activeTouches; +@end + +@implementation R360SDLInputDiagnostics { + std::unordered_map _controllers; +} + +- (void)openControllerAtIndex:(int)index { + if (!SDL_IsGameController(index)) return; + SDL_GameController *controller = SDL_GameControllerOpen(index); + if (!controller) return; + SDL_Joystick *joystick = SDL_GameControllerGetJoystick(controller); + SDL_JoystickID identifier = SDL_JoystickInstanceID(joystick); + _controllers[identifier] = controller; + const char *name = SDL_GameControllerName(controller); + [R360Diagnostics.sharedDiagnostics setInputState:[NSString stringWithFormat:@"controller connected: %s", name ?: "unknown"]]; +} + +- (void)openConnectedControllers { + SDL_GameControllerEventState(SDL_ENABLE); + for (int i = 0; i < SDL_NumJoysticks(); ++i) [self openControllerAtIndex:i]; + if (_controllers.empty()) [R360Diagnostics.sharedDiagnostics setInputState:@"touch ready | controller: none"]; +} + +- (void)handleEvent:(const SDL_Event *)event window:(SDL_Window *)window { + switch (event->type) { + case SDL_FINGERDOWN: + self.activeTouches += 1; + // fall through + case SDL_FINGERMOTION: { + int w = 0, h = 0; SDL_GetWindowSize(window, &w, &h); + [R360Diagnostics.sharedDiagnostics setInputState:[NSString stringWithFormat:@"touch %@ | normalized %.3f,%.3f | points %.0f,%.0f | active %ld", + event->type == SDL_FINGERDOWN ? @"down" : @"move", event->tfinger.x, event->tfinger.y, + event->tfinger.x * w, event->tfinger.y * h, (long)self.activeTouches]]; + break; + } + case SDL_FINGERUP: { + self.activeTouches = MAX(0, self.activeTouches - 1); + [R360Diagnostics.sharedDiagnostics setInputState:[NSString stringWithFormat:@"touch up | normalized %.3f,%.3f | active %ld", event->tfinger.x, event->tfinger.y, (long)self.activeTouches]]; + break; + } + case SDL_CONTROLLERDEVICEADDED: + [self openControllerAtIndex:event->cdevice.which]; + break; + case SDL_CONTROLLERDEVICEREMOVED: { + auto it = _controllers.find(event->cdevice.which); + if (it != _controllers.end()) { SDL_GameControllerClose(it->second); _controllers.erase(it); } + [R360Diagnostics.sharedDiagnostics setInputState:@"controller disconnected"]; + break; + } + case SDL_CONTROLLERBUTTONDOWN: + case SDL_CONTROLLERBUTTONUP: { + const char *button = SDL_GameControllerGetStringForButton((SDL_GameControllerButton)event->cbutton.button); + [R360Diagnostics.sharedDiagnostics setInputState:[NSString stringWithFormat:@"controller button %s %@", button ?: "unknown", event->type == SDL_CONTROLLERBUTTONDOWN ? @"down" : @"up"]]; + break; + } + case SDL_CONTROLLERAXISMOTION: { + const char *axis = SDL_GameControllerGetStringForAxis((SDL_GameControllerAxis)event->caxis.axis); + [R360Diagnostics.sharedDiagnostics setInputState:[NSString stringWithFormat:@"controller axis %s = %d", axis ?: "unknown", event->caxis.value]]; + break; + } + default: break; + } +} + +- (void)shutdown { + for (auto &entry : _controllers) SDL_GameControllerClose(entry.second); + _controllers.clear(); +} +@end diff --git a/ios-native/Sources/main.mm b/ios-native/Sources/main.mm index 2547b6dd5c..51c6db1d5b 100644 --- a/ios-native/Sources/main.mm +++ b/ios-native/Sources/main.mm @@ -1,30 +1,20 @@ #import - #import "R360BootstrapViewController.h" #import "R360Diagnostics.h" +#import "R360LifecycleService.h" @interface R360AppDelegate : UIResponder @property(nonatomic, strong) UIWindow *window; @end - @implementation R360AppDelegate - - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { - self.window = [[UIWindow alloc] initWithFrame:UIScreen.mainScreen.bounds]; - self.window.rootViewController = [[R360BootstrapViewController alloc] init]; + (void)application; (void)launchOptions; + [R360LifecycleService.sharedService startObserving]; + self.window=[[UIWindow alloc] initWithFrame:UIScreen.mainScreen.bounds]; + self.window.rootViewController=[[R360BootstrapViewController alloc] init]; [self.window makeKeyAndVisible]; return YES; } - -- (void)applicationWillTerminate:(UIApplication *)application { - [R360Diagnostics.sharedDiagnostics setCheckpoint:@"application-will-terminate"]; -} - +- (void)applicationWillTerminate:(UIApplication *)application { (void)application; [R360Diagnostics.sharedDiagnostics setCheckpoint:@"application-will-terminate"]; } @end - -int main(int argc, char *argv[]) { - @autoreleasepool { - [R360Diagnostics.sharedDiagnostics setCheckpoint:@"bootstrap-enter"]; - return UIApplicationMain(argc, argv, nil, NSStringFromClass(R360AppDelegate.class)); - } -} +int main(int argc,char *argv[]){ @autoreleasepool { [R360Diagnostics.sharedDiagnostics setCheckpoint:@"bootstrap-enter"]; return UIApplicationMain(argc,argv,nil,NSStringFromClass(R360AppDelegate.class)); } } diff --git a/ios-native/cmake/SDL2Pinned.cmake b/ios-native/cmake/SDL2Pinned.cmake new file mode 100644 index 0000000000..065ee25675 --- /dev/null +++ b/ios-native/cmake/SDL2Pinned.cmake @@ -0,0 +1,29 @@ +include(FetchContent) + +set(RENDER360_SDL2_VERSION "2.32.10") +set(RENDER360_SDL2_TAG "release-2.32.10") +set(RENDER360_SDL2_COMMIT "5d249570393f7a37e037abf22cd6012a4cc56a71") +set(RENDER360_SDL2_ARCHIVE_SHA256 "5f5993c530f084535c65a6879e9b26ad441169b3e25d789d83287040a9ca5165") +set(RENDER360_SDL2_URL "https://github.com/libsdl-org/SDL/releases/download/${RENDER360_SDL2_TAG}/SDL2-${RENDER360_SDL2_VERSION}.tar.gz") + +set(SDL_SHARED OFF CACHE BOOL "" FORCE) +set(SDL_STATIC ON CACHE BOOL "" FORCE) +set(SDL_TESTS OFF CACHE BOOL "" FORCE) +set(SDL_TEST_LIBRARY OFF CACHE BOOL "" FORCE) +set(SDL_INSTALL OFF CACHE BOOL "" FORCE) +set(SDL_HIDAPI OFF CACHE BOOL "" FORCE) + +FetchContent_Declare(render360_sdl2 + URL "${RENDER360_SDL2_URL}" + URL_HASH "SHA256=${RENDER360_SDL2_ARCHIVE_SHA256}" + DOWNLOAD_EXTRACT_TIMESTAMP TRUE +) +FetchContent_MakeAvailable(render360_sdl2) + +if(NOT TARGET SDL2::SDL2-static) + message(FATAL_ERROR "Pinned SDL2 source did not provide SDL2::SDL2-static") +endif() + +file(WRITE "${CMAKE_BINARY_DIR}/render360-sdl2-version.txt" + "version=${RENDER360_SDL2_VERSION}\ntag=${RENDER360_SDL2_TAG}\ncommit=${RENDER360_SDL2_COMMIT}\nsha256=${RENDER360_SDL2_ARCHIVE_SHA256}\n") +message(STATUS "Render360 SDL2: ${RENDER360_SDL2_VERSION} ${RENDER360_SDL2_COMMIT}") From c0dcdd8da5fdf8c0b8ef0ca5453b54517d008c93 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Tue, 15 Sep 2026 21:05:53 -0400 Subject: [PATCH 148/159] ios: fix N1 CMake control-flow syntax --- ios-native/CMakeLists.txt | 105 ++++++++++++++++++++++++++++++-------- 1 file changed, 83 insertions(+), 22 deletions(-) diff --git a/ios-native/CMakeLists.txt b/ios-native/CMakeLists.txt index 887403b758..86fe050619 100644 --- a/ios-native/CMakeLists.txt +++ b/ios-native/CMakeLists.txt @@ -1,37 +1,98 @@ cmake_minimum_required(VERSION 3.25) project(Render360PortalIOS VERSION 0.2.0 LANGUAGES C CXX OBJC OBJCXX) -if(NOT IOS) message(FATAL_ERROR "Render360PortalIOS must be configured with -DCMAKE_SYSTEM_NAME=iOS") endif() + +if(NOT IOS) + message(FATAL_ERROR "Render360PortalIOS must be configured with -DCMAKE_SYSTEM_NAME=iOS") +endif() + string(TOLOWER "${CMAKE_OSX_SYSROOT}" RENDER360_SYSROOT_LOWER) -if(RENDER360_SYSROOT_LOWER MATCHES "iphonesimulator") message(FATAL_ERROR "Native target requires physical iPhoneOS, not the simulator") endif() -if(NOT CMAKE_OSX_ARCHITECTURES) set(CMAKE_OSX_ARCHITECTURES "arm64" CACHE STRING "Native iPhone architecture" FORCE) -elseif(NOT CMAKE_OSX_ARCHITECTURES STREQUAL "arm64") message(FATAL_ERROR "Render360 native iOS requires exactly arm64; got '${CMAKE_OSX_ARCHITECTURES}'") endif() +if(RENDER360_SYSROOT_LOWER MATCHES "iphonesimulator") + message(FATAL_ERROR "Native target requires physical iPhoneOS, not the simulator") +endif() + +if(NOT CMAKE_OSX_ARCHITECTURES) + set(CMAKE_OSX_ARCHITECTURES "arm64" CACHE STRING "Native iPhone architecture" FORCE) +elseif(NOT CMAKE_OSX_ARCHITECTURES STREQUAL "arm64") + message(FATAL_ERROR "Render360 native iOS requires exactly arm64; got '${CMAKE_OSX_ARCHITECTURES}'") +endif() + set(CMAKE_C_STANDARD 11) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_POSITION_INDEPENDENT_CODE ON) + set(RENDER360_BUNDLE_ID "com.render360.portal" CACHE STRING "iOS bundle identifier") set(RENDER360_DEPLOYMENT_TARGET "15.0" CACHE STRING "Minimum iOS deployment target") set(RENDER360_BUILD_IDENTIFIER "local" CACHE STRING "Build/commit identifier") set(RENDER360_BUILD_NUMBER "1" CACHE STRING "CFBundleVersion") -if(CMAKE_OSX_DEPLOYMENT_TARGET AND NOT CMAKE_OSX_DEPLOYMENT_TARGET STREQUAL RENDER360_DEPLOYMENT_TARGET) message(FATAL_ERROR "Deployment target mismatch") endif() + +if(CMAKE_OSX_DEPLOYMENT_TARGET AND NOT CMAKE_OSX_DEPLOYMENT_TARGET STREQUAL RENDER360_DEPLOYMENT_TARGET) + message(FATAL_ERROR + "CMAKE_OSX_DEPLOYMENT_TARGET (${CMAKE_OSX_DEPLOYMENT_TARGET}) must match RENDER360_DEPLOYMENT_TARGET (${RENDER360_DEPLOYMENT_TARGET})") +endif() + include(cmake/SDL2Pinned.cmake) + add_executable(Render360Portal MACOSX_BUNDLE - Sources/main.mm Sources/R360BootstrapViewController.h Sources/R360BootstrapViewController.mm - Sources/R360Diagnostics.h Sources/R360Diagnostics.mm Sources/R360PortalValidator.h Sources/R360PortalValidator.mm - Sources/R360RendererBackend.h Sources/R360GLESRendererBackend.h Sources/R360GLESRendererBackend.mm - Sources/R360SDLAudioHost.h Sources/R360SDLAudioHost.mm Sources/R360SDLInputDiagnostics.h Sources/R360SDLInputDiagnostics.mm - Sources/R360LifecycleService.h Sources/R360LifecycleService.mm Sources/R360SDLHost.h Sources/R360SDLHost.mm) -target_compile_definitions(Render360Portal PRIVATE RENDER360_IOS_NATIVE=1 RENDER360_PORTAL_NATIVE_BOOTSTRAP=1 RENDER360_N1_SDL_HOST=1 SDL_MAIN_HANDLED=1 RENDER360_BUILD_IDENTIFIER="${RENDER360_BUILD_IDENTIFIER}") -target_link_libraries(Render360Portal PRIVATE SDL2::SDL2-static "-framework UIKit" "-framework Foundation" "-framework UniformTypeIdentifiers" "-framework QuartzCore" "-framework OpenGLES" "-framework GameController" "-framework AVFoundation") + Sources/main.mm + Sources/R360BootstrapViewController.h + Sources/R360BootstrapViewController.mm + Sources/R360Diagnostics.h + Sources/R360Diagnostics.mm + Sources/R360PortalValidator.h + Sources/R360PortalValidator.mm + Sources/R360RendererBackend.h + Sources/R360GLESRendererBackend.h + Sources/R360GLESRendererBackend.mm + Sources/R360SDLAudioHost.h + Sources/R360SDLAudioHost.mm + Sources/R360SDLInputDiagnostics.h + Sources/R360SDLInputDiagnostics.mm + Sources/R360LifecycleService.h + Sources/R360LifecycleService.mm + Sources/R360SDLHost.h + Sources/R360SDLHost.mm +) + +target_compile_definitions(Render360Portal PRIVATE + RENDER360_IOS_NATIVE=1 + RENDER360_PORTAL_NATIVE_BOOTSTRAP=1 + RENDER360_N1_SDL_HOST=1 + SDL_MAIN_HANDLED=1 + RENDER360_BUILD_IDENTIFIER="${RENDER360_BUILD_IDENTIFIER}" +) + +target_link_libraries(Render360Portal PRIVATE + SDL2::SDL2-static + "-framework UIKit" + "-framework Foundation" + "-framework UniformTypeIdentifiers" + "-framework QuartzCore" + "-framework OpenGLES" + "-framework GameController" + "-framework AVFoundation" +) + set_target_properties(Render360Portal PROPERTIES - MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/Info.plist" - XCODE_ATTRIBUTE_PRODUCT_BUNDLE_IDENTIFIER "${RENDER360_BUNDLE_ID}" - XCODE_ATTRIBUTE_IPHONEOS_DEPLOYMENT_TARGET "${RENDER360_DEPLOYMENT_TARGET}" - XCODE_ATTRIBUTE_MARKETING_VERSION "${PROJECT_VERSION}" - XCODE_ATTRIBUTE_CURRENT_PROJECT_VERSION "${RENDER360_BUILD_NUMBER}" - XCODE_ATTRIBUTE_TARGETED_DEVICE_FAMILY "1" XCODE_ATTRIBUTE_ONLY_ACTIVE_ARCH "NO" XCODE_ATTRIBUTE_ARCHS "arm64" - XCODE_ATTRIBUTE_ENABLE_BITCODE "NO" XCODE_ATTRIBUTE_CLANG_ENABLE_OBJC_ARC "YES" XCODE_ATTRIBUTE_SUPPORTED_PLATFORMS "iphoneos" - XCODE_ATTRIBUTE_SUPPORTS_MACCATALYST "NO" XCODE_ATTRIBUTE_SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD "NO" XCODE_ATTRIBUTE_SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD "NO" - XCODE_ATTRIBUTE_GENERATE_INFOPLIST_FILE "NO" XCODE_ATTRIBUTE_INFOPLIST_FILE "${CMAKE_CURRENT_SOURCE_DIR}/Info.plist") -message(STATUS "Render360 iOS N1 target: sdk=${CMAKE_OSX_SYSROOT} arch=${CMAKE_OSX_ARCHITECTURES} deployment=${RENDER360_DEPLOYMENT_TARGET} bundle=${RENDER360_BUNDLE_ID}") + MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/Info.plist" + XCODE_ATTRIBUTE_PRODUCT_BUNDLE_IDENTIFIER "${RENDER360_BUNDLE_ID}" + XCODE_ATTRIBUTE_IPHONEOS_DEPLOYMENT_TARGET "${RENDER360_DEPLOYMENT_TARGET}" + XCODE_ATTRIBUTE_MARKETING_VERSION "${PROJECT_VERSION}" + XCODE_ATTRIBUTE_CURRENT_PROJECT_VERSION "${RENDER360_BUILD_NUMBER}" + XCODE_ATTRIBUTE_TARGETED_DEVICE_FAMILY "1" + XCODE_ATTRIBUTE_ONLY_ACTIVE_ARCH "NO" + XCODE_ATTRIBUTE_ARCHS "arm64" + XCODE_ATTRIBUTE_ENABLE_BITCODE "NO" + XCODE_ATTRIBUTE_CLANG_ENABLE_OBJC_ARC "YES" + XCODE_ATTRIBUTE_SUPPORTED_PLATFORMS "iphoneos" + XCODE_ATTRIBUTE_SUPPORTS_MACCATALYST "NO" + XCODE_ATTRIBUTE_SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD "NO" + XCODE_ATTRIBUTE_SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD "NO" + XCODE_ATTRIBUTE_GENERATE_INFOPLIST_FILE "NO" + XCODE_ATTRIBUTE_INFOPLIST_FILE "${CMAKE_CURRENT_SOURCE_DIR}/Info.plist" +) + +message(STATUS + "Render360 iOS N1 target: sdk=${CMAKE_OSX_SYSROOT} arch=${CMAKE_OSX_ARCHITECTURES} deployment=${RENDER360_DEPLOYMENT_TARGET} bundle=${RENDER360_BUNDLE_ID}") + # N1 stops at the SDL/GLES/input/audio host. Source libraries begin in N2. From 621d83d39f4edd84e85f84ce17d6ad84659ce25b Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Tue, 15 Sep 2026 21:21:35 -0400 Subject: [PATCH 149/159] ios: declare SDL frame callback target --- ios-native/Sources/R360SDLHost.mm | 1 + 1 file changed, 1 insertion(+) diff --git a/ios-native/Sources/R360SDLHost.mm b/ios-native/Sources/R360SDLHost.mm index 853a351e97..fca70e6efc 100644 --- a/ios-native/Sources/R360SDLHost.mm +++ b/ios-native/Sources/R360SDLHost.mm @@ -17,6 +17,7 @@ @interface R360SDLHost () @property(nonatomic, assign, readwrite, getter=isRunning) BOOL running; @property(nonatomic, assign) BOOL renderingEnabled; @property(nonatomic, assign) BOOL firstFramePresented; +- (void)performFrame; @end static void SDLCALL R360FrameCallback(void *context) { From 6384c78fe0058878a2594b5f7f17e278ede99823 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Tue, 15 Sep 2026 21:28:10 -0400 Subject: [PATCH 150/159] ios: harden N1 lifecycle shutdown and input diagnostics --- ios-native/Sources/R360LifecycleService.h | 1 + ios-native/Sources/R360LifecycleService.mm | 7 ++++ ios-native/Sources/R360SDLHost.mm | 32 +++++++++++++++++-- ios-native/Sources/R360SDLInputDiagnostics.mm | 6 ++-- 4 files changed, 41 insertions(+), 5 deletions(-) diff --git a/ios-native/Sources/R360LifecycleService.h b/ios-native/Sources/R360LifecycleService.h index 4d7a1378b4..0f96000c81 100644 --- a/ios-native/Sources/R360LifecycleService.h +++ b/ios-native/Sources/R360LifecycleService.h @@ -8,6 +8,7 @@ NS_ASSUME_NONNULL_BEGIN - (void)r360AudioInterruptionBegan; - (void)r360AudioInterruptionEndedShouldResume:(BOOL)shouldResume; - (void)r360OrientationDidChange; +- (void)r360WillTerminate; @end @interface R360LifecycleService : NSObject @property(nonatomic, weak, nullable) id delegate; diff --git a/ios-native/Sources/R360LifecycleService.mm b/ios-native/Sources/R360LifecycleService.mm index fc6f1bb31d..9369437570 100644 --- a/ios-native/Sources/R360LifecycleService.mm +++ b/ios-native/Sources/R360LifecycleService.mm @@ -17,6 +17,7 @@ - (void)startObserving { [nc addObserver:self selector:@selector(memoryWarning:) name:UIApplicationDidReceiveMemoryWarningNotification object:nil]; [nc addObserver:self selector:@selector(orientation:) name:UIDeviceOrientationDidChangeNotification object:nil]; [nc addObserver:self selector:@selector(audioInterruption:) name:AVAudioSessionInterruptionNotification object:AVAudioSession.sharedInstance]; + [nc addObserver:self selector:@selector(willTerminate:) name:UIApplicationWillTerminateNotification object:nil]; [UIDevice.currentDevice beginGeneratingDeviceOrientationNotifications]; [R360Diagnostics.sharedDiagnostics setLifecycleState:@"observing"]; } @@ -38,4 +39,10 @@ - (void)audioInterruption:(NSNotification *)n { [self.delegate r360AudioInterruptionEndedShouldResume:resume]; } } +- (void)willTerminate:(NSNotification *)n { + (void)n; + [R360Diagnostics.sharedDiagnostics setLifecycleState:@"terminating"]; + [R360Diagnostics.sharedDiagnostics setCheckpoint:@"application-will-terminate"]; + [self.delegate r360WillTerminate]; +} @end diff --git a/ios-native/Sources/R360SDLHost.mm b/ios-native/Sources/R360SDLHost.mm index fca70e6efc..f641dfe6df 100644 --- a/ios-native/Sources/R360SDLHost.mm +++ b/ios-native/Sources/R360SDLHost.mm @@ -18,6 +18,7 @@ @interface R360SDLHost () @property(nonatomic, assign) BOOL renderingEnabled; @property(nonatomic, assign) BOOL firstFramePresented; - (void)performFrame; +- (void)cleanupHost; @end static void SDLCALL R360FrameCallback(void *context) { @@ -53,26 +54,33 @@ - (BOOL)start:(NSString **)error { width, height, SDL_WINDOW_OPENGL | SDL_WINDOW_ALLOW_HIGHDPI | SDL_WINDOW_FULLSCREEN); if (!self.window) { if (error) *error = [NSString stringWithFormat:@"SDL_CreateWindow failed: %s", SDL_GetError()]; - SDL_Quit(); return NO; + [self cleanupHost]; + return NO; } [R360Diagnostics.sharedDiagnostics setCheckpoint:@"sdl-window-created"]; self.renderer = [R360GLESRendererBackend new]; NSString *localError = nil; if (![self.renderer startWithWindow:self.window error:&localError]) { - if (error) *error = localError; return NO; + if (error) *error = localError; + [self cleanupHost]; + return NO; } self.input = [R360SDLInputDiagnostics new]; [self.input openConnectedControllers]; self.audio = [R360SDLAudioHost new]; if (![self.audio start:&localError]) { - if (error) *error = localError; return NO; + if (error) *error = localError; + [self cleanupHost]; + return NO; } R360LifecycleService.sharedService.delegate = self; [R360LifecycleService.sharedService startObserving]; self.renderingEnabled = YES; self.running = YES; + self.firstFramePresented = NO; if (SDL_iPhoneSetAnimationCallback(self.window, 1, R360FrameCallback, (__bridge void *)self) != 0) { if (error) *error = [NSString stringWithFormat:@"SDL_iPhoneSetAnimationCallback failed: %s", SDL_GetError()]; + [self cleanupHost]; return NO; } return YES; @@ -95,6 +103,23 @@ - (void)performFrame { } } +- (void)cleanupHost { + self.renderingEnabled = NO; + self.running = NO; + [self.audio shutdown]; + [self.input shutdown]; + [self.renderer shutdown]; + self.audio = nil; + self.input = nil; + self.renderer = nil; + if (self.window) { + SDL_DestroyWindow(self.window); + self.window = NULL; + } + if (SDL_WasInit(0) != 0) SDL_Quit(); + self.firstFramePresented = NO; +} + - (void)r360WillResignActive { self.renderingEnabled = NO; [self.audio pause]; } - (void)r360DidBecomeActive { [self.renderer resume]; [self.audio resume]; self.renderingEnabled = YES; } - (void)r360DidEnterBackground { self.renderingEnabled = NO; [self.audio pause]; } @@ -102,4 +127,5 @@ - (void)r360WillEnterForeground { [self.renderer resume]; } - (void)r360AudioInterruptionBegan { [self.audio pause]; } - (void)r360AudioInterruptionEndedShouldResume:(BOOL)shouldResume { if (shouldResume) [self.audio resume]; } - (void)r360OrientationDidChange { [self.renderer refreshMetrics]; } +- (void)r360WillTerminate { [self cleanupHost]; } @end diff --git a/ios-native/Sources/R360SDLInputDiagnostics.mm b/ios-native/Sources/R360SDLInputDiagnostics.mm index e5a8fb753f..48def58872 100644 --- a/ios-native/Sources/R360SDLInputDiagnostics.mm +++ b/ios-native/Sources/R360SDLInputDiagnostics.mm @@ -18,7 +18,8 @@ - (void)openControllerAtIndex:(int)index { SDL_JoystickID identifier = SDL_JoystickInstanceID(joystick); _controllers[identifier] = controller; const char *name = SDL_GameControllerName(controller); - [R360Diagnostics.sharedDiagnostics setInputState:[NSString stringWithFormat:@"controller connected: %s", name ?: "unknown"]]; + SDL_GameControllerType type = SDL_GameControllerGetType(controller); + [R360Diagnostics.sharedDiagnostics setInputState:[NSString stringWithFormat:@"controller connected: %s | type %d | instance %d", name ?: "unknown", (int)type, (int)identifier]]; } - (void)openConnectedControllers { @@ -50,7 +51,7 @@ - (void)handleEvent:(const SDL_Event *)event window:(SDL_Window *)window { case SDL_CONTROLLERDEVICEREMOVED: { auto it = _controllers.find(event->cdevice.which); if (it != _controllers.end()) { SDL_GameControllerClose(it->second); _controllers.erase(it); } - [R360Diagnostics.sharedDiagnostics setInputState:@"controller disconnected"]; + [R360Diagnostics.sharedDiagnostics setInputState:[NSString stringWithFormat:@"controller disconnected: instance %d", (int)event->cdevice.which]]; break; } case SDL_CONTROLLERBUTTONDOWN: @@ -71,5 +72,6 @@ - (void)handleEvent:(const SDL_Event *)event window:(SDL_Window *)window { - (void)shutdown { for (auto &entry : _controllers) SDL_GameControllerClose(entry.second); _controllers.clear(); + self.activeTouches = 0; } @end From 358747cdb3c128c707fcfe3b67b622a4947eacee Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Tue, 15 Sep 2026 23:02:35 -0400 Subject: [PATCH 151/159] ios: add focused N2 Source platform boundary --- .../SourceCompat/R360SourceIOSPlatform.h | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 ios-native/SourceCompat/R360SourceIOSPlatform.h diff --git a/ios-native/SourceCompat/R360SourceIOSPlatform.h b/ios-native/SourceCompat/R360SourceIOSPlatform.h new file mode 100644 index 0000000000..5830259499 --- /dev/null +++ b/ios-native/SourceCompat/R360SourceIOSPlatform.h @@ -0,0 +1,32 @@ +#pragma once + +#include + +#if !TARGET_OS_IOS +#error "Render360 Source foundation targets require iOS" +#endif + +#if TARGET_OS_SIMULATOR +#error "Render360 Source foundation targets require physical iPhoneOS" +#endif + +#if !defined(__aarch64__) +#error "Render360 Source foundation targets require arm64" +#endif + +/* + * This Source branch predates iOS support and uses POSIX/OSX as its Darwin + * feature switches. Keep those compatibility defines centralized here + * instead of scattering them through Valve source files. OSX here means + * "Darwin APIs/header layout" to the legacy Source platform layer; it does + * not mean that the target is macOS. + */ +#ifndef POSIX +#define POSIX 1 +#endif + +#ifndef OSX +#define OSX 1 +#endif + +#define RENDER360_SOURCE_IOS 1 From dc9333b82e67518865b4232cfa78243b17dae985 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Tue, 15 Sep 2026 23:02:56 -0400 Subject: [PATCH 152/159] ios: add explicit N2 Source foundation graph --- ios-native/cmake/SourceFoundation.cmake | 114 ++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 ios-native/cmake/SourceFoundation.cmake diff --git a/ios-native/cmake/SourceFoundation.cmake b/ios-native/cmake/SourceFoundation.cmake new file mode 100644 index 0000000000..d5cb868dda --- /dev/null +++ b/ios-native/cmake/SourceFoundation.cmake @@ -0,0 +1,114 @@ +set(RENDER360_SOURCE_ROOT "${CMAKE_CURRENT_LIST_DIR}/../..") +get_filename_component(RENDER360_SOURCE_ROOT "${RENDER360_SOURCE_ROOT}" ABSOLUTE) +set(RENDER360_SOURCE_COMPAT "${CMAKE_CURRENT_LIST_DIR}/../SourceCompat/R360SourceIOSPlatform.h") + +add_library(r360_source_ios_platform INTERFACE) +target_compile_options(r360_source_ios_platform INTERFACE + "$<$:-include${RENDER360_SOURCE_COMPAT}>" + "$<$:-include${RENDER360_SOURCE_COMPAT}>" +) +target_compile_definitions(r360_source_ios_platform INTERFACE + POSIX=1 + OSX=1 + RENDER360_SOURCE_IOS=1 +) +target_include_directories(r360_source_ios_platform INTERFACE + "${RENDER360_SOURCE_ROOT}" + "${RENDER360_SOURCE_ROOT}/public" + "${RENDER360_SOURCE_ROOT}/public/tier0" + "${RENDER360_SOURCE_ROOT}/public/tier1" + "${RENDER360_SOURCE_ROOT}/public/mathlib" + "${RENDER360_SOURCE_ROOT}/common" +) + +# N2 starts with the portable/native core of the repository's own tier0 +# source inventory. Desktop-only profilers, crash/minidump handlers and +# allocator replacement layers remain out of this first iOS foundation slice. +set(R360_TIER0_SOURCES + "${RENDER360_SOURCE_ROOT}/tier0/commandline.cpp" + "${RENDER360_SOURCE_ROOT}/tier0/cpu_posix.cpp" + "${RENDER360_SOURCE_ROOT}/tier0/platform_posix.cpp" + "${RENDER360_SOURCE_ROOT}/tier0/tier0_strtools.cpp" + "${RENDER360_SOURCE_ROOT}/tier0/tslist.cpp" +) +add_library(r360_tier0 STATIC ${R360_TIER0_SOURCES}) +target_link_libraries(r360_tier0 PUBLIC r360_source_ios_platform) +target_compile_definitions(r360_tier0 PRIVATE TIER0_STATIC_LIB=1) +set_target_properties(r360_tier0 PROPERTIES + OUTPUT_NAME "tier0_ios" + XCODE_ATTRIBUTE_IPHONEOS_DEPLOYMENT_TARGET "${RENDER360_DEPLOYMENT_TARGET}" + XCODE_ATTRIBUTE_ARCHS "arm64" + XCODE_ATTRIBUTE_SUPPORTED_PLATFORMS "iphoneos" +) + +# Explicit tier1 core list derived from tier1/wscript. This is sufficient to +# establish the true tier1->tier0 dependency without pulling filesystem or the +# engine runtime into N2. +set(R360_TIER1_SOURCES + "${RENDER360_SOURCE_ROOT}/tier1/bitbuf.cpp" + "${RENDER360_SOURCE_ROOT}/tier1/byteswap.cpp" + "${RENDER360_SOURCE_ROOT}/tier1/characterset.cpp" + "${RENDER360_SOURCE_ROOT}/tier1/checksum_crc.cpp" + "${RENDER360_SOURCE_ROOT}/tier1/checksum_md5.cpp" + "${RENDER360_SOURCE_ROOT}/tier1/checksum_sha1.cpp" + "${RENDER360_SOURCE_ROOT}/tier1/commandbuffer.cpp" + "${RENDER360_SOURCE_ROOT}/tier1/generichash.cpp" + "${RENDER360_SOURCE_ROOT}/tier1/interface.cpp" + "${RENDER360_SOURCE_ROOT}/tier1/lzss.cpp" + "${RENDER360_SOURCE_ROOT}/tier1/mempool.cpp" + "${RENDER360_SOURCE_ROOT}/tier1/rangecheckedvar.cpp" + "${RENDER360_SOURCE_ROOT}/tier1/splitstring.cpp" + "${RENDER360_SOURCE_ROOT}/tier1/stringpool.cpp" + "${RENDER360_SOURCE_ROOT}/tier1/strtools.cpp" + "${RENDER360_SOURCE_ROOT}/tier1/strtools_unicode.cpp" + "${RENDER360_SOURCE_ROOT}/tier1/tier1.cpp" + "${RENDER360_SOURCE_ROOT}/tier1/uniqueid.cpp" + "${RENDER360_SOURCE_ROOT}/tier1/utlbinaryblock.cpp" + "${RENDER360_SOURCE_ROOT}/tier1/utlbuffer.cpp" + "${RENDER360_SOURCE_ROOT}/tier1/utlbufferutil.cpp" + "${RENDER360_SOURCE_ROOT}/tier1/utlstring.cpp" + "${RENDER360_SOURCE_ROOT}/tier1/utlsymbol.cpp" + "${RENDER360_SOURCE_ROOT}/tier1/qsort_s.cpp" +) +add_library(r360_tier1 STATIC ${R360_TIER1_SOURCES}) +target_link_libraries(r360_tier1 PUBLIC r360_tier0 r360_source_ios_platform) +target_compile_definitions(r360_tier1 PRIVATE TIER1_STATIC_LIB=1) +set_target_properties(r360_tier1 PROPERTIES + OUTPUT_NAME "tier1_ios" + XCODE_ATTRIBUTE_IPHONEOS_DEPLOYMENT_TARGET "${RENDER360_DEPLOYMENT_TARGET}" + XCODE_ATTRIBUTE_ARCHS "arm64" + XCODE_ATTRIBUTE_SUPPORTED_PLATFORMS "iphoneos" +) + +# Portable/scalar mathlib foundation. The repository's SIMD files include +# public/mathlib/ssemath.h, which selects sse2neon.h on AArch64, but that +# dependency is absent from this repository and its pinned thirdparty tree. +# N2 therefore does not fake an SSE implementation: SIMD translation remains +# an explicit follow-up incompatibility while this real scalar/core mathlib +# slice establishes the ARM64 build graph. +set(R360_MATHLIB_SOURCES + "${RENDER360_SOURCE_ROOT}/mathlib/almostequal.cpp" + "${RENDER360_SOURCE_ROOT}/mathlib/anorms.cpp" + "${RENDER360_SOURCE_ROOT}/mathlib/bumpvects.cpp" + "${RENDER360_SOURCE_ROOT}/mathlib/color_conversion.cpp" + "${RENDER360_SOURCE_ROOT}/mathlib/halton.cpp" + "${RENDER360_SOURCE_ROOT}/mathlib/IceKey.cpp" + "${RENDER360_SOURCE_ROOT}/mathlib/imagequant.cpp" + "${RENDER360_SOURCE_ROOT}/mathlib/polyhedron.cpp" + "${RENDER360_SOURCE_ROOT}/mathlib/quantize.cpp" + "${RENDER360_SOURCE_ROOT}/mathlib/spherical.cpp" + "${RENDER360_SOURCE_ROOT}/mathlib/vmatrix.cpp" +) +add_library(r360_mathlib STATIC ${R360_MATHLIB_SOURCES}) +target_link_libraries(r360_mathlib PUBLIC r360_tier1 r360_tier0 r360_source_ios_platform) +target_compile_definitions(r360_mathlib PRIVATE MATHLIB_LIB=1) +set_target_properties(r360_mathlib PROPERTIES + OUTPUT_NAME "mathlib_ios" + XCODE_ATTRIBUTE_IPHONEOS_DEPLOYMENT_TARGET "${RENDER360_DEPLOYMENT_TARGET}" + XCODE_ATTRIBUTE_ARCHS "arm64" + XCODE_ATTRIBUTE_SUPPORTED_PLATFORMS "iphoneos" +) + +add_custom_target(r360_source_foundation ALL + DEPENDS r360_tier0 r360_tier1 r360_mathlib +) From ff954f60155cb59c17f663d559957c7fd33d71d0 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Tue, 15 Sep 2026 23:03:17 -0400 Subject: [PATCH 153/159] ios: wire N2 Source foundation targets --- ios-native/CMakeLists.txt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/ios-native/CMakeLists.txt b/ios-native/CMakeLists.txt index 86fe050619..25dd8922e2 100644 --- a/ios-native/CMakeLists.txt +++ b/ios-native/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.25) -project(Render360PortalIOS VERSION 0.2.0 LANGUAGES C CXX OBJC OBJCXX) +project(Render360PortalIOS VERSION 0.3.0 LANGUAGES C CXX OBJC OBJCXX) if(NOT IOS) message(FATAL_ERROR "Render360PortalIOS must be configured with -DCMAKE_SYSTEM_NAME=iOS") @@ -32,6 +32,7 @@ if(CMAKE_OSX_DEPLOYMENT_TARGET AND NOT CMAKE_OSX_DEPLOYMENT_TARGET STREQUAL REND endif() include(cmake/SDL2Pinned.cmake) +include(cmake/SourceFoundation.cmake) add_executable(Render360Portal MACOSX_BUNDLE Sources/main.mm @@ -54,10 +55,13 @@ add_executable(Render360Portal MACOSX_BUNDLE Sources/R360SDLHost.mm ) +add_dependencies(Render360Portal r360_source_foundation) + target_compile_definitions(Render360Portal PRIVATE RENDER360_IOS_NATIVE=1 RENDER360_PORTAL_NATIVE_BOOTSTRAP=1 RENDER360_N1_SDL_HOST=1 + RENDER360_N2_SOURCE_FOUNDATION=1 SDL_MAIN_HANDLED=1 RENDER360_BUILD_IDENTIFIER="${RENDER360_BUILD_IDENTIFIER}" ) @@ -93,6 +97,4 @@ set_target_properties(Render360Portal PROPERTIES ) message(STATUS - "Render360 iOS N1 target: sdk=${CMAKE_OSX_SYSROOT} arch=${CMAKE_OSX_ARCHITECTURES} deployment=${RENDER360_DEPLOYMENT_TARGET} bundle=${RENDER360_BUNDLE_ID}") - -# N1 stops at the SDL/GLES/input/audio host. Source libraries begin in N2. + "Render360 iOS N2 target: sdk=${CMAKE_OSX_SYSROOT} arch=${CMAKE_OSX_ARCHITECTURES} deployment=${RENDER360_DEPLOYMENT_TARGET} bundle=${RENDER360_BUNDLE_ID}") From 81ed58a9737d5bb46ef3fb747ff895b7013b6d03 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Tue, 15 Sep 2026 23:03:45 -0400 Subject: [PATCH 154/159] ci: prove N2 Source foundation libraries --- .github/workflows/ios-native.yml | 34 ++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ios-native.yml b/.github/workflows/ios-native.yml index 5f9bbca90b..16d017213e 100644 --- a/.github/workflows/ios-native.yml +++ b/.github/workflows/ios-native.yml @@ -22,7 +22,10 @@ jobs: run: | set -euo pipefail if find ios-native -type f \( -iname '*.vpk' -o -iname '*.bsp' -o -iname '*.vtf' -o -iname '*.vmt' -o -iname '*.vcs' -o -iname '*.wav' -o -iname '*.mp3' -o -iname '*.mdl' \) -print -quit | grep -q .; then echo 'Retail game asset detected.' >&2; exit 1; fi - if grep -RInE '__EMSCRIPTEN__|MEMFS|WORKERFS|SharedArrayBuffer|PROXY_TO_PTHREAD|OffscreenCanvas' ios-native/Sources ios-native/CMakeLists.txt ios-native/cmake; then echo 'Browser runtime dependency entered native target.' >&2; exit 1; fi + if grep -RInE '__EMSCRIPTEN__|MEMFS|WORKERFS|SharedArrayBuffer|PROXY_TO_PTHREAD|OffscreenCanvas' ios-native/Sources ios-native/SourceCompat ios-native/CMakeLists.txt ios-native/cmake; then echo 'Browser runtime dependency entered native target.' >&2; exit 1; fi + grep -q 'add_library(r360_tier0 STATIC' ios-native/cmake/SourceFoundation.cmake + grep -q 'add_library(r360_tier1 STATIC' ios-native/cmake/SourceFoundation.cmake + grep -q 'add_library(r360_mathlib STATIC' ios-native/cmake/SourceFoundation.cmake - name: Configure Xcode arm64 iPhone project run: | set -euxo pipefail @@ -42,11 +45,32 @@ jobs: grep -Eq '^[[:space:]]*TARGETED_DEVICE_FAMILY = 1$' build/xcode-build-settings.txt grep -Eq '^[[:space:]]*SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO$' build/xcode-build-settings.txt grep -Eq '^[[:space:]]*SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO$' build/xcode-build-settings.txt - - name: Build unsigned arm64 N1 app + - name: Build N2 Source foundation in dependency order + shell: bash + run: | + set -euxo pipefail + for target in r360_tier0 r360_tier1 r360_mathlib; do + echo "=== building ${target} ===" + xcodebuild -project build/ios/Render360PortalIOS.xcodeproj -target "${target}" -configuration Release -sdk iphoneos -destination 'generic/platform=iOS' CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO ONLY_ACTIVE_ARCH=NO ARCHS=arm64 build + done + - name: Verify N2 static ARM64 products + shell: bash + run: | + set -euxo pipefail + : > build/foundation-libraries.txt + for stem in tier0_ios tier1_ios mathlib_ios; do + lib="$(find build/ios -type f -name "lib${stem}.a" -print -quit)" + test -n "$lib" + archs="$(lipo -archs "$lib")" + test "$archs" = arm64 + echo "${stem}=${lib} arch=${archs}" | tee -a build/foundation-libraries.txt + done + if grep -Eq '(^|[[:space:]])(i386|x86_64)([[:space:]]|$)' build/foundation-libraries.txt; then exit 1; fi + - name: Build unsigned arm64 N2 app run: | set -euxo pipefail xcodebuild -project build/ios/Render360PortalIOS.xcodeproj -scheme Render360Portal -configuration Release -sdk iphoneos -destination 'generic/platform=iOS' -derivedDataPath build/DerivedData CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO ONLY_ACTIVE_ARCH=NO ARCHS=arm64 build - - name: Verify N1 bundle and package unsigned IPA + - name: Verify N2 bundle and package unsigned IPA shell: bash run: | set -euxo pipefail @@ -73,10 +97,11 @@ jobs: PY rm -rf build/ipa; mkdir -p build/ipa/Payload; ditto "$APP_PATH" build/ipa/Payload/Render360Portal.app cat > build/ipa/BUILD_INFO.txt < Date: Tue, 15 Sep 2026 23:24:33 -0400 Subject: [PATCH 155/159] ios: expose legacy GNUC switch for Source foundations --- ios-native/SourceCompat/R360SourceIOSPlatform.h | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/ios-native/SourceCompat/R360SourceIOSPlatform.h b/ios-native/SourceCompat/R360SourceIOSPlatform.h index 5830259499..934baf35e6 100644 --- a/ios-native/SourceCompat/R360SourceIOSPlatform.h +++ b/ios-native/SourceCompat/R360SourceIOSPlatform.h @@ -16,10 +16,15 @@ /* * This Source branch predates iOS support and uses POSIX/OSX as its Darwin - * feature switches. Keep those compatibility defines centralized here - * instead of scattering them through Valve source files. OSX here means + * feature switches. Keep those compatibility defines centralized here + * instead of scattering them through Valve source files. OSX here means * "Darwin APIs/header layout" to the legacy Source platform layer; it does * not mean that the target is macOS. + * + * The legacy platform header also tests the historical GNUC build-system + * switch in addition to the compiler-provided __GNUC__/__clang__ macros. + * Desktop build scripts normally provide GNUC; the native CMake graph must + * provide the same semantic switch explicitly for AppleClang. */ #ifndef POSIX #define POSIX 1 @@ -29,4 +34,8 @@ #define OSX 1 #endif +#ifndef GNUC +#define GNUC 1 +#endif + #define RENDER360_SOURCE_IOS 1 From fb441ac62441d00402d6f8b36f8f38ee73e649aa Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Tue, 15 Sep 2026 23:33:51 -0400 Subject: [PATCH 156/159] ios: select legacy Darwin allocator headers in Source --- ios-native/SourceCompat/R360SourceIOSPlatform.h | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/ios-native/SourceCompat/R360SourceIOSPlatform.h b/ios-native/SourceCompat/R360SourceIOSPlatform.h index 934baf35e6..b5d7932fea 100644 --- a/ios-native/SourceCompat/R360SourceIOSPlatform.h +++ b/ios-native/SourceCompat/R360SourceIOSPlatform.h @@ -17,9 +17,9 @@ /* * This Source branch predates iOS support and uses POSIX/OSX as its Darwin * feature switches. Keep those compatibility defines centralized here - * instead of scattering them through Valve source files. OSX here means - * "Darwin APIs/header layout" to the legacy Source platform layer; it does - * not mean that the target is macOS. + * instead of scattering them through Valve source files. OSX and _OSX here + * mean "Darwin APIs/header layout" to the legacy Source platform layer; they + * do not mean that the target is macOS. * * The legacy platform header also tests the historical GNUC build-system * switch in addition to the compiler-provided __GNUC__/__clang__ macros. @@ -34,6 +34,10 @@ #define OSX 1 #endif +#ifndef _OSX +#define _OSX 1 +#endif + #ifndef GNUC #define GNUC 1 #endif From cb447af690b82b6d4fc22cf944346d82c15e00a7 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Tue, 15 Sep 2026 23:36:10 -0400 Subject: [PATCH 157/159] ios: build full Source mathlib foundation inventory --- ios-native/cmake/SourceFoundation.cmake | 33 +++++++++++++++---------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/ios-native/cmake/SourceFoundation.cmake b/ios-native/cmake/SourceFoundation.cmake index d5cb868dda..95d3d91eb9 100644 --- a/ios-native/cmake/SourceFoundation.cmake +++ b/ios-native/cmake/SourceFoundation.cmake @@ -22,7 +22,7 @@ target_include_directories(r360_source_ios_platform INTERFACE ) # N2 starts with the portable/native core of the repository's own tier0 -# source inventory. Desktop-only profilers, crash/minidump handlers and +# source inventory. Desktop-only profilers, crash/minidump handlers and # allocator replacement layers remain out of this first iOS foundation slice. set(R360_TIER0_SOURCES "${RENDER360_SOURCE_ROOT}/tier0/commandline.cpp" @@ -41,9 +41,9 @@ set_target_properties(r360_tier0 PROPERTIES XCODE_ATTRIBUTE_SUPPORTED_PLATFORMS "iphoneos" ) -# Explicit tier1 core list derived from tier1/wscript. This is sufficient to -# establish the true tier1->tier0 dependency without pulling filesystem or the -# engine runtime into N2. +# Explicit tier1 core list derived from tier1/wscript. This establishes the +# true tier1 -> tier0 dependency without pulling filesystem or engine runtime +# into N2. set(R360_TIER1_SOURCES "${RENDER360_SOURCE_ROOT}/tier1/bitbuf.cpp" "${RENDER360_SOURCE_ROOT}/tier1/byteswap.cpp" @@ -80,24 +80,31 @@ set_target_properties(r360_tier1 PROPERTIES XCODE_ATTRIBUTE_SUPPORTED_PLATFORMS "iphoneos" ) -# Portable/scalar mathlib foundation. The repository's SIMD files include -# public/mathlib/ssemath.h, which selects sse2neon.h on AArch64, but that -# dependency is absent from this repository and its pinned thirdparty tree. -# N2 therefore does not fake an SSE implementation: SIMD translation remains -# an explicit follow-up incompatibility while this real scalar/core mathlib -# slice establishes the ARM64 build graph. +# Exact mathlib source inventory from mathlib/wscript. On AArch64 this Source +# branch's SSE implementation includes common/sse2neon.h, so N2 keeps the +# genuine mathlib implementation and lets compiler diagnostics identify any +# unsupported intrinsic instead of replacing it with fake/scalar stubs. set(R360_MATHLIB_SOURCES - "${RENDER360_SOURCE_ROOT}/mathlib/almostequal.cpp" - "${RENDER360_SOURCE_ROOT}/mathlib/anorms.cpp" - "${RENDER360_SOURCE_ROOT}/mathlib/bumpvects.cpp" "${RENDER360_SOURCE_ROOT}/mathlib/color_conversion.cpp" "${RENDER360_SOURCE_ROOT}/mathlib/halton.cpp" + "${RENDER360_SOURCE_ROOT}/mathlib/lightdesc.cpp" + "${RENDER360_SOURCE_ROOT}/mathlib/mathlib_base.cpp" + "${RENDER360_SOURCE_ROOT}/mathlib/powsse.cpp" + "${RENDER360_SOURCE_ROOT}/mathlib/sparse_convolution_noise.cpp" + "${RENDER360_SOURCE_ROOT}/mathlib/sseconst.cpp" + "${RENDER360_SOURCE_ROOT}/mathlib/sse.cpp" + "${RENDER360_SOURCE_ROOT}/mathlib/ssenoise.cpp" + "${RENDER360_SOURCE_ROOT}/mathlib/anorms.cpp" + "${RENDER360_SOURCE_ROOT}/mathlib/bumpvects.cpp" "${RENDER360_SOURCE_ROOT}/mathlib/IceKey.cpp" "${RENDER360_SOURCE_ROOT}/mathlib/imagequant.cpp" "${RENDER360_SOURCE_ROOT}/mathlib/polyhedron.cpp" "${RENDER360_SOURCE_ROOT}/mathlib/quantize.cpp" + "${RENDER360_SOURCE_ROOT}/mathlib/randsse.cpp" "${RENDER360_SOURCE_ROOT}/mathlib/spherical.cpp" + "${RENDER360_SOURCE_ROOT}/mathlib/simdvectormatrix.cpp" "${RENDER360_SOURCE_ROOT}/mathlib/vmatrix.cpp" + "${RENDER360_SOURCE_ROOT}/mathlib/almostequal.cpp" ) add_library(r360_mathlib STATIC ${R360_MATHLIB_SOURCES}) target_link_libraries(r360_mathlib PUBLIC r360_tier1 r360_tier0 r360_source_ios_platform) From 28d3b48456ddee2bf4fe7d5d61d09bc3f8afe88b Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Tue, 15 Sep 2026 23:42:09 -0400 Subject: [PATCH 158/159] ios: make tier1 MD5 C++17 compatible --- tier1/checksum_md5.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tier1/checksum_md5.cpp b/tier1/checksum_md5.cpp index fea0d35096..ff42fcf0aa 100644 --- a/tier1/checksum_md5.cpp +++ b/tier1/checksum_md5.cpp @@ -36,7 +36,7 @@ //----------------------------------------------------------------------------- static void MD5Transform(unsigned int buf[4], unsigned int const in[16]) { - register unsigned int a, b, c, d; + unsigned int a, b, c, d; a = buf[0]; b = buf[1]; From 9d44f0776e05ff45c60e0a5db9dc8063325e8bac Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Tue, 15 Sep 2026 23:47:39 -0400 Subject: [PATCH 159/159] ci: watch N2 Source foundation inputs --- .github/workflows/ios-native.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ios-native.yml b/.github/workflows/ios-native.yml index 16d017213e..07c1bfaca5 100644 --- a/.github/workflows/ios-native.yml +++ b/.github/workflows/ios-native.yml @@ -2,7 +2,15 @@ name: iOS Native Bootstrap IPA on: push: branches: [render360/ios-native] - paths: ['ios-native/**','.github/workflows/ios-native.yml','docs/IOS_NATIVE_**'] + paths: + - 'ios-native/**' + - 'tier0/**' + - 'tier1/**' + - 'mathlib/**' + - 'common/**' + - 'public/**' + - '.github/workflows/ios-native.yml' + - 'docs/IOS_NATIVE_**' workflow_dispatch: permissions: { contents: read } jobs: