From f19e879513a0a029b274f5631a675e2f411d0317 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 14:25:41 +0000 Subject: [PATCH 1/2] web: recover from missing cross-origin isolation; add graphics quality option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a deploy Safari can load the start page before the updated service worker controls it, so the page has no COOP/COEP headers, no SharedArrayBuffer, and the engine dies with "The object can not be cloned" on a black screen. The start page used to reload only once per tab; it now reloads whenever isolation is missing (at most once every 20 s, so browsers that can't isolate don't loop), and the game page does the same instead of starting without threads. Graphics quality (start page segmented control, and "Graphics" in the in-game ••• menu, applied on next launch): - Standard: 540p, quarter-size textures (previous behaviour, default) - HD: 720p, half-size textures - Max: full screen resolution, full-size textures The chosen quality is included in Copy diagnostics. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qy5DKdwTqGQqScWvJyNcft --- emscripten/pages-index.html | 43 ++++++++++++++++++++++++++++++++----- emscripten/pre.js | 23 ++++++++++++++++++-- emscripten/shell.html | 38 +++++++++++++++++++++++++++++++- 3 files changed, 96 insertions(+), 8 deletions(-) diff --git a/emscripten/pages-index.html b/emscripten/pages-index.html index e02d3a6728..42ac633144 100644 --- a/emscripten/pages-index.html +++ b/emscripten/pages-index.html @@ -56,6 +56,10 @@ /* The old direct launch and the ZIP path stay wired for the script but out of sight. */ #launch,#reload,label[for=ownershipFiles],#clearOwnership{display:none} + .quality{display:grid;grid-template-columns:repeat(3,1fr);gap:4px;margin-top:14px;padding:4px;border-radius:14px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.08)} + .quality button{padding:9px 6px;border-radius:11px;font-size:14px;font-weight:600;color:var(--ink);background:transparent;box-shadow:none} + .quality button[aria-pressed=true]{color:#06121b;background:linear-gradient(180deg,#6fd0ff,var(--blue));box-shadow:0 4px 14px rgba(47,180,255,.3)} + #qualityHint{margin-top:6px;font-size:12.5px;color:var(--dim)} details{width:100%;color:var(--dim);font-size:13px} summary{list-style:none;cursor:pointer;text-align:center;padding:6px;font-size:13px;color:var(--dim)} summary::-webkit-details-marker{display:none} @@ -103,6 +107,12 @@

Choose your Portal folder

Play

Turn your iPhone sideways. The game fills the screen.

+
+ + + +
+

@@ -231,15 +241,38 @@

Diagnostics

await navigator.serviceWorker.ready; if(registration.waiting)registration.waiting.postMessage({type:'SKIP_WAITING'}); set('sw',true,navigator.serviceWorker.controller?'controlling':'registered'); - if(!navigator.serviceWorker.controller&&!sessionStorage.getItem('render360PagesReloaded')){ - sessionStorage.setItem('render360PagesReloaded','1'); - log('Service worker installed; reloading once for isolation.'); - location.reload(); - return new Promise(()=>{}); + // The isolation headers only apply to pages the worker controls. After + // a deploy Safari can load the page before the new worker takes over, + // leaving it without SharedArrayBuffer, so reload whenever isolation is + // missing. The time guard stops a loop on browsers that can't isolate. + if(!navigator.serviceWorker.controller||!window.crossOriginIsolated){ + let last=0;try{last=Number(sessionStorage.getItem('render360PagesReloadAt'))||0}catch(_){} + if(Date.now()-last>20000){ + try{sessionStorage.setItem('render360PagesReloadAt',String(Date.now()))}catch(_){} + log('Service worker not isolating this page yet; reloading.'); + location.reload(); + return new Promise(()=>{}); + } } }catch(error){set('sw',false,'registration failed');log('Service worker error:',error?.stack||String(error))} } + const QUALITY_HINTS={ + standard:'540p, lighter textures. Smoothest, and safest on older iPhones.', + hd:'720p, sharper textures. Uses more memory and battery.', + max:'Full screen resolution and full textures. May run out of memory on older iPhones.' + }; + function setupQuality(){ + let current='standard'; + try{current=localStorage.getItem('render360Quality')||'standard'}catch(_){} + if(!QUALITY_HINTS[current])current='standard'; + const buttons=[...document.querySelectorAll('.quality button')]; + const show=()=>{for(const b of buttons)b.setAttribute('aria-pressed',String(b.dataset.quality===current));$('qualityHint').textContent=QUALITY_HINTS[current]}; + for(const b of buttons)b.addEventListener('click',()=>{current=b.dataset.quality;try{localStorage.setItem('render360Quality',current)}catch(_){}show()}); + show(); + } + setupQuality(); + function testSharedMemory(){ try{const memory=new WebAssembly.Memory({initial:1,maximum:1,shared:true});return !!memory.buffer&&Object.prototype.toString.call(memory.buffer).includes('SharedArrayBuffer')} catch(error){log('Wasm shared-memory test:',String(error));return false} diff --git a/emscripten/pre.js b/emscripten/pre.js index 7d28bd51e0..957846bc62 100644 --- a/emscripten/pre.js +++ b/emscripten/pre.js @@ -307,6 +307,24 @@ Module['arguments'].push( // engine's own on-screen touch controls are the only way to move or look. // They default to off everywhere except Android. Window-only: this file also // runs inside every pthread worker. +// Graphics quality, picked on the start page or in the in-game menu. +// standard: 540p, quarter-size textures (the safe default for older phones) +// hd: 720p, half-size textures +// max: the screen's full resolution, full-size textures (uses the most memory) +const RENDER360_QUALITY = { + standard: { phoneHeight: 540, desktopHeight: 720, picmip: 2 }, + hd: { phoneHeight: 720, desktopHeight: 1080, picmip: 1 }, + max: { phoneHeight: 4096, desktopHeight: 4096, picmip: 0 } +} +let render360Quality = 'standard' +if(render360IsWindow) { + try { + const saved = localStorage.getItem('render360Quality') + if(saved && RENDER360_QUALITY[saved]) render360Quality = saved + } catch(_) {} + Module.render360Quality = render360Quality +} + if(render360IsWindow) { let coarsePointer = false try { coarsePointer = !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches) } catch(_) {} @@ -320,7 +338,7 @@ if(render360IsWindow) { // Quarter-size textures. On a 6 inch screen the difference is hard to // see, and textures are the largest thing a map load adds to both the // Wasm heap and GPU memory, which is where iOS kills the page. - Module['arguments'].push('+mat_picmip', '2') + Module['arguments'].push('+mat_picmip', String(RENDER360_QUALITY[render360Quality].picmip)) // Cheaper rendering features for a phone GPU. Bump maps and specular // stay on: turning them off makes Portal's materials ask for a // $bumpmap texture that is not loaded (console errors). @@ -348,7 +366,8 @@ if(render360IsWindow) { } catch(_) {} if(longSide > 0 && shortSide > 0) { const dpr = Math.max(1, Number(window.devicePixelRatio || 1)) - const height = Math.max(360, Math.min(phone ? 540 : 720, Math.round(shortSide * dpr))) + const quality = RENDER360_QUALITY[render360Quality] + const height = Math.max(360, Math.min(phone ? quality.phoneHeight : quality.desktopHeight, Math.round(shortSide * dpr))) const width = Math.round(height * longSide / shortSide / 2) * 2 Module.render360GameSize = { width, height } Module['arguments'].push('-w', String(width), '-h', String(height)) diff --git a/emscripten/shell.html b/emscripten/shell.html index 0736faef4c..e26baab0a0 100644 --- a/emscripten/shell.html +++ b/emscripten/shell.html @@ -227,6 +227,7 @@

Portal stopped

+
Shares the engine log with the developer
@@ -344,7 +345,30 @@

Portal stopped

} // ---------------------------------------------------------- menu + var RENDER360_QUALITY_NAMES = { standard: 'Standard', hd: 'HD', max: 'Max' }; + var RENDER360_QUALITY_ORDER = ['standard', 'hd', 'max']; + function render360SavedQuality() { + var q = 'standard'; + try { q = localStorage.getItem('render360Quality') || 'standard'; } catch (_) {} + return RENDER360_QUALITY_NAMES[q] ? q : 'standard'; + } + function render360ShowQuality() { + var row = document.getElementById('r360-quality-row'); + if (!row) return; + var saved = render360SavedQuality(); + var running = (window.Module && Module.render360Quality) || saved; + row.textContent = 'Graphics: ' + RENDER360_QUALITY_NAMES[saved] + + (saved !== running ? ' (next launch)' : ''); + } + // Resolution and texture size are fixed when the engine starts, so a new + // choice applies the next time Portal is opened. + function render360CycleQuality() { + var next = RENDER360_QUALITY_ORDER[(RENDER360_QUALITY_ORDER.indexOf(render360SavedQuality()) + 1) % RENDER360_QUALITY_ORDER.length]; + try { localStorage.setItem('render360Quality', next); } catch (_) {} + render360ShowQuality(); + } function render360OpenSheet(open) { + if (open) render360ShowQuality(); document.getElementById('r360-sheet').classList.toggle('r360-open', !!open); } @@ -410,7 +434,8 @@

Portal stopped

'OffscreenCanvas=' + typeof OffscreenCanvas, 'WebGL2=' + String(!!document.createElement('canvas').getContext('webgl2')), 'viewport=' + window.innerWidth + 'x' + window.innerHeight + '@' + (window.devicePixelRatio || 1) + - ' game=' + JSON.stringify((typeof Module !== 'undefined' && Module.render360GameSize) || null), + ' game=' + JSON.stringify((typeof Module !== 'undefined' && Module.render360GameSize) || null) + + ' quality=' + ((typeof Module !== 'undefined' && Module.render360Quality) || 'unknown'), '', '--- latest runtime event only ---', render360LatestOutput || '' @@ -555,6 +580,17 @@

Portal stopped

' OffscreenCanvas=' + typeof OffscreenCanvas); if (!window.crossOriginIsolated || typeof SharedArrayBuffer === 'undefined') { + // Usually the start page's service worker wasn't in control yet (Safari + // right after a deploy). Reload the whole page once so it is; the time + // guard stops a loop where isolation is impossible (Private tabs). + var render360LastIsolationReload = 0; + try { render360LastIsolationReload = Number(sessionStorage.getItem('render360PagesReloadAt')) || 0; } catch (_) {} + if (Date.now() - render360LastIsolationReload > 20000) { + try { sessionStorage.setItem('render360PagesReloadAt', String(Date.now())); } catch (_) {} + render360AppendOutput('[Render360 baseline] not isolated; reloading the start page'); + try { (window.top || window).location.reload(); } catch (_) { window.location.reload(); } + throw new Error('Render360: reloading for cross-origin isolation'); + } render360ShowFailure('Portal can’t start here', 'This page is not cross-origin isolated, so threads are unavailable. Open it from the Portal start page, not a Private tab.'); } From 53db3cda315f0a5fe7f902642c68e043bcba84fe Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 15:05:22 +0000 Subject: [PATCH 2/2] shaderapi: don't reject page-sized windows on web (game quit at startup) With the engine now asking for the page-sized mode (1168x540 on an iPhone 11), CShaderDeviceMgrDx8::ValidateMode compared it with the GL layer's fixed 640x480 "display mode" and refused it. SetMode failed, CreateGameWindow returned false, ModInit failed and the engine exited right after "[Render360 video] mode 1168x540" with RUN_OK and nothing on screen. A browser canvas can be any size, so skip the windowed size check on web. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qy5DKdwTqGQqScWvJyNcft --- materialsystem/shaderapidx9/shaderdevicedx8.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/materialsystem/shaderapidx9/shaderdevicedx8.cpp b/materialsystem/shaderapidx9/shaderdevicedx8.cpp index d44060917b..4bec1f201b 100644 --- a/materialsystem/shaderapidx9/shaderdevicedx8.cpp +++ b/materialsystem/shaderapidx9/shaderdevicedx8.cpp @@ -1462,10 +1462,15 @@ bool CShaderDeviceMgrDx8::ValidateMode( int nAdapter, const ShaderDeviceInfo_t & // settings GetCurrentModeInfo( &displayMode, 0 ); +#ifndef __EMSCRIPTEN__ // make sure the window fits within the current video mode + // (In the browser the GL layer reports a fixed 640x480 display, while + // the canvas can be any size the page asks for; a page-sized window + // was rejected here and the engine quit before showing anything.) if ( ( info.m_DisplayMode.m_nWidth > displayMode.m_nWidth ) || ( info.m_DisplayMode.m_nHeight > displayMode.m_nHeight ) ) return false; +#endif } else {