diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9be0fc8165..7d55e61f4b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,23 +1,356 @@ -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 + pages: write + id-token: write + +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=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 -- '-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 -- '--preload-file' emscripten/build.sh + grep -q -- '-lworkerfs.js' emscripten/build.sh + grep -q -- 'phase3-workerfs.js' 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 + 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 '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 + 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 + 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 + grep -q 'WORKERFS' emscripten/phase3-workerfs.js + grep -q 'render360-direct-vpk' emscripten/phase3-workerfs.js + grep -q 'background1 chunk preload is disabled' emscripten/phase3-workerfs.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 + node --check emscripten/pre.js + node --check emscripten/phase3-workerfs.js + node --check emscripten/assets/phase3-staging.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 + 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 + from pathlib import Path + html = Path('emscripten/pages-index.html').read_text() + scripts = re.findall(r']*)?>(.*?)', 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: | + 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 + 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 + grep -a -q 'probable-process-kill-reload' build/install/hl2_launcher.js + grep -a -q 'render360-direct-vpk' build/install/hl2_launcher.js + grep -a -q 'WORKERFS' build/install/hl2_launcher.js + test -s emscripten/portal-local-vpk.js + test -s emscripten/portal-boot-overlay.js + test -s emscripten/assets/phase3-staging.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 + fi + + 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 + + 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 = '' + 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();" + 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&&!globalThis.render360Phase3DirectSelected){\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" + " 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&&!globalThis.render360Phase3DirectSelected)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()});" + replace_once(old, new, 'launcher navigation') + + path.write_text(text) + PY + + 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 + 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 + + cat > build/install/render360-baseline.json <.data", + "chunkSources": ["local-vpk-cache", "original-yikes-host"], + "bundledPortalRetailData": false + } + JSON + + (cd build/install && zip -9 -r "$GITHUB_WORKSPACE/Render360-Portal-iPhone-Baseline.zip" .) + ls -lh Render360-Portal-iPhone-Baseline.zip + + - name: Upload runtime artifact + if: github.event_name != 'pull_request' + uses: actions/upload-artifact@v7 + with: + name: Render360-Portal-iPhone-Baseline + path: Render360-Portal-iPhone-Baseline.zip + 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 + + - name: Upload GitHub Pages artifact + if: github.event_name != 'pull_request' + uses: actions/upload-pages-artifact@v4 + with: + path: build/install + + deploy-pages: + name: Deploy iPhone staging page + if: github.event_name != 'pull_request' + needs: build-wasm runs-on: ubuntu-latest + environment: + name: portal-iphone-staging + url: ${{ steps.deployment.outputs.page_url }} steps: - - uses: actions/checkout@v2 - with: - submodules: recursive - - name: Build wasm - run: | - source emscripten/get_emscripten.sh - emmake emscripten/build.sh - cd build/install/ - zip -r ../../out.zip * - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: release.zip - path: out.zip \ No newline at end of file + - name: Deploy baseline to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/ios-native-signed.yml b/.github/workflows/ios-native-signed.yml new file mode 100644 index 0000000000..506a540fec --- /dev/null +++ b/.github/workflows/ios-native-signed.yml @@ -0,0 +1,201 @@ +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: 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 }} + 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_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: + 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 + + 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 + + EXECUTABLE="$APP_PATH/Render360Portal" + test -f "$EXECUTABLE" + codesign --verify --deep --strict --verbose=2 "$APP_PATH" + codesign -d --entitlements :- "$APP_PATH" || true + 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) + 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 + 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 diff --git a/.github/workflows/ios-native.yml b/.github/workflows/ios-native.yml new file mode 100644 index 0000000000..07c1bfaca5 --- /dev/null +++ b/.github/workflows/ios-native.yml @@ -0,0 +1,135 @@ +name: iOS Native Bootstrap IPA +on: + push: + branches: [render360/ios-native] + paths: + - 'ios-native/**' + - 'tier0/**' + - 'tier1/**' + - 'mathlib/**' + - 'common/**' + - 'public/**' + - '.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 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 detected.' >&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 + 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 + 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:]]*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 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 N2 bundle and package unsigned IPA + shell: bash + run: | + set -euxo pipefail + 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,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('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 + cat > build/ipa/BUILD_INFO.txt < 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. +``` 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. 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. 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. diff --git a/docs/IOS_NATIVE_PORTING_AUDIT.md b/docs/IOS_NATIVE_PORTING_AUDIT.md new file mode 100644 index 0000000000..a9530a01f7 --- /dev/null +++ b/docs/IOS_NATIVE_PORTING_AUDIT.md @@ -0,0 +1,302 @@ +# Render360 Portal Native iOS — Porting Audit + +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. + +## Current audited baseline + +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 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: + +- 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 — baseline and IPA pipeline resolution + +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 a DerivedData product path. + +Both native workflows now resolve the actual CMake/Xcode product location and retain a DerivedData fallback. + +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. + +## Phase 01 — N0 bootstrap hardening + +### Bootstrap structure + +Phase 01 split the temporary native host into focused pieces so N1 can introduce SDL without rewriting one giant `main.mm`: + +- `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. + +No SDL, Source, fake gameplay, browser runtime, or retail Portal data was added. + +### N0 diagnostic checkpoints + +The bootstrap now exposes explicit native checkpoints including: + +- `bootstrap-enter` +- `ui-ready` +- `game-data-not-configured` +- `import-picker-open` +- `candidate-root-validating` +- `candidate-root-valid` +- `candidate-root-invalid` +- `memory-warning` + +Picker cancellation and app termination also have explicit diagnostic checkpoints. Diagnostics are current-state/latest-error based rather than an unbounded in-memory log. + +### Missing game-data behavior + +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. + +The N0 validator checks the user-selected root for: + +- 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/`. + +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. + +### CMake/Xcode target hardening + +The N0 target now fails configuration for the iOS Simulator and requires exactly ARM64. CI verifies generated Release build settings for: + +- `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 + +Build number and Git commit identifier are supplied from GitHub Actions and surfaced in bootstrap diagnostics. + +### Info.plist hardening + +The built Info.plist is CI-validated for: + +- `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 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. + +### Phase 01 CI proof + +The final code-bearing Phase 01 proof is native workflow run **#13** (`35040031386`) on commit `7ec9b1c397dfdfd7b7f69f65dfa79aa5ca1aa7aa`. + +Result: **success**. + +Passed steps include: + +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. + +Artifact: + +- `Render360-Portal-iOS-unsigned-13` +- artifact id `10424796574` +- artifact SHA-256 `caddb53614d147663d16b561893c1d89b2e329f57b25f589196a716bcce6ad1b` + +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. + +### Proof classification after Phase 01 + +**CI-proven:** + +- 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. + +**Code-review-proven:** + +- 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. + +**Physical-iPhone-proven:** + +- **BLOCKED / NOT YET VERIFIED.** There is no repository evidence yet that this bootstrap has been signed/sideloaded and launched on a real iPhone 11. + +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. + +## Repository/module inventory for later native work + +### Source foundations to reuse + +Existing first native Source build candidates remain: + +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. + +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. + +## Pinned Source-related submodules + +- `thirdparty` -> `nillerusr/source-thirdparty` at `c5b901ecef515ea068fa8b8a19ca5cd5353905cb` +- `ivp` -> `nillerusr/source-physics` at `47533475e01cbff05fbc3bbe8b4edc485f292cea` +- `lib` -> `nillerusr/source-engine-libs` at `86a66ee92d9fda0a09f54a435e850faa7ab5d0fa` + +Future SDL integration must likewise pin a concrete revision/release and document how CI obtains it. + +## Browser/Emscripten boundary + +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. + +## Desktop/platform assumptions still requiring later iOS treatment + +### 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. + +### Filesystem and paths + +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. + +### Threading/TLS/atomics/timing + +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 + +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 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 + +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 — 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 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/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` only where the host/build graph requires the same change + +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 01 acceptance gates — result + +A. **PASS — CI-proven.** Native workflow run #13 produced an unsigned IPA containing an exactly ARM64 executable. + +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. + +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. + +D. **PASS — CI-proven.** No blocked retail Portal/game asset type is present under `ios-native/` or in the produced IPA. + +E. **PASS.** This audit explicitly separates CI-proven, code-review-proven and physical-device-proven status. + +F. **BLOCKED / NOT VERIFIED.** Physical-iPhone launch has not been demonstrated. No physical-device success claim is made. + +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. 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. +``` diff --git a/emscripten/assets/phase3-staging.js b/emscripten/assets/phase3-staging.js new file mode 100644 index 0000000000..c759ae3c8c --- /dev/null +++ b/emscripten/assets/phase3-staging.js @@ -0,0 +1,222 @@ +(() => { + 'use strict'; + + const FILES_TYPE = 'render360-retail-files'; + const REQUEST_TYPE = 'render360-retail-request'; + 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'; + + let retailDescriptors = []; + let runtimeFrame = null; + let runtimeOverlay = null; + let phase3Button = null; + + function normalize(value) { + return String(value || '').replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+/g, '/').toLowerCase(); + } + + function inferRelativePath(file) { + const raw = normalize(file?.webkitRelativePath || file?.name || ''); + if(!file?.webkitRelativePath) return raw; + const parts = raw.split('/'); + return parts.length > 1 ? parts.slice(1).join('/') : raw; + } + + 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; + } + + function summarize(descriptors) { + 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(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, maps, gameinfo, background1 }; + } + + function setPhase3Status(text) { + const hint = document.getElementById('launchHint'); + if(hint) hint.textContent = text; + } + + function refreshButton() { + if(!phase3Button) return; + const stats = summarize(retailDescriptors); + 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} 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.'); + } + } + + function rememberFolder(fileList) { + const files = Array.from(fileList || []); + const next = []; + for(const file of files) { + const path = inferRelativePath(file); + if(!keepForDirectVPK(path, file)) continue; + next.push({ path, file }); + } + retailDescriptors = next; + globalThis.render360Phase3RetailFiles = retailDescriptors; + const stats = summarize(retailDescriptors); + 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, + maps: stats.maps, bytes: stats.bytes, gameinfo: stats.gameinfo, + background1: stats.background1, residencyPolicy: RESIDENCY_POLICY + })); + } catch(_) {} + refreshButton(); + } + + function markFreshLaunch() { + try { + const state = JSON.parse(localStorage.getItem(CRASH_STATE_KEY) || 'null'); + if(state) { + state.active = false; + state.blocked = false; + state.interruption = null; + state.phase = 'phase3-manual-relaunch'; + state.updatedAt = Date.now(); + localStorage.setItem(CRASH_STATE_KEY, JSON.stringify(state)); + } + localStorage.removeItem('render360-ios-last-error-v1'); + localStorage.removeItem('render360-missing-shader-v1'); + localStorage.removeItem('render360-startup-checkpoint-v1'); + } catch(_) {} + } + + function closeRuntime() { + if(runtimeFrame) { + try { runtimeFrame.src = 'about:blank'; } catch(_) {} + runtimeFrame.remove(); + runtimeFrame = null; + } + if(runtimeOverlay) { + runtimeOverlay.remove(); + runtimeOverlay = null; + } + document.documentElement.style.overflow = ''; + document.body.style.overflow = ''; + } + + function launchDirectVPK() { + const stats = summarize(retailDescriptors); + 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; + } + + closeRuntime(); + markFreshLaunch(); + globalThis.render360Phase3DirectSelected = true; + setPhase3Status(`Starting Phase 3. ${RESIDENCY_POLICY}.`); + + const overlay = document.createElement('div'); + overlay.id = 'render360Phase3Runtime'; + overlay.style.cssText = 'position:fixed;inset:0;z-index:2147483000;background:#050607;display:flex;flex-direction:column;padding-top:env(safe-area-inset-top);'; + + const bar = document.createElement('div'); + bar.style.cssText = 'height:48px;flex:0 0 48px;display:flex;align-items:center;gap:10px;padding:6px 10px;background:rgba(14,16,20,.94);border-bottom:1px solid rgba(255,255,255,.08);font:13px -apple-system,BlinkMacSystemFont,system-ui;color:#dfe5ed;'; + + const back = document.createElement('button'); + back.type = 'button'; + back.textContent = 'Exit'; + back.style.cssText = 'appearance:none;border:0;border-radius:10px;padding:8px 12px;background:#f4f7fb;color:#101318;font-weight:700;'; + back.addEventListener('click', closeRuntime); + + const label = document.createElement('span'); + 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); + + const frame = document.createElement('iframe'); + frame.id = 'render360Phase3Frame'; + frame.title = 'Render360 Portal Phase 3 runtime'; + // 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(); + + overlay.append(bar, frame); + document.body.appendChild(overlay); + document.documentElement.style.overflow = 'hidden'; + document.body.style.overflow = 'hidden'; + runtimeOverlay = overlay; + runtimeFrame = frame; + } + + function installUI() { + const folder = document.getElementById('ownershipFolder'); + if(folder) { + // Register before the page's normal verifier. It later clears input.value, + // but these File objects remain strongly referenced in this staging page. + folder.addEventListener('change', event => rememberFolder(event.target.files)); + } + + const existingLaunch = document.getElementById('launch'); + const actions = existingLaunch?.parentElement; + if(actions && !document.getElementById('launchPhase3')) { + const button = document.createElement('button'); + button.id = 'launchPhase3'; + button.type = 'button'; + button.disabled = true; + button.textContent = 'Launch Phase 3 · Current Map Only'; + button.addEventListener('click', launchDirectVPK); + actions.prepend(button); + phase3Button = button; + } + refreshButton(); + } + + window.addEventListener('message', event => { + if(event.origin !== location.origin) return; + if(!runtimeFrame || event.source !== runtimeFrame.contentWindow) return; + const data = event?.data; + if(!data || data.type !== REQUEST_TYPE || !data.token) return; + const stats = summarize(retailDescriptors); + if(!stats.gameinfo || !stats.background1 || !stats.dirs || !stats.vpks) return; + runtimeFrame.contentWindow.postMessage({ + type: FILES_TYPE, + token: data.token, + files: retailDescriptors + }, location.origin); + }); + + if(document.readyState === 'loading') document.addEventListener('DOMContentLoaded', installUI, { once: true }); + else installUI(); +})(); \ No newline at end of file diff --git a/emscripten/build.sh b/emscripten/build.sh index 8054ae61cf..975549a499 100755 --- a/emscripten/build.sh +++ b/emscripten/build.sh @@ -11,28 +11,357 @@ export CXX=em++ set -ex -#rm -rf build/install +# Keep Source's upstream pthread/SharedArrayBuffer architecture intact, but stop +# the browser build from attempting to dlopen desktop-only optional modules. +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__ + 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); + } + + 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 { + Msg("Render360: loaded module: %s\n", szModuleName); + } +#else +''' +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 ( + 'Render360: optional browser module skipped:', + 'Render360: loaded module:', + '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}') +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') +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 + +# 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 $@ find build/ -name '*.map' -exec cp {} build/install/ \; -#link_libs="-sERROR_ON_UNDEFINED_SYMBOLS=0" +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 + +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" + +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 -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 \ +# iPhone Safari has a relatively tight WebContent process budget. At startup +# Portal simultaneously holds the shared Wasm heap, Wasm SIDE_MODULE bytes/JIT +# code, pthread stacks and WebGL resources. Phase 3 removes the retail map/VPK +# payload from MEMFS: the staging page keeps the user's File objects alive and +# forwards them to the Source pthread where WORKERFS exposes them read-only. +# Source then opens the retail VPKs and performs its own range reads on demand. +# WORKERFS is not part of the default JS filesystem, so link it explicitly. +EMCC_FORCE_STDLIBS=libc,libc++,libc++abi emcc -Os \ + -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 -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 \ - -L build/install/ \ + -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 \ - $link_libs \ -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/ \ No newline at end of file +cp -r emscripten/assets build/install/ diff --git a/emscripten/get_emscripten.sh b/emscripten/get_emscripten.sh index f368144c51..6a117326ac 100755 --- a/emscripten/get_emscripten.sh +++ b/emscripten/get_emscripten.sh @@ -12,6 +12,324 @@ 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 + +# 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\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\tQ_strncpy( g_szBasedir, pOverrideDir, sizeof( g_szBasedir ) ); +\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} +\telse +\t{ +\t\tMsg( "[Render360 startup] basedir-override:%s\\n", g_szBasedir ); +\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, + 'CommandLine()->CheckParm( "-basedir", &pOverrideDir )', + '[Render360 startup] basedir-override:', + '[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 diff --git a/emscripten/pages-index.html b/emscripten/pages-index.html new file mode 100644 index 0000000000..84657311a2 --- /dev/null +++ b/emscripten/pages-index.html @@ -0,0 +1,386 @@ + + + + + + + Render360 · Portal iPhone baseline + + + +
+

Portal upstream baseline

+

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…
+
Service workerchecking…
+
crossOriginIsolatedchecking…
+
SharedArrayBufferchecking…
+
Wasm shared memorychecking…
+
OffscreenCanvaschecking…
+
WebGL2checking…
+
Runtime fileschecking…
+
Portal ownership proofnot verified
+
Runtime chunk sourcechecking…
+
+ +
+ Verify your Portal copy +

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.

+
+ + + + + +
+
+
+ +
+ 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 threaded runtime can start…

+
Last runtimenone recorded
+
+ + +
+
+ +
+ Diagnostics +

Only the latest staging event is retained; completed steps are discarded.

+
+ + +
+
+
+ + + + \ No newline at end of file diff --git a/emscripten/phase3-mobile-runtime.js b/emscripten/phase3-mobile-runtime.js new file mode 100644 index 0000000000..0b30401767 --- /dev/null +++ b/emscripten/phase3-mobile-runtime.js @@ -0,0 +1,523 @@ +// 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 + + // 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 + 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 + const meaningful = + line.includes('[Render360 startup]') || + /(?:filesystem|gameinfo\.txt|engine error|unable to|failed to mount|startup failed)/i.test(line) + if(!meaningful) return + + const match = line.match(/\[Render360 startup\]\s*(.+)$/i) + recordStartupCheckpoint(match ? match[1] : line, line) + } + + 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) + } + + // 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 + let debug = startupDebug + if(!checkpoint || !debug?.trace?.length) { + try { + checkpoint = checkpoint || JSON.parse(localStorage.getItem(STARTUP_KEY) || 'null')?.line || '' + debug = JSON.parse(localStorage.getItem(STARTUP_DEBUG_KEY) || 'null') || debug + } catch(_) {} + } + const base = oldDiagnosticText() + 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"]') + 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() +})() diff --git a/emscripten/phase3-workerfs.js b/emscripten/phase3-workerfs.js new file mode 100644 index 0000000000..ac292dbcec --- /dev/null +++ b/emscripten/phase3-workerfs.js @@ -0,0 +1,438 @@ +// Render360 Phase 3 — zero-copy Portal retail filesystem for iPhone Safari. +// +// The staging document owns the user's File objects. The launcher runs inside a +// same-origin iframe so that staging document stays alive, then sends a compact +// list of retail File objects to this runtime. Pthread workers receive those +// File objects over BroadcastChannel, mount them through WORKERFS, and expose a +// writable MEMFS shadow tree of symlinks at /portal, /hl2 and /platform. +// +// 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 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 +// gameplay -> current BSP only +// transition -> Source shuts the old level down, then opens the next BSP +// future maps -> never prefetched by Render360 +// +// 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' + + const CHANNEL_NAME = 'render360-direct-vpk-channel-v1' + const REQUEST_TYPE = 'render360-retail-request' + const FILES_TYPE = 'render360-retail-files' + const READY_TYPE = 'render360-retail-worker-ready' + const MOUNTED_TYPE = 'render360-retail-mounted' + const FAILED_TYPE = 'render360-retail-mount-failed' + const EXPECTED_POOL_WORKERS = 2 + 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', + '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', + 'escape_00', + 'escape_01', + 'escape_02' + ]) + + const isWindow = typeof window !== 'undefined' && typeof document !== 'undefined' + const isPthread = typeof ENVIRONMENT_IS_PTHREAD !== 'undefined' && !!ENVIRONMENT_IS_PTHREAD + const embeddedLauncher = !!(isWindow && window.parent && window.parent !== window) + const workerId = isPthread + ? `pthread-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` + : 'browser-main' + + let channel = null + try { + 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, '/') + .replace(/^\/+/, '') + .replace(/\/+/g, '/') + .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('/') + return at <= 0 ? '/' : clean.slice(0, at) + } + + function safePhase(phase) { + try { globalThis.render360SetPhase?.(phase) } catch(_) {} + } + + function safePrint(text) { + try { Module.print?.(text) } catch(_) { try { console.log(text) } catch(__) {} } + } + + function safePrintErr(text) { + 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 + // 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) { + 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 + 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(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, 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 + } + + function unlinkIfSymlink(path) { + try { + const node = FS.lookupPath(path, { follow: false })?.node + if(node && FS.isLink(node.mode)) FS.unlink(path) + } catch(_) {} + } + + function mountRetailWorkerFS(descriptors, token) { + if(!isPthread) throw new Error('WORKERFS mount attempted outside an Emscripten pthread worker') + if(typeof WORKERFS === 'undefined') throw new Error('WORKERFS is unavailable; build must link -lworkerfs.js') + if(typeof FileReaderSync === 'undefined') throw new Error('FileReaderSync is unavailable in this worker') + if(!Array.isArray(descriptors) || !descriptors.length) throw new Error('no Portal retail File objects were transferred') + + if(Module.render360DirectVPKMounted === true) { + return Module.render360DirectVPKStats || retailDescriptorStats(descriptors) + } + + 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') + + 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() + globalThis.__render360RetailNextHandle = 1 + + safePhase('phase3-workerfs-mount-start') + FS.mkdirTree(RETAIL_MOUNT) + try { FS.unmount(RETAIL_MOUNT) } catch(_) {} + FS.mount(WORKERFS, { blobs }, RETAIL_MOUNT) + + let links = 0 + for(const rel of exposed) { + const livePath = '/' + rel + const targetPath = RETAIL_MOUNT + '/' + rel + FS.mkdirTree(dirname(livePath)) + unlinkIfSymlink(livePath) + try { + FS.lookupPath(livePath, { follow: false }) + continue + } catch(_) {} + FS.symlink(targetPath, livePath) + links++ + } + + 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 + Module.render360DirectVPKStats = stats + Module.render360ResidentBytes = Number(Module.render360ResidentBytes || 0) + Module.render360ResidentFiles = Number(Module.render360ResidentFiles || 0) + publishResidency() + 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 + } + + if(isPthread && channel) { + channel.addEventListener('message', event => { + const data = event?.data + if(!data || data.type !== FILES_TYPE) return + if(data.targetWorkerId && data.targetWorkerId !== workerId) return + try { + const stats = mountRetailWorkerFS(data.files, data.token) + channel.postMessage({ type: MOUNTED_TYPE, token: data.token, workerId, stats }) + } catch(error) { + const message = String(error?.stack || error?.message || error) + safePhase(`phase3-workerfs-failed:${String(error?.message || error).slice(0, 120)}`) + safePrintErr(`[Render360 Phase 3] WORKERFS mount failed: ${message}`) + channel.postMessage({ type: FAILED_TYPE, token: data.token, workerId, error: message.slice(-2048) }) + } + }) + channel.postMessage({ type: READY_TYPE, workerId }) + } + + if(isWindow && embeddedLauncher) { + Module.render360DirectVPKRequested = true + safePhase('phase3-await-prerun') + + 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() + const readyWorkers = new Set() + + function releaseDependency() { + if(released) return + released = true + if(timeout) clearTimeout(timeout) + Module.render360DirectVPKReady = true + safePhase(`phase3-workers-ready:${mountedWorkers.size}`) + const stats = Module.render360DirectRetailStats || {} + // 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') + } + } + + function failHandoff(message) { + if(released) return + const text = `[Render360 Phase 3] ${message}` + safePhase(`phase3-handoff-failed:${String(message).slice(0, 120)}`) + safePrintErr(text) + if(typeof abort === 'function') abort(text) + else throw new Error(text) + } + + function sendToWorker(id) { + if(!channel || !descriptors || !id || mountedWorkers.has(id) || failedWorkers.has(id)) return + channel.postMessage({ type: FILES_TYPE, token, targetWorkerId: id, files: descriptors }) + } + + if(channel) { + channel.addEventListener('message', event => { + const data = event?.data + if(!data) return + if(data.type === READY_TYPE && data.workerId) { + readyWorkers.add(data.workerId) + if(handoffStarted) sendToWorker(data.workerId) + return + } + if(data.token !== token) return + if(data.type === MOUNTED_TYPE && data.workerId) { + mountedWorkers.add(data.workerId) + failedWorkers.delete(data.workerId) + safePrint(`[Render360 Phase 3] worker ${mountedWorkers.size}/${EXPECTED_POOL_WORKERS} mounted WORKERFS.`) + if(mountedWorkers.size >= EXPECTED_POOL_WORKERS) releaseDependency() + return + } + if(data.type === FAILED_TYPE && data.workerId) { + failedWorkers.set(data.workerId, data.error || 'unknown WORKERFS failure') + failHandoff(`worker ${data.workerId} could not mount retail files: ${data.error || 'unknown error'}`) + } + }) + } + + globalThis.addEventListener('message', event => { + if(event.origin !== location.origin) return + const data = event?.data + if(!data || data.type !== FILES_TYPE || data.token !== token) return + if(!Array.isArray(data.files) || !data.files.length) { + failHandoff('staging page did not provide a full Portal folder') + return + } + 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}: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) + }) + + // 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) + }) + } + + if(typeof DataLoader !== 'undefined') { + const originalLoadMapWithDeps = DataLoader.prototype.loadMapWithDeps + DataLoader.prototype.loadMapWithDeps = async function(mapName) { + if(Module.render360DirectVPKMounted === true || (isWindow && Module.render360DirectVPKRequested === true)) { + 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 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 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) + } + } + + publishResidency() + globalThis.render360Phase3 = { + active: embeddedLauncher || isPthread, + embeddedLauncher, + isPthread, + workerId, + mountPoint: RETAIL_MOUNT, + expectedPoolWorkers: EXPECTED_POOL_WORKERS, + prefetchEnabled: false, + get currentMap() { return residency.currentMap }, + get residency() { return { ...Module.render360MapResidency } } + } +})() diff --git a/emscripten/portal-boot-overlay.js b/emscripten/portal-boot-overlay.js new file mode 100644 index 0000000000..355fc2e5ec --- /dev/null +++ b/emscripten/portal-boot-overlay.js @@ -0,0 +1,775 @@ +(() => { + 'use strict'; + + // Phase 2 memory model: + // 1. Build a deterministic first-frame overlay under a hard 20 MiB budget. + // 2. Never copy the complete retail .vcs cache into bootstrap MEMFS. + // 3. Inspect each Portal BSP's referenced VMTs and build a shader-only delta + // pack for that map. The service worker appends that tiny pack to the map + // chunk only when Source actually requests the map. + // + // The selected Portal files stay local. Cache Storage holds the generated + // packed records; no retail data is uploaded to GitHub Pages. + const CACHE_NAME = 'render360-portal-boot-overlay-v3'; + const MAP_SHADER_CACHE_NAME = 'render360-portal-map-shaders-v1'; + const OVERLAY_PATH = './render360-bootstrap-overlay.data'; + const MANIFEST_VERSION = 'portal-first-frame-v1'; + const BOOTSTRAP_SHADER_BUDGET_BYTES = 20 * 1024 * 1024; + const MAX_SINGLE_SHADER_BYTES = 16 * 1024 * 1024; + const MAX_VMT_BYTES = 2 * 1024 * 1024; + const MAX_BSP_TEXT_SCAN_BYTES = 24 * 1024 * 1024; + + 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 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/effects/flashlight001.vtf', + 'materials/effects/flashlight_border.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' + ]; + + // Ordered first-frame manifest. Required families are always retained if + // present. Optional families are admitted only while the packed overlay still + // fits the 20 MiB budget. Any new bootstrap dependency must be explicit here. + const FIRST_FRAME_SHADER_MANIFEST = [ + { family: 'vertexlit_and_unlit_generic', prefixes: ['vertexlit_and_unlit_generic'], required: true }, + { family: 'lightmappedgeneric', prefixes: ['lightmappedgeneric'], required: true }, + { family: 'unlitgeneric', prefixes: ['unlitgeneric'], required: true }, + { family: 'screenspace_general', prefixes: ['screenspace_general'], required: true }, + { family: 'sky', prefixes: ['sky'], required: false }, + { family: 'sprite', prefixes: ['sprite', 'spritecard'], required: false }, + { family: 'worldvertextransition', prefixes: ['worldvertextransition'], required: false }, + { family: 'worldtwotextureblend', prefixes: ['worldtwotextureblend'], required: false }, + { family: 'depthwrite', prefixes: ['depthwrite'], required: false }, + { family: 'shadow', prefixes: ['shadow', 'shadowmodel'], required: false }, + { family: 'decalmodulate', prefixes: ['decalmodulate'], required: false } + ]; + + const SHADER_RE = /(?:^|\/)shaders\/(?:fxc|vsh|psh)\/[^/]+\.vcs$/i; + const MAP_RESOURCE_RE = /(?:^|\/)(?:maps\/[^/]+\.bsp|materials\/[^/]+(?:\/[^/]+)*\.vmt)$/i; + + 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 basename(path) { + const p = normalizePath(path); + const at = p.lastIndexOf('/'); + return at === -1 ? p : p.slice(at + 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 compactShaderName(value) { + return String(value || '').toLowerCase().replace(/[^a-z0-9]/g, ''); + } + + function compiledShaderFamily(path) { + const stem = basename(path).replace(/\.vcs$/i, ''); + // Retail cache names normally end in _vs20/_ps20b/_vs30/_ps30 plus + // optional combo suffixes. Remove the stage portion to obtain the family. + return stem.replace(/_(?:vs|ps|vsh|psh)[a-z0-9_]*$/i, ''); + } + + function materialShaderCandidateKeys(shaderName) { + const key = compactShaderName(shaderName).replace(/^sdk/, ''); + const aliases = { + vertexlitgeneric: ['vertexlitandunlitgeneric', 'vertexlitgeneric'], + unlitgeneric: ['vertexlitandunlitgeneric', 'unlitgeneric'], + lightmappedgeneric: ['lightmappedgeneric'], + screenspacegeneral: ['screenspacegeneral'], + worldvertextransition: ['worldvertextransition'], + worldtwotextureblend: ['worldtwotextureblend'], + decalmodulate: ['decalmodulate'], + sprite: ['sprite', 'spritecard'], + spritecard: ['spritecard', 'sprite'], + sky: ['sky'], + water: ['water'], + refract: ['refract'], + cable: ['cable'], + teeth: ['teeth'], + eyes: ['eyes', 'eyerefract'], + eyerefract: ['eyerefract', 'eyes'], + modulate: ['modulate'], + unlittwotexture: ['unlittwotexture'], + depthwrite: ['depthwrite'], + shadow: ['shadow', 'shadowmodel'] + }; + return aliases[key] || (key ? [key] : []); + } + + 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; + } + + 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 shaderManifestEntry(path) { + const clean = normalizePath(path); + if (!SHADER_RE.test(clean)) return null; + const stem = basename(clean).replace(/\.vcs$/i, ''); + for (const entry of FIRST_FRAME_SHADER_MANIFEST) { + for (const prefix of entry.prefixes) { + if (stem === prefix || stem.startsWith(prefix + '_')) return entry; + } + } + return null; + } + + function addDescriptor(found, descriptor) { + const key = normalizePath(descriptor.path); + if (!key || found.has(key)) return false; + found.set(key, descriptor); + return true; + } + + function descriptorCost(descriptor) { + return Number(descriptor.size || 0) + 8 + String(descriptor.path || '').length * 2; + } + + function selectWithinBudget(found, log) { + const boot = []; + const byFamily = new Map(FIRST_FRAME_SHADER_MANIFEST.map(x => [x.family, []])); + for (const descriptor of found.values()) { + if (descriptor.category !== 'shader') { + boot.push(descriptor); + continue; + } + if (!byFamily.has(descriptor.family)) byFamily.set(descriptor.family, []); + byFamily.get(descriptor.family).push(descriptor); + } + + let bytes = boot.reduce((sum, descriptor) => sum + descriptorCost(descriptor), 0); + const selected = [...boot]; + const includedFamilies = []; + const omittedFamilies = []; + + const includeFamily = entry => { + const files = byFamily.get(entry.family) || []; + if (!files.length) { + log(`Boot shader manifest: family not present in selected install: ${entry.family}`); + return; + } + const familyBytes = files.reduce((sum, descriptor) => sum + descriptorCost(descriptor), 0); + if (!entry.required && bytes + familyBytes > BOOTSTRAP_SHADER_BUDGET_BYTES) { + omittedFamilies.push({ family: entry.family, files: files.length, bytes: familyBytes, reason: 'budget' }); + log(`Boot shader manifest: deferred optional ${entry.family} (${files.length} files/${familyBytes} bytes) to stay under 20 MiB.`); + return; + } + selected.push(...files); + bytes += familyBytes; + includedFamilies.push({ family: entry.family, files: files.length, bytes: familyBytes, required: entry.required }); + }; + + for (const entry of FIRST_FRAME_SHADER_MANIFEST.filter(x => x.required)) includeFamily(entry); + if (bytes > BOOTSTRAP_SHADER_BUDGET_BYTES) { + const required = includedFamilies.map(x => `${x.family}=${x.bytes}`).join(', '); + throw new Error(`Required first-frame shader manifest exceeds 20 MiB bootstrap budget (${bytes} bytes). Families: ${required}`); + } + for (const entry of FIRST_FRAME_SHADER_MANIFEST.filter(x => !x.required)) includeFamily(entry); + + return { selected, estimatedBytes: bytes, includedFamilies, omittedFamilies }; + } + + async function indexTargets(files, log) { + const allFiles = Array.from(files || []); + const filesByRel = new Map(); + const found = new Map(); + const gameEntries = new Map(); + const fixedFound = new Set(); + let discoveredShaderFiles = 0; + let selectedShaderFiles = 0; + let omittedShaderFiles = 0; + let looseShaders = 0; + let vpkShaders = 0; + let skippedHugeShaders = 0; + + for (const file of allFiles) { + const rel = inferRelativePath(file); + if (!rel) continue; + filesByRel.set(rel, file); + + if (/^(?:portal|hl2|platform)\//.test(rel)) { + const looseDescriptor = { + kind: 'loose', path: '/' + rel, file, size: file.size + }; + if (MAP_RESOURCE_RE.test(rel) || SHADER_RE.test(rel)) addDescriptor(gameEntries, looseDescriptor); + + const fixed = fixedSuffixFor(rel); + if (fixed) { + fixedFound.add(fixed); + addDescriptor(found, { ...looseDescriptor, category: 'boot' }); + } + + if (SHADER_RE.test(rel)) { + discoveredShaderFiles++; + const manifest = shaderManifestEntry(rel); + if (!manifest) { + omittedShaderFiles++; + } else if (file.size <= MAX_SINGLE_SHADER_BYTES) { + if (addDescriptor(found, { + ...looseDescriptor, category: 'shader', family: manifest.family + })) { + looseShaders++; + selectedShaderFiles++; + } + } else { + skippedHugeShaders++; + log(`Boot overlay skipped unusually large loose shader (${file.size} bytes): ${rel}`); + } + } + } + } + + 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) { + 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; + 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 path = '/' + [parent, internal].filter(Boolean).join('/'); + const totalSize = preload.length + entryLength; + const shaderCandidate = SHADER_RE.test(internal); + const fixed = fixedSuffixFor(internal); + const manifest = shaderCandidate ? shaderManifestEntry(internal) : null; + const baseDescriptor = { + kind: 'vpk', path, dirFile: file, dirRel: rel, archiveBase, + archiveIndex, entryOffset, entryLength, preload, headerSize, treeSize, + size: totalSize + }; + + if (MAP_RESOURCE_RE.test(internal) || shaderCandidate) addDescriptor(gameEntries, baseDescriptor); + + if (shaderCandidate) discoveredShaderFiles++; + if (shaderCandidate && !manifest) omittedShaderFiles++; + const shader = !!manifest && totalSize <= MAX_SINGLE_SHADER_BYTES; + + if (!fixed && !shader) { + if (manifest && totalSize > MAX_SINGLE_SHADER_BYTES) { + skippedHugeShaders++; + log(`Boot overlay skipped unusually large VPK shader (${totalSize} bytes): ${parent}/${internal}`); + } + continue; + } + + if (fixed) fixedFound.add(fixed); + if (addDescriptor(found, { + ...baseDescriptor, + category: shader ? 'shader' : 'boot', + family: shader ? manifest.family : null + }) && shader) { + vpkShaders++; + selectedShaderFiles++; + } + } + } + } + } + + 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 shader manifest ${MANIFEST_VERSION}: selected ${selectedShaderFiles}/${discoveredShaderFiles} retail .vcs files; deferred ${omittedShaderFiles} unrelated shader files.`); + log(`Boot shader sources: ${looseShaders} loose, ${vpkShaders} VPK.`); + if (skippedHugeShaders) log(`Boot overlay skipped ${skippedHugeShaders} shader file(s) larger than ${MAX_SINGLE_SHADER_BYTES} bytes.`); + + return { + found, gameEntries, filesByRel, fixedFound, discoveredShaderFiles, + selectedShaderFiles, omittedShaderFiles, 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) { + 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' }); + } + + function resolveGameEntry(entries, ref, preferredRoot = 'portal') { + const clean = normalizePath(ref).replace(/^\.\//, '').replace(/^\/+/, ''); + if (!clean) return null; + if (clean.startsWith('portal/') || clean.startsWith('hl2/') || clean.startsWith('platform/')) { + const exact = '/' + clean; + return 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 (entries.has(candidate)) return candidate; + } + return null; + } + + function findEntryBySuffix(entries, suffix) { + const needle = '/' + normalizePath(suffix); + for (const path of entries.keys()) if (path.endsWith(needle)) return path; + return null; + } + + function resolveMaterialEntry(entries, material, preferredRoot = 'portal') { + let clean = normalizePath(material).replace(/^materials\//, '').replace(/^\/+/, ''); + if (!clean) return null; + if (!clean.endsWith('.vmt')) clean += '.vmt'; + return resolveGameEntry(entries, 'materials/' + clean, preferredRoot); + } + + 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 offset = 8 + i * 16; + const fileofs = dv.getInt32(offset, true); + const filelen = dv.getInt32(offset + 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, limit) { + let end = start; + const max = Math.min(bytes.length, limit == null ? bytes.length : limit); + while (end < max && bytes[end] !== 0) end++; + return new TextDecoder('utf-8').decode(bytes.subarray(start, end)); + } + + function discoverBSPMaterialRefs(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 count = Math.floor(stringTable.filelen / 4); + for (let i = 0; i < count; i++) { + const rel = parsed.dv.getUint32(stringTable.fileofs + i * 4, true); + if (rel >= stringData.filelen) continue; + const value = normalizePath(extractCString(bytes, stringData.fileofs + rel, stringData.fileofs + stringData.filelen)); + if (value) refs.add(value); + } + } + + const scan = bytes.subarray(0, Math.min(bytes.length, MAX_BSP_TEXT_SCAN_BYTES)); + const text = new TextDecoder('latin1').decode(scan); + const vmtRe = /[a-zA-Z0-9_./\\-]{2,}\.vmt/g; + let match; + while ((match = vmtRe.exec(text))) { + const value = normalizePath(match[0]).replace(/^materials\//, ''); + if (value) refs.add(value); + } + return refs; + } + + function vmtRootShader(text) { + const clean = String(text || '') + .replace(/^\uFEFF/, '') + .replace(/\/\/[^\r\n]*/g, '') + .trim(); + const match = clean.match(/^(?:"([^"]+)"|([a-zA-Z0-9_]+))/); + return match ? String(match[1] || match[2] || '').trim() : ''; + } + + function vmtPatchInclude(text) { + const match = String(text || '').match(/"?include"?\s*"([^"]+)"/i); + return match ? match[1] : ''; + } + + async function materialShaderName(path, entries, filesByRel, cache, log, depth = 0) { + if (!path || depth > 4) return ''; + if (cache.has(path)) return cache.get(path); + const descriptor = entries.get(path); + if (!descriptor || Number(descriptor.size || 0) > MAX_VMT_BYTES) { + cache.set(path, ''); + return ''; + } + + try { + const blob = await readDescriptor(descriptor, filesByRel); + const text = await blob.text(); + const root = vmtRootShader(text); + if (compactShaderName(root) === 'patch') { + const include = vmtPatchInclude(text); + const preferred = path.startsWith('/hl2/') ? 'hl2' : 'portal'; + const includedPath = include ? resolveMaterialEntry(entries, include, preferred) : null; + const shader = includedPath + ? await materialShaderName(includedPath, entries, filesByRel, cache, log, depth + 1) + : ''; + cache.set(path, shader); + return shader; + } + cache.set(path, root); + return root; + } catch (error) { + log(`Map shader scan skipped ${path}: ${error?.message || error}`); + cache.set(path, ''); + return ''; + } + } + + function buildCompiledShaderIndex(entries) { + const index = new Map(); + for (const path of entries.keys()) { + if (!SHADER_RE.test(path)) continue; + const key = compactShaderName(compiledShaderFamily(path)); + if (!key) continue; + if (!index.has(key)) index.set(key, []); + index.get(key).push(path); + } + for (const paths of index.values()) paths.sort(); + return index; + } + + function shaderPathsForMaterial(shaderName, shaderIndex) { + const out = new Set(); + const candidates = materialShaderCandidateKeys(shaderName); + for (const candidate of candidates) { + for (const [compiledKey, paths] of shaderIndex) { + if (compiledKey === candidate || compiledKey.startsWith(candidate)) { + for (const path of paths) out.add(path); + } + } + } + return out; + } + + async function packDescriptors(paths, entries, filesByRel) { + const encoder = new TextEncoder(); + const parts = []; + let bytes = 0; + let records = 0; + for (const path of [...paths].sort()) { + const descriptor = entries.get(path); + if (!descriptor) continue; + const blob = await readDescriptor(descriptor, filesByRel); + const pathBytes = encoder.encode(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++; + } + return { blob: new Blob(parts, { type: 'application/octet-stream' }), bytes, records }; + } + + async function buildMapShaderPacks(entries, filesByRel, bootSelectedPaths, log) { + await caches.delete(MAP_SHADER_CACHE_NAME); + const cache = await caches.open(MAP_SHADER_CACHE_NAME); + const shaderIndex = buildCompiledShaderIndex(entries); + const vmtShaderCache = new Map(); + const seenShaderPaths = new Set([...bootSelectedPaths].map(normalizePath)); + const results = []; + + log(`Map shader index: ${shaderIndex.size} compiled retail shader families available for lazy map packs.`); + + for (const mapName of MAPS) { + let mapPath = resolveGameEntry(entries, `maps/${mapName}.bsp`, 'portal'); + if (!mapPath) mapPath = findEntryBySuffix(entries, `maps/${mapName}.bsp`); + if (!mapPath) { + results.push({ mapName, skipped: true, reason: 'map not found' }); + continue; + } + + const requiredShaderNames = new Set(); + try { + const mapBlob = await readDescriptor(entries.get(mapPath), filesByRel); + const refs = discoverBSPMaterialRefs(await mapBlob.arrayBuffer()); + for (const material of refs) { + const preferred = mapPath.startsWith('/hl2/') ? 'hl2' : 'portal'; + const vmtPath = resolveMaterialEntry(entries, material, preferred); + if (!vmtPath) continue; + const shader = await materialShaderName(vmtPath, entries, filesByRel, vmtShaderCache, log); + if (shader) requiredShaderNames.add(shader); + } + } catch (error) { + log(`Map shader scan failed for ${mapName}: ${error?.message || error}`); + } + + const deltaPaths = new Set(); + const matchedFamilies = new Set(); + for (const shaderName of requiredShaderNames) { + const matches = shaderPathsForMaterial(shaderName, shaderIndex); + if (!matches.size) { + log(`Map shader manifest: no compiled .vcs family matched ${shaderName} for ${mapName}.`); + continue; + } + matchedFamilies.add(shaderName); + for (const path of matches) { + const normalized = normalizePath(path); + if (seenShaderPaths.has(normalized)) continue; + const descriptor = entries.get(path); + if (!descriptor || Number(descriptor.size || 0) > MAX_SINGLE_SHADER_BYTES) continue; + seenShaderPaths.add(normalized); + deltaPaths.add(path); + } + } + + if (!deltaPaths.size) { + results.push({ + mapName, + records: 0, + bytes: 0, + shaderFamilies: [...matchedFamilies] + }); + continue; + } + + const packed = await packDescriptors(deltaPaths, entries, filesByRel); + const url = new URL(`./shader-packs/${mapName}.data`, location.href).href; + await cache.put(url, new Response(packed.blob, { + headers: { + 'Content-Type': 'application/octet-stream', + 'X-Render360-Shader-Pack': 'map-v1', + 'X-Render360-Map': mapName, + 'X-Render360-Shader-Records': String(packed.records), + 'X-Render360-Shader-Bytes': String(packed.bytes), + 'X-Render360-Shader-Families': [...matchedFamilies].join(',').slice(0, 4096) + } + })); + + log(`Map shader pack ${mapName}: ${packed.records} new .vcs files, ${(packed.bytes / 1048576).toFixed(2)} MiB, families=${[...matchedFamilies].join(',') || 'none'}.`); + results.push({ + mapName, + records: packed.records, + bytes: packed.bytes, + shaderFamilies: [...matchedFamilies] + }); + await new Promise(resolve => setTimeout(resolve, 0)); + } + + return results; + } + + 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 indexed = await indexTargets(files, log); + const { found, gameEntries, filesByRel, fixedFound } = indexed; + if (!found.size) throw new Error('None of the shared Source boot assets were found in the selected Portal install.'); + if (!indexed.selectedShaderFiles) throw new Error('No first-frame Source .vcs shader families were found in the selected Portal/HL2/platform files.'); + + const selection = selectWithinBudget(found, log); + const descriptors = selection.selected.sort((a, b) => a.path.localeCompare(b.path)); + const encoder = new TextEncoder(); + const parts = []; + let bytes = 0; + let records = 0; + 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); + 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++; + if (descriptor.category === 'shader') { + shaderBytes += blob.size; + shaderRecords++; + } + } + + if (bytes > BOOTSTRAP_SHADER_BUDGET_BYTES) { + throw new Error(`Packed first-frame bootstrap is ${bytes} bytes, above the ${BOOTSTRAP_SHADER_BUDGET_BYTES}-byte Phase 2 budget.`); + } + + 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-manifest', + 'X-Render360-Boot-Manifest': MANIFEST_VERSION, + 'X-Render360-Boot-Bytes': String(bytes), + 'X-Render360-Boot-Records': String(records), + 'X-Render360-Shader-Records': String(shaderRecords) + } + })); + + log(`Boot overlay ready: ${records} records, ${(bytes / 1048576).toFixed(2)} MiB; shaders=${shaderRecords} records/${(shaderBytes / 1048576).toFixed(2)} MiB.`); + log(`Boot shader families: ${selection.includedFamilies.map(x => x.family).join(', ') || 'none'}.`); + if (selection.omittedFamilies.length) log(`Deferred shader families: ${selection.omittedFamilies.map(x => x.family).join(', ')}.`); + + const bootSelectedPaths = new Set(descriptors.filter(x => x.category === 'shader').map(x => normalizePath(x.path))); + const mapShaderPacks = await buildMapShaderPacks(gameEntries, filesByRel, bootSelectedPaths, log); + + return { + ok: true, + manifestVersion: MANIFEST_VERSION, + records, + bytes, + shaderRecords, + shaderBytes, + fixedRecords: fixedFound.size, + discoveredShaderFiles: indexed.discoveredShaderFiles, + selectedShaderFiles: indexed.selectedShaderFiles, + deferredShaderFiles: indexed.omittedShaderFiles, + includedFamilies: selection.includedFamilies, + omittedFamilies: selection.omittedFamilies, + mapShaderPacks, + skippedHugeShaders: indexed.skippedHugeShaders, + found: descriptors.map(x => x.path) + }; + } + + async function hasOverlay() { + if (!('caches' in globalThis)) return false; + const cache = await caches.open(CACHE_NAME); + const response = await cache.match(new URL(OVERLAY_PATH, location.href).href); + if (!response || !response.ok) return false; + const version = response.headers.get('X-Render360-Boot-Manifest'); + const bytes = Number(response.headers.get('X-Render360-Boot-Bytes') || 0); + return version === MANIFEST_VERSION && bytes > 0 && bytes <= BOOTSTRAP_SHADER_BUDGET_BYTES; + } + + async function clear() { + if (!('caches' in globalThis)) return; + await Promise.all([ + caches.delete(CACHE_NAME), + caches.delete(MAP_SHADER_CACHE_NAME) + ]); + } + + globalThis.Render360PortalBootOverlay = { + CACHE_NAME, + MAP_SHADER_CACHE_NAME, + OVERLAY_PATH, + MANIFEST_VERSION, + BOOTSTRAP_SHADER_BUDGET_BYTES, + BOOT_ASSET_SUFFIXES, + FIRST_FRAME_SHADER_MANIFEST, + SHADER_RE, + shaderManifestEntry, + build, + hasOverlay, + clear + }; +})(); \ No newline at end of file diff --git a/emscripten/portal-local-vpk.js b/emscripten/portal-local-vpk.js new file mode 100644 index 0000000000..e6abd6c72f --- /dev/null +++ b/emscripten/portal-local-vpk.js @@ -0,0 +1,523 @@ +(() => { + '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; + const NATIVE_BINARY_RE = /\.(?:dll|dylib|exe|so)$/i; + + 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 (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; + 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) && !NATIVE_BINARY_RE.test(path)) queue.push(path); }; + + const resolveLooseReference = (value, preferredRoot) => { + const clean = normalizePath(value).replace(/^\/+/, ''); + 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); + 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) || NATIVE_BINARY_RE.test(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(/^\/+/, ''); + 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'); + 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) { + 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'); + 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) && !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; + 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 }; +})(); diff --git a/emscripten/post.js b/emscripten/post.js index 1582b1dfc0..349b79180b 100644 --- a/emscripten/post.js +++ b/emscripten/post.js @@ -12,5 +12,200 @@ addRunDependency('load_game_data') 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', 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 }) })(); + +// 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. 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' + + 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 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 + 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 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(!path || !file) continue + + const fullPath = '/' + path + 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) { + 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') + } + if(vpkPlaceholders <= 0) { + throw new Error('no Portal VPK names were exposed to the shared Source namespace') + } + + Module.render360Phase3StartupMemfsBytes = bytes + Module.render360Phase3StartupMemfsFiles = count + 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 => { + 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(DEPENDENCY) + 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. +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]', + 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 52aff49088..1adc48a2a1 100644 --- a/emscripten/pre.js +++ b/emscripten/pre.js @@ -1,9 +1,203 @@ +// 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 only the latest launch +// checkpoint so a process-kill reload reports where execution actually stopped. +const RENDER360_IOS_CRASH_STATE_KEY = 'render360-ios-crash-state-v2' +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 +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 && + render360Now - Number(render360PreviousState.updatedAt || 0) < 3 * 60 * 1000 +) + +// Deliberately flat: no nested previous-state history. Every checkpoint replaces +// the one before it. If Safari kills WebContent, phase is left at the last phase +// that actually ran, while interruption explains why this reload was blocked. +const render360CrashState = { + launchId: render360LaunchId, + active: !render360ProbableProcessReload, + blocked: render360ProbableProcessReload, + phase: render360ProbableProcessReload ? String(render360PreviousState?.phase || 'unknown') : 'runtime-script-start', + interruption: render360ProbableProcessReload ? 'probable-process-kill-reload' : null, + startedAt: render360ProbableProcessReload ? Number(render360PreviousState?.startedAt || render360Now) : render360Now, + updatedAt: render360Now, + wasmHeapBytes: render360ProbableProcessReload ? Number(render360PreviousState?.wasmHeapBytes || 0) : 0, + memfsBytes: render360ProbableProcessReload ? Number(render360PreviousState?.memfsBytes || 0) : 0, + memfsFiles: render360ProbableProcessReload ? Number(render360PreviousState?.memfsFiles || 0) : 0 +} + +// A deliberate fresh launch should not carry an error from an older attempt. +if(render360IsWindow && !render360ProbableProcessReload) { + try { localStorage.removeItem('render360-ios-last-error-v1') } catch(_) {} +} + +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 + } catch(_) {} + 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) + render360WriteCrashState(render360CrashState) +} + +function render360SetPhase(phase) { + render360CrashState.phase = String(phase || 'unknown') + render360CrashState.interruption = null + 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 + } +} + +// Worker-side phase changes matter most for PROXY_TO_PTHREAD. Relay them to +// the Window so localStorage still contains the latest 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.interruption = incoming.interruption || null + 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 retained phase is the last useful event. + Module['noInitialRun'] = true + setTimeout(() => { + const heap = Math.round(Number(render360CrashState.wasmHeapBytes || 0) / 1048576) + const memfs = Math.round(Number(render360CrashState.memfsBytes || 0) / 1048576) + const message = `[Render360 iOS guard] Safari restarted this launcher after a probable WebContent/GPU process kill. Last phase=${render360CrashState.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' + }, 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) +} + +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' + render360CrashState.interruption = null + 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 +// 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(() => { + render360SetPhase('prerun-liblauncher-ready') + Module.print?.('[Render360] load-time liblauncher preload requested') +}) + Module['arguments'] = Module['arguments'] || [] Module['arguments'].push( '-game', 'portal', '-noip', '-language', 'english', '-windowed', + '-novid', + '-nojoy', '+mat_hdr_level', '0', '+mat_colorcorrection', '1' ) @@ -29,23 +223,19 @@ 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}`) - } + if(index === -1) throw new Error(`no such map: ${mapName}`) + + await this.loadBootOverlay() - // 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) - } } async loadMapCached(mapName) { @@ -56,11 +246,14 @@ 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 = `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 = '' @@ -68,55 +261,169 @@ class DataLoader { } } - async loadMap(mapName) { - this.setProgress(mapName, 0) + installOwnedFile(path, blob) { + if(/\.(?:dll|dylib|exe|so)$/i.test(path)) { + Module.printErr?.(`[Render360] ignored native binary from game-data chunk: ${path}`) + return + } - let resolve, reject - const promise = new Promise((res, rej) => { resolve = res; reject = rej }) + 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(_) {} - const xhr = new XMLHttpRequest() - xhr.responseType = 'arraybuffer' - xhr.onprogress = e => { - this.setProgress(mapName, e.loaded / e.total) + if(typeof FS.createDataFile === 'function') { + FS.createDataFile(parent, name, blob, true, true, true) + } else { + FS.writeFile(path, blob) } - xhr.onerror = () => { - reject(new Error(`cannot load map ${mapName}`)) + 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) { + 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 + 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 || 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(dataLen) + blob.set(new Uint8Array(dv.buffer, offset + 8 + pathLen, dataLen)) + offset = recordEnd + fileCount++ + this.installOwnedFile(path, blob) } + return { fileCount, byteLength: dv.byteLength } + } - 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) + async streamDataResponse(response, label, onProgress) { + 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)) + } + + 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 + } - resolve() + 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++ + + if((fileCount & 31) === 0) await new Promise(resolve => setTimeout(resolve, 0)) + } + } finally { + try { reader.releaseLock() } catch(_) {} } - xhr.open('GET', `chunks/${mapName}.data`, true) - xhr.send() - return promise + if(onProgress) onProgress(1) + return { fileCount, byteLength: consumed } + } + + async loadBootOverlay() { + if(this.bootOverlayPromise) return this.bootOverlayPromise + this.bootOverlayPromise = (async () => { + try { + 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 result = await this.streamDataResponse(response, 'boot overlay') + 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}`) + } + })() + return this.bootOverlayPromise + } + + async loadMap(mapName) { + this.setProgress(mapName, 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) + 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}`) + throw error + } } } @@ -126,5 +433,9 @@ 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}`) + Atomics.store(HEAP32, lock, 0) + Atomics.notify(HEAP32, lock) }) } \ No newline at end of file diff --git a/emscripten/render360-pages-sw.js b/emscripten/render360-pages-sw.js new file mode 100644 index 0000000000..508b6ad1be --- /dev/null +++ b/emscripten/render360-pages-sw.js @@ -0,0 +1,279 @@ +/* Render360 Portal staging service worker. + * + * GitHub Pages cannot set COOP/COEP response headers itself. This worker + * injects them after the first controlled reload so the upstream pthread / + * SharedArrayBuffer runtime can be exercised on static hosting. + * + * Portal's Source runtime still requests the original same-origin + * `chunks/.data` path. Keep the original web port's packed data as the + * authoritative first choice. A locally generated VPK chunk is the fallback. + * + * Phase 2 keeps the first-frame boot overlay under a strict memory budget and + * stores later shader families as map-scoped delta packs. When Source requests + * chunks/.data this worker appends that map's shader records as a + * backpressure-aware stream. The browser never needs to materialize the whole + * map chunk merely to attach a small shader pack. + * + * No retail game data is committed to GitHub Pages. + */ + +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-v3'; +const OLD_BOOT_OVERLAY_CACHES = [ + 'render360-portal-boot-overlay-v2', + 'render360-portal-boot-overlay-v1' +]; +const MAP_SHADER_CACHE = 'render360-portal-map-shaders-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 = false; +let upstreamUnavailableUntil = 0; + +self.addEventListener('install', event => { + self.skipWaiting(); +}); + +self.addEventListener('activate', event => { + event.waitUntil((async () => { + await Promise.all([ + caches.delete(OLD_LOCAL_CHUNK_CACHE), + ...OLD_BOOT_OVERLAY_CACHES.map(name => caches.delete(name)) + ]); + 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(); + })()); +}); + +self.addEventListener('message', event => { + if (!event.data) return; + if (event.data.type === 'RENDER360_CLEAR_LOCAL_CHUNKS') { + event.waitUntil(Promise.all([ + caches.delete(LOCAL_CHUNK_CACHE), + 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(MAP_SHADER_CACHE), + ...OLD_BOOT_OVERLAY_CACHES.map(name => caches.delete(name)) + ])); + } +}); + +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; + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: isolationHeaders(response.headers, 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); + } +} + +function concatenateResponseBodies(responses) { + const bodies = responses.map(response => response?.body).filter(Boolean); + let bodyIndex = 0; + let reader = null; + + return new ReadableStream({ + async pull(controller) { + while (bodyIndex < bodies.length) { + if (!reader) reader = bodies[bodyIndex].getReader(); + const next = await reader.read(); + if (next.done) { + try { reader.releaseLock(); } catch (_) {} + reader = null; + bodyIndex++; + continue; + } + controller.enqueue(next.value); + return; + } + controller.close(); + }, + async cancel(reason) { + if (reader) { + try { await reader.cancel(reason); } catch (_) {} + } + for (let i = bodyIndex + (reader ? 1 : 0); i < bodies.length; i++) { + try { await bodies[i].cancel(reason); } catch (_) {} + } + } + }); +} + +async function appendMapShaderPack(baseResponse, chunkName, source) { + if (!baseResponse || !baseResponse.ok) return baseResponse; + const cache = await caches.open(MAP_SHADER_CACHE); + const packUrl = new URL(`./shader-packs/${chunkName}`, self.location.href).href; + const pack = await cache.match(packUrl, { ignoreSearch: true }); + + const headers = isolationHeaders(baseResponse.headers, { + 'Cache-Control': 'no-store, max-age=0', + 'X-Render360-Chunk-Source': source + }); + headers.delete('Content-Length'); + headers.delete('Content-Encoding'); + headers.delete('ETag'); + + if (!pack || !pack.ok || !pack.body) { + return new Response(baseResponse.body, { + status: baseResponse.status, + statusText: baseResponse.statusText, + headers + }); + } + + headers.set('X-Render360-Map-Shader-Pack', 'map-v1'); + headers.set('X-Render360-Shader-Records', pack.headers.get('X-Render360-Shader-Records') || '0'); + headers.set('X-Render360-Shader-Bytes', pack.headers.get('X-Render360-Shader-Bytes') || '0'); + const families = pack.headers.get('X-Render360-Shader-Families'); + if (families) headers.set('X-Render360-Shader-Families', families); + + return new Response(concatenateResponseBodies([baseResponse, pack]), { + status: baseResponse.status, + statusText: baseResponse.statusText, + headers + }); +} + +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); + + if (Date.now() >= upstreamUnavailableUntil) { + try { + const upstream = await fetchUpstreamChunk(upstreamUrl); + if (!upstream.ok) throw new Error(`HTTP ${upstream.status}`); + upstreamUnavailableUntil = 0; + return appendMapShaderPack(upstream, name, '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); + } + } + + const local = await cache.match(request, { ignoreSearch: true }); + if (local && local.ok) { + return appendMapShaderPack(local, name, 'local-vpk'); + } + + 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() { + 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) { + return withIsolationHeaders(new Response('Render360 Phase 2 first-frame boot/shader 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-manifest' + }); +} + +async function fetchRuntimeFresh(request) { + 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' + }); +} + +async function fetchLauncherDataDirect(request) { + 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; + + const url = new URL(request.url); + const sameOrigin = url.origin === self.location.origin; + + if (!sameOrigin) { + event.respondWith(fetch(request)); + return; + } + + 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' && url.pathname.endsWith('/hl2_launcher.data')) { + return fetchLauncherDataDirect(request); + } + if (request.method === 'GET' && MUTABLE_RUNTIME_RE.test(url.pathname)) { + return fetchRuntimeFresh(request); + } + 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), { + status: 502, + headers: { + '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', + 'Cache-Control': 'no-store, max-age=0' + } + }); + })); +}); \ No newline at end of file diff --git a/emscripten/shell.html b/emscripten/shell.html index 9ed3bcca24..6eab411744 100644 --- a/emscripten/shell.html +++ b/emscripten/shell.html @@ -3,18 +3,40 @@ - yikes! + Render360 Portal upstream baseline - + @@ -23,9 +45,7 @@
Downloading...
- - +
@@ -35,40 +55,166 @@
- + +
+ + Only the latest runtime event is retained +
{{{ SCRIPT }}} - + \ No newline at end of file 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") 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', diff --git a/ios-native/CMakeLists.txt b/ios-native/CMakeLists.txt new file mode 100644 index 0000000000..25dd8922e2 --- /dev/null +++ b/ios-native/CMakeLists.txt @@ -0,0 +1,100 @@ +cmake_minimum_required(VERSION 3.25) +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") +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() + +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 + "CMAKE_OSX_DEPLOYMENT_TARGET (${CMAKE_OSX_DEPLOYMENT_TARGET}) must match RENDER360_DEPLOYMENT_TARGET (${RENDER360_DEPLOYMENT_TARGET})") +endif() + +include(cmake/SDL2Pinned.cmake) +include(cmake/SourceFoundation.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 +) + +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}" +) + +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 N2 target: sdk=${CMAKE_OSX_SYSROOT} arch=${CMAKE_OSX_ARCHITECTURES} deployment=${RENDER360_DEPLOYMENT_TARGET} bundle=${RENDER360_BUNDLE_ID}") diff --git a/ios-native/Info.plist b/ios-native/Info.plist new file mode 100644 index 0000000000..f23239d42b --- /dev/null +++ b/ios-native/Info.plist @@ -0,0 +1,13 @@ + + + +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/README.md b/ios-native/README.md new file mode 100644 index 0000000000..c839c6e0f4 --- /dev/null +++ b/ios-native/README.md @@ -0,0 +1,119 @@ +# 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. + +## 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. + +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 +``` + +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`. diff --git a/ios-native/SourceCompat/R360SourceIOSPlatform.h b/ios-native/SourceCompat/R360SourceIOSPlatform.h new file mode 100644 index 0000000000..b5d7932fea --- /dev/null +++ b/ios-native/SourceCompat/R360SourceIOSPlatform.h @@ -0,0 +1,45 @@ +#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 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. + * Desktop build scripts normally provide GNUC; the native CMake graph must + * provide the same semantic switch explicitly for AppleClang. + */ +#ifndef POSIX +#define POSIX 1 +#endif + +#ifndef OSX +#define OSX 1 +#endif + +#ifndef _OSX +#define _OSX 1 +#endif + +#ifndef GNUC +#define GNUC 1 +#endif + +#define RENDER360_SOURCE_IOS 1 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..019429764c --- /dev/null +++ b/ios-native/Sources/R360BootstrapViewController.mm @@ -0,0 +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];} +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; +@end +@implementation R360BootstrapViewController +- (void)viewDidLoad { + [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 new file mode 100644 index 0000000000..f71ea7b3d1 --- /dev/null +++ b/ios-native/Sources/R360Diagnostics.h @@ -0,0 +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 new file mode 100644 index 0000000000..97db3ffad5 --- /dev/null +++ b/ios-native/Sources/R360Diagnostics.mm @@ -0,0 +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 *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"; +#elif defined(__x86_64__) + return @"x86_64"; +#else + return @"unknown"; +#endif +} +- (NSString *)formattedSummary { + 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..0f96000c81 --- /dev/null +++ b/ios-native/Sources/R360LifecycleService.h @@ -0,0 +1,18 @@ +#import +NS_ASSUME_NONNULL_BEGIN +@protocol R360LifecycleServiceDelegate +- (void)r360WillResignActive; +- (void)r360DidBecomeActive; +- (void)r360DidEnterBackground; +- (void)r360WillEnterForeground; +- (void)r360AudioInterruptionBegan; +- (void)r360AudioInterruptionEndedShouldResume:(BOOL)shouldResume; +- (void)r360OrientationDidChange; +- (void)r360WillTerminate; +@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..9369437570 --- /dev/null +++ b/ios-native/Sources/R360LifecycleService.mm @@ -0,0 +1,48 @@ +#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]; + [nc addObserver:self selector:@selector(willTerminate:) name:UIApplicationWillTerminateNotification object:nil]; + [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]; + } +} +- (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/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/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..f641dfe6df --- /dev/null +++ b/ios-native/Sources/R360SDLHost.mm @@ -0,0 +1,131 @@ +#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; +- (void)performFrame; +- (void)cleanupHost; +@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()]; + [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; + [self cleanupHost]; + return NO; + } + self.input = [R360SDLInputDiagnostics new]; + [self.input openConnectedControllers]; + self.audio = [R360SDLAudioHost new]; + if (![self.audio start:&localError]) { + 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; +} + +- (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)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]; } +- (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.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..48def58872 --- /dev/null +++ b/ios-native/Sources/R360SDLInputDiagnostics.mm @@ -0,0 +1,77 @@ +#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); + 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 { + 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:[NSString stringWithFormat:@"controller disconnected: instance %d", (int)event->cdevice.which]]; + 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(); + self.activeTouches = 0; +} +@end diff --git a/ios-native/Sources/main.mm b/ios-native/Sources/main.mm new file mode 100644 index 0000000000..51c6db1d5b --- /dev/null +++ b/ios-native/Sources/main.mm @@ -0,0 +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 { + (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 { (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)); } } 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}") diff --git a/ios-native/cmake/SourceFoundation.cmake b/ios-native/cmake/SourceFoundation.cmake new file mode 100644 index 0000000000..95d3d91eb9 --- /dev/null +++ b/ios-native/cmake/SourceFoundation.cmake @@ -0,0 +1,121 @@ +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 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" + "${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" +) + +# 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/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) +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 +) diff --git a/launcher_main/render360_browser_files.cpp b/launcher_main/render360_browser_files.cpp new file mode 100644 index 0000000000..827fda5072 --- /dev/null +++ b/launcher_main/render360_browser_files.cpp @@ -0,0 +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 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 + +extern "C" EMSCRIPTEN_KEEPALIVE int render360_browser_file_open(const char *pathPtr) +{ + 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 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 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) +{ + 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 *pathPtr) +{ + 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__ 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" diff --git a/launcher_main/wscript b/launcher_main/wscript index 32985f5bcb..69c26b346d 100755 --- a/launcher_main/wscript +++ b/launcher_main/wscript @@ -32,6 +32,10 @@ def build(bld): install_path = bld.env.BINDIR if bld.env.DEST_OS == 'wasm': + # 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, 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) 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];