diff --git a/.gitattributes b/.gitattributes index ef236cfae..6cdc2e687 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,6 +3,12 @@ # Enforce LF for vcpkg patch files, as patch.exe on Windows fails with CRLF line endings *.patch text eol=lf +# Empty context lines in unified diffs must retain their leading space. +bridge/renpy_runtime/mobile_launcher/patches/**/*.patch whitespace=-blank-at-eol,-space-before-tab +vcpkg/ports/libiconv/*.patch whitespace=-blank-at-eol,-blank-at-eof,-space-before-tab +vcpkg/ports/openssl/**/*.patch whitespace=-blank-at-eol,-blank-at-eof,-space-before-tab +# Retain the fixed upstream Windows recipe byte-for-byte. +vcpkg/ports/openssl/windows/portfile.cmake whitespace=-blank-at-eol # Product game archives are binary assets. *.xp3 -text diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c06fb4322..d2ea50a88 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -50,7 +50,9 @@ permissions: concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}-${{ github.event_name }} - cancel-in-progress: true + # Finish actual source/app builds even when a probe-only follow-up arrives. + # The pending run advances to the latest checked implementation. + cancel-in-progress: false env: # GitHub does not expose repository secrets to fork or Dependabot pull @@ -70,15 +72,24 @@ env: AETHER_APP_BUNDLE_ID: ${{ secrets.AETHERID || 'com.liuyu.aether.aether' }} jobs: + renpy-android-runtime: + uses: ./.github/workflows/renpy-mobile-runtime-build.yml + + renpy-ios-runtime: + uses: ./.github/workflows/renpy-ios-runtime-build.yml + android: name: Build Android App - runs-on: ubuntu-latest + needs: renpy-android-runtime + runs-on: ubuntu-24.04 env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' CCACHE_DIR: ${{ github.workspace }}/.ccache GODOT_RELEASE: 4.7.2-stable GODOT_TEMPLATE_VERSION: 4.7.2.stable ANDROID_NDK_VERSION: 28.1.13356709 + # Package the fork built from this exact checkout, with its Python data. + AETHERKIRI_ENABLE_RENPY: ON steps: - name: Checkout Repository @@ -117,7 +128,7 @@ jobs: - name: Initialize private runtime dependencies if: env.AETHERKIRI_WITH_INTERNAL == 'true' - run: packages/AetherInternal/tools/prepare_ci_dependencies.sh --platform android + run: bash packages/AetherInternal/tools/prepare_ci_dependencies.sh --platform android - name: Initialize public runtime packages if: env.AETHERKIRI_WITH_INTERNAL != 'true' @@ -150,9 +161,34 @@ jobs: - name: Install Build Systems run: | sudo apt-get update - sudo apt-get install -y cmake ninja-build pkg-config autoconf autoconf-archive automake libtool gettext bison flex curl unzip zip zstd ccache jq mono-complete + sudo apt-get install -y cmake ninja-build pkg-config autoconf autoconf-archive automake libtool gettext bison flex nasm curl unzip zip zstd ccache jq mono-complete rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android i686-linux-android + - name: Use Python matching the rebuilt runtime's bytecode + uses: actions/setup-python@v6 + with: + python-version: '3.12' + + - name: Download the rebuilt Ren'Py Android runtime + uses: actions/download-artifact@v6 + with: + name: renpy-android-runtime + path: ${{ runner.temp }}/renpy-runtime-download + + - name: Stage and validate the rebuilt Ren'Py Android payload + run: | + set -euo pipefail + mobile_root="${RUNNER_TEMP}/aetherkiri-renpy-mobile/android" + mkdir -p "$mobile_root" + tar -xzf "${RUNNER_TEMP}/renpy-runtime-download/renpy-android-runtime.tar.gz" -C "$mobile_root" + mobile_root="$mobile_root/payload" + test -f "$mobile_root/rapt/prototype/renpyandroid/src/main/jniLibs/arm64-v8a/librenpython.so" + python3 tools/validate_renpy_mobile_payload.py --platform android \ + --library "$mobile_root/rapt/prototype/renpyandroid/src/main/jniLibs/arm64-v8a/librenpython.so" \ + --private-root "$mobile_root/rapt/runtime/private" + echo "RENPY_MOBILE_ROOT=$mobile_root" >> "$GITHUB_ENV" + echo "AETHERKIRI_RENPY_ANDROID_PRIVATE_ASSETS=$mobile_root/rapt/runtime/private" >> "$GITHUB_ENV" + - name: Restore ccache id: restore-ccache uses: actions/cache/restore@v6 @@ -328,7 +364,7 @@ jobs: export CMAKE_C_COMPILER_LAUNCHER=ccache export CMAKE_CXX_COMPILER_LAUNCHER=ccache export NDK_CCACHE=ccache - ./build.sh android release --jobs=4 + ./build.sh android release --jobs=4 2>&1 | tee "$RUNNER_TEMP/renpy-android-release-build.log" if [[ "$AETHERKIRI_WITH_INTERNAL" == "true" ]]; then grep -Fx 'AETHERKIRI_ENABLE_SOFTPAL_RUNTIME:BOOL=ON' \ out/android/arm64-v8a/release/CMakeCache.txt @@ -343,7 +379,17 @@ jobs: export CMAKE_C_COMPILER_LAUNCHER=ccache export CMAKE_CXX_COMPILER_LAUNCHER=ccache export NDK_CCACHE=ccache - ./build.sh android debug --jobs=4 + ./build.sh android debug --jobs=4 2>&1 | tee "$RUNNER_TEMP/renpy-android-debug-build.log" + + - name: Verify the compiled provider and final APK runtime payload + run: | + set -euo pipefail + grep -F 'renpy_runtime_mobile_provider.cpp' \ + out/android/arm64-v8a/debug/compile_commands.json + grep -F 'renpy_mobile_adapter.cpp' \ + out/android/arm64-v8a/debug/compile_commands.json + python3 tools/validate_renpy_mobile_payload.py --platform android \ + --apk out/godot/android/debug/Aether-debug.apk - name: Verify exported APKs run: | @@ -358,6 +404,85 @@ jobs: name: android-release-apk path: out/godot/android/release/Aether-release.apk retention-days: 7 + - name: Upload installable Debug APK for device acceptance + uses: actions/upload-artifact@v6 + with: + name: android-renpy-debug-apk + path: out/godot/android/debug/Aether-debug.apk + if-no-files-found: error + retention-days: 7 + + - name: Build the public Ren'Py x86_64 Debug app for cloud emulator gameplay + env: + # The official E-mote package has no x86_64 Android slice. This + # Ren'Py-only emulator uses the existing isolated public test profile. + AETHERKIRI_ENABLE_INTERNAL: 'OFF' + AETHERKIRI_ENABLE_SOFTPAL_RUNTIME: 'OFF' + AETHERKIRI_WITH_INTERNAL: 'false' + AETHERKIRI_ANDROID_TEST_PACKAGE_ID: org.aetherkiri.renpy.debug + run: | + set -euo pipefail + export CMAKE_C_COMPILER_LAUNCHER=ccache + export CMAKE_CXX_COMPILER_LAUNCHER=ccache + export NDK_CCACHE=ccache + bash scripts/build_android.sh debug --abi=x86_64 2>&1 | tee "$RUNNER_TEMP/renpy-android-emulator-build.log" + python3 tools/validate_renpy_mobile_payload.py --platform android --abi x86_64 \ + --apk out/godot/android/x86_64/debug/Aether-emulator-debug.apk + + - name: Upload the actual cloud emulator APK + uses: actions/upload-artifact@v6 + with: + name: android-renpy-emulator-debug-apk + path: out/godot/android/x86_64/debug/Aether-emulator-debug.apk + if-no-files-found: error + retention-days: 7 + + - name: Record any produced APK even when its build or payload gate failed + if: always() + run: | + python3 - <<'PY' + import hashlib, json, pathlib, subprocess, zipfile + root = pathlib.Path('out/godot/android') + for apk in root.rglob('*.apk'): + output = apk.parent / 'diagnostics' + output.mkdir(parents=True, exist_ok=True) + result = { + 'artifact_kind': 'diagnostic_apk', 'gameplay': 'unverified', + 'ci_job_status': '${{ job.status }}', 'apk': str(apk), + } + digest = hashlib.sha256() + with apk.open('rb') as stream: + for block in iter(lambda: stream.read(1024 * 1024), b''): + digest.update(block) + result['apk_sha256'] = digest.hexdigest() + try: + with zipfile.ZipFile(apk) as archive: + (output / 'apk-contents.txt').write_text('\n'.join(sorted(archive.namelist())) + '\n') + except zipfile.BadZipFile as error: + result['zip_error'] = str(error) + # Keep the checksums captured against this APK's own stage. A + # subsequent x86_64 export replaces the current arm64 stage. + if not (output / 'apk-payload-checksums.json').exists(): + abi = 'x86_64' if 'x86_64' in apk.parts else 'arm64-v8a' + subprocess.run(['python3', 'tools/inspect_renpy_android_apk.py', + '--apk', str(apk), '--abi', abi, + '--godot-build', 'apps/godot_app/android/build', + '--output-dir', str(output)], check=False) + (output / 'ci-build-result.json').write_text(json.dumps(result, indent=2) + '\n') + PY + + - name: Preserve Android application diagnostics, including failed APKs + if: always() + uses: actions/upload-artifact@v6 + with: + name: android-app-build-diagnostics + path: | + ${{ runner.temp }}/renpy-android-*-build.log + out/godot/android/**/*.apk + out/godot/android/**/diagnostics/** + apps/godot_app/android/build/aether-renpy.gradle + if-no-files-found: warn + retention-days: 7 - name: Save ccache if: success() && github.event_name != 'pull_request' && steps.restore-ccache.outputs.cache-hit != 'true' @@ -366,14 +491,56 @@ jobs: path: .ccache key: ${{ runner.os }}-android-ccache-v2 + android-renpy-gameplay: + needs: android + runs-on: macos-15-intel + timeout-minutes: 40 + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: actions/download-artifact@v6 + with: + name: android-renpy-emulator-debug-apk + path: ${{ runner.temp }}/renpy-apk + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: '21' + - name: Run actual APK gameplay with the existing Intel HVF accelerator + run: | + set -euo pipefail + python3 tools/run_renpy_android_emulator_session.py --cloud-emulator \ + --apk "${RUNNER_TEMP}/renpy-apk/Aether-emulator-debug.apk" \ + --package org.aetherkiri.renpy.debug --boot-timeout 480 \ + --gameplay-timeout 900 --stage-timeout 90 \ + --output-dir "${RUNNER_TEMP}/renpy-android-device-evidence" + - name: Preserve real device evidence even on failure + if: always() + uses: actions/upload-artifact@v6 + with: + name: renpy-android-gameplay-evidence + path: ${{ runner.temp }}/renpy-android-device-evidence + if-no-files-found: warn + retention-days: 7 + ios: name: Build iOS App + needs: renpy-ios-runtime runs-on: macos-latest env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' CCACHE_DIR: ${{ github.workspace }}/.ccache GODOT_RELEASE: 4.7.2-stable GODOT_TEMPLATE_VERSION: 4.7.2.stable + # iOS requires the same fork compiled with legally supplied Apple SDKs. + AETHERKIRI_ENABLE_RENPY: ON + AETHERKIRI_RENPY_RENIOS_LINK: ON steps: - name: Checkout Repository @@ -411,7 +578,7 @@ jobs: - name: Initialize private runtime dependencies if: env.AETHERKIRI_WITH_INTERNAL == 'true' - run: packages/AetherInternal/tools/prepare_ci_dependencies.sh --platform apple + run: bash packages/AetherInternal/tools/prepare_ci_dependencies.sh --platform apple - name: Initialize public runtime packages if: env.AETHERKIRI_WITH_INTERNAL != 'true' @@ -479,6 +646,63 @@ jobs: echo "$(brew --prefix bison)/bin" >> $GITHUB_PATH rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios + - name: Compile the lifecycle and provider for the real Xcode iOS target + run: | + set -euo pipefail + # Use the SDK already licensed/provisioned on this cloud macOS runner; + # no SDK archive, credential, or user-computer access is transferred. + brew install python@3.12 + echo "$(brew --prefix python@3.12)/libexec/bin" >> "$GITHUB_PATH" + python_config="$(brew --prefix python@3.12)/bin/python3.12-config" + ios_sdk="$(xcrun --sdk iphoneos --show-sdk-path)" + evidence_dir="${RUNNER_TEMP}/renpy-ios-compile" + mkdir -p "$evidence_dir" + xcrun --sdk iphoneos clang -target arm64-apple-ios14.0 \ + -isysroot "$ios_sdk" -std=c11 -Wall -Wextra -Werror \ + $("$python_config" --includes) \ + -I bridge/renpy_runtime/mobile_launcher/include \ + -c bridge/renpy_runtime/mobile_launcher/patches/native/renpy_mobile_lifecycle.c \ + -o "$evidence_dir/renpy_mobile_lifecycle.o" + for source in renpy_runtime_mobile_provider renpy_runtime_ios_adapter; do + xcrun --sdk iphoneos clang++ -target arm64-apple-ios14.0 \ + -isysroot "$ios_sdk" -std=c++17 -Wall -Wextra -Werror \ + -DAETHERKIRI_RENPY_IOS=1 -I abi/include \ + -I bridge/renpy_runtime/include -I bridge/renpy_runtime/mobile_launcher/include \ + -c "bridge/renpy_runtime/src/$source.cpp" -o "$evidence_dir/$source.o" + done + for object in "$evidence_dir"/*.o; do + xcrun lipo "$object" -verify_arch arm64 + done + echo "iOS target objects compiled using the Python 3.12 C API; no target CPython link or gameplay has run." + + - name: Preserve iOS target compile evidence + if: always() + uses: actions/upload-artifact@v6 + with: + name: renpy-ios-target-objects + path: ${{ runner.temp }}/renpy-ios-compile/*.o + if-no-files-found: warn + retention-days: 7 + + - name: Download the rebuilt Ren'Py iOS runtime + uses: actions/download-artifact@v6 + with: + name: renpy-ios-runtime + path: ${{ runner.temp }}/renpy-ios-runtime + + - name: Stage rebuilt Ren'Py iOS runtime + run: | + set -euo pipefail + mobile_root="${RUNNER_TEMP}/aetherkiri-renpy-mobile/ios" + mkdir -p "$mobile_root" + tar -xzf "${RUNNER_TEMP}/renpy-ios-runtime/renpy-ios-runtime.tar.gz" -C "$mobile_root" + mobile_root="$mobile_root/payload" + test -f "$mobile_root/renios/prototype/prebuilt/release/librenpython.a" + python3 tools/validate_renpy_mobile_payload.py --platform ios --arch arm64 --sdk iphoneos \ + --library "$mobile_root/renios/prototype/prebuilt/release/librenpython.a" \ + --private-root "$mobile_root/renios/prototype/base" + echo "RENPY_MOBILE_ROOT=$mobile_root" >> "$GITHUB_ENV" + - name: Install Godot export tools run: | set -euo pipefail @@ -629,6 +853,14 @@ jobs: exit 1 fi + - name: Verify iOS Ren'Py lifecycle provider was compiled + run: | + set -euo pipefail + grep -F 'renpy_runtime_mobile_provider.cpp' \ + out/ios/debug/compile_commands.json + grep -F 'renpy_runtime_ios_adapter.cpp' \ + out/ios/debug/compile_commands.json + - name: Verify exported Xcode project and IPA run: | set -euo pipefail @@ -646,6 +878,13 @@ jobs: echo "Godot iOS ${build_type} unsigned IPA not found" >&2 exit 1 fi + test -f "out/godot/ios/${build_type}/Aether/renios/renios-manifest.txt" + test -f "out/godot/ios/${build_type}/Aether/Frameworks/MetalANGLE.xcframework/Info.plist" + if [[ -e "out/godot/ios/${build_type}/Aether/renios/main.c" || + -e "out/godot/ios/${build_type}/Aether/renios/prototype.xcodeproj" ]]; then + echo "Renios bundle unexpectedly includes a prototype entrypoint/project" >&2 + exit 1 + fi done - name: Upload Release Unsigned IPA @@ -726,7 +965,7 @@ jobs: - name: Initialize private runtime dependencies if: env.AETHERKIRI_WITH_INTERNAL == 'true' - run: packages/AetherInternal/tools/prepare_ci_dependencies.sh --platform macos + run: bash packages/AetherInternal/tools/prepare_ci_dependencies.sh --platform macos - name: Initialize public runtime packages if: env.AETHERKIRI_WITH_INTERNAL != 'true' @@ -785,6 +1024,33 @@ jobs: echo "$(brew --prefix bison)/bin" >> $GITHUB_PATH rustup target add aarch64-apple-darwin + - name: Validate Ren'Py SDK installer + run: bash tools/test_install_renpy_sdk.sh + + - name: Validate Ren'Py mobile support staging + run: bash tools/test_install_renpy_mobile_support.sh + + - name: Validate Ren'Py iOS link and bundle boundary + run: bash tools/test_renios_ios_bundle.sh + + - name: Validate Ren'Py mobile native source-build plan + run: bash tools/test_renpy_mobile_source_build_plan.sh + + - name: Install pinned Ren'Py SDK + run: | + set -euo pipefail + sdk_root="${RUNNER_TEMP}/aetherkiri-renpy-sdk" + ./tools/install_renpy_sdk.sh --platform darwin --arch arm64 --destination "$sdk_root" + { + echo "AETHERKIRI_ENABLE_RENPY=ON" + echo "AETHERKIRI_RENPY_SDK_ROOT=$sdk_root" + echo "RENPY_SDK=$sdk_root" + } >> "$GITHUB_ENV" + test -x "$sdk_root/renpy.sh" + + - name: Run Ren'Py SDK smoke fixture + run: bash tools/run_renpy_smoke.sh + - name: Install Godot export tools run: | set -euo pipefail @@ -890,15 +1156,27 @@ jobs: --path apps/godot_app \ --script res://scripts/smoke_test.gd + - name: Run Ren'Py Godot gameplay E2E + env: + AETHERKIRI_RENPY_FIXTURE: ${{ github.workspace }}/tests/fixtures/renpy_smoke + run: | + set -euo pipefail + chmod +x tools/run_renpy_godot_e2e.sh + tools/run_renpy_godot_e2e.sh + - name: Run complete public and integrated private unit tests run: | set -euo pipefail test_build_dir="${GITHUB_WORKSPACE}/out/macos/ci-tests" configure_log="${RUNNER_TEMP}/aetherkiri-test-configure.log" + test -n "${AETHERKIRI_RENPY_SDK_ROOT:-}" + test -d "$AETHERKIRI_RENPY_SDK_ROOT" cmake --preset "MacOS Debug Config" --fresh \ -B "$test_build_dir" \ -D ENABLE_TESTS=ON \ + -D AETHERKIRI_ENABLE_RENPY=ON \ + -D "AETHERKIRI_RENPY_SDK_ROOT=$AETHERKIRI_RENPY_SDK_ROOT" \ -D BUILD_TOOLS=ON \ -D BUILD_ENGINE_API=OFF \ -D BUILD_GODOT_EXTENSION=OFF \ @@ -915,7 +1193,10 @@ jobs: fi cmake --build "$test_build_dir" --parallel 4 - ctest --test-dir "$test_build_dir" --show-only=human + test_listing="${RUNNER_TEMP}/aetherkiri-test-listing.txt" + ctest --test-dir "$test_build_dir" --show-only=human | tee "$test_listing" + grep -F "renpy-provider" "$test_listing" + grep -F "renpy-input-mapping" "$test_listing" ctest \ --test-dir "$test_build_dir" \ --output-on-failure \ diff --git a/.github/workflows/renpy-android-apk-audit.yml b/.github/workflows/renpy-android-apk-audit.yml new file mode 100644 index 000000000..61d64d8f8 --- /dev/null +++ b/.github/workflows/renpy-android-apk-audit.yml @@ -0,0 +1,207 @@ +name: Audit the actual exported Ren'Py APKs + +# These exact APKs were genuinely linked/exported at b330d418. Their first +# audit failed on the aapt2 minSdkVersion format. Recheck the original bytes +# without pretending that this job compiles the host, or republishes raw logs. +on: + pull_request: + paths: + - '.github/workflows/renpy-android-apk-audit.yml' + - 'tools/inspect_renpy_android_apk.py' + - 'tools/validate_renpy_mobile_payload.py' + workflow_dispatch: + +permissions: + contents: read + actions: read + +jobs: + audit: + name: Audit the actual Debug APK (${{ matrix.abi }}) + runs-on: ubuntu-24.04 + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - abi: arm64-v8a + preset: Android Debug + artifact: '11569036023' + digest: 'sha256:7364686c88b8c02bd43826469f42014353570ae75f3246b7fc2211015fc56c74' + - abi: x86_64 + preset: Android Emulator Debug + artifact: '11570702817' + digest: 'sha256:444ce5528b42fe29ed84a91ea29726ccf83a380dc3d71b6463bba8a12408b211' + env: + SOURCE_RUN: '37817371584' + SOURCE_HOST_HEAD: 'b330d41813cc712328d762e1f7a75f99f1d59882' + NATIVE_RUN: '37812943810' + NATIVE_ARTIFACT: '11567113193' + NATIVE_HEAD: 'dbb863ac101301c8a9d78dea358e2157a0185165' + NATIVE_DIGEST: 'sha256:04a1f247585b8f4647e6dcd8c4102757f558ee542524df1504267b40f0a4239c' + ANDROID_NDK_VERSION: '28.1.13356709' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: actions/checkout@v6 + with: + ref: b330d41813cc712328d762e1f7a75f99f1d59882 + path: original-host + persist-credentials: false + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - name: Verify immutable artifact identities and exact prior failure + env: + GH_TOKEN: ${{ github.token }} + APK_ARTIFACT: ${{ matrix.artifact }} + APK_DIGEST: ${{ matrix.digest }} + ABI: ${{ matrix.abi }} + run: | + python3 - <<'PY' + import json, os, urllib.request + def get(path): + request = urllib.request.Request('https://api.github.com/repos/AetherKiri/Aether/' + path, + headers={'Authorization': 'Bearer ' + os.environ['GH_TOKEN'], + 'Accept': 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28'}) + with urllib.request.urlopen(request, timeout=60) as response: + return json.load(response) + for run_key, head_key, conclusion in [('SOURCE_RUN', 'SOURCE_HOST_HEAD', 'failure'), + ('NATIVE_RUN', 'NATIVE_HEAD', 'success')]: + run = get('actions/runs/' + os.environ[run_key]) + assert run['head_sha'] == os.environ[head_key] + assert run['status'] == 'completed' and run['conclusion'] == conclusion + for artifact_key, digest_key, run_key, name in [ + ('APK_ARTIFACT', 'APK_DIGEST', 'SOURCE_RUN', 'renpy-android-app-evidence-' + os.environ['ABI']), + ('NATIVE_ARTIFACT', 'NATIVE_DIGEST', 'NATIVE_RUN', 'renpy-android-runtime')]: + artifact = get('actions/artifacts/' + os.environ[artifact_key]) + assert not artifact['expired'] and artifact['name'] == name + assert artifact['workflow_run']['id'] == int(os.environ[run_key]) + assert artifact['digest'] == os.environ[digest_key] + PY + - uses: actions/download-artifact@v6 + with: + artifact-ids: ${{ matrix.artifact }} + run-id: 37817371584 + github-token: ${{ github.token }} + merge-multiple: true + path: ${{ runner.temp }}/original-build-evidence + - uses: actions/download-artifact@v6 + with: + artifact-ids: 11567113193 + run-id: 37812943810 + github-token: ${{ github.token }} + merge-multiple: true + path: ${{ runner.temp }}/original-native-download + - name: Use the normal SDK with its existing license acceptance + run: | + set -euo pipefail + sdk_root="${ANDROID_HOME:-/usr/local/lib/android/sdk}" + test -d "$sdk_root" + ndk_root="$sdk_root/ndk/$ANDROID_NDK_VERSION" + if [[ ! -f "$ndk_root/build/cmake/android.toolchain.cmake" ]]; then + "$sdk_root/cmdline-tools/latest/bin/sdkmanager" --install "ndk;$ANDROID_NDK_VERSION" > "$GITHUB_ENV" + echo "ANDROID_NDK_HOME=$ndk_root" >> "$GITHUB_ENV" + - name: Verify the original APK and restage exact compiled native inputs + env: + ABI: ${{ matrix.abi }} + APK_ARTIFACT: ${{ matrix.artifact }} + APK_DIGEST: ${{ matrix.digest }} + AUDIT_PR_HEAD: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + set -euo pipefail + python3 - <<'PY' + import hashlib, json, os, pathlib, shutil, subprocess + root = pathlib.Path(os.environ['RUNNER_TEMP']) + source = root / 'original-build-evidence' + def one(pattern): + matches = list(source.rglob(pattern)) + assert len(matches) == 1, (pattern, len(matches)) + return matches[0] + previous = json.loads(one('apk-payload-checksums.json').read_text()) + assert previous['gameplay'] == 'unverified' and previous['package_bytes'] == 'passed' + assert previous['android_manifest']['status'] == 'failed' + assert previous['errors'] == ['aapt badging lacks package, minimum SDK, or native ABI metadata'] + assert previous['files'] and all(file['match'] for file in previous['files'].values()) + original = json.loads(one('provenance.json').read_text()) + assert original['host_pr_head'] == os.environ['SOURCE_HOST_HEAD'] + assert original['native_source_head'] == os.environ['NATIVE_HEAD'] + assert original['native_source_run'] == int(os.environ['NATIVE_RUN']) + assert original['native_artifact_id'] == int(os.environ['NATIVE_ARTIFACT']) + assert original['native_artifact_digest'] == os.environ['NATIVE_DIGEST'] + assert original['abi'] == previous['abi'] == os.environ['ABI'] + apk = one('*.apk') + apk_hash = hashlib.sha256(apk.read_bytes()).hexdigest() + assert apk_hash == previous['apk_sha256'] + print('Original exported APK SHA256:', apk_hash) + result = root / 'apk-audit-result' + result.mkdir() + deliverable = root / 'validated-apk' + deliverable.mkdir() + shutil.copyfile(apk, deliverable / apk.name) + provenance = {'original_build': original, 'original_apk_sha256': apk_hash, + 'source_app_artifact': int(os.environ['APK_ARTIFACT']), 'source_app_artifact_digest': os.environ['APK_DIGEST'], + 'audit_pr_head': os.environ['AUDIT_PR_HEAD'], + 'audit_checkout_head': subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip(), + 'new_host_compilation': 'not_run', 'gameplay': 'unverified'} + (result / 'provenance.json').write_text(json.dumps(provenance, indent=2) + '\n') + with open(os.environ['GITHUB_ENV'], 'a') as env: + env.write('AUDIT_APK=' + str(deliverable / apk.name) + '\n') + PY + mkdir -p "$RUNNER_TEMP/original-native" "$RUNNER_TEMP/audit-staging" + tar -xzf "$RUNNER_TEMP/original-native-download/renpy-android-runtime.tar.gz" -C "$RUNNER_TEMP/original-native" + # This is reference staging for byte comparison, never an app build. + # Use the exact original script/Java support and native dependency. + touch "$RUNNER_TEMP/audit-staging/build.gradle" + bash original-host/tools/stage_renpy_android_support.sh \ + --mobile-root "$RUNNER_TEMP/original-native/payload" \ + --godot-build "$RUNNER_TEMP/audit-staging" --abis '${{ matrix.abi }}' + python3 original-host/tools/prepare_renpy_android_test_export.py \ + --source-project original-host/apps/godot_app --destination "$RUNNER_TEMP/audit-project" \ + --preset-name '${{ matrix.preset }}' --package-id org.aetherkiri.renpy.debug + - name: Audit real SDK metadata, packaged ELF closure, raw bytes and signature + run: | + set -euo pipefail + python3 tools/inspect_renpy_android_apk.py --apk "$AUDIT_APK" \ + --godot-build "$RUNNER_TEMP/audit-staging" --abi '${{ matrix.abi }}' \ + --output-dir "$RUNNER_TEMP/apk-audit-result" \ + --export-presets "$RUNNER_TEMP/audit-project/export_presets.cfg" --export-preset '${{ matrix.preset }}' \ + --build-type debug --android-sdk "$ANDROID_HOME" --android-ndk "$ANDROID_NDK_HOME" + python3 tools/validate_renpy_mobile_payload.py --platform android --abi '${{ matrix.abi }}' --apk "$AUDIT_APK" + apksigner="$(find "$ANDROID_HOME/build-tools" -name apksigner -type f | sort -V | tail -1)" + test -x "$apksigner" + "$apksigner" verify --verbose --print-certs "$AUDIT_APK" | tee "$RUNNER_TEMP/apk-audit-result/apk-signature.txt" + - name: Upload only the revalidated installable APK + uses: actions/upload-artifact@v6 + with: + name: renpy-installable-debug-${{ matrix.abi }} + path: ${{ runner.temp }}/validated-apk/*.apk + if-no-files-found: error + retention-days: 7 + - name: Upload the original public demo and phone instructions + if: matrix.abi == 'arm64-v8a' + uses: actions/upload-artifact@v6 + with: + name: renpy-demo-project + path: | + original-host/demos/aetherkiri-renpy/game + original-host/demos/aetherkiri-renpy/README.md + if-no-files-found: error + retention-days: 7 + - name: Preserve only newly generated audit metadata + if: always() + uses: actions/upload-artifact@v6 + with: + name: renpy-android-apk-audit-evidence-${{ matrix.abi }} + path: | + ${{ runner.temp }}/apk-audit-result/*.json + ${{ runner.temp }}/apk-audit-result/*.readelf.txt + ${{ runner.temp }}/apk-audit-result/*.exports.txt + ${{ runner.temp }}/apk-audit-result/apk-android-badging.txt + ${{ runner.temp }}/apk-audit-result/apk-signature.txt + if-no-files-found: warn + retention-days: 7 diff --git a/.github/workflows/renpy-android-app.yml b/.github/workflows/renpy-android-app.yml new file mode 100644 index 000000000..0f3f6beba --- /dev/null +++ b/.github/workflows/renpy-android-app.yml @@ -0,0 +1,265 @@ +name: Ren'Py Android APK reproduction + +# Independently reproduce app packaging with an already compiled dependency. +# The main Build workflow continues to compile the exact current source tree. +# This pinned, expiring Actions artifact is deliberate: never silently replace +# it with an unrelated runtime or describe it as compiled from the host HEAD. +on: + pull_request: + paths: + - '.github/workflows/renpy-android-app.yml' + - 'scripts/build_android.sh' + - 'tools/stage_renpy_android_support.sh' + - 'tools/inspect_renpy_android_apk.py' + - 'tools/prepare_renpy_android_test_export.py' + - 'tools/test_prepare_renpy_android_test_export.py' + - 'bridge/renpy_runtime/android/**' + - 'vcpkg/ports/libgdiplus/**' + - 'vcpkg/ports/ggml/**' + - 'tools/test_libgdiplus_cmake_relocation.py' + - 'tools/test_ggml_overlay_source.py' + - 'demos/aetherkiri-renpy/**' + workflow_dispatch: + +permissions: + contents: read + actions: read + packages: read + +concurrency: + group: renpy-android-apk-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: false + +jobs: + package: + name: Installable Debug APK (${{ matrix.abi }}) + runs-on: ubuntu-24.04 + timeout-minutes: 120 + strategy: + fail-fast: false + matrix: + abi: [arm64-v8a, x86_64] + env: + GODOT_RELEASE: 4.7.2-stable + GODOT_TEMPLATE_VERSION: 4.7.2.stable + ANDROID_NDK_VERSION: 28.1.13356709 + JOBS: '4' + AETHERKIRI_ENABLE_INTERNAL: 'OFF' + AETHERKIRI_ENABLE_SOFTPAL_RUNTIME: 'OFF' + AETHERKIRI_WITH_INTERNAL: 'false' + AETHERKIRI_VCPKG_CACHE_WRITE: 'false' + AETHERKIRI_ENABLE_RENPY: 'ON' + CCACHE_DIR: ${{ github.workspace }}/.ccache + NATIVE_RUNTIME_RUN: '37866667365' + NATIVE_RUNTIME_ARTIFACT: '11589199045' + NATIVE_RUNTIME_HEAD: '1967809558f4022aa781062bfe5a4f8e903c84d0' + # Keep the disposable Debug key and its data separate from an existing + # production installation. The build uses a copied export project. + AETHERKIRI_ANDROID_TEST_PACKAGE_ID: org.aetherkiri.renpy.debug + NATIVE_RUNTIME_DIGEST: 'sha256:08537fa4c09a84d8226c50644127ab6918290a0a09f0720c878b465bce25014c' + steps: + - uses: actions/checkout@v6 + with: + submodules: false + persist-credentials: false + - name: Verify the fixed dependency's actual compilation and provenance + env: + GH_TOKEN: ${{ github.token }} + HOST_PR_HEAD: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + python3 - <<'PY' + import json, os, pathlib, subprocess, urllib.request + def get(path): + request = urllib.request.Request('https://api.github.com/repos/AetherKiri/Aether/' + path, + headers={'Authorization': 'Bearer ' + os.environ['GH_TOKEN'], + 'Accept': 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28'}) + with urllib.request.urlopen(request, timeout=60) as response: + return json.load(response) + run_id = os.environ['NATIVE_RUNTIME_RUN'] + run = get('actions/runs/' + run_id) + assert run['status'] == 'completed' and run['conclusion'] == 'success', 'Native source run did not pass' + assert run['head_sha'] == os.environ['NATIVE_RUNTIME_HEAD'], 'Native source revision differs' + artifact = get('actions/artifacts/' + os.environ['NATIVE_RUNTIME_ARTIFACT']) + assert artifact['name'] == 'renpy-android-runtime' and not artifact['expired'], 'Pinned runtime expired; update the reviewed dependency pin' + assert artifact['workflow_run']['id'] == int(run_id), 'Runtime came from another run' + assert artifact['digest'] == os.environ['NATIVE_RUNTIME_DIGEST'], 'Runtime artifact digest differs' + evidence = pathlib.Path(os.environ['RUNNER_TEMP']) / 'renpy-android-app-evidence' + evidence.mkdir() + provenance = { + 'native_source_head': run['head_sha'], 'native_source_run': int(run_id), + 'native_artifact_id': artifact['id'], 'native_artifact_digest': artifact['digest'], + 'host_checkout_head': subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip(), + 'host_pr_head': os.environ['HOST_PR_HEAD'], 'abi': '${{ matrix.abi }}', + 'native_compilation': 'passed', 'gameplay': 'unverified', + 'note': 'Host and native revisions are recorded independently. The native dependency passed compilation and validation at its recorded exact commit. Gameplay remains unverified.'} + (evidence / 'provenance.json').write_text(json.dumps(provenance, indent=2) + '\n') + PY + - uses: actions/download-artifact@v6 + with: + artifact-ids: 11589199045 + run-id: 37866667365 + github-token: ${{ github.token }} + merge-multiple: true + path: ${{ runner.temp }}/renpy-runtime-download + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - name: Validate the genuine native and private Python dependency + run: | + set -euo pipefail + root="$RUNNER_TEMP/renpy-runtime" + mkdir -p "$root" + tar -xzf "$RUNNER_TEMP/renpy-runtime-download/renpy-android-runtime.tar.gz" -C "$root" + python3 - "$root/manifest.json" <<'PY' + import json, sys + manifest = json.load(open(sys.argv[1])) + assert manifest['native_compilation'] == 'passed' and manifest['device_gameplay'] == 'unverified' + assert manifest['platform'] == 'android' + assert manifest['build_source_sha'] == '7bfab40c1174f622f644b24669afd5fb167fbb79' + assert manifest['renpy_source_sha'] == '39895c1e017f0b36ffea2447d97eccd69d76ee1c' + assert manifest['greenlet_source_sha'] == '65f8da82b13a1273e55a6bfcbd1f9da09fc4eb7a' + PY + python3 tools/validate_renpy_mobile_payload.py --platform android --abi '${{ matrix.abi }}' \ + --library "$root/payload/rapt/prototype/renpyandroid/src/main/jniLibs/${{ matrix.abi }}/librenpython.so" \ + --private-root "$root/payload/rapt/runtime/private" + cp "$root/manifest.json" "$RUNNER_TEMP/renpy-android-app-evidence/native-manifest.json" + echo "RENPY_MOBILE_ROOT=$root/payload" >> "$GITHUB_ENV" + echo "AETHERKIRI_RENPY_ANDROID_PRIVATE_ASSETS=$root/payload/rapt/runtime/private" >> "$GITHUB_ENV" + - name: Initialize public application packages + run: | + git submodule update --init --depth 1 packages/OnscripterYuri + git submodule update --init --recursive --depth 1 packages/psdfile + git submodule update --init --depth 1 packages/AetherSiglus + git submodule update --init --depth 1 packages/AetherMinori + git submodule update --init --depth 1 packages/AetherKrkr + - uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-linux-android,x86_64-linux-android + components: rust-src + - name: Install build tools + run: | + sudo apt-get update + sudo apt-get install --no-install-recommends -y cmake ninja-build pkg-config autoconf \ + autoconf-archive automake libtool gettext bison flex nasm curl unzip zip zstd ccache jq mono-complete + ccache --max-size=400M + - uses: actions/cache/restore@v6 + with: + path: .ccache + key: ${{ runner.os }}-android-ccache-v2 + restore-keys: ${{ runner.os }}-android-ccache- + - uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: '17' + - uses: gradle/actions/wrapper-validation@v6 + with: + allow-checksums: 2c23278a62dc9f96ab11bee897d9f53e6479d6419135e4560ce822a6431df5e5 + - uses: gradle/actions/setup-gradle@v6 + with: + validate-wrappers: false + - name: Download the official Godot editor and export templates + run: | + set -euo pipefail + mkdir -p .godot "$RUNNER_TEMP/godot-templates" + curl --fail --location --retry 3 -o "$RUNNER_TEMP/godot.zip" \ + "https://github.com/godotengine/godot-builds/releases/download/${GODOT_RELEASE}/Godot_v${GODOT_RELEASE}_linux.x86_64.zip" + unzip -tq "$RUNNER_TEMP/godot.zip" + unzip -q "$RUNNER_TEMP/godot.zip" -d .godot + godot_bin="$(find .godot -maxdepth 1 -type f -name 'Godot*_linux.x86_64' -print -quit)" + test -n "$godot_bin" + chmod +x "$godot_bin" + curl --fail --location --retry 3 -o "$RUNNER_TEMP/godot-templates.tpz" \ + "https://github.com/godotengine/godot-builds/releases/download/${GODOT_RELEASE}/Godot_v${GODOT_RELEASE}_export_templates.tpz" + unzip -tq "$RUNNER_TEMP/godot-templates.tpz" + unzip -q "$RUNNER_TEMP/godot-templates.tpz" -d "$RUNNER_TEMP/godot-templates" + template_dir="$HOME/.local/share/godot/export_templates/$GODOT_TEMPLATE_VERSION" + mkdir -p "$template_dir" + cp -a "$RUNNER_TEMP/godot-templates/templates/." "$template_dir/" + echo "GODOT_BIN=$GITHUB_WORKSPACE/$godot_bin" >> "$GITHUB_ENV" + echo "GODOT_TEMPLATE_DIR=$template_dir" >> "$GITHUB_ENV" + - name: Use the official Android SDK with existing license acceptance + run: | + set -euo pipefail + sdk_root="${ANDROID_HOME:-/usr/local/lib/android/sdk}" + test -d "$sdk_root" + ndk_root="$sdk_root/ndk/$ANDROID_NDK_VERSION" + if [[ ! -f "$ndk_root/build/cmake/android.toolchain.cmake" ]]; then + "$sdk_root/cmdline-tools/latest/bin/sdkmanager" --install "ndk;$ANDROID_NDK_VERSION" > "$GITHUB_ENV" + echo "ANDROID_NDK_HOME=$ndk_root" >> "$GITHUB_ENV" + - name: Bootstrap the normal public vcpkg dependency cache + env: + VCPKG_PACKAGES_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + mkdir -p .devtools + git clone https://github.com/microsoft/vcpkg.git .devtools/vcpkg + .devtools/vcpkg/bootstrap-vcpkg.sh -disableMetrics + echo "VCPKG_ROOT=$GITHUB_WORKSPACE/.devtools/vcpkg" >> "$GITHUB_ENV" + bash .github/scripts/configure-vcpkg-binary-cache.sh "$GITHUB_WORKSPACE/.devtools/vcpkg/vcpkg" + - name: Verify package configuration survives a different build directory + run: | + set -euo pipefail + python3 tools/test_libgdiplus_cmake_relocation.py --vcpkg-root .devtools/vcpkg \ + | tee "$RUNNER_TEMP/renpy-android-app-evidence/libgdiplus-relocation.log" + - name: Create a disposable standard Android Debug signing key + run: | + set -euo pipefail + key="$RUNNER_TEMP/renpy-android-debug.keystore" + keytool -genkeypair -keystore "$key" -storepass android -keypass android \ + -alias androiddebugkey -keyalg RSA -keysize 2048 -validity 365 \ + -dname 'CN=Android Debug,O=Android,C=US' -noprompt + echo "GODOT_ANDROID_KEYSTORE_DEBUG_PATH=$key" >> "$GITHUB_ENV" + echo "GODOT_ANDROID_KEYSTORE_DEBUG_USER=androiddebugkey" >> "$GITHUB_ENV" + echo "GODOT_ANDROID_KEYSTORE_DEBUG_PASSWORD=android" >> "$GITHUB_ENV" + - name: Compile the actual host and export the signed Debug APK + run: | + set -euo pipefail + export CMAKE_C_COMPILER_LAUNCHER=ccache CMAKE_CXX_COMPILER_LAUNCHER=ccache NDK_CCACHE=ccache + bash scripts/build_android.sh debug --abi='${{ matrix.abi }}' 2>&1 \ + | tee "$RUNNER_TEMP/renpy-android-app-evidence/build.log" + - name: Verify the actual APK payload and Debug signature + run: | + set -euo pipefail + apk="out/godot/android/debug/Aether-debug.apk" + if [[ '${{ matrix.abi }}' == x86_64 ]]; then + apk="out/godot/android/x86_64/debug/Aether-emulator-debug.apk" + fi + python3 tools/validate_renpy_mobile_payload.py --platform android --abi '${{ matrix.abi }}' --apk "$apk" + grep -F 'renpy_runtime_mobile_provider.cpp' 'out/android/${{ matrix.abi }}/debug/compile_commands.json' + grep -F 'renpy_mobile_adapter.cpp' 'out/android/${{ matrix.abi }}/debug/compile_commands.json' + apksigner="$(find "$ANDROID_HOME/build-tools" -name apksigner -type f | sort -V | tail -1)" + test -x "$apksigner" + "$apksigner" verify --verbose --print-certs "$apk" \ + | tee "$RUNNER_TEMP/renpy-android-app-evidence/apk-signature.txt" + - name: Upload the validated installable APK + uses: actions/upload-artifact@v6 + with: + name: renpy-installable-debug-${{ matrix.abi }} + path: out/godot/android/**/*.apk + if-no-files-found: error + retention-days: 7 + - name: Upload the public demo project for phone import + if: matrix.abi == 'arm64-v8a' + uses: actions/upload-artifact@v6 + with: + name: renpy-demo-project + path: | + demos/aetherkiri-renpy/game + demos/aetherkiri-renpy/README.md + if-no-files-found: error + retention-days: 7 + - name: Preserve real build evidence and any diagnostic APK on failure + if: always() + uses: actions/upload-artifact@v6 + with: + name: renpy-android-app-evidence-${{ matrix.abi }} + path: | + ${{ runner.temp }}/renpy-android-app-evidence/** + out/godot/android/**/*.apk + out/godot/android/**/diagnostics/** + apps/godot_app/android/build/aether-renpy.gradle + if-no-files-found: warn + retention-days: 7 diff --git a/.github/workflows/renpy-android-device.yml b/.github/workflows/renpy-android-device.yml new file mode 100644 index 000000000..cb5973d62 --- /dev/null +++ b/.github/workflows/renpy-android-device.yml @@ -0,0 +1,44 @@ +name: Ren'Py Android Cloud Device + +on: + push: + branches: + - main + pull_request: + paths: + - '.github/workflows/renpy-android-device.yml' + - 'tools/preflight_renpy_android_device.py' + - 'tools/run_renpy_android_device_acceptance.py' + - 'apps/godot_app/scripts/renpy_mobile_acceptance.gd' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: renpy-android-device-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + cloud-device-preflight: + name: Boot actual official Android emulator (device availability only) + runs-on: ubuntu-24.04 + timeout-minutes: 25 + steps: + - name: Checkout device preflight + uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Check existing KVM and boot official Android 35 x86_64 + run: | + python3 tools/preflight_renpy_android_device.py \ + --cloud-emulator --boot-timeout 480 \ + --output-dir "${RUNNER_TEMP}/renpy-android-device-preflight" + - name: Preserve actual device result and logs + if: always() + uses: actions/upload-artifact@v6 + with: + name: renpy-android-cloud-device-preflight + path: ${{ runner.temp }}/renpy-android-device-preflight + if-no-files-found: warn + retention-days: 7 diff --git a/.github/workflows/renpy-android-hvf-gameplay.yml b/.github/workflows/renpy-android-hvf-gameplay.yml new file mode 100644 index 000000000..cac4feb49 --- /dev/null +++ b/.github/workflows/renpy-android-hvf-gameplay.yml @@ -0,0 +1,185 @@ +name: Ren'Py Android Intel HVF Gameplay + +# Execute the renderer-fixed APK compiled and validated at 0ff10e3. The current checkout +# supplies the actual device harness; this job does not rebuild the APK. +on: + pull_request: + paths: + - '.github/workflows/renpy-android-hvf-gameplay.yml' + - 'tools/run_renpy_android_emulator_session.py' + - 'tools/test_renpy_android_emulator_session.py' + - 'tools/preflight_renpy_android_device.py' + - 'tools/run_renpy_android_device_acceptance.py' + - 'tools/validate_renpy_mobile_payload.py' + - 'tools/inspect_renpy_android_apk.py' + workflow_dispatch: + +permissions: + contents: read + actions: read + +concurrency: + group: renpy-android-hvf-gameplay-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: false + +jobs: + gameplay: + name: Actual validated x64 APK on Android 35 (Intel HVF) + runs-on: macos-15-intel + timeout-minutes: 45 + env: + APK_RUN: '37915707195' + APK_JOB: '113771235880' + APK_HEAD: '0ff10e3018177f12d21d66e08036c1b5e025b833' + APK_ARTIFACT: '11610231057' + APK_ARTIFACT_DIGEST: 'sha256:ca3c5f3ba4c352bf7b995ac6e11a15fce9476aea4155e4c9a339ce7e50a6efc9' + APK_NATIVE_SHA256: '2ff2cd7e484a199a7d387704249b34b7a0ec6114a384931ed845ea25c1a2f3b5' + APK_SIGNER_SHA256: '2830f93a34ea5eceef0dc91389a3718668e286db1368bc0d1d55348bcf0b9b91' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: '21' + - name: Check session process boundaries (not gameplay) + run: python3 tools/test_renpy_android_emulator_session.py + - name: Verify successful APK compilation and immutable artifact metadata + env: + GH_TOKEN: ${{ github.token }} + HARNESS_PR_HEAD: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + python3 - <<'PY' + import json, os, pathlib, subprocess, urllib.request + def get(path): + request = urllib.request.Request('https://api.github.com/repos/AetherKiri/Aether/' + path, + headers={'Authorization': 'Bearer ' + os.environ['GH_TOKEN'], + 'Accept': 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28'}) + with urllib.request.urlopen(request, timeout=60) as response: + return json.load(response) + run = get('actions/runs/' + os.environ['APK_RUN']) + assert run['status'] == 'completed' and run['conclusion'] == 'success', 'Pinned APK source run did not pass' + assert run['id'] == int(os.environ['APK_RUN']) and run['head_sha'] == os.environ['APK_HEAD'] + job = get('actions/jobs/' + os.environ['APK_JOB']) + assert job['run_id'] == run['id'] and job['head_sha'] == run['head_sha'] + assert job['name'] == 'Installable Debug APK (x86_64)' + assert job['status'] == 'completed' and job['conclusion'] == 'success' + for name in ('Compile the actual host and export the signed Debug APK', + 'Verify the actual APK payload and Debug signature', + 'Upload the validated installable APK'): + steps = [step for step in job['steps'] if step['name'] == name] + assert len(steps) == 1 and steps[0]['conclusion'] == 'success', name + artifact = get('actions/artifacts/' + os.environ['APK_ARTIFACT']) + assert artifact['id'] == int(os.environ['APK_ARTIFACT']) and not artifact['expired'] + assert artifact['name'] == 'renpy-installable-debug-x86_64' + assert artifact['workflow_run']['id'] == run['id'] and artifact['workflow_run']['head_sha'] == run['head_sha'] + assert artifact['digest'] == os.environ['APK_ARTIFACT_DIGEST'], 'Pinned ZIP metadata digest differs' + native_run = get('actions/runs/37866667365') + assert native_run['status'] == 'completed' and native_run['conclusion'] == 'success' + assert native_run['head_sha'] == '1967809558f4022aa781062bfe5a4f8e903c84d0' + native_artifact = get('actions/artifacts/11589199045') + assert native_artifact['id'] == 11589199045 and not native_artifact['expired'] + assert native_artifact['name'] == 'renpy-android-runtime' and native_artifact['workflow_run']['id'] == native_run['id'] + assert native_artifact['workflow_run']['head_sha'] == native_run['head_sha'] + assert native_artifact['digest'] == 'sha256:08537fa4c09a84d8226c50644127ab6918290a0a09f0720c878b465bce25014c' + evidence = pathlib.Path(os.environ['RUNNER_TEMP']) / 'renpy-android-device-evidence' + evidence.mkdir(exist_ok=True) + provenance = {'apk_source_run': run['id'], 'apk_source_job': job['id'], 'apk_source_head': run['head_sha'], + 'apk_artifact_id': artifact['id'], 'apk_artifact_zip_digest': artifact['digest'], + 'apk_compilation': 'passed_at_recorded_source_head', 'apk_compilation_in_this_job': 'not_run', + 'native_dependency_head': '1967809558f4022aa781062bfe5a4f8e903c84d0', + 'native_dependency_run': 37866667365, 'native_dependency_artifact': 11589199045, + 'native_dependency_digest': 'sha256:08537fa4c09a84d8226c50644127ab6918290a0a09f0720c878b465bce25014c', + 'harness_checkout_head': subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip(), + 'harness_pr_head': os.environ['HARNESS_PR_HEAD'], 'gameplay': 'not_run', + 'note': 'The official download action checks the ZIP against GitHub metadata. This is the validated prior APK, not a new compilation of the harness checkout.'} + (evidence / 'apk-provenance.json').write_text(json.dumps(provenance, indent=2) + '\n') + print(json.dumps(provenance, sort_keys=True)) + PY + - name: Download the exact validated APK through the official action + uses: actions/download-artifact@v6 + with: + artifact-ids: 11610231057 + run-id: 37915707195 + repository: AetherKiri/Aether + github-token: ${{ github.token }} + path: ${{ runner.temp }}/renpy-hvf-apk + - name: Verify actual x64 APK native bytes, private Python and compiled manifest + run: | + python3 - <<'PY' + import hashlib, json, os, pathlib, re, shlex, subprocess, sys, zipfile + sys.path.insert(0, str(pathlib.Path('tools').resolve())) + from inspect_renpy_android_apk import parse_badging, sdk_tool + root = pathlib.Path(os.environ['RUNNER_TEMP']) + evidence = root / 'renpy-android-device-evidence' + apks = list((root / 'renpy-hvf-apk').rglob('*.apk')) + assert len(apks) == 1 and apks[0].name == 'Aether-emulator-debug.apk', 'Expected exactly the validated x64 APK' + apk = apks[0] + with zipfile.ZipFile(apk) as archive: + names = archive.namelist() + assert len(names) == len(set(names)), 'Duplicate APK members' + abis = {name.split('/')[1] for name in names if name.startswith('lib/') and name.endswith('.so')} + assert abis == {'x86_64'}, 'Unexpected packaged APK ABI' + native_hash = hashlib.sha256(archive.read('lib/x86_64/librenpython.so')).hexdigest() + assert native_hash == os.environ['APK_NATIVE_SHA256'], 'Actual compiled native bytes differ' + sdk = pathlib.Path(os.environ['ANDROID_HOME']) + bins = sorted(sdk.glob('ndk/*/toolchains/llvm/prebuilt/darwin-x86_64/bin')) + readers = [path for path in bins if (path/'llvm-nm').is_file() and (path/'llvm-readelf').is_file()] + assert readers, 'Existing official NDK LLVM readers are missing; no new license will be accepted' + environment = dict(os.environ, RENPY_MOBILE_NM=shlex.join([str(readers[-1]/'llvm-nm')]), + RENPY_MOBILE_READELF=shlex.join([str(readers[-1]/'llvm-readelf')])) + with (evidence/'apk-payload-validation.log').open('w') as log: + subprocess.run([sys.executable, 'tools/validate_renpy_mobile_payload.py', '--platform', 'android', + '--abi', 'x86_64', '--apk', str(apk)], env=environment, stdout=log, + stderr=subprocess.STDOUT, timeout=180, check=True) + badging = subprocess.run([str(sdk_tool(sdk, 'build-tools/*/aapt2')), 'dump', 'badging', str(apk)], + capture_output=True, text=True, timeout=60, check=True) + (evidence/'apk-badging.log').write_text(badging.stdout) + manifest = parse_badging(badging.stdout) + assert manifest['package'] == 'org.aetherkiri.renpy.debug' and manifest['version_code'] == 4501 + assert manifest['min_sdk'] == 26 and manifest['target_sdk'] == 36 and manifest['debuggable'] + assert manifest['native_abis'] == ['x86_64'] + signer = subprocess.run([str(sdk_tool(sdk, 'build-tools/*/apksigner')), 'verify', '--verbose', + '--print-certs', str(apk)], capture_output=True, text=True, timeout=60, check=True) + (evidence/'apk-signature.log').write_text(signer.stdout + signer.stderr) + assert re.search(r'^Verified using v2 scheme .*: true\s*$', signer.stdout, re.MULTILINE), 'APK V2 signature did not verify' + assert re.findall(r'^Number of signers: (\d+)\s*$', signer.stdout, re.MULTILINE) == ['1'], 'APK must have exactly one signer' + certificate_lines = [line for line in signer.stdout.splitlines() if 'certificate SHA-256 digest:' in line] + certificates = [] + for line in certificate_lines: + match = re.fullmatch(r'(?:Signer #1 |V2 Signer: )certificate SHA-256 digest: ([0-9a-f]{64})\s*', line) + assert match, 'Unexpected signer or certificate scheme in actual APK verification' + certificates.append(match.group(1)) + assert certificates and set(certificates) == {os.environ['APK_SIGNER_SHA256']}, 'Actual APK signer differs' + provenance_path = evidence/'apk-provenance.json' + provenance = json.loads(provenance_path.read_text()) + provenance.update(apk_sha256=hashlib.sha256(apk.read_bytes()).hexdigest(), + actual_native_sha256=native_hash, actual_manifest=manifest, payload_validation='passed', + apk_signature_v2='verified', apk_signer_sha256=certificates[0]) + provenance_path.write_text(json.dumps(provenance, indent=2)+'\n') + with open(os.environ['GITHUB_ENV'], 'a') as stream: + stream.write('RENPY_HVF_APK=' + str(apk.resolve()) + '\n') + print(json.dumps({'actual_apk_sha256':provenance['apk_sha256'], 'actual_native_sha256':native_hash, + 'actual_manifest':manifest, 'gameplay':'not_run'}, sort_keys=True)) + PY + - name: Boot actual Android 35 and run full Ren'Py APK gameplay + # Run 37899763345 (68e7c016) measured one guest vCPU and ongoing ART + # work at the 600s cutoff. This bounded 900s coldboot diagnostic keeps + # the original gameplay gates and remains an unverified experiment. + run: | + python3 tools/run_renpy_android_emulator_session.py --cloud-emulator \ + --apk "$RENPY_HVF_APK" --package org.aetherkiri.renpy.debug \ + --boot-timeout 900 --gameplay-timeout 900 --stage-timeout 90 \ + --output-dir "$RUNNER_TEMP/renpy-android-device-evidence" + - name: Preserve actual Android gameplay evidence + if: always() + uses: actions/upload-artifact@v6 + with: + name: renpy-android-gameplay-evidence + path: ${{ runner.temp }}/renpy-android-device-evidence + if-no-files-found: warn + retention-days: 7 diff --git a/.github/workflows/renpy-android-hvf-preflight.yml b/.github/workflows/renpy-android-hvf-preflight.yml new file mode 100644 index 000000000..277e118a6 --- /dev/null +++ b/.github/workflows/renpy-android-hvf-preflight.yml @@ -0,0 +1,111 @@ +name: Ren'Py Android Intel HVF Preflight + +on: + pull_request: + paths: + - '.github/workflows/renpy-android-hvf-preflight.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + intel-hvf: + name: Check existing Intel macOS Android hypervisor + runs-on: macos-15-intel + timeout-minutes: 5 + steps: + - name: Query existing official emulator and HVF + shell: bash + run: | + python3 - <<'PY' + import json + import os + import platform + import re + import subprocess + from pathlib import Path + + def bounded_output(value, limit): + if isinstance(value, bytes): + value = value.decode("utf-8", errors="replace") + value = value or "" + return {"output": value[:limit], "output_truncated": len(value) > limit} + + def probe(command, limit=1600): + result = {"command": command, "timeout_seconds": 45} + try: + completed = subprocess.run( + command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, timeout=45, check=False, + ) + result.update(returncode=completed.returncode) + result.update(bounded_output(completed.stdout, limit)) + except subprocess.TimeoutExpired as exc: + result.update(returncode=None, timed_out=True) + result.update(bounded_output(exc.stdout, limit)) + except OSError as exc: + result.update(returncode=None, error=str(exc)[:500]) + return result + + def hvf_is_usable(check): + if check.get("returncode") != 0 or check.get("output_truncated"): + return False + lines = [line.strip() for line in check.get("output", "").splitlines() if line.strip()] + try: + start = lines.index("accel:") + except ValueError: + return False + if len(lines) < start + 3 or lines[start + 1] != "0": + return False + description = lines[start + 2] + lower = description.lower() + if re.search(r"unavailable|unsupported|unusable|not\s+(?:available|installed|usable)|" + r"could\s+not|failed|disabled|error", lower): + return False + # Google's documented successful macOS output names the framework + # and OS version; other emulator versions explicitly say usable. + documented = re.fullmatch(r"Hypervisor\.Framework OS X Version \d+(?:\.\d+)*", description) + explicit = re.search(r"\b(?:hvf|hypervisor\.framework)\b", lower) and re.search( + r"\b(?:installed and usable|available and usable|is usable)\b", lower) + return bool(documented or explicit) + + def classify(result): + host = result["host"] + if host["system"] != "Darwin" or host["machine"] != "x86_64": + return "not_run", "This preflight requires the official Intel macOS runner." + if not result.get("emulator"): + return "not_run", "Existing ANDROID_HOME/emulator/emulator is unavailable; no tools were downloaded." + for name in ("hypervisor", "version", "acceleration"): + check = result["checks"][name] + if check.get("returncode") != 0: + return "failed", f"Actual {name} command failed or timed out; see its bounded output." + if result["checks"]["hypervisor"]["output"].strip() != "1": + return "failed", "The actual Intel runner does not expose kern.hv_support=1." + if not hvf_is_usable(result["checks"]["acceleration"]): + return "failed", "Google's actual acceleration report does not confirm usable HVF." + return "supported", "Google's actual acceleration check confirms usable HVF on Intel macOS." + + def main(): + result = { + "scope": "existing-intel-macos-hvf-capability", + "status": "not_run", + "host": {"system": platform.system(), "machine": platform.machine()}, + "avd_launched": False, "apk_installed": False, "gameplay_executed": False, + "checks": {}, + } + result["checks"]["hypervisor"] = probe(["/usr/sbin/sysctl", "-n", "kern.hv_support"], 500) + sdk = os.environ.get("ANDROID_HOME", "") + emulator = Path(sdk) / "emulator" / "emulator" if sdk else None + if emulator is not None and emulator.is_file() and os.access(emulator, os.X_OK): + result["emulator"] = str(emulator) + # -version is the official Android Emulator command-line flag. + result["checks"]["version"] = probe([str(emulator), "-version"], 1200) + result["checks"]["acceleration"] = probe([str(emulator), "-accel-check"]) + result["status"], result["reason"] = classify(result) + print(json.dumps(result, sort_keys=True)) + return {"supported": 0, "not_run": 2, "failed": 1}[result["status"]] + + if __name__ == "__main__": + raise SystemExit(main()) + PY diff --git a/.github/workflows/renpy-build-diagnostics.yml b/.github/workflows/renpy-build-diagnostics.yml new file mode 100644 index 000000000..61e4c5db6 --- /dev/null +++ b/.github/workflows/renpy-build-diagnostics.yml @@ -0,0 +1,69 @@ +name: Read Ren'Py build diagnostics + +on: + pull_request: + paths: + - '.github/workflows/renpy-build-diagnostics.yml' + workflow_dispatch: + +permissions: + contents: read + actions: read + +jobs: + actual-build-log: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Download the actual failed source-build evidence + uses: actions/download-artifact@v6 + with: + repository: AetherKiri/Aether + run-id: 37838829329 + name: renpy-ios-source-build-evidence + github-token: ${{ github.token }} + path: ${{ runner.temp }}/renpy-ios-source-evidence + - name: Read a sanitized summary of the original build logs + run: | + set -euo pipefail + python3 - "$RUNNER_TEMP/renpy-ios-source-evidence" <<'PY' + import pathlib, re, sys + # Read the genuine compiler log, not configure environment dumps. + logs = sorted(pathlib.Path(sys.argv[1]).rglob('renpy-ios-source-build.log')) + if not logs: + raise SystemExit('The genuine source-build evidence contains no log files.') + print('Original iOS source run 37838829329; job 113522721484; head 8b2db6bfb6fdfe65126aba1db2484002427c109c.') + print('Evidence artifact 11578038161; GitHub archive digest sha256:59e42e8ba5bd069d02c5417ac610705752b4b2ca003e071dcfacc8f60a8273c9.') + print('Reading preserved compiler evidence; no build is rerun or simulated.') + remaining = 24000 + for log in logs: + text = log.read_text(errors='replace') + text = re.sub(r'\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)', '', text) + text = re.sub(r'\x1b\[[0-?]*[ -/]*[@-~]', '', text) + text = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', '', text) + lines = text.splitlines() + interesting = [i for i, line in enumerate(lines) + if re.search(r'error:|fatal error|FAILED|Error [1-9]|undefined reference|not found|Traceback|No such file|exception|Killed|process failed|tasks failed', line, re.I)] + indices = set(range(max(0, len(lines)-110), len(lines))) + for i in interesting[-16:]: + indices.update(range(max(0, i-4), min(len(lines), i+9))) + excerpt = [] + for i in sorted(indices): + line = lines[i] + if re.match(r'^\s*(?:export\s|declare\s|env:|Environment:|(?!(?:FAILED|ERROR|FATAL|WARNING):)[A-Z_][A-Z0-9_]*\s*[=:])', line): + continue + if re.search(r'authorization|password|credential|secret|(?:^|[\s_])token(?:[\s_=:]|$)', line, re.I): + continue + line = line.replace('/Users/runner/work/Aether/Aether', '') + line = line.replace('/Users/runner/work/_temp', '') + excerpt.append(line[:1200]) + summary = '\n'.join(excerpt) + print(f'Original log: {log.name}; {len(lines)} lines') + if len(summary) > remaining: + summary = summary[-remaining:] + print(summary) + remaining -= len(summary) + if remaining <= 0: + print('Summary reached its display limit; complete original logs remain in the artifact.') + break + PY diff --git a/.github/workflows/renpy-desktop.yml b/.github/workflows/renpy-desktop.yml new file mode 100644 index 000000000..d5761d1c9 --- /dev/null +++ b/.github/workflows/renpy-desktop.yml @@ -0,0 +1,90 @@ +name: Ren'Py Desktop Acceptance + +on: + push: + branches: + - main + pull_request: + paths: + - '.github/workflows/renpy-desktop.yml' + - 'bridge/renpy_runtime/**' + - 'demos/aetherkiri-renpy/**' + - 'tests/fixtures/renpy_smoke/**' + - 'tools/install_renpy_sdk.sh' + - 'tools/run_renpy_smoke.sh' + - 'tools/run_renpy_overlay_probe.sh' + - 'tools/test_renpy_demo_fixture.sh' + - 'tools/test_install_renpy_sdk.sh' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: renpy-desktop-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + desktop-sdk: + name: Execute Ren'Py desktop fixture (${{ matrix.platform }}) + strategy: + fail-fast: false + matrix: + include: + - platform: linux + os: ubuntu-latest + - platform: windows + os: windows-latest + runs-on: ${{ matrix.os }} + defaults: + run: + shell: bash + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Install Linux prerequisites + if: matrix.platform == 'linux' + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y curl tar bzip2 unzip + + - name: Validate installer metadata and shell syntax + run: bash tools/test_install_renpy_sdk.sh + + - name: Validate Ren'Py demo fixture + run: bash tools/test_renpy_demo_fixture.sh + + - name: Install pinned Ren'Py SDK and run fixture smoke + env: + SDK_PLATFORM: ${{ matrix.platform }} + run: | + set -euo pipefail + sdk_root="${RUNNER_TEMP}/aetherkiri-renpy-sdk" + bash tools/install_renpy_sdk.sh --platform "${SDK_PLATFORM}" \ + --arch x86_64 --destination "${sdk_root}" + test -f "${sdk_root}/renpy.sh" || test -f "${sdk_root}/renpy.exe" + platform_lib_dir="$(find "${sdk_root}/lib" -maxdepth 1 -type d -name 'py3-*' -print -quit 2>/dev/null || true)" + if [[ -z "${platform_lib_dir}" ]] || + ! find "${platform_lib_dir}" -maxdepth 1 -type f \ + \( -name '*.so*' -o -name 'python*' \) -print -quit 2>/dev/null | + grep -q .; then + echo "Ren'Py SDK platform files are absent; execution smoke cannot run" + exit 1 + fi + RENPY_SDK="${sdk_root}" bash tools/run_renpy_smoke.sh + AETHERKIRI_RENPY_DEMO_FIXTURE="${GITHUB_WORKSPACE}/demos/aetherkiri-renpy" \ + RENPY_SDK="${sdk_root}" RENPY_DEMO_RUN_SMOKE=1 \ + bash tools/test_renpy_demo_fixture.sh + + - name: Exercise real Ren'Py frame, input, and quit on Linux + if: matrix.platform == 'linux' + env: + RENPY_RENDERER: sw + run: | + set -euo pipefail + RENPY_SDK="${RUNNER_TEMP}/aetherkiri-renpy-sdk" \ + bash tools/run_renpy_overlay_probe.sh diff --git a/.github/workflows/renpy-ios-libffi.yml b/.github/workflows/renpy-ios-libffi.yml new file mode 100644 index 000000000..c098a3337 --- /dev/null +++ b/.github/workflows/renpy-ios-libffi.yml @@ -0,0 +1,73 @@ +name: Ren'Py actual iOS libffi regression + +# The same genuine regression also gates the full iOS source build. Run it +# independently so a prior long source build cannot hide a target SDK error. +# This job never builds, installs or exercises the Ren'Py application. +on: + pull_request: + paths: + - '.github/workflows/renpy-ios-libffi.yml' + - 'tools/test_renpy_mobile_ios_libffi.py' + - 'bridge/renpy_runtime/mobile_launcher/patches/native/0006-ios-offscreen-renderer.patch' + - 'bridge/renpy_runtime/mobile_launcher/patches/native/0007-darwin-ios-build.patch' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: renpy-ios-libffi-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: false + +jobs: + libffi: + name: Compile and link external ffi with the actual iOS SDKs + runs-on: macos-latest + timeout-minutes: 40 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Record the selected SDKs and provision ordinary build tools + run: | + set -euo pipefail + xcodebuild -version + for sdk in macosx iphoneos iphonesimulator; do + xcrun --sdk "$sdk" --show-sdk-path + xcrun --sdk "$sdk" --show-sdk-version + done + brew install autoconf autoconf-archive automake libtool ccache python@3.12 + echo "$(brew --prefix python@3.12)/libexec/bin" >> "$GITHUB_PATH" + echo "LIBTOOLIZE=glibtoolize" >> "$GITHUB_ENV" + - name: Fetch only the pinned public Python and libffi build sources + run: | + set -euo pipefail + source_root="$RUNNER_TEMP/renpy-libffi-source" + git clone --depth=1 --filter=blob:none --no-checkout https://github.com/renpy/renpy-build.git "$source_root" + git -C "$source_root" sparse-checkout init --no-cone + git -C "$source_root" sparse-checkout set --no-cone \ + /renpybuild/ /tasks/ /patches/ /source/Python-3.12.8.tar.xz \ + /source/libffi-3.4.5.tar.gz /source/Python-3.12.8-Setup.stdlib /tools/cmake_build_variables.cmake + git -C "$source_root" fetch --depth=1 origin 7bfab40c1174f622f644b24669afd5fb167fbb79 + git -C "$source_root" checkout --detach 7bfab40c1174f622f644b24669afd5fb167fbb79 + test "$(git -C "$source_root" rev-parse HEAD)" = 7bfab40c1174f622f644b24669afd5fb167fbb79 + python3 -m venv "$RUNNER_TEMP/renpy-libffi-venv" + "$RUNNER_TEMP/renpy-libffi-venv/bin/python" -m pip install Cython==3.2.4 Jinja2==3.1.6 requests==2.32.3 + - name: Execute real host and two target SDK configure and link checks + run: | + set -euo pipefail + "$RUNNER_TEMP/renpy-libffi-venv/bin/python" tools/test_renpy_mobile_ios_libffi.py \ + "$RUNNER_TEMP/renpy-libffi-source" --cython "$RUNNER_TEMP/renpy-libffi-venv/bin/cython" \ + --output-dir "$RUNNER_TEMP/renpy-libffi-results" \ + 2>&1 | tee "$RUNNER_TEMP/renpy-libffi-regression.log" + - name: Preserve new regression results + if: always() + uses: actions/upload-artifact@v6 + with: + name: renpy-ios-libffi-regression + path: | + ${{ runner.temp }}/renpy-libffi-results/*.json + ${{ runner.temp }}/renpy-libffi-results/*.map + ${{ runner.temp }}/renpy-libffi-regression.log + if-no-files-found: warn + retention-days: 7 diff --git a/.github/workflows/renpy-ios-renderer.yml b/.github/workflows/renpy-ios-renderer.yml new file mode 100644 index 000000000..a1a7ab4e3 --- /dev/null +++ b/.github/workflows/renpy-ios-renderer.yml @@ -0,0 +1,100 @@ +name: Ren'Py iOS renderer + +on: + pull_request: + paths: + - '.github/workflows/renpy-ios-renderer.yml' + - 'tools/run_renpy_ios_egl_probe.sh' + - 'tools/renpy_ios_egl_probe/**' + - 'vcpkg/ports/sdl2/**' + - 'bridge/renpy_runtime/mobile_launcher/patches/native/**' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: renpy-ios-renderer-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: false + +jobs: + simulator-pbuffer: + name: Execute MetalANGLE EGL rendering on iOS Simulator + runs-on: macos-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Fetch the actual framework from the pinned upstream source + uses: actions/checkout@v6 + with: + repository: renpy/renpy-build + ref: 7bfab40c1174f622f644b24669afd5fb167fbb79 + path: _renpy-probe-source + persist-credentials: false + - name: Build, install and execute the actual Simulator probe + run: | + set -euo pipefail + bash tools/run_renpy_ios_egl_probe.sh \ + --renpy-build "$GITHUB_WORKSPACE/_renpy-probe-source" \ + --output "$GITHUB_WORKSPACE/out/renpy-ios-egl-probe" \ + 2>&1 | tee "$RUNNER_TEMP/renpy-ios-egl-probe.log" + - name: Preserve Simulator results and rendering evidence + if: always() + uses: actions/upload-artifact@v6 + with: + name: renpy-ios-egl-probe + path: | + out/renpy-ios-egl-probe/evidence + ${{ runner.temp }}/renpy-ios-egl-probe.log + if-no-files-found: error + retention-days: 7 + - name: Install the actual SDL configure tools + if: ${{ !cancelled() }} + run: brew install autoconf automake libtool cmake ninja + - name: Build and execute the patched SDL offscreen renderer + run: | + set -euo pipefail + export LIBTOOLIZE=glibtoolize + bash tools/renpy_ios_egl_probe/run_sdl_probe.sh \ + --renpy-build "$GITHUB_WORKSPACE/_renpy-probe-source" \ + --output "$GITHUB_WORKSPACE/out/renpy-ios-sdl-probe" \ + 2>&1 | tee "$RUNNER_TEMP/renpy-ios-sdl-probe.log" + - name: Preserve patched SDL execution evidence + if: always() + uses: actions/upload-artifact@v6 + with: + name: renpy-ios-sdl-probe + path: | + out/renpy-ios-sdl-probe/evidence + ${{ runner.temp }}/renpy-ios-sdl-probe.log + if-no-files-found: warn + retention-days: 7 + - name: Fetch the actual SDL version used by the host + if: ${{ !cancelled() }} + uses: actions/checkout@v6 + with: + repository: libsdl-org/SDL + ref: 5d249570393f7a37e037abf22cd6012a4cc56a71 + path: _host-sdl-probe-source + persist-credentials: false + - name: Build and execute actual UIKit SDL and MetalANGLE isolation + if: ${{ !cancelled() }} + run: | + set -euo pipefail + bash tools/renpy_ios_egl_probe/run_host_gl_probe.sh \ + --renpy-build "$GITHUB_WORKSPACE/_renpy-probe-source" \ + --host-sdl-source "$GITHUB_WORKSPACE/_host-sdl-probe-source" \ + --output "$GITHUB_WORKSPACE/out/renpy-ios-host-gl-probe" \ + 2>&1 | tee "$RUNNER_TEMP/renpy-ios-host-gl-probe.log" + - name: Preserve actual host graphics isolation evidence + if: always() + uses: actions/upload-artifact@v6 + with: + name: renpy-ios-host-gl-probe + path: | + out/renpy-ios-host-gl-probe/evidence + ${{ runner.temp }}/renpy-ios-host-gl-probe.log + if-no-files-found: warn + retention-days: 7 diff --git a/.github/workflows/renpy-ios-runtime-build.yml b/.github/workflows/renpy-ios-runtime-build.yml new file mode 100644 index 000000000..84e188a4c --- /dev/null +++ b/.github/workflows/renpy-ios-runtime-build.yml @@ -0,0 +1,739 @@ +name: Build Ren'Py iOS runtime + +on: + pull_request: + paths: + - '.github/workflows/renpy-ios-runtime-build.yml' + - 'tools/run_renpy_mobile_source_build.sh' + - 'tools/apply_renpy_mobile_patch_series.py' + - 'tools/validate_renpy_mobile_payload.py' + - 'tools/test_renpy_mobile_ios_libffi.py' + - 'tools/test_renpy_mobile_ios_host_python.py' + - 'tools/test_renpy_mobile_assimp_copy.py' + - 'tools/test_renpy_mobile_freetype_host_tools.py' + - 'tools/renpy_assimp_acceptance/**' + - 'tools/renpy_ios_acceptance/**' + - 'tools/run_renpy_ios_device_acceptance.py' + - 'tools/patch_renpy_ios_runtime_project.py' + - 'tools/build_godot_ios_simulator_template.py' + - 'tools/test_godot_ios_simulator_template.py' + - 'tools/godot_ios_simulator_template/**' + - 'tools/build_godot_ios_device_template.py' + - 'tools/test_godot_ios_device_template.py' + - 'tools/test_renpy_ios_force_load.py' + - 'tools/ios_public_vcpkg_cache.py' + - 'tools/test_ios_public_vcpkg_cache.py' + - 'tools/godot_ios_device_template/**' + - 'scripts/build_ios.sh' + - 'bridge/renpy_runtime/mobile_launcher/**' + - 'bridge/renpy_runtime/src/**' + - 'apps/godot_app/**' + workflow_call: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: renpy-ios-source-${{ github.event.pull_request.number || github.ref }}-${{ github.workflow }} + cancel-in-progress: false + +jobs: + ios-test-driver: + name: Compile the actual XCUITest input driver + needs: [ios-runtime] + runs-on: macos-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Build the Swift UI test runner with the real Simulator SDK + run: | + set -euo pipefail + xcodebuild build-for-testing \ + -project tools/renpy_ios_acceptance/RenPyAcceptance.xcodeproj \ + -scheme RenPyAcceptance -configuration Debug \ + -destination 'generic/platform=iOS Simulator' \ + -derivedDataPath "$RUNNER_TEMP/renpy-ui-driver" CODE_SIGN_IDENTITY=- \ + 2>&1 | tee "$RUNNER_TEMP/renpy-ui-driver-build.log" + echo 'Swift runner compiled; this step does not execute RenPy gameplay.' + - name: Preserve the actual Swift build evidence + if: always() + uses: actions/upload-artifact@v6 + with: + name: renpy-ios-ui-driver-build-evidence + path: ${{ runner.temp }}/renpy-ui-driver-build.log + if-no-files-found: error + retention-days: 7 + + ios-runtime: + name: Compile Ren'Py with this cloud runner's Xcode SDKs + runs-on: macos-latest + timeout-minutes: 360 + env: + RENPY_BUILD_ROOT: ${{ github.workspace }}/_renpy-build + RENPY_OUTPUT_ROOT: ${{ github.workspace }}/out/renpy-ios-source + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Use Python 3.12 for payload bytecode validation on cache hits too + uses: actions/setup-python@v6 + with: + python-version: '3.12' + - name: Restore a runtime compiled from these exact source inputs + id: runtime-cache + uses: actions/cache@v6 + with: + path: out/renpy-ios-source + key: ${{ runner.os }}-${{ runner.arch }}-renpy-ios-runtime-v1-${{ hashFiles('tools/run_renpy_mobile_source_build.sh', 'tools/apply_renpy_mobile_patch_series.py', 'bridge/renpy_runtime/mobile_launcher/build.sh', 'bridge/renpy_runtime/mobile_launcher/include/renpy_mobile_launcher.h', 'bridge/renpy_runtime/mobile_launcher/patches/native/*.patch', 'bridge/renpy_runtime/mobile_launcher/patches/native/*.c', 'bridge/renpy_runtime/mobile_launcher/patches/python/*.patch') }} + - name: Provision native macOS prerequisites and record the actual SDKs + if: steps.runtime-cache.outputs.cache-hit != 'true' + run: | + set -euo pipefail + xcodebuild -version + for sdk in macosx iphoneos iphonesimulator; do + xcrun --sdk "$sdk" --show-sdk-path + xcrun --sdk "$sdk" --show-sdk-version + done + brew install autoconf autoconf-archive automake libtool pkg-config \ + ccache cmake ninja uv python@3.12 openssl@3 xz bzip2 libffi gnu-tar coreutils + echo "$(brew --prefix coreutils)/libexec/gnubin" >> "$GITHUB_PATH" + echo "$(brew --prefix gnu-tar)/libexec/gnubin" >> "$GITHUB_PATH" + echo "$(brew --prefix python@3.12)/libexec/bin" >> "$GITHUB_PATH" + brew cleanup + # This isolated source-build job uses only the selected Xcode. Keep + # that installation and all Simulator runtimes; recover space from + # other preinstalled Xcode versions without copying any Apple SDK. + python3 - <<'PY' + import pathlib, shutil, subprocess + developer = pathlib.Path(subprocess.check_output(['xcode-select', '-p'], text=True).strip()).resolve() + selected = next((p for p in developer.parents if p.suffix == '.app'), None) + if selected is None or not (selected / 'Contents/Developer').is_dir(): + raise SystemExit('Cannot identify the selected Xcode; no installations will be removed.') + for app in pathlib.Path('/Applications').glob('Xcode*.app'): + if app.is_symlink() or app.resolve() == selected: + continue + if (app / 'Contents/Developer/usr/bin/xcodebuild').is_file(): + print('Removing unused cloud-runner installation:', app) + subprocess.run(['sudo', 'rm', '-rf', str(app)], check=True) + PY + df -h "$GITHUB_WORKSPACE" + - name: Prepare the pinned source and all native lifecycle patches + if: steps.runtime-cache.outputs.cache-hit != 'true' + run: | + set -euo pipefail + bash tools/run_renpy_mobile_source_build.sh --mode prepare --fetch \ + --platform ios --renpy-build "$RENPY_BUILD_ROOT" \ + --output-dir "$RENPY_OUTPUT_ROOT" 2>&1 | tee "$RUNNER_TEMP/renpy-ios-source-prepare.log" + uv --project "$RENPY_BUILD_ROOT/renpy" run python -m compileall -q \ + "$RENPY_BUILD_ROOT/renpybuild" "$RENPY_BUILD_ROOT/tasks" \ + 2>&1 | tee -a "$RUNNER_TEMP/renpy-ios-source-prepare.log" + uv --project "$RENPY_BUILD_ROOT/renpy" run python \ + "$GITHUB_WORKSPACE/tools/test_renpy_mobile_libffi_cfi.py" "$RENPY_BUILD_ROOT" \ + 2>&1 | tee -a "$RUNNER_TEMP/renpy-ios-source-prepare.log" + uv --project "$RENPY_BUILD_ROOT/renpy" run python \ + "$GITHUB_WORKSPACE/tools/test_renpy_mobile_metalangle_flags.py" "$RENPY_BUILD_ROOT" \ + 2>&1 | tee -a "$RUNNER_TEMP/renpy-ios-source-prepare.log" + uv --project "$RENPY_BUILD_ROOT/renpy" run python \ + "$GITHUB_WORKSPACE/tools/test_renpy_mobile_ios_nis.py" "$RENPY_BUILD_ROOT" \ + 2>&1 | tee -a "$RUNNER_TEMP/renpy-ios-source-prepare.log" + uv --project "$RENPY_BUILD_ROOT/renpy" run python \ + "$GITHUB_WORKSPACE/tools/test_renpy_mobile_ios_libffi.py" "$RENPY_BUILD_ROOT" \ + 2>&1 | tee -a "$RUNNER_TEMP/renpy-ios-source-prepare.log" + uv --project "$RENPY_BUILD_ROOT/renpy" run python \ + "$GITHUB_WORKSPACE/tools/test_renpy_mobile_ios_host_python.py" "$RENPY_BUILD_ROOT" \ + --renpy-src "$RENPY_BUILD_ROOT/renpy" \ + 2>&1 | tee -a "$RUNNER_TEMP/renpy-ios-source-prepare.log" + uv --project "$RENPY_BUILD_ROOT/renpy" run python \ + "$GITHUB_WORKSPACE/tools/test_renpy_mobile_assimp_copy.py" "$RENPY_BUILD_ROOT" \ + --compiler "$(xcrun --sdk macosx -f clang++)" \ + 2>&1 | tee -a "$RUNNER_TEMP/renpy-ios-source-prepare.log" + uv --project "$RENPY_BUILD_ROOT/renpy" run python \ + "$GITHUB_WORKSPACE/tools/test_renpy_mobile_freetype_host_tools.py" "$RENPY_BUILD_ROOT" \ + --output-dir "$RUNNER_TEMP/renpy-freetype-host-tools" --jobs 3 \ + 2>&1 | tee -a "$RUNNER_TEMP/renpy-ios-source-prepare.log" + - name: Compile target CPython, SDL, Ren'Py and device/Simulator archives + if: steps.runtime-cache.outputs.cache-hit != 'true' + run: | + set -euo pipefail + bash tools/run_renpy_mobile_source_build.sh --mode build \ + --platform ios --renpy-build "$RENPY_BUILD_ROOT" \ + --output-dir "$RENPY_OUTPUT_ROOT" 2>&1 | tee "$RUNNER_TEMP/renpy-ios-source-build.log" + - name: Validate both genuine target archives and the Python payload + run: | + set -euo pipefail + python3 - "$RENPY_OUTPUT_ROOT/manifest.json" <<'PY' + import json, sys + manifest = json.load(open(sys.argv[1])) + assert manifest['native_compilation'] == 'passed' + assert manifest['device_gameplay'] == 'unverified' + assert manifest['platform'] == 'ios' + assert manifest['build_source_sha'] == '7bfab40c1174f622f644b24669afd5fb167fbb79' + assert manifest['renpy_source_sha'] == '39895c1e017f0b36ffea2447d97eccd69d76ee1c' + assert manifest['greenlet_source_sha'] == '65f8da82b13a1273e55a6bfcbd1f9da09fc4eb7a' + PY + for kind in release debug; do + sdk=iphoneos + [[ "$kind" != debug ]] || sdk=iphonesimulator + python3 tools/validate_renpy_mobile_payload.py --platform ios --arch arm64 --sdk "$sdk" \ + --library "$RENPY_OUTPUT_ROOT/payload/renios/prototype/prebuilt/$kind/librenpython.a" \ + --private-root "$RENPY_OUTPUT_ROOT/payload/renios/prototype/base" + done + tar -C "$RENPY_OUTPUT_ROOT" -czf "$RUNNER_TEMP/renpy-ios-runtime.tar.gz" payload manifest.json + - name: Upload the real compiled runtime for the application build + uses: actions/upload-artifact@v6 + with: + name: renpy-ios-runtime + path: ${{ runner.temp }}/renpy-ios-runtime.tar.gz + if-no-files-found: error + retention-days: 7 + - name: Preserve actual source-build evidence + if: always() + uses: actions/upload-artifact@v6 + with: + name: renpy-ios-source-build-evidence + path: | + ${{ runner.temp }}/renpy-ios-source-build.log + ${{ runner.temp }}/renpy-ios-source-prepare.log + _renpy-build/tmp/**/config.log + out/renpy-ios-source/manifest.json + if-no-files-found: warn + retention-days: 7 + + ios-simulator-gameplay: + name: Build and exercise the actual Ren'Py Simulator app + needs: [ios-runtime, ios-test-driver] + runs-on: macos-latest + timeout-minutes: 180 + permissions: + contents: read + packages: read + env: + AETHERKIRI_ENABLE_RENPY: ON + AETHERKIRI_ENABLE_INTERNAL: OFF + AETHERKIRI_VCPKG_CACHE_WRITE: 'false' + VCPKG_PUBLIC_CACHE_SDK: iphonesimulator + VCPKG_PUBLIC_CACHE_TRIPLET: arm64-ios-simulator + GODOT_RELEASE: 4.7.2-stable + GODOT_TEMPLATE_VERSION: 4.7.2.stable + JOBS: '3' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Initialize the public application dependencies + run: | + set -euo pipefail + git submodule update --init --recursive --depth 1 \ + packages/OnscripterYuri packages/psdfile packages/AetherSiglus \ + packages/AetherMinori packages/AetherKrkr + - uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-apple-ios-sim + - name: Install application build tools + run: | + set -euo pipefail + brew install cmake ninja pkg-config autoconf autoconf-archive automake \ + libtool gettext coreutils bison nasm ccache mono python@3.12 + echo "$(brew --prefix gettext)/bin" >> "$GITHUB_PATH" + echo "$(brew --prefix bison)/bin" >> "$GITHUB_PATH" + echo "$(brew --prefix python@3.12)/libexec/bin" >> "$GITHUB_PATH" + xcodebuild -version + xcrun --sdk iphonesimulator --show-sdk-version + - name: Check actual Apple archive force-loading before the full app link + run: | + python3 tools/test_renpy_ios_force_load.py --output-dir "$RUNNER_TEMP/renpy-ios-force-load" + - uses: actions/download-artifact@v6 + with: + name: renpy-ios-runtime + path: ${{ runner.temp }}/renpy-ios-runtime + - name: Stage the genuine same-run Ren'Py payload + run: | + set -euo pipefail + mobile_root="$RUNNER_TEMP/renpy-ios-gameplay-runtime" + mkdir -p "$mobile_root" + tar -xzf "$RUNNER_TEMP/renpy-ios-runtime/renpy-ios-runtime.tar.gz" -C "$mobile_root" + echo "AETHERKIRI_RENPY_MOBILE_ROOT=$mobile_root/payload" >> "$GITHUB_ENV" + - name: Install the pinned Godot editor and real export templates + run: | + set -euo pipefail + mkdir -p .godot "$RUNNER_TEMP/godot-templates" + curl --fail --location --retry 3 -o "$RUNNER_TEMP/godot-macos.zip" \ + "https://github.com/godotengine/godot-builds/releases/download/$GODOT_RELEASE/Godot_v${GODOT_RELEASE}_macos.universal.zip" + unzip -q "$RUNNER_TEMP/godot-macos.zip" -d .godot + curl --fail --location --retry 3 -o "$RUNNER_TEMP/godot-templates.tpz" \ + "https://github.com/godotengine/godot-builds/releases/download/$GODOT_RELEASE/Godot_v${GODOT_RELEASE}_export_templates.tpz" + unzip -q "$RUNNER_TEMP/godot-templates.tpz" -d "$RUNNER_TEMP/godot-templates" + template_dir="$HOME/Library/Application Support/Godot/export_templates/$GODOT_TEMPLATE_VERSION" + mkdir -p "$template_dir" + cp -a "$RUNNER_TEMP/godot-templates/templates/." "$template_dir/" + echo "GODOT_BIN=$GITHUB_WORKSPACE/.godot/Godot.app/Contents/MacOS/Godot" >> "$GITHUB_ENV" + echo "GODOT_EXPORT_TEMPLATE=$template_dir/ios.zip" >> "$GITHUB_ENV" + - name: Prepare the normal vcpkg binary cache + env: + VCPKG_PACKAGES_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + git clone https://github.com/microsoft/vcpkg.git .devtools/vcpkg + bash .devtools/vcpkg/bootstrap-vcpkg.sh -disableMetrics + echo "VCPKG_ROOT=$GITHUB_WORKSPACE/.devtools/vcpkg" >> "$GITHUB_ENV" + bash .github/scripts/configure-vcpkg-binary-cache.sh "$GITHUB_WORKSPACE/.devtools/vcpkg/vcpkg" + - name: Exercise the real files provider and hostile archive validation + run: | + set -euo pipefail + python3 tools/test_ios_public_vcpkg_cache.py \ + --vcpkg-root "$VCPKG_ROOT" --cmake "$(command -v cmake)" --ninja "$(command -v ninja)" \ + --output-dir "$RUNNER_TEMP/vcpkg-public-provider-regression" + - name: Link the genuine complete Ren'Py framework before compiling the Godot engine + run: | + set -euo pipefail + bash scripts/build_ios.sh debug --simulator --simulator-arch=arm64 --renpy-framework-only + - name: Install the pinned Godot source build driver + run: | + set -euo pipefail + python3 -m venv "$RUNNER_TEMP/godot-scons" + "$RUNNER_TEMP/godot-scons/bin/python" -m pip install 'SCons==4.9.1' + python3 tools/test_godot_ios_simulator_template.py --template "$GODOT_EXPORT_TEMPLATE" + - name: Identify the exact public Godot source and selected Apple toolchain + id: godot-simulator-cache-key + run: | + set -euo pipefail + cache_key="$("$RUNNER_TEMP/godot-scons/bin/python" tools/build_godot_ios_simulator_template.py cache-key \ + --input-template "$GODOT_EXPORT_TEMPLATE")" + [[ "$cache_key" =~ ^godot-ios-simulator-v1-[0-9a-f]{64}$ ]] + echo "key=$cache_key" >> "$GITHUB_OUTPUT" + - name: Restore only the compiled public Simulator template and its build proof + id: godot-simulator-cache + uses: actions/cache/restore@v6 + with: + path: | + ${{ runner.temp }}/godot-arm64-simulator/ios.zip + ${{ runner.temp }}/godot-arm64-simulator/evidence.json + key: ${{ steps.godot-simulator-cache-key.outputs.key }} + - name: Build the missing real arm64 Godot Simulator engine and camera + if: steps.godot-simulator-cache.outputs.cache-hit != 'true' + run: | + set -euo pipefail + if ! "$RUNNER_TEMP/godot-scons/bin/python" tools/build_godot_ios_simulator_template.py build \ + --input-template "$GODOT_EXPORT_TEMPLATE" \ + --output-template "$RUNNER_TEMP/godot-arm64-simulator/ios.zip" \ + --output-dir "$RUNNER_TEMP/godot-arm64-simulator" --jobs "$JOBS"; then + # Current compiler output only: bounded console diagnostics, no + # raw-log republication or environment/configuration dumps. + python3 - "$RUNNER_TEMP/godot-arm64-simulator/source-build.log" <<'PY' + import pathlib, re, sys + path = pathlib.Path(sys.argv[1]) + if path.is_file(): + content = path.read_text(errors='replace') + content = re.sub(r'\x1b\[[0-?]*[ -/]*[@-~]', '', content) + lines = content.splitlines() + indices = set(range(max(0, len(lines)-100), len(lines))) + for i, line in enumerate(lines): + if re.search(r'error:|fatal error|FAILED|Error [1-9]|undefined|Traceback|No such file', line, re.I): + indices.update(range(max(0, i-3), min(len(lines), i+7))) + excerpt = [lines[i] for i in sorted(indices) + if not re.match(r'^\s*(?:export\s|declare\s|env:|Environment:|[A-Z_][A-Z0-9_]*\s*[=:])', lines[i])] + print('\n'.join(excerpt)[-24000:]) + PY + exit 1 + fi + - name: Verify actual cached or freshly built archive bytes and source identity + run: | + set -euo pipefail + "$RUNNER_TEMP/godot-scons/bin/python" tools/build_godot_ios_simulator_template.py verify-cache \ + --input-template "$GODOT_EXPORT_TEMPLATE" \ + --template "$RUNNER_TEMP/godot-arm64-simulator/ios.zip" \ + --evidence "$RUNNER_TEMP/godot-arm64-simulator/evidence.json" \ + --report "$RUNNER_TEMP/godot-simulator-cache-verification.json" + echo "GODOT_EXPORT_TEMPLATE=$RUNNER_TEMP/godot-arm64-simulator/ios.zip" >> "$GITHUB_ENV" + - name: Save the verified public engine before the application build + if: >- + steps.godot-simulator-cache.outputs.cache-hit != 'true' && + (github.event_name != 'pull_request' || + (github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]')) + uses: actions/cache/save@v6 + with: + path: | + ${{ runner.temp }}/godot-arm64-simulator/ios.zip + ${{ runner.temp }}/godot-arm64-simulator/evidence.json + key: ${{ steps.godot-simulator-cache-key.outputs.key }} + - name: Identify public dependency archives with the actual Apple tools + id: public-vcpkg-family + run: | + set -euo pipefail + # This runs after the normal NuGet read configuration has reached + # GITHUB_ENV. Add only successful public package ZIP writes locally. + case "$VCPKG_BINARY_SOURCES" in + clear\;nuget,*\,read) ;; + *) echo 'The existing NuGet read provider is required.' >&2; exit 1 ;; + esac + family="$(python3 tools/ios_public_vcpkg_cache.py family \ + --repository . --vcpkg-root "$VCPKG_ROOT" \ + --sdk "$VCPKG_PUBLIC_CACHE_SDK" --triplet "$VCPKG_PUBLIC_CACHE_TRIPLET" \ + --internal "$AETHERKIRI_ENABLE_INTERNAL")" + [[ "$family" =~ ^ios-public-vcpkg-v1-[0-9a-f]{64}$ ]] + echo "family=$family" >> "$GITHUB_OUTPUT" + echo "VCPKG_BINARY_SOURCES=$VCPKG_BINARY_SOURCES;files,$RUNNER_TEMP/vcpkg-public-archives,readwrite" >> "$GITHUB_ENV" + - name: Restore a partial public dependency cache for this exact toolchain + uses: actions/cache/restore@v6 + with: + path: ${{ runner.temp }}/vcpkg-public-archives + key: ${{ steps.public-vcpkg-family.outputs.family }}-${{ github.run_id }}-${{ github.run_attempt }} + restore-keys: ${{ steps.public-vcpkg-family.outputs.family }}- + - name: Validate every restored package before vcpkg can read it + run: | + set -euo pipefail + python3 tools/ios_public_vcpkg_cache.py validate \ + --repository . --vcpkg-root "$VCPKG_ROOT" \ + --sdk "$VCPKG_PUBLIC_CACHE_SDK" --triplet "$VCPKG_PUBLIC_CACHE_TRIPLET" \ + --internal "$AETHERKIRI_ENABLE_INTERNAL" \ + --cache-dir "$RUNNER_TEMP/vcpkg-public-archives" \ + --report "$RUNNER_TEMP/vcpkg-public-before.json" + - name: Build the installable arm64 Simulator application + id: public-vcpkg-app + run: | + set -euo pipefail + bash scripts/build_ios.sh debug --simulator --simulator-arch=arm64 \ + 2>&1 | tee "$RUNNER_TEMP/renpy-ios-simulator-build.log" + - name: Validate successful package archives even when the App build fails + id: public-vcpkg-after + continue-on-error: true + if: >- + always() && !cancelled() && + steps.public-vcpkg-app.outcome != 'skipped' && + steps.public-vcpkg-family.outcome == 'success' + run: | + set -euo pipefail + python3 tools/ios_public_vcpkg_cache.py validate \ + --repository . --vcpkg-root "$VCPKG_ROOT" \ + --sdk "$VCPKG_PUBLIC_CACHE_SDK" --triplet "$VCPKG_PUBLIC_CACHE_TRIPLET" \ + --internal "$AETHERKIRI_ENABLE_INTERNAL" \ + --cache-dir "$RUNNER_TEMP/vcpkg-public-archives" \ + --previous-report "$RUNNER_TEMP/vcpkg-public-before.json" \ + --report "$RUNNER_TEMP/vcpkg-public-after.json" --github-output "$GITHUB_OUTPUT" + - name: Save newly built public packages before later gameplay checks + if: >- + always() && !cancelled() && + env.AETHERKIRI_ENABLE_INTERNAL == 'OFF' && + steps.public-vcpkg-after.outcome == 'success' && + steps.public-vcpkg-after.outputs.new-archive-count != '0' && + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository && + github.actor != 'dependabot[bot]' + uses: actions/cache/save@v6 + with: + path: ${{ runner.temp }}/vcpkg-public-archives + key: ${{ steps.public-vcpkg-family.outputs.family }}-${{ github.run_id }}-${{ github.run_attempt }} + - name: Install and execute real gameplay with XCUITest + run: | + set -euo pipefail + python3 tools/run_renpy_ios_device_acceptance.py --cloud-simulator \ + --app out/godot/ios-simulator-arm64/debug/build-simulator-arm64/Aether.app \ + --output-dir out/renpy-ios-gameplay --overall-timeout 600 + - name: Preserve actual Simulator gameplay and build evidence + if: always() + uses: actions/upload-artifact@v6 + with: + name: renpy-ios-gameplay-evidence + path: | + out/renpy-ios-gameplay + ${{ runner.temp }}/renpy-ios-simulator-build.log + ${{ runner.temp }}/godot-arm64-simulator/evidence.json + ${{ runner.temp }}/godot-simulator-cache-verification.json + ${{ runner.temp }}/renpy-ios-force-load/evidence.json + if-no-files-found: warn + retention-days: 7 + - name: Archive the actual Simulator application preserving executable permissions + if: always() + run: | + set -euo pipefail + app=out/godot/ios-simulator-arm64/debug/build-simulator-arm64/Aether.app + if [[ -d "$app" ]]; then + ditto -c -k --sequesterRsrc --keepParent "$app" "$RUNNER_TEMP/Aether-renpy-simulator.app.zip" + fi + - name: Preserve the installable Simulator application + if: always() + uses: actions/upload-artifact@v6 + with: + name: renpy-ios-simulator-app + path: ${{ runner.temp }}/Aether-renpy-simulator.app.zip + if-no-files-found: warn + retention-days: 7 + + ios-device-package: + name: Build the unsigned Ren'Py iPhoneOS package after Simulator gameplay + needs: [ios-runtime, ios-simulator-gameplay] + runs-on: macos-latest + timeout-minutes: 180 + permissions: + contents: read + packages: read + env: + AETHERKIRI_ENABLE_RENPY: ON + AETHERKIRI_ENABLE_INTERNAL: OFF + AETHERKIRI_RENPY_IOS_DEVICE_GLES: ON + AETHERKIRI_VCPKG_CACHE_WRITE: 'false' + VCPKG_PUBLIC_CACHE_SDK: iphoneos + VCPKG_PUBLIC_CACHE_TRIPLET: arm64-ios + GODOT_RELEASE: 4.7.2-stable + GODOT_TEMPLATE_VERSION: 4.7.2.stable + JOBS: '3' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Initialize the public application dependencies + run: | + set -euo pipefail + git submodule update --init --recursive --depth 1 \ + packages/OnscripterYuri packages/psdfile packages/AetherSiglus \ + packages/AetherMinori packages/AetherKrkr + - uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-apple-ios + - name: Install application build tools + run: | + set -euo pipefail + brew install cmake ninja pkg-config autoconf autoconf-archive automake \ + libtool gettext coreutils bison nasm ccache mono python@3.12 + echo "$(brew --prefix gettext)/bin" >> "$GITHUB_PATH" + echo "$(brew --prefix bison)/bin" >> "$GITHUB_PATH" + echo "$(brew --prefix python@3.12)/libexec/bin" >> "$GITHUB_PATH" + xcodebuild -version + xcrun --sdk iphoneos --show-sdk-version + - uses: actions/download-artifact@v6 + with: + name: renpy-ios-runtime + path: ${{ runner.temp }}/renpy-ios-runtime + - name: Stage the genuine same-run Ren'Py payload + run: | + set -euo pipefail + mobile_root="$RUNNER_TEMP/renpy-ios-device-runtime" + mkdir -p "$mobile_root" + tar -xzf "$RUNNER_TEMP/renpy-ios-runtime/renpy-ios-runtime.tar.gz" -C "$mobile_root" + cp "$mobile_root/manifest.json" "$RUNNER_TEMP/renpy-ios-device-runtime-manifest.json" + echo "AETHERKIRI_RENPY_MOBILE_ROOT=$mobile_root/payload" >> "$GITHUB_ENV" + - name: Install the pinned Godot editor and real export templates + run: | + set -euo pipefail + mkdir -p .godot "$RUNNER_TEMP/godot-templates" + curl --fail --location --retry 3 -o "$RUNNER_TEMP/godot-macos.zip" \ + "https://github.com/godotengine/godot-builds/releases/download/$GODOT_RELEASE/Godot_v${GODOT_RELEASE}_macos.universal.zip" + unzip -q "$RUNNER_TEMP/godot-macos.zip" -d .godot + curl --fail --location --retry 3 -o "$RUNNER_TEMP/godot-templates.tpz" \ + "https://github.com/godotengine/godot-builds/releases/download/$GODOT_RELEASE/Godot_v${GODOT_RELEASE}_export_templates.tpz" + unzip -q "$RUNNER_TEMP/godot-templates.tpz" -d "$RUNNER_TEMP/godot-templates" + template_dir="$HOME/Library/Application Support/Godot/export_templates/$GODOT_TEMPLATE_VERSION" + mkdir -p "$template_dir" + cp -a "$RUNNER_TEMP/godot-templates/templates/." "$template_dir/" + echo "GODOT_BIN=$GITHUB_WORKSPACE/.godot/Godot.app/Contents/MacOS/Godot" >> "$GITHUB_ENV" + echo "GODOT_EXPORT_TEMPLATE=$template_dir/ios.zip" >> "$GITHUB_ENV" + - name: Build the real SDL3-free Godot iPhoneOS engine and camera + run: | + set -euo pipefail + python3 -m venv "$RUNNER_TEMP/godot-device-scons" + "$RUNNER_TEMP/godot-device-scons/bin/python" -m pip install 'SCons==4.9.1' + python3 tools/test_godot_ios_device_template.py --template "$GODOT_EXPORT_TEMPLATE" \ + --godot "$GODOT_BIN" --output-dir "$RUNNER_TEMP/godot-device-acceptance" + if ! "$RUNNER_TEMP/godot-device-scons/bin/python" tools/build_godot_ios_device_template.py build \ + --input-template "$GODOT_EXPORT_TEMPLATE" \ + --output-template "$RUNNER_TEMP/godot-renpy-device/ios.zip" \ + --output-dir "$RUNNER_TEMP/godot-renpy-device" --jobs "$JOBS"; then + # Current compiler output only: bounded diagnostics, no raw-log + # republication, environment dumps or additional SDK downloads. + python3 - "$RUNNER_TEMP/godot-renpy-device/source-build.log" <<'PY' + import pathlib, re, sys + path = pathlib.Path(sys.argv[1]) + if path.is_file(): + content = re.sub(r'\x1b\[[0-?]*[ -/]*[@-~]', '', path.read_text(errors='replace')) + lines = content.splitlines() + indices = set(range(max(0, len(lines)-100), len(lines))) + for i, line in enumerate(lines): + if re.search(r'error:|fatal error|FAILED|Error [1-9]|undefined|Traceback|No such file', line, re.I): + indices.update(range(max(0, i-3), min(len(lines), i+7))) + excerpt = [lines[i] for i in sorted(indices) + if not re.match(r'^\s*(?:export\s|declare\s|env:|Environment:|[A-Z_][A-Z0-9_]*\s*[=:])', lines[i])] + print('\n'.join(excerpt)[-24000:]) + PY + exit 1 + fi + python3 tools/build_godot_ios_device_template.py verify \ + --template "$RUNNER_TEMP/godot-renpy-device/ios.zip" + echo "GODOT_EXPORT_TEMPLATE=$RUNNER_TEMP/godot-renpy-device/ios.zip" >> "$GITHUB_ENV" + - name: Prepare the normal vcpkg binary cache + env: + VCPKG_PACKAGES_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + git clone https://github.com/microsoft/vcpkg.git .devtools/vcpkg + bash .devtools/vcpkg/bootstrap-vcpkg.sh -disableMetrics + echo "VCPKG_ROOT=$GITHUB_WORKSPACE/.devtools/vcpkg" >> "$GITHUB_ENV" + bash .github/scripts/configure-vcpkg-binary-cache.sh "$GITHUB_WORKSPACE/.devtools/vcpkg/vcpkg" + - name: Exercise the real files provider and hostile archive validation + run: | + set -euo pipefail + python3 tools/test_ios_public_vcpkg_cache.py \ + --vcpkg-root "$VCPKG_ROOT" --cmake "$(command -v cmake)" --ninja "$(command -v ninja)" \ + --output-dir "$RUNNER_TEMP/vcpkg-public-provider-regression" + - name: Identify public dependency archives with the actual Apple tools + id: public-vcpkg-family + run: | + set -euo pipefail + # This runs after the normal NuGet read configuration has reached + # GITHUB_ENV. Add only successful public package ZIP writes locally. + case "$VCPKG_BINARY_SOURCES" in + clear\;nuget,*\,read) ;; + *) echo 'The existing NuGet read provider is required.' >&2; exit 1 ;; + esac + family="$(python3 tools/ios_public_vcpkg_cache.py family \ + --repository . --vcpkg-root "$VCPKG_ROOT" \ + --sdk "$VCPKG_PUBLIC_CACHE_SDK" --triplet "$VCPKG_PUBLIC_CACHE_TRIPLET" \ + --internal "$AETHERKIRI_ENABLE_INTERNAL")" + [[ "$family" =~ ^ios-public-vcpkg-v1-[0-9a-f]{64}$ ]] + echo "family=$family" >> "$GITHUB_OUTPUT" + echo "VCPKG_BINARY_SOURCES=$VCPKG_BINARY_SOURCES;files,$RUNNER_TEMP/vcpkg-public-archives,readwrite" >> "$GITHUB_ENV" + - name: Restore a partial public dependency cache for this exact toolchain + uses: actions/cache/restore@v6 + with: + path: ${{ runner.temp }}/vcpkg-public-archives + key: ${{ steps.public-vcpkg-family.outputs.family }}-${{ github.run_id }}-${{ github.run_attempt }} + restore-keys: ${{ steps.public-vcpkg-family.outputs.family }}- + - name: Validate every restored package before vcpkg can read it + run: | + set -euo pipefail + python3 tools/ios_public_vcpkg_cache.py validate \ + --repository . --vcpkg-root "$VCPKG_ROOT" \ + --sdk "$VCPKG_PUBLIC_CACHE_SDK" --triplet "$VCPKG_PUBLIC_CACHE_TRIPLET" \ + --internal "$AETHERKIRI_ENABLE_INTERNAL" \ + --cache-dir "$RUNNER_TEMP/vcpkg-public-archives" \ + --report "$RUNNER_TEMP/vcpkg-public-before.json" + - name: Build the genuine iPhoneOS app and unsigned IPA + id: public-vcpkg-app + run: | + set -euo pipefail + # The existing package path invokes xcodebuild with signing disabled + # and validates the actual app's native closure before creating IPA. + bash scripts/build_ios.sh debug --package-ipa \ + 2>&1 | tee "$RUNNER_TEMP/renpy-ios-device-build.log" + test -s out/godot/ios/debug/Aether-Debug-Unsigned.ipa + echo 'Unsigned iPhoneOS package built. Device signing, installation and gameplay remain unverified.' + - name: Validate successful package archives even when the App build fails + id: public-vcpkg-after + continue-on-error: true + if: >- + always() && !cancelled() && + steps.public-vcpkg-app.outcome != 'skipped' && + steps.public-vcpkg-family.outcome == 'success' + run: | + set -euo pipefail + python3 tools/ios_public_vcpkg_cache.py validate \ + --repository . --vcpkg-root "$VCPKG_ROOT" \ + --sdk "$VCPKG_PUBLIC_CACHE_SDK" --triplet "$VCPKG_PUBLIC_CACHE_TRIPLET" \ + --internal "$AETHERKIRI_ENABLE_INTERNAL" \ + --cache-dir "$RUNNER_TEMP/vcpkg-public-archives" \ + --previous-report "$RUNNER_TEMP/vcpkg-public-before.json" \ + --report "$RUNNER_TEMP/vcpkg-public-after.json" --github-output "$GITHUB_OUTPUT" + - name: Save newly built public packages before later gameplay checks + if: >- + always() && !cancelled() && + env.AETHERKIRI_ENABLE_INTERNAL == 'OFF' && + steps.public-vcpkg-after.outcome == 'success' && + steps.public-vcpkg-after.outputs.new-archive-count != '0' && + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository && + github.actor != 'dependabot[bot]' + uses: actions/cache/save@v6 + with: + path: ${{ runner.temp }}/vcpkg-public-archives + key: ${{ steps.public-vcpkg-family.outputs.family }}-${{ github.run_id }}-${{ github.run_attempt }} + - name: Preserve genuine device build and linkage evidence + if: always() + uses: actions/upload-artifact@v6 + with: + name: renpy-ios-unsigned-device-build-evidence + path: | + ${{ runner.temp }}/renpy-ios-device-build.log + ${{ runner.temp }}/renpy-ios-device-runtime-manifest.json + ${{ runner.temp }}/godot-renpy-device/evidence.json + out/ios/debug/renpy-native/exports.txt + out/ios/debug/renpy-native/support-class-symbols.txt + out/ios/debug/renpy-native/linked-libraries.txt + out/ios/debug/renpy-native/host-linkage.txt + out/ios/debug/renpy-native/runtime-linkage.txt + out/godot/ios/debug/Aether/renios/renios-manifest.txt + if-no-files-found: warn + retention-days: 7 + - name: Preserve the unsigned iPhoneOS package requiring device signing + uses: actions/upload-artifact@v6 + with: + name: renpy-ios-device-unsigned-ipa + path: out/godot/ios/debug/Aether-Debug-Unsigned.ipa + if-no-files-found: error + retention-days: 7 + + ios-source-failure-summary: + name: Read a sanitized summary of this run's iOS source failure + needs: [ios-runtime] + if: always() && needs.ios-runtime.result == 'failure' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Require the existing source-build evidence from this run + uses: actions/download-artifact@v6 + with: + name: renpy-ios-source-build-evidence + path: ${{ runner.temp }}/renpy-ios-source-evidence + - name: Read a sanitized compiler excerpt without uploading another log + run: | + set -euo pipefail + python3 - "$RUNNER_TEMP/renpy-ios-source-evidence" "$GITHUB_RUN_ID" "$GITHUB_SHA" <<'PY' + import pathlib, re, sys + # Read genuine compiler evidence, never configure environment dumps. + root = pathlib.Path(sys.argv[1]) + logs = sorted(root.rglob('renpy-ios-source-build.log')) + if not logs: + logs = sorted(root.rglob('renpy-ios-source-prepare.log')) + if not logs: + raise SystemExit('This run has no genuine source-build or prepare log in its preserved evidence.') + print(f'Current iOS source failure run {sys.argv[2]}; checkout head {sys.argv[3]}.') + print('Scope: preserved source compiler/preparation evidence from this run; no build or gameplay is rerun or simulated.') + # Keep the approved renpy-build-diagnostics.yml filter and display cap. + # No upload step republishes the original log or creates an artifact. + remaining = 24000 + for log in logs: + text = log.read_text(errors='replace') + text = re.sub(r'\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)', '', text) + text = re.sub(r'\x1b\[[0-?]*[ -/]*[@-~]', '', text) + text = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', '', text) + lines = text.splitlines() + if not lines: + raise SystemExit(f'The preserved genuine log {log.name} is empty; no compiler evidence is available.') + interesting = [i for i, line in enumerate(lines) + if re.search(r'error:|fatal error|FAILED|Error [1-9]|undefined reference|not found|Traceback|No such file|exception|Killed|process failed|tasks failed', line, re.I)] + indices = set(range(max(0, len(lines)-110), len(lines))) + for i in interesting[-16:]: + indices.update(range(max(0, i-4), min(len(lines), i+9))) + excerpt = [] + for i in sorted(indices): + line = lines[i] + if re.match(r'^\s*(?:export\s|declare\s|env:|Environment:|(?!(?:FAILED|ERROR|FATAL|WARNING):)[A-Z_][A-Z0-9_]*\s*[=:])', line): + continue + if re.search(r'authorization|password|credential|secret|(?:^|[\s_])token(?:[\s_=:]|$)', line, re.I): + continue + line = line.replace('/Users/runner/work/Aether/Aether', '') + line = line.replace('/Users/runner/work/_temp', '') + excerpt.append(line[:1200]) + summary = '\n'.join(excerpt) + if not summary: + raise SystemExit(f'No displayable compiler evidence remains in {log.name} after filtering.') + print(f'Original log: {log.name}; {len(lines)} lines') + if len(summary) > remaining: + summary = summary[-remaining:] + print(summary) + remaining -= len(summary) + if remaining <= 0: + print('Summary reached its display limit; complete original logs remain in this run\'s existing artifact.') + break + PY diff --git a/.github/workflows/renpy-mobile-contract.yml b/.github/workflows/renpy-mobile-contract.yml new file mode 100644 index 000000000..5751245f1 --- /dev/null +++ b/.github/workflows/renpy-mobile-contract.yml @@ -0,0 +1,153 @@ +name: Ren'Py Mobile Contract + +on: + push: + branches: + - main + pull_request: + paths: + - '.github/workflows/renpy-mobile-contract.yml' + - 'bridge/renpy_runtime/mobile_launcher/**' + - 'demos/aetherkiri-renpy/**' + - 'bridge/renpy_runtime/src/renpy_mobile_adapter.cpp' + - 'bridge/renpy_runtime/src/renpy_runtime_mobile_provider.cpp' + - 'bridge/renpy_runtime/tests/mobile_*.cpp' + - 'tools/test_renpy_mobile_launcher_*.sh' + - 'tools/test_renpy_mobile_symbol_gate.sh' + - 'tools/run_renpy_mobile_source_build.sh' + - 'tools/apply_renpy_mobile_patch_series.py' + - 'tools/test_renpy_mobile_patch_series.py' + - 'tools/test_renpy_mobile_source_build_plan.sh' + - 'tools/test_renpy_mobile_native_fork.sh' + - 'tools/test_renpy_mobile_cooperative.py' + - 'tools/test_renpy_mobile_darwin_build.py' + - 'tools/test_renpy_mobile_build_parallelism.py' + - 'tools/test_renpy_mobile_static_api.py' + - 'tools/test_renpy_mobile_libffi_cfi.py' + - 'tools/test_renpy_mobile_metalangle_flags.py' + - 'tools/test_renpy_mobile_ios_nis.py' + - 'tools/validate_renpy_mobile_payload.py' + - 'tools/test_renpy_mobile_packaging.sh' + - 'tools/test_renpy_mobile_runtime_protocol.sh' + - 'tools/test_stage_renpy_android_support.sh' + - 'tools/inspect_renpy_android_apk.py' + - 'tools/test_renpy_android_apk_inspection.py' + - 'tools/prepare_renpy_android_test_export.py' + - 'tools/test_prepare_renpy_android_test_export.py' + - 'tools/test_renpy_device_evidence_metrics.py' + - 'tools/run_renpy_android_device_acceptance.py' + - 'tools/run_renpy_ios_device_acceptance.py' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: renpy-mobile-contract-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + lifecycle-contract: + name: Validate host lifecycle contract (no playability claim) + runs-on: ubuntu-latest + defaults: + run: + shell: bash + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Install contract test tools + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y build-essential binutils clang-18 python3-dev libsdl2-dev pkg-config + + - name: Run mobile launcher scaffold and loader tests + run: | + set -euo pipefail + bash tools/test_renpy_mobile_launcher_scaffold.sh + bash tools/test_renpy_mobile_launcher_loader.sh + bash tools/test_renpy_mobile_symbol_gate.sh + bash tools/test_renpy_mobile_source_build_plan.sh + bash tools/test_renpy_mobile_native_fork.sh + bash tools/test_renpy_mobile_packaging.sh + + - name: Check APK export and device-evidence counterexamples + run: | + set -euo pipefail + # Use only an NDK already supplied by the cloud image. Without it, + # the ELF compiler fixture reports a skip rather than a target pass. + ndk_root="${ANDROID_NDK_HOME:-${ANDROID_NDK_LATEST_HOME:-}}" + if [[ -n "$ndk_root" && -d "$ndk_root" ]]; then + export ANDROID_NDK_HOME="$ndk_root" + fi + python3 tools/test_renpy_android_apk_inspection.py + python3 tools/test_prepare_renpy_android_test_export.py + python3 tools/test_renpy_device_evidence_metrics.py + + - name: Checkout pinned Ren'Py source for the 8.5.3 rebuild + uses: actions/checkout@v6 + with: + repository: renpy/renpy + ref: 39895c1e017f0b36ffea2447d97eccd69d76ee1c + path: _renpy-source + fetch-depth: 1 + - name: Validate cooperative patch applicability + run: | + set -euo pipefail + bash bridge/renpy_runtime/mobile_launcher/build.sh \ + --check-python-patch --renpy-src _renpy-source + + - name: Exercise stack-preserving scheduling and input boundaries + run: | + set -euo pipefail + python3 -m venv "${RUNNER_TEMP}/cooperative-test" + "${RUNNER_TEMP}/cooperative-test/bin/pip" install greenlet==3.2.4 + bash bridge/renpy_runtime/mobile_launcher/build.sh \ + --apply-python-patch --renpy-src _renpy-source + "${RUNNER_TEMP}/cooperative-test/bin/python" \ + tools/test_renpy_mobile_cooperative.py --renpy-src _renpy-source + + - name: Checkout pinned native build tasks for platform configuration tests + uses: actions/checkout@v6 + with: + repository: renpy/renpy-build + ref: 7bfab40c1174f622f644b24669afd5fb167fbb79 + path: _renpy-build-source + persist-credentials: false + - name: Validate Darwin and Linux task configuration boundaries + run: | + set -euo pipefail + "${RUNNER_TEMP}/cooperative-test/bin/pip" install Jinja2==3.1.6 Cython==3.2.4 + "${RUNNER_TEMP}/cooperative-test/bin/python" \ + tools/test_renpy_mobile_darwin_build.py _renpy-build-source + "${RUNNER_TEMP}/cooperative-test/bin/python" \ + tools/test_renpy_mobile_build_parallelism.py _renpy-build-source + "${RUNNER_TEMP}/cooperative-test/bin/python" \ + tools/test_renpy_mobile_patch_series.py _renpy-build-source + "${RUNNER_TEMP}/cooperative-test/bin/python" \ + tools/test_renpy_mobile_libffi_cfi.py _renpy-build-source --clang /usr/bin/clang-18 + "${RUNNER_TEMP}/cooperative-test/bin/python" \ + tools/test_renpy_mobile_metalangle_flags.py _renpy-build-source --clang /usr/bin/clang-18 + "${RUNNER_TEMP}/cooperative-test/bin/python" \ + tools/test_renpy_mobile_ios_nis.py _renpy-build-source + + - name: Generate the static API and compile its genuine C consumers + run: | + set -euo pipefail + "${RUNNER_TEMP}/cooperative-test/bin/python" \ + tools/test_renpy_mobile_static_api.py _renpy-build-source _renpy-source + + - name: Compile the lifecycle implementation against Python and SDL2 + run: | + set -euo pipefail + cc -std=c11 -Wall -Wextra -Werror -fPIC \ + $(python3-config --includes) $(pkg-config --cflags sdl2) \ + -I bridge/renpy_runtime/mobile_launcher/include \ + -c bridge/renpy_runtime/mobile_launcher/patches/native/renpy_mobile_lifecycle.c \ + -o "${RUNNER_TEMP}/renpy_mobile_lifecycle.o" + bash tools/test_renpy_mobile_runtime_protocol.sh + echo "Compiled and exercised the native CPython boundary; this does not execute a mobile game." diff --git a/.github/workflows/renpy-mobile-runtime-build.yml b/.github/workflows/renpy-mobile-runtime-build.yml new file mode 100644 index 000000000..148f500c5 --- /dev/null +++ b/.github/workflows/renpy-mobile-runtime-build.yml @@ -0,0 +1,122 @@ +name: Build Ren'Py Android runtime + +on: + pull_request: + paths: + - '.github/workflows/renpy-mobile-runtime-build.yml' + - 'tools/run_renpy_mobile_source_build.sh' + - 'tools/apply_renpy_mobile_patch_series.py' + - 'bridge/renpy_runtime/mobile_launcher/**' + workflow_call: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: renpy-android-source-${{ github.event.pull_request.number || github.ref }}-${{ github.workflow }} + cancel-in-progress: false + +jobs: + android-runtime: + name: Compile the pinned Ren'Py lifecycle fork + runs-on: ubuntu-24.04 + timeout-minutes: 360 + env: + RENPY_BUILD_ROOT: ${{ github.workspace }}/_renpy-build + RENPY_OUTPUT_ROOT: ${{ github.workspace }}/out/renpy-mobile-source + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Restore a runtime compiled from these exact source inputs + id: runtime-cache + uses: actions/cache@v6 + with: + path: out/renpy-mobile-source + key: ${{ runner.os }}-renpy-android-runtime-v3-${{ hashFiles('tools/run_renpy_mobile_source_build.sh', 'tools/apply_renpy_mobile_patch_series.py', 'bridge/renpy_runtime/mobile_launcher/build.sh', 'bridge/renpy_runtime/mobile_launcher/include/renpy_mobile_launcher.h', 'bridge/renpy_runtime/mobile_launcher/patches/native/*.patch', 'bridge/renpy_runtime/mobile_launcher/patches/native/*.c', 'bridge/renpy_runtime/mobile_launcher/patches/python/*.patch') }} + - name: Provision the official Linux build prerequisites + if: steps.runtime-cache.outputs.cache-hit != 'true' + run: | + set -euo pipefail + # This disposable source-build job does not use the host Android SDK, + # .NET, or Haskell installations. Keep their storage for the rebuild. + sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc \ + /usr/local/.ghcup /usr/local/swift /usr/local/share/boost /opt/hostedtoolcache + sudo apt-get update + sudo apt-get install --no-install-recommends -y \ + build-essential ccache unzip autoconf autoconf-archive automake \ + libtool-bin pkg-config llvm-18 clang-18 lld-18 python3-dev python3-venv \ + curl python3-jinja2 debootstrap libgmp-dev libmpfr-dev libmpc-dev \ + libssl-dev libbz2-dev liblzma-dev bc cmake ninja-build libassimp-dev \ + libavcodec-dev libavformat-dev libswresample-dev libswscale-dev \ + libharfbuzz-dev libfreetype6-dev libfribidi-dev libsdl2-dev \ + libsdl2-image-dev libjpeg-dev + /usr/bin/python3 -m venv "${RUNNER_TEMP}/renpy-tools" + "${RUNNER_TEMP}/renpy-tools/bin/pip" install uv==0.6.17 + echo "${RUNNER_TEMP}/renpy-tools/bin" >> "$GITHUB_PATH" + echo /usr/lib/llvm-18/bin >> "$GITHUB_PATH" + - name: Prepare pinned sources and apply the runtime patches + if: steps.runtime-cache.outputs.cache-hit != 'true' + run: | + set -euo pipefail + bash tools/run_renpy_mobile_source_build.sh --mode prepare --fetch \ + --platform android --renpy-build "$RENPY_BUILD_ROOT" \ + --output-dir "$RENPY_OUTPUT_ROOT" + python3 tools/test_renpy_mobile_optional_sdk.py "$RENPY_BUILD_ROOT" + - name: Supply the official pinned Android NDK + if: steps.runtime-cache.outputs.cache-hit != 'true' + run: | + set -euo pipefail + mkdir -p "$RENPY_BUILD_ROOT/tars" + ndk_archive="$RENPY_BUILD_ROOT/tars/android-ndk-r29-linux.zip" + curl --fail --location --retry 3 \ + https://dl.google.com/android/repository/android-ndk-r29-linux.zip \ + -o "$ndk_archive" + # Published by android/ndk's Unsupported-Downloads page. + printf '87e2bb7e9be5d6a1c6cdf5ec40dd4e0c6d07c30b %s\n' "$ndk_archive" | sha1sum --check + - name: Build the native libraries and Python payload + if: steps.runtime-cache.outputs.cache-hit != 'true' + run: | + set -euo pipefail + bash tools/run_renpy_mobile_source_build.sh --mode build \ + --platform android --renpy-build "$RENPY_BUILD_ROOT" \ + --output-dir "$RENPY_OUTPUT_ROOT" 2>&1 | tee "${RUNNER_TEMP}/renpy-source-build.log" + - name: Validate every deliverable including restored cache entries + run: | + set -euo pipefail + python3 - "$RENPY_OUTPUT_ROOT/manifest.json" <<'PY' + import json, sys + manifest = json.load(open(sys.argv[1])) + assert manifest['native_compilation'] == 'passed' + assert manifest['device_gameplay'] == 'unverified' + assert manifest['platform'] == 'android' + assert manifest['build_source_sha'] == '7bfab40c1174f622f644b24669afd5fb167fbb79' + assert manifest['renpy_source_sha'] == '39895c1e017f0b36ffea2447d97eccd69d76ee1c' + assert manifest['greenlet_source_sha'] == '65f8da82b13a1273e55a6bfcbd1f9da09fc4eb7a' + PY + for abi in arm64-v8a x86_64; do + python3 tools/validate_renpy_mobile_payload.py \ + --platform android --abi "$abi" \ + --library "$RENPY_OUTPUT_ROOT/payload/rapt/prototype/renpyandroid/src/main/jniLibs/$abi/librenpython.so" \ + --private-root "$RENPY_OUTPUT_ROOT/payload/rapt/runtime/private" + done + tar -C "$RENPY_OUTPUT_ROOT" -czf "${RUNNER_TEMP}/renpy-android-runtime.tar.gz" payload manifest.json + - name: Upload the compiled runtime for the application build + uses: actions/upload-artifact@v6 + with: + name: renpy-android-runtime + path: ${{ runner.temp }}/renpy-android-runtime.tar.gz + if-no-files-found: error + retention-days: 7 + - name: Preserve source-build evidence + if: always() + uses: actions/upload-artifact@v6 + with: + name: renpy-source-build-evidence + path: | + ${{ runner.temp }}/renpy-source-build.log + _renpy-build/tmp/**/config.log + out/renpy-mobile-source/manifest.json + if-no-files-found: warn + retention-days: 7 diff --git a/CMakeLists.txt b/CMakeLists.txt index 02c8c5289..c3029b1b5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -111,6 +111,14 @@ option(BUILD_TOOLS "build tools execute build(exclude android ios)" ON) option(BUILD_ENGINE_API "build engine_api bridge shared library" ON) option(BUILD_GODOT_EXTENSION "build Godot GDExtension host library" ON) option(AETHERKIRI_ENABLE_RFVP "build the experimental native FVP runtime (requires Rust)" OFF) +option(AETHERKIRI_ENABLE_RENPY + "build the opt-in Ren'Py desktop or mobile runtime provider" OFF) +set(AETHERKIRI_RENPY_SDK_ROOT "" CACHE PATH + "official desktop Ren'Py SDK directory for the embedded provider") +set(AETHERKIRI_RENPY_MOBILE_LIBRARY "" CACHE FILEPATH + "rebuilt host lifecycle librenpython.so/.a for the selected mobile target") +set(AETHERKIRI_RENPY_PRIVATE_ROOT "" CACHE PATH + "unpacked cooperative Ren'Py Python runtime resources for mobile packaging") option(BUILD_GPU_BRIDGE "build legacy external GPU bridge renderer" OFF) option(AETHERKIRI_ENABLE_ONSCRIPTER "enable the bundled OnscripterYuri runtime" ON) @@ -289,7 +297,8 @@ endif() # krkr2core/krkr2plugin linkage; the Godot extension host links the glue and # installs its engine_legacy_* surface into engine_api through the legacy # services table (Phase 2d link flip — engine_api itself never links it). -if(BUILD_ENGINE_API OR AETHERKIRI_BUILD_NATIVE_ONSCRIPTER_TESTS OR AETHERKIRI_ENABLE_RFVP) +# Build the ABI whenever a provider (including Ren'Py) needs the registry. +if(BUILD_ENGINE_API OR AETHERKIRI_BUILD_NATIVE_ONSCRIPTER_TESTS OR AETHERKIRI_ENABLE_RFVP OR AETHERKIRI_ENABLE_RENPY) add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/bridge/krkr2_runtime) add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/abi) endif() @@ -309,11 +318,15 @@ endif() # product platform that can register Minori. if(AETHERKIRI_ENABLE_MINORI AND BUILD_GODOT_EXTENSION AND (LINUX OR APPLE OR ANDROID OR EMSCRIPTEN)) - add_subdirectory(${AETHERKIRI_MINORI_DIR}) + include(cmake/AetherKiriMinori.cmake) + aetherkiri_add_minori_runtime("${AETHERKIRI_MINORI_DIR}") endif() if(AETHERKIRI_ENABLE_RFVP) add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/bridge/rfvp_runtime) endif() +if(AETHERKIRI_ENABLE_RENPY) + add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/bridge/renpy_runtime) +endif() if(BUILD_GODOT_EXTENSION) add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/bridge/godot_extension) endif() diff --git a/CMakePresets.json b/CMakePresets.json index d5784cd3d..8b0e703d2 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -279,6 +279,27 @@ "VCPKG_BUILD_TYPE": "release" } }, + { + "name": "Android x64 Config", + "inherits": "Android arm64 Config", + "hidden": true, + "binaryDir": "${sourceDir}/out/android/x86_64/${presetName}", + "cacheVariables": { + "ANDROID_ABI": "x86_64", + "VCPKG_TARGET_TRIPLET": "x64-android" + } + }, + { + "name": "Android x64 Debug Config", + "inherits": "Android x64 Config", + "binaryDir": "${sourceDir}/out/android/x86_64/debug", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug", + "VCPKG_BUILD_TYPE": "debug", + "CMAKE_C_FLAGS_DEBUG": "-Og -g", + "CMAKE_CXX_FLAGS_DEBUG": "-Og -g" + } + }, { "name": "Web Config", "generator": "Ninja", @@ -375,6 +396,10 @@ "name": "Android arm64 Release Build", "configurePreset": "Android arm64 Release Config" }, + { + "name": "Android x64 Debug Build", + "configurePreset": "Android x64 Debug Config" + }, { "name": "Web Debug Build", "configurePreset": "Web Debug Config" diff --git a/README.md b/README.md index 90fbe2e0b..3d94912e5 100644 --- a/README.md +++ b/README.md @@ -318,3 +318,41 @@ release, or a distributor they authorize in writing; third-party forks and derivative apps may not rely on that permission, which neither grants rights in upstream or third-party material on behalf of other copyright holders nor revokes rights already granted by the GPL. + + +## Ren'Py support + +Ren'Py desktop support is an opt-in provider. CI and local builds download the +official Ren'Py 8.5.3 SDK through `tools/install_renpy_sdk.sh` and verify its +pinned SHA-256 before extraction. Set `AETHERKIRI_RENPY_SDK_ROOT` to the +extracted SDK when launching a desktop build. Android and iOS builds may link +the provider registration stub, but opening a game returns +`ENGINE_RESULT_NOT_SUPPORTED` until the native mobile adapter is implemented; +Web builds keep Ren'Py disabled. + +The official Ren'Py 8.5.3 Android and iOS support inputs can be downloaded and +staged with `tools/install_renpy_mobile_support.sh`. This verifies the +published RAPT and Renios SHA-256 values and stages the native package +templates, libraries, and project support files under a separate mobile root. +Staging these inputs does not enable mobile gameplay: the Android JNI/Activity +bootstrap, iOS in-process adapter, Godot rendering/input bridge, and +device/simulator E2E remain required before a mobile provider can be enabled. + +When `AETHERKIRI_ENABLE_RENPY=ON`, `scripts/build_android.sh` copies the +official RAPT arm64 `librenpython.so`, Java/resource templates, and any +`AETHERKIRI_RENPY_ANDROID_PRIVATE_ASSETS` into the existing Godot Android +template. The RAPT launcher is packaged as assets rather than merged into the +manifest, and `RenPyMobileBridge` only binds the Activity Godot already owns. +This is a package/JNI smoke milestone; Ren'Py mobile open remains explicitly +`ENGINE_RESULT_NOT_SUPPORTED` until lifecycle, SDL surface, input, and render +handoff are implemented. + +For an iOS link/bundle smoke, set `AETHERKIRI_ENABLE_RENPY=ON` and +`AETHERKIRI_RENPY_MOBILE_ROOT` to the staged root before running +`scripts/build_ios.sh`. The build folds the official Renios static dependency +closure into the Godot extension and bundles its resources and MetalANGLE +framework, while deliberately omitting the prototype application entrypoint +and retaining `ENGINE_RESULT_NOT_SUPPORTED` until lifecycle, rendering, and +input integration are complete. +Provide `AETHERKIRI_RENPY_RENIOS_BASE` when a generated game `base/` directory +should be included in that smoke bundle. diff --git a/abi/src/android_jni_bridge.cpp b/abi/src/android_jni_bridge.cpp index e8306f048..dab27bcc1 100644 --- a/abi/src/android_jni_bridge.cpp +++ b/abi/src/android_jni_bridge.cpp @@ -117,6 +117,10 @@ static ANativeWindow* g_native_window = nullptr; static uint32_t g_surface_width = 0; static uint32_t g_surface_height = 0; static std::mutex g_surface_mutex; +// Keep the Java Surface alongside ANativeWindow so the host-owned RAPT shim +// can return the exact Surface already attached by Godot's EngineBridge. +static jobject g_host_surface = nullptr; // global ref +static std::mutex g_host_surface_mutex; // --------------------------------------------------------------------------- // Global Application Context @@ -128,11 +132,23 @@ static std::mutex g_surface_mutex; static jobject g_app_context = nullptr; // global ref static std::mutex g_context_mutex; +// The host Activity is optional during the staging milestone. When the +// generated Godot Activity calls RenPyMobileBridge.bindHostActivity, keep a +// global JNI reference so a future in-process adapter can attach without +// constructing a second Activity. +static jobject g_host_activity = nullptr; // global ref +static std::mutex g_host_activity_mutex; + __attribute__((visibility("default"))) jobject krkr_GetApplicationContext() { std::lock_guard lock(g_context_mutex); return g_app_context; } +__attribute__((visibility("default"))) jobject krkr_GetHostActivity() { + std::lock_guard lock(g_host_activity_mutex); + return g_host_activity; +} + __attribute__((visibility("default"))) ANativeWindow* krkr_GetNativeWindow() { std::lock_guard lock(g_surface_mutex); if (g_native_window) { @@ -177,6 +193,17 @@ extern "C" JNIEXPORT void JNICALL Java_org_github_krkr2_aetherkiri_EngineBridge_nativeSetSurface( JNIEnv* env, jobject /* thiz */, jobject surface, jint width, jint height) { + { + std::lock_guard surface_ref_lock(g_host_surface_mutex); + if (g_host_surface) { + env->DeleteGlobalRef(g_host_surface); + g_host_surface = nullptr; + } + if (surface) { + g_host_surface = env->NewGlobalRef(surface); + } + } + std::lock_guard lock(g_surface_mutex); // Release previous window if any @@ -203,7 +230,15 @@ Java_org_github_krkr2_aetherkiri_EngineBridge_nativeSetSurface( extern "C" JNIEXPORT void JNICALL Java_org_github_krkr2_aetherkiri_EngineBridge_nativeDetachSurface( - JNIEnv* /* env */, jobject /* thiz */) { + JNIEnv* env, jobject /* thiz */) { + + { + std::lock_guard surface_ref_lock(g_host_surface_mutex); + if (g_host_surface) { + env->DeleteGlobalRef(g_host_surface); + g_host_surface = nullptr; + } + } std::lock_guard lock(g_surface_mutex); if (g_native_window) { @@ -215,6 +250,26 @@ Java_org_github_krkr2_aetherkiri_EngineBridge_nativeDetachSurface( } } +// --------------------------------------------------------------------------- +// JNI bridge: host-owned RAPT SDL callback shims +// Returns the existing Godot Surface/Context; never creates a View, Surface, +// or Activity. The official RAPT SDLActivity remains an asset-only template. +// --------------------------------------------------------------------------- + +extern "C" JNIEXPORT jobject JNICALL +Java_org_libsdl_app_SDLActivity_getNativeSurface(JNIEnv* env, + jclass /* clazz */) { + std::lock_guard lock(g_host_surface_mutex); + return g_host_surface ? env->NewLocalRef(g_host_surface) : nullptr; +} + +extern "C" JNIEXPORT jobject JNICALL +Java_org_libsdl_app_SDLActivity_getContext(JNIEnv* env, + jclass /* clazz */) { + std::lock_guard lock(g_context_mutex); + return g_app_context ? env->NewLocalRef(g_app_context) : nullptr; +} + // --------------------------------------------------------------------------- // JNI bridge: Android host -> C++ engine // Passes the Android Application Context for use by engine code that @@ -240,3 +295,25 @@ Java_org_github_krkr2_aetherkiri_EngineBridge_nativeSetApplicationContext( LOGW("nativeSetApplicationContext: null context passed"); } } + +// --------------------------------------------------------------------------- +// JNI bridge: host-owned Ren'Py handoff +// --------------------------------------------------------------------------- + +extern "C" JNIEXPORT void JNICALL +Java_org_github_krkr2_aetherkiri_RenPyMobileBridge_nativeSetHostActivity( + JNIEnv* env, jclass /* clazz */, jobject activity) { + std::lock_guard lock(g_host_activity_mutex); + + if (g_host_activity) { + env->DeleteGlobalRef(g_host_activity); + g_host_activity = nullptr; + } + + if (activity) { + g_host_activity = env->NewGlobalRef(activity); + LOGI("RenPyMobileBridge: host Activity bound"); + } else { + LOGI("RenPyMobileBridge: host Activity cleared"); + } +} diff --git a/abi/src/engine_api_dispatch.cpp b/abi/src/engine_api_dispatch.cpp index 260a1139b..1f30eeab5 100644 --- a/abi/src/engine_api_dispatch.cpp +++ b/abi/src/engine_api_dispatch.cpp @@ -1459,6 +1459,14 @@ engine_result_t engine_set_option(engine_handle_t public_handle, option->value_utf8); } handle->pending_options[option->key_utf8] = option->value_utf8; + // Runtime-specific options are configured before open_game selects the + // provider. Keep them queued for provider creation instead of falling + // through to the legacy backend while the handle is still undecided. + if (handle->backend == BackendKind::kUndecided && + key.rfind("renpy_", 0) == 0) { + SetThreadError(nullptr); + return ENGINE_RESULT_OK; + } if (handle->backend == BackendKind::kProvider) { if (!PROVIDER_HAS(handle->provider, set_option)) { return Unsupported(handle, "set_option"); diff --git a/apps/godot_app/aether_kiri.gdextension b/apps/godot_app/aether_kiri.gdextension index 12d85b8b7..d8574b4f7 100644 --- a/apps/godot_app/aether_kiri.gdextension +++ b/apps/godot_app/aether_kiri.gdextension @@ -11,7 +11,10 @@ macos.debug = "res://bin/macos/debug/libaether_kiri_godot.dylib" macos.release = "res://bin/macos/release/libaether_kiri_godot.dylib" ios.debug = "res://bin/ios/debug/libaether_kiri_godot.a" ios.release = "res://bin/ios/release/libaether_kiri_godot.a" +ios.ios_simulator.debug = "res://bin/ios-simulator/debug/libaether_kiri_godot.a" +ios.ios_simulator.ios_simulator_x64.debug = "res://bin/ios-simulator-x64/debug/libaether_kiri_godot.a" android.debug.arm64 = "res://bin/android/arm64-v8a/debug/libaether_kiri_godot.so" +android.debug.x86_64 = "res://bin/android/x86_64/debug/libaether_kiri_godot.so" android.release.arm64 = "res://bin/android/arm64-v8a/release/libaether_kiri_godot.so" web.debug.wasm32 = "res://bin/web/debug/aether_kiri_godot.wasm" web.release.wasm32 = "res://bin/web/release/aether_kiri_godot.wasm" @@ -46,3 +49,8 @@ android.release.arm64 = { "res://bin/android/arm64-v8a/release/libSDL2.so": "", "res://bin/android/arm64-v8a/release/libomp.so": "" } +android.debug.x86_64 = { + "res://bin/android/x86_64/debug/libengine_api.so": "", + "res://bin/android/x86_64/debug/libSDL2.so": "", + "res://bin/android/x86_64/debug/libomp.so": "" +} diff --git a/apps/godot_app/export_presets.cfg b/apps/godot_app/export_presets.cfg index 02ea10eaa..7128a63e3 100644 --- a/apps/godot_app/export_presets.cfg +++ b/apps/godot_app/export_presets.cfg @@ -247,3 +247,110 @@ export_path="../../out/godot/linux/release/Aether.x86_64" binary_format/embed_pck=true binary_format/architecture="x86_64" debug/export_console_wrapper=0 + +[preset.10] +name="Android Emulator Debug" +platform="Android" +runnable=true +custom_features="renpy_emulator" +export_filter="all_resources" +include_filter="builtin_demos/**/*.xp3,builtin_demos/**/*.json,builtin_demos/**/*.txt,legal/*.txt" +exclude_filter="scripts/diagnostic_session_test.gd,scripts/builtin_demo_test.gd,scripts/game_input_mapping_test.gd,tests/**" +export_path="../../out/godot/android/x86_64/debug/Aether-emulator-debug.apk" + +[preset.10.options] +custom_template/debug="" +custom_template/release="" +gradle_build/use_gradle_build=true +gradle_build/min_sdk=26 +architectures/armeabi-v7a=false +architectures/arm64-v8a=false +architectures/x86=false +architectures/x86_64=true +version/code=4501 +version/name="1.0.7-alpha.3" +package/unique_name="org.github.krkr2.aetherkiri" +package/name="Aether" +package/signed=true +screen/immersive_mode=true +screen/support_small=true +screen/support_normal=true +screen/support_large=true +screen/support_xlarge=true +user_data_backup/allow=false +permissions/custom_permissions=PackedStringArray("android.permission.INTERNET", "android.permission.ACCESS_NETWORK_STATE", "android.permission.READ_EXTERNAL_STORAGE", "android.permission.WRITE_EXTERNAL_STORAGE", "android.permission.MANAGE_EXTERNAL_STORAGE") +launcher_icons/main_192x192="res://assets/icons/icon_192x192.png" +launcher_icons/adaptive_foreground_432x432="res://assets/icons/icon_adaptive_fg.png" +launcher_icons/adaptive_background_432x432="res://assets/icons/icon_adaptive_bg.png" + +[preset.11] +name="iOS Simulator Debug" +platform="iOS" +runnable=false +custom_features="ios_simulator" +export_filter="all_resources" +include_filter="builtin_demos/**/*.xp3,builtin_demos/**/*.json,builtin_demos/**/*.txt,legal/*.txt" +exclude_filter="scripts/diagnostic_session_test.gd,scripts/builtin_demo_test.gd,scripts/game_input_mapping_test.gd,tests/**" +export_path="../../out/godot/ios-simulator-arm64/debug/Aether.xcodeproj" + +[preset.11.options] +application/app_store_team_id="3JL7FE9XQT" +application/bundle_identifier="org.aetherkiri.renpy-simulator.debug" +application/short_version="1.0.7" +application/version="4501" +application/min_ios_version="16.0" +application/export_project_only=true +user_data/accessible_from_files_app=true +user_data/accessible_from_itunes_sharing=true +icons/app_store_1024x1024="res://assets/icons/app_store_1024x1024.png" +icons/iphone_120x120="res://assets/icons/icon_120x120.png" +icons/iphone_180x180="res://assets/icons/icon_180x180.png" +icons/ipad_152x152="res://assets/icons/icon_152x152.png" +icons/ipad_167x167="res://assets/icons/icon_167x167.png" +icons/settings_58x58="res://assets/icons/icon_58x58.png" +icons/settings_87x87="res://assets/icons/icon_87x87.png" +icons/spotlight_80x80="res://assets/icons/icon_80x80.png" +icons/spotlight_120x120="res://assets/icons/icon_120x120.png" +icons/notification_40x40="res://assets/icons/icon_40x40.png" +icons/notification_60x60="res://assets/icons/icon_60x60.png" +icons/notification_76x76="res://assets/icons/icon_76x76.png" +icons/notification_114x114="res://assets/icons/icon_114x114.png" +icons/ios_128x128="res://assets/icons/icon_128x128.png" +icons/ios_136x136="res://assets/icons/icon_136x136.png" +icons/ios_192x192="res://assets/icons/icon_192x192.png" + +[preset.12] +name="iOS Simulator x64 Debug" +platform="iOS" +runnable=false +custom_features="ios_simulator,ios_simulator_x64" +export_filter="all_resources" +include_filter="builtin_demos/**/*.xp3,builtin_demos/**/*.json,builtin_demos/**/*.txt,legal/*.txt" +exclude_filter="scripts/diagnostic_session_test.gd,scripts/builtin_demo_test.gd,scripts/game_input_mapping_test.gd,tests/**" +export_path="../../out/godot/ios-simulator-x86_64/debug/Aether.xcodeproj" + +[preset.12.options] +application/app_store_team_id="3JL7FE9XQT" +application/bundle_identifier="org.aetherkiri.renpy-simulator.debug" +application/short_version="1.0.7" +application/version="4501" +application/min_ios_version="16.0" +application/export_project_only=true +user_data/accessible_from_files_app=true +user_data/accessible_from_itunes_sharing=true +icons/app_store_1024x1024="res://assets/icons/app_store_1024x1024.png" +icons/iphone_120x120="res://assets/icons/icon_120x120.png" +icons/iphone_180x180="res://assets/icons/icon_180x180.png" +icons/ipad_152x152="res://assets/icons/icon_152x152.png" +icons/ipad_167x167="res://assets/icons/icon_167x167.png" +icons/settings_58x58="res://assets/icons/icon_58x58.png" +icons/settings_87x87="res://assets/icons/icon_87x87.png" +icons/spotlight_80x80="res://assets/icons/icon_80x80.png" +icons/spotlight_120x120="res://assets/icons/icon_120x120.png" +icons/notification_40x40="res://assets/icons/icon_40x40.png" +icons/notification_60x60="res://assets/icons/icon_60x60.png" +icons/notification_76x76="res://assets/icons/icon_76x76.png" +icons/notification_114x114="res://assets/icons/icon_114x114.png" +icons/ios_128x128="res://assets/icons/icon_128x128.png" +icons/ios_136x136="res://assets/icons/icon_136x136.png" +icons/ios_192x192="res://assets/icons/icon_192x192.png" diff --git a/apps/godot_app/project.godot b/apps/godot_app/project.godot index 0cb6d02f2..354b7bcbf 100644 --- a/apps/godot_app/project.godot +++ b/apps/godot_app/project.godot @@ -49,4 +49,6 @@ rendering_device/fallback_to_opengl3=true rendering/gl_compatibility/driver.android="opengl3" renderer/rendering_method="mobile" textures/vram_compression/import_etc2_astc=true +renderer/rendering_method.renpy_emulator="gl_compatibility" renderer/rendering_method.android="mobile" +renderer/rendering_method.ios_simulator="gl_compatibility" diff --git a/apps/godot_app/scripts/game_input_mapping.gd b/apps/godot_app/scripts/game_input_mapping.gd index 51720cc19..7774fd187 100644 --- a/apps/godot_app/scripts/game_input_mapping.gd +++ b/apps/godot_app/scripts/game_input_mapping.gd @@ -7,7 +7,7 @@ static func input_surface_size( content_size: Vector2, requested_surface: Vector2 ) -> Vector2: - if runtime_kind in ["minori", "onscripter", "rfvp", "siglus", "softpal"]: + if runtime_kind in ["minori", "onscripter", "rfvp", "siglus", "renpy", "softpal"]: return content_size if requested_surface.x > 0.0 and requested_surface.y > 0.0: return requested_surface diff --git a/apps/godot_app/scripts/game_launch_entry.gd b/apps/godot_app/scripts/game_launch_entry.gd index d676c3640..d64cf4468 100644 --- a/apps/godot_app/scripts/game_launch_entry.gd +++ b/apps/godot_app/scripts/game_launch_entry.gd @@ -1,5 +1,7 @@ extends RefCounted +const GameMetadata = preload("res://scripts/game_metadata.gd") + const FIELD := "launchFile" const SUPPORTED_EXTENSIONS := ["exe", "xp3", "hcb"] const DIRECTORY_RUNTIME_KINDS := [ @@ -7,6 +9,7 @@ const DIRECTORY_RUNTIME_KINDS := [ "catsystem2", "minori", "onscripter", + "renpy", "siglus", "softpal", "wa2", @@ -60,6 +63,10 @@ static func resolve_for_runtime( # Runtime providers and directory-based runtimes consume the game root. A # selected EXE/XP3 is only a legacy KiriKiri entry point and must not # replace the root passed to a provider probe/open pair. + if runtime_kind.to_lower() == "renpy": + var path := _normalize_path(String(game.get("path", ""))) + var project_root := GameMetadata.renpy_project_root(path) + return project_root if not project_root.is_empty() else path if requires_game_root or runtime_uses_directory(runtime_kind): # Preserve valid trailing spaces from native file-picker paths. return _normalize_path(String(game.get("path", ""))) diff --git a/apps/godot_app/scripts/game_launch_entry_test.gd b/apps/godot_app/scripts/game_launch_entry_test.gd index e44bb61b6..23f51110f 100644 --- a/apps/godot_app/scripts/game_launch_entry_test.gd +++ b/apps/godot_app/scripts/game_launch_entry_test.gd @@ -151,6 +151,27 @@ func _init() -> void: spaced_root.path_join("cs2.exe"), "launch file inside trailing-space directory" ) + _expect_equal( + GameLaunchEntry.resolve_for_runtime( + {"path": root, GameLaunchEntry.FIELD: "launcher.exe"}, "renpy" + ), root, "Ren'Py ignores legacy launcher selection" + ) + var renpy_root := ProjectSettings.globalize_path("user://renpy_launch_%d" % Time.get_ticks_usec()) + DirAccess.make_dir_recursive_absolute(renpy_root.path_join("game")) + var renpy_script := renpy_root.path_join("game/script.rpy") + var script_file := FileAccess.open(renpy_script, FileAccess.WRITE) + script_file.store_string("label start:\n return\n") + script_file.close() + var renpy_launcher := renpy_root.path_join("launcher.exe") + var launcher_file := FileAccess.open(renpy_launcher, FileAccess.WRITE) + launcher_file.store_string("launcher") + launcher_file.close() + for selected in [renpy_root, renpy_root + "/", renpy_root.path_join("game"), renpy_script, renpy_launcher]: + _expect_equal(GameLaunchEntry.resolve_for_runtime({"path": selected}, "renpy"), renpy_root, "Ren'Py normalized launch root") + DirAccess.remove_absolute(renpy_script) + DirAccess.remove_absolute(renpy_launcher) + DirAccess.remove_absolute(renpy_root.path_join("game")) + DirAccess.remove_absolute(renpy_root) if failures == 0: print("game_launch_entry_test: PASS") quit(0) diff --git a/apps/godot_app/scripts/game_metadata.gd b/apps/godot_app/scripts/game_metadata.gd index 3be5c1b19..e67e49c72 100644 --- a/apps/godot_app/scripts/game_metadata.gd +++ b/apps/godot_app/scripts/game_metadata.gd @@ -4,14 +4,18 @@ extends RefCounted const RUNTIME_KIRIKIRI := "kirikiri" const RUNTIME_ONSCRIPTER := "onscripter" const RUNTIME_RFVP := "rfvp" +const RUNTIME_RENPY := "renpy" const RUNTIME_ARTEMIS := "artemis" const RUNTIME_SIGLUS := "siglus" const RUNTIME_CATSYSTEM2 := "catsystem2" static func inspect(path: String) -> Dictionary: - var root := path + var root := path.replace("\\", "/") if FileAccess.file_exists(root): root = root.get_base_dir() + var renpy_root := renpy_project_root(root) + if not renpy_root.is_empty(): + root = renpy_root var result := { "engine": RUNTIME_KIRIKIRI, "title": "", @@ -39,6 +43,11 @@ static func inspect(path: String) -> Dictionary: ): result.engine = RUNTIME_ONSCRIPTER result.signals.append("onscript-marker") + elif not renpy_root.is_empty(): + # Scripts must be in a project game/ directory. Loose Python files, + # launcher names, and archive extensions alone are not sufficient. + result.engine = RUNTIME_RENPY + result.signals.append("renpy-package") elif _is_catsystem2_package(files): # CatSystem2 titles commonly ship a Windows launcher beside loose or # packed INT resources. Keep the directory as the launch path so the @@ -194,6 +203,48 @@ static func _has_prefix(values: PackedStringArray, prefix: String) -> bool: return true return false +static func renpy_project_root(path: String) -> String: + var root := path.replace("\\", "/").simplify_path() + if FileAccess.file_exists(root): + root = root.get_base_dir() + if _is_renpy_project(root): + return root + # A picker may return game/ or one of its script/archive files. Only + # promote it when the parent passes the same project validation. + if root.get_file() == "game" and _is_renpy_project(root.get_base_dir()): + return root.get_base_dir() + return "" + +static func _is_renpy_project(root: String) -> bool: + return not root.is_empty() and _has_renpy_scripts(root.path_join("game")) + +static func _has_renpy_scripts(root: String, depth: int = 0) -> bool: + var dir := DirAccess.open(root) + if dir == null or depth > 8: + return false + for entry in dir.get_files(): + var extension := entry.get_extension().to_lower() + if extension not in ["rpy", "rpyc", "rpa"]: + continue + var file := FileAccess.open(root.path_join(entry), FileAccess.READ) + if file == null or file.get_length() == 0: + continue + if extension == "rpy": + return true + # Compiled scripts and archives carry signatures. Do not claim + # unrelated files merely because their extension is suggestive. + var signature := file.get_buffer(10).get_string_from_ascii() + if extension == "rpyc" and signature == "RENPY RPC2": + return true + if extension == "rpa" and (signature.begins_with("RPA-2.0 ") \ + or signature.begins_with("RPA-3.0 ")): + return true + for entry in dir.get_directories(): + if entry not in ["saves", "cache"] \ + and _has_renpy_scripts(root.path_join(entry), depth + 1): + return true + return false + static func _has_extension(values: PackedStringArray, extension: String) -> bool: var normalized := extension.to_lower().trim_prefix(".") for value in values: diff --git a/apps/godot_app/scripts/game_metadata_test.gd b/apps/godot_app/scripts/game_metadata_test.gd index d99c39282..b23b84e55 100644 --- a/apps/godot_app/scripts/game_metadata_test.gd +++ b/apps/godot_app/scripts/game_metadata_test.gd @@ -98,6 +98,38 @@ func _init() -> void: "invalid HCB does not claim another runtime" ) + var renpy_root := fixture_root.path_join("renpy_project") + DirAccess.make_dir_recursive_absolute(renpy_root.path_join("game")) + _write(renpy_root.path_join("renpy.exe"), "launcher") + _expect_equal(String(GameMetadata.inspect(renpy_root).engine), "kirikiri", "launcher alone is not Ren'Py") + _write(renpy_root.path_join("game/archive.rpa"), "not a Ren'Py archive") + _expect_equal(String(GameMetadata.inspect(renpy_root).engine), "kirikiri", "invalid archive rejected") + _write(renpy_root.path_join("game/script.rpyc"), "not a compiled script") + _expect_equal(String(GameMetadata.inspect(renpy_root).engine), "kirikiri", "invalid compiled script rejected") + _write(renpy_root.path_join("game/script.rpy"), "label start:\n return\n") + for path in [renpy_root, renpy_root.path_join("renpy.exe"), renpy_root.path_join("game"), renpy_root.path_join("game/script.rpy")]: + var renpy := GameMetadata.inspect(path) + _expect_equal(String(renpy.engine), "renpy", "Ren'Py selected path " + path) + _expect_equal(String(renpy.launchFile), "", "Ren'Py keeps directory launch") + _expect_equal(GameMetadata.renpy_project_root(path), renpy_root, "Ren'Py normalizes to project root") + _expect_true(Array(renpy.signals).has("renpy-package"), "Ren'Py detection signal") + DirAccess.remove_absolute(renpy_root.path_join("game/script.rpy")) + _write(renpy_root.path_join("game/script.rpyc"), "RENPY RPC2fixture") + _expect_equal(String(GameMetadata.inspect(renpy_root).engine), "renpy", "compiled Ren'Py script signature") + DirAccess.remove_absolute(renpy_root.path_join("game/script.rpyc")) + _write(renpy_root.path_join("game/archive.rpa"), "RPA-3.0 0000000000000010 00000000\nfixture") + _expect_equal(String(GameMetadata.inspect(renpy_root).engine), "renpy", "Ren'Py archive signature") + DirAccess.remove_absolute(renpy_root.path_join("game/archive.rpa")) + DirAccess.make_dir_recursive_absolute(renpy_root.path_join("game/story")) + _write(renpy_root.path_join("game/story/chapter.RPY"), "label start:\n return\n") + _expect_equal(String(GameMetadata.inspect(renpy_root).engine), "renpy", "nested source project without bundled runtime") + _write(renpy_root.path_join("0.txt"), "*start\nend\n") + _expect_equal(String(GameMetadata.inspect(renpy_root).engine), "onscripter", "existing ONS marker retains precedence") + var loose_root := fixture_root.path_join("loose_script") + DirAccess.make_dir_recursive_absolute(loose_root) + _write(loose_root.path_join("script.rpy"), "label start:\n return\n") + _expect_equal(String(GameMetadata.inspect(loose_root).engine), "kirikiri", "loose script requires project layout") + _remove_tree(fixture_root) if failures == 0: print("game_metadata_test: PASS") diff --git a/apps/godot_app/scripts/main.gd b/apps/godot_app/scripts/main.gd index a5ad4dd12..6ee5a5033 100644 --- a/apps/godot_app/scripts/main.gd +++ b/apps/godot_app/scripts/main.gd @@ -58,10 +58,21 @@ const RUNTIME_FONT_DIR := "user://runtime_fonts" const RUNTIME_DEFAULT_FONT_FILE := "default.otf" const RUNTIME_SYMBOL_FONT_FILE := "symbols.ttf" const ProbeConfig = preload("res://scripts/probe_config.gd") +const RENPY_STARTUP_TRACE_PATH := "user://renpy-device-evidence/startup.jsonl" +const RENPY_STARTUP_TRACE_MAX_TICKS := 9007199254740991 +const RENPY_STARTUP_PHASES := [ + "main_ready_entered", "ui_build_entered", "ui_build_ready", + "player_create_entered", "player_create_ready", "player_create_failed", + "ready_frame_pending", "ready_frame_entered", + "engine_initialize_entered", "engine_initialize_ready", "engine_initialize_failed", + "observer_create_entered", "observer_create_ready", + "observer_start_entered", "observer_start_returned", +] const GameMetadata = preload("res://scripts/game_metadata.gd") const CoverIndex = preload("res://scripts/cover_index.gd") const VNDBCoverResolver = preload("res://scripts/vndb_cover_resolver.gd") const GameInputMapping = preload("res://scripts/game_input_mapping.gd") +const RenpyKeyInput = preload("res://scripts/renpy_key_input.gd") const GameVirtualControls = preload("res://scripts/game_virtual_controls.gd") const DiagnosticSession = preload("res://scripts/diagnostic_session.gd") const DiagnosticLocalization = preload("res://scripts/diagnostic_localization.gd") @@ -1823,6 +1834,7 @@ const POINTER_MOD_CANCEL := 1 << 30 const KEY_MOD_CONTROL := 0x04 const RUNTIME_KIRIKIRI := "kirikiri" const RUNTIME_ONSCRIPTER := "onscripter" +const RUNTIME_RENPY := "renpy" const RUNTIME_MINORI := "minori" const RUNTIME_CATSYSTEM2 := "catsystem2" const RUNTIME_SIGLUS := "siglus" @@ -2237,6 +2249,8 @@ var black_frame_consecutive := 0 var black_frame_last_log_msec := 0 var black_frame_guard_enabled := false var cli_probe_script := "" +var renpy_startup_trace_run_id := "" +var renpy_startup_trace_seq := 0 var cli_probe_runtime_debug := false var verbose_render_log := false var diagnostics_enabled := false @@ -2511,9 +2525,66 @@ func _detect_cli_probe_script() -> String: return _normalize_cli_probe_script(arg.substr("--aether-probe-script=".length())) if FileAccess.file_exists(ProbeConfig.debug_request_path()): var request := ProbeConfig.load() + _configure_renpy_startup_trace(request) return _normalize_cli_probe_script(String(request.get("probe_script", ""))) return "" +static func _renpy_startup_request_run_id(request: Dictionary) -> String: + if request.get("probe_script") != "res://scripts/renpy_mobile_acceptance.gd": + return "" + var candidate = request.get("run_id") + if not candidate is String or candidate.length() != 32: + return "" + for character in candidate: + if not character in "0123456789abcdef": + return "" + return candidate + +static func _renpy_startup_phase_row(run_id: String, seq: Variant, phase: String, + ticks_msec: Variant, result: Variant = null) -> Dictionary: + if _renpy_startup_request_run_id({"probe_script": "res://scripts/renpy_mobile_acceptance.gd", + "run_id": run_id}).is_empty() or phase not in RENPY_STARTUP_PHASES: + return {} + if typeof(seq) != TYPE_INT or seq < 1 or seq > 64: + return {} + if typeof(ticks_msec) != TYPE_INT or ticks_msec < 0 or ticks_msec > RENPY_STARTUP_TRACE_MAX_TICKS: + return {} + if result != null and (typeof(result) != TYPE_INT or result < -2147483648 or result > 2147483647): + return {} + var row := {"run_id": run_id, "seq": seq, "ticks_msec": ticks_msec, "phase": phase} + if result != null: + row["result"] = result + return row + +func _configure_renpy_startup_trace(request: Dictionary) -> void: + if not OS.is_debug_build() or OS.get_name() not in ["Android", "iOS"]: + return + renpy_startup_trace_run_id = _renpy_startup_request_run_id(request) + renpy_startup_trace_seq = 0 + +func _record_renpy_startup_phase(phase: String, result: Variant = null) -> void: + if renpy_startup_trace_run_id.is_empty() or not OS.is_debug_build() \ + or OS.get_name() not in ["Android", "iOS"]: + return + var row := _renpy_startup_phase_row(renpy_startup_trace_run_id, + renpy_startup_trace_seq + 1, phase, Time.get_ticks_msec(), result) + if row.is_empty(): + return + var directory := ProjectSettings.globalize_path(RENPY_STARTUP_TRACE_PATH.get_base_dir()) + if DirAccess.make_dir_recursive_absolute(directory) != OK: + return + var mode := FileAccess.WRITE if renpy_startup_trace_seq == 0 else FileAccess.READ_WRITE + var file := FileAccess.open(RENPY_STARTUP_TRACE_PATH, mode) + if file == null: + return + file.seek_end() + file.store_line(JSON.stringify(row)) + file.flush() + var written := file.get_error() == OK + file.close() + if written: + renpy_startup_trace_seq += 1 + func _normalize_cli_probe_script(path: String) -> String: var normalized := path.strip_edges() if normalized.is_empty(): @@ -2523,6 +2594,7 @@ func _normalize_cli_probe_script(path: String) -> String: "res://scripts/step_render_probe.gd", "res://scripts/gui_render_probe.gd", "res://scripts/perf_input_probe.gd", + "res://scripts/renpy_mobile_acceptance.gd", "res://scripts/wa2_gui.gd", ] for item in known: @@ -10697,6 +10769,9 @@ func _game_info_from_path(path: String) -> Dictionary: func _game_runtime_root(path: String) -> String: var resolved := _resolve_game_path(path) + var renpy_root := GameMetadata.renpy_project_root(resolved) + if not renpy_root.is_empty(): + return renpy_root if FileAccess.file_exists(resolved): return resolved.get_base_dir() return resolved @@ -11555,6 +11630,7 @@ func _ready() -> void: add_child(vndb_resolver) vndb_resolver.resolved.connect(_on_vndb_cover_resolved) cli_probe_script = _detect_cli_probe_script() + _record_renpy_startup_phase("main_ready_entered") _apply_ui_font() DisplayServer.window_set_flag(DisplayServer.WINDOW_FLAG_TRANSPARENT, false) _apply_initial_window_size() @@ -11631,7 +11707,9 @@ func _ready() -> void: if ui_motion.get_parent() == null: add_child(ui_motion) + _record_renpy_startup_phase("ui_build_entered") _build_ui() + _record_renpy_startup_phase("ui_build_ready") _stage_runtime_fonts() # Scene-test preview: scheduled before the optional runtime player init @@ -11639,8 +11717,11 @@ func _ready() -> void: _restore_scene_test_state() call_deferred("_apply_pending_scene_test") + _record_renpy_startup_phase("player_create_entered") if not _create_runtime_player(): + _record_renpy_startup_phase("player_create_failed") return + _record_renpy_startup_phase("player_create_ready") _restore_native_translation_model_access() _initialize_iap() @@ -11674,6 +11755,7 @@ func _ready() -> void: _apply_upscale_algorithm() _append_log("AetherKiri shell ready. Initializing engine...") + _record_renpy_startup_phase("ready_frame_pending") call_deferred("_finish_ready_after_first_frame") func _setup_debug_console() -> void: @@ -11956,6 +12038,7 @@ func _switch_runtime_player(runtime_kind: String) -> bool: var normalized := runtime_kind if normalized not in [ RUNTIME_ONSCRIPTER, + RUNTIME_RENPY, RUNTIME_MINORI, RUNTIME_CATSYSTEM2, RUNTIME_SIGLUS, @@ -11991,6 +12074,8 @@ func _switch_runtime_player(runtime_kind: String) -> bool: if normalized == RUNTIME_ONSCRIPTER else "MinoriRust" if normalized == RUNTIME_MINORI + else "Ren'Py" + if normalized == RUNTIME_RENPY else "CatSystem2" if normalized == RUNTIME_CATSYSTEM2 else "Siglus" @@ -12614,6 +12699,7 @@ func _ensure_player_initialized() -> bool: var runtime_id := "auto" if current_player_runtime_kind in [ RUNTIME_ONSCRIPTER, + RUNTIME_RENPY, RUNTIME_MINORI, RUNTIME_CATSYSTEM2, RUNTIME_SIGLUS, @@ -12635,6 +12721,8 @@ func _ensure_player_initialized() -> bool: if current_player_runtime_kind == RUNTIME_ONSCRIPTER else "MinoriRust" if current_player_runtime_kind == RUNTIME_MINORI + else "Ren'Py" + if current_player_runtime_kind == RUNTIME_RENPY else "CatSystem2" if current_player_runtime_kind == RUNTIME_CATSYSTEM2 else "Siglus" @@ -12645,8 +12733,15 @@ func _ensure_player_initialized() -> bool: func _finish_ready_after_first_frame() -> void: await get_tree().process_frame + _record_renpy_startup_phase("ready_frame_entered") + _record_renpy_startup_phase("engine_initialize_entered") var engine_initialized := _ensure_player_initialized() + var engine_result: Variant = null + if not renpy_startup_trace_run_id.is_empty() and player != null and player.has_method("get_last_result"): + engine_result = player.get_last_result() + _record_renpy_startup_phase( + "engine_initialize_ready" if engine_initialized else "engine_initialize_failed", engine_result) if engine_initialized: _apply_backend(false) @@ -13224,6 +13319,18 @@ func _add_scene_test_exit_chip() -> void: func _run_cli_script_probe() -> void: _write_probe_marker("cli_probe start script=%s" % cli_probe_script) var config := ProbeConfig.load() + if cli_probe_script == "res://scripts/renpy_mobile_acceptance.gd": + _prepare_cli_probe_view(config) + _record_renpy_startup_phase("observer_create_entered") + var acceptance := load(cli_probe_script).new() as Node + add_child(acceptance) + _record_renpy_startup_phase("observer_create_ready") + if OS.has_feature("ios"): + acceptance.connect("game_finished", _finish_ios_mobile_acceptance.bind(acceptance)) + _record_renpy_startup_phase("observer_start_entered") + acceptance.start(config, player) + _record_renpy_startup_phase("observer_start_returned") + return var target_game_path: String = ProbeConfig.require_game_path(config) var requested_game_path := target_game_path if OS.get_name() == "iOS" and not target_game_path.is_empty(): @@ -13254,6 +13361,37 @@ func _run_cli_script_probe() -> void: printerr("unsupported probe script: %s" % cli_probe_script) await _probe_cleanup_and_quit(2) +func _finish_ios_mobile_acceptance(native_state: Dictionary, acceptance: Node) -> void: + # Exercise the application's normal runtime-exit path, including its real + # LibraryView and existing navigation, rather than painting a test screen. + _return_to_library_after_runtime_exit() + cli_probe_script = "" + for _frame in range(6): + await get_tree().process_frame + await RenderingServer.frame_post_draw + var library := { + "scene_path": get_tree().current_scene.scene_file_path, + "script_path": get_script().resource_path, + "library_path": str(home_view.get_path()), + "shell_visible": shell_root.is_visible_in_tree(), + "library_visible": home_view.is_visible_in_tree(), + "game_view_visible": game_view.is_visible_in_tree(), + "route": shell_route, + "mode": home_library_mode, + "title": home_title_label.text, + "title_visible": home_title_label.is_visible_in_tree(), + "search_path": str(home_search_input.get_path()), + "search_visible": home_search_input.is_visible_in_tree(), + "viewport_size": [get_viewport_rect().size.x, get_viewport_rect().size.y], + "native_exited": bool(native_state.get("exited", false)), + } + var library_image := get_viewport().get_texture().get_image() + var evidence_dir := ProjectSettings.globalize_path("user://renpy-device-evidence") + var image_result := library_image.save_png(evidence_dir.path_join("production-library.png")) + library["image_result"] = image_result + acceptance.call("observe_library_return", library) + acceptance.queue_free() + func _prepare_cli_probe_view(config: Dictionary) -> void: var window_size := ProbeConfig.window_size(config, Vector2i(1280, 720)) if window_size.x > 0 and window_size.y > 0: @@ -13301,12 +13439,16 @@ func _probe_open_game(config: Dictionary, target_game_path: String, backend_env: player.set_engine_option(String(key), String(engine_options[key])) var surface_size := ProbeConfig.surface_size(config) _write_probe_marker("probe_open_game surface=%dx%d fps_limit=%d" % [surface_size.x, surface_size.y, fps_limit]) - var surface_result: int = int(player.set_surface_size(surface_size.x, surface_size.y)) + var surface_result: int = ENGINE_RESULT_OK + # Ren'Py publishes its own frame dimensions; host resize is unsupported. + if runtime_kind != RUNTIME_RENPY: + surface_result = int(player.set_surface_size(surface_size.x, surface_size.y)) if surface_result != ENGINE_RESULT_OK: _write_probe_marker("probe_open_game set_surface_failed error=%s" % player.get_last_error()) printerr("set_surface_size failed: %s" % player.get_last_error()) return false - current_surface_size = surface_size + if runtime_kind != RUNTIME_RENPY: + current_surface_size = surface_size game_path.text = target_game_path var result: int = int(player.open_game(target_game_path, true)) if result != ENGINE_RESULT_OK: @@ -13576,13 +13718,22 @@ func _probe_run_actions(config: Dictionary, step: int) -> int: elif kind == "click_stream": step = await _probe_run_click_stream(config, step, label, action) continue + elif kind == "wait_ms": + # Ren'Py starts in a separate SDK process. A frame-count-only + # warmup can finish before that process has initialized its + # renderer, so permit probes to wait on wall-clock time while + # continuing to pump the runtime and transport. + var duration_ms: int = max(0, int(action.get("duration_ms", action.get("milliseconds", 0)))) + if not await _probe_advance_for_ms(duration_ms): + return -1 elif kind == "wait" or kind == "capture": pass else: print("skip unknown action: %s" % kind) continue - var after_frames := int(action.get("after_frames", ProbeConfig.int_value(config, "after_click_frames", _runtime_int("AETHERKIRI_PROBE_AFTER_CLICK_FRAMES", 180)))) + var default_after_frames := 0 if kind == "wait_ms" else ProbeConfig.int_value(config, "after_click_frames", _runtime_int("AETHERKIRI_PROBE_AFTER_CLICK_FRAMES", 180)) + var after_frames := int(action.get("after_frames", default_after_frames)) if not await _probe_advance(after_frames): return -1 if bool(action.get("capture", true)): @@ -15334,6 +15485,8 @@ func _env_vector2i(key: String, fallback: Vector2i) -> Vector2i: func _sync_player_surface_size(force: bool) -> void: if player == null: return + if active_runtime_kind == RUNTIME_RENPY: + return var target_size := _desired_render_surface_size() if not force and target_size == current_surface_size: return @@ -15372,6 +15525,8 @@ func _sync_player_surface_size(force: bool) -> void: current_surface_size = target_size func _sync_game_surface_to_texture(texture_size: Vector2i) -> void: + if active_runtime_kind == RUNTIME_RENPY: + return if render_surface_mode != RENDER_SURFACE_MODE_GAME: return if not follow_texture_surface_size: @@ -16351,12 +16506,15 @@ func _input(event: InputEvent) -> void: player.send_text_input(DisplayServer.clipboard_get()) get_viewport().set_input_as_handled() return - player.send_key_event( - key.pressed, - _kirikiri_virtual_key(key), - _kirikiri_key_modifiers(key), - key.unicode - ) + if active_runtime_kind == RUNTIME_RENPY: + RenpyKeyInput.send_key_event(player, key) + else: + player.send_key_event( + key.pressed, + _kirikiri_virtual_key(key), + _kirikiri_key_modifiers(key), + key.unicode + ) get_viewport().set_input_as_handled() return if _is_game_pointer_event(event): diff --git a/apps/godot_app/scripts/renpy_key_input.gd b/apps/godot_app/scripts/renpy_key_input.gd new file mode 100644 index 000000000..69a23f117 --- /dev/null +++ b/apps/godot_app/scripts/renpy_key_input.gd @@ -0,0 +1,12 @@ +extends RefCounted + +# This entry explicitly identifies Godot keycodes for the Ren'Py provider. +# KiriKiri and the other runtimes keep their existing Windows-VK path. +static func send_key_event(player: Object, key: InputEventKey) -> int: + var code := int(key.keycode) + if key.keycode == KEY_NONE: + code = int(key.physical_keycode) + var modifiers := int(key.get_modifiers_mask()) + if key.echo: + modifiers |= 0x80 + return player.send_renpy_key_event(key.pressed, code, modifiers, key.unicode) diff --git a/apps/godot_app/scripts/renpy_key_input_test.gd b/apps/godot_app/scripts/renpy_key_input_test.gd new file mode 100644 index 000000000..592ed27c6 --- /dev/null +++ b/apps/godot_app/scripts/renpy_key_input_test.gd @@ -0,0 +1,59 @@ +extends SceneTree + +const RenpyKeyInput = preload("res://scripts/renpy_key_input.gd") + +class Recorder extends RefCounted: + var events: Array = [] + var result := 0 + + func send_renpy_key_event(pressed: bool, code: int, modifiers: int, unicode: int) -> int: + events.append([pressed, code, modifiers, unicode]) + return result + +func _init() -> void: + var recorder := Recorder.new() + # Actual Godot InputEventKey objects preserve characters that the legacy + # VK path would treat as Delete, Help, arrows, keypad or function keys. + for code in range(32, 127): + var key := InputEventKey.new() + key.pressed = true + key.keycode = code + key.unicode = code + assert(RenpyKeyInput.send_key_event(recorder, key) == 0) + assert(recorder.events.back() == [true, code, 0, code]) + for code in [KEY_DELETE, KEY_ENTER, KEY_KP_ENTER, KEY_BACKSPACE, KEY_ESCAPE, + KEY_TAB, KEY_BACKTAB, KEY_HOME, KEY_END, KEY_LEFT, KEY_RIGHT, + KEY_UP, KEY_DOWN, KEY_PAGEUP, KEY_PAGEDOWN, KEY_INSERT, KEY_F1, KEY_F24]: + var key := InputEventKey.new() + key.keycode = code + key.pressed = false + assert(RenpyKeyInput.send_key_event(recorder, key) == 0) + assert(recorder.events.back() == [false, code, 0, 0]) + var shortcut := InputEventKey.new() + shortcut.pressed = true + shortcut.keycode = KEY_A + shortcut.unicode = 65 + shortcut.shift_pressed = true + shortcut.ctrl_pressed = true + shortcut.meta_pressed = true + shortcut.echo = true + RenpyKeyInput.send_key_event(recorder, shortcut) + var modifiers := int(shortcut.get_modifiers_mask()) | 0x80 + assert(recorder.events.back() == [true, KEY_A, modifiers, 65]) + assert(modifiers & KEY_MASK_CTRL and modifiers & KEY_MASK_SHIFT and modifiers & KEY_MASK_META) + var physical := InputEventKey.new() + physical.keycode = KEY_NONE + physical.physical_keycode = KEY_DELETE + physical.pressed = true + RenpyKeyInput.send_key_event(recorder, physical) + assert(recorder.events.back() == [true, KEY_DELETE, 0, 0]) + var committed := InputEventKey.new() + committed.pressed = true + committed.unicode = 0x1f642 + RenpyKeyInput.send_key_event(recorder, committed) + assert(recorder.events.back() == [true, KEY_NONE, 0, 0x1f642]) + recorder.result = -4 + assert(RenpyKeyInput.send_key_event(recorder, committed) == -4) + print("Ren'Py Godot key forwarding passed: printable/special/physical keys, modifiers, repeat and Unicode") + print("Recorder boundary only; no GDExtension, SDL or gameplay execution") + quit(0) diff --git a/apps/godot_app/scripts/renpy_mobile_acceptance.gd b/apps/godot_app/scripts/renpy_mobile_acceptance.gd new file mode 100644 index 000000000..16090ce30 --- /dev/null +++ b/apps/godot_app/scripts/renpy_mobile_acceptance.gd @@ -0,0 +1,361 @@ +extends Node + +signal game_finished(native_state: Dictionary) + +# A debug-device observer, driven exclusively by OS input. It never manufactures +# game progress, sends a scripted click, or falls back to a viewport screenshot. +const GameInputMapping = preload("res://scripts/game_input_mapping.gd") +const RenpyKeyInput = preload("res://scripts/renpy_key_input.gd") +const POINTER_DOWN := 1 +const POINTER_MOVE := 2 +const POINTER_UP := 3 +const TOUCH_POINTER_ID_OFFSET := 100000 + +var player +var rect: TextureRect +var config: Dictionary = {} +var evidence_dir := "" +var game_dir := "" +var run_id := "" +var started := false +var finished := false +var paused := false +var keyboard_active := false +var keyboard_visible := false +var resumed_capture_pending := false +var paused_frame_serial := 0 +var checkpoint_count := 0 +var suppress_mouse_until := 0 +var deadline := 0 + +func start(request: Dictionary, runtime_player: Object) -> void: + config = request + player = runtime_player + if not OS.is_debug_build() or OS.get_name() not in ["Android", "iOS"]: + _fail("This observer requires a debug mobile app on a cloud device.") + return + run_id = String(config.get("run_id", "")) + game_dir = String(config.get("game_path", "")).path_join("game") + evidence_dir = ProjectSettings.globalize_path("user://renpy-device-evidence") + DirAccess.make_dir_recursive_absolute(evidence_dir) + if run_id.is_empty() or not FileAccess.file_exists(game_dir.path_join("aether-device-request.json")): + _fail("The staged real Ren'Py device project/request is missing.") + return + deadline = Time.get_ticks_msec() + int(config.get("timeout_seconds", 180)) * 1000 + rect = TextureRect.new() + rect.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + rect.mouse_filter = Control.MOUSE_FILTER_IGNORE + rect.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR + add_child(rect) + # Use the provider's original pixels for the native/OS image comparison, + # independently of any saved cloud debug-app enhancement preference. + player.set_frame_enhancement_enabled(false) + var result: int = player.set_render_backend("Godot Native") + if result == 0: + result = player.set_engine_option("runtime", "renpy") + if result == 0: + result = player.open_game(String(config.get("game_path", "")), true) + if result != 0: + _fail("Opening the real Ren'Py provider failed: %s" % player.get_last_error()) + return + _record({"kind": "opening", "platform": OS.get_name(), "debug_build": true}) + +func _process(delta: float) -> void: + if finished or player == null: + return + if Time.get_ticks_msec() > deadline: + _fail("Device gameplay timed out; no automatic success or skipped result.") + return + if not started: + var state: int = player.get_startup_state() + if state == 3: + _fail("Ren'Py startup failed: %s" % player.get_last_error()) + return + if state != 2: + return + started = true + _record({"kind": "startup", "provider_debug": _provider_debug()}) + if paused: + return + var result: int = player.tick(clampf(delta, 0.001, 0.1)) + # Normal exit must be observed in native provider state, independently of + # the script's quit_requested checkpoint. A successful tick alone is not exit. + var native_state := _provider_debug() + if bool(native_state.get("exited", false)): + if not _has_game_checkpoint("quit_requested"): + _fail("Native provider exited before the game's explicit Quit action.") + return + _finish(native_state) + return + if result != 0: + _fail("Ren'Py tick failed: %s" % player.get_last_error()) + return + var texture: Texture2D = player.update_frame_texture() + if texture != null: + rect.texture = texture + if resumed_capture_pending: + resumed_capture_pending = false + _capture_provider("resumed_ready", _game_checkpoint("post_text_ready")) + _sync_keyboard() + _observe_game_checkpoints() + +func _notification(what: int) -> void: + if not started or finished or player == null: + return + if what == NOTIFICATION_APPLICATION_PAUSED and not paused: + var result: int = player.pause() + paused = result == 0 + paused_frame_serial = int(_provider_debug().get("frame_serial", 0)) + _record({"kind": "lifecycle", "phase": "paused", "result": result, + "provider_debug": _provider_debug()}) + if result != 0: + _fail("Native pause failed: %s" % player.get_last_error()) + elif what == NOTIFICATION_APPLICATION_RESUMED and paused: + var result: int = player.resume() + paused = false + resumed_capture_pending = result == 0 + _record({"kind": "lifecycle", "phase": "resumed", "result": result, + "provider_debug": _provider_debug()}) + if result != 0: + _fail("Native resume failed: %s" % player.get_last_error()) + +func _input(event: InputEvent) -> void: + if not started or paused or finished or player == null or rect.texture == null: + return + var result := 0 + if event is InputEventScreenTouch: + var touch := event as InputEventScreenTouch + suppress_mouse_until = Time.get_ticks_msec() + 700 + var point := _map_point(touch.position) + if point.x < 0: + return + var action := POINTER_DOWN if touch.pressed else POINTER_UP + result = player.send_pointer_event(action, TOUCH_POINTER_ID_OFFSET + touch.index, + point.x, point.y, 0.0, 0.0, 0) + _record({"kind": "os_touch", "action": action, "pointer_id": touch.index, + "position": [point.x, point.y], "result": result}) + elif event is InputEventScreenDrag: + var drag := event as InputEventScreenDrag + suppress_mouse_until = Time.get_ticks_msec() + 700 + var point := _map_point(drag.position) + var relative := GameInputMapping.map_delta(drag.relative, rect.size, rect.texture.get_size()) + result = player.send_pointer_event(POINTER_MOVE, TOUCH_POINTER_ID_OFFSET + drag.index, + point.x, point.y, relative.x, relative.y, 0) + elif event is InputEventMouseButton: + if event.device == -1 or Time.get_ticks_msec() < suppress_mouse_until: + return + var button := event as InputEventMouseButton + var point := _map_point(button.position) + if point.x < 0: + return + result = player.send_pointer_event(POINTER_DOWN if button.pressed else POINTER_UP, + 0, point.x, point.y, 0.0, 0.0, 0) + _record({"kind": "os_mouse", "pressed": button.pressed, "result": result}) + elif event is InputEventKey: + var key := event as InputEventKey + var code := int(key.keycode) + result = RenpyKeyInput.send_key_event(player, key) + _record({"kind": "os_key", "pressed": key.pressed, "code": code, + "keycode_space": "godot", "unicode": key.unicode, "result": result}) + else: + return + get_viewport().set_input_as_handled() + if result != 0: + _fail("The real OS input was rejected: %s" % player.get_last_error()) + +func _map_point(point: Vector2) -> Vector2: + return GameInputMapping.map_point(point, rect.get_global_rect(), rect.texture.get_size()) + +func _sync_keyboard() -> void: + var state: Dictionary = player.get_text_input_state() + var active := bool(state.get("available", false)) and bool(state.get("ime_active", false)) + var available := DisplayServer.has_feature(DisplayServer.FEATURE_VIRTUAL_KEYBOARD) + if active != keyboard_active: + keyboard_active = active + if active and available: + DisplayServer.virtual_keyboard_show(String(state.get("text", "")), Rect2(0, 0, 1, 1)) + elif available: + DisplayServer.virtual_keyboard_hide() + _record({"kind": "soft_keyboard", "active": active, "feature_available": available, + "source": "native-text-input-state", "state": state}) + # Showing/hiding the Android keyboard is asynchronous. Its actual height, + # rather than the native request alone, proves OS visibility and dismissal. + var height := DisplayServer.virtual_keyboard_get_height() if available else 0 + var visible := height > 0 + if visible != keyboard_visible: + keyboard_visible = visible + _record({"kind": "soft_keyboard_visibility", "visible": visible, "height": height, + "source": "os-virtual-keyboard-height"}) + +func _game_checkpoints() -> Array: + var rows: Array = [] + var file := FileAccess.open(game_dir.path_join("aether-device-checkpoints.jsonl"), FileAccess.READ) + if file == null: + return rows + while not file.eof_reached(): + var parsed = JSON.parse_string(file.get_line()) + if parsed is Dictionary and String(parsed.get("run_id", "")) == run_id \ + and String(parsed.get("source", "")) == "renpy-script": + rows.append(parsed) + return rows + +func _has_game_checkpoint(stage: String) -> bool: + return not _game_checkpoint(stage).is_empty() + +func _game_checkpoint(stage: String) -> Dictionary: + for row in _game_checkpoints(): + if String(row.get("stage", "")) == stage: + return row + return {} + +func _observe_game_checkpoints() -> void: + var rows := _game_checkpoints() + while checkpoint_count < rows.size(): + var row: Dictionary = rows[checkpoint_count] + checkpoint_count += 1 + _record({"kind": "checkpoint_seen", "checkpoint": row}) + if String(row.get("stage", "")) in ["start_ready", "post_text_ready", "quit_ready"]: + _capture_provider(String(row.get("stage", "")), row) + +func _marker_rendered(frame: Dictionary, checkpoint: Dictionary) -> bool: + var details: Dictionary = checkpoint.get("details", {}) + var canvas: Array = details.get("logical_canvas", []) + var bounds: Array = details.get("marker_bounds", []) + var rgb: Array = details.get("marker_rgb", []) + if canvas.size() != 2 or bounds.size() != 4 or rgb.size() != 3 \ + or float(canvas[0]) <= 0 or float(canvas[1]) <= 0: + return false + var width := int(frame.get("width", 0)) + var height := int(frame.get("height", 0)) + var stride := int(frame.get("stride_bytes", 0)) + var rgba: PackedByteArray = frame.get("rgba", PackedByteArray()) + if width <= 0 or height <= 0 or stride < width * 4 or rgba.size() < stride * height: + return false + # Check actual pixels away from the marker's centered white label. The + # script writes its checkpoint before call-screen: that alone is not proof + # that this scene has reached the real renderer yet. + for u in [0.15, 0.85]: + for v in [0.25, 0.5, 0.75]: + var x := int((float(bounds[0]) + u * float(bounds[2])) * width / float(canvas[0])) + var y := int((float(bounds[1]) + v * float(bounds[3])) * height / float(canvas[1])) + if x < 0 or x >= width or y < 0 or y >= height: + return false + var offset := y * stride + x * 4 + for channel in range(3): + if absi(int(rgba[offset + channel]) - int(rgb[channel])) > 24: + return false + return true + +func _capture_provider(stage: String, checkpoint: Dictionary) -> void: + var game_stage := "post_text_ready" if stage == "resumed_ready" else stage + if String(checkpoint.get("source", "")) != "renpy-script" \ + or String(checkpoint.get("run_id", "")) != run_id \ + or String(checkpoint.get("stage", "")) != game_stage: + _fail("Provider capture lacks the actual executed Ren'Py checkpoint for %s." % stage) + return + # Let the interaction settle, then wait for keyboard animation and a fresh + # native redraw after resume. Old pre-pause pixels cannot prove recovery. + var expires := mini(deadline, Time.get_ticks_msec() + 15000) + for _frame in range(3): + await get_tree().process_frame + var frame: Dictionary = {} + while Time.get_ticks_msec() < expires: + if finished or player == null: + return + var keyboard_height := DisplayServer.virtual_keyboard_get_height() + var fresh := stage != "resumed_ready" or int(_provider_debug().get("frame_serial", 0)) > paused_frame_serial + if not paused and not keyboard_active and keyboard_height <= 0 and fresh: + # Read after a completed real draw, then require the script's + # current visible marker rather than accepting an earlier frame. + await RenderingServer.frame_post_draw + if finished or player == null: + return + frame = player.read_frame_rgba() + if _marker_rendered(frame, checkpoint): + break + await get_tree().process_frame + if Time.get_ticks_msec() >= expires or not _marker_rendered(frame, checkpoint): + _fail("Native presentation did not render %s's actual script marker after keyboard/lifecycle change." % stage) + return + var width := int(frame.get("width", 0)) + var height := int(frame.get("height", 0)) + var stride := int(frame.get("stride_bytes", 0)) + var rgba: PackedByteArray = frame.get("rgba", PackedByteArray()) + if width <= 0 or height <= 0 or stride < width * 4 or rgba.size() < stride * height: + _fail("Native provider did not supply a complete real RGBA frame.") + return + var packed_rgba := rgba + if stride != width * 4: + packed_rgba = PackedByteArray() + for y in range(height): + packed_rgba.append_array(rgba.slice(y * stride, y * stride + width * 4)) + var image := Image.create_from_data(width, height, false, Image.FORMAT_RGBA8, packed_rgba) + var path := evidence_dir.path_join("provider-%s.png" % stage) + if image.save_png(path) != OK: + _fail("Could not save native provider image.") + return + var box := rect.get_global_rect() + var scale := minf(box.size.x / width, box.size.y / height) + var drawn_size := Vector2(width, height) * scale + var origin := box.position + (box.size - drawn_size) * 0.5 + var viewport_size := get_viewport().get_visible_rect().size + _record({"kind": "provider_frame", "stage": stage, "source": "native-provider-rgba", + "path": path, "width": width, "height": height, "rgba_bytes": rgba.size(), + "native_stride_bytes": stride, "frame_serial": int(frame.get("frame_serial", 0)), + "drawn_box": [origin.x, origin.y, drawn_size.x, drawn_size.y], + "viewport_size": [viewport_size.x, viewport_size.y], "provider_debug": _provider_debug(), + "game_checkpoint": checkpoint}) + +func _provider_debug() -> Dictionary: + var raw := String(player.get_plugin_debug_info()) + var parsed = JSON.parse_string(raw) + return parsed if parsed is Dictionary else {"unparsed": raw} + +func _record(row: Dictionary) -> void: + row["run_id"] = run_id + row["host_ticks_msec"] = Time.get_ticks_msec() + print("renpy_device_observer %s" % JSON.stringify(row)) + if evidence_dir.is_empty(): + return + var path := evidence_dir.path_join("observer.jsonl") + var file := FileAccess.open(path, FileAccess.READ_WRITE if FileAccess.file_exists(path) else FileAccess.WRITE) + if file != null: + file.seek_end() + file.store_line(JSON.stringify(row)) + file.flush() + +func _finish(native_state: Dictionary) -> void: + finished = true + rect.texture = null + player.release_frame_texture() + player.destroy_engine() + _record({"kind": "normal_exit", "provider_debug": native_state, + "engine_destroyed": not player.is_initialized()}) + if OS.has_feature("ios"): + # iOS returns to the application's real library. Ending UIKit itself + # is a separate host policy and cannot stand in for native game exit. + if not game_finished.has_connections(): + _record({"kind": "failure", "message": "The iOS gameplay observer has no production library return handler."}) + return + game_finished.emit(native_state) + else: + get_tree().quit(0) + +func observe_library_return(library: Dictionary) -> void: + _record({"kind": "library_return", "source": "actual-production-main-scene", "ui": library, + "engine_destroyed": not player.is_initialized()}) + +func _fail(message: String) -> void: + if finished: + return + finished = true + _record({"kind": "failure", "message": message}) + printerr("renpy_device_acceptance failed: %s" % message) + if player != null: + if rect != null: + rect.texture = null + player.release_frame_texture() + player.destroy_engine() + get_tree().quit(1) diff --git a/apps/godot_app/scripts/renpy_mobile_acceptance.gd.uid b/apps/godot_app/scripts/renpy_mobile_acceptance.gd.uid new file mode 100644 index 000000000..002e7b5c5 --- /dev/null +++ b/apps/godot_app/scripts/renpy_mobile_acceptance.gd.uid @@ -0,0 +1 @@ +uid://dntw1pfiolmsc diff --git a/bridge/godot_extension/CMakeLists.txt b/bridge/godot_extension/CMakeLists.txt index 844261d98..73b317afc 100644 --- a/bridge/godot_extension/CMakeLists.txt +++ b/bridge/godot_extension/CMakeLists.txt @@ -267,6 +267,12 @@ if(IOS) "$" VERBATIM) endif() +if(TARGET aether_renpy_runtime) + # The runtime target owns the provider and its platform definitions. + # iOS packaging merges that archive into the exported static extension. + target_link_libraries(aether_kiri_godot PRIVATE aether_renpy_runtime) + target_compile_definitions(aether_kiri_godot PRIVATE AETHERKIRI_WITH_RENPY=1) +endif() if(TARGET aether_rfvp_runtime) target_link_libraries(aether_kiri_godot PRIVATE aether_rfvp_runtime) target_compile_definitions(aether_kiri_godot PRIVATE AETHERKIRI_WITH_RFVP=1) diff --git a/bridge/godot_extension/src/aether_runtime_player.cpp b/bridge/godot_extension/src/aether_runtime_player.cpp index 881534e47..f519ec36e 100644 --- a/bridge/godot_extension/src/aether_runtime_player.cpp +++ b/bridge/godot_extension/src/aether_runtime_player.cpp @@ -1,6 +1,10 @@ #include "engine_api.h" #include "engine_options.h" #include "engine_runtime_provider.h" +#if defined(AETHERKIRI_WITH_RENPY) +#include "renpy_godot_input.h" +#include "renpy_runtime.h" +#endif #if defined(AETHERKIRI_WITH_RFVP) #include "rfvp_runtime_provider.h" #endif @@ -131,6 +135,10 @@ void aether_native_launch_file_picker_free_string(char *value); #if defined(__ANDROID__) #include #include +#if defined(AETHERKIRI_WITH_RENPY) +#include "renpy_android_host.h" +#include "renpy_java_boolean.h" +#endif extern JNIEnv* krkr_GetJNIEnv(); extern jobject krkr_GetApplicationContext(); @@ -736,6 +744,32 @@ bool AndroidJavaHasException(JavaClassWrapper *wrapper, const char *stage) { return false; } +#if defined(AETHERKIRI_WITH_RENPY) +bool AndroidLoadRenPyNativeBridge(std::string *error) { + // Godot's public Java wrapper already owns a valid VM/env after app + // setup. Java loading must precede our engine_api JNI accessors: loading + // a GDExtension with dlopen alone never calls engine_api's JNI_OnLoad. + JavaClassWrapper *wrapper = JavaClassWrapper::get_singleton(); + if (wrapper == nullptr) { + *error = "Ren'Py requires Godot's Android JavaClassWrapper after app setup"; + return false; + } + Ref bridge = wrapper->wrap("org.github.krkr2.aetherkiri.RenPyMobileBridge"); + const bool wrap_failed = AndroidJavaHasException(wrapper, "wrap RenPyMobileBridge"); + if (bridge.is_null() || wrap_failed) { + *error = "Ren'Py Android host bridge is missing from the APK or could not load"; + return false; + } + const Variant loaded = bridge->call("isNativeBridgeLoaded"); + if (AndroidJavaHasException(wrapper, "RenPyMobileBridge.isNativeBridgeLoaded") || + !renpy_mobile_runtime::AndroidJavaBooleanResultIsTrue(loaded)) { + *error = "Ren'Py could not load engine_api through the Android Java host bridge"; + return false; + } + return true; +} +#endif + Object *AndroidVariantObject(const Variant &value) { if (value.get_type() != Variant::OBJECT) { return nullptr; @@ -1225,28 +1259,34 @@ bool AndroidRequestRuntimeStoragePermissions() { } bool AndroidHasExternalStoragePermission() { - const AndroidGodotStoragePermissionState godot_state = - AndroidGetGodotStoragePermissionState(); - if (godot_state.read || godot_state.write || godot_state.manage) { - AK_ANDROID_LOGI("storage permission effective granted via Godot OS"); - return true; - } - const int sdk = AndroidGetSdkInt(); - if (sdk > 0 && sdk < 23) { - return true; - } - if (sdk > 0 && sdk < 30) { - AK_ANDROID_LOGI( - "storage permission state sdk=%d read=%d write=%d effective_read=0", - sdk, godot_state.read ? 1 : 0, godot_state.write ? 1 : 0); - return false; - } if (sdk <= 0) { AK_ANDROID_LOGW( "storage permission state unavailable: Android SDK/JNI not ready"); return false; } + if (sdk < 23) { + return true; + } + + const AndroidGodotStoragePermissionState godot_state = + AndroidGetGodotStoragePermissionState(); + if (sdk < 30) { + const bool granted = godot_state.read || godot_state.write; + AK_ANDROID_LOGI( + "storage permission state sdk=%d read=%d write=%d effective_read=%d", + sdk, godot_state.read ? 1 : 0, godot_state.write ? 1 : 0, + granted ? 1 : 0); + return granted; + } + + // On Android 11+, legacy READ/WRITE grants do not establish the broad + // file access needed by native game loaders. Godot reports MANAGE only + // when Environment.isExternalStorageManager() confirms the user's grant. + if (godot_state.manage) { + AK_ANDROID_LOGI("storage permission effective granted via Godot OS all files access"); + return true; + } const int java_result = AndroidHasExternalStoragePermissionViaGodotJava(); if (java_result >= 0) { @@ -1407,13 +1447,18 @@ bool AndroidStartSettingsIntent(JNIEnv *env, jobject context, const char *action } bool AndroidRequestExternalStoragePermission() { - const AndroidGodotStoragePermissionState before = - AndroidGetGodotStoragePermissionState(); - if (before.read || before.write || before.manage) { + if (AndroidHasExternalStoragePermission()) { AK_ANDROID_LOGI("storage permission already granted"); return true; } + const int sdk = AndroidGetSdkInt(); + if (sdk <= 0) { + AK_ANDROID_LOGW( + "storage permission request unavailable: Android SDK/JNI not ready"); + return false; + } + // Calling through Godot's OS keeps this path on the Activity that owns // the current Godot instance. This is important on Android 6-9, where // the extension may be loaded as an ELF dependency and JNI_OnLoad is not @@ -1424,17 +1469,13 @@ bool AndroidRequestExternalStoragePermission() { AK_ANDROID_LOGI( "storage permission request via Godot OS dispatched=%d", dispatched ? 1 : 0); - if (dispatched) { - const int sdk = AndroidGetSdkInt(); - if (sdk <= 0 || sdk < 30) { - return true; - } + if (dispatched && sdk < 30) { + return true; } } - const int sdk = AndroidGetSdkInt(); AK_ANDROID_LOGI("storage permission request sdk=%d", sdk); - if (sdk > 0 && sdk < 30) { + if (sdk < 30) { return false; } if (AndroidHasExternalStoragePermission()) { @@ -9640,6 +9681,24 @@ class AetherRuntimePlayer final : public Node { CharString path_utf8 = game_root_path.utf8(); const String normalized_runtime = runtime_id_.strip_edges().to_lower(); +#if defined(__ANDROID__) && defined(AETHERKIRI_WITH_RENPY) + if (normalized_runtime == "renpy" || + (normalized_runtime == "auto" && + engine_probe_runtime_provider("renpy", path_utf8.get_data()) > 0)) { + // Bind the real Activity before async Open/first Tick. This only + // retains the existing host; SDL/Python still initialize on Tick. + std::string binding_error; + if (!aetherkiri::renpy::mobile::PrepareRenPyAndroidHost( + AndroidLoadRenPyNativeBridge, krkr_GetJNIEnv, + AndroidGetGodotActivityLocal, AndroidFindClassWithAppClassLoader, + &binding_error)) { + AndroidBridgeLog("event=renpy_host_bind_failed error=%s", binding_error.c_str()); + last_result_ = ResultToString(ENGINE_RESULT_INVALID_STATE); + last_error_ = String::utf8(binding_error.c_str()); + return ENGINE_RESULT_INVALID_STATE; + } + } +#endif artemis_logical_frame_pacing_ = normalized_runtime == "artemis" || (normalized_runtime == "auto" && engine_probe_runtime_provider("artemis", path_utf8.get_data()) > 0); @@ -9934,6 +9993,24 @@ class AetherRuntimePlayer final : public Node { return result; } + int send_renpy_key_event(bool pressed, int godot_keycode, int modifiers, + int unicode_codepoint) { +#if defined(AETHERKIRI_WITH_RENPY) + if (handle_ == nullptr) return ENGINE_RESULT_INVALID_STATE; + if (runtime_id_ != "renpy") return ENGINE_RESULT_NOT_SUPPORTED; + const engine_result_t result = aetherkiri::renpy::input_mapping::SendGodotKeyEvents( + [this](const engine_input_event_t& event) { + return engine_send_input(handle_, &event); + }, pressed, godot_keycode, modifiers, + static_cast(std::max(0, unicode_codepoint))); + update_last_error(result); + return result; +#else + (void)pressed; (void)godot_keycode; (void)modifiers; (void)unicode_codepoint; + return ENGINE_RESULT_NOT_SUPPORTED; +#endif + } + int send_text_input(const String& text) { if (handle_ == nullptr) { return ENGINE_RESULT_INVALID_STATE; @@ -11510,6 +11587,9 @@ void main() { &AetherRuntimePlayer::send_key_event); ClassDB::bind_method(D_METHOD("send_text_input", "text"), &AetherRuntimePlayer::send_text_input); + ClassDB::bind_method(D_METHOD("send_renpy_key_event", "pressed", "godot_keycode", + "modifiers", "unicode_codepoint"), + &AetherRuntimePlayer::send_renpy_key_event); ClassDB::bind_method(D_METHOD("get_text_input_state"), &AetherRuntimePlayer::get_text_input_state); ClassDB::bind_method(D_METHOD("send_ime_preedit", "text", "start", "length"), @@ -12624,6 +12704,9 @@ void InitializeAetherRuntime(ModuleInitializationLevel level) { #if defined(AETHERKIRI_WITH_RFVP) aetherkiri::rfvp::RegisterRuntimeProvider(); #endif +#if defined(AETHERKIRI_WITH_RENPY) + aetherkiri::renpy::RegisterRuntimeProvider(); +#endif #if defined(AETHERKIRI_WITH_SOFTPAL) AetherSoftPalRegisterRuntime(); #endif diff --git a/bridge/renpy_runtime/CMakeLists.txt b/bridge/renpy_runtime/CMakeLists.txt new file mode 100644 index 000000000..7d23c53a5 --- /dev/null +++ b/bridge/renpy_runtime/CMakeLists.txt @@ -0,0 +1,135 @@ +cmake_minimum_required(VERSION 3.19) +project(aether_renpy_runtime LANGUAGES CXX) + +if(IOS OR ANDROID) + # Mobile builds link the engine in-process. The desktop subprocess + # provider cannot be compiled here: it assumes a writable game/libs + # directory, fork/exec (or CreateProcess), and a desktop Ren'Py launcher. + # An enabled mobile build must carry the rebuilt lifecycle runtime and + # the matching cooperative Python resources. Archive staging alone does + # not satisfy this requirement. + if(NOT AETHERKIRI_RENPY_MOBILE_LIBRARY OR NOT AETHERKIRI_RENPY_PRIVATE_ROOT) + message(FATAL_ERROR "Mobile Ren'Py requires AETHERKIRI_RENPY_MOBILE_LIBRARY and AETHERKIRI_RENPY_PRIVATE_ROOT; official blocking RAPT/Renios archives are unsupported") + endif() + find_package(Python3 REQUIRED COMPONENTS Interpreter) + if(ANDROID) + set(_renpy_mobile_platform android) + else() + set(_renpy_mobile_platform ios) + endif() + set(_renpy_validation_arch arm64) + if(ANDROID) + set(_renpy_validation_arch "${CMAKE_ANDROID_ARCH_ABI}") + endif() + if(CMAKE_OSX_ARCHITECTURES) + list(GET CMAKE_OSX_ARCHITECTURES 0 _renpy_validation_arch) + endif() + execute_process(COMMAND "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/../../tools/validate_renpy_mobile_payload.py" + --platform "${_renpy_mobile_platform}" + --arch "${_renpy_validation_arch}" + --library "${AETHERKIRI_RENPY_MOBILE_LIBRARY}" + --private-root "${AETHERKIRI_RENPY_PRIVATE_ROOT}" + RESULT_VARIABLE _renpy_validation_result + OUTPUT_VARIABLE _renpy_validation_output + ERROR_VARIABLE _renpy_validation_error) + if(NOT _renpy_validation_result EQUAL 0) + message(FATAL_ERROR "${_renpy_validation_error}") + endif() + message(STATUS "${_renpy_validation_output}") + set(AETHERKIRI_RENPY_SOURCES + src/renpy_runtime_mobile_provider.cpp + src/renpy_runtime_ios_adapter.cpp + mobile_launcher/src/renpy_mobile_loader.cpp) + if(ANDROID) + list(APPEND AETHERKIRI_RENPY_SOURCES src/renpy_mobile_adapter.cpp) + endif() +else() + if(NOT (WIN32 OR APPLE OR LINUX)) + message(FATAL_ERROR + "The embedded Ren'Py SDK provider requires a desktop subprocess host") + endif() + if(WEB OR EMSCRIPTEN) + message(FATAL_ERROR + "The embedded Ren'Py SDK provider is not available on this platform") + endif() + if(NOT AETHERKIRI_RENPY_SDK_ROOT OR + NOT IS_DIRECTORY "${AETHERKIRI_RENPY_SDK_ROOT}") + message(FATAL_ERROR + "AETHERKIRI_ENABLE_RENPY requires AETHERKIRI_RENPY_SDK_ROOT to point " + "to an official Ren'Py SDK directory") + endif() + set(AETHERKIRI_RENPY_SOURCES src/renpy_runtime_provider.cpp) +endif() + +add_library(aether_renpy_runtime STATIC ${AETHERKIRI_RENPY_SOURCES}) +target_compile_features(aether_renpy_runtime PUBLIC cxx_std_17) +set_target_properties(aether_renpy_runtime PROPERTIES POSITION_INDEPENDENT_CODE ON) +if(IOS) + target_compile_definitions(aether_renpy_runtime PRIVATE + AETHERKIRI_RENPY_IOS=1) +elseif(NOT ANDROID) + target_compile_definitions(aether_renpy_runtime PRIVATE + AETHERKIRI_RENPY_SDK_ROOT="${AETHERKIRI_RENPY_SDK_ROOT}" + AETHERKIRI_RENPY_OVERLAY_SOURCE="${CMAKE_CURRENT_SOURCE_DIR}/python/aether_renpy_overlay.py") +endif() +target_include_directories(aether_renpy_runtime PUBLIC + "${CMAKE_CURRENT_SOURCE_DIR}/include" + "${CMAKE_CURRENT_SOURCE_DIR}/mobile_launcher/include" + "${CMAKE_CURRENT_SOURCE_DIR}/../../abi/include") +if(NOT TARGET engine_api) + message(FATAL_ERROR "Ren'Py provider requires the engine_api target") +endif() +target_link_libraries(aether_renpy_runtime PRIVATE engine_api) +if(UNIX AND NOT (IOS OR ANDROID)) + target_link_libraries(aether_renpy_runtime PRIVATE pthread) +endif() +if(ANDROID) + target_link_libraries(aether_renpy_runtime PRIVATE ${CMAKE_DL_LIBS}) +elseif(IOS) + target_link_libraries(aether_renpy_runtime PRIVATE "-framework CoreFoundation") +endif() + +if(ENABLE_TESTS AND NOT (IOS OR ANDROID)) + enable_testing() + add_executable(renpy-provider-test tests/provider.cpp) + target_compile_definitions(renpy-provider-test PRIVATE + AETHERKIRI_RENPY_TEST_SDK_ROOT="${AETHERKIRI_RENPY_SDK_ROOT}") + target_link_libraries(renpy-provider-test PRIVATE aether_renpy_runtime) + add_test(NAME renpy-provider COMMAND renpy-provider-test) + + add_executable(renpy-input-mapping-test tests/input_mapping.cpp) + target_link_libraries(renpy-input-mapping-test PRIVATE aether_renpy_runtime) + add_test(NAME renpy-input-mapping COMMAND renpy-input-mapping-test) + + add_executable(renpy-mobile-adapter-test + tests/mobile_adapter.cpp src/renpy_mobile_adapter.cpp) + target_include_directories(renpy-mobile-adapter-test PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/include" + "${CMAKE_CURRENT_SOURCE_DIR}/../../abi/include") + add_test(NAME renpy-mobile-adapter COMMAND renpy-mobile-adapter-test) + + add_executable(renpy-ios-adapter-test + tests/ios_adapter.cpp src/renpy_runtime_ios_adapter.cpp) + target_include_directories(renpy-ios-adapter-test PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/include" + "${CMAKE_CURRENT_SOURCE_DIR}/../../abi/include") + add_test(NAME renpy-ios-adapter COMMAND renpy-ios-adapter-test) +elseif(ENABLE_TESTS AND (IOS OR ANDROID)) + # This target is compile-only in cross builds. It verifies the mobile + # provider ABI and its deliberate NOT_SUPPORTED contract without trying + # to execute an iOS/Android binary on the build host. + add_executable(renpy-mobile-provider-test tests/mobile_provider.cpp) + target_link_libraries(renpy-mobile-provider-test PRIVATE aether_renpy_runtime) + if(ANDROID) + # Compile the Android JNI/bootstrap boundary as a standalone test + # target as well. Cross builds do not execute this binary on the host. + add_executable(renpy-mobile-adapter-test tests/mobile_adapter.cpp) + target_link_libraries(renpy-mobile-adapter-test PRIVATE + aether_renpy_runtime) + endif() + if(IOS) + add_executable(renpy-ios-adapter-test tests/ios_adapter.cpp) + target_link_libraries(renpy-ios-adapter-test PRIVATE aether_renpy_runtime) + endif() +endif() diff --git a/bridge/renpy_runtime/README.md b/bridge/renpy_runtime/README.md new file mode 100644 index 000000000..382c4448a --- /dev/null +++ b/bridge/renpy_runtime/README.md @@ -0,0 +1,132 @@ +# Ren'Py SDK provider + +This is an explicitly opt-in bootstrap milestone. Configure +`AETHERKIRI_ENABLE_RENPY=ON` and point `AETHERKIRI_RENPY_SDK_ROOT` at an +official desktop Ren'Py SDK checkout. The provider validates a `game/script.rpy` +project and launches the SDK executable directly, without a shell. + +The provider stages an opt-in Python overlay into `game/libs` while a game is +open. The overlay exports real renderer pixels through the AKRF1 transport and +forwards input through JSONL; staging is removed when the provider closes. A +read-only game root returns `ENGINE_RESULT_NOT_SUPPORTED` with a diagnostic. +Native GPU texture import remains unsupported, so hosts use the RGBA frame +path while the SDK-owned window stays available for debugging. + +The Ren'Py game keeps its own logical canvas size from `game/options.rpy` (for +example 640x360). AetherKiri presents that frame in the same aspect-preserving +host surface used by the AR/KrKr runtimes, so a 16:9 Ren'Py game is enlarged to +the available display without stretching. Pointer coordinates are mapped back +from the enlarged display to the logical Ren'Py canvas before input is sent. + +The process is started with an argv array and an exec-error pipe, polled with +`waitpid`/`WaitForSingleObject`, and terminated during provider destruction. +Paths are validated before launch and are never passed through a shell. + +## Experimental Python overlay + +`python/aether_renpy_overlay.py` is an opt-in Ren'Py-side prototype. Set +`AETHERKIRI_RENPY_OVERLAY=1` and import `install()` from the game's +`game/options.rpy` to hook `renpy.display.core.Interface.draw_screen`. + +The hook uses the active SDK renderer (`renpy.display.draw`) after the real +draw. On Ren'Py 8.5.x, the compatibility `pygame.image` module does not +provide `tostring`; the overlay copies the live `Surface._pixels_address` +using its pitch and channel masks, then emits tightly packed RGBA bytes. The +frame file is atomically replaced on each draw and has this little-endian +layout: + +``` +AKRF1\0\0\0 | u32 width | u32 height | u64 serial | u32 payload_len | RGBA... +``` + +Set `AETHERKIRI_RENPY_FRAME` to select the frame path. Input is consumed from +`AETHERKIRI_RENPY_INPUT` as JSON lines during Ren'Py periodic callbacks: +`{"type": 768, "attributes": {"key": 13, "mod": 0, "unicode": "\r", "scancode": 40, "repeat": false}}` +is a `KEYDOWN` event; `mod`, `unicode`, `scancode`, and `repeat` default to +zero, empty, zero, and false when omitted. Mouse events use the usual `pos`, +`button`, and `rel` attributes. Set `AETHERKIRI_RENPY_ERROR` to an optional +append-only diagnostics path; frame and input failures are reported there +without terminating the SDK process. + +The reproducible desktop probe is `tools/run_renpy_overlay_probe.sh`; run it +with `RENPY_SDK=/path/to/renpy-8.5.3-sdk`. It launches the official SDK with +SDL's dummy drivers, verifies a nonblank 640x360 frame, injects Down+Return, +and checks that the Ren'Py menu selection reaches script code. It uses the +SDK's `gl2` renderer by default; set `RENPY_RENDERER=sw` to exercise the +software renderer instead. + +The overlay is desktop-only. Mobile builds use the in-process lifecycle fork, +with its own packaged Python home and stack-preserving cooperative loop. + +## Mobile build and runtime status + +Mobile gameplay is **unverified**. There is no accepted APK/device or +IPA/simulator gameplay result for this change. Exported symbols, host fixture +tests, and a successful application build do not establish mobile support. + +The source runner pins a coherent, independently rebuilt 8.5.3 fork: + +- renpy-build: `7bfab40c1174f622f644b24669afd5fb167fbb79` +- Ren'Py: `39895c1e017f0b36ffea2447d97eccd69d76ee1c` +- greenlet: `65f8da82b13a1273e55a6bfcbd1f9da09fc4eb7a` + +It applies both the Python and native patches before compiling the actual +runtime. Greenlet preserves the script and interaction stacks on the host +thread. Game initialization starts in the first tick, after asynchronous game +open has returned. Frames come from the real renderer screenshot, copied from +its Surface with row pitch respected. Input retains press, release, motion, +scroll and committed UTF-8 text semantics. Pause, resume and natural exit are +observable through the provider. + +Android compilation requires a supported Ubuntu 24.04 cloud builder, LLVM 18, +NDK r29 and at least 64 GiB of free disk. The fork uses SDL2's offscreen +EGL/pbuffer path to avoid taking Godot's display Surface. This path still +requires real Android graphics, input and lifecycle acceptance. + +The iOS native archive must be rebuilt with legally supplied Apple SDKs. Its +stock SDL UIKit/MetalANGLE window path is not a verified Godot rendering +integration; this remains an implementation and simulator/device validation +blocker. Device builds also require the appropriate signing credentials; +simulator verification does not require distribution signing. + +Enabling Ren'Py in a mobile application build now requires the rebuilt native +library and matching cooperative Python/standard-library resources. CMake and +packaging reject an official blocking RAPT/Renios archive, missing resources, +incompatible native targets and missing lifecycle exports. iOS native linking +is enabled by default. No second launcher entrypoint is copied into Godot. + +## Reproducible cloud commands + +Prepare pinned sources without installing system packages or licensed SDKs: + +```bash +bash tools/run_renpy_mobile_source_build.sh --mode prepare --fetch \ + --platform android --renpy-build /workspace/renpy-build +``` + +Run the actual source build on a provisioned cloud builder: + +```bash +bash tools/run_renpy_mobile_source_build.sh --mode build \ + --platform android --renpy-build /workspace/renpy-build +``` + +The output is `out/renpy-mobile-source/payload` plus a provenance manifest. +Android private assets are in `payload/rapt/runtime/private`. Build the debug +app with that payload, then run the real device gate described in +[`demos/aetherkiri-renpy/README.md`](../../demos/aetherkiri-renpy/README.md). + +The Build workflow requires the source-build job's output before packaging +Android. It preserves a debug APK for installation. For iOS, the workflow +requires `AETHER_RENPY_IOS_RUNTIME_RUN_ID` and +`AETHER_RENPY_IOS_RUNTIME_ARTIFACT` repository variables identifying a rebuilt +payload artifact named `renpy-ios-runtime.tar.gz` with `payload/renios` inside. +A missing input is a build failure with a diagnostic, rather than a successful +package containing only a mobile guard. + +The Mobile Contract workflow validates contracts and compilation, with no +playability claim. Desktop Acceptance runs the SDK fixtures; its Linux job +also exercises a real rendered frame, input and quit. The device gate requires +actual APK installation, executed Ren'Py script checkpoints, native RGBA +pixels matching an OS screenshot, touch, text/keyboard, background/resume, +and normal game exit while the application process stays alive. diff --git a/bridge/renpy_runtime/android/RenPyMobileBridge.java b/bridge/renpy_runtime/android/RenPyMobileBridge.java new file mode 100644 index 000000000..9ebbeb945 --- /dev/null +++ b/bridge/renpy_runtime/android/RenPyMobileBridge.java @@ -0,0 +1,141 @@ +package org.github.krkr2.aetherkiri; + +import android.app.Activity; +import android.content.Context; +import android.content.res.AssetManager; + +import org.libsdl.app.SDLActivity; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; + +/** + * Host-owned handoff point for the staged Ren'Py Android support package. + * + * The Godot Activity remains the only Activity. The provider binds that host, + * extracts the private Python runtime and initializes SDL's JNI callbacks. + * The native fork renders into an offscreen EGL pbuffer. + */ +public final class RenPyMobileBridge { + private static boolean nativeBridgeLoaded; + + static { + nativeBridgeLoaded = tryLoadNativeBridge(); + } + + private RenPyMobileBridge() { + } + + /** Bind the already-running Godot Activity; never constructs an Activity. */ + public static void bindHostActivity(Activity activity) { + if (activity == null) { + throw new IllegalArgumentException("host Activity must not be null"); + } + ensureNativeBridge(); + SDLActivity.bindHostActivity(activity); + nativeSetHostActivity(activity); + } + + /** Clear the host Activity during destruction/configuration teardown. */ + public static void clearHostActivity() { + if (nativeBridgeLoaded || tryLoadNativeBridge()) { + nativeSetHostActivity(null); + } + SDLActivity.clearHostActivity(); + } + + /** Load/retry through Java so JNI_OnLoad records the real host JavaVM. */ + public static boolean isNativeBridgeLoaded() { + return nativeBridgeLoaded || tryLoadNativeBridge(); + } + + /** + * Package inspection helper. It checks the staged arm64 payload exists; + * it does not verify lifecycle exports or gameplay. + */ + public static boolean hasBundledRenPy(Context context) { + if (context == null || context.getApplicationInfo() == null) { + return false; + } + String nativeDir = context.getApplicationInfo().nativeLibraryDir; + return nativeDir != null && new File(nativeDir, "librenpython.so").isFile(); + } + + /** Extract the bundled Python tree, separate from imported game files. */ + public static synchronized String preparePrivateRoot() throws IOException { + Context context = SDLActivity.getContext(); + if (context == null) throw new IOException("Ren'Py host Context is unavailable"); + long version; + try { version = context.getPackageManager().getPackageInfo(context.getPackageName(), 0).lastUpdateTime; } + catch (android.content.pm.PackageManager.NameNotFoundException e) { throw new IOException(e); } + File root = new File(context.getFilesDir(), "renpy_mobile/" + version); + File ready = new File(root, ".ready"); + if (!ready.isFile()) { + copyAssets(context.getAssets(), "renpy_mobile/private", root, root.getCanonicalPath()); + if (!new File(root, "main.py").isFile() || !new File(root, "renpy/aether_mobile.py").isFile()) { + throw new IOException("Ren'Py APK lacks the patched Python private payload (main.py and renpy/aether_mobile.py)"); + } + if (!ready.createNewFile() && !ready.isFile()) throw new IOException("could not mark Ren'Py payload extraction complete"); + } + return root.getCanonicalPath(); + } + + private static void copyAssets(AssetManager assets, String source, File target, String root) throws IOException { + if (!target.getCanonicalPath().startsWith(root + File.separator) && !target.getCanonicalPath().equals(root)) { + throw new IOException("Ren'Py asset escapes its extraction directory"); + } + String[] children = assets.list(source); + if (children != null && children.length > 0) { + if (!target.isDirectory() && !target.mkdirs()) throw new IOException("could not create " + target); + for (String child : children) copyAssets(assets, source + "/" + child, new File(target, child), root); + } else { + File parent = target.getParentFile(); + if (!parent.isDirectory() && !parent.mkdirs()) throw new IOException("could not create " + parent); + try (InputStream input = assets.open(source); FileOutputStream output = new FileOutputStream(target)) { + byte[] buffer = new byte[65536]; + int count; + while ((count = input.read(buffer)) != -1) output.write(buffer, 0, count); + output.getFD().sync(); + } + } + } + + public static String getApkPath() { + Context context = SDLActivity.getContext(); + return context == null ? "" : context.getApplicationInfo().sourceDir; + } + + public static void prepareRuntime(int width, int height) { + SDLActivity.prepareRuntime(width, height); + } + + public static void stopRuntime() { + // cooperative_stop destroys the offscreen window first. Clear the + // IME request while preserving the host Activity and JNI callback tables. + SDLActivity.releaseRenderTarget(); + } + + private static void ensureNativeBridge() { + if (!nativeBridgeLoaded && !tryLoadNativeBridge()) { + throw new IllegalStateException("AetherKiri engine_api JNI bridge is unavailable"); + } + } + + private static synchronized boolean tryLoadNativeBridge() { + if (nativeBridgeLoaded) { + return true; + } + try { + System.loadLibrary("engine_api"); + nativeBridgeLoaded = true; + } catch (LinkageError ignored) { + // Godot may load this class before the extension. Retry from + // ensureNativeBridge once the host has finished loading libraries. + } + return nativeBridgeLoaded; + } + + private static native void nativeSetHostActivity(Activity activity); +} diff --git a/bridge/renpy_runtime/android/org/libsdl/LICENSE.txt b/bridge/renpy_runtime/android/org/libsdl/LICENSE.txt new file mode 100644 index 000000000..3931238c9 --- /dev/null +++ b/bridge/renpy_runtime/android/org/libsdl/LICENSE.txt @@ -0,0 +1,17 @@ +Copyright (C) 1997-2022 Sam Lantinga + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. +3. This notice may not be removed or altered from any source distribution. diff --git a/bridge/renpy_runtime/android/org/libsdl/app/SDL.java b/bridge/renpy_runtime/android/org/libsdl/app/SDL.java new file mode 100644 index 000000000..3ee1c2d6e --- /dev/null +++ b/bridge/renpy_runtime/android/org/libsdl/app/SDL.java @@ -0,0 +1,86 @@ +// SDL 2.0.20, zlib license: ../LICENSE.txt. +// AetherKiri modification: host Activity owns initialization. +package org.libsdl.app; + +import android.content.Context; + +import java.lang.Class; +import java.lang.reflect.Method; + +/** + SDL library initialization +*/ +public class SDL { + + // This function should be called first and sets up the native code + // so it can call into the Java classes + public static void setupJNI() { + SDLActivity.nativeSetupJNI(); + SDLAudioManager.nativeSetupJNI(); + SDLControllerManager.nativeSetupJNI(); + } + + // This function should be called each time the activity is started + public static void initialize() { + setContext(null); + + SDLAudioManager.initialize(); + SDLControllerManager.initialize(); + } + + // This function stores the current activity (SDL or not) + public static void setContext(Context context) { + mContext = context; + } + + public static Context getContext() { + return mContext; + } + + public static void loadLibrary(String libraryName) throws UnsatisfiedLinkError, SecurityException, NullPointerException { + + if (libraryName == null) { + throw new NullPointerException("No library name provided."); + } + + try { + // Let's see if we have ReLinker available in the project. This is necessary for + // some projects that have huge numbers of local libraries bundled, and thus may + // trip a bug in Android's native library loader which ReLinker works around. (If + // loadLibrary works properly, ReLinker will simply use the normal Android method + // internally.) + // + // To use ReLinker, just add it as a dependency. For more information, see + // https://github.com/KeepSafe/ReLinker for ReLinker's repository. + // + Class relinkClass = mContext.getClassLoader().loadClass("com.getkeepsafe.relinker.ReLinker"); + Class relinkListenerClass = mContext.getClassLoader().loadClass("com.getkeepsafe.relinker.ReLinker$LoadListener"); + Class contextClass = mContext.getClassLoader().loadClass("android.content.Context"); + Class stringClass = mContext.getClassLoader().loadClass("java.lang.String"); + + // Get a 'force' instance of the ReLinker, so we can ensure libraries are reinstalled if + // they've changed during updates. + Method forceMethod = relinkClass.getDeclaredMethod("force"); + Object relinkInstance = forceMethod.invoke(null); + Class relinkInstanceClass = relinkInstance.getClass(); + + // Actually load the library! + Method loadMethod = relinkInstanceClass.getDeclaredMethod("loadLibrary", contextClass, stringClass, stringClass, relinkListenerClass); + loadMethod.invoke(relinkInstance, mContext, libraryName, null, null); + } + catch (final Throwable e) { + // Fall back + try { + System.loadLibrary(libraryName); + } + catch (final UnsatisfiedLinkError ule) { + throw ule; + } + catch (final SecurityException se) { + throw se; + } + } + } + + protected static Context mContext; +} diff --git a/bridge/renpy_runtime/android/org/libsdl/app/SDLActivity.java b/bridge/renpy_runtime/android/org/libsdl/app/SDLActivity.java new file mode 100644 index 000000000..c9075a68b --- /dev/null +++ b/bridge/renpy_runtime/android/org/libsdl/app/SDLActivity.java @@ -0,0 +1,236 @@ +package org.libsdl.app; + +import android.app.Activity; +import android.app.UiModeManager; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.content.res.Configuration; +import android.graphics.Bitmap; +import android.net.Uri; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.util.DisplayMetrics; +import android.util.SparseArray; +import android.view.InputDevice; +import android.view.PointerIcon; +import android.view.Surface; +import android.view.View; +import android.widget.Toast; + +import java.lang.ref.WeakReference; + +/** + * SDL 2.0.20 JNI callbacks delegated to Godot's existing Activity. + * The patched native payload uses SDL's offscreen EGL pbuffer driver. + * These callbacks never acquire Godot's SurfaceView or start an Activity. + * CPU frames come from Ren'Py's GL screenshot. Activity inheritance exists + * only for SDL's JNI lookup of inherited ContextWrapper methods (battery + * queries). This class is never instantiated or registered in a manifest; + * every Context receiver is the actual Godot Activity. + */ +public final class SDLActivity extends Activity { + public static final String AETHERKIRI_HOST_SHIM = "renpy-sdl-host-shim-v1"; + private static WeakReference hostActivity = new WeakReference<>(null); + private static final Handler uiHandler = new Handler(Looper.getMainLooper()); + private static final SparseArray cursors = new SparseArray<>(); + public static volatile boolean mHasFocus; + private static boolean initialized; + private static volatile boolean textInputActive; + private static int nextCursor = 1; + + private SDLActivity() {} + + public static void bindHostActivity(Activity activity) { + hostActivity = new WeakReference<>(activity); + SDL.setContext(activity); + mHasFocus = true; + } + + public static void clearHostActivity() { + releaseRenderTarget(); + mHasFocus = false; + textInputActive = false; + hostActivity = new WeakReference<>(null); + SDL.setContext(null); + } + + public static Activity getHostActivity() { return hostActivity.get(); } + public static Context getContext() { return SDL.getContext(); } + public static View getContentView() { + Activity activity = getHostActivity(); + return activity == null ? null : activity.getWindow().getDecorView(); + } + + /** Must run before Python/pygame initializes SDL video/audio. */ + public static synchronized void prepareRuntime(int width, int height) { + if (getHostActivity() == null) throw new IllegalStateException("Ren'Py host Activity is unavailable"); + if (width <= 0 || height <= 0) throw new IllegalArgumentException("invalid Ren'Py render size"); + if (!initialized) { + // dlopen does not invoke JNI_OnLoad. Java loading is required to + // bind SDL's JavaVM, then all three callback tables make SDL ready. + System.loadLibrary("renpython"); + SDLAudioManager.initialize(); + SDLControllerManager.initialize(); + SDL.setupJNI(); + initialized = true; + } + mHasFocus = true; + nativeFocusChanged(true); + } + + public static Surface getNativeSurface() { + // Fail closed if an unpatched payload accidentally selects the + // Android window driver: the host surface must never be borrowed. + return null; + } + public static synchronized void releaseRenderTarget() { + textInputActive = false; + } + public static DisplayMetrics getDisplayDPI() { return getContext().getResources().getDisplayMetrics(); } + public static boolean isAndroidTV() { + UiModeManager manager = (UiModeManager) getContext().getSystemService(Context.UI_MODE_SERVICE); + return manager != null && manager.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION; + } + public static boolean isTablet() { return getContext().getResources().getConfiguration().smallestScreenWidthDp >= 600; } + public static boolean isChromebook() { return getContext().getPackageManager().hasSystemFeature("org.chromium.arc.device_management"); } + public static boolean isDeXMode() { return false; } + public static boolean isScreenKeyboardShown() { return textInputActive; } + public static boolean shouldMinimizeOnFocusLoss() { return false; } + public static boolean supportsRelativeMouse() { return false; } + public static boolean setRelativeMouseEnabled(boolean enabled) { return !enabled; } + public static void setWindowStyle(boolean fullscreen) { /* Godot owns visible window styling. */ } + public static void setOrientation(int width, int height, boolean resizable, String hint) { /* Godot owns orientation. */ } + public static boolean setActivityTitle(String title) { + Activity activity = getHostActivity(); + if (activity == null) return false; + uiHandler.post(() -> activity.setTitle(title)); + return true; + } + public static boolean sendMessage(int command, int param) { + // SDL's text-input hide command. Godot polls the provider's active + // state and drives its own IME; no second EditText steals focus. + if (command == 3) { textInputActive = false; return true; } + return false; + } + public static boolean showTextInput(int x, int y, int width, int height) { + textInputActive = true; + return getHostActivity() != null; + } + public static void manualBackButton() { /* Ren'Py BACK is delivered by engine_send_input. */ } + public static void minimizeWindow() { + Activity activity = getHostActivity(); + if (activity != null) uiHandler.post(() -> activity.moveTaskToBack(true)); + } + public static int openURL(String url) { + Activity activity = getHostActivity(); + if (activity == null) return -1; + uiHandler.post(() -> activity.startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(url)))); + return 0; + } + public static void requestPermission(String permission, int requestCode) { + Activity activity = getHostActivity(); + if (activity == null) { nativePermissionResult(requestCode, false); return; } + if (Build.VERSION.SDK_INT < 23 || activity.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED) { + nativePermissionResult(requestCode, true); + } else { + // The host owns permission request/results; do not create an SDL + // Activity or claim a permission was granted. + nativePermissionResult(requestCode, false); + } + } + public static int showToast(String message, int duration, int gravity, int xOffset, int yOffset) { + Context context = getContext(); + if (context == null) return -1; + uiHandler.post(() -> { + Toast toast = Toast.makeText(context, message, duration); + toast.setGravity(gravity, xOffset, yOffset); + toast.show(); + }); + return 0; + } + public static boolean getManifestEnvironmentVariables() { + try { + ApplicationInfo info = getContext().getPackageManager().getApplicationInfo( + getContext().getPackageName(), PackageManager.GET_META_DATA); + if (info.metaData != null) for (String key : info.metaData.keySet()) { + if (key.startsWith("SDL_ENV.")) nativeSetenv(key.substring(8), String.valueOf(info.metaData.get(key))); + } + return true; + } catch (PackageManager.NameNotFoundException e) { return false; } + } + public static void initTouch() { + for (int id : InputDevice.getDeviceIds()) { + InputDevice device = InputDevice.getDevice(id); + if (device != null && (device.getSources() & InputDevice.SOURCE_TOUCHSCREEN) != 0) nativeAddTouch(id, device.getName()); + } + } + private static ClipboardManager clipboard() { + return (ClipboardManager) getContext().getSystemService(Context.CLIPBOARD_SERVICE); + } + public static boolean clipboardHasText() { return clipboard().hasPrimaryClip(); } + public static String clipboardGetText() { + ClipData data = clipboard().getPrimaryClip(); + return data == null || data.getItemCount() == 0 ? "" : data.getItemAt(0).coerceToText(getContext()).toString(); + } + public static void clipboardSetText(String text) { clipboard().setPrimaryClip(ClipData.newPlainText("Ren'Py", text)); } + public static int createCustomCursor(int[] colors, int width, int height, int hotSpotX, int hotSpotY) { + if (Build.VERSION.SDK_INT < 24) return 0; + PointerIcon icon = PointerIcon.create(Bitmap.createBitmap(colors, width, height, Bitmap.Config.ARGB_8888), hotSpotX, hotSpotY); + synchronized (cursors) { int id = nextCursor++; cursors.put(id, icon); return id; } + } + public static void destroyCustomCursor(int id) { synchronized (cursors) { cursors.remove(id); } } + public static boolean setCustomCursor(int id) { + if (Build.VERSION.SDK_INT < 24 || getContentView() == null) return false; + PointerIcon icon; + synchronized (cursors) { icon = cursors.get(id); } + if (icon == null) return false; + uiHandler.post(() -> { if (getContentView() != null) getContentView().setPointerIcon(icon); }); + return true; + } + public static boolean setSystemCursor(int id) { + if (Build.VERSION.SDK_INT < 24 || getContentView() == null) return false; + int[] types = { PointerIcon.TYPE_ARROW, PointerIcon.TYPE_TEXT, PointerIcon.TYPE_WAIT, + PointerIcon.TYPE_CROSSHAIR, PointerIcon.TYPE_WAIT, PointerIcon.TYPE_TOP_LEFT_DIAGONAL_DOUBLE_ARROW, + PointerIcon.TYPE_TOP_RIGHT_DIAGONAL_DOUBLE_ARROW, PointerIcon.TYPE_HORIZONTAL_DOUBLE_ARROW, + PointerIcon.TYPE_VERTICAL_DOUBLE_ARROW, PointerIcon.TYPE_ALL_SCROLL, PointerIcon.TYPE_NO_DROP, PointerIcon.TYPE_HAND }; + if (id < 0 || id >= types.length) return false; + PointerIcon icon = PointerIcon.getSystemIcon(getContext(), types[id]); + uiHandler.post(() -> { if (getContentView() != null) getContentView().setPointerIcon(icon); }); + return true; + } + + public static native int nativeSetupJNI(); + public static native int nativeRunMain(String library, String function, Object arguments); + public static native void nativeLowMemory(); + public static native void nativeSendQuit(); + public static native void nativeQuit(); + public static native void nativePause(); + public static native void nativeResume(); + public static native void nativeFocusChanged(boolean hasFocus); + public static native void onNativeDropFile(String filename); + public static native void nativeSetScreenResolution(int surfaceWidth, int surfaceHeight, int deviceWidth, int deviceHeight, float rate); + public static native void onNativeResize(); + public static native void onNativeKeyDown(int keycode); + public static native void onNativeKeyUp(int keycode); + public static native boolean onNativeSoftReturnKey(); + public static native void onNativeKeyboardFocusLost(); + public static native void nativeSetenv(String name, String value); + public static native void onNativeTouch(int touchDevId, int pointerFingerId, int action, float x, float y, float pressure); + public static native void onNativeMouse(int button, int action, float x, float y, boolean relative); + public static native void onNativeAccel(float x, float y, float z); + public static native void onNativeClipboardChanged(); + public static native void onNativeSurfaceCreated(); + public static native void onNativeSurfaceChanged(); + public static native void onNativeSurfaceDestroyed(); + public static native String nativeGetHint(String name); + public static native boolean nativeGetHintBoolean(String name, boolean defaultValue); + public static native void onNativeOrientationChanged(int orientation); + public static native void nativeAddTouch(int touchId, String name); + public static native void nativePermissionResult(int requestCode, boolean result); + public static native void onNativeLocaleChanged(); +} diff --git a/bridge/renpy_runtime/android/org/libsdl/app/SDLAudioManager.java b/bridge/renpy_runtime/android/org/libsdl/app/SDLAudioManager.java new file mode 100644 index 000000000..a5dfe2665 --- /dev/null +++ b/bridge/renpy_runtime/android/org/libsdl/app/SDLAudioManager.java @@ -0,0 +1,395 @@ +// Unmodified SDL 2.0.20 helper, zlib license: ../LICENSE.txt. +package org.libsdl.app; + +import android.media.AudioFormat; +import android.media.AudioManager; +import android.media.AudioRecord; +import android.media.AudioTrack; +import android.media.MediaRecorder; +import android.os.Build; +import android.util.Log; + +public class SDLAudioManager +{ + protected static final String TAG = "SDLAudio"; + + protected static AudioTrack mAudioTrack; + protected static AudioRecord mAudioRecord; + + public static void initialize() { + mAudioTrack = null; + mAudioRecord = null; + } + + // Audio + + protected static String getAudioFormatString(int audioFormat) { + switch (audioFormat) { + case AudioFormat.ENCODING_PCM_8BIT: + return "8-bit"; + case AudioFormat.ENCODING_PCM_16BIT: + return "16-bit"; + case AudioFormat.ENCODING_PCM_FLOAT: + return "float"; + default: + return Integer.toString(audioFormat); + } + } + + protected static int[] open(boolean isCapture, int sampleRate, int audioFormat, int desiredChannels, int desiredFrames) { + int channelConfig; + int sampleSize; + int frameSize; + + Log.v(TAG, "Opening " + (isCapture ? "capture" : "playback") + ", requested " + desiredFrames + " frames of " + desiredChannels + " channel " + getAudioFormatString(audioFormat) + " audio at " + sampleRate + " Hz"); + + /* On older devices let's use known good settings */ + if (Build.VERSION.SDK_INT < 21) { + if (desiredChannels > 2) { + desiredChannels = 2; + } + } + + /* AudioTrack has sample rate limitation of 48000 (fixed in 5.0.2) */ + if (Build.VERSION.SDK_INT < 22) { + if (sampleRate < 8000) { + sampleRate = 8000; + } else if (sampleRate > 48000) { + sampleRate = 48000; + } + } + + if (audioFormat == AudioFormat.ENCODING_PCM_FLOAT) { + int minSDKVersion = (isCapture ? 23 : 21); + if (Build.VERSION.SDK_INT < minSDKVersion) { + audioFormat = AudioFormat.ENCODING_PCM_16BIT; + } + } + switch (audioFormat) + { + case AudioFormat.ENCODING_PCM_8BIT: + sampleSize = 1; + break; + case AudioFormat.ENCODING_PCM_16BIT: + sampleSize = 2; + break; + case AudioFormat.ENCODING_PCM_FLOAT: + sampleSize = 4; + break; + default: + Log.v(TAG, "Requested format " + audioFormat + ", getting ENCODING_PCM_16BIT"); + audioFormat = AudioFormat.ENCODING_PCM_16BIT; + sampleSize = 2; + break; + } + + if (isCapture) { + switch (desiredChannels) { + case 1: + channelConfig = AudioFormat.CHANNEL_IN_MONO; + break; + case 2: + channelConfig = AudioFormat.CHANNEL_IN_STEREO; + break; + default: + Log.v(TAG, "Requested " + desiredChannels + " channels, getting stereo"); + desiredChannels = 2; + channelConfig = AudioFormat.CHANNEL_IN_STEREO; + break; + } + } else { + switch (desiredChannels) { + case 1: + channelConfig = AudioFormat.CHANNEL_OUT_MONO; + break; + case 2: + channelConfig = AudioFormat.CHANNEL_OUT_STEREO; + break; + case 3: + channelConfig = AudioFormat.CHANNEL_OUT_STEREO | AudioFormat.CHANNEL_OUT_FRONT_CENTER; + break; + case 4: + channelConfig = AudioFormat.CHANNEL_OUT_QUAD; + break; + case 5: + channelConfig = AudioFormat.CHANNEL_OUT_QUAD | AudioFormat.CHANNEL_OUT_FRONT_CENTER; + break; + case 6: + channelConfig = AudioFormat.CHANNEL_OUT_5POINT1; + break; + case 7: + channelConfig = AudioFormat.CHANNEL_OUT_5POINT1 | AudioFormat.CHANNEL_OUT_BACK_CENTER; + break; + case 8: + if (Build.VERSION.SDK_INT >= 23) { + channelConfig = AudioFormat.CHANNEL_OUT_7POINT1_SURROUND; + } else { + Log.v(TAG, "Requested " + desiredChannels + " channels, getting 5.1 surround"); + desiredChannels = 6; + channelConfig = AudioFormat.CHANNEL_OUT_5POINT1; + } + break; + default: + Log.v(TAG, "Requested " + desiredChannels + " channels, getting stereo"); + desiredChannels = 2; + channelConfig = AudioFormat.CHANNEL_OUT_STEREO; + break; + } + +/* + Log.v(TAG, "Speaker configuration (and order of channels):"); + + if ((channelConfig & 0x00000004) != 0) { + Log.v(TAG, " CHANNEL_OUT_FRONT_LEFT"); + } + if ((channelConfig & 0x00000008) != 0) { + Log.v(TAG, " CHANNEL_OUT_FRONT_RIGHT"); + } + if ((channelConfig & 0x00000010) != 0) { + Log.v(TAG, " CHANNEL_OUT_FRONT_CENTER"); + } + if ((channelConfig & 0x00000020) != 0) { + Log.v(TAG, " CHANNEL_OUT_LOW_FREQUENCY"); + } + if ((channelConfig & 0x00000040) != 0) { + Log.v(TAG, " CHANNEL_OUT_BACK_LEFT"); + } + if ((channelConfig & 0x00000080) != 0) { + Log.v(TAG, " CHANNEL_OUT_BACK_RIGHT"); + } + if ((channelConfig & 0x00000100) != 0) { + Log.v(TAG, " CHANNEL_OUT_FRONT_LEFT_OF_CENTER"); + } + if ((channelConfig & 0x00000200) != 0) { + Log.v(TAG, " CHANNEL_OUT_FRONT_RIGHT_OF_CENTER"); + } + if ((channelConfig & 0x00000400) != 0) { + Log.v(TAG, " CHANNEL_OUT_BACK_CENTER"); + } + if ((channelConfig & 0x00000800) != 0) { + Log.v(TAG, " CHANNEL_OUT_SIDE_LEFT"); + } + if ((channelConfig & 0x00001000) != 0) { + Log.v(TAG, " CHANNEL_OUT_SIDE_RIGHT"); + } +*/ + } + frameSize = (sampleSize * desiredChannels); + + // Let the user pick a larger buffer if they really want -- but ye + // gods they probably shouldn't, the minimums are horrifyingly high + // latency already + int minBufferSize; + if (isCapture) { + minBufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat); + } else { + minBufferSize = AudioTrack.getMinBufferSize(sampleRate, channelConfig, audioFormat); + } + desiredFrames = Math.max(desiredFrames, (minBufferSize + frameSize - 1) / frameSize); + + int[] results = new int[4]; + + if (isCapture) { + if (mAudioRecord == null) { + mAudioRecord = new AudioRecord(MediaRecorder.AudioSource.DEFAULT, sampleRate, + channelConfig, audioFormat, desiredFrames * frameSize); + + // see notes about AudioTrack state in audioOpen(), above. Probably also applies here. + if (mAudioRecord.getState() != AudioRecord.STATE_INITIALIZED) { + Log.e(TAG, "Failed during initialization of AudioRecord"); + mAudioRecord.release(); + mAudioRecord = null; + return null; + } + + mAudioRecord.startRecording(); + } + + results[0] = mAudioRecord.getSampleRate(); + results[1] = mAudioRecord.getAudioFormat(); + results[2] = mAudioRecord.getChannelCount(); + + } else { + if (mAudioTrack == null) { + mAudioTrack = new AudioTrack(AudioManager.STREAM_MUSIC, sampleRate, channelConfig, audioFormat, desiredFrames * frameSize, AudioTrack.MODE_STREAM); + + // Instantiating AudioTrack can "succeed" without an exception and the track may still be invalid + // Ref: https://android.googlesource.com/platform/frameworks/base/+/refs/heads/master/media/java/android/media/AudioTrack.java + // Ref: http://developer.android.com/reference/android/media/AudioTrack.html#getState() + if (mAudioTrack.getState() != AudioTrack.STATE_INITIALIZED) { + /* Try again, with safer values */ + + Log.e(TAG, "Failed during initialization of Audio Track"); + mAudioTrack.release(); + mAudioTrack = null; + return null; + } + + mAudioTrack.play(); + } + + results[0] = mAudioTrack.getSampleRate(); + results[1] = mAudioTrack.getAudioFormat(); + results[2] = mAudioTrack.getChannelCount(); + } + results[3] = desiredFrames; + + Log.v(TAG, "Opening " + (isCapture ? "capture" : "playback") + ", got " + results[3] + " frames of " + results[2] + " channel " + getAudioFormatString(results[1]) + " audio at " + results[0] + " Hz"); + + return results; + } + + /** + * This method is called by SDL using JNI. + */ + public static int[] audioOpen(int sampleRate, int audioFormat, int desiredChannels, int desiredFrames) { + return open(false, sampleRate, audioFormat, desiredChannels, desiredFrames); + } + + /** + * This method is called by SDL using JNI. + */ + public static void audioWriteFloatBuffer(float[] buffer) { + if (mAudioTrack == null) { + Log.e(TAG, "Attempted to make audio call with uninitialized audio!"); + return; + } + + for (int i = 0; i < buffer.length;) { + int result = mAudioTrack.write(buffer, i, buffer.length - i, AudioTrack.WRITE_BLOCKING); + if (result > 0) { + i += result; + } else if (result == 0) { + try { + Thread.sleep(1); + } catch(InterruptedException e) { + // Nom nom + } + } else { + Log.w(TAG, "SDL audio: error return from write(float)"); + return; + } + } + } + + /** + * This method is called by SDL using JNI. + */ + public static void audioWriteShortBuffer(short[] buffer) { + if (mAudioTrack == null) { + Log.e(TAG, "Attempted to make audio call with uninitialized audio!"); + return; + } + + for (int i = 0; i < buffer.length;) { + int result = mAudioTrack.write(buffer, i, buffer.length - i); + if (result > 0) { + i += result; + } else if (result == 0) { + try { + Thread.sleep(1); + } catch(InterruptedException e) { + // Nom nom + } + } else { + Log.w(TAG, "SDL audio: error return from write(short)"); + return; + } + } + } + + /** + * This method is called by SDL using JNI. + */ + public static void audioWriteByteBuffer(byte[] buffer) { + if (mAudioTrack == null) { + Log.e(TAG, "Attempted to make audio call with uninitialized audio!"); + return; + } + + for (int i = 0; i < buffer.length; ) { + int result = mAudioTrack.write(buffer, i, buffer.length - i); + if (result > 0) { + i += result; + } else if (result == 0) { + try { + Thread.sleep(1); + } catch(InterruptedException e) { + // Nom nom + } + } else { + Log.w(TAG, "SDL audio: error return from write(byte)"); + return; + } + } + } + + /** + * This method is called by SDL using JNI. + */ + public static int[] captureOpen(int sampleRate, int audioFormat, int desiredChannels, int desiredFrames) { + return open(true, sampleRate, audioFormat, desiredChannels, desiredFrames); + } + + /** This method is called by SDL using JNI. */ + public static int captureReadFloatBuffer(float[] buffer, boolean blocking) { + return mAudioRecord.read(buffer, 0, buffer.length, blocking ? AudioRecord.READ_BLOCKING : AudioRecord.READ_NON_BLOCKING); + } + + /** This method is called by SDL using JNI. */ + public static int captureReadShortBuffer(short[] buffer, boolean blocking) { + if (Build.VERSION.SDK_INT < 23) { + return mAudioRecord.read(buffer, 0, buffer.length); + } else { + return mAudioRecord.read(buffer, 0, buffer.length, blocking ? AudioRecord.READ_BLOCKING : AudioRecord.READ_NON_BLOCKING); + } + } + + /** This method is called by SDL using JNI. */ + public static int captureReadByteBuffer(byte[] buffer, boolean blocking) { + if (Build.VERSION.SDK_INT < 23) { + return mAudioRecord.read(buffer, 0, buffer.length); + } else { + return mAudioRecord.read(buffer, 0, buffer.length, blocking ? AudioRecord.READ_BLOCKING : AudioRecord.READ_NON_BLOCKING); + } + } + + /** This method is called by SDL using JNI. */ + public static void audioClose() { + if (mAudioTrack != null) { + mAudioTrack.stop(); + mAudioTrack.release(); + mAudioTrack = null; + } + } + + /** This method is called by SDL using JNI. */ + public static void captureClose() { + if (mAudioRecord != null) { + mAudioRecord.stop(); + mAudioRecord.release(); + mAudioRecord = null; + } + } + + /** This method is called by SDL using JNI. */ + public static void audioSetThreadPriority(boolean iscapture, int device_id) { + try { + + /* Set thread name */ + if (iscapture) { + Thread.currentThread().setName("SDLAudioC" + device_id); + } else { + Thread.currentThread().setName("SDLAudioP" + device_id); + } + + /* Set thread priority */ + android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_AUDIO); + + } catch (Exception e) { + Log.v(TAG, "modify thread properties failed " + e.toString()); + } + } + + public static native int nativeSetupJNI(); +} diff --git a/bridge/renpy_runtime/android/org/libsdl/app/SDLControllerManager.java b/bridge/renpy_runtime/android/org/libsdl/app/SDLControllerManager.java new file mode 100644 index 000000000..f14a05236 --- /dev/null +++ b/bridge/renpy_runtime/android/org/libsdl/app/SDLControllerManager.java @@ -0,0 +1,793 @@ +// Unmodified SDL 2.0.20 helper, zlib license: ../LICENSE.txt. +package org.libsdl.app; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +import android.content.Context; +import android.os.Build; +import android.os.VibrationEffect; +import android.os.Vibrator; +import android.util.Log; +import android.view.InputDevice; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.View; + + +public class SDLControllerManager +{ + + public static native int nativeSetupJNI(); + + public static native int nativeAddJoystick(int device_id, String name, String desc, + int vendor_id, int product_id, + boolean is_accelerometer, int button_mask, + int naxes, int nhats, int nballs); + public static native int nativeRemoveJoystick(int device_id); + public static native int nativeAddHaptic(int device_id, String name); + public static native int nativeRemoveHaptic(int device_id); + public static native int onNativePadDown(int device_id, int keycode); + public static native int onNativePadUp(int device_id, int keycode); + public static native void onNativeJoy(int device_id, int axis, + float value); + public static native void onNativeHat(int device_id, int hat_id, + int x, int y); + + protected static SDLJoystickHandler mJoystickHandler; + protected static SDLHapticHandler mHapticHandler; + + private static final String TAG = "SDLControllerManager"; + + public static void initialize() { + if (mJoystickHandler == null) { + if (Build.VERSION.SDK_INT >= 19) { + mJoystickHandler = new SDLJoystickHandler_API19(); + } else { + mJoystickHandler = new SDLJoystickHandler_API16(); + } + } + + if (mHapticHandler == null) { + if (Build.VERSION.SDK_INT >= 26) { + mHapticHandler = new SDLHapticHandler_API26(); + } else { + mHapticHandler = new SDLHapticHandler(); + } + } + } + + // Joystick glue code, just a series of stubs that redirect to the SDLJoystickHandler instance + public static boolean handleJoystickMotionEvent(MotionEvent event) { + return mJoystickHandler.handleMotionEvent(event); + } + + /** + * This method is called by SDL using JNI. + */ + public static void pollInputDevices() { + mJoystickHandler.pollInputDevices(); + } + + /** + * This method is called by SDL using JNI. + */ + public static void pollHapticDevices() { + mHapticHandler.pollHapticDevices(); + } + + /** + * This method is called by SDL using JNI. + */ + public static void hapticRun(int device_id, float intensity, int length) { + mHapticHandler.run(device_id, intensity, length); + } + + /** + * This method is called by SDL using JNI. + */ + public static void hapticStop(int device_id) + { + mHapticHandler.stop(device_id); + } + + // Check if a given device is considered a possible SDL joystick + public static boolean isDeviceSDLJoystick(int deviceId) { + InputDevice device = InputDevice.getDevice(deviceId); + // We cannot use InputDevice.isVirtual before API 16, so let's accept + // only nonnegative device ids (VIRTUAL_KEYBOARD equals -1) + if ((device == null) || (deviceId < 0)) { + return false; + } + int sources = device.getSources(); + + /* This is called for every button press, so let's not spam the logs */ + /* + if ((sources & InputDevice.SOURCE_CLASS_JOYSTICK) != 0) { + Log.v(TAG, "Input device " + device.getName() + " has class joystick."); + } + if ((sources & InputDevice.SOURCE_DPAD) == InputDevice.SOURCE_DPAD) { + Log.v(TAG, "Input device " + device.getName() + " is a dpad."); + } + if ((sources & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD) { + Log.v(TAG, "Input device " + device.getName() + " is a gamepad."); + } + */ + + return ((sources & InputDevice.SOURCE_CLASS_JOYSTICK) != 0 || + ((sources & InputDevice.SOURCE_DPAD) == InputDevice.SOURCE_DPAD) || + ((sources & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD) + ); + } + +} + +class SDLJoystickHandler { + + /** + * Handles given MotionEvent. + * @param event the event to be handled. + * @return if given event was processed. + */ + public boolean handleMotionEvent(MotionEvent event) { + return false; + } + + /** + * Handles adding and removing of input devices. + */ + public void pollInputDevices() { + } +} + +/* Actual joystick functionality available for API >= 12 devices */ +class SDLJoystickHandler_API16 extends SDLJoystickHandler { + + static class SDLJoystick { + public int device_id; + public String name; + public String desc; + public ArrayList axes; + public ArrayList hats; + } + static class RangeComparator implements Comparator { + @Override + public int compare(InputDevice.MotionRange arg0, InputDevice.MotionRange arg1) { + // Some controllers, like the Moga Pro 2, return AXIS_GAS (22) for right trigger and AXIS_BRAKE (23) for left trigger - swap them so they're sorted in the right order for SDL + int arg0Axis = arg0.getAxis(); + int arg1Axis = arg1.getAxis(); + if (arg0Axis == MotionEvent.AXIS_GAS) { + arg0Axis = MotionEvent.AXIS_BRAKE; + } else if (arg0Axis == MotionEvent.AXIS_BRAKE) { + arg0Axis = MotionEvent.AXIS_GAS; + } + if (arg1Axis == MotionEvent.AXIS_GAS) { + arg1Axis = MotionEvent.AXIS_BRAKE; + } else if (arg1Axis == MotionEvent.AXIS_BRAKE) { + arg1Axis = MotionEvent.AXIS_GAS; + } + + return arg0Axis - arg1Axis; + } + } + + private final ArrayList mJoysticks; + + public SDLJoystickHandler_API16() { + + mJoysticks = new ArrayList(); + } + + @Override + public void pollInputDevices() { + int[] deviceIds = InputDevice.getDeviceIds(); + + for (int device_id : deviceIds) { + if (SDLControllerManager.isDeviceSDLJoystick(device_id)) { + SDLJoystick joystick = getJoystick(device_id); + if (joystick == null) { + InputDevice joystickDevice = InputDevice.getDevice(device_id); + joystick = new SDLJoystick(); + joystick.device_id = device_id; + joystick.name = joystickDevice.getName(); + joystick.desc = getJoystickDescriptor(joystickDevice); + joystick.axes = new ArrayList(); + joystick.hats = new ArrayList(); + + List ranges = joystickDevice.getMotionRanges(); + Collections.sort(ranges, new RangeComparator()); + for (InputDevice.MotionRange range : ranges) { + if ((range.getSource() & InputDevice.SOURCE_CLASS_JOYSTICK) != 0) { + if (range.getAxis() == MotionEvent.AXIS_HAT_X || range.getAxis() == MotionEvent.AXIS_HAT_Y) { + joystick.hats.add(range); + } else { + joystick.axes.add(range); + } + } + } + + mJoysticks.add(joystick); + SDLControllerManager.nativeAddJoystick(joystick.device_id, joystick.name, joystick.desc, + getVendorId(joystickDevice), getProductId(joystickDevice), false, + getButtonMask(joystickDevice), joystick.axes.size(), joystick.hats.size()/2, 0); + } + } + } + + /* Check removed devices */ + ArrayList removedDevices = null; + for (SDLJoystick joystick : mJoysticks) { + int device_id = joystick.device_id; + int i; + for (i = 0; i < deviceIds.length; i++) { + if (device_id == deviceIds[i]) break; + } + if (i == deviceIds.length) { + if (removedDevices == null) { + removedDevices = new ArrayList(); + } + removedDevices.add(device_id); + } + } + + if (removedDevices != null) { + for (int device_id : removedDevices) { + SDLControllerManager.nativeRemoveJoystick(device_id); + for (int i = 0; i < mJoysticks.size(); i++) { + if (mJoysticks.get(i).device_id == device_id) { + mJoysticks.remove(i); + break; + } + } + } + } + } + + protected SDLJoystick getJoystick(int device_id) { + for (SDLJoystick joystick : mJoysticks) { + if (joystick.device_id == device_id) { + return joystick; + } + } + return null; + } + + @Override + public boolean handleMotionEvent(MotionEvent event) { + if ((event.getSource() & InputDevice.SOURCE_JOYSTICK) != 0) { + int actionPointerIndex = event.getActionIndex(); + int action = event.getActionMasked(); + if (action == MotionEvent.ACTION_MOVE) { + SDLJoystick joystick = getJoystick(event.getDeviceId()); + if (joystick != null) { + for (int i = 0; i < joystick.axes.size(); i++) { + InputDevice.MotionRange range = joystick.axes.get(i); + /* Normalize the value to -1...1 */ + float value = (event.getAxisValue(range.getAxis(), actionPointerIndex) - range.getMin()) / range.getRange() * 2.0f - 1.0f; + SDLControllerManager.onNativeJoy(joystick.device_id, i, value); + } + for (int i = 0; i < joystick.hats.size() / 2; i++) { + int hatX = Math.round(event.getAxisValue(joystick.hats.get(2 * i).getAxis(), actionPointerIndex)); + int hatY = Math.round(event.getAxisValue(joystick.hats.get(2 * i + 1).getAxis(), actionPointerIndex)); + SDLControllerManager.onNativeHat(joystick.device_id, i, hatX, hatY); + } + } + } + } + return true; + } + + public String getJoystickDescriptor(InputDevice joystickDevice) { + String desc = joystickDevice.getDescriptor(); + + if (desc != null && !desc.isEmpty()) { + return desc; + } + + return joystickDevice.getName(); + } + public int getProductId(InputDevice joystickDevice) { + return 0; + } + public int getVendorId(InputDevice joystickDevice) { + return 0; + } + public int getButtonMask(InputDevice joystickDevice) { + return -1; + } +} + +class SDLJoystickHandler_API19 extends SDLJoystickHandler_API16 { + + @Override + public int getProductId(InputDevice joystickDevice) { + return joystickDevice.getProductId(); + } + + @Override + public int getVendorId(InputDevice joystickDevice) { + return joystickDevice.getVendorId(); + } + + @Override + public int getButtonMask(InputDevice joystickDevice) { + int button_mask = 0; + int[] keys = new int[] { + KeyEvent.KEYCODE_BUTTON_A, + KeyEvent.KEYCODE_BUTTON_B, + KeyEvent.KEYCODE_BUTTON_X, + KeyEvent.KEYCODE_BUTTON_Y, + KeyEvent.KEYCODE_BACK, + KeyEvent.KEYCODE_BUTTON_MODE, + KeyEvent.KEYCODE_BUTTON_START, + KeyEvent.KEYCODE_BUTTON_THUMBL, + KeyEvent.KEYCODE_BUTTON_THUMBR, + KeyEvent.KEYCODE_BUTTON_L1, + KeyEvent.KEYCODE_BUTTON_R1, + KeyEvent.KEYCODE_DPAD_UP, + KeyEvent.KEYCODE_DPAD_DOWN, + KeyEvent.KEYCODE_DPAD_LEFT, + KeyEvent.KEYCODE_DPAD_RIGHT, + KeyEvent.KEYCODE_BUTTON_SELECT, + KeyEvent.KEYCODE_DPAD_CENTER, + + // These don't map into any SDL controller buttons directly + KeyEvent.KEYCODE_BUTTON_L2, + KeyEvent.KEYCODE_BUTTON_R2, + KeyEvent.KEYCODE_BUTTON_C, + KeyEvent.KEYCODE_BUTTON_Z, + KeyEvent.KEYCODE_BUTTON_1, + KeyEvent.KEYCODE_BUTTON_2, + KeyEvent.KEYCODE_BUTTON_3, + KeyEvent.KEYCODE_BUTTON_4, + KeyEvent.KEYCODE_BUTTON_5, + KeyEvent.KEYCODE_BUTTON_6, + KeyEvent.KEYCODE_BUTTON_7, + KeyEvent.KEYCODE_BUTTON_8, + KeyEvent.KEYCODE_BUTTON_9, + KeyEvent.KEYCODE_BUTTON_10, + KeyEvent.KEYCODE_BUTTON_11, + KeyEvent.KEYCODE_BUTTON_12, + KeyEvent.KEYCODE_BUTTON_13, + KeyEvent.KEYCODE_BUTTON_14, + KeyEvent.KEYCODE_BUTTON_15, + KeyEvent.KEYCODE_BUTTON_16, + }; + int[] masks = new int[] { + (1 << 0), // A -> A + (1 << 1), // B -> B + (1 << 2), // X -> X + (1 << 3), // Y -> Y + (1 << 4), // BACK -> BACK + (1 << 5), // MODE -> GUIDE + (1 << 6), // START -> START + (1 << 7), // THUMBL -> LEFTSTICK + (1 << 8), // THUMBR -> RIGHTSTICK + (1 << 9), // L1 -> LEFTSHOULDER + (1 << 10), // R1 -> RIGHTSHOULDER + (1 << 11), // DPAD_UP -> DPAD_UP + (1 << 12), // DPAD_DOWN -> DPAD_DOWN + (1 << 13), // DPAD_LEFT -> DPAD_LEFT + (1 << 14), // DPAD_RIGHT -> DPAD_RIGHT + (1 << 4), // SELECT -> BACK + (1 << 0), // DPAD_CENTER -> A + (1 << 15), // L2 -> ?? + (1 << 16), // R2 -> ?? + (1 << 17), // C -> ?? + (1 << 18), // Z -> ?? + (1 << 20), // 1 -> ?? + (1 << 21), // 2 -> ?? + (1 << 22), // 3 -> ?? + (1 << 23), // 4 -> ?? + (1 << 24), // 5 -> ?? + (1 << 25), // 6 -> ?? + (1 << 26), // 7 -> ?? + (1 << 27), // 8 -> ?? + (1 << 28), // 9 -> ?? + (1 << 29), // 10 -> ?? + (1 << 30), // 11 -> ?? + (1 << 31), // 12 -> ?? + // We're out of room... + 0xFFFFFFFF, // 13 -> ?? + 0xFFFFFFFF, // 14 -> ?? + 0xFFFFFFFF, // 15 -> ?? + 0xFFFFFFFF, // 16 -> ?? + }; + boolean[] has_keys = joystickDevice.hasKeys(keys); + for (int i = 0; i < keys.length; ++i) { + if (has_keys[i]) { + button_mask |= masks[i]; + } + } + return button_mask; + } +} + +class SDLHapticHandler_API26 extends SDLHapticHandler { + @Override + public void run(int device_id, float intensity, int length) { + SDLHaptic haptic = getHaptic(device_id); + if (haptic != null) { + Log.d("SDL", "Rtest: Vibe with intensity " + intensity + " for " + length); + if (intensity == 0.0f) { + stop(device_id); + return; + } + + int vibeValue = Math.round(intensity * 255); + + if (vibeValue > 255) { + vibeValue = 255; + } + if (vibeValue < 1) { + stop(device_id); + return; + } + try { + haptic.vib.vibrate(VibrationEffect.createOneShot(length, vibeValue)); + } + catch (Exception e) { + // Fall back to the generic method, which uses DEFAULT_AMPLITUDE, but works even if + // something went horribly wrong with the Android 8.0 APIs. + haptic.vib.vibrate(length); + } + } + } +} + +class SDLHapticHandler { + + static class SDLHaptic { + public int device_id; + public String name; + public Vibrator vib; + } + + private final ArrayList mHaptics; + + public SDLHapticHandler() { + mHaptics = new ArrayList(); + } + + public void run(int device_id, float intensity, int length) { + SDLHaptic haptic = getHaptic(device_id); + if (haptic != null) { + haptic.vib.vibrate(length); + } + } + + public void stop(int device_id) { + SDLHaptic haptic = getHaptic(device_id); + if (haptic != null) { + haptic.vib.cancel(); + } + } + + public void pollHapticDevices() { + + final int deviceId_VIBRATOR_SERVICE = 999999; + boolean hasVibratorService = false; + + int[] deviceIds = InputDevice.getDeviceIds(); + // It helps processing the device ids in reverse order + // For example, in the case of the XBox 360 wireless dongle, + // so the first controller seen by SDL matches what the receiver + // considers to be the first controller + + for (int i = deviceIds.length - 1; i > -1; i--) { + SDLHaptic haptic = getHaptic(deviceIds[i]); + if (haptic == null) { + InputDevice device = InputDevice.getDevice(deviceIds[i]); + Vibrator vib = device.getVibrator(); + if (vib.hasVibrator()) { + haptic = new SDLHaptic(); + haptic.device_id = deviceIds[i]; + haptic.name = device.getName(); + haptic.vib = vib; + mHaptics.add(haptic); + SDLControllerManager.nativeAddHaptic(haptic.device_id, haptic.name); + } + } + } + + /* Check VIBRATOR_SERVICE */ + Vibrator vib = (Vibrator) SDL.getContext().getSystemService(Context.VIBRATOR_SERVICE); + if (vib != null) { + hasVibratorService = vib.hasVibrator(); + + if (hasVibratorService) { + SDLHaptic haptic = getHaptic(deviceId_VIBRATOR_SERVICE); + if (haptic == null) { + haptic = new SDLHaptic(); + haptic.device_id = deviceId_VIBRATOR_SERVICE; + haptic.name = "VIBRATOR_SERVICE"; + haptic.vib = vib; + mHaptics.add(haptic); + SDLControllerManager.nativeAddHaptic(haptic.device_id, haptic.name); + } + } + } + + /* Check removed devices */ + ArrayList removedDevices = null; + for (SDLHaptic haptic : mHaptics) { + int device_id = haptic.device_id; + int i; + for (i = 0; i < deviceIds.length; i++) { + if (device_id == deviceIds[i]) break; + } + + if (device_id != deviceId_VIBRATOR_SERVICE || !hasVibratorService) { + if (i == deviceIds.length) { + if (removedDevices == null) { + removedDevices = new ArrayList(); + } + removedDevices.add(device_id); + } + } // else: don't remove the vibrator if it is still present + } + + if (removedDevices != null) { + for (int device_id : removedDevices) { + SDLControllerManager.nativeRemoveHaptic(device_id); + for (int i = 0; i < mHaptics.size(); i++) { + if (mHaptics.get(i).device_id == device_id) { + mHaptics.remove(i); + break; + } + } + } + } + } + + protected SDLHaptic getHaptic(int device_id) { + for (SDLHaptic haptic : mHaptics) { + if (haptic.device_id == device_id) { + return haptic; + } + } + return null; + } +} + +class SDLGenericMotionListener_API12 implements View.OnGenericMotionListener { + // Generic Motion (mouse hover, joystick...) events go here + @Override + public boolean onGenericMotion(View v, MotionEvent event) { + float x, y; + int action; + + switch ( event.getSource() ) { + case InputDevice.SOURCE_JOYSTICK: + case InputDevice.SOURCE_GAMEPAD: + case InputDevice.SOURCE_DPAD: + return SDLControllerManager.handleJoystickMotionEvent(event); + + case InputDevice.SOURCE_MOUSE: + action = event.getActionMasked(); + switch (action) { + case MotionEvent.ACTION_SCROLL: + x = event.getAxisValue(MotionEvent.AXIS_HSCROLL, 0); + y = event.getAxisValue(MotionEvent.AXIS_VSCROLL, 0); + SDLActivity.onNativeMouse(0, action, x, y, false); + return true; + + case MotionEvent.ACTION_HOVER_MOVE: + x = event.getX(0); + y = event.getY(0); + + SDLActivity.onNativeMouse(0, action, x, y, false); + return true; + + default: + break; + } + break; + + default: + break; + } + + // Event was not managed + return false; + } + + public boolean supportsRelativeMouse() { + return false; + } + + public boolean inRelativeMode() { + return false; + } + + public boolean setRelativeMouseEnabled(boolean enabled) { + return false; + } + + public void reclaimRelativeMouseModeIfNeeded() + { + + } + + public float getEventX(MotionEvent event) { + return event.getX(0); + } + + public float getEventY(MotionEvent event) { + return event.getY(0); + } + +} + +class SDLGenericMotionListener_API24 extends SDLGenericMotionListener_API12 { + // Generic Motion (mouse hover, joystick...) events go here + + private boolean mRelativeModeEnabled; + + @Override + public boolean onGenericMotion(View v, MotionEvent event) { + + // Handle relative mouse mode + if (mRelativeModeEnabled) { + if (event.getSource() == InputDevice.SOURCE_MOUSE) { + int action = event.getActionMasked(); + if (action == MotionEvent.ACTION_HOVER_MOVE) { + float x = event.getAxisValue(MotionEvent.AXIS_RELATIVE_X); + float y = event.getAxisValue(MotionEvent.AXIS_RELATIVE_Y); + SDLActivity.onNativeMouse(0, action, x, y, true); + return true; + } + } + } + + // Event was not managed, call SDLGenericMotionListener_API12 method + return super.onGenericMotion(v, event); + } + + @Override + public boolean supportsRelativeMouse() { + return true; + } + + @Override + public boolean inRelativeMode() { + return mRelativeModeEnabled; + } + + @Override + public boolean setRelativeMouseEnabled(boolean enabled) { + mRelativeModeEnabled = enabled; + return true; + } + + @Override + public float getEventX(MotionEvent event) { + if (mRelativeModeEnabled) { + return event.getAxisValue(MotionEvent.AXIS_RELATIVE_X); + } else { + return event.getX(0); + } + } + + @Override + public float getEventY(MotionEvent event) { + if (mRelativeModeEnabled) { + return event.getAxisValue(MotionEvent.AXIS_RELATIVE_Y); + } else { + return event.getY(0); + } + } +} + +class SDLGenericMotionListener_API26 extends SDLGenericMotionListener_API24 { + // Generic Motion (mouse hover, joystick...) events go here + private boolean mRelativeModeEnabled; + + @Override + public boolean onGenericMotion(View v, MotionEvent event) { + float x, y; + int action; + + switch ( event.getSource() ) { + case InputDevice.SOURCE_JOYSTICK: + case InputDevice.SOURCE_GAMEPAD: + case InputDevice.SOURCE_DPAD: + return SDLControllerManager.handleJoystickMotionEvent(event); + + case InputDevice.SOURCE_MOUSE: + // DeX desktop mouse cursor is a separate non-standard input type. + case InputDevice.SOURCE_MOUSE | InputDevice.SOURCE_TOUCHSCREEN: + action = event.getActionMasked(); + switch (action) { + case MotionEvent.ACTION_SCROLL: + x = event.getAxisValue(MotionEvent.AXIS_HSCROLL, 0); + y = event.getAxisValue(MotionEvent.AXIS_VSCROLL, 0); + SDLActivity.onNativeMouse(0, action, x, y, false); + return true; + + case MotionEvent.ACTION_HOVER_MOVE: + x = event.getX(0); + y = event.getY(0); + SDLActivity.onNativeMouse(0, action, x, y, false); + return true; + + default: + break; + } + break; + + case InputDevice.SOURCE_MOUSE_RELATIVE: + action = event.getActionMasked(); + switch (action) { + case MotionEvent.ACTION_SCROLL: + x = event.getAxisValue(MotionEvent.AXIS_HSCROLL, 0); + y = event.getAxisValue(MotionEvent.AXIS_VSCROLL, 0); + SDLActivity.onNativeMouse(0, action, x, y, false); + return true; + + case MotionEvent.ACTION_HOVER_MOVE: + x = event.getX(0); + y = event.getY(0); + SDLActivity.onNativeMouse(0, action, x, y, true); + return true; + + default: + break; + } + break; + + default: + break; + } + + // Event was not managed + return false; + } + + @Override + public boolean supportsRelativeMouse() { + return (!SDLActivity.isDeXMode() || (Build.VERSION.SDK_INT >= 27)); + } + + @Override + public boolean inRelativeMode() { + return mRelativeModeEnabled; + } + + @Override + public boolean setRelativeMouseEnabled(boolean enabled) { + if (!SDLActivity.isDeXMode() || (Build.VERSION.SDK_INT >= 27)) { + if (enabled) { + SDLActivity.getContentView().requestPointerCapture(); + } else { + SDLActivity.getContentView().releasePointerCapture(); + } + mRelativeModeEnabled = enabled; + return true; + } else { + return false; + } + } + + @Override + public void reclaimRelativeMouseModeIfNeeded() + { + if (mRelativeModeEnabled && !SDLActivity.isDeXMode()) { + SDLActivity.getContentView().requestPointerCapture(); + } + } + + @Override + public float getEventX(MotionEvent event) { + // Relative mouse in capture mode will only have relative for X/Y + return event.getX(0); + } + + @Override + public float getEventY(MotionEvent event) { + // Relative mouse in capture mode will only have relative for X/Y + return event.getY(0); + } +} diff --git a/bridge/renpy_runtime/android/org/libsdl/app/SDLInputConnection.java b/bridge/renpy_runtime/android/org/libsdl/app/SDLInputConnection.java new file mode 100644 index 000000000..41880d623 --- /dev/null +++ b/bridge/renpy_runtime/android/org/libsdl/app/SDLInputConnection.java @@ -0,0 +1,13 @@ +package org.libsdl.app; + +/** + * Registration target required by SDL 2.0.20 JNI_OnLoad. Godot owns the IME + * view and sends committed Unicode through engine_send_input; this class + * never adds another editor or consumes uncommitted composition text. + */ +public final class SDLInputConnection { + private SDLInputConnection() {} + public static native void nativeCommitText(String text, int newCursorPosition); + public static native void nativeGenerateScancodeForUnichar(char unicode); + public static native void nativeSetComposingText(String text, int newCursorPosition); +} diff --git a/bridge/renpy_runtime/android/org/renpy/android/PythonSDLActivity.java b/bridge/renpy_runtime/android/org/renpy/android/PythonSDLActivity.java new file mode 100644 index 000000000..43ec5b39c --- /dev/null +++ b/bridge/renpy_runtime/android/org/renpy/android/PythonSDLActivity.java @@ -0,0 +1,52 @@ +package org.renpy.android; + +import android.app.Activity; +import android.content.Context; +import android.content.pm.PackageManager; +import android.os.Build; +import android.os.VibrationEffect; +import android.os.Vibrator; +import android.view.WindowManager; + +import org.libsdl.app.SDLActivity; + +/** Ren'Py Android API delegated to the existing host, without an Activity. */ +public final class PythonSDLActivity { + public static final String AETHERKIRI_HOST_SHIM = "renpy-python-host-shim-v1"; + public static final PythonSDLActivity mActivity = new PythonSDLActivity(); + public Object mStore = null; + public volatile boolean quitRequested; + + public Activity getActivity() { + Activity activity = SDLActivity.getHostActivity(); + if (activity == null) throw new IllegalStateException("Ren'Py host Activity is unavailable"); + return activity; + } + public PackageManager getPackageManager() { return getActivity().getPackageManager(); } + public String getPackageName() { return getActivity().getPackageName(); } + public int getDPI() { return getActivity().getResources().getDisplayMetrics().densityDpi; } + public static boolean isChromebook() { return SDLActivity.isChromebook(); } + public void openUrl(String url) { SDLActivity.openURL(url); } + public void vibrate(double seconds) { + Vibrator vibrator = (Vibrator) getActivity().getSystemService(Context.VIBRATOR_SERVICE); + if (vibrator == null || seconds <= 0) return; + long duration = Math.max(1L, (long) (seconds * 1000.0)); + if (Build.VERSION.SDK_INT >= 26) vibrator.vibrate(VibrationEffect.createOneShot(duration, VibrationEffect.DEFAULT_AMPLITUDE)); + else vibrator.vibrate(duration); + } + public void setWakeLock(boolean active) { + Activity activity = getActivity(); + activity.runOnUiThread(() -> { + if (active) activity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + else activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + }); + } + // The host owns the visible loading UI and lifecycle. cooperative_pause + // saves state and pauses audio instead of asking another Activity to stop. + public void hidePresplash() {} + public void armOnStop() {} + public void finishOnStop() { quitRequested = true; } + public void finishAndRemoveTask() { quitRequested = true; } + public void createStore() { /* IAP integration is not registered by this host. */ } + public native void nativeSetEnv(String variable, String value); +} diff --git a/bridge/renpy_runtime/include/renpy_android_host.h b/bridge/renpy_runtime/include/renpy_android_host.h new file mode 100644 index 000000000..e66d35531 --- /dev/null +++ b/bridge/renpy_runtime/include/renpy_android_host.h @@ -0,0 +1,105 @@ +#pragma once + +#include +#include + +namespace aetherkiri::renpy::mobile { + +// Private Android handoff. Neither JNI references nor callback tables travel +// through the public provider's reserved media/shader extension slots. +class AndroidLocalReference final { + public: + AndroidLocalReference(JNIEnv* env, jobject value) : env_(env), value_(value) {} + ~AndroidLocalReference() { if (value_) env_->DeleteLocalRef(value_); } + AndroidLocalReference(const AndroidLocalReference&) = delete; + AndroidLocalReference& operator=(const AndroidLocalReference&) = delete; + jobject get() const { return value_; } + private: + JNIEnv* env_; + jobject value_; +}; + +inline bool AndroidHostException(JNIEnv* env, std::string* error) { + if (!env->ExceptionCheck()) return false; + AndroidLocalReference exception(env, env->ExceptionOccurred()); + env->ExceptionClear(); + if (exception.get()) { + AndroidLocalReference klass(env, env->GetObjectClass(exception.get())); + if (!env->ExceptionCheck() && klass.get()) { + const auto describe = env->GetMethodID(static_cast(klass.get()), + "toString", "()Ljava/lang/String;"); + if (!env->ExceptionCheck() && describe) { + AndroidLocalReference detail(env, env->CallObjectMethod(exception.get(), describe)); + if (!env->ExceptionCheck() && detail.get()) { + const char* utf8 = env->GetStringUTFChars(static_cast(detail.get()), nullptr); + if (utf8) { + *error += ": "; + *error += utf8; + env->ReleaseStringUTFChars(static_cast(detail.get()), utf8); + } + } + } + } + } + if (env->ExceptionCheck()) env->ExceptionClear(); + return true; +} + +inline bool RequireAndroidHostActivity(JNIEnv* env, jobject activity, + std::string* error) { + if (!activity) { + *error = "Ren'Py requires the existing Godot Activity"; + return false; + } + AndroidLocalReference klass(env, env->FindClass("android/app/Activity")); + *error = "Ren'Py could not validate the existing Godot Activity"; + if (AndroidHostException(env, error) || !klass.get()) return false; + const bool valid = env->IsInstanceOf(activity, static_cast(klass.get())); + if (AndroidHostException(env, error)) return false; + if (!valid) { + *error = "Ren'Py host must be an android.app.Activity"; + return false; + } + return true; +} + +using AndroidActivityResolver = jobject (*)(JNIEnv*); // Returns an owned local reference. +using AndroidClassResolver = jclass (*)(JNIEnv*, const char*); // App class loader; owned local ref. + +inline bool BindRenPyAndroidHost(JNIEnv* env, AndroidActivityResolver resolve_activity, + AndroidClassResolver resolve_class, std::string* error) { + if (!env) { + *error = "Ren'Py could not attach to the Android VM"; + return false; + } + AndroidLocalReference activity(env, resolve_activity(env)); + *error = "Ren'Py could not obtain the existing Godot Activity"; + if (AndroidHostException(env, error) || + !RequireAndroidHostActivity(env, activity.get(), error)) return false; + AndroidLocalReference bridge(env, resolve_class(env, + "org/github/krkr2/aetherkiri/RenPyMobileBridge")); + *error = "Ren'Py Android host bridge is missing from the APK"; + if (AndroidHostException(env, error) || !bridge.get()) return false; + const auto bind = env->GetStaticMethodID(static_cast(bridge.get()), + "bindHostActivity", "(Landroid/app/Activity;)V"); + *error = "Ren'Py Android host bridge lacks bindHostActivity(Activity)"; + if (AndroidHostException(env, error) || !bind) return false; + env->CallStaticVoidMethod(static_cast(bridge.get()), bind, activity.get()); + *error = "Ren'Py could not bind the existing Godot Activity"; + if (AndroidHostException(env, error)) return false; + error->clear(); + return true; +} + +inline bool PrepareRenPyAndroidHost(bool (*load_bridge)(std::string*), + JNIEnv* (*resolve_env)(), + AndroidActivityResolver resolve_activity, + AndroidClassResolver resolve_class, + std::string* error) { + // load_bridge uses Godot's own Java environment. Do not ask engine_api for + // its cached JavaVM until the real Java load has called JNI_OnLoad. + if (!load_bridge(error)) return false; + return BindRenPyAndroidHost(resolve_env(), resolve_activity, resolve_class, error); +} + +} // namespace aetherkiri::renpy::mobile diff --git a/bridge/renpy_runtime/include/renpy_godot_input.h b/bridge/renpy_runtime/include/renpy_godot_input.h new file mode 100644 index 000000000..2162e2411 --- /dev/null +++ b/bridge/renpy_runtime/include/renpy_godot_input.h @@ -0,0 +1,45 @@ +#pragma once + +#include "engine_api.h" +#include "renpy_input_mapping.h" + +namespace aetherkiri::renpy::input_mapping { + +inline bool HasCommittedGodotText(int modifiers, uint32_t codepoint) { + if (codepoint < 0x20 || (codepoint >= 0x7f && codepoint < 0xa0) || + codepoint > 0x10ffff || (codepoint >= 0xd800 && codepoint <= 0xdfff)) + return false; + if (modifiers & kGodotModifierMeta) return false; + const bool control = (modifiers & (kAetherModifierControl | kGodotModifierControl)) != 0; + const bool alt = (modifiers & (kAetherModifierAlt | kGodotModifierAlt)) != 0; + // Ctrl shortcuts are key events. Ctrl+Alt may be AltGr and can legitimately + // supply a committed character; do not synthesize one from the keycode. + return !control || alt; +} + +template +engine_result_t SendGodotKeyEvents(Send&& send, bool pressed, int keycode, + int modifiers, uint32_t codepoint) { + engine_input_event_t event{}; + event.struct_size = sizeof(event); + engine_result_t result = ENGINE_RESULT_OK; + if (keycode != 0) { + event.type = pressed ? ENGINE_INPUT_EVENT_KEY_DOWN : ENGINE_INPUT_EVENT_KEY_UP; + event.key_code = keycode; + event.modifiers = modifiers; + event.reserved_u32 = static_cast(KeyCodeSpace::Godot); + // Ren'Py text is committed through TEXT_INPUT. Desktop and mobile + // KEYDOWN must not carry another copy of that Unicode character. + result = send(event); + } + if (result == ENGINE_RESULT_OK && pressed && HasCommittedGodotText(modifiers, codepoint)) { + event.type = ENGINE_INPUT_EVENT_TEXT_INPUT; + event.key_code = event.modifiers = 0; + event.reserved_u32 = 0; + event.unicode_codepoint = codepoint; + result = send(event); + } + return result; +} + +} // namespace aetherkiri::renpy::input_mapping diff --git a/bridge/renpy_runtime/include/renpy_input_mapping.h b/bridge/renpy_runtime/include/renpy_input_mapping.h new file mode 100644 index 000000000..57b8004cf --- /dev/null +++ b/bridge/renpy_runtime/include/renpy_input_mapping.h @@ -0,0 +1,200 @@ +#pragma once + +namespace aetherkiri::renpy::input_mapping { + +// The Godot shell translates keys to Windows-style virtual keys and uses +// KiriKiri modifier bits (main.gd's _kirikiri_virtual_key/key_modifiers). +// Ren'Py 8.5.x uses pygame_sdl2's SDL2 keycodes/KMOD_* values. Keep this +// translation independent of SDL headers, which are owned by the child SDK. +constexpr int kPygameScancodeMask = 1 << 30; +constexpr int kPygameKmodShift = 0x0003; +constexpr int kPygameKmodControl = 0x00c0; +constexpr int kPygameKmodAlt = 0x0300; +constexpr int kPygameKmodGui = 0x0c00; +constexpr int kAetherModifierShift = 0x01; +constexpr int kAetherModifierAlt = 0x02; +constexpr int kAetherModifierControl = 0x04; +constexpr int kAetherModifierEcho = 0x80; +constexpr int kGodotModifierShift = 1 << 25; +constexpr int kGodotModifierAlt = 1 << 26; +constexpr int kGodotModifierMeta = 1 << 27; +constexpr int kGodotModifierControl = 1 << 28; +constexpr int kGodotSpecial = 1 << 22; +constexpr int kGodotCodeMask = 0x007fffff; + +constexpr inline int PygameFunctionKey(int function) { + // SDL2's F1-F12 scancodes are contiguous at 58. F13-F24 start at + // scancode 104, after the keypad block. + if (function >= 1 && function <= 12) + return kPygameScancodeMask + 58 + function - 1; + if (function >= 13 && function <= 24) + return kPygameScancodeMask + 104 + function - 13; + return 0; +} + +inline int MapPointerButtonToPygame(int button) { + // EngineApi: 0=left, 1=right, 2=middle. pygame: 1=left, 2=middle, + // 3=right. Do not confuse the already-translated ABI with raw Godot's + // MouseButton enum (1=left, 2=right, 3=middle). + switch (button) { + case 0: return 1; + case 1: return 3; + case 2: return 2; + default: return button > 0 ? button : 1; + } +} + +// Ren'Py-only use of engine_input_event_t.reserved_u32. An explicit source +// removes the low-byte ambiguity between Godot characters and Windows VKs. +// The provider consumes this field before sending SDL keycodes to the launcher. +enum class KeyCodeSpace : unsigned int { Legacy = 0, Godot = 1 }; + +inline int MapGodotKeyCodeToPygame(int code) { + code &= kGodotCodeMask; + if (code >= kGodotSpecial && code < kGodotSpecial + 0x1000) { + const int special = code - kGodotSpecial; + switch (special) { + case 1: return 27; // Escape + case 2: // Tab + case 3: return 9; // Backtab + case 4: return 8; // Backspace + case 5: return 13; // Return + case 6: return kPygameScancodeMask + 88; // Keypad Enter + case 7: return kPygameScancodeMask + 73; // Insert + case 8: return 127; // Delete + case 9: return kPygameScancodeMask + 72; // Pause/Break + case 10: return kPygameScancodeMask + 70; // Print Screen + case 11: return kPygameScancodeMask + 154; // SysReq + case 12: return kPygameScancodeMask + 156; // Clear + case 13: return kPygameScancodeMask + 74; // Home + case 14: return kPygameScancodeMask + 77; // End + case 15: return kPygameScancodeMask + 80; // Left + case 16: return kPygameScancodeMask + 82; // Up + case 17: return kPygameScancodeMask + 79; // Right + case 18: return kPygameScancodeMask + 81; // Down + case 19: return kPygameScancodeMask + 75; // Page Up + case 20: return kPygameScancodeMask + 78; // Page Down + case 21: return kPygameScancodeMask + 225; // Shift (left) + case 22: return kPygameScancodeMask + 224; // Ctrl (left) + case 23: return kPygameScancodeMask + 227; // Meta (left) + case 24: return kPygameScancodeMask + 226; // Alt (left) + case 25: return kPygameScancodeMask + 57; // Caps Lock + case 26: return kPygameScancodeMask + 83; // Num Lock + case 27: return kPygameScancodeMask + 71; // Scroll Lock + case 66: return kPygameScancodeMask + 118; // Menu + case 69: return kPygameScancodeMask + 117; // Help + default: break; + } + if (special >= 28 && special <= 62) + return PygameFunctionKey(special - 27); // F1-F35 (SDL has F1-F24) + if (special >= 129 && special <= 143) { + static constexpr int keypad[] = { + 85, 84, 86, 99, 87, 98, 89, 90, + 91, 92, 93, 94, 95, 96, 97, + }; // Multiply, divide, subtract, period, add, 0..9. + return kPygameScancodeMask + keypad[special - 129]; + } + return 0; + } + + if (code >= 'A' && code <= 'Z') return code + ('a' - 'A'); + if (code >= 0x20 && code <= 0x10ffff && + !(code >= 0xd800 && code <= 0xdfff)) return code; + return 0; +} + +inline int MapKeyCodeToPygame(int code, KeyCodeSpace space = KeyCodeSpace::Legacy) { + if (space == KeyCodeSpace::Godot) return MapGodotKeyCodeToPygame(code); + // Preserve SDL2 non-printable keycodes used by existing direct callers. + // Low-byte SDL ASCII and Windows VK values overlap; the ABI's VK + // interpretation takes precedence (e.g. 0x70 means F1, not 'p'). + if (code >= kPygameScancodeMask) return code; + if (code >= 0x01000000) code &= kGodotCodeMask; + + // Raw Godot special keys remain accepted by legacy direct callers. + if (code >= kGodotSpecial && code < kGodotSpecial + 0x1000) + return MapGodotKeyCodeToPygame(code); + + switch (code) { + case 0x08: return 8; // Backspace + case 0x09: return 9; // Tab + case 0x0c: return kPygameScancodeMask + 156; // Clear + case 0x0d: return 13; // Return + case 0x10: return kPygameScancodeMask + 225; // Shift + case 0x11: return kPygameScancodeMask + 224; // Ctrl + case 0x12: return kPygameScancodeMask + 226; // Alt + case 0x13: return kPygameScancodeMask + 72; // Pause + case 0x14: return kPygameScancodeMask + 57; // Caps Lock + case 0x1b: return 27; // Escape + case 0x20: return 32; // Space + case 0x21: return kPygameScancodeMask + 75; // Page Up + case 0x22: return kPygameScancodeMask + 78; // Page Down + case 0x23: return kPygameScancodeMask + 77; // End + case 0x24: return kPygameScancodeMask + 74; // Home + case 0x25: return kPygameScancodeMask + 80; // Left + case 0x26: return kPygameScancodeMask + 82; // Up + case 0x27: return kPygameScancodeMask + 79; // Right + case 0x28: return kPygameScancodeMask + 81; // Down + case 0x2c: return kPygameScancodeMask + 70; // Print Screen + case 0x2d: return kPygameScancodeMask + 73; // Insert + case 0x2e: return 127; // Delete + case 0x2f: return kPygameScancodeMask + 117; // Help + case 0x5b: return kPygameScancodeMask + 227; // Left Windows + case 0x5c: return kPygameScancodeMask + 231; // Right Windows + case 0x5d: return kPygameScancodeMask + 101; // Applications + case 0x6a: return kPygameScancodeMask + 85; // Keypad multiply + case 0x6b: return kPygameScancodeMask + 87; // Keypad add + case 0x6c: return kPygameScancodeMask + 133; // Keypad separator + case 0x6d: return kPygameScancodeMask + 86; // Keypad subtract + case 0x6e: return kPygameScancodeMask + 99; // Keypad decimal + case 0x6f: return kPygameScancodeMask + 84; // Keypad divide + case 0x90: return kPygameScancodeMask + 83; // Num Lock + case 0x91: return kPygameScancodeMask + 71; // Scroll Lock + case 0xa0: return kPygameScancodeMask + 225; // Left Shift + case 0xa1: return kPygameScancodeMask + 229; // Right Shift + case 0xa2: return kPygameScancodeMask + 224; // Left Ctrl + case 0xa3: return kPygameScancodeMask + 228; // Right Ctrl + case 0xa4: return kPygameScancodeMask + 226; // Left Alt + case 0xa5: return kPygameScancodeMask + 230; // Right Alt + case 0xba: return ';'; + case 0xbb: return '='; + case 0xbc: return ','; + case 0xbd: return '-'; + case 0xbe: return '.'; + case 0xbf: return '/'; + case 0xc0: return '`'; + case 0xdb: return '['; + case 0xdc: return '\\'; + case 0xdd: return ']'; + case 0xde: return '\''; + case 0xe2: return '\\'; + default: break; + } + if (code >= 0x60 && code <= 0x69) + return kPygameScancodeMask + (code == 0x60 ? 98 : 89 + code - 0x61); + if (code >= 0x70 && code <= 0x87) + return PygameFunctionKey(code - 0x70 + 1); + if (code >= 'A' && code <= 'Z') return code + ('a' - 'A'); + if (code >= 0x21 && code <= 0x7e) return code; + return 0; // K_UNKNOWN +} + +inline int MapModifiersToPygame(int modifiers) { + int result = 0; + if (modifiers & (kAetherModifierShift | kGodotModifierShift)) + result |= kPygameKmodShift; + if (modifiers & (kAetherModifierAlt | kGodotModifierAlt)) + result |= kPygameKmodAlt; + if (modifiers & (kAetherModifierControl | kGodotModifierControl)) + result |= kPygameKmodControl; + if (modifiers & kGodotModifierMeta) result |= kPygameKmodGui; + // No raw SDL passthrough: Aether's echo=0x80 would become SDL's RCtrl; + // mouse-held/cancel bits are not keyboard modifiers either. + return result; +} + +inline bool IsAetherKeyRepeat(int modifiers) { + return (modifiers & kAetherModifierEcho) != 0; +} + +} // namespace aetherkiri::renpy::input_mapping diff --git a/bridge/renpy_runtime/include/renpy_java_boolean.h b/bridge/renpy_runtime/include/renpy_java_boolean.h new file mode 100644 index 000000000..abd7dec82 --- /dev/null +++ b/bridge/renpy_runtime/include/renpy_java_boolean.h @@ -0,0 +1,21 @@ +#pragma once + +#include + +namespace renpy_mobile_runtime { + +// Godot's Android Java wrapper assigns JNI jboolean (uint8_t) directly to +// Variant, which yields INT. Accept its exact true value without allowing +// generic Variant truthiness to report a failed bridge load as successful. +template +bool AndroidJavaBooleanResultIsTrue(const VariantValue &result) { + if (result.get_type() == VariantValue::BOOL) { + return static_cast(result); + } + if (result.get_type() == VariantValue::INT) { + return static_cast(result) == 1; + } + return false; +} + +} // namespace renpy_mobile_runtime diff --git a/bridge/renpy_runtime/include/renpy_mobile_adapter.h b/bridge/renpy_runtime/include/renpy_mobile_adapter.h new file mode 100644 index 000000000..d7ef592c6 --- /dev/null +++ b/bridge/renpy_runtime/include/renpy_mobile_adapter.h @@ -0,0 +1,51 @@ +#pragma once + +#include "engine_api.h" + +#include + +namespace aetherkiri::renpy::mobile { + +/* + * The Android RAPT package is built around PythonSDLActivity. AetherKiri + * already owns the process and its Godot Activity, so this adapter + * must attach RAPT to that Activity rather than starting another one. + * + * This boundary intentionally carries an opaque host pointer. Android hosts + * may pass a JNI jobject through it without making the public provider ABI + * depend on jni.h; non-Android callers can leave it null. PrepareRuntime + * extracts the bundled private Python tree and initializes SDL's JNI/audio + * callbacks. Video uses the patched offscreen EGL backend. Preflight/Start + * preserve the explicit rejection of an official blocking RAPT launcher. + */ +struct BootstrapRequest { + const char* game_root_path_utf8 = nullptr; + const char* startup_script_utf8 = nullptr; + void* existing_host_activity = nullptr; +}; + +class BootstrapAdapter final { + public: + BootstrapAdapter() = default; + + /* Validate the host-owned Android handoff and the staged RAPT payload. + * This never calls SDL_main or starts a second Activity. */ + engine_result_t Preflight(const BootstrapRequest& request); + + engine_result_t Start(const BootstrapRequest& request); + engine_result_t PrepareRuntime(const BootstrapRequest& request, + uint32_t width, uint32_t height, + std::string* private_root, + std::string* apk_path); + engine_result_t Stop(); + + bool running() const { return running_; } + const std::string& last_error() const { return last_error_; } + + private: + bool running_ = false; + std::string last_error_; + void* native_library_handle_ = nullptr; +}; + +} // namespace aetherkiri::renpy::mobile diff --git a/bridge/renpy_runtime/include/renpy_project.h b/bridge/renpy_runtime/include/renpy_project.h new file mode 100644 index 000000000..425e41c48 --- /dev/null +++ b/bridge/renpy_runtime/include/renpy_project.h @@ -0,0 +1,17 @@ +#pragma once + +#include + +namespace aetherkiri::renpy { +inline bool HasRenpyProject(const std::filesystem::path& root) { + namespace fs = std::filesystem; + std::error_code ec; + if (!fs::is_directory(root, ec)) return false; + // Match the desktop distributor's supported directory layouts. + return (fs::is_directory(root / "game", ec) && + (fs::exists(root / "game" / "script.rpy", ec) || + fs::exists(root / "game" / "script.rpyc", ec) || + fs::exists(root / "game" / "options.rpy", ec))) || + fs::exists(root / "script.rpy", ec); +} +} // namespace aetherkiri::renpy diff --git a/bridge/renpy_runtime/include/renpy_runtime.h b/bridge/renpy_runtime/include/renpy_runtime.h new file mode 100644 index 000000000..9beab3fae --- /dev/null +++ b/bridge/renpy_runtime/include/renpy_runtime.h @@ -0,0 +1,11 @@ +#pragma once + +namespace aetherkiri::renpy { + +// Registers the opt-in Ren'Py SDK subprocess provider. The provider only +// builds when AETHERKIRI_RENPY_SDK_ROOT names an official SDK checkout. The +// desktop host imports frames through the validated RGBA compatibility path; +// direct native GPU texture sharing remains unsupported. +void RegisterRuntimeProvider(); + +} // namespace aetherkiri::renpy diff --git a/bridge/renpy_runtime/include/renpy_runtime_ios_adapter.h b/bridge/renpy_runtime/include/renpy_runtime_ios_adapter.h new file mode 100644 index 000000000..059bdda56 --- /dev/null +++ b/bridge/renpy_runtime/include/renpy_runtime_ios_adapter.h @@ -0,0 +1,88 @@ +#ifndef AETHERKIRI_RENPY_RUNTIME_IOS_ADAPTER_H_ +#define AETHERKIRI_RENPY_RUNTIME_IOS_ADAPTER_H_ + +#include "engine_runtime_provider.h" + +#if defined(__cplusplus) +extern "C" { +#endif + +/* + * Boundary between the Renios launcher and the AetherKiri runtime provider. + * + * The official Renios prototype owns the process entrypoint in its main.c + * template. AetherKiri is already running inside Godot, so this adapter must + * never create a second UIKit/SDL application entrypoint. An + * eventual implementation is installed by the iOS host and is driven by the + * host-owned lifecycle below. The current build intentionally has no + * implementation; the provider remains ENGINE_RESULT_NOT_SUPPORTED until + * the complete Renios static-library closure and rendering/input bridge are + * linked. + */ +#define AETHERKIRI_RENPY_IOS_ADAPTER_API_VERSION 0x01000000u + +/* Build-time inspection of the official Renios closure. The shipped + * launcher_main entrypoint reaches Py_RunMain and owns a blocking Python + * main loop; it is not a host-tick API. Keep this fact explicit so a caller + * cannot mistake the linked archives for a playable adapter. */ +#define AETHERKIRI_RENPY_IOS_LAUNCHER_CONTRACT_API_VERSION 0x01000000u + +typedef enum renpy_ios_launcher_mode_v1_t { + RENPY_IOS_LAUNCHER_MODE_UNAVAILABLE = 0, + RENPY_IOS_LAUNCHER_MODE_BLOCKING_PY_MAIN = 1, + RENPY_IOS_LAUNCHER_MODE_HOST_TICK = 2 +} renpy_ios_launcher_mode_v1_t; + +typedef struct renpy_ios_launcher_contract_v1_t { + uint32_t struct_size; + uint32_t api_version; + uint32_t mode; + const char* launcher_symbol_utf8; + const char* blocking_symbol_utf8; + const char* limitation_utf8; +} renpy_ios_launcher_contract_v1_t; + +/* Returns the static contract discovered by the build probe. The current + * Renios archive reports BLOCKING_PY_MAIN and therefore cannot be driven by + * the Godot frame loop. */ +ENGINE_API_EXPORT const renpy_ios_launcher_contract_v1_t* +renpy_get_ios_launcher_contract(void); + +typedef struct renpy_ios_inprocess_adapter_v1_t { + uint32_t struct_size; + uint32_t api_version; + void* user_data; + + /* Allocate adapter-owned state. This must not create a UIApplication or + * enter an SDL application loop. */ + engine_result_t (*create)(void* user_data, + const engine_runtime_host_v1_t* host, + const engine_create_desc_t* desc, + void** out_runtime); + void (*destroy)(void* runtime); + + /* Start Renios on the already-running host loop. */ + engine_result_t (*open_game)(void* runtime, + const char* game_root_path_utf8, + const char* startup_script_utf8); + engine_result_t (*tick)(void* runtime, uint32_t delta_ms); + engine_result_t (*pause)(void* runtime); + engine_result_t (*resume)(void* runtime); + engine_result_t (*close_game)(void* runtime); + const char* (*get_last_error)(void* runtime); +} renpy_ios_inprocess_adapter_v1_t; + +/* Installs or removes the host-owned adapter. Passing NULL removes it. */ +ENGINE_API_EXPORT engine_result_t renpy_install_ios_inprocess_adapter( + const renpy_ios_inprocess_adapter_v1_t* adapter); + +/* Returns the currently installed adapter, or NULL when the Renios closure is + * not linked into the app. The returned descriptor is process-lived. */ +ENGINE_API_EXPORT const renpy_ios_inprocess_adapter_v1_t* +renpy_get_ios_inprocess_adapter(void); + +#if defined(__cplusplus) +} /* extern "C" */ +#endif + +#endif /* AETHERKIRI_RENPY_RUNTIME_IOS_ADAPTER_H_ */ diff --git a/bridge/renpy_runtime/mobile_launcher/README.md b/bridge/renpy_runtime/mobile_launcher/README.md new file mode 100644 index 000000000..8c1bb5b08 --- /dev/null +++ b/bridge/renpy_runtime/mobile_launcher/README.md @@ -0,0 +1,71 @@ +# Host-owned Ren'Py mobile lifecycle fork + +This fork separates Python initialization from the official blocking launchers +and runs the full Ren'Py bootstrap inside a stack-preserving greenlet. It is a +reviewable runtime implementation and build path; mobile gameplay remains +unverified. The contract templates are separate, deliberately non-playable +fixtures and are never packaged. + +## Pinned source build + +`tools/run_renpy_mobile_source_build.sh` pins the Ren'Py build/source and greenlet +revisions documented in the [provider README](../README.md). It applies the +Python patch to the actual source compiled by renpy-build, compiles the native +lifecycle and greenlet objects for each selected Android/iOS target, and checks +the exports before assembling a payload. The legacy Python patch filename +contains `skeleton`, but its implementation now preserves the actual Python +and Cython stacks with greenlet rather than exception-based yields. + +```bash +bash tools/run_renpy_mobile_source_build.sh --mode prepare --fetch \ + --platform android --renpy-build /workspace/renpy-build +bash tools/run_renpy_mobile_source_build.sh --mode build \ + --platform android --renpy-build /workspace/renpy-build +``` + +Preparation does not run upstream `prepare.sh`, which resets or pulls source +revisions and installs system packages. Build requires a provisioned Ubuntu +24.04 cloud builder, LLVM 18, NDK r29 and 64 GiB of free disk. iOS source builds +also require the legally supplied SDK archives requested by the pinned +renpy-build toolchain. `--platform ios` or `all` selects those targets explicitly. + +`--mode check` only checks prerequisites. `--mode auto` with an existing checkout +performs compilation; an absent optional checkout reports a skip. CI uses +`--mode build` explicitly. Successful export checks or compilation do not assert +mobile playability. + +## Runtime ownership + +ABI version 2 is defined in `include/renpy_mobile_launcher.h`. The loader requires +bootstrap, window binding, init/tick/frame/input, pause/resume, shutdown, text +input state and surface sizing. Native initialization copies the host callback +table. The provider owns the paths and initializes Python and greenlet on the +first host tick, rather than the asynchronous open thread. + +Frame capture occurs on Ren'Py's rendering thread through its GL screenshot. +The fork copies visible Surface rows and retains the bytes while the host reads +them. It does not use `SDL_GetWindowSurface` for a GL window. Input preserves +pointer actions, key transitions, scroll deltas and committed UTF-8 text. +Android uses the patched SDL2 offscreen EGL/pbuffer backend and restores the +host's EGL context when returning to Godot. The Java bridge supplies the actual +SDL JNI callback closure without launching another Activity. + +The stock iOS SDL UIKit/MetalANGLE path still requires a host rendering +integration. The provider refuses that unimplemented path explicitly instead +of activating it merely because an archive exports the lifecycle symbols. + +## Acceptance and artifacts + +The application build must carry the native library, cooperative Ren'Py modules +and matching Python standard library. `tools/validate_renpy_mobile_payload.py` +checks staged inputs and actual Android APK contents. Missing or official +blocking launchers fail the enabled build rather than producing a guard-only +Ren'Py package. + +Run the real cloud-device gate documented in +[the demo README](../../../demos/aetherkiri-renpy/README.md) after building an +installable debug APK. It requires executed game checkpoints, native pixels +matching an OS screenshot, actual touch and text/keyboard events, +background/resume and normal exit. Host boundary tests and simulated library +fixtures are reported separately and cannot satisfy this gate. No mobile +APK/IPA gameplay result has been accepted for this change. diff --git a/bridge/renpy_runtime/mobile_launcher/build.sh b/bridge/renpy_runtime/mobile_launcher/build.sh new file mode 100644 index 000000000..d09c16c1f --- /dev/null +++ b/bridge/renpy_runtime/mobile_launcher/build.sh @@ -0,0 +1,503 @@ +#!/usr/bin/env bash +# Check or install a host-owned Ren'Py mobile launcher fork. +# +# This script never transforms the official blocking launcher by itself. It +# checks the upstream source locations, prints the exact renpy-build hooks, and +# optionally installs already-built artifacts only when they export the +# versioned host lifecycle ABI from include/renpy_mobile_launcher.h. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +contract_header="$repo_root/bridge/renpy_runtime/mobile_launcher/include/renpy_mobile_launcher.h" + +usage() { + cat <<'USAGE' +Usage: + build.sh --check --renpy-build PATH + build.sh --compile-contract --renpy-build PATH [--output-dir PATH] + build.sh --check-python-patch --renpy-src PATH + build.sh --apply-python-patch --renpy-src PATH + build.sh --print-plan --renpy-build PATH + build.sh --install --renpy-build PATH --stage PATH \ + [--android-so-arm64 PATH] [--android-so-armv7 PATH] + [--android-so-x86_64 PATH] [--ios-debug-a PATH] [--ios-release-a PATH] + +--check validates the official source layout and confirms that the current +launcher is still the blocking SDL_main/launcher_main implementation. +--source-build-plan prints the official renpy-build source-build commands, +required Ubuntu/toolchain inputs, and the exact RAPT/Renios artifact paths. +--check-renpy-build performs a strict, read-only preflight for those inputs; it +checks platform-specific Ubuntu/disk/toolchain prerequisites, pinned source checkout, SDK archives, +and cooperative-loop patch applicability. It never runs the heavy build. +--compile-contract compiles the Android/iOS templates as contract-only objects; +those objects deliberately return NOT_IMPLEMENTED and are never packaged. +--check-python-patch validates the Ren'Py Python cooperative-loop patch against +an official Ren'Py source checkout. --apply-python-patch applies it in place +only after `git apply --check`; it refuses a dirty checkout. The patch is an +opt-in cooperative loop and does not make the native runtime playable by itself. +--print-plan prints the upstream renpy-build commands and replacement paths. +--install copies caller-supplied, already-built lifecycle artifacts into the +staged RAPT/Renios tree after checking the required exported symbol names. +This script does not build Ren'Py and does not claim the resulting artifacts +are playable; the fork implementation and device/simulator tests remain +required. +USAGE +} + +mode="" +platform="android" +renpy_build="" +renpy_src="" +stage="" +android_so_arm64="" +android_so_armv7="" +android_so_x86_64="" +ios_debug_a="" +ios_release_a="" +output_dir="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --check|--source-build-plan|--check-renpy-build|--compile-contract|--check-python-patch|--apply-python-patch|--print-plan|--install) + [[ -z "$mode" ]] || { echo "choose one mode" >&2; exit 2; } + mode="${1#--}" + ;; + --platform) + [[ $# -ge 2 ]] || { echo "--platform requires a value" >&2; exit 2; } + platform="$2"; shift + ;; + --renpy-build) + [[ $# -ge 2 ]] || { echo "--renpy-build requires a path" >&2; exit 2; } + renpy_build="$2"; shift + ;; + --renpy-src) + [[ $# -ge 2 ]] || { echo "--renpy-src requires a path" >&2; exit 2; } + renpy_src="$2"; shift + ;; + --stage) + [[ $# -ge 2 ]] || { echo "--stage requires a path" >&2; exit 2; } + stage="$2"; shift + ;; + --output-dir) + [[ $# -ge 2 ]] || { echo "--output-dir requires a path" >&2; exit 2; } + output_dir="$2"; shift + ;; + --android-so|--android-so-arm64) + [[ $# -ge 2 ]] || { echo "--android-so-arm64 requires a path" >&2; exit 2; } + android_so_arm64="$2"; shift + ;; + --android-so-armv7) + [[ $# -ge 2 ]] || { echo "--android-so-armv7 requires a path" >&2; exit 2; } + android_so_armv7="$2"; shift + ;; + --android-so-x86_64) + [[ $# -ge 2 ]] || { echo "--android-so-x86_64 requires a path" >&2; exit 2; } + android_so_x86_64="$2"; shift + ;; + --ios-debug-a) + [[ $# -ge 2 ]] || { echo "--ios-debug-a requires a path" >&2; exit 2; } + ios_debug_a="$2"; shift + ;; + --ios-release-a) + [[ $# -ge 2 ]] || { echo "--ios-release-a requires a path" >&2; exit 2; } + ios_release_a="$2"; shift + ;; + -h|--help) + usage; exit 0 + ;; + *) + echo "unknown argument: $1" >&2; usage >&2; exit 2 + ;; + esac + shift +done + +[[ -n "$mode" ]] || { usage >&2; exit 2; } +[[ -f "$contract_header" ]] || { echo "missing lifecycle contract: $contract_header" >&2; exit 1; } + +# The source-build modes are deliberately separate from --install. They make +# the official renpy-build workflow reproducible and auditable without hiding a +# many-hour native rebuild inside the regular AetherKiri CI jobs. +renpy_build_source_preflight() { + local strict="$1" failures=0 required source_root="${renpy_src:-$renpy_build/renpy}" + case "$platform" in android|ios|all) ;; *) echo "invalid platform: $platform" >&2; return 2 ;; esac + for required in build.sh prepare.sh tasks/renpython.py tasks/librenpy.py tasks/rapt.py tasks/renios.py runtime/librenpython3_android.c runtime/librenpython3.c; do + if [[ ! -f "$renpy_build/$required" ]]; then + echo "missing renpy-build input: $renpy_build/$required" >&2 + failures=$((failures + 1)) + fi + done + if ! git -C "$renpy_build" rev-parse HEAD >/dev/null 2>&1 || + [[ "$(git -C "$renpy_build" rev-parse HEAD 2>/dev/null)" != 7bfab40c1174f622f644b24669afd5fb167fbb79 ]]; then + echo "renpy-build must be pinned to Ren'Py 8.5.3 build SHA 7bfab40c1174f622f644b24669afd5fb167fbb79" >&2 + failures=$((failures + 1)) + fi + if ! git -C "$source_root" rev-parse HEAD >/dev/null 2>&1; then + echo "Ren'Py source checkout missing: $source_root" >&2 + failures=$((failures + 1)) + elif [[ "$(git -C "$source_root" rev-parse HEAD)" != 39895c1e017f0b36ffea2447d97eccd69d76ee1c ]]; then + echo "Ren'Py source must be pinned to 8.5.3 SHA 39895c1e017f0b36ffea2447d97eccd69d76ee1c" >&2 + failures=$((failures + 1)) + elif git -C "$source_root" apply --check "$python_patch" >/dev/null 2>&1; then + echo "cooperative-loop patch: applies cleanly (prepare required before build)" + elif git -C "$source_root" apply --reverse --check "$python_patch" >/dev/null 2>&1; then + echo "cooperative-loop patch: already applied to the actual build source" + else + echo "cooperative-loop patch does not match $source_root" >&2 + failures=$((failures + 1)) + fi + local host_system="$(uname -s)" archives=() host_tools=() + [[ "$platform" == ios ]] || archives+=(android-ndk-r29-linux.zip) + if [[ "$platform" != android && "$host_system" != Darwin ]]; then + archives+=(iPhoneOS14.0.sdk.tar.gz iPhoneSimulator14.0.sdk.tar.gz) + fi + # Bash 3.2 treats an empty array as unset under nounset (the macOS default). + for required in ${archives[@]+"${archives[@]}"}; do + if [[ ! -f "$renpy_build/tars/$required" ]]; then + echo "missing toolchain archive: $renpy_build/tars/$required" >&2 + failures=$((failures + 1)) + fi + done + host_tools=(bash git python3 curl tar unzip make cmake ninja pkg-config ccache autoconf automake uv) + if [[ "$host_system" == Darwin ]]; then + host_tools+=(brew xcrun glibtoolize realpath) + else + host_tools+=(clang-18 clang++-18 llvm-ar-18 llvm-nm-18) + fi + for required in "${host_tools[@]}"; do + if ! command -v "$required" >/dev/null 2>&1; then + echo "missing host tool: $required" >&2 + failures=$((failures + 1)) + fi + done + local ubuntu_id="" ubuntu_version="" + if [[ -r /etc/os-release ]]; then + # shellcheck disable=SC1091 + . /etc/os-release + ubuntu_id="${ID:-}"; ubuntu_version="${VERSION_ID:-}" + fi + if [[ "$host_system" == Darwin ]]; then + if [[ "$platform" != ios ]]; then + echo "native macOS source builds support --platform ios only" >&2 + failures=$((failures + 1)) + fi + local sdk sdk_path sdk_tool + for sdk in macosx iphoneos iphonesimulator; do + sdk_path="$(xcrun --sdk "$sdk" --show-sdk-path 2>/dev/null || true)" + if [[ ! -d "$sdk_path" ]]; then + echo "selected Xcode is missing its $sdk SDK" >&2 + failures=$((failures + 1)) + else + echo "local Xcode $sdk SDK: $sdk_path" + fi + done + for sdk_tool in clang clang++ ar ranlib nm lipo otool; do + if ! xcrun --find "$sdk_tool" >/dev/null 2>&1; then + echo "selected Xcode is missing $sdk_tool" >&2 + failures=$((failures + 1)) + fi + done + for required in openssl@3 xz bzip2 libffi; do + local dependency_prefix + dependency_prefix="$(brew --prefix "$required" 2>/dev/null || true)" + if [[ ! -d "$dependency_prefix/include" || ! -d "$dependency_prefix/lib" ]]; then + echo "missing native host dependency: $required" >&2 + failures=$((failures + 1)) + fi + done + if [[ ! -f "${AETHERKIRI_RENPY_CONFIG_SUB:-}" ]]; then + echo "missing Homebrew automake config.sub (use the source runner build mode)" >&2 + failures=$((failures + 1)) + fi + elif [[ "$host_system" != Linux || "$ubuntu_id" != ubuntu || "$ubuntu_version" != 24.04 ]]; then + echo "renpy-build officially requires Ubuntu 24.04 (detected ${ubuntu_id:-unknown} ${ubuntu_version:-unknown})" >&2 + failures=$((failures + 1)) + fi + local available_kib + available_kib="$(df -Pk "$renpy_build" | awk 'NR==2 {print $4}')" + if [[ ! "$available_kib" =~ ^[0-9]+$ ]] || (( available_kib < 64 * 1024 * 1024 )); then + echo "insufficient free disk for renpy-build (official minimum: 64 GiB; available ${available_kib:-unknown} KiB)" >&2 + failures=$((failures + 1)) + fi + if [[ "$strict" == 1 && "$failures" != 0 ]]; then + echo "Ren'Py source-build preflight failed with $failures issue(s)" >&2 + return 1 + fi +} + +renpy_build_source_plan() { + cat <&2; exit 2; } + [[ -d "$renpy_build" ]] || { echo "renpy-build directory not found: $renpy_build" >&2; exit 1; } + renpy_build_source_plan + if [[ "$mode" == "check-renpy-build" ]]; then + renpy_build_source_preflight 1 + fi + exit 0 +fi + +if [[ "$mode" == "check-python-patch" || "$mode" == "apply-python-patch" ]]; then + [[ -n "$renpy_src" ]] || { echo "--renpy-src is required" >&2; exit 2; } + [[ -d "$renpy_src" ]] || { echo "Ren'Py source checkout not found: $renpy_src" >&2; exit 1; } + [[ -f "$python_patch" ]] || { echo "missing cooperative-loop patch: $python_patch" >&2; exit 1; } + git -C "$renpy_src" rev-parse --is-inside-work-tree >/dev/null 2>&1 || { + echo "--renpy-src must be a git checkout" >&2; exit 1; + } + if git -C "$renpy_src" apply --reverse --check "$python_patch" >/dev/null 2>&1; then + echo "Ren'Py Python cooperative-loop patch applies cleanly (already applied)" + exit 0 + fi + git -C "$renpy_src" apply --check "$python_patch" + if [[ "$mode" == "check-python-patch" ]]; then + echo "Ren'Py Python cooperative-loop patch applies cleanly" + echo " cooperative Python loop validated; native ABI/frame/input integration remains required" + exit 0 + fi + if [[ -n "$(git -C "$renpy_src" status --porcelain --untracked-files=no)" ]]; then + echo "refusing to apply cooperative-loop patch to a dirty Ren'Py checkout" >&2 + exit 1 + fi + git -C "$renpy_src" apply "$python_patch" + python3 -m py_compile "$renpy_src/renpy/aether_mobile.py" \ + "$renpy_src/renpy/bootstrap.py" "$renpy_src/renpy/execution.py" "$renpy_src/renpy/display/core.py" + echo "applied and syntax-checked Ren'Py Python cooperative loop" + echo " native ABI/frame/input integration and device E2E remain required" + exit 0 +fi + +[[ -n "$renpy_build" ]] || { echo "--renpy-build is required" >&2; exit 2; } +[[ -d "$renpy_build" ]] || { echo "renpy-build directory not found: $renpy_build" >&2; exit 1; } + +runtime="$renpy_build/runtime" +tasks="$renpy_build/tasks/renpython.py" +renios_main="$renpy_build/renios/prototype/main.c" +for required in \ + "$runtime/librenpython3_android.c" \ + "$runtime/librenpython3.c" \ + "$runtime/jniwrapperstuff.h" \ + "$tasks" \ + "$renios_main"; do + [[ -f "$required" ]] || { echo "missing official Ren'Py build input: $required" >&2; exit 1; } +done + +# Keep this check source-based: it works on a checkout without requiring a +# host compiler or an Apple/Android SDK. +grep -Eq 'int[[:space:]]+SDL_main[[:space:]]*\(' "$runtime/librenpython3_android.c" || { + echo "official Android launcher no longer contains SDL_main; inspect before using this scaffold" >&2 + exit 1 +} +grep -Eq 'Py_RunMain[[:space:]]*\(' "$runtime/librenpython3_android.c" || { + echo "official Android launcher no longer contains Py_RunMain; inspect before using this scaffold" >&2 + exit 1 +} +grep -Eq 'launcher_main[[:space:]]*\(' "$runtime/librenpython3.c" || { + echo "official desktop/iOS launcher no longer contains launcher_main; inspect before using this scaffold" >&2 + exit 1 +} +grep -Eq 'Py_RunMain[[:space:]]*\(' "$runtime/librenpython3.c" || { + echo "official desktop/iOS launcher no longer contains Py_RunMain; inspect before using this scaffold" >&2 + exit 1 +} +grep -Eq 'SDL_RunApp|SDL_UIKitRunApp' "$renios_main" || { + echo "Renios prototype entrypoint changed; inspect before using this scaffold" >&2 + exit 1 +} + +print_plan() { + cat < Py_RunMain)" + echo " official iOS/desktop launcher remains blocking (launcher_main -> Py_RunMain)" + echo " no artifact was built or installed" + exit 0 +fi + +if [[ "$mode" == "compile-contract" ]]; then + compiler="${CC:-cc}" + output_dir="${output_dir:-$repo_root/out/renpy-mobile-contract}" + mkdir -p "$output_dir" + common=(-std=c11 -Wall -Wextra -Werror -fPIC -I"$repo_root/bridge/renpy_runtime/mobile_launcher/include" -DAETHERKIRI_RENPY_LIFECYCLE_CONTRACT_ONLY=1) + "$compiler" "${common[@]}" -x c -c \ + "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/android/librenpython_android_host.c.template" \ + -o "$output_dir/librenpython_android_host.o" + "$compiler" "${common[@]}" -x c -c \ + "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/ios/librenpython_ios_host.c.template" \ + -o "$output_dir/librenpython_ios_host.o" + echo "compiled contract-only launcher objects in $output_dir" + echo "these objects intentionally return RENPY_MOBILE_NOT_IMPLEMENTED and are not runtime artifacts" + exit 0 +fi + +print_plan +[[ "$mode" == "print-plan" ]] && exit 0 + +[[ -n "$stage" ]] || { echo "--stage is required for --install" >&2; exit 2; } +[[ -n "$android_so_arm64" || -n "$android_so_armv7" || -n "$android_so_x86_64" || + -n "$ios_debug_a" || -n "$ios_release_a" ]] || { + echo "--install requires at least one artifact path" >&2 + exit 2 +} + +required_symbols=( + renpy_mobile_bootstrap + renpy_mobile_bind_window + renpy_mobile_init + renpy_mobile_tick + renpy_mobile_frame + renpy_mobile_input + renpy_mobile_pause + renpy_mobile_resume + renpy_mobile_shutdown + renpy_mobile_text_input_state + renpy_mobile_set_surface_size +) + +nm_symbols() { + local artifact="$1" kind="$2" + case "$kind" in + android) + nm -D --defined-only "$artifact" + ;; + ios) + if command -v xcrun >/dev/null 2>&1; then + xcrun nm -gU "$artifact" + else + nm -g "$artifact" + fi + ;; + *) echo "unknown lifecycle artifact kind: $kind" >&2; return 2 ;; + esac +} + +check_symbols() { + local artifact="$1" kind="$2" expected_machine="${3:-}" + [[ -f "$artifact" ]] || { echo "artifact not found: $artifact" >&2; exit 1; } + if LC_ALL=C grep -aFq 'AETHERKIRI_RENPY_LIFECYCLE_STUB' "$artifact"; then + echo "refusing contract-only lifecycle stub as runtime artifact: $artifact" >&2 + exit 1 + fi + + local format + format="$(file -b "$artifact" 2>/dev/null || true)" + case "$kind" in + android) + [[ "$format" == *ELF* && "$format" == *shared\ object* ]] || { + echo "Android lifecycle artifact is not an ELF shared object: $artifact ($format)" >&2 + exit 1 + } + if [[ -n "$expected_machine" ]]; then + command -v readelf >/dev/null 2>&1 || { + echo "readelf is required to validate Android ABI: $artifact" >&2 + exit 1 + } + local machine + machine="$(readelf -h "$artifact" | sed -n 's/^ *Machine: *//p')" + [[ "$machine" == "$expected_machine" ]] || { + echo "Android lifecycle artifact ABI mismatch: expected $expected_machine, got $machine" >&2 + exit 1 + } + fi + ;; + ios) + [[ "$format" == *archive* || "$format" == *Mach-O* ]] || { + echo "iOS lifecycle artifact is not a static archive: $artifact ($format)" >&2 + exit 1 + } + ;; + *) echo "unknown lifecycle artifact kind: $kind" >&2; exit 2 ;; + esac + + local symbols + symbols="$(mktemp)" + if ! nm_symbols "$artifact" "$kind" >"$symbols" 2>/dev/null; then + echo "could not inspect lifecycle symbols in $artifact; install the native nm/xcrun toolchain" >&2 + rm -f "$symbols" + exit 1 + fi + local symbol + for symbol in "${required_symbols[@]}"; do + if ! awk -v symbol="$symbol" '{ name=$NF; sub(/^_/, "", name); if (name==symbol) found=1 } END { exit !found }' "$symbols"; then + echo "artifact lacks required lifecycle export $symbol: $artifact" >&2 + rm -f "$symbols" + exit 1 + fi + done + rm -f "$symbols" +} + +install_one() { + local source="$1" destination="$2" kind="$3" expected_machine="${4:-}" + check_symbols "$source" "$kind" "$expected_machine" + mkdir -p "$(dirname "$destination")" + install -m 0755 "$source" "$destination" + echo "installed host lifecycle artifact: $destination" +} + +# Each Android ABI is explicit so an arm64 binary cannot silently be copied +# into an armv7/x86_64 slot. The expected machine is checked from ELF headers. +if [[ -n "$android_so_arm64" ]]; then + install_one "$android_so_arm64" "$stage/rapt/prototype/renpyandroid/src/main/jniLibs/arm64-v8a/librenpython.so" android "AArch64" +fi +if [[ -n "$android_so_armv7" ]]; then + install_one "$android_so_armv7" "$stage/rapt/prototype/renpyandroid/src/main/jniLibs/armeabi-v7a/librenpython.so" android "ARM" +fi +if [[ -n "$android_so_x86_64" ]]; then + install_one "$android_so_x86_64" "$stage/rapt/prototype/renpyandroid/src/main/jniLibs/x86_64/librenpython.so" android "Advanced Micro Devices X86-64" +fi +if [[ -n "$ios_release_a" ]]; then + install_one "$ios_release_a" "$stage/renios/prototype/prebuilt/release/librenpython.a" ios +fi +if [[ -n "$ios_debug_a" ]]; then + install_one "$ios_debug_a" "$stage/renios/prototype/prebuilt/debug/librenpython.a" ios +fi + +echo "Artifacts installed, but playable mobile support is not asserted by this scaffold" diff --git a/bridge/renpy_runtime/mobile_launcher/include/renpy_mobile_launcher.h b/bridge/renpy_runtime/mobile_launcher/include/renpy_mobile_launcher.h new file mode 100644 index 000000000..326b7f4f3 --- /dev/null +++ b/bridge/renpy_runtime/mobile_launcher/include/renpy_mobile_launcher.h @@ -0,0 +1,138 @@ +#ifndef AETHERKIRI_RENPY_MOBILE_LAUNCHER_H +#define AETHERKIRI_RENPY_MOBILE_LAUNCHER_H + +/* + * Host-owned Ren'Py mobile lifecycle ABI. + * + * The patched official Ren'Py native launcher exports these symbols from the + * replacement librenpython.so (Android) and librenpython.a (Renios). + * + * The existing official libraries are process launchers: SDL_main and + * launcher_main eventually call Py_RunMain and do not satisfy this ABI. + */ + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define RENPY_MOBILE_LAUNCHER_ABI_VERSION 2u +#if defined(__GNUC__) +#define RENPY_MOBILE_EXPORT __attribute__((visibility("default"))) +#else +#define RENPY_MOBILE_EXPORT +#endif + +typedef enum renpy_mobile_status { + RENPY_MOBILE_OK = 0, + RENPY_MOBILE_FINISHED = 1, + RENPY_MOBILE_INVALID_ARGUMENT = -1, + RENPY_MOBILE_INVALID_STATE = -2, + RENPY_MOBILE_NOT_IMPLEMENTED = -3, + RENPY_MOBILE_ERROR = -4, +} renpy_mobile_status_t; + +typedef enum renpy_mobile_input_type { + RENPY_MOBILE_INPUT_KEY = 1, + RENPY_MOBILE_INPUT_POINTER = 2, + RENPY_MOBILE_INPUT_TEXT = 3, + RENPY_MOBILE_INPUT_GAMEPAD = 4, +} renpy_mobile_input_type_t; + +/* Actions are independent of SDL's platform-specific event constants. */ +typedef enum renpy_mobile_input_action { + RENPY_MOBILE_POINTER_DOWN = 1, + RENPY_MOBILE_POINTER_MOVE = 2, + RENPY_MOBILE_POINTER_UP = 3, + RENPY_MOBILE_POINTER_SCROLL = 4, + RENPY_MOBILE_KEY_DOWN = 5, + RENPY_MOBILE_KEY_UP = 6, + RENPY_MOBILE_BACK = 8, +} renpy_mobile_input_action_t; + +typedef struct renpy_mobile_config { + uint32_t struct_size; + uint32_t abi_version; + const char *private_root_utf8; + const char *public_root_utf8; + const char *apk_path_utf8; + const char *argv0_utf8; + const char *const *argv; + int argc; + /* The game project is separate from the bundled Python runtime root. */ + const char *game_root_utf8; +} renpy_mobile_config_t; + +typedef struct renpy_mobile_host { + uint32_t struct_size; + uint32_t abi_version; + void *user_data; + + /* Called on the host render thread. `rgba` remains owned by Ren'Py until + * the callback returns. The host must copy it before returning. */ + int (*present_rgba)(void *user_data, const uint8_t *rgba, + uint32_t width, uint32_t height, uint32_t stride); + + /* Optional diagnostics hook. The string is UTF-8 and only borrowed for + * the duration of the callback. */ + void (*log_utf8)(void *user_data, int level, const char *message); +} renpy_mobile_host_t; + +typedef struct renpy_mobile_frame { + uint32_t struct_size; + const uint8_t *rgba; + uint32_t width; + uint32_t height; + uint32_t stride; + uint64_t serial; +} renpy_mobile_frame_t; + +typedef struct renpy_mobile_input { + uint32_t struct_size; + uint32_t type; + uint64_t timestamp_ns; + int32_t device_id; + int32_t code; + int32_t value; + float x; + float y; + float pressure; + const char *text_utf8; + float delta_x; + float delta_y; + uint32_t modifiers; /* pygame/SDL2 KMOD_* bits. */ + uint32_t repeat; +} renpy_mobile_input_t; + +/* The only supported entrypoint sequence is: + * + * init -> zero or more { tick, frame, input, pause/resume } -> shutdown + * + * init/tick/frame/input/pause/resume/shutdown must return promptly. In + * particular, none of them may call Py_RunMain, SDL_main, UIApplicationMain, + * SDL_RunApp, or create an Android Activity. */ +RENPY_MOBILE_EXPORT int renpy_mobile_init(const renpy_mobile_config_t *config, + const renpy_mobile_host_t *host); +/* Initialize only CPython and built-in extensions; init creates the game loop. */ +RENPY_MOBILE_EXPORT int renpy_mobile_bootstrap(const renpy_mobile_config_t *config, + const renpy_mobile_host_t *host); +RENPY_MOBILE_EXPORT int renpy_mobile_tick(uint32_t budget_ms); +RENPY_MOBILE_EXPORT int renpy_mobile_frame(renpy_mobile_frame_t *out_frame); +RENPY_MOBILE_EXPORT int renpy_mobile_input(const renpy_mobile_input_t *event); +RENPY_MOBILE_EXPORT int renpy_mobile_pause(void); +RENPY_MOBILE_EXPORT int renpy_mobile_resume(void); +/* The renderer passes its real SDL window after context creation and NULL + * immediately before destroying it, so cached EGL handles cannot dangle. */ +RENPY_MOBILE_EXPORT int renpy_mobile_bind_window(void *window); +/* Optional extensions. Older loaders can still consume frames and inputs. */ +RENPY_MOBILE_EXPORT int renpy_mobile_text_input_state(uint32_t *active); +RENPY_MOBILE_EXPORT int renpy_mobile_set_surface_size(uint32_t width, uint32_t height); +RENPY_MOBILE_EXPORT void renpy_mobile_shutdown(void); + +#ifdef __cplusplus +} +#endif + +#endif /* AETHERKIRI_RENPY_MOBILE_LAUNCHER_H */ diff --git a/bridge/renpy_runtime/mobile_launcher/include/renpy_mobile_loader.h b/bridge/renpy_runtime/mobile_launcher/include/renpy_mobile_loader.h new file mode 100644 index 000000000..7cace1a53 --- /dev/null +++ b/bridge/renpy_runtime/mobile_launcher/include/renpy_mobile_loader.h @@ -0,0 +1,87 @@ +#ifndef AETHERKIRI_RENPY_MOBILE_LOADER_H +#define AETHERKIRI_RENPY_MOBILE_LOADER_H + +#include "renpy_mobile_launcher.h" + +#include +#include + +namespace aetherkiri::renpy::mobile { + +/* + * Loads the optional host-owned launcher ABI from the rebuilt mobile payload. + * + * The official RAPT/Renios archives do not export these functions, so an + * unavailable loader is an expected and safe state. Android resolves the + * symbols from librenpython.so; iOS links the isolated runtime framework with strong + * references so missing native inputs fail the build. This class never falls back to + * SDL_main, launcher_main, Py_RunMain, or an application entrypoint. + */ +class Launcher final { + public: + Launcher(); + ~Launcher(); + + Launcher(const Launcher&) = delete; + Launcher& operator=(const Launcher&) = delete; + + bool available(); + const std::string& last_error() const { return last_error_; } + + int Init(const renpy_mobile_config_t& config, + const renpy_mobile_host_t& host); + int Tick(uint32_t budget_ms); + int Frame(renpy_mobile_frame_t* out_frame); + int Input(const renpy_mobile_input_t& event); + int Pause(); + int Resume(); + int TextInputState(uint32_t* active); + int SetSurfaceSize(uint32_t width, uint32_t height); + void Shutdown(); + + bool initialized() const { return initialized_; } + bool finished() const { return finished_; } + + private: + bool Resolve(); + void Unload(); + + using InitFn = int (*)(const renpy_mobile_config_t*, + const renpy_mobile_host_t*); + using BootstrapFn = int (*)(const renpy_mobile_config_t*, + const renpy_mobile_host_t*); + using TickFn = int (*)(uint32_t); + using FrameFn = int (*)(renpy_mobile_frame_t*); + using InputFn = int (*)(const renpy_mobile_input_t*); + using PauseFn = int (*)(); + using ResumeFn = int (*)(); + using TextInputStateFn = int (*)(uint32_t*); + using SurfaceSizeFn = int (*)(uint32_t, uint32_t); + using BindWindowFn = int (*)(void*); + using ShutdownFn = void (*)(); + + void* library_handle_ = nullptr; + InitFn init_ = nullptr; + BootstrapFn bootstrap_ = nullptr; + TickFn tick_ = nullptr; + FrameFn frame_ = nullptr; + InputFn input_ = nullptr; + PauseFn pause_ = nullptr; + ResumeFn resume_ = nullptr; + TextInputStateFn text_input_state_ = nullptr; + SurfaceSizeFn surface_size_ = nullptr; + BindWindowFn bind_window_ = nullptr; + ShutdownFn shutdown_ = nullptr; + bool resolved_ = false; + bool initialized_ = false; + bool finished_ = false; + // The isolated SDL library still reads process environment variables. + // Apply its video selection only while executing its owner-thread ABI. + std::array video_environment_values_; + std::array video_environment_defined_{}; + std::string last_error_; +}; + +} // namespace aetherkiri::renpy::mobile + +#endif // AETHERKIRI_RENPY_MOBILE_LOADER_H diff --git a/bridge/renpy_runtime/mobile_launcher/patches/README.md b/bridge/renpy_runtime/mobile_launcher/patches/README.md new file mode 100644 index 000000000..4135e0c59 --- /dev/null +++ b/bridge/renpy_runtime/mobile_launcher/patches/README.md @@ -0,0 +1,30 @@ +# Pinned embedded runtime patches + +These patches rebuild Ren'Py 8.5.3 from the exact source revisions recorded in +`tools/run_renpy_mobile_source_build.sh`. They are a separate SDL 2.0.20 source +build; they cannot be mixed with the official downloadable SDL3 binary closure. + +The native patches compile the lifecycle implementation and target-built +`_aether_greenlet`, add Python initialization without `Py_RunMain`, and enable +Android's private EGL pbuffer. The Python patch runs the complete Ren'Py +bootstrap on a suspended greenlet. Tick sets its deadline before switching into +the game, preserves the interaction stack, and publishes the renderer's RGBA +screenshot. Window creation, recreation and destruction update the native GL +binding. The eleven exports in `../include/renpy_mobile_launcher.h` are checked +before installing the rebuilt payload. + +The source runner applies both native and Python patches to the sources that +it actually builds. `--mode prepare` fetches and patches; `--mode check` checks +the build prerequisites; `--mode build` performs the compilation and creates a +provenance manifest only after successful native/resource validation. + +Android still requires complete runtime linking, APK packaging and device +acceptance. iOS has a remaining implementation blocker: the stock SDL +UIKit/MetalANGLE path creates its own window. The provider rejects that path +until rendering is integrated with the existing host. Passing patch, symbol or +protocol tests is not evidence of mobile gameplay. + +The C files under `android/` and `ios/` ending in `.template` are deliberately +nonfunctional contract fixtures. They are never installed as runtime archives. +See [native/README.md](native/README.md) and [python/README.md](python/README.md) +for the actual build seams and limitations. diff --git a/bridge/renpy_runtime/mobile_launcher/patches/android/librenpython_android_host.c.template b/bridge/renpy_runtime/mobile_launcher/patches/android/librenpython_android_host.c.template new file mode 100644 index 000000000..622af47af --- /dev/null +++ b/bridge/renpy_runtime/mobile_launcher/patches/android/librenpython_android_host.c.template @@ -0,0 +1,74 @@ +/* + * Contract-only Android native fork template. + * + * Start from renpy-build/runtime/librenpython_android.c and factor its + * init_environ(), call_prepare_python(), SDL setup, and Python config code into + * renpy_mobile_init(). Then replace these deliberate NOT_IMPLEMENTED bodies + * with the real cooperative Ren'Py loop. This file is compiled only by the + * scaffold source test; it is never packaged as librenpython.so. + */ +#define AETHERKIRI_RENPY_LIFECYCLE_STUB 1 +#if defined(__GNUC__) +__attribute__((used)) +#endif +static const char kAetherkiriLifecycleStubMarker[] = "AETHERKIRI_RENPY_LIFECYCLE_STUB"; +#include "renpy_mobile_launcher.h" + +static int lifecycle_started; + +int renpy_mobile_init(const renpy_mobile_config_t *config, + const renpy_mobile_host_t *host) { + if (config == 0 || host == 0 || config->struct_size < sizeof(*config) || + host->struct_size < sizeof(*host) || + config->abi_version != RENPY_MOBILE_LAUNCHER_ABI_VERSION || + host->abi_version != RENPY_MOBILE_LAUNCHER_ABI_VERSION) { + return RENPY_MOBILE_INVALID_ARGUMENT; + } + if (lifecycle_started) return RENPY_MOBILE_INVALID_STATE; + + /* TODO(real fork): + * - copy Android paths into owned storage + * - initialize SDL against the host-owned surface/context + * - call Python pre-initialization and init_librenpy() + * - import the Ren'Py bootstrap without calling the blocking Py_RunMain entrypoint + */ + return RENPY_MOBILE_NOT_IMPLEMENTED; +} + +int renpy_mobile_tick(uint32_t budget_ms) { + (void)budget_ms; + return lifecycle_started ? RENPY_MOBILE_NOT_IMPLEMENTED + : RENPY_MOBILE_INVALID_STATE; +} + +int renpy_mobile_frame(renpy_mobile_frame_t *out_frame) { + if (out_frame == 0 || out_frame->struct_size < sizeof(*out_frame)) { + return RENPY_MOBILE_INVALID_ARGUMENT; + } + return lifecycle_started ? RENPY_MOBILE_NOT_IMPLEMENTED + : RENPY_MOBILE_INVALID_STATE; +} + +int renpy_mobile_input(const renpy_mobile_input_t *event) { + if (event == 0 || event->struct_size < sizeof(*event)) { + return RENPY_MOBILE_INVALID_ARGUMENT; + } + return lifecycle_started ? RENPY_MOBILE_NOT_IMPLEMENTED + : RENPY_MOBILE_INVALID_STATE; +} + +int renpy_mobile_pause(void) { + return lifecycle_started ? RENPY_MOBILE_NOT_IMPLEMENTED + : RENPY_MOBILE_INVALID_STATE; +} + +int renpy_mobile_resume(void) { + return lifecycle_started ? RENPY_MOBILE_NOT_IMPLEMENTED + : RENPY_MOBILE_INVALID_STATE; +} + +void renpy_mobile_shutdown(void) { + /* TODO(real fork): stop the cooperative Python/SDL loop and finalize in + * the same thread that owns the interpreter. */ + lifecycle_started = 0; +} diff --git a/bridge/renpy_runtime/mobile_launcher/patches/ios/librenpython_ios_host.c.template b/bridge/renpy_runtime/mobile_launcher/patches/ios/librenpython_ios_host.c.template new file mode 100644 index 000000000..88336d248 --- /dev/null +++ b/bridge/renpy_runtime/mobile_launcher/patches/ios/librenpython_ios_host.c.template @@ -0,0 +1,73 @@ +/* + * Contract-only Renios native fork template. + * + * Start from renpy-build/runtime/librenpython.c and factor its + * preinitialize(), search_python_home(), Python config, and Ren'Py bootstrap + * code into renpy_mobile_init(). The UIKit prototype main.c must remain out + * of the Godot target. This file is compiled only by the scaffold source + * test; it is never packaged as librenpython.a. + */ +#define AETHERKIRI_RENPY_LIFECYCLE_STUB 1 +#if defined(__GNUC__) +__attribute__((used)) +#endif +static const char kAetherkiriLifecycleStubMarker[] = "AETHERKIRI_RENPY_LIFECYCLE_STUB"; +#include "renpy_mobile_launcher.h" + +static int lifecycle_started; + +int renpy_mobile_init(const renpy_mobile_config_t *config, + const renpy_mobile_host_t *host) { + if (config == 0 || host == 0 || config->struct_size < sizeof(*config) || + host->struct_size < sizeof(*host) || + config->abi_version != RENPY_MOBILE_LAUNCHER_ABI_VERSION || + host->abi_version != RENPY_MOBILE_LAUNCHER_ABI_VERSION) { + return RENPY_MOBILE_INVALID_ARGUMENT; + } + if (lifecycle_started) return RENPY_MOBILE_INVALID_STATE; + + /* TODO(real fork): + * - initialize Python/Ren'Py without launcher_main or Py_RunMain + * - use the existing Godot Metal/SDL surface instead of + * SDL_RunApp, SDL_UIKitRunApp, or UIApplicationMain + * - drive one bounded display/input step from renpy.display.core.Interface + */ + return RENPY_MOBILE_NOT_IMPLEMENTED; +} + +int renpy_mobile_tick(uint32_t budget_ms) { + (void)budget_ms; + return lifecycle_started ? RENPY_MOBILE_NOT_IMPLEMENTED + : RENPY_MOBILE_INVALID_STATE; +} + +int renpy_mobile_frame(renpy_mobile_frame_t *out_frame) { + if (out_frame == 0 || out_frame->struct_size < sizeof(*out_frame)) { + return RENPY_MOBILE_INVALID_ARGUMENT; + } + return lifecycle_started ? RENPY_MOBILE_NOT_IMPLEMENTED + : RENPY_MOBILE_INVALID_STATE; +} + +int renpy_mobile_input(const renpy_mobile_input_t *event) { + if (event == 0 || event->struct_size < sizeof(*event)) { + return RENPY_MOBILE_INVALID_ARGUMENT; + } + return lifecycle_started ? RENPY_MOBILE_NOT_IMPLEMENTED + : RENPY_MOBILE_INVALID_STATE; +} + +int renpy_mobile_pause(void) { + return lifecycle_started ? RENPY_MOBILE_NOT_IMPLEMENTED + : RENPY_MOBILE_INVALID_STATE; +} + +int renpy_mobile_resume(void) { + return lifecycle_started ? RENPY_MOBILE_NOT_IMPLEMENTED + : RENPY_MOBILE_INVALID_STATE; +} + +void renpy_mobile_shutdown(void) { + /* TODO(real fork): stop/finalize Python and Ren'Py on the owning thread. */ + lifecycle_started = 0; +} diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/0001-renpy-build-link.patch b/bridge/renpy_runtime/mobile_launcher/patches/native/0001-renpy-build-link.patch new file mode 100644 index 000000000..b9f8c7a1d --- /dev/null +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/0001-renpy-build-link.patch @@ -0,0 +1,63 @@ +diff --git a/tasks/librenpy.py b/tasks/librenpy.py +--- a/tasks/librenpy.py ++++ b/tasks/librenpy.py +@@ -24,7 +24,7 @@ def build(c: Context): + if c.platform == "web" and c.python == "2": + return + +- c.env("CFLAGS", """{{ CFLAGS }} "-I{{ renpy }}/src" "-I{{renpy}}/tmp/gen3" """) +- c.env("CXXFLAGS", """{{ CXXFLAGS }} "-I{{ renpy }}/src" "-I{{renpy}}/tmp/gen3" """) ++ c.env("CFLAGS", """{{ CFLAGS }} "-I{{ renpy }}/src" "-I{{renpy}}/tmp/gen3-static" "-I{{renpy}}/tmp/gen3" """) ++ c.env("CXXFLAGS", """{{ CXXFLAGS }} "-I{{ renpy }}/src" "-I{{renpy}}/tmp/gen3-static" "-I{{renpy}}/tmp/gen3" """) + + gen = "gen3-static/" +diff --git a/tasks/renpython.py b/tasks/renpython.py +index 114bc33..3bf1a3a 100644 +--- a/tasks/renpython.py ++++ b/tasks/renpython.py +@@ -41,6 +41,20 @@ def build_android(c: Context): + """) + + ++@task(kind="python", always=True, platforms="android,ios", pythons="3") ++def build_mobile_lifecycle(c: Context): ++ # Compile for every target, including each iOS simulator architecture. ++ c.run(""" ++ {{ CC }} {{ CFLAGS }} ++ -c -o renpy_mobile_lifecycle.o {{ runtime }}/renpy_mobile_lifecycle.c ++ """) ++ c.run(""" ++ {{ CXX }} {{ CXXFLAGS }} -std=c++11 ++ -I{{ runtime }}/greenlet ++ -c -o aether_greenlet.o {{ runtime }}/greenlet/greenlet.cpp ++ """) ++ ++ + @task(kind="python", always=True, platforms="linux") + def link_linux(c: Context): + +@@ -130,6 +144,7 @@ def link_android(c: Context): + + -o librenpython.so + librenpython_android.o ++ renpy_mobile_lifecycle.o aether_greenlet.o + + -lrenpy + -l{{ pythonver }} +@@ -147,6 +162,7 @@ def link_android(c: Context): + + -lGLESv1_CM + -lGLESv2 ++ -lEGL + + -lOpenSLES + +@@ -464,7 +480,7 @@ def link_windows(c: Context): + def link_ios(c: Context): + + c.unlink("librenpython.a") +- c.run("""{{ AR }} -r librenpython.a librenpython.o""") ++ c.run("""{{ AR }} -r librenpython.a librenpython.o renpy_mobile_lifecycle.o aether_greenlet.o""") + c.run("""install -d {{install}}/lib""") + c.run("""install librenpython.a {{ install }}/lib""") + diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/0002-android-host-bootstrap.patch b/bridge/renpy_runtime/mobile_launcher/patches/native/0002-android-host-bootstrap.patch new file mode 100644 index 000000000..ba82e3b8d --- /dev/null +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/0002-android-host-bootstrap.patch @@ -0,0 +1,80 @@ +diff --git a/runtime/librenpython3_android.c b/runtime/librenpython3_android.c +index f6d8b03..996dfe6 100644 +--- a/runtime/librenpython3_android.c ++++ b/runtime/librenpython3_android.c +@@ -8,6 +8,12 @@ + #include + #include "android/log.h" + #include "jniwrapperstuff.h" ++#include "renpy_mobile_launcher.h" ++ ++/* Built into the same target CPython, never imported from a host wheel. */ ++PyMODINIT_FUNC PyInit__greenlet(void); ++PyMODINIT_FUNC PyInit__aether_host(void); ++ + + void init_librenpy(void); + +@@ -143,6 +149,62 @@ int start_python(void) { + return rv; + } + ++/* Initialize the embedded interpreter without running a process main loop. */ ++int renpy_mobile_bootstrap(const renpy_mobile_config_t *mobile_config, ++ const renpy_mobile_host_t *host) { ++ if (!mobile_config || !host || !mobile_config->private_root_utf8 || ++ !mobile_config->game_root_utf8) return RENPY_MOBILE_INVALID_ARGUMENT; ++ if (Py_IsInitialized()) { ++ if (host->log_utf8) host->log_utf8(host->user_data, 2, ++ "Ren'Py interpreter reuse is not implemented; restart the host application for another game session"); ++ return RENPY_MOBILE_INVALID_STATE; ++ } ++ init_environ(); ++ setenv_workaround("RENPY_PLATFORM", PLATFORM "-" ARCH); ++ setenv_workaround("ANDROID_PRIVATE", mobile_config->private_root_utf8); ++ setenv_workaround("ANDROID_PUBLIC", mobile_config->public_root_utf8 ++ ? mobile_config->public_root_utf8 : mobile_config->game_root_utf8); ++ setenv_workaround("ANDROID_OLD_PUBLIC", mobile_config->public_root_utf8 ++ ? mobile_config->public_root_utf8 : mobile_config->game_root_utf8); ++ if (mobile_config->apk_path_utf8) ++ setenv_workaround("ANDROID_APK", mobile_config->apk_path_utf8); ++ SDL_SetHint(SDL_HINT_ANDROID_BLOCK_ON_PAUSE, "0"); ++ SDL_SetHint("AETHER_RENPY_EMBEDDED", "1"); ++ setenv_workaround("SDL_VIDEODRIVER", "offscreen"); ++ if (PyImport_ExtendInittab(inittab) == -1 || ++ PyImport_AppendInittab("_aether_greenlet", PyInit__greenlet) == -1 || ++ PyImport_AppendInittab("_aether_host", PyInit__aether_host) == -1) ++ return RENPY_MOBILE_ERROR; ++ init_librenpy(); ++ ++ PyPreConfig preconfig; ++ PyPreConfig_InitPythonConfig(&preconfig); ++ preconfig.utf8_mode = 1; ++ preconfig.use_environment = 0; ++ PyStatus status = Py_PreInitialize(&preconfig); ++ if (PyStatus_Exception(status)) return RENPY_MOBILE_ERROR; ++ PyConfig pyconfig; ++ PyConfig_InitPythonConfig(&pyconfig); ++ pyconfig.user_site_directory = 0; ++ pyconfig.parse_argv = 0; ++ pyconfig.install_signal_handlers = 0; ++ status = PyConfig_SetBytesString(&pyconfig, &pyconfig.home, mobile_config->private_root_utf8); ++ if (!PyStatus_Exception(status)) status = Py_InitializeFromConfig(&pyconfig); ++ PyConfig_Clear(&pyconfig); ++ if (PyStatus_Exception(status)) return RENPY_MOBILE_ERROR; ++ /* Py_RunMain normally adds the script directory, so do that explicitly. */ ++ PyObject *root = PyUnicode_DecodeFSDefault(mobile_config->private_root_utf8); ++ if (!root || PyList_Insert(PySys_GetObject("path"), 0, root) == -1) { ++ Py_XDECREF(root); ++ return RENPY_MOBILE_ERROR; ++ } ++ Py_DECREF(root); ++ /* Every host ABI call reacquires the GIL. Let audio/save workers run ++ while the game stack is suspended or the application is paused. */ ++ PyEval_SaveThread(); ++ return RENPY_MOBILE_OK; ++} ++ + void call_prepare_python(void) { + JNIEnv* env = (JNIEnv*) SDL_AndroidGetJNIEnv(); + jobject activity = (jobject) SDL_AndroidGetActivity(); diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/0003-ios-host-bootstrap.patch b/bridge/renpy_runtime/mobile_launcher/patches/native/0003-ios-host-bootstrap.patch new file mode 100644 index 000000000..6f5228bd9 --- /dev/null +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/0003-ios-host-bootstrap.patch @@ -0,0 +1,68 @@ +diff --git a/runtime/librenpython3.c b/runtime/librenpython3.c +index 6c7e426..1922883 100644 +--- a/runtime/librenpython3.c ++++ b/runtime/librenpython3.c +@@ -4,6 +4,12 @@ + #include + #include + #include "Python.h" ++#include "renpy_mobile_launcher.h" ++ ++/* Built into the same target CPython, never imported from a host wheel. */ ++PyMODINIT_FUNC PyInit__greenlet(void); ++PyMODINIT_FUNC PyInit__aether_host(void); ++ + + /** + * A small note on encoding: This does as much as it can in UTF-8 mode, +@@ -552,6 +558,50 @@ int EXPORT launcher_main(int argc, char **argv) { + /** + * This is called from the launcher executable, to start Ren'Py running. + */ ++/* The host provides the packaged Python home and owns the application loop. */ ++int EXPORT renpy_mobile_bootstrap(const renpy_mobile_config_t *mobile_config, ++ const renpy_mobile_host_t *host) { ++ if (!mobile_config || !host || !mobile_config->private_root_utf8 || ++ !mobile_config->game_root_utf8) return RENPY_MOBILE_INVALID_ARGUMENT; ++ if (Py_IsInitialized()) { ++ if (host->log_utf8) host->log_utf8(host->user_data, 2, ++ "Ren'Py interpreter reuse is not implemented; restart the host application for another game session"); ++ return RENPY_MOBILE_INVALID_STATE; ++ } ++ if (PyImport_AppendInittab("_aether_greenlet", PyInit__greenlet) == -1 || ++ PyImport_AppendInittab("_aether_host", PyInit__aether_host) == -1) ++ return RENPY_MOBILE_ERROR; ++ char *argv[] = { (char *)mobile_config->argv0_utf8, NULL }; ++ if (!argv[0]) return RENPY_MOBILE_INVALID_ARGUMENT; ++ PyPreConfig preconfig; ++ PyPreConfig_InitIsolatedConfig(&preconfig); ++ preconfig.utf8_mode = 1; ++ preconfig.use_environment = 0; ++ PyStatus status = Py_PreInitialize(&preconfig); ++ if (PyStatus_Exception(status)) return RENPY_MOBILE_ERROR; ++ init_librenpy(); ++ PyConfig_InitIsolatedConfig(&config); ++ set_renpy_platform(); ++ config.user_site_directory = 0; ++ config.parse_argv = 0; ++ config.install_signal_handlers = 0; ++ status = PyConfig_SetBytesString(&config, &config.home, ++ mobile_config->private_root_utf8); ++ if (!PyStatus_Exception(status)) status = Py_InitializeFromConfig(&config); ++ PyConfig_Clear(&config); ++ if (PyStatus_Exception(status)) return RENPY_MOBILE_ERROR; ++ PyObject *root = PyUnicode_DecodeFSDefault(mobile_config->private_root_utf8); ++ if (!root || PyList_Insert(PySys_GetObject("path"), 0, root) == -1) { ++ Py_XDECREF(root); ++ return RENPY_MOBILE_ERROR; ++ } ++ Py_DECREF(root); ++ /* Every host ABI call reacquires the GIL. Let audio/save workers run ++ while the game stack is suspended or the application is paused. */ ++ PyEval_SaveThread(); ++ return RENPY_MOBILE_OK; ++} ++ + int EXPORT launcher_main_wide(int argc, wchar_t **argv) { + + preinitialize_wide(1, argc, argv); diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/0004-android-offscreen-renderer.patch b/bridge/renpy_runtime/mobile_launcher/patches/native/0004-android-offscreen-renderer.patch new file mode 100644 index 000000000..89fff32e6 --- /dev/null +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/0004-android-offscreen-renderer.patch @@ -0,0 +1,51 @@ +diff --git a/tasks/sdl2.py b/tasks/sdl2.py +index d266b21..6756b47 100644 +--- a/tasks/sdl2.py ++++ b/tasks/sdl2.py +@@ -59,6 +59,7 @@ def build(c: Context): + {% endif %} + + {% if c.platform == "android" %} ++ --enable-video-offscreen + + --disable-oss + --disable-alsa +diff --git a/patches/SDL2-2.0.20/aether-offscreen.patch b/patches/SDL2-2.0.20/aether-offscreen.patch +new file mode 100644 +index 00000000..a94b3993 +--- /dev/null ++++ b/patches/SDL2-2.0.20/aether-offscreen.patch +@@ -0,0 +1,33 @@ ++diff --git a/src/video/offscreen/SDL_offscreenopengl.c b/src/video/offscreen/SDL_offscreenopengl.c ++index 87641520..9c0552cf 100644 ++--- a/src/video/offscreen/SDL_offscreenopengl.c +++++ b/src/video/offscreen/SDL_offscreenopengl.c ++@@ -63,2 +63,11 @@ OFFSCREEN_GL_LoadLibrary(_THIS, const char* path) ++ { +++#ifdef __ANDROID__ +++ /* Android exposes EGL_DEFAULT_DISPLAY; device-enumeration extensions are +++ optional. Use a private pbuffer, never the host's ANativeWindow. */ +++ int ret = SDL_EGL_LoadLibrary(_this, path, (NativeDisplayType)EGL_DEFAULT_DISPLAY, 0); +++ if (ret != 0) return ret; +++ _this->egl_data->is_offscreen = SDL_TRUE; +++ _this->egl_data->egl_surfacetype = EGL_PBUFFER_BIT; +++ return SDL_EGL_ChooseConfig(_this); +++#else ++ int ret = SDL_EGL_LoadLibraryOnly(_this, path); ++@@ -85,2 +94,3 @@ OFFSCREEN_GL_LoadLibrary(_THIS, const char* path) ++ return 0; +++#endif ++ } ++diff --git a/src/video/SDL_egl.c b/src/video/SDL_egl.c ++index e10cf7c7..62004e3b 100644 ++--- a/src/video/SDL_egl.c +++++ b/src/video/SDL_egl.c ++@@ -279,3 +279,7 @@ SDL_EGL_UnloadLibrary(_THIS) ++ if (_this->egl_data->egl_display) { ++- _this->egl_data->eglTerminate(_this->egl_data->egl_display); +++ /* The embedded host may use this same default EGLDisplay. +++ eglTerminate would destroy its contexts and native surfaces. */ +++ if (!SDL_GetHintBoolean("AETHER_RENPY_EMBEDDED", SDL_FALSE)) { +++ _this->egl_data->eglTerminate(_this->egl_data->egl_display); +++ } ++ _this->egl_data->egl_display = NULL; diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/0005-optional-cubism.patch b/bridge/renpy_runtime/mobile_launcher/patches/native/0005-optional-cubism.patch new file mode 100644 index 000000000..bcb38cd8c --- /dev/null +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/0005-optional-cubism.patch @@ -0,0 +1,55 @@ +diff --git a/tasks/live2d.py b/tasks/live2d.py +--- a/tasks/live2d.py ++++ b/tasks/live2d.py +@@ -4,8 +4,12 @@ + + @annotator + def annotate(c: Context): +- c.include("{{ install }}/cubism/Core/include") +- c.env("CUBISM", "{{ install }}/cubism") ++ # Basic Ren'Py games do not require the separately licensed Cubism SDK. ++ # Clear inherited CUBISM values so generation and target compilation agree. ++ c.environ.pop("CUBISM", None) ++ if c.path("{{ install }}/cubism/Core/include/Live2DCubismCore.h").is_file(): ++ c.include("{{ install }}/cubism/Core/include") ++ c.env("CUBISM", "{{ install }}/cubism") + + + @task(platforms="all") +diff --git a/tasks/librenpy.py b/tasks/librenpy.py +--- a/tasks/librenpy.py ++++ b/tasks/librenpy.py +@@ -45,6 +45,11 @@ + parts = l.split() + + if parts[0] == "renpy.compat.dictviews" and c.python != "2": ++ continue ++ ++ if parts[0] == "renpy.gl2.live2dmodel" and not c.path( ++ "{{ install }}/cubism/Core/include/Live2DCubismCore.h" ++ ).is_file(): + continue + + modules.append(parts[0]) +diff --git a/tasks/pythonlib.py b/tasks/pythonlib.py +--- a/tasks/pythonlib.py ++++ b/tasks/pythonlib.py +@@ -200,8 +200,6 @@ + pefile + ordlookup/ + +-steamapi +- + brotli + """ + +@@ -214,6 +212,9 @@ + + # The list of rules. + rules = set(PY3_MODULES.split()) ++ # Steam generation is optional and skipped when its SDK is unavailable. ++ if c.path("{{ pytmp }}/steam/steamapi.py").is_file(): ++ rules.add("steamapi") + used_rules = set() + + search = [ diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/0006-ios-offscreen-renderer.patch b/bridge/renpy_runtime/mobile_launcher/patches/native/0006-ios-offscreen-renderer.patch new file mode 100644 index 000000000..371a6dfcf --- /dev/null +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/0006-ios-offscreen-renderer.patch @@ -0,0 +1,315 @@ +diff --git a/patches/SDL2-2.0.20/aether-pbuffer-metalangle.patch b/patches/SDL2-2.0.20/aether-pbuffer-metalangle.patch +new file mode 100644 +index 0000000..452e728 +--- /dev/null ++++ b/patches/SDL2-2.0.20/aether-pbuffer-metalangle.patch +@@ -0,0 +1,185 @@ ++diff --git a/configure.ac b/configure.ac ++index 3c1c6c8..3fa0bde 100644 ++--- a/configure.ac +++++ b/configure.ac ++@@ -4116,6 +4116,9 @@ case "$host" in ++ CheckDeclarationAfterStatement ++ CheckDummyVideo ++ CheckOffscreenVideo +++ # MetalANGLE supplies EGL for the host-isolated offscreen pbuffer. +++ # Its include directory is provided by the Ren'Py metalangle task. +++ CheckEGL ++ CheckDiskAudio ++ CheckDummyAudio ++ CheckDLOPEN ++@@ -4146,7 +4149,9 @@ case "$host" in ++ have_joystick=yes ++ else ++ # Need this code for accelerometer as joystick support ++- SOURCES="$SOURCES $srcdir/src/joystick/iphoneos/*.m" +++ if test x$enable_video_offscreen != xyes; then +++ SOURCES="$SOURCES $srcdir/src/joystick/iphoneos/*.m" +++ fi ++ fi ++ # Set up files for the haptic library ++ #if test x$enable_haptic = xyes; then ++@@ -4185,12 +4190,16 @@ case "$host" in ++ # Set up other core UNIX files ++ SOURCES="$SOURCES $srcdir/src/core/unix/*.c" ++ # The iOS platform requires special setup. ++- AC_DEFINE(SDL_VIDEO_DRIVER_UIKIT, 1, [ ]) +++ # An embedded offscreen build must not register SDL's UIKit classes: +++ # Objective-C classes are process-global even across Mach-O namespaces. +++ if test x$enable_video_offscreen != xyes; then +++ AC_DEFINE(SDL_VIDEO_DRIVER_UIKIT, 1, [ ]) +++ SOURCES="$SOURCES $srcdir/src/video/uikit/*.m" +++ fi ++ AC_DEFINE(SDL_VIDEO_OPENGL_ES2, 1, [ ]) ++ AC_DEFINE(SDL_VIDEO_OPENGL_ES, 1, [ ]) ++ AC_DEFINE(SDL_VIDEO_RENDER_OGL_ES, 1, [ ]) ++ AC_DEFINE(SDL_VIDEO_RENDER_OGL_ES2, 1, [ ]) ++- SOURCES="$SOURCES $srcdir/src/video/uikit/*.m" ++ EXTRA_LDFLAGS="$EXTRA_LDFLAGS -lm -liconv -lobjc" ++ EXTRA_LDFLAGS="$EXTRA_LDFLAGS -Wl,-framework,AVFoundation" ++ EXTRA_LDFLAGS="$EXTRA_LDFLAGS -Wl,-framework,AudioToolbox" ++diff --git a/src/audio/coreaudio/SDL_coreaudio.m b/src/audio/coreaudio/SDL_coreaudio.m ++index 67d838f..0beafca 100644 ++--- a/src/audio/coreaudio/SDL_coreaudio.m +++++ b/src/audio/coreaudio/SDL_coreaudio.m ++@@ -346,6 +346,11 @@ static void interruption_end(_THIS) ++ } ++ } ++ +++/* Altered for the embedded Ren'Py fork: the host may have its own SDL2. +++ CoreAudio remains enabled, but its Objective-C class cannot share a name. */ +++#if defined(METALANGLE) && defined(__IPHONEOS__) +++#define SDLInterruptionListener AetherRenpySDLInterruptionListener +++#endif ++ @interface SDLInterruptionListener : NSObject ++ ++ @property (nonatomic, assign) SDL_AudioDevice *device; ++diff --git a/src/hidapi/SDL_hidapi.c b/src/hidapi/SDL_hidapi.c ++index aacff63..8d33aaf 100644 ++--- a/src/hidapi/SDL_hidapi.c +++++ b/src/hidapi/SDL_hidapi.c ++@@ -1522,8 +1522,10 @@ int SDL_hid_get_indexed_string(SDL_hid_device *device, int string_index, wchar_t ++ ++ void SDL_hid_ble_scan(SDL_bool active) ++ { ++-#if __IPHONEOS__ || __TVOS__ +++#if !SDL_HIDAPI_DISABLED && (__IPHONEOS__ || __TVOS__) ++ hid_ble_scan(active); +++#else +++ (void)active; ++ #endif ++ } ++ ++diff --git a/src/misc/ios/SDL_aetherdevice.m b/src/misc/ios/SDL_aetherdevice.m ++new file mode 100644 ++index 0000000..84dae52 ++--- /dev/null +++++ b/src/misc/ios/SDL_aetherdevice.m ++@@ -0,0 +1,35 @@ +++/* +++ Simple DirectMedia Layer +++ Copyright (C) 1997-2022 Sam Lantinga +++ +++ This software is provided 'as-is', without any express or implied +++ warranty. In no event will the authors be held liable for any damages +++ arising from the use of this software. +++ +++ Permission is granted to anyone to use this software for any purpose, +++ including commercial applications, and to alter it and redistribute it +++ freely, subject to the following restrictions: +++ +++ 1. The origin of this software must not be misrepresented; you must not +++ claim that you wrote the original software. If you use this software +++ in a product, an acknowledgment in the product documentation would be +++ appreciated but is not required. +++ 2. Altered source versions must be plainly marked as such, and must not be +++ misrepresented as being the original software. +++ 3. This notice may not be removed or altered from any source distribution. +++*/ +++/* Altered for embedded Ren'Py: preserve the actual platform idiom query +++ * without including UIKit video classes or creating a second UIWindow. */ +++#include "../../SDL_internal.h" +++#include "SDL_stdinc.h" +++ +++#if defined(__IPHONEOS__) && SDL_VIDEO_DRIVER_OFFSCREEN && !SDL_VIDEO_DRIVER_UIKIT +++#import +++ +++SDL_bool SDL_IsIPad(void) +++{ +++ @autoreleasepool { +++ return [[UIDevice currentDevice] userInterfaceIdiom] == UIUserInterfaceIdiomPad ? SDL_TRUE : SDL_FALSE; +++ } +++} +++#endif ++diff --git a/src/video/SDL_egl.c b/src/video/SDL_egl.c ++index 62004e3..9c3f2b3 100644 ++--- a/src/video/SDL_egl.c +++++ b/src/video/SDL_egl.c ++@@ -38,6 +38,9 @@ ++ #include "SDL_egl_c.h" ++ #include "SDL_loadso.h" ++ #include "SDL_hints.h" +++#if defined(METALANGLE) && defined(__IPHONEOS__) +++#include +++#endif ++ ++ #ifdef EGL_KHR_create_context ++ /* EGL_OPENGL_ES3_BIT_KHR was added in version 13 of the extension. */ ++@@ -508,6 +511,21 @@ SDL_EGL_LoadLibrary(_THIS, const char *egl_path, NativeDisplayType native_displa ++ ++ #if !defined(__WINRT__) ++ #if !defined(SDL_VIDEO_DRIVER_VITA) +++#if defined(METALANGLE) && defined(__IPHONEOS__) +++ if (platform == EGL_PLATFORM_ANGLE_ANGLE) { +++ const EGLint metal_attributes[] = { +++ EGL_PLATFORM_ANGLE_TYPE_ANGLE, EGL_PLATFORM_ANGLE_TYPE_METAL_ANGLE, EGL_NONE +++ }; +++ if (!_this->egl_data->eglGetPlatformDisplayEXT) { +++ return SDL_SetError("MetalANGLE has no eglGetPlatformDisplayEXT"); +++ } +++ _this->egl_data->egl_display = _this->egl_data->eglGetPlatformDisplayEXT( +++ platform, (void *)(uintptr_t)native_display, metal_attributes); +++ if (_this->egl_data->egl_display == EGL_NO_DISPLAY) { +++ return SDL_SetError("Could not create MetalANGLE Metal pbuffer display"); +++ } +++ } else +++#endif ++ if (platform) { ++ /* EGL 1.5 allows querying for client version with EGL_NO_DISPLAY ++ * -- ++diff --git a/src/video/offscreen/SDL_offscreenopengl.c b/src/video/offscreen/SDL_offscreenopengl.c ++index 9c0552c..e8f87a7 100644 ++--- a/src/video/offscreen/SDL_offscreenopengl.c +++++ b/src/video/offscreen/SDL_offscreenopengl.c ++@@ -26,6 +26,9 @@ ++ #include "SDL_offscreenopengl.h" ++ ++ #include "SDL_opengl.h" +++#if defined(METALANGLE) && defined(__IPHONEOS__) +++#include +++#endif ++ ++ int ++ OFFSCREEN_GL_SwapWindow(_THIS, SDL_Window* window) ++@@ -61,7 +64,16 @@ OFFSCREEN_GL_CreateContext(_THIS, SDL_Window* window) ++ int ++ OFFSCREEN_GL_LoadLibrary(_THIS, const char* path) ++ { ++-#ifdef __ANDROID__ +++#if defined(METALANGLE) && defined(__IPHONEOS__) +++ /* MetalANGLE's Metal display supports pbuffer surfaces without a +++ UIWindow, MGLLayer, or a host-owned native drawable. */ +++ int ret = SDL_EGL_LoadLibrary(_this, path, (NativeDisplayType)EGL_DEFAULT_DISPLAY, +++ EGL_PLATFORM_ANGLE_ANGLE); +++ if (ret != 0) return ret; +++ _this->egl_data->is_offscreen = SDL_TRUE; +++ _this->egl_data->egl_surfacetype = EGL_PBUFFER_BIT; +++ return SDL_EGL_ChooseConfig(_this); +++#elif defined(__ANDROID__) ++ /* Android exposes EGL_DEFAULT_DISPLAY; device-enumeration extensions are ++ optional. Use a private pbuffer, never the host's ANativeWindow. */ ++ int ret = SDL_EGL_LoadLibrary(_this, path, (NativeDisplayType)EGL_DEFAULT_DISPLAY, 0); +diff --git a/runtime/librenpython3.c b/runtime/librenpython3.c +index 1922883..d4c1256 100644 +--- a/runtime/librenpython3.c ++++ b/runtime/librenpython3.c +@@ -5,6 +5,10 @@ + #include + #include "Python.h" + #include "renpy_mobile_launcher.h" ++#ifdef IOS ++#include ++#include ++#endif + + /* Built into the same target CPython, never imported from a host wheel. */ + PyMODINIT_FUNC PyInit__greenlet(void); +@@ -559,6 +563,33 @@ int EXPORT launcher_main(int argc, char **argv) { + * This is called from the launcher executable, to start Ren'Py running. + */ + /* The host provides the packaged Python home and owns the application loop. */ ++#ifdef IOS ++static int aether_configure_ios_pbuffer(const renpy_mobile_host_t *host) { ++ /* Resolve the actually embedded framework. RTLD_DEFAULT may resolve GLES ++ to Apple's unrelated OpenGLES implementation when both are linked. */ ++ CFURLRef frameworks = CFBundleCopyPrivateFrameworksURL(CFBundleGetMainBundle()); ++ CFURLRef framework = frameworks ? CFURLCreateCopyAppendingPathComponent( ++ kCFAllocatorDefault, frameworks, CFSTR("MetalANGLE.framework"), 1) : NULL; ++ CFURLRef library = framework ? CFURLCreateCopyAppendingPathComponent( ++ kCFAllocatorDefault, framework, CFSTR("MetalANGLE"), 0) : NULL; ++ char path[PATH_MAX]; ++ int valid = library && CFURLGetFileSystemRepresentation(library, 1, (UInt8 *)path, sizeof(path)); ++ if (library) CFRelease(library); ++ if (framework) CFRelease(framework); ++ if (frameworks) CFRelease(frameworks); ++ if (!valid) { ++ if (host->log_utf8) host->log_utf8(host->user_data, 3, ++ "Could not resolve the embedded MetalANGLE framework for Ren'Py EGL"); ++ return RENPY_MOBILE_ERROR; ++ } ++ if (setenv("SDL_VIDEO_GL_DRIVER", path, 1) || setenv("SDL_VIDEO_EGL_DRIVER", path, 1) || ++ setenv("SDL_VIDEODRIVER", "offscreen", 1) || setenv("AETHER_RENPY_EMBEDDED", "1", 1)) { ++ return RENPY_MOBILE_ERROR; ++ } ++ return RENPY_MOBILE_OK; ++} ++#endif ++ + int EXPORT renpy_mobile_bootstrap(const renpy_mobile_config_t *mobile_config, + const renpy_mobile_host_t *host) { + if (!mobile_config || !host || !mobile_config->private_root_utf8 || +@@ -568,6 +599,9 @@ int EXPORT renpy_mobile_bootstrap(const renpy_mobile_config_t *mobile_config, + "Ren'Py interpreter reuse is not implemented; restart the host application for another game session"); + return RENPY_MOBILE_INVALID_STATE; + } ++#ifdef IOS ++ if (aether_configure_ios_pbuffer(host) != RENPY_MOBILE_OK) return RENPY_MOBILE_ERROR; ++#endif + if (PyImport_AppendInittab("_aether_greenlet", PyInit__greenlet) == -1 || + PyImport_AppendInittab("_aether_host", PyInit__aether_host) == -1) + return RENPY_MOBILE_ERROR; +diff --git a/tasks/metalangle.py b/tasks/metalangle.py +index 5d4a5c9..0b30f45 100644 +--- a/tasks/metalangle.py ++++ b/tasks/metalangle.py +@@ -5,8 +5,11 @@ from renpybuild.task import task, annotator + @annotator + def annotate(c: Context): + if c.platform == "ios": +- c.env("CFLAGS", "{{ CFLAGS }} -F {{install}} -framework MetalANGLE -DMETALANGLE") +- c.env("CXXFLAGS", "{{ CXXFLAGS }} -F {{install}} -framework MetalANGLE -DMETALANGLE") ++ # EGL/GLES use normal includes, not framework-prefixed ones. ++ # SDL's configure must find these same headers before it enables EGL. ++ c.env("CFLAGS", "{{ CFLAGS }} -I {{install}}/MetalANGLE.framework/Headers -F {{install}} -framework MetalANGLE -DMETALANGLE") ++ c.env("CXXFLAGS", "{{ CXXFLAGS }} -I {{install}}/MetalANGLE.framework/Headers -F {{install}} -framework MetalANGLE -DMETALANGLE") ++ c.env("CPPFLAGS", "{{ CPPFLAGS }} -I {{install}}/MetalANGLE.framework/Headers -DMETALANGLE") + + @task(kind="python", platforms="ios") + def install(c: Context): +diff --git a/tasks/sdl2.py b/tasks/sdl2.py +index 6756b47..a7d5b01 100644 +--- a/tasks/sdl2.py ++++ b/tasks/sdl2.py +@@ -58,9 +58,19 @@ def build(c: Context): + --disable-libdecor + {% endif %} + +- {% if c.platform == "android" %} ++ {% if c.platform in ("android", "ios") %} + --enable-video-offscreen ++ {% endif %} ++ ++ {% if c.platform == "ios" %} ++ --disable-joystick ++ --disable-sensor ++ --disable-power ++ --disable-haptic ++ --disable-hidapi ++ {% endif %} + ++ {% if c.platform == "android" %} + --disable-oss + --disable-alsa + --disable-esd +@@ -76,19 +86,8 @@ def build(c: Context): + if c.platform == "ios": + with open(c.path("include/SDL_config.h"), "a") as f: + f.write(""" +-/* Enable system power support */ +-#define SDL_POWER_UIKIT 1 +- +-/* enable iPhone keyboard support */ +-#define SDL_IPHONE_KEYBOARD 1 +- +-/* enable iOS extended launch screen */ +-#define SDL_IPHONE_LAUNCHSCREEN 1 +- +-/* Set max recognized G-force from accelerometer +- See src/joystick/uikit/SDL_sysjoystick.m for notes on why this is needed +- */ +-#define SDL_IPHONE_MAX_GFORCE 5.0 ++/* Embedded Ren'Py uses the host's keyboard, app lifecycle and sensors. ++ SDL's UIKit window/controller classes are excluded from this build. */ + + /* enable filesystem support */ + #define SDL_FILESYSTEM_COCOA 1 diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/0007-darwin-ios-build.patch b/bridge/renpy_runtime/mobile_launcher/patches/native/0007-darwin-ios-build.patch new file mode 100644 index 000000000..fc9c67dee --- /dev/null +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/0007-darwin-ios-build.patch @@ -0,0 +1,521 @@ +diff --git a/renpybuild/run.py b/renpybuild/run.py +--- a/renpybuild/run.py ++++ b/renpybuild/run.py +@@ -39,6 +39,29 @@ + + + def llvm(c, bin="", prefix="", suffix="-18", clang_args="", use_ld=True): ++ ++ if sys.platform == "darwin": ++ # Use the selected Xcode installation and its SDKs in place. ++ def tool(name): ++ path = subprocess.check_output(["xcrun", "--find", name], text=True).strip() ++ if any(character.isspace() for character in path): ++ raise RuntimeError("Autoconf toolchain paths must not contain whitespace: " + path) ++ return path ++ ++ if not clang_args: ++ sdk = subprocess.check_output(["xcrun", "--sdk", "macosx", "--show-sdk-path"], text=True).strip() ++ clang_args = f'-isysroot "{sdk}"' ++ clang_args = clang_args.replace("--sysroot ", "-isysroot ") ++ arguments = shlex.split(c.expand(clang_args)) ++ if any(any(character.isspace() for character in argument) for argument in arguments): ++ raise RuntimeError("Autoconf SDK paths must not contain whitespace: " + clang_args) ++ clang_args = " ".join(arguments) ++ c.env("CC", f'ccache {tool("clang")} {clang_args} -std=gnu17') ++ c.env("CXX", f'ccache {tool("clang++")} {clang_args} -std=gnu++17 -stdlib=libc++') ++ c.env("CPP", f'ccache {tool("clang")} {clang_args} -E') ++ for variable, name in (("AR", "ar"), ("RANLIB", "ranlib"), ("STRIP", "strip"), ("NM", "nm")): ++ c.env(variable, tool(name)) ++ return + + if bin: + c.env("PATH", bin + ":{{ PATH }}") +@@ -106,6 +122,8 @@ + if c.platform == "android": + c.var("ndk_version", "android-ndk-r29") + ++ c.native_darwin = sys.platform == "darwin" ++ + cpuccount = os.cpu_count() + + if cpuccount is None: +@@ -116,6 +134,7 @@ + + c.var("make", "nice make -j " + str(cpuccount)) + c.var("configure", "./configure") ++ c.var("config_sub", os.environ.get("AETHERKIRI_RENPY_CONFIG_SUB", "/usr/share/misc/config.sub")) + c.var("cmake_configure", "cmake") + + c.var("sysroot", c.tmp / f"sysroot.{c.platform}-{c.arch}") +@@ -175,9 +204,11 @@ + c.var("sdl_host_platform", "{{ host_platform }}") + + if (c.platform == "ios") and (c.arch == "arm64"): +- c.var("ffi_host_platform", "aarch64-ios-darwin21") ++ # libffi enables Apple's executable trampoline table by host vendor. ++ # The compiler still targets the selected device/Simulator SDK. ++ c.var("ffi_host_platform", "aarch64-apple-darwin21") + elif (c.platform == "ios") and (c.arch == "sim-arm64"): +- c.var("ffi_host_platform", "aarch64-ios-darwin21") ++ c.var("ffi_host_platform", "aarch64-apple-darwin21") + elif (c.platform == "mac") and (c.arch == "arm64"): + c.var("ffi_host_platform", "aarch64-apple-darwin21.6.0") + else: +@@ -192,14 +213,22 @@ + elif (c.platform == "ios") and (c.arch == "sim-x86_64"): + c.env("IPHONEOS_DEPLOYMENT_TARGET", "13.0") + +- c.var("lipo", "llvm-lipo-15") ++ c.var("lipo", "xcrun lipo" if c.native_darwin else "llvm-lipo-15") + + + if c.kind == "host" or c.kind == "host-python" or c.kind == "cross": + + llvm(c) ++ if c.native_darwin: ++ for prefix in os.environ.get("AETHERKIRI_RENPY_HOST_DEPS", "").split(os.pathsep): ++ if not prefix: ++ continue ++ c.env("CPPFLAGS", "{{ CPPFLAGS }} -I" + prefix + "/include") ++ c.env("CFLAGS", "{{ CFLAGS }} -I" + prefix + "/include") ++ c.env("LDFLAGS", "{{ LDFLAGS }} -L" + prefix + "/lib") ++ c.env("PKG_CONFIG_PATH", prefix + "/lib/pkgconfig:{{ PKG_CONFIG_PATH }}") + c.env("LDFLAGS", "{{ LDFLAGS }} -L{{install}}/lib64") +- c.env("PKG_CONFIG_PATH", "{{ install }}/lib/pkgconfig") ++ c.env("PKG_CONFIG_PATH", "{{ install }}/lib/pkgconfig:{{ PKG_CONFIG_PATH }}") + + # c.var("cmake_system_name", "Linux") + # c.var("cmake_system_processor", "x86_64") +@@ -328,7 +355,7 @@ + ) + + c.env("CFLAGS", "{{ CFLAGS }} -DSDL_MAIN_HANDLED -miphoneos-version-min=13.0") +- c.env("LDFLAGS", "{{ LDFLAGS }} -miphoneos-version-min=13.0 -lmockrt") ++ c.env("LDFLAGS", "{{ LDFLAGS }} -miphoneos-version-min=13.0{{ '' if c.native_darwin else ' -lmockrt' }}") + + c.var("cmake_system_name", "Darwin") + c.var("cmake_system_processor", "aarch64") +@@ -342,7 +369,7 @@ + ) + + c.env("CFLAGS", "{{ CFLAGS }} -DSDL_MAIN_HANDLED -mios-simulator-version-min=13.0") +- c.env("LDFLAGS", "{{ LDFLAGS }} -mios-version-min=13.0 -lmockrt") ++ c.env("LDFLAGS", "{{ LDFLAGS }} -mios-version-min=13.0{{ '' if c.native_darwin else ' -lmockrt' }}") + + c.var("cmake_system_name", "Darwin") + c.var("cmake_system_processor", "aarch64") +@@ -356,7 +383,7 @@ + ) + + c.env("CFLAGS", "{{ CFLAGS }} -DSDL_MAIN_HANDLED -mios-simulator-version-min=13.0") +- c.env("LDFLAGS", "{{ LDFLAGS }} -mios-simulator-version-min=13.0 -lmockrt") ++ c.env("LDFLAGS", "{{ LDFLAGS }} -mios-simulator-version-min=13.0{{ '' if c.native_darwin else ' -lmockrt' }}") + + c.var("cmake_system_name", "Darwin") + c.var("cmake_system_processor", "x86_64") +@@ -431,6 +458,11 @@ + c.env("CXXFLAGS", "{{ CFLAGS }}") + + c.var("cmake_args", "-G Ninja {{ cmake_args }} -DCMAKE_PROJECT_INCLUDE_BEFORE={{root}}/tools/cmake_build_variables.cmake -DCMAKE_BUILD_TYPE=Release -DCMAKE_BUILD_PARALLEL_LEVEL=" + str(cpuccount)) ++ if c.native_darwin: ++ # The pinned libyuv project predates CMake 4's policy baseline. ++ # Set the external compatibility policy while preserving its sources. ++ c.var("cmake_args", "{{ cmake_args }} -DCMAKE_POLICY_VERSION_MINIMUM=3.5") ++ + + # Used by zlib. + if c.kind != "host": +diff --git a/tasks/toolchain.py b/tasks/toolchain.py +--- a/tasks/toolchain.py ++++ b/tasks/toolchain.py +@@ -2,6 +2,8 @@ + from renpybuild.task import task + + import os ++import sys ++import subprocess + import requests + + mingw_version = "20241217-ucrt-ubuntu-20.04-x86_64" +@@ -68,8 +70,12 @@ + c.clean("{{ cross }}") + c.chdir("{{ cross }}") + +- c.run("tar xaf {{ tars }}/iPhoneOS14.0.sdk.tar.gz") +- c.run("ln -s iPhoneOS14.0.sdk sdk") ++ if sys.platform == "darwin": ++ sdk = subprocess.check_output(["xcrun", "--sdk", "iphoneos", "--show-sdk-path"], text=True).strip() ++ c.path("sdk").symlink_to(sdk, target_is_directory=True) ++ else: ++ c.run("tar xaf {{ tars }}/iPhoneOS14.0.sdk.tar.gz") ++ c.run("ln -s iPhoneOS14.0.sdk sdk") + + + @task(kind="cross", platforms="ios", archs="sim-arm64,sim-x86_64") +@@ -78,12 +84,21 @@ + c.clean("{{ cross }}") + c.chdir("{{ cross }}") + +- c.run("tar xaf {{ tars }}/iPhoneSimulator14.0.sdk.tar.gz") +- c.run("ln -s iPhoneSimulator14.0.sdk sdk") ++ if sys.platform == "darwin": ++ sdk = subprocess.check_output(["xcrun", "--sdk", "iphonesimulator", "--show-sdk-path"], text=True).strip() ++ c.path("sdk").symlink_to(sdk, target_is_directory=True) ++ else: ++ c.run("tar xaf {{ tars }}/iPhoneSimulator14.0.sdk.tar.gz") ++ c.run("ln -s iPhoneSimulator14.0.sdk sdk") + + + @task(platforms="ios") + def mockrt(c: Context): ++ if sys.platform == "darwin": ++ # Native Xcode supplies the real runtime availability implementation. ++ # Never package the Linux cross-build's always-available shim. ++ c.unlink("{{ install }}/lib/libmockrt.a") ++ return + c.clean() + c.run("{{ CC }} {{ CFLAGS }} -c {{ source }}/mockrt.c") + c.run("mkdir -p {{ install }}/lib") +diff --git a/tasks/hostpython3.py b/tasks/hostpython3.py +--- a/tasks/hostpython3.py ++++ b/tasks/hostpython3.py +@@ -1,5 +1,6 @@ + from renpybuild.context import Context + from renpybuild.task import task ++import shutil + + version = "3.12.8" + +@@ -23,9 +24,15 @@ + + c.run("""{{ make }} install""") + +- c.rmtree("{{ host }}/lib/python3.12/config-3.12-x86_64-linux-gnu/Tools/") +- c.run("install -d {{ host }}/lib/python3.12/config-3.12-x86_64-linux-gnu/Tools/") +- c.run("cp -a Tools/scripts {{ host }}/lib/python3.12/config-3.12-x86_64-linux-gnu/Tools/scripts") ++ configurations = list(c.path("{{ host }}/lib/python3.12").glob("config-3.12-*/")) ++ if not configurations: ++ raise RuntimeError("host Python did not install its configuration directory") ++ for configuration in configurations: ++ destination = configuration / "Tools" / "scripts" ++ if destination.exists(): ++ shutil.rmtree(destination) ++ destination.parent.mkdir(parents=True, exist_ok=True) ++ shutil.copytree(c.path("Tools/scripts"), destination) + + + +diff --git a/tasks/python3.py b/tasks/python3.py +--- a/tasks/python3.py ++++ b/tasks/python3.py +@@ -56,6 +56,11 @@ + + c.chdir("Python-{{ version }}") + c.patch("Python-{{ version }}/ios-posixmodule.diff") ++ ++ # Prefer the explicitly supplied target libffi over Darwin's desktop SDK. ++ c.patch("Python-{{ version }}/aether-external-libffi.diff") ++ c.patch("Python-{{ version }}/aether-posix-functions.diff") ++ c.run("autoreconf -vfi") + + c.run("cp {{patches}}/_scproxy.pyx Modules") + c.chdir("Modules") +@@ -100,18 +104,26 @@ + c.generate("{{ source }}/Python-{{ version }}-Setup.stdlib", "Modules/Setup.stdlib") + c.generate("{{ source }}/Python-{{ version }}-Setup.stdlib", "Modules/Setup") + ++ if c.platform == "ios": ++ # configure already generated a Makefile using its own Setup.stdlib. ++ # Always regenerate the real static object rules and builtin registry ++ # after replacing both templates, even with same-second timestamps. ++ c.run("""sh Modules/makesetup -c Modules/config.c.in -s Modules ++ Modules/Setup.local Modules/Setup.stdlib Modules/Setup.bootstrap ++ Modules/Setup""") ++ c.run("mv config.c Modules/config.c") ++ + c.run("""{{ make }}""") + + c.run("""{{ make }} install""") + + c.copy("{{ host }}/bin/python3", "{{ install }}/bin/hostpython3") + +- for i in [ "_sysconfigdata__linux_x86_64-linux-gnu.py" ]: +- c.var("i", i) +- +- c.copy( +- "{{ host }}/lib/{{pythonver}}/{{ i }}", +- "{{ install }}/lib/{{pythonver}}/{{ i }}") ++ configurations = list(c.path("{{ host }}/lib/{{ pythonver }}").glob("_sysconfigdata_*.py")) ++ if not configurations: ++ raise RuntimeError("host Python sysconfig data is missing") ++ for configuration in configurations: ++ c.copy(str(configuration), "{{ install }}/lib/{{ pythonver }}/" + configuration.name) + + + @task(kind="python", pythons="3", platforms="linux,mac") +@@ -133,13 +145,20 @@ + @task(kind="python", pythons="3", platforms="ios") + def build_ios(c: Context): + common(c) ++ ++ c.env("SDKROOT", str(c.path("{{ cross }}/sdk").resolve())) ++ c.env("LIBFFI_CFLAGS", "-I{{ install }}/include") ++ c.env("LIBFFI_LIBS", "-L{{ install }}/lib -lffi") + + with open(c.path("config.site"), "a") as f: + f.write("ac_cv_little_endian_double=yes\n") + # f.write("ac_cv_header_langinfo_h=no\n") + f.write("ac_cv_func_getentropy=no\n") + f.write("ac_cv_have_long_long_format=yes\n") +- f.write("ac_cv_func_clock_settime=no") ++ f.write("ac_cv_func_clock_settime=no\n") ++ # iOS does not provide the NIS RPC headers. This is CPython's ++ # supported unsupported-module selector, not a fabricated header probe. ++ f.write("py_cv_module_nis=n/a\n") + + c.run(""" + {{configure}} {{ cross_config }} +diff --git a/patches/Python-3.12.8/aether-external-libffi.diff b/patches/Python-3.12.8/aether-external-libffi.diff +new file mode 100644 +--- /dev/null ++++ b/patches/Python-3.12.8/aether-external-libffi.diff +@@ -0,0 +1,13 @@ ++diff --git a/configure.ac b/configure.ac ++--- a/configure.ac +++++ b/configure.ac ++@@ -3840,7 +3840,8 @@ ++ ++ dnl detect libffi ++ have_libffi=missing ++-AS_VAR_IF([ac_sys_system], [Darwin], [ +++dnl Respect explicit libffi flags before Darwin's SDK auto-selection. +++AS_IF([test "$ac_sys_system" = Darwin && test -z "$LIBFFI_CFLAGS$LIBFFI_LIBS"], [ ++ WITH_SAVE_ENV([ ++ CFLAGS="-I${SDKROOT}/usr/include/ffi $CFLAGS" ++ AC_CHECK_HEADER([ffi.h], [ +diff --git a/patches/Python-3.12.8/aether-posix-functions.diff b/patches/Python-3.12.8/aether-posix-functions.diff +new file mode 100644 +--- /dev/null ++++ b/patches/Python-3.12.8/aether-posix-functions.diff +@@ -0,0 +1,38 @@ ++--- a/configure.ac +++++ b/configure.ac ++@@ -4899,7 +4899,7 @@ ++ # checks for library functions ++ AC_CHECK_FUNCS([ \ ++ accept4 alarm bind_textdomain_codeset chmod chown clock close_range confstr \ ++- copy_file_range ctermid dup dup3 execv explicit_bzero explicit_memset \ +++ copy_file_range ctermid dup execv explicit_bzero explicit_memset \ ++ faccessat fchmod fchmodat fchown fchownat fdopendir fdwalk fexecve \ ++ fork fork1 fpathconf fstatat ftime ftruncate futimens futimes futimesat \ ++ gai_strerror getegid getentropy geteuid getgid getgrgid getgrgid_r \ ++@@ -4909,7 +4909,7 @@ ++ getspnam getuid getwd if_nameindex initgroups kill killpg lchown linkat \ ++ lockf lstat lutimes madvise mbrtowc memrchr mkdirat mkfifo mkfifoat \ ++ mknod mknodat mktime mmap mremap nice openat opendir pathconf pause pipe \ ++- pipe2 plock poll posix_fadvise posix_fallocate posix_spawn posix_spawnp \ +++ plock poll posix_fadvise posix_fallocate posix_spawn posix_spawnp \ ++ pread preadv preadv2 pthread_condattr_setclock pthread_init pthread_kill \ ++ pwrite pwritev pwritev2 readlink readlinkat readv realpath renameat \ ++ rtpSpawn sched_get_priority_max sched_rr_get_interval sched_setaffinity \ ++@@ -4937,6 +4937,17 @@ ++ [], ++ [@%:@include ++ @%:@include ]) +++ +++# Simulator libSystem can export symbols absent from the selected SDK's +++# public headers. Check declarations and deployment availability rather +++# than inventing a prototype in AC_CHECK_FUNCS. +++WITH_SAVE_ENV([ +++ AS_VAR_IF([ac_sys_system], [Darwin], [ +++ CFLAGS="$CFLAGS -Werror=unguarded-availability" +++ ]) +++ PY_CHECK_FUNC([dup3], [@%:@include ]) +++ PY_CHECK_FUNC([pipe2], [@%:@include ]) +++]) ++ ++ # For some functions, having a definition is not sufficient, since ++ # we want to take their address. +diff --git a/tasks/librenpy.py b/tasks/librenpy.py +--- a/tasks/librenpy.py ++++ b/tasks/librenpy.py +@@ -12,7 +12,10 @@ + def gen_static3(c: Context): + + c.chdir("{{ renpy }}") +- c.env("RENPY_DEPS_INSTALL", "/usr::/usr/lib/x86_64-linux-gnu/") ++ if c.native_darwin: ++ c.env("RENPY_DEPS_INSTALL", "{{ install }}") ++ else: ++ c.env("RENPY_DEPS_INSTALL", "/usr::/usr/lib/x86_64-linux-gnu/") + c.env("RENPY_STATIC", "1") + c.env("RENPY_REGENERATE_CYTHON", "1") + c.run("{{ hostpython }} setup.py generate") +diff --git a/tasks/renios.py b/tasks/renios.py +--- a/tasks/renios.py ++++ b/tasks/renios.py +@@ -24,7 +24,8 @@ + for d in paths: + fn = d / name + +- p = subprocess.run([ "llvm-otool-15", "-l", f"{fn}"], capture_output=True) ++ command = ["xcrun", "otool"] if sys.platform == "darwin" else ["llvm-otool-15"] ++ p = subprocess.run(command + ["-l", str(fn)], capture_output=True, check=True) + + obj = None + +@@ -73,10 +74,10 @@ + + # debug. + +- paths = [ +- c.path("{{ tmp }}/install.ios-sim-x86_64/lib"), +- c.path("{{ tmp }}/install.ios-sim-arm64/lib"), +- ] ++ # The native arm64 cloud build creates device and arm64 simulator slices. ++ paths = [c.path("{{ tmp }}/install.ios-sim-arm64/lib")] ++ if sys.platform != "darwin": ++ paths.insert(0, c.path("{{ tmp }}/install.ios-sim-x86_64/lib")) + + c.run("install -d {{ renios }}/prototype/prebuilt/debug") + +diff --git a/source/Python-3.12.8-Setup.stdlib b/source/Python-3.12.8-Setup.stdlib +--- a/source/Python-3.12.8-Setup.stdlib ++++ b/source/Python-3.12.8-Setup.stdlib +@@ -100,7 +100,7 @@ + {% endif %} + + # Mac, only. +-{% if (c.kind != "host") and (c.platform == "mac") %} ++{% if ((c.kind != "host") and (c.platform == "mac")) or ((c.kind == "host") and c.native_darwin) %} + _scproxy -DPy_BUILD_CORE_BUILTIN _scproxy.c -framework SystemConfiguration -framework CoreFoundation + {% endif %} + +diff --git a/tools/cmake_build_variables.cmake b/tools/cmake_build_variables.cmake +--- a/tools/cmake_build_variables.cmake ++++ b/tools/cmake_build_variables.cmake +@@ -54,7 +54,7 @@ + # https://cmake.org/cmake/help/latest/variable/CMAKE_LINKER_TYPE.html and + # https://cmake.org/cmake/help/latest/variable/CMAKE_LANG_USING_LINKER_TYPE.html, + # but Ubuntu's current version of camke is too low +-if(CMAKE_VERSION VERSION_GREATER "3.29") ++if(CMAKE_VERSION VERSION_GREATER "3.29" AND NOT CMAKE_HOST_SYSTEM_NAME STREQUAL "Darwin") + set_env_without_string(CC "-fuse-ld=lld -Wno-unused-command-line-argument ") + set_env_without_string(CXX "-fuse-ld=lld -Wno-unused-command-line-argument ") + set_env_without_string(CPP "-fuse-ld=lld -Wno-unused-command-line-argument ") +diff --git a/tasks/fribidi.py b/tasks/fribidi.py +--- a/tasks/fribidi.py ++++ b/tasks/fribidi.py +@@ -17,7 +17,7 @@ + c.var("version", version) + c.chdir("fribidi-{{version}}") + +- c.run("""cp /usr/share/misc/config.sub config.sub""") ++ c.run("""cp {{ config_sub }} config.sub""") + c.run("""{{configure}} {{ cross_config }} --disable-shared --prefix="{{ install }}" """) + c.run("""{{ make }}""") + c.run("""make install """) +diff --git a/tasks/harfbuzz.py b/tasks/harfbuzz.py +--- a/tasks/harfbuzz.py ++++ b/tasks/harfbuzz.py +@@ -23,7 +23,7 @@ + c.var("version", version) + c.chdir("harfbuzz-{{version}}") + +- c.run("""cp /usr/share/misc/config.sub .""") ++ c.run("""cp {{ config_sub }} .""") + + c.run("""{{configure}} {{ cross_config }} + --disable-shared +diff --git a/tasks/sdl2_image.py b/tasks/sdl2_image.py +--- a/tasks/sdl2_image.py ++++ b/tasks/sdl2_image.py +@@ -23,7 +23,7 @@ + if c.platform == "windows": + c.env("ac_cv_lib_jpeg_jpeg_CreateDecompress", "yes") + +- c.run("""cp /usr/share/misc/config.sub config.sub""") ++ c.run("""cp {{ config_sub }} config.sub""") + + # c.run("""./autogen.sh""") + c.run("autoreconf -f") +diff --git a/tasks/zsync.py b/tasks/zsync.py +--- a/tasks/zsync.py ++++ b/tasks/zsync.py +@@ -11,7 +11,7 @@ + c.var("version", version) + c.run("tar xjf {{source}}/zsync-{{version}}.tar.bz2") + +- c.run("""cp /usr/share/misc/config.sub zsync-{{version}}/autotools""") ++ c.run("""cp {{ config_sub }} zsync-{{version}}/autotools""") + + @task(kind="python", platforms="linux") + def build_linux(c: Context): +diff --git a/tasks/libffi.py b/tasks/libffi.py +--- a/tasks/libffi.py ++++ b/tasks/libffi.py +@@ -10,6 +10,8 @@ + + c.var("version", version) + c.run("tar xzf {{source}}/libffi-{{version}}.tar.gz") ++ c.chdir("libffi-{{version}}") ++ c.patchdir("libffi") + + + @task() +diff --git a/patches/libffi/aarch64-cfi.patch b/patches/libffi/aarch64-cfi.patch +new file mode 100644 +--- /dev/null ++++ b/patches/libffi/aarch64-cfi.patch +@@ -0,0 +1,34 @@ ++# Backport libffi/libffi 8308bed5b2423878aa20d7884a99cf2e30b8daf7 (#857). ++# Keep Mach-O entry labels before their CFI region; retain all unwind metadata. ++--- a/src/aarch64/sysv.S +++++ b/src/aarch64/sysv.S ++@@ -89,8 +89,8 @@ ++ x5 closure ++ */ ++ +++CNAME(ffi_call_SYSV): ++ cfi_startproc ++-CNAME(ffi_call_SYSV): ++ BTI_C ++ /* Sign the lr with x1 since that is where it will be stored */ ++ SIGN_LR_WITH_REG(x1) ++@@ -347,8 +347,8 @@ ++ #endif ++ ++ .align 4 +++CNAME(ffi_closure_SYSV): ++ cfi_startproc ++-CNAME(ffi_closure_SYSV): ++ BTI_C ++ SIGN_LR ++ stp x29, x30, [sp, #-ffi_closure_SYSV_FS]! ++@@ -643,8 +643,8 @@ ++ #endif ++ ++ .align 4 +++CNAME(ffi_go_closure_SYSV): ++ cfi_startproc ++-CNAME(ffi_go_closure_SYSV): ++ BTI_C ++ stp x29, x30, [sp, #-ffi_closure_SYSV_FS]! ++ cfi_adjust_cfa_offset (ffi_closure_SYSV_FS) +diff --git a/tasks/metalangle.py b/tasks/metalangle.py +--- a/tasks/metalangle.py ++++ b/tasks/metalangle.py +@@ -7,9 +7,10 @@ + if c.platform == "ios": + # EGL/GLES use normal includes, not framework-prefixed ones. + # SDL's configure must find these same headers before it enables EGL. +- c.env("CFLAGS", "{{ CFLAGS }} -I {{install}}/MetalANGLE.framework/Headers -F {{install}} -framework MetalANGLE -DMETALANGLE") +- c.env("CXXFLAGS", "{{ CXXFLAGS }} -I {{install}}/MetalANGLE.framework/Headers -F {{install}} -framework MetalANGLE -DMETALANGLE") ++ c.env("CFLAGS", "{{ CFLAGS }} -I {{install}}/MetalANGLE.framework/Headers -F {{install}} -DMETALANGLE") ++ c.env("CXXFLAGS", "{{ CXXFLAGS }} -I {{install}}/MetalANGLE.framework/Headers -F {{install}} -DMETALANGLE") + c.env("CPPFLAGS", "{{ CPPFLAGS }} -I {{install}}/MetalANGLE.framework/Headers -DMETALANGLE") ++ c.env("LDFLAGS", "{{ LDFLAGS }} -F {{install}} -framework MetalANGLE") + + @task(kind="python", platforms="ios") + def install(c: Context): diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/0008-android-export-isolation.patch b/bridge/renpy_runtime/mobile_launcher/patches/native/0008-android-export-isolation.patch new file mode 100644 index 000000000..dea0fb4a7 --- /dev/null +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/0008-android-export-isolation.patch @@ -0,0 +1,34 @@ +diff --git a/tasks/renpython.py b/tasks/renpython.py +index 3bf1a3a..afe13e5 100644 +--- a/tasks/renpython.py ++++ b/tasks/renpython.py +@@ -135,12 +135,29 @@ def link_linux(c: Context): + @task(kind="python", always=True, platforms="android") + def link_android(c: Context): + ++ # Ren'Py statically links its own SDL2/FFmpeg/CPython. Bsymbolic protects ++ # internal calls; restrict exports so later host libraries cannot bind to ++ # these private implementations. JNI registration/Java entrypoints remain ++ # visible, including SDL's JNI_OnLoad and pyjnius invocation handlers. ++ c.generate_text(""" ++{ ++ global: ++ renpy_mobile_*; ++ JNI_OnLoad; ++ Java_*; ++ local: ++ *; ++}; ++""", "aether_mobile.exports") ++ + c.run(""" + {{ CXX }} {{ LDFLAGS }} + -shared + -static-libstdc++ + -Wl,-Bsymbolic + -Wl,--no-undefined ++ -Wl,--version-script=aether_mobile.exports ++ -Wl,-soname,librenpython.so + + -o librenpython.so + librenpython_android.o diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/0009-bounded-build-parallelism.patch b/bridge/renpy_runtime/mobile_launcher/patches/native/0009-bounded-build-parallelism.patch new file mode 100644 index 000000000..7c7cbd0e6 --- /dev/null +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/0009-bounded-build-parallelism.patch @@ -0,0 +1,86 @@ +diff --git a/renpybuild/run.py b/renpybuild/run.py +--- a/renpybuild/run.py ++++ b/renpybuild/run.py +@@ -4,7 +4,7 @@ + import subprocess + import sys + import sysconfig +-import threading ++from concurrent.futures import ThreadPoolExecutor + + import jinja2 + +@@ -441,10 +441,9 @@ + traceback.print_stack() + sys.exit(1) + +-class RunCommand(threading.Thread): +- +- def __init__(self, command, context): +- super().__init__() ++class RunCommand: ++ ++ def __init__(self, command, context, executor): + + command = context.expand(command) + self.command = shlex.split(command) +@@ -452,7 +451,7 @@ + self.cwd = context.cwd + self.environ = context.environ.copy() + +- self.start() ++ self.future = executor.submit(self.run) + + + def run(self): +@@ -461,7 +460,7 @@ + self.code = result.returncode + + def wait(self): +- self.join() ++ self.future.result() + + def report(self): + print ("-" * 78) +@@ -485,16 +484,32 @@ + def __init__(self, context): + self.context = context + self.tasks = [ ] ++ requested = context.environ.get("RENPY_BUILD_JOBS") ++ if requested is None: ++ jobs = os.cpu_count() or 1 ++ else: ++ try: ++ jobs = int(requested) ++ except ValueError: ++ raise ValueError("RENPY_BUILD_JOBS must be a positive integer") from None ++ if jobs < 1: ++ raise ValueError("RENPY_BUILD_JOBS must be a positive integer") ++ self.executor = ThreadPoolExecutor(max_workers=jobs) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): ++ try: ++ if exc_type is None: ++ for i in self.tasks: ++ i.wait() ++ finally: ++ # Join every running compiler, including body/spawn failures. ++ # Work not yet started is cancelled rather than left in the queue. ++ self.executor.shutdown(wait=True, cancel_futures=True) + if exc_type is not None: + return +- +- for i in self.tasks: +- i.wait() + + good = [ i for i in self.tasks if i.code == 0 ] + bad = [ i for i in self.tasks if i.code != 0 ] +@@ -511,4 +526,4 @@ + sys.exit(1) + + def run(self, command): +- self.tasks.append(RunCommand(command, self.context)) ++ self.tasks.append(RunCommand(command, self.context, self.executor)) diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/0010-ios-host-python.patch b/bridge/renpy_runtime/mobile_launcher/patches/native/0010-ios-host-python.patch new file mode 100644 index 000000000..9aeb6fa9b --- /dev/null +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/0010-ios-host-python.patch @@ -0,0 +1,192 @@ +diff --git a/renpybuild/ios_host_python.py b/renpybuild/ios_host_python.py +new file mode 100644 +--- /dev/null ++++ b/renpybuild/ios_host_python.py +@@ -0,0 +1,147 @@ ++"""Keep iOS build tools on the host interpreter and package only pure wheels.""" ++ ++from email.parser import BytesParser ++from pathlib import PurePosixPath ++import re ++import shlex ++import shutil ++import zipfile ++ ++ ++_NATIVE_FILE = re.compile(r"\.(?:so(?:\..*)?|dylib|pyd|dll|exe|a|o|obj|lib|bc)$", re.I) ++_NATIVE_MAGIC = ( ++ b"\x7fELF", b"\xfe\xed\xfa\xce", b"\xce\xfa\xed\xfe", ++ b"\xfe\xed\xfa\xcf", b"\xcf\xfa\xed\xfe", b"\xca\xfe\xba\xbe", ++ b"\xbe\xba\xfe\xca", b"\xca\xfe\xba\xbf", b"\xbf\xba\xfe\xca", ++ b"!\n", b"!\n", b"BC\xc0\xde", b"\xde\xc0\x17\x0b", b"MZ", ++) ++# These Windows launcher resources are data in the upstream pure setuptools ++# wheel. They are not native extensions, and pythonlib excludes setuptools. ++_SETUPTOOLS_LAUNCHERS = frozenset( ++ "setuptools/" + kind + suffix + ".exe" ++ for kind in ("cli", "gui") ++ for suffix in ("", "-32", "-64", "-arm64") ++) ++ ++ ++def _run(c, *arguments): ++ # pip's build-backend subprocesses do not inherit the parent's -I flag. ++ # Isolate their Python paths without changing package indexes or proxies. ++ previous_environ = c.environ ++ c.environ = dict(previous_environ) ++ c.environ.pop("PYTHONHOME", None) ++ c.environ.pop("PYTHONPATH", None) ++ try: ++ c.run(shlex.join(str(argument) for argument in arguments)) ++ finally: ++ c.environ = previous_environ ++ ++ ++def _host_tools(c): ++ return c.path("{{ install }}/aether-host-tools") ++ ++ ++def prepare_ios_host_python(c): ++ """Create a target-local host venv and replace the copied host executable.""" ++ if c.platform != "ios": ++ raise ValueError("iOS host tools require an iOS target") ++ ++ host_python = c.path("{{ host }}/bin/python3") ++ # Framework/Homebrew installs can expose only the versioned executable. ++ if not host_python.is_file(): ++ host_python = c.path("{{ host }}/bin/{{ pythonver }}") ++ host_python = host_python.resolve(strict=True) ++ tools = _host_tools(c) ++ _run(c, host_python, "-I", "-m", "venv", "--clear", "--symlinks", tools) ++ tool_python = tools / "bin/python3" ++ if not tool_python.is_symlink() or tool_python.resolve(strict=True) != host_python: ++ raise RuntimeError("iOS host tools must link to the canonical host Python") ++ ++ wrapper = c.path("{{ install }}/bin/hostpython3") ++ wrapper.parent.mkdir(parents=True, exist_ok=True) ++ # Unlink first so an earlier symlink cannot overwrite the canonical host. ++ if wrapper.is_symlink() or wrapper.exists(): ++ wrapper.unlink() ++ wrapper.write_text( ++ "#!/bin/sh\nunset PYTHONHOME PYTHONPATH\nexec " + shlex.quote(str(tool_python)) + ' -I "$@"\n', ++ encoding="utf-8", ++ ) ++ wrapper.chmod(0o755) ++ ++ ++def _verify_pure_wheel(path): ++ with zipfile.ZipFile(path) as archive: ++ files = archive.namelist() ++ wheel_files = [name for name in files ++ if name.endswith(".dist-info/WHEEL") and len(PurePosixPath(name).parts) == 2] ++ if len(wheel_files) != 1: ++ raise RuntimeError("wheel must contain one WHEEL metadata file: " + str(path)) ++ metadata_root = wheel_files[0].removesuffix("WHEEL") ++ wheel = BytesParser().parsebytes(archive.read(wheel_files[0])) ++ if wheel.get_all("Root-Is-Purelib") != ["true"]: ++ raise RuntimeError("iOS dependency is not pure Python: " + str(path)) ++ tags = wheel.get_all("Tag", []) ++ if not tags or any(tag.split("-")[1:] != ["none", "any"] for tag in tags): ++ raise RuntimeError("iOS dependency has a native wheel tag: " + str(path)) ++ if not path.name.endswith("-none-any.whl"): ++ raise RuntimeError("iOS dependency has a native wheel filename: " + str(path)) ++ ++ distribution = BytesParser().parsebytes(archive.read(metadata_root + "METADATA")) ++ setuptools_launchers = ( ++ distribution.get("Name", "").lower() == "setuptools" ++ and distribution.get("Version") == "74.1.2" ++ ) ++ for member in archive.infolist(): ++ name = member.filename ++ if member.is_dir(): ++ continue ++ if name.startswith("/") or ".." in PurePosixPath(name).parts: ++ raise RuntimeError("unsafe wheel member: " + name) ++ with archive.open(member) as stream: ++ header = stream.read(8) ++ if setuptools_launchers and name in _SETUPTOOLS_LAUNCHERS and header.startswith(b"MZ"): ++ continue ++ if _NATIVE_FILE.search(name) or any(header.startswith(magic) for magic in _NATIVE_MAGIC): ++ raise RuntimeError("native file in iOS dependency wheel: " + name) ++ ++ ++def install_ios_python_packages(c, requirements): ++ """Install the same verified pure dependencies for host tools and iOS data.""" ++ if c.platform != "ios": ++ raise ValueError("iOS pure packages require an iOS target") ++ requirements = shlex.split(requirements) if isinstance(requirements, str) else list(requirements) ++ if not requirements: ++ raise ValueError("iOS Python requirements must not be empty") ++ ++ tools = _host_tools(c) ++ python = tools / "bin/python3" ++ if not python.is_file(): ++ raise RuntimeError("iOS host Python has not been prepared") ++ # Python 3.12 venvs include pip, but not setuptools. Build pefile's only ++ # upstream distribution (an sdist) on the host without target compilation. ++ _run(c, python, "-I", "-m", "pip", "install", "--no-compile", "--upgrade", ++ "setuptools==74.1.2", "wheel==0.45.1") ++ wheelhouse = tools / "pure-wheels" ++ if wheelhouse.exists(): ++ shutil.rmtree(wheelhouse) ++ wheelhouse.mkdir() ++ _run(c, python, "-I", "-m", "pip", "wheel", "--no-deps", "--no-build-isolation", ++ "--no-binary=pefile", "--wheel-dir", wheelhouse, "pefile==2022.5.30") ++ _run(c, python, "-I", "-m", "pip", "download", "--platform", "any", ++ "--only-binary=:all:", "--find-links", wheelhouse, "--dest", wheelhouse, ++ *requirements) ++ ++ wheels = sorted(wheelhouse.glob("*.whl")) ++ if not wheels: ++ raise RuntimeError("no pure iOS dependency wheels were collected") ++ for wheel in wheels: ++ _verify_pure_wheel(wheel) ++ ++ # Explicit verified wheel inputs plus --no-deps prevent either installation ++ # from resolving additional unverified or host-native distributions. ++ _run(c, python, "-I", "-m", "pip", "install", "--no-deps", "--no-compile", ++ "--upgrade", *wheels) ++ target_site = c.path("{{ install }}/lib/{{ pythonver }}/site-packages") ++ _run(c, python, "-I", "-m", "pip", "install", "--no-deps", "--no-compile", ++ "--upgrade", "--platform", "any", "--only-binary=:all:", ++ "--target", target_site, *wheels) +diff --git a/tasks/python3.py b/tasks/python3.py +--- a/tasks/python3.py ++++ b/tasks/python3.py +@@ -118,7 +118,11 @@ + + c.run("""{{ make }} install""") + +- c.copy("{{ host }}/bin/python3", "{{ install }}/bin/hostpython3") ++ if c.platform == "ios": ++ from renpybuild.ios_host_python import prepare_ios_host_python ++ prepare_ios_host_python(c) ++ else: ++ c.copy("{{ host }}/bin/python3", "{{ install }}/bin/hostpython3") + + configurations = list(c.path("{{ host }}/lib/{{ pythonver }}").glob("_sysconfigdata_*.py")) + if not configurations: +@@ -257,8 +261,7 @@ + + @task(kind="python", pythons="3", platforms="all") + def pip(c: Context): +- c.run("{{ install }}/bin/hostpython3 -s -m ensurepip") +- c.run("""{{ install }}/bin/hostpython3 -s -m pip install --no-compile --upgrade ++ requirements = """ + future==1.0.0 + six==1.16.0 + rsa==4.9 +@@ -274,4 +277,12 @@ + websockets==12.0 + setuptools==74.1.2 + pysocks==1.7.1 +- """) ++ """ ++ if c.platform == "ios": ++ from renpybuild.ios_host_python import install_ios_python_packages ++ install_ios_python_packages(c, requirements) ++ return ++ ++ c.run("{{ install }}/bin/hostpython3 -s -m ensurepip") ++ c.run("""{{ install }}/bin/hostpython3 -s -m pip install --no-compile --upgrade ++ {{ requirements }}""", requirements=requirements) diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/0011-assimp-owned-array-copy.patch b/bridge/renpy_runtime/mobile_launcher/patches/native/0011-assimp-owned-array-copy.patch new file mode 100644 index 000000000..f65572325 --- /dev/null +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/0011-assimp-owned-array-copy.patch @@ -0,0 +1,205 @@ +diff --git a/patches/assimp-owned-arrays.diff b/patches/assimp-owned-arrays.diff +new file mode 100644 +--- /dev/null ++++ b/patches/assimp-owned-arrays.diff +@@ -0,0 +1,189 @@ ++diff --git a/code/Common/SceneCombiner.cpp b/code/Common/SceneCombiner.cpp ++index 88fa497..c7289b9 100644 ++--- a/code/Common/SceneCombiner.cpp +++++ b/code/Common/SceneCombiner.cpp ++@@ -63,6 +63,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ++ #include ++ #include ++ #include +++#include ++ #include ++ ++ namespace Assimp { ++@@ -988,6 +989,8 @@ inline void CopyPtrArray(Type **&dest, const Type *const *src, ai_uint num) { ++ // ------------------------------------------------------------------------------------------------ ++ template ++ inline void GetArrayCopy(Type *&dest, ai_uint num) { +++ static_assert(std::is_trivially_copyable::value, +++ "GetArrayCopy requires a trivially copyable element type"); ++ if (!dest) { ++ return; ++ } ++@@ -1103,10 +1106,12 @@ void SceneCombiner::Copy(aiMesh **_dest, const aiMesh *src) { ++ CopyPtrArray(dest->mBones, dest->mBones, dest->mNumBones); ++ ++ // make a deep copy of all faces ++- GetArrayCopy(dest->mFaces, dest->mNumFaces); ++- for (unsigned int i = 0; i < dest->mNumFaces; ++i) { ++- aiFace &f = dest->mFaces[i]; ++- GetArrayCopy(f.mIndices, f.mNumIndices); +++ if (src->mFaces) { +++ dest->mFaces = new aiFace[dest->mNumFaces]; +++ for (unsigned int i = 0; i < dest->mNumFaces; ++i) { +++ // aiFace assignment owns a deep copy of its index array. +++ dest->mFaces[i] = src->mFaces[i]; +++ } ++ } ++ ++ // make a deep copy of all blend shapes ++@@ -1251,12 +1256,20 @@ void SceneCombiner::Copy(aiMeshMorphAnim **_dest, const aiMeshMorphAnim *src) { ++ *dest = *src; ++ ++ // and reallocate all arrays ++- GetArrayCopy(dest->mKeys, dest->mNumKeys); ++- for (ai_uint i = 0; i < dest->mNumKeys; ++i) { ++- dest->mKeys[i].mValues = new unsigned int[dest->mKeys[i].mNumValuesAndWeights]; ++- dest->mKeys[i].mWeights = new double[dest->mKeys[i].mNumValuesAndWeights]; ++- ::memcpy(dest->mKeys[i].mValues, src->mKeys[i].mValues, dest->mKeys[i].mNumValuesAndWeights * sizeof(unsigned int)); ++- ::memcpy(dest->mKeys[i].mWeights, src->mKeys[i].mWeights, dest->mKeys[i].mNumValuesAndWeights * sizeof(double)); +++ if (src->mKeys) { +++ dest->mKeys = new aiMeshMorphKey[dest->mNumKeys]; +++ for (ai_uint i = 0; i < dest->mNumKeys; ++i) { +++ const aiMeshMorphKey &source = src->mKeys[i]; +++ aiMeshMorphKey &key = dest->mKeys[i]; +++ key.mTime = source.mTime; +++ key.mNumValuesAndWeights = source.mNumValuesAndWeights; +++ if (key.mNumValuesAndWeights) { +++ key.mValues = new unsigned int[key.mNumValuesAndWeights]; +++ key.mWeights = new double[key.mNumValuesAndWeights]; +++ ::memcpy(key.mValues, source.mValues, key.mNumValuesAndWeights * sizeof(unsigned int)); +++ ::memcpy(key.mWeights, source.mWeights, key.mNumValuesAndWeights * sizeof(double)); +++ } +++ } ++ } ++ } ++ ++diff --git a/contrib/Open3DGC/o3dgcCommon.h b/contrib/Open3DGC/o3dgcCommon.h ++index ff6bf75..a39b1e9 100644 ++--- a/contrib/Open3DGC/o3dgcCommon.h +++++ b/contrib/Open3DGC/o3dgcCommon.h ++@@ -163,8 +163,18 @@ namespace o3dgc ++ { ++ public: ++ SC3DMCStats(void) +++ : m_timeCoord(0), +++ m_timeNormal(0), +++ m_timeCoordIndex(0), +++ m_timeFloatAttribute{}, +++ m_timeIntAttribute{}, +++ m_timeReorder(0), +++ m_streamSizeCoord(0), +++ m_streamSizeNormal(0), +++ m_streamSizeCoordIndex(0), +++ m_streamSizeFloatAttribute{}, +++ m_streamSizeIntAttribute{} ++ { ++- memset(this, 0, sizeof(SC3DMCStats)); ++ }; ++ ~SC3DMCStats(void){}; ++ ++diff --git a/contrib/Open3DGC/o3dgcIndexedFaceSet.h b/contrib/Open3DGC/o3dgcIndexedFaceSet.h ++index adb8cb0..9e03e21 100644 ++--- a/contrib/Open3DGC/o3dgcIndexedFaceSet.h +++++ b/contrib/Open3DGC/o3dgcIndexedFaceSet.h ++@@ -35,13 +35,35 @@ namespace o3dgc ++ public: ++ //! Constructor. ++ IndexedFaceSet(void) +++ : m_nCoordIndex(0), +++ m_coordIndex(nullptr), +++ m_indexBufferID(nullptr), +++ m_nCoord(0), +++ m_nNormal(0), +++ m_coordMin{}, +++ m_coordMax{}, +++ m_normalMin{}, +++ m_normalMax{}, +++ m_coord(nullptr), +++ m_normal(nullptr), +++ m_numFloatAttributes(0), +++ m_numIntAttributes(0), +++ m_typeFloatAttribute{}, +++ m_typeIntAttribute{}, +++ m_nFloatAttribute{}, +++ m_nIntAttribute{}, +++ m_dimFloatAttribute{}, +++ m_dimIntAttribute{}, +++ m_minFloatAttribute{}, +++ m_maxFloatAttribute{}, +++ m_floatAttribute{}, +++ m_intAttribute{}, +++ m_creaseAngle(30), +++ m_ccw(true), +++ m_solid(true), +++ m_convex(true), +++ m_isTriangularMesh(true) ++ { ++- memset(this, 0, sizeof(IndexedFaceSet)); ++- m_ccw = true; ++- m_solid = true; ++- m_convex = true; ++- m_isTriangularMesh = true; ++- m_creaseAngle = 30; ++ }; ++ //! Destructor. ++ ~IndexedFaceSet(void) {}; ++diff --git a/contrib/Open3DGC/o3dgcSC3DMCEncodeParams.h b/contrib/Open3DGC/o3dgcSC3DMCEncodeParams.h ++index 5f3db96..969580c 100644 ++--- a/contrib/Open3DGC/o3dgcSC3DMCEncodeParams.h +++++ b/contrib/Open3DGC/o3dgcSC3DMCEncodeParams.h ++@@ -34,14 +34,18 @@ namespace o3dgc ++ public: ++ //! Constructor. ++ SC3DMCEncodeParams(void) +++ : m_numFloatAttributes(0), +++ m_numIntAttributes(0), +++ m_coordQuantBits(14), +++ m_normalQuantBits(8), +++ m_floatAttributeQuantBits{}, +++ m_coordPredMode(O3DGC_SC3DMC_PARALLELOGRAM_PREDICTION), +++ m_normalPredMode(O3DGC_SC3DMC_SURF_NORMALS_PREDICTION), +++ m_floatAttributePredMode{}, +++ m_intAttributePredMode{}, +++ m_streamTypeMode(O3DGC_STREAM_TYPE_ASCII), +++ m_encodeMode(O3DGC_SC3DMC_ENCODE_MODE_TFAN) ++ { ++- memset(this, 0, sizeof(SC3DMCEncodeParams)); ++- m_encodeMode = O3DGC_SC3DMC_ENCODE_MODE_TFAN; ++- m_streamTypeMode = O3DGC_STREAM_TYPE_ASCII; ++- m_coordQuantBits = 14; ++- m_normalQuantBits = 8; ++- m_coordPredMode = O3DGC_SC3DMC_PARALLELOGRAM_PREDICTION; ++- m_normalPredMode = O3DGC_SC3DMC_SURF_NORMALS_PREDICTION; ++ for(unsigned long a = 0; a < O3DGC_SC3DMC_MAX_NUM_FLOAT_ATTRIBUTES; ++a) ++ { ++ m_floatAttributePredMode[a] = O3DGC_SC3DMC_DIFFERENTIAL_PREDICTION; ++diff --git a/contrib/Open3DGC/o3dgcTimer.h b/contrib/Open3DGC/o3dgcTimer.h ++index f5ed0c8..ca2085f 100644 ++--- a/contrib/Open3DGC/o3dgcTimer.h +++++ b/contrib/Open3DGC/o3dgcTimer.h ++@@ -80,8 +80,8 @@ namespace o3dgc ++ { ++ public: ++ Timer(void) +++ : m_cclock{}, m_start{}, m_stop{} ++ { ++- memset(this, 0, sizeof(Timer)); ++ host_get_clock_service(mach_host_self(), CALENDAR_CLOCK, & m_cclock); ++ }; ++ ~Timer(void) ++@@ -110,8 +110,8 @@ namespace o3dgc ++ { ++ public: ++ Timer(void) +++ : m_start{}, m_stop{} ++ { ++- memset(this, 0, sizeof(Timer)); ++ }; ++ ~Timer(void){}; ++ void Tic() +diff --git a/tasks/assimp.py b/tasks/assimp.py +--- a/tasks/assimp.py ++++ b/tasks/assimp.py +@@ -10,6 +10,7 @@ + + c.chdir("assimp") + c.patch("assimp.diff") ++ c.patch("assimp-owned-arrays.diff") + + @task(platforms="all") + def build(c : Context): diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/0012-freetype-host-tools.patch b/bridge/renpy_runtime/mobile_launcher/patches/native/0012-freetype-host-tools.patch new file mode 100644 index 000000000..8693c75ce --- /dev/null +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/0012-freetype-host-tools.patch @@ -0,0 +1,102 @@ +diff --git a/renpybuild/freetype_host_tools.py b/renpybuild/freetype_host_tools.py +new file mode 100644 +--- /dev/null ++++ b/renpybuild/freetype_host_tools.py +@@ -0,0 +1,55 @@ ++"""Isolate FreeType's native export generator from the target toolchain.""" ++from pathlib import Path ++import re ++import shlex ++import shutil ++import subprocess ++import sys ++ ++ ++_CROSS_DEPLOYMENT = ( ++ "IPHONEOS_DEPLOYMENT_TARGET", "TVOS_DEPLOYMENT_TARGET", ++ "WATCHOS_DEPLOYMENT_TARGET", "XROS_DEPLOYMENT_TARGET", ++ "DRIVERKIT_DEPLOYMENT_TARGET", "SDKROOT", ++) ++ ++ ++def host_make_arguments(c): ++ """Use FreeType's CC_BUILD and CCexe hooks without changing target flags.""" ++ environment = dict(c.environ) ++ for variable in _CROSS_DEPLOYMENT: ++ environment.pop(variable, None) ++ ++ def output(arguments): ++ return subprocess.check_output(arguments, env=environment, text=True).strip() ++ ++ flags = [] ++ if sys.platform == "darwin": ++ compiler = Path(output(["xcrun", "--sdk", "macosx", "--find", "clang"])) ++ sdk = Path(output(["xcrun", "--sdk", "macosx", "--show-sdk-path"])).resolve(strict=True) ++ flags = ["-isysroot", str(sdk)] ++ triple = output([str(compiler), *flags, "-dumpmachine"]) ++ if not re.search(r"-apple-(?:darwin|macos)", triple): ++ raise RuntimeError("FreeType's host compiler must target native macOS: " + triple) ++ flags = ["-target", triple, *flags] ++ else: ++ selected = shutil.which("cc", path=environment.get("PATH")) ++ if not selected: ++ raise RuntimeError("FreeType requires a genuine native C compiler") ++ compiler = Path(selected) ++ ++ wrapper = c.path("{{ build }}/aether-freetype-host-cc") ++ if any(character.isspace() for character in str(wrapper)): ++ raise RuntimeError("FreeType's Autoconf host compiler path must not contain whitespace") ++ wrapper.write_text( ++ "#!/bin/sh\nunset " + " ".join(_CROSS_DEPLOYMENT) + "\nexec " ++ + shlex.join([str(compiler), *flags]) + ' "$@"\n', encoding="utf-8", ++ ) ++ wrapper.chmod(0o755) ++ c.env("CC_BUILD", str(wrapper)) ++ ++ # configure can reset CC_BUILD to CC when a target conftest runs locally. ++ # Command-line make assignments override unix-cc.mk's CCexe := assignment. ++ # apinames uses only the native C library; target include/link flags do not ++ # belong in its CCexe_CFLAGS or CCexe_LDFLAGS. ++ return shlex.join(["CCexe=" + str(wrapper), "CCexe_CFLAGS=", "CCexe_LDFLAGS="]) +diff --git a/tasks/freetype.py b/tasks/freetype.py +--- a/tasks/freetype.py ++++ b/tasks/freetype.py +@@ -1,5 +1,6 @@ + from renpybuild.context import Context + from renpybuild.task import task, annotator ++from renpybuild.freetype_host_tools import host_make_arguments + + version = "2.13.3" + +@@ -26,6 +27,8 @@ + c.var("version", version) + c.chdir("freetype-{{version}}") + ++ host_tools = host_make_arguments(c) ++ + c.run("""{{configure}} {{ cross_config }} --disable-shared --prefix="{{ install }}" --with-harfbuzz=no""") +- c.run("""{{ make }}""") +- c.run("""make install""") ++ c.run("""{{ make }} {{ host_tools }}""", host_tools=host_tools) ++ c.run("""make install {{ host_tools }}""", host_tools=host_tools) +diff --git a/tasks/freetypehb.py b/tasks/freetypehb.py +--- a/tasks/freetypehb.py ++++ b/tasks/freetypehb.py +@@ -1,5 +1,6 @@ + from renpybuild.context import Context + from renpybuild.task import task, annotator ++from renpybuild.freetype_host_tools import host_make_arguments + + version = "2.13.3" + +@@ -26,6 +27,8 @@ + c.var("version", version) + c.chdir("freetype-{{version}}") + ++ host_tools = host_make_arguments(c) ++ + c.run("""{{configure}} {{ cross_config }} --disable-shared --prefix="{{ install }}" --with-harfbuzz=yes""") +- c.run("""{{ make }}""") +- c.run("""make install""") ++ c.run("""{{ make }} {{ host_tools }}""", host_tools=host_tools) ++ c.run("""make install {{ host_tools }}""", host_tools=host_tools) diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/README.md b/bridge/renpy_runtime/mobile_launcher/patches/native/README.md new file mode 100644 index 000000000..bc903b4b3 --- /dev/null +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/README.md @@ -0,0 +1,150 @@ +# Native lifecycle patch + +The source runner applies these patches to the pinned SDL2/Python3 +renpy-build revision, then copies `renpy_mobile_lifecycle.c` and the versioned +ABI header into that checkout. It compiles the shared native lifecycle and +linked greenlet for Android and every selected iOS target. The platform +bootstrap initializes CPython without calling the process launchers. + +`0001-renpy-build-link.patch` also includes the static Cython output directory +when compiling Ren'Py's C and C++ sources. Its generated surface API header +lives in `tmp/gen3-static`, so a clean rebuild does not depend on a previous +desktop generation having populated `tmp/gen3`. + +`0005-optional-cubism.patch` keeps the base runtime build independent of the +optional licensed SDKs. It enables Live2D generation and its native module only +when the target Cubism header exists, and packages `steamapi` only when the +Steam task actually generated that module. Games that require Live2D still +need the Cubism SDK and its runtime library; supplying the header preserves the +upstream compilation path. No native build tasks are marked complete by this +patch. + +`0007-darwin-ios-build.patch` builds the complete iOS dependency chain on a +macOS cloud runner using the selected Xcode SDKs in place. The source runner +selects device arm64 and simulator arm64, and packages those slices under the +existing `release` and `debug` directories. Install Homebrew `autoconf`, +`autoconf-archive`, `automake`, `libtool`, `pkg-config`, `ccache`, `cmake`, +`ninja`, `uv`, `python@3.12`, `openssl@3`, `xz`, `bzip2`, `libffi`, `gnu-tar` +and `coreutils`. Put the GNU tar and coreutils `libexec/gnubin` directories on +PATH, select Xcode, then run the source runner's `prepare --fetch` and `build` +modes with `--platform ios`. The runner derives host dependency prefixes and +`config.sub` from the installed Homebrew packages. It requires actual Xcode +tools, all three local SDKs and 64 GiB of free disk; it does not substitute +prerequisite fixtures. The port still needs a successful native macOS build +and simulator gameplay before it establishes iOS runtime support. +The Autoconf compiler environment uses unquoted tool and SDK paths from Xcode; +the cloud build requires these paths to contain no whitespace and rejects +unsupported paths explicitly. Quoted path text inside `CC` is interpreted as +part of the executable name by the pinned configure scripts. CI preserves their +`config.log` files alongside the source-build log when compilation fails. + +The Darwin port also backports libffi's upstream fix +[`8308bed5`](https://github.com/libffi/libffi/commit/8308bed5b2423878aa20d7884a99cf2e30b8daf7) +to the pinned 3.4.5 source archive. Three arm64 entry labels precede +`cfi_startproc`, as required by LLVM 17 and later when assembling Mach-O. +Unwind metadata remains enabled. The unpack task applies this patch before +any target compilation; it does not replace the pinned libffi dependency. +The MetalANGLE annotator keeps its header paths and definitions in compiler +flags and supplies `-framework MetalANGLE` through linker flags. Compile-only +dependency builds retain their strict warning checks. + +The iOS CPython task disables optional NIS using the supported +`py_cv_module_nis=n/a` configure selector because the iOS SDK does not provide +its RPC headers. Host Python and the other target policies retain their normal +module detection. The regression runs genuine CPython configure and checks its +generated module rules; on macOS it also probes both actual iOS SDKs. + +The same task checks `dup3` and `pipe2` through CPython's header-based +`PY_CHECK_FUNC` probes, with Darwin deployment availability warnings treated +as errors. Simulator libraries can export these symbols without public SDK +declarations; a successful symbol-only link probe cannot enable their use. +Absent APIs retain CPython's existing `dup2` and `pipe` fallbacks. The genuine +host and target SDK regression also compiles `Modules/posixmodule.o` through +its actual generated Makefile, without launching the target interpreter. + +`0009-bounded-build-parallelism.patch` limits the upstream native object build +group to the host CPU count. Set `RENPY_BUILD_JOBS` to a positive integer to +select another limit. Child compiler failures still fail the build, and an +exception cancels queued work and joins running child processes. This limits +concurrent compiler memory use; it does not change source-build prerequisites. + +`0010-ios-host-python.patch` keeps iOS build tools on the canonical host +CPython. Copying its executable into a target prefix selects the target +standard library and dynamic modules, which the host cannot load. Each target +instead gets a build-only virtual environment linked to the original host +interpreter, plus an isolated `hostpython3` wrapper used by package installation, +Ren'Py source generation and bytecode compilation. Host tools clear inherited +`PYTHONHOME` and `PYTHONPATH`; package indexes and proxy settings remain intact. + +The original Python dependency versions are installed into the target's +`site-packages` from verified pure wheels. The pinned `pefile` release has only +a source distribution, so the host builds its pure wheel first. The wheel gate +checks platform tags and archive contents, rejecting native extensions and +executables except the eight known Windows launcher resources in the upstream +`setuptools==74.1.2` pure wheel. Those resources are build-tool data, and the +existing Python payload whitelist excludes setuptools. The host virtual +environment lies outside the packaged Python and native archive roots. +The preparation regression executes real pip installation, the pinned +`setuplib` import and actual bytecode compilation under conflicting Python +path variables. It does not compile or execute an iOS runtime. +Both ordinary and versioned-only framework executable layouts resolve to +the real host interpreter; no active target interpreter is used as a fallback. + +`0011-assimp-owned-array-copy.patch` preserves Assimp 5.4.3 and its strict +compiler checks. SceneCombiner uses `aiFace`'s deep assignment and explicitly +copies morph-key values and weights into independent arrays. Its generic +byte-copy helper accepts only trivially copyable elements. Four bundled +Open3DGC constructors initialize their members instead of clearing whole +nontrivial objects; their prior defaults and platform clock operations remain. +The normal Assimp download task applies this source patch after the existing +upstream patch. The preparation regression runs the actual host download and +GLTF/STL build recipe with Clang 21, then links and executes the real library's +copy operations and Open3DGC constructor checks. This catches both ownership +and current compiler problems before the complete device/Simulator build. +Host execution does not establish iOS target compilation or gameplay. + +`0012-freetype-host-tools.patch` builds FreeType's `apinames` export generator +with a separate native compiler. On Darwin its wrapper explicitly selects the +macOS target and selected Xcode macOS SDK, clearing cross deployment settings +only for that child. Both FreeType recipes override the host tool's make and +install variables while retaining the target compiler and flags. The genuine +FreeType task regression checks executed export generation and every archive +object's CPU and platform; it does not execute target libraries or gameplay. +See [the regression instructions](../../../../../tools/renpy_freetype_acceptance/README.md). + +`renpy_mobile_lifecycle.c` owns the copied host callback table and the borrowed +frame byte storage. It invokes the full cooperative Python entrypoints, +preserves pointer/key/text actions, publishes renderer screenshots and +returns observable normal-exit and text-input state. Window binding connects +the real SDL renderer window to the Android EGL context handoff. + +Host pause stops PERIODIC, REDRAW and TIMEEVENT, and clears only their queued +events. Resume restarts PERIODIC and wakes the suspended event wait. The saved +interaction invalidates its timer scheduling caches and rearms its original +redraw and timeout deadlines without restarting the interaction or resetting +the renderer. Repeated pause and resume calls are idempotent. Real background +and resume gameplay acceptance remains required. + +Terminal shutdown runs on the interpreter's owning thread while its healthy +private context is current. The cooperative patch stops the engine timers, +retires GL2 Program destructors before dropping cached references, and destroys +the Python-owned SDL window, context and pbuffer. Programs retained by script +stores or texture loaders cannot later delete a host GL program during garbage +collection. SDL hint override keeps `AETHER_RENPY_EMBEDDED` true throughout the +window and video teardown; the existing SDL patch skips `eglTerminate` on the +shared display. Normal bootstrap renderer teardown is not repeated. + +Shutdown is terminal: another Ren'Py session requires restarting the host. +If its private EGL context cannot be made current or Python cleanup fails, +native reports the failure, retains the session and clears host callbacks. +It never substitutes the current host context for deleting Ren'Py resources. +Recovery from genuine EGL context/display loss remains unimplemented; this +does not imply an ordinary host Surface replacement destroys the private +pbuffer. Real long-background, rotation and context-loss stress acceptance +is still required. + +These sources need actual target compilation and cloud-device gameplay +acceptance. A successful host compilation or exported-symbol check is not +mobile playability evidence. iOS startup requires the patched offscreen +MetalANGLE driver; successful renderer probes do not establish full Ren'Py +gameplay, input, pause/resume or shutdown acceptance. diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/renpy_mobile_lifecycle.c b/bridge/renpy_runtime/mobile_launcher/patches/native/renpy_mobile_lifecycle.c new file mode 100644 index 000000000..ff14214b5 --- /dev/null +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/renpy_mobile_lifecycle.c @@ -0,0 +1,370 @@ +/* In-process lifecycle for the patched Ren'Py launcher. Rendering and events + * remain on Ren'Py's owning thread inside its stackful cooperative loop. + * OpenGL windows have no SDL software surface: cooperative_frame reads the + * renderer's screenshot before yielding to Godot. */ +#include +#include +#include +#include +#include "renpy_mobile_launcher.h" +#if defined(__APPLE__) +#include +#endif +#if defined(__APPLE__) && TARGET_OS_IPHONE && defined(METALANGLE) +#include +#include +#endif +#if defined(__ANDROID__) || (defined(__APPLE__) && TARGET_OS_IPHONE && defined(METALANGLE)) +#define AETHER_MOBILE_EGL 1 +#include +#endif + +static renpy_mobile_host_t aether_host; +static PyObject *aether_main_module; +static PyObject *aether_frame_bytes; +static unsigned long aether_owner_thread; +static int aether_started; +static int aether_paused; +static void *aether_window; +#if defined(AETHER_MOBILE_EGL) +static EGLDisplay aether_display = EGL_NO_DISPLAY; +static EGLContext aether_context = EGL_NO_CONTEXT; +static EGLSurface aether_draw_surface = EGL_NO_SURFACE; +static EGLSurface aether_read_surface = EGL_NO_SURFACE; +typedef struct graphics_scope { + EGLDisplay display; + EGLContext context; + EGLSurface draw_surface; + EGLSurface read_surface; + EGLenum api; +} graphics_scope; +static int begin_graphics(graphics_scope *scope) { + scope->display = eglGetCurrentDisplay(); + scope->context = eglGetCurrentContext(); + scope->draw_surface = eglGetCurrentSurface(EGL_DRAW); + scope->read_surface = eglGetCurrentSurface(EGL_READ); + scope->api = eglQueryAPI(); + if (aether_context != EGL_NO_CONTEXT) { + eglBindAPI(EGL_OPENGL_ES_API); + if (!eglMakeCurrent(aether_display, aether_draw_surface, aether_read_surface, aether_context)) { + const EGLint error = eglGetError(); + if (aether_host.log_utf8) { + char detail[160]; + snprintf(detail, sizeof(detail), + "Ren'Py private EGL context could not be made current (EGL error 0x%04x); context-loss recovery is unavailable", + (unsigned int)error); + aether_host.log_utf8(aether_host.user_data, 3, detail); + } + eglBindAPI(scope->api); + return RENPY_MOBILE_ERROR; + } + } + return RENPY_MOBILE_OK; +} +static int end_graphics(const graphics_scope *scope) { + EGLBoolean restored = EGL_TRUE; + if (scope->display != EGL_NO_DISPLAY) { + eglBindAPI(scope->api); + restored = eglMakeCurrent(scope->display, scope->draw_surface, scope->read_surface, scope->context); + } else { + EGLDisplay display = aether_display != EGL_NO_DISPLAY ? aether_display : eglGetCurrentDisplay(); + if (display != EGL_NO_DISPLAY) restored = eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT); + eglBindAPI(scope->api); + } + if (!restored && aether_host.log_utf8) aether_host.log_utf8(aether_host.user_data, 3, + "Ren'Py failed to restore the host EGL context after a lifecycle call"); + return restored ? RENPY_MOBILE_OK : RENPY_MOBILE_ERROR; +} +#else +typedef struct graphics_scope { int unused; } graphics_scope; +static int begin_graphics(graphics_scope *scope) { (void)scope; return RENPY_MOBILE_OK; } +static int end_graphics(const graphics_scope *scope) { (void)scope; return RENPY_MOBILE_OK; } +#endif + +int renpy_mobile_bind_window(void *window) { + if (PyThread_get_thread_ident() != aether_owner_thread) return RENPY_MOBILE_INVALID_ARGUMENT; + aether_window = window; +#if defined(AETHER_MOBILE_EGL) + if (!window) { + aether_display = EGL_NO_DISPLAY; + aether_context = EGL_NO_CONTEXT; + aether_draw_surface = EGL_NO_SURFACE; + aether_read_surface = EGL_NO_SURFACE; + return RENPY_MOBILE_OK; + } + aether_display = eglGetCurrentDisplay(); + aether_context = eglGetCurrentContext(); + aether_draw_surface = eglGetCurrentSurface(EGL_DRAW); + aether_read_surface = eglGetCurrentSurface(EGL_READ); + if (aether_display == EGL_NO_DISPLAY || aether_context == EGL_NO_CONTEXT || + aether_draw_surface == EGL_NO_SURFACE) return RENPY_MOBILE_INVALID_STATE; +#endif + return RENPY_MOBILE_OK; +} + +static PyObject *host_bind_window(PyObject *self, PyObject *args) { + unsigned long long pointer; + (void)self; + if (!PyArg_ParseTuple(args, "K", &pointer)) return NULL; + return PyLong_FromLong(renpy_mobile_bind_window((void *)(uintptr_t)pointer)); +} +static PyMethodDef host_methods[] = { + {"bind_window", host_bind_window, METH_VARARGS, "Bind the actual offscreen Ren'Py renderer window."}, + {NULL, NULL, 0, NULL} +}; +static struct PyModuleDef host_module = { + PyModuleDef_HEAD_INIT, "_aether_host", NULL, -1, host_methods, + NULL, NULL, NULL, NULL +}; +PyMODINIT_FUNC PyInit__aether_host(void) { return PyModule_Create(&host_module); } + +static void report_python_error(const char *operation) { + PyObject *type = NULL, *value = NULL, *traceback = NULL; + PyErr_Fetch(&type, &value, &traceback); + PyErr_NormalizeException(&type, &value, &traceback); + PyObject *text = value ? PyObject_Str(value) : NULL; + const char *detail = text ? PyUnicode_AsUTF8(text) : NULL; + if (aether_host.log_utf8) { + aether_host.log_utf8(aether_host.user_data, 3, operation); + if (detail) aether_host.log_utf8(aether_host.user_data, 3, detail); + } + Py_XDECREF(text); + Py_XDECREF(type); + Py_XDECREF(value); + Py_XDECREF(traceback); + PyErr_Clear(); +} + +static int check_owner(void) { + if (!aether_started || !Py_IsInitialized()) return RENPY_MOBILE_INVALID_STATE; + if (PyThread_get_thread_ident() != aether_owner_thread) { + if (aether_host.log_utf8) aether_host.log_utf8(aether_host.user_data, 3, + "Ren'Py lifecycle called from a different thread than init"); + return RENPY_MOBILE_INVALID_STATE; + } + return RENPY_MOBILE_OK; +} + +static int call_noargs(const char *name) { + int status = check_owner(); + if (status != RENPY_MOBILE_OK) return status; + graphics_scope graphics; + status = begin_graphics(&graphics); + if (status != RENPY_MOBILE_OK) return status; + PyGILState_STATE gil = PyGILState_Ensure(); + PyObject *value = PyObject_CallMethod(aether_main_module, name, NULL); + if (!value) { report_python_error(name); status = RENPY_MOBILE_ERROR; } + Py_XDECREF(value); + PyGILState_Release(gil); + if (end_graphics(&graphics) != RENPY_MOBILE_OK) status = RENPY_MOBILE_ERROR; + return status; +} + +int renpy_mobile_init(const renpy_mobile_config_t *config, + const renpy_mobile_host_t *host) { + if (!config || !host || config->struct_size < sizeof(*config) || + host->struct_size < sizeof(*host) || + config->abi_version != RENPY_MOBILE_LAUNCHER_ABI_VERSION || + host->abi_version != RENPY_MOBILE_LAUNCHER_ABI_VERSION || + !config->game_root_utf8 || !config->game_root_utf8[0]) return RENPY_MOBILE_INVALID_ARGUMENT; + if (aether_started) return RENPY_MOBILE_INVALID_STATE; + if (!Py_IsInitialized()) return RENPY_MOBILE_INVALID_STATE; +#if defined(__APPLE__) && TARGET_OS_IPHONE +#if !defined(METALANGLE) + if (host->log_utf8) host->log_utf8(host->user_data, 3, + "Ren'Py iOS runtime was built without MetalANGLE EGL support; the UIKit video backend cannot share the host window"); + return RENPY_MOBILE_NOT_IMPLEMENTED; +#else + /* Query compiled drivers without initializing SDL video: the actual + renderer is created inside the first cooperative tick on this thread. */ + int has_offscreen = 0; + for (int index = 0; index < SDL_GetNumVideoDrivers(); ++index) { + const char *driver = SDL_GetVideoDriver(index); + if (driver && !strcmp(driver, "offscreen")) has_offscreen = 1; + } + if (!has_offscreen) { + if (host->log_utf8) host->log_utf8(host->user_data, 3, + "Ren'Py iOS runtime does not contain SDL's offscreen EGL pbuffer driver; rebuild the patched SDL library"); + return RENPY_MOBILE_NOT_IMPLEMENTED; + } + const char *selected_driver = getenv("SDL_VIDEODRIVER"); + if (!selected_driver || strcmp(selected_driver, "offscreen")) { + if (host->log_utf8) host->log_utf8(host->user_data, 3, + "Ren'Py iOS bootstrap did not select SDL_VIDEODRIVER=offscreen; UIKit video startup is refused"); + return RENPY_MOBILE_NOT_IMPLEMENTED; + } +#endif +#endif + + /* The caller is allowed to pass a temporary callback table. */ + aether_host = *host; + PyGILState_STATE gil = PyGILState_Ensure(); + aether_main_module = PyImport_ImportModule("renpy.aether_mobile"); + PyObject *started = NULL; + if (aether_main_module) started = PyObject_CallMethod(aether_main_module, + "cooperative_start", "ss", config->game_root_utf8, + config->argv0_utf8 ? config->argv0_utf8 : "AetherKiri"); + if (!started) { + report_python_error("Ren'Py cooperative initialization failed"); + Py_CLEAR(aether_main_module); + memset(&aether_host, 0, sizeof(aether_host)); + PyGILState_Release(gil); + return RENPY_MOBILE_ERROR; + } + Py_DECREF(started); + aether_owner_thread = PyThread_get_thread_ident(); + aether_started = 1; + aether_paused = 0; + PyGILState_Release(gil); + return RENPY_MOBILE_OK; +} + +int renpy_mobile_tick(uint32_t budget_ms) { + int status = check_owner(); + if (status != RENPY_MOBILE_OK || aether_paused) return status; + graphics_scope graphics; + status = begin_graphics(&graphics); + if (status != RENPY_MOBILE_OK) return status; + PyGILState_STATE gil = PyGILState_Ensure(); + PyObject *result = PyObject_CallMethod(aether_main_module, "cooperative_tick", "I", budget_ms); + if (!result) { report_python_error("Ren'Py cooperative tick failed"); status = RENPY_MOBILE_ERROR; } + else if (PyUnicode_Check(result)) { + const char *value = PyUnicode_AsUTF8(result); + if (!value || !strcmp(value, "not_started")) status = RENPY_MOBILE_INVALID_STATE; + else if (!strcmp(value, "finished")) status = RENPY_MOBILE_FINISHED; + else if (strcmp(value, "yield")) status = RENPY_MOBILE_ERROR; + } else status = RENPY_MOBILE_ERROR; + Py_XDECREF(result); + PyGILState_Release(gil); + if (end_graphics(&graphics) != RENPY_MOBILE_OK) status = RENPY_MOBILE_ERROR; + return status; +} + +int renpy_mobile_frame(renpy_mobile_frame_t *out_frame) { + if (!out_frame || out_frame->struct_size < sizeof(*out_frame)) return RENPY_MOBILE_INVALID_ARGUMENT; + int status = check_owner(); + if (status != RENPY_MOBILE_OK) return status; + PyGILState_STATE gil = PyGILState_Ensure(); + PyObject *frame = PyObject_CallMethod(aether_main_module, "cooperative_frame", NULL); + if (!frame) { report_python_error("Ren'Py renderer screenshot failed"); status = RENPY_MOBILE_ERROR; } + else if (frame == Py_None) status = RENPY_MOBILE_INVALID_STATE; + else { + PyObject *pixels = NULL; + unsigned int width = 0, height = 0; + unsigned long long serial = 0; + if (!PyArg_ParseTuple(frame, "OIIK", &pixels, &width, &height, &serial) || + !PyBytes_Check(pixels) || !width || !height || width > UINT32_MAX / 4u || + (uint64_t)width * height * 4u > (uint64_t)PY_SSIZE_T_MAX || + PyBytes_GET_SIZE(pixels) != (Py_ssize_t)((uint64_t)width * height * 4u)) { + PyErr_Clear(); status = RENPY_MOBILE_ERROR; + } else { + Py_INCREF(pixels); + Py_XSETREF(aether_frame_bytes, pixels); + out_frame->rgba = (const uint8_t *)PyBytes_AS_STRING(aether_frame_bytes); + out_frame->width = width; + out_frame->height = height; + out_frame->stride = width * 4u; + out_frame->serial = serial; + } + } + Py_XDECREF(frame); + PyGILState_Release(gil); + return status; +} + +int renpy_mobile_input(const renpy_mobile_input_t *event) { + if (!event || event->struct_size < sizeof(*event)) return RENPY_MOBILE_INVALID_ARGUMENT; + int status = check_owner(); + if (status != RENPY_MOBILE_OK) return status; + PyGILState_STATE gil = PyGILState_Ensure(); + /* Python owns text before control can yield, unlike a borrowed SDL3 + * text-event pointer. Event kinds retain press/release/scroll semantics. */ + PyObject *input = Py_BuildValue("{s:I,s:i,s:i,s:i,s:f,s:f,s:f,s:f,s:I,s:I,s:s,s:K}", + "type", event->type, "value", event->value, "device_id", event->device_id, + "code", event->code, "x", event->x, "y", event->y, + "delta_x", event->delta_x, "delta_y", event->delta_y, + "modifiers", event->modifiers, "repeat", event->repeat, + "text", event->text_utf8 ? event->text_utf8 : "", + "timestamp_ns", (unsigned long long)event->timestamp_ns); + PyObject *result = input ? PyObject_CallMethod(aether_main_module, "cooperative_input", "O", input) : NULL; + if (!result) { report_python_error("Ren'Py input delivery failed"); status = RENPY_MOBILE_ERROR; } + Py_XDECREF(result); + Py_XDECREF(input); + PyGILState_Release(gil); + return status; +} + +int renpy_mobile_pause(void) { + int status = call_noargs("cooperative_pause"); + if (status == RENPY_MOBILE_OK) aether_paused = 1; + return status; +} + +int renpy_mobile_resume(void) { + int status = call_noargs("cooperative_resume"); + if (status == RENPY_MOBILE_OK) aether_paused = 0; + return status; +} + +int renpy_mobile_text_input_state(uint32_t *active) { + if (!active) return RENPY_MOBILE_INVALID_ARGUMENT; + int status = check_owner(); + if (status != RENPY_MOBILE_OK) return status; + PyGILState_STATE gil = PyGILState_Ensure(); + PyObject *result = PyObject_CallMethod(aether_main_module, "cooperative_text_input_state", NULL); + if (!result) { report_python_error("Ren'Py text input state failed"); status = RENPY_MOBILE_ERROR; } + else { + int truth = PyObject_IsTrue(result); + if (truth < 0) { report_python_error("Ren'Py text input state failed"); status = RENPY_MOBILE_ERROR; } + else *active = truth ? 1u : 0u; + } + Py_XDECREF(result); + PyGILState_Release(gil); + return status; +} + +int renpy_mobile_set_surface_size(uint32_t width, uint32_t height) { + if (!width || !height) return RENPY_MOBILE_INVALID_ARGUMENT; + int status = check_owner(); + if (status != RENPY_MOBILE_OK) return status; + PyGILState_STATE gil = PyGILState_Ensure(); + PyObject *result = PyObject_CallMethod(aether_main_module, "cooperative_set_surface_size", "II", width, height); + if (!result) { report_python_error("Ren'Py surface resize failed"); status = RENPY_MOBILE_ERROR; } + Py_XDECREF(result); + PyGILState_Release(gil); + return status; +} + +void renpy_mobile_shutdown(void) { + if (!aether_started) return; + if (check_owner() != RENPY_MOBILE_OK) { + // Destruction on the wrong thread cannot switch a greenlet, but must + // never retain callbacks whose user_data is about to be destroyed. + memset(&aether_host, 0, sizeof(aether_host)); + return; + } + if (call_noargs("cooperative_stop") != RENPY_MOBILE_OK) { + /* A failed graphics scope must never dispose Python-owned GL objects + * with the host context current. Retain the stopped-or-suspended + * session and refuse reuse; only the process can reclaim it safely. */ + if (aether_host.log_utf8) aether_host.log_utf8(aether_host.user_data, 3, + "Ren'Py terminal cleanup failed; retained runtime resources require a host application restart"); + memset(&aether_host, 0, sizeof(aether_host)); + return; + } + PyGILState_STATE gil = PyGILState_Ensure(); + Py_CLEAR(aether_frame_bytes); + Py_CLEAR(aether_main_module); + memset(&aether_host, 0, sizeof(aether_host)); + aether_started = 0; + aether_paused = 0; + aether_owner_thread = 0; + aether_window = NULL; +#if defined(AETHER_MOBILE_EGL) + aether_display = EGL_NO_DISPLAY; + aether_context = EGL_NO_CONTEXT; + aether_draw_surface = EGL_NO_SURFACE; + aether_read_surface = EGL_NO_SURFACE; +#endif + PyGILState_Release(gil); +} diff --git a/bridge/renpy_runtime/mobile_launcher/patches/python/0001-cooperative-loop-skeleton.patch b/bridge/renpy_runtime/mobile_launcher/patches/python/0001-cooperative-loop-skeleton.patch new file mode 100644 index 000000000..59e779997 --- /dev/null +++ b/bridge/renpy_runtime/mobile_launcher/patches/python/0001-cooperative-loop-skeleton.patch @@ -0,0 +1,832 @@ +diff --git a/renpy/bootstrap.py b/renpy/bootstrap.py +index 4d3b125..33f2cf7 100644 +--- a/renpy/bootstrap.py ++++ b/renpy/bootstrap.py +@@ -445,7 +445,7 @@ def bootstrap(renpy_base): + if not renpy.emscripten: + subprocess.Popen.__del__ = popen_del # type: ignore + +- if renpy.android: ++ if renpy.android and not renpy.aether_mobile.cooperative_active(): + from jnius import autoclass # type: ignore + + import android +diff --git a/renpy/display/core.py b/renpy/display/core.py +index 89c74d1..458fc7b 100644 +--- a/renpy/display/core.py ++++ b/renpy/display/core.py +@@ -32,6 +32,7 @@ import platform + + import renpy + import renpy.pygame as pygame ++import renpy.aether_mobile as aether_mobile + + # Imports for backward compatibility. + from renpy.display.position import absolute as absolute, position as position +@@ -1301,6 +1302,9 @@ class Interface: + self.surftree = surftree + self.fullscreen_video = fullscreen_video + ++ if draw: ++ aether_mobile.cooperative_capture(surftree) ++ + if self.first_frame: + self.after_first_frame() + self.first_frame = False +@@ -1630,7 +1634,14 @@ class Interface: + + self.check_background_screenshot() + +- if renpy.emscripten: ++ if aether_mobile.cooperative_active(): ++ while True: ++ ev = pygame.event.poll() ++ if ev.type != pygame.NOEVENT: ++ break ++ aether_mobile.cooperative_checkpoint(force=True) ++ ++ elif renpy.emscripten: + emscripten.sleep(0) + + while True: +@@ -1918,6 +1929,9 @@ class Interface: + GL context is created properly. + """ + ++ if aether_mobile.cooperative_active(): ++ return ++ + from jnius import autoclass + + SDLActivity = autoclass("org.libsdl.app.SDLActivity") +@@ -1936,6 +1950,10 @@ class Interface: + Handles the SDL2 suspend process. + """ + ++ if aether_mobile.cooperative_active(): ++ # The host owns background/resume and saves/pauses through the ABI. ++ return False ++ + if ev.type != pygame.APP_WILLENTERBACKGROUND: + return False + +@@ -2756,6 +2774,7 @@ class Interface: + + old_timeout_time = None + old_redraw_time = None ++ aether_timer_generation = aether_mobile.cooperative_timer_generation() + + rv = None + +@@ -2774,6 +2793,13 @@ class Interface: + can_block = False + + while rv is None: ++ aether_mobile.cooperative_checkpoint() ++ if aether_timer_generation != aether_mobile.cooperative_timer_generation(): ++ # Host pause canceled SDL timers while this stack stayed ++ # suspended. Rearm the existing deadlines below even when ++ # the redraw/timeout times themselves have not changed. ++ old_timeout_time = old_redraw_time = None ++ aether_timer_generation = aether_mobile.cooperative_timer_generation() + renpy.plog(1, "start of interact while loop") + + renpy.execution.not_infinite_loop(10) +diff --git a/renpy/display/tts.py b/renpy/display/tts.py +index 34f6576..1a49ea5 100644 +--- a/renpy/display/tts.py ++++ b/renpy/display/tts.py +@@ -83,7 +83,10 @@ class AndroidTTS(object): + + PythonSDLActivity = autoclass("org.renpy.android.PythonSDLActivity") + self.TextToSpeech = autoclass("android.speech.tts.TextToSpeech") +- self.tts = self.TextToSpeech(PythonSDLActivity.mActivity, None) ++ activity = PythonSDLActivity.mActivity ++ if renpy.aether_mobile.cooperative_active(): ++ activity = activity.getActivity() ++ self.tts = self.TextToSpeech(activity, None) + + def speak(self, s): + self.tts.speak(s, self.TextToSpeech.QUEUE_FLUSH, None) +diff --git a/renpy/execution.py b/renpy/execution.py +index 9da4b1e..375ae55 100644 +--- a/renpy/execution.py ++++ b/renpy/execution.py +@@ -609,6 +609,8 @@ class Context(renpy.object.Object): + first = True + + while node: ++ # Bound non-interactive Ren'Py AST loops as well as UI waits. ++ renpy.aether_mobile.cooperative_checkpoint() + if node.name == self.come_from_name: + self.come_from_name = None + node = self.call(self.come_from_label, return_site=node.name) +@@ -1047,6 +1049,8 @@ def run_context(top): + + try: + try: ++ import renpy.aether_mobile ++ renpy.aether_mobile.cooperative_checkpoint() + context.run() + + rv = renpy.store._return +diff --git a/renpy/gl2/gl2draw.pyx b/renpy/gl2/gl2draw.pyx +index cb66af0..3762a48 100644 +--- a/renpy/gl2/gl2draw.pyx ++++ b/renpy/gl2/gl2draw.pyx +@@ -364,7 +364,9 @@ cdef class GL2Draw: + renpy.display.log.write("GL Disabled.") + return False + +- if renpy.mobile: ++ if renpy.aether_mobile.cooperative_active(): ++ physical_size = renpy.aether_mobile.cooperative_surface_size() or virtual_size ++ elif renpy.mobile: + physical_size = (None, None) + elif renpy.game.preferences.physical_size is None: + physical_size = (renpy.config.physical_width, renpy.config.physical_height) +@@ -373,7 +375,9 @@ cdef class GL2Draw: + + pwidth, pheight = self.select_physical_size(physical_size) + +- if renpy.android or renpy.ios: ++ if renpy.aether_mobile.cooperative_active(): ++ fullscreen = False ++ elif renpy.android or renpy.ios: + fullscreen = True + elif renpy.emscripten: + fullscreen = False +@@ -409,14 +413,16 @@ cdef class GL2Draw: + window_flags = pygame.OPENGL | pygame.DOUBLEBUF + + if renpy.android: +- pwidth = 0 +- pheight = 0 ++ if not renpy.aether_mobile.cooperative_active(): ++ pwidth = 0 ++ pheight = 0 + gles = True + + elif renpy.ios: + window_flags |= pygame.WINDOW_ALLOW_HIGHDPI | pygame.RESIZABLE +- pwidth = 0 +- pheight = 0 ++ if not renpy.aether_mobile.cooperative_active(): ++ pwidth = 0 ++ pheight = 0 + gles = True + + else: +diff --git a/renpy/pygame/display.pyx b/renpy/pygame/display.pyx +index 85d16d0..c8bda49 100644 +--- a/renpy/pygame/display.pyx ++++ b/renpy/pygame/display.pyx +@@ -64,6 +64,14 @@ def _get_hint(hint, default): + return default + + ++def prepare_mobile_shutdown(): ++ # SDL_DestroyWindow unloads EGL too. The patched embedded SDL must keep ++ # the shared host display alive even if the process environment says 0. ++ hint("AETHER_RENPY_EMBEDDED", "1", priority=SDL_HINT_OVERRIDE) ++ if not SDL_GetHintBoolean(b"AETHER_RENPY_EMBEDDED", SDL_FALSE): ++ raise error("Could not protect the host EGL display during Ren'Py shutdown") ++ ++ + main_done = False + + def sdl_main_init(): +@@ -207,7 +215,9 @@ cdef class Window: + if self.gl_context == NULL: + raise error() + +- SDL_GL_MakeCurrent(self.window, self.gl_context) ++ if SDL_GL_MakeCurrent(self.window, self.gl_context) != 0: ++ raise error() ++ self.aether_bind_gl_window() + + if not ios: + # Try setting the swap interval - first positive, then negated +@@ -262,8 +272,22 @@ cdef class Window: + This should be called before the window is deleted. + """ + +- if self.gl_context != NULL: +- SDL_GL_DeleteContext(self.gl_context) ++ if self.window == NULL and self.gl_context == NULL: ++ return ++ ++ if renpy.aether_mobile.cooperative_active(): ++ prepare_mobile_shutdown() ++ import _aether_host ++ if _aether_host.bind_window(0) != 0: ++ raise error("Could not unbind the Ren'Py renderer window") ++ ++ cdef SDL_GLContext context = self.gl_context ++ cdef SDL_Window *window = self.window ++ self.gl_context = NULL ++ self.window = NULL ++ ++ if context != NULL: ++ SDL_GL_DeleteContext(context) + + if self.surface: + +@@ -274,7 +298,8 @@ cdef class Window: + # Necessary to collect the GL surface, doesn't hurt the window surface. + self.surface = None + +- SDL_DestroyWindow(self.window) ++ if window != NULL: ++ SDL_DestroyWindow(window) + + def resize(self, size, opengl=False, fullscreen=None, maximized=None): + """ +@@ -334,8 +359,20 @@ cdef class Window: + if self.gl_context == NULL: + raise error() + ++ if self.gl_context: ++ if SDL_GL_MakeCurrent(self.window, self.gl_context) != 0: ++ raise error() ++ self.aether_bind_gl_window() + self.create_surface() + ++ def aether_bind_gl_window(self): ++ # Bind before any engine checkpoint can return to the host, including ++ # initialization before the first frame and context recreation. ++ if renpy.aether_mobile.cooperative_active(): ++ import _aether_host ++ if _aether_host.bind_window(self.window) != 0: ++ raise error("Could not bind the Ren'Py renderer window") ++ + def recreate_gl_context(self, always=False): + """ + Check to see if the GL context was lost, and re-create it if it was. +@@ -350,7 +387,9 @@ cdef class Window: + if self.gl_context == NULL: + raise error() + +- SDL_GL_MakeCurrent(self.window, self.gl_context) ++ if SDL_GL_MakeCurrent(self.window, self.gl_context) != 0: ++ raise error() ++ self.aether_bind_gl_window() + + return True + +diff --git a/renpy/pygame/key.pyx b/renpy/pygame/key.pyx +index ca6b0ba..faa13bf 100644 +--- a/renpy/pygame/key.pyx ++++ b/renpy/pygame/key.pyx +@@ -17,6 +17,7 @@ + # 3. This notice may not be removed or altered from any source distribution. + + from sdl2 cimport * ++import renpy + from libc.stdlib cimport malloc, free + from libc.string cimport memcpy + from renpy.pygame.display cimport Window, main_window +@@ -49,6 +50,8 @@ cdef class KeyboardState: + memcpy(self.data, state, self.numkeys) + + def __getitem__(self, int key): ++ if renpy.aether_mobile.cooperative_active(): ++ return renpy.aether_mobile.cooperative_key_pressed(key) + if SDLK_DELETE < key < SDLK_CAPSLOCK: + raise IndexError("Out of range.") + +@@ -60,6 +63,8 @@ cdef class KeyboardState: + + + def get_focused(): ++ if renpy.aether_mobile.cooperative_active(): ++ return True + return SDL_GetKeyboardFocus() != NULL + + def get_pressed(): +@@ -69,9 +74,14 @@ def get_pressed(): + return KeyboardState() + + def get_mods(): ++ if renpy.aether_mobile.cooperative_active(): ++ return renpy.aether_mobile.cooperative_modifiers() + return SDL_GetModState() + + def set_mods(state): ++ if renpy.aether_mobile.cooperative_active(): ++ renpy.aether_mobile.cooperative_set_modifiers(state) ++ return + SDL_SetModState(state) + + def set_repeat(delay=0, interval=0): +diff --git a/renpy/pygame/mouse.pyx b/renpy/pygame/mouse.pyx +index aa95777..6936194 100644 +--- a/renpy/pygame/mouse.pyx ++++ b/renpy/pygame/mouse.pyx +@@ -17,6 +17,7 @@ + # 3. This notice may not be removed or altered from any source distribution. + + from sdl2 cimport * ++import renpy + from renpy.pygame.display cimport main_window + from renpy.pygame.surface cimport Surface + +@@ -34,12 +35,16 @@ def reset(): + active_cursor = None + + def get_pressed(): ++ if renpy.aether_mobile.cooperative_active(): ++ return renpy.aether_mobile.cooperative_mouse_pressed() + cdef Uint32 state = SDL_GetMouseState(NULL, NULL) + return (1 if state & SDL_BUTTON_LMASK else 0, + 1 if state & SDL_BUTTON_MMASK else 0, + 1 if state & SDL_BUTTON_RMASK else 0) + + def get_pos(): ++ if renpy.aether_mobile.cooperative_active(): ++ return renpy.aether_mobile.cooperative_mouse_pos() + cdef int x, y + SDL_GetMouseState(&x, &y) + return (x, y) +@@ -60,6 +65,8 @@ def set_visible(visible): + SDL_SetRelativeMouseMode(SDL_FALSE if visible else SDL_TRUE) + + def get_focused(): ++ if renpy.aether_mobile.cooperative_active(): ++ return True + return SDL_GetMouseFocus() != NULL + + def set_cursor(size, hotspot, xormasks, andmasks): +diff --git a/renpy/pygame/surface.pyx b/renpy/pygame/surface.pyx +index 889c908..bfd94f3 100644 +--- a/renpy/pygame/surface.pyx ++++ b/renpy/pygame/surface.pyx +@@ -18,6 +18,7 @@ + # 3. This notice may not be removed or altered from any source distribution. + + from libc.string cimport memmove ++from cpython.bytes cimport PyBytes_FromStringAndSize, PyBytes_AS_STRING + from sdl2 cimport * + + from renpy.pygame.color cimport map_color, get_color +@@ -835,6 +836,17 @@ cdef class Surface: + def get_view(self, kind='2'): + raise error("Surface.get_view is not supported.") + ++ def aether_copy_pixels(self): ++ """Copy visible rows, excluding Surface pitch and subsurface padding.""" ++ cdef int row_bytes = self.surface.w * self.surface.format.BytesPerPixel ++ cdef bytes result = PyBytes_FromStringAndSize(NULL, row_bytes * self.surface.h) ++ cdef char *target = PyBytes_AS_STRING(result) ++ cdef char *source = self.surface.pixels ++ cdef int row ++ for row in range(self.surface.h): ++ memmove(target + row * row_bytes, source + row * self.surface.pitch, row_bytes) ++ return result ++ + def get_buffer(self): + cdef Uint8 *pixels = self.surface.pixels + return pixels[self.surface.h * self.surface.pitch] +diff --git a/renpy/aether_mobile.py b/renpy/aether_mobile.py +new file mode 100644 +index 0000000..b1910bb +--- /dev/null ++++ b/renpy/aether_mobile.py +@@ -0,0 +1,386 @@ ++"""Host-owned Ren'Py loop; all SDL/GL work stays on the calling thread. ++ ++The native launcher links greenlet with its target CPython. No exception is ++used to yield: the complete script, screen and Cython stacks remain suspended. ++""" ++import importlib.util ++import os ++import sys ++import threading ++import time ++ ++try: ++ from _aether_greenlet import getcurrent, greenlet ++except ImportError: ++ getcurrent = greenlet = None ++ ++_owner = None ++_host = None ++_game = None ++_deadline = None ++_frame = None ++_serial = 0 ++_paused = False ++_timer_generation = 0 ++_stopping = False ++_terminal_cleanup_done = False ++_error = None ++_input = [] ++_buttons = [False, False, False] ++_pointer = (0, 0) ++_surface_size = None ++_keys = {} ++_modifiers = 0 ++ ++ ++def cooperative_active(): ++ return _game is not None ++ ++ ++def _check_owner(): ++ if _owner is not None and threading.get_ident() != _owner: ++ raise RuntimeError("Ren'Py mobile SDL/GL must run on its original host thread") ++ ++ ++def cooperative_start(game_root, argv0): ++ """Create a suspended game. Initialization starts inside the first tick.""" ++ global _owner, _host, _game, _paused, _stopping, _error, _frame, _serial ++ if greenlet is None: ++ raise RuntimeError("Target CPython is missing the linked greenlet runtime") ++ if _terminal_cleanup_done: ++ raise RuntimeError("Ren'Py interpreter reuse is unavailable; restart the host application") ++ if _game is not None: ++ raise RuntimeError("Ren'Py mobile game is already started") ++ if not os.path.isdir(game_root) or not os.path.isfile(argv0): ++ raise ValueError("game directory or packaged Ren'Py bootstrap is missing") ++ _owner = threading.get_ident() ++ _host = getcurrent() ++ _paused = _stopping = False ++ _error = _frame = None ++ _serial = 0 ++ _game = greenlet(lambda: _run(game_root, argv0), parent=_host) ++ ++ ++def _run(game_root, argv0): ++ global _error ++ saved_argv = sys.argv ++ try: ++ # Use the official distributor path functions and full bootstrap, ++ # including import_all, importer, init script, preferences and saves. ++ sys.argv = [argv0, game_root] ++ spec = importlib.util.spec_from_file_location("_aether_renpy_entry", argv0) ++ entry = importlib.util.module_from_spec(spec) ++ sys.modules[spec.name] = entry ++ spec.loader.exec_module(entry) ++ entry.main() ++ except SystemExit as exc: ++ if exc.code not in (None, 0): ++ _error = "Ren'Py exited with status %r" % (exc.code,) ++ except BaseException: ++ import traceback ++ _error = traceback.format_exc() ++ finally: ++ sys.argv = saved_argv ++ sys.modules.pop("_aether_renpy_entry", None) ++ return "finished" if _error is None else "error" ++ ++ ++def cooperative_tick(budget_ms): ++ global _deadline ++ _check_owner() ++ if _game is None: ++ return "not_started" ++ if _game.dead: ++ if _error: ++ raise RuntimeError(_error) ++ return "finished" ++ if _paused: ++ return "yield" ++ _deadline = time.monotonic() + max(0, budget_ms) / 1000.0 ++ try: ++ _game.switch() ++ finally: ++ _deadline = None ++ if _error: ++ raise RuntimeError(_error) ++ return "finished" if _game.dead else "yield" ++ ++ ++def cooperative_checkpoint(force=False): ++ """Suspend at an engine boundary without unwinding the game stack.""" ++ if not cooperative_active() or getcurrent() is not _game: ++ return ++ if _stopping: ++ import renpy.game ++ raise renpy.game.QuitException() ++ if force or (_deadline is not None and time.monotonic() >= _deadline): ++ _host.switch("yield") ++ if _stopping: ++ import renpy.game ++ raise renpy.game.QuitException() ++ _dispatch_input() ++ ++ ++def cooperative_capture(surftree): ++ global _frame, _serial ++ if not cooperative_active(): ++ return ++ import renpy ++ import renpy.pygame as pygame ++ surface = renpy.display.draw.screenshot(surftree) ++ if surface.get_bytesize() != 4: ++ raise RuntimeError("Ren'Py screenshot must contain RGBA pixels") ++ # The official GL2 screenshot writes RGBA bytes and flips to top-left. ++ # Ren'Py pygame deliberately has no image.tostring/get_view API. ++ pixels = surface.aether_copy_pixels() ++ width, height = surface.get_size() ++ if len(pixels) != width * height * 4: ++ raise RuntimeError("Invalid packed Ren'Py frame size") ++ _serial += 1 ++ _frame = (pixels, width, height, _serial) ++ window = pygame.display.get_window() ++ if window is not None: ++ # Binding uses the actual renderer window, never a synthetic surface. ++ import _aether_host ++ if _aether_host.bind_window(window.get_sdl_window_pointer().value) != 0: ++ raise RuntimeError("Could not bind Ren'Py renderer window") ++ cooperative_checkpoint(force=True) ++ ++ ++def cooperative_set_surface_size(width, height): ++ """Select the initial pbuffer; the host scales frames after viewport resize.""" ++ global _surface_size ++ _check_owner() ++ if width <= 0 or height <= 0: ++ raise ValueError("Invalid host surface size") ++ if _frame is None: ++ _surface_size = (int(width), int(height)) ++ ++ ++def cooperative_surface_size(): ++ return _surface_size ++ ++ ++def cooperative_frame(): ++ _check_owner() ++ return _frame ++ ++ ++def cooperative_input(event): ++ _check_owner() ++ if _game is None or _game.dead: ++ raise RuntimeError("Ren'Py mobile game is not running") ++ # Copy borrowed native text/event storage before returning to the host. ++ _input.append(dict(event)) ++ ++ ++def _dispatch_input(): ++ global _pointer, _modifiers ++ if not _input: ++ return ++ import renpy ++ import renpy.pygame as pygame ++ queued = list(_input) ++ del _input[:] ++ for item in queued: ++ kind, action = item["type"], item["value"] ++ if kind == 3: ++ text = item.get("text", "") ++ if text: ++ pygame.event.post(pygame.event.Event(pygame.TEXTINPUT, text=text)) ++ elif kind == 1: ++ key = item["code"] ++ if action == 8: ++ key = pygame.K_ESCAPE ++ action = 5 ++ _keys[key] = action == 5 ++ _modifiers = item.get("modifiers", 0) ++ pygame.event.post(pygame.event.Event( ++ pygame.KEYDOWN if action == 5 else pygame.KEYUP, ++ key=key, mod=item.get("modifiers", 0), unicode="", ++ repeat=bool(item.get("repeat", False)))) ++ elif kind == 2: ++ # Frames contain the rendered game tree, excluding window letterbox. ++ # Convert normalized game content through GL2's actual viewport; ++ # the normal mouse_event path translates this back to virtual pixels. ++ pos = renpy.display.draw.untranslate_point( ++ item["x"] * renpy.config.screen_width, ++ item["y"] * renpy.config.screen_height) ++ button = item.get("code", 1) or 1 ++ if action in (1, 3): ++ if 1 <= button <= 3: ++ _buttons[button - 1] = action == 1 ++ pygame.event.post(pygame.event.Event( ++ pygame.MOUSEBUTTONDOWN if action == 1 else pygame.MOUSEBUTTONUP, ++ pos=pos, button=button, which=4294967295, touch=True)) ++ elif action == 2: ++ rel = (pos[0] - _pointer[0], pos[1] - _pointer[1]) ++ pygame.event.post(pygame.event.Event( ++ pygame.MOUSEMOTION, pos=pos, rel=rel, ++ buttons=tuple(_buttons), which=4294967295, touch=True)) ++ elif action == 4: ++ wheel_y = item.get("delta_y", 0) ++ if pygame.event.get_mousewheel_buttons(): ++ if wheel_y: ++ wheel_button = 4 if wheel_y > 0 else 5 ++ for event_type in (pygame.MOUSEBUTTONDOWN, pygame.MOUSEBUTTONUP): ++ pygame.event.post(pygame.event.Event( ++ event_type, pos=pos, button=wheel_button, ++ which=4294967295, touch=True)) ++ else: ++ pygame.event.post(pygame.event.Event( ++ pygame.MOUSEWHEEL, x=item.get("delta_x", 0), ++ y=wheel_y, flipped=False)) ++ _pointer = pos ++ ++ ++def cooperative_mouse_pos(): ++ return _pointer ++ ++ ++def cooperative_mouse_pressed(): ++ return tuple(_buttons) ++ ++ ++def cooperative_key_pressed(key): ++ return bool(_keys.get(key, False)) ++ ++ ++def cooperative_modifiers(): ++ return _modifiers ++ ++ ++def cooperative_set_modifiers(value): ++ global _modifiers ++ _modifiers = value ++ ++ ++def cooperative_pause(): ++ global _paused ++ _check_owner() ++ if _game is None or _game.dead or _paused: ++ return ++ import renpy ++ interface = renpy.display.interface ++ if interface and interface.started: ++ import renpy.pygame as pygame ++ import renpy.display.core as core ++ interface.mobile_save() ++ renpy.audio.audio.pause_all() ++ # check_suspend is bypassed in host mode. Stop its three timers here ++ # rather than let SDL's timer thread fill the queue while Tick stops. ++ timers = (core.PERIODIC, core.REDRAW, core.TIMEEVENT) ++ for event_type in timers: ++ pygame.time.set_timer(event_type, 0) ++ pygame.event.clear(timers) ++ if interface.pushed_event is not None and interface.pushed_event.type in timers: ++ interface.pushed_event = None ++ _paused = True ++ ++ ++def cooperative_resume(): ++ global _paused, _timer_generation ++ _check_owner() ++ if _game is None or _game.dead or not _paused: ++ return ++ import renpy ++ interface = renpy.display.interface ++ if interface and interface.started: ++ import renpy.pygame as pygame ++ import renpy.display.core as core ++ # Remove any callback that was already in flight when pause removed ++ # the timers. Do not clear touch, key or text events. ++ pygame.event.clear((core.PERIODIC, core.REDRAW, core.TIMEEVENT)) ++ pygame.time.set_timer(core.PERIODIC, core.PERIODIC_INTERVAL) ++ renpy.audio.audio.unpause_all() ++ interface.force_redraw = True ++ _timer_generation += 1 ++ # Wake an event_wait suspended inside interact_core. Its saved timer ++ # caches are invalidated at the next loop boundary, without restarting ++ # the interaction or firing an artificial timeout at a displayable. ++ pygame.event.post(pygame.event.Event(core.PERIODIC)) ++ _paused = False ++ ++ ++def cooperative_timer_generation(): ++ return _timer_generation ++ ++ ++def cooperative_text_input_state(): ++ if not cooperative_active(): ++ return False ++ import renpy.pygame as pygame ++ return bool(pygame.key.text_input) ++ ++ ++def cooperative_stop(): ++ global _game, _host, _owner, _paused, _stopping, _frame ++ _check_owner() ++ if _game is None or _terminal_cleanup_done: ++ return ++ _stopping = True ++ _paused = False ++ # A greenlet is false before its first switch and after termination. ++ # Throwing into a newborn greenlet would bypass _run's exception handler. ++ if _game: ++ import renpy.game ++ _game.throw(renpy.game.QuitException()) ++ if not _game.dead: ++ raise RuntimeError("Ren'Py game did not finish its terminal shutdown") ++ _terminal_display_cleanup() ++ _game = _host = _owner = _frame = None ++ _input.clear() ++ _keys.clear() ++ _buttons[:] = [False, False, False] ++ ++ ++def _terminal_display_cleanup(): ++ """Release a healthy session while native still owns its GL context. ++ ++ No renderer imports are performed here: initialization might have failed ++ before display or GL2 became available. This is terminal, not a reusable ++ interpreter or a context-loss recovery path. ++ """ ++ global _terminal_cleanup_done ++ pygame = sys.modules.get("renpy.pygame") ++ display = getattr(pygame, "display", None) ++ shader = sys.modules.get("renpy.gl2.gl2shader") ++ if shader is not None: ++ # Set before releasing any refs or deleting the SDL context: script ++ # stores and texture loaders may retain Programs after cache.clear(). ++ shader.retire_mobile_programs() ++ if display is None: ++ _terminal_cleanup_done = True ++ return ++ ++ display.prepare_mobile_shutdown() ++ renpy = sys.modules.get("renpy") ++ package = getattr(renpy, "display", None) ++ draw = getattr(package, "draw", None) ++ core = sys.modules.get("renpy.display.core") ++ try: ++ # bootstrap.finally already calls draw.quit(). Never repeat quit_fbo. ++ if draw is not None: ++ loader = getattr(draw, "texture_loader", None) ++ if loader is not None: ++ loader.retire_mobile() ++ cache = getattr(draw, "shader_cache", None) ++ if cache is not None: ++ cache.clear() ++ video = sys.modules.get("renpy.display.video") ++ if video is not None: ++ video.group_texture.clear() ++ interface = getattr(package, "interface", None) ++ if interface is not None and interface.started: ++ interface.kill_textures() ++ draw.texture_loader = draw.shader_cache = draw.window = None ++ package.draw = None ++ finally: ++ if core is not None: ++ timers = (core.PERIODIC, core.REDRAW, core.TIMEEVENT) ++ for event_type in timers: ++ pygame.time.set_timer(event_type, 0) ++ if display.get_init(): ++ pygame.event.clear(timers) ++ display.quit() ++ _terminal_cleanup_done = True +diff --git a/renpy/gl2/gl2shader.pyx b/renpy/gl2/gl2shader.pyx +index 0aa2436..44a565c 100644 +--- a/renpy/gl2/gl2shader.pyx ++++ b/renpy/gl2/gl2shader.pyx +@@ -179,6 +179,16 @@ class Variable: + return (self.storage, self.type, self.name, self.array) == (other.storage, other.type, other.name, other.array) + + ++# Embedded sessions retain CPython after their SDL context is destroyed. A ++# Program can still be held by a texture loader, script store, or debugger. ++# Its late destructor must not delete an unrelated host-context program. ++cdef bint aether_terminal_programs_retired = False ++ ++def retire_mobile_programs(): ++ global aether_terminal_programs_retired ++ aether_terminal_programs_retired = True ++ ++ + cdef class Program: + """ + Represents an OpenGL program. +@@ -197,7 +207,8 @@ cdef class Program: + self.uniform_setters = [ ] + + def __dealloc__(self): +- glDeleteProgram(self.program) ++ if self.program and not aether_terminal_programs_retired: ++ glDeleteProgram(self.program) + + def find_variables(self, source, seen_uniforms: set, samplers: int): + +diff --git a/renpy/gl2/gl2texture.pyx b/renpy/gl2/gl2texture.pyx +index ec8c958..73a2bd5 100644 +--- a/renpy/gl2/gl2texture.pyx ++++ b/renpy/gl2/gl2texture.pyx +@@ -95,6 +95,15 @@ cdef class TextureLoader: + + self.allocated = set() + ++ def retire_mobile(self): ++ # CPU-only terminal teardown. SDL context destruction frees the GPU ++ # objects, including textures retained outside the render caches. ++ self.allocated.clear() ++ self.free_list.clear() ++ self.texture_load_queue.clear() ++ self.ftl_program = None ++ self.total_texture_size = 0 ++ + def get_texture_size(self): + """ + Returns the amount of memory locked up in textures. diff --git a/bridge/renpy_runtime/mobile_launcher/patches/python/README.md b/bridge/renpy_runtime/mobile_launcher/patches/python/README.md new file mode 100644 index 000000000..333a206df --- /dev/null +++ b/bridge/renpy_runtime/mobile_launcher/patches/python/README.md @@ -0,0 +1,33 @@ +# Ren'Py cooperative execution patch + +`0001-cooperative-loop-skeleton.patch` retains its historical filename but now +contains the embedded runtime implementation. It applies to pinned Ren'Py +commit `39895c1e017f0b36ffea2447d97eccd69d76ee1c`, not moving master. + +Validate or apply with: + +```sh +bridge/renpy_runtime/mobile_launcher/build.sh \ + --check-python-patch --renpy-src /path/to/renpy +bridge/renpy_runtime/mobile_launcher/build.sh \ + --apply-python-patch --renpy-src /path/to/renpy +``` + +`renpy.aether_mobile` creates a greenlet without running the game during init. +The first tick sets a deadline and enters the complete `renpy.py` bootstrap. +Subsequent ticks resume the same Python and Cython stack. Safe script and +interaction boundaries switch back to the host instead of unwinding an +exception through `interact` cleanup. Arbitrary Python code cannot be forcibly +preempted; a game that blocks between these boundaries can still stall a tick. + +Patched pygame modules publish GL-renderer screenshots, bind each actual GL +window immediately, query host pointer/button/key state, and report text-input +visibility. Events retain their down/up/motion semantics, Unicode ownership +and modifier state. Pause suspends ticks; stop requests normal Ren'Py shutdown. +A second session in the same process is rejected because Ren'Py global and +Cython state reuse has not been implemented; restart the host application. + +The native fork supplies the target-built `_aether_greenlet` and `_aether_host` +modules. A host wheel or desktop SDK is not a mobile substitute. Cooperative +and native protocol tests exercise controlled boundaries; actual APK/IPA +installation and gameplay acceptance remain separate requirements. diff --git a/bridge/renpy_runtime/mobile_launcher/src/renpy_mobile_loader.cpp b/bridge/renpy_runtime/mobile_launcher/src/renpy_mobile_loader.cpp new file mode 100644 index 000000000..4ae946d86 --- /dev/null +++ b/bridge/renpy_runtime/mobile_launcher/src/renpy_mobile_loader.cpp @@ -0,0 +1,253 @@ +#include "renpy_mobile_loader.h" + +#include +#include + +#if defined(__ANDROID__) || defined(__APPLE__) || defined(AETHERKIRI_RENPY_PROTOCOL_TEST) +#include +#endif + + +namespace aetherkiri::renpy::mobile { +namespace { + +constexpr int kOk = RENPY_MOBILE_OK; + +constexpr const char* kVideoEnvironment[] = { + "SDL_VIDEODRIVER", "SDL_VIDEO_GL_DRIVER", "SDL_VIDEO_EGL_DRIVER"}; + +void CaptureVideoEnvironment(std::array& values, + std::array& defined) { + for (size_t index = 0; index < values.size(); ++index) { + const char* value = std::getenv(kVideoEnvironment[index]); + defined[index] = value != nullptr; + values[index] = value ? value : ""; + } +} + +bool ReplaceVideoEnvironment(const std::array& values, + const std::array& defined) { + bool success = true; + for (size_t index = 0; index < values.size(); ++index) { +#if defined(_WIN32) + const int status = _putenv_s(kVideoEnvironment[index], + defined[index] ? values[index].c_str() : ""); +#else + const int status = defined[index] + ? setenv(kVideoEnvironment[index], values[index].c_str(), 1) + : unsetenv(kVideoEnvironment[index]); +#endif + if (status != 0) success = false; + } + return success; +} + +// Snapshot on every call: the host may update its selection between calls. +// Bootstrap supplies the runtime values before the first cooperative tick. +class VideoEnvironmentScope final { + public: + VideoEnvironmentScope() { CaptureVideoEnvironment(values_, defined_); } + ~VideoEnvironmentScope() { ReplaceVideoEnvironment(values_, defined_); } + + bool Apply(const std::array& values, + const std::array& defined) { + return ReplaceVideoEnvironment(values, defined); + } + + private: + std::array values_; + std::array defined_{}; +}; + +#if defined(__ANDROID__) || defined(__APPLE__) || defined(AETHERKIRI_RENPY_PROTOCOL_TEST) +template +Function ResolveSymbol(void* handle, const char* name) { + return reinterpret_cast(dlsym(handle, name)); +} +#endif + +} // namespace + +Launcher::Launcher() = default; + +Launcher::~Launcher() { + Shutdown(); + Unload(); +} + +bool Launcher::Resolve() { + if (resolved_) return init_ != nullptr; + resolved_ = true; + +#if defined(__ANDROID__) + const char* library_name = "librenpython.so"; + library_handle_ = dlopen(library_name, RTLD_NOW | RTLD_LOCAL); + if (!library_handle_) { + const char* detail = dlerror(); + last_error_ = "host lifecycle launcher unavailable: could not load "; + last_error_ += library_name; + if (detail) { + last_error_ += ": "; + last_error_ += detail; + } + return false; + } + init_ = ResolveSymbol(library_handle_, "renpy_mobile_init"); + bootstrap_ = ResolveSymbol(library_handle_, "renpy_mobile_bootstrap"); + tick_ = ResolveSymbol(library_handle_, "renpy_mobile_tick"); + frame_ = ResolveSymbol(library_handle_, "renpy_mobile_frame"); + input_ = ResolveSymbol(library_handle_, "renpy_mobile_input"); + pause_ = ResolveSymbol(library_handle_, "renpy_mobile_pause"); + resume_ = ResolveSymbol(library_handle_, "renpy_mobile_resume"); + text_input_state_ = ResolveSymbol(library_handle_, "renpy_mobile_text_input_state"); + surface_size_ = ResolveSymbol(library_handle_, "renpy_mobile_set_surface_size"); + bind_window_ = ResolveSymbol(library_handle_, "renpy_mobile_bind_window"); + shutdown_ = ResolveSymbol(library_handle_, "renpy_mobile_shutdown"); +#elif defined(AETHERKIRI_RENPY_IOS) + // Strong references bind the isolated AetherRenPyRuntime framework. Missing + // lifecycle exports fail at link time instead of producing a stub app. + init_ = renpy_mobile_init; + bootstrap_ = renpy_mobile_bootstrap; + tick_ = renpy_mobile_tick; + frame_ = renpy_mobile_frame; + input_ = renpy_mobile_input; + pause_ = renpy_mobile_pause; + resume_ = renpy_mobile_resume; + text_input_state_ = renpy_mobile_text_input_state; + surface_size_ = renpy_mobile_set_surface_size; + bind_window_ = renpy_mobile_bind_window; + shutdown_ = renpy_mobile_shutdown; +#elif defined(__APPLE__) || defined(AETHERKIRI_RENPY_PROTOCOL_TEST) + // Optional protocol-test lookup. Production iOS binds the isolated runtime + // framework above and never resolves its internal SDL or Python symbols. + init_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_init"); + bootstrap_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_bootstrap"); + tick_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_tick"); + frame_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_frame"); + input_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_input"); + pause_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_pause"); + resume_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_resume"); + text_input_state_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_text_input_state"); + surface_size_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_set_surface_size"); + bind_window_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_bind_window"); + shutdown_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_shutdown"); +#else + last_error_ = "host lifecycle launcher unavailable on this platform"; +#endif + + if (!init_ || !bootstrap_ || !tick_ || !frame_ || !input_ || !pause_ || !resume_ || + !text_input_state_ || !surface_size_ || !bind_window_ || !shutdown_) { + last_error_ = + "host lifecycle launcher unavailable: rebuilt Ren'Py payload must " + "export the complete ABI v2 lifecycle, bootstrap, renderer binding, text state and resize functions"; + Unload(); + return false; + } + return true; +} + +bool Launcher::available() { + return Resolve(); +} + +int Launcher::Init(const renpy_mobile_config_t& config, + const renpy_mobile_host_t& host) { + if (!Resolve()) return RENPY_MOBILE_NOT_IMPLEMENTED; + if (initialized_) return RENPY_MOBILE_INVALID_STATE; + VideoEnvironmentScope environment; + int result = bootstrap_ ? bootstrap_(&config, &host) : RENPY_MOBILE_OK; + CaptureVideoEnvironment(video_environment_values_, video_environment_defined_); + if (result == kOk) result = init_(&config, &host); + if (result == kOk) { + initialized_ = true; + finished_ = false; + } else { + last_error_ = "host lifecycle launcher init failed with status " + + std::to_string(result); + } + return result; +} + +int Launcher::Tick(uint32_t budget_ms) { + if (!initialized_ || !tick_) return RENPY_MOBILE_INVALID_STATE; + VideoEnvironmentScope environment; + if (!environment.Apply(video_environment_values_, video_environment_defined_)) return RENPY_MOBILE_ERROR; + const int result = tick_(budget_ms); + if (result == RENPY_MOBILE_FINISHED) finished_ = true; + return result; +} + +int Launcher::Frame(renpy_mobile_frame_t* out_frame) { + if (!initialized_ || !frame_ || !out_frame) return RENPY_MOBILE_INVALID_STATE; + VideoEnvironmentScope environment; + if (!environment.Apply(video_environment_values_, video_environment_defined_)) return RENPY_MOBILE_ERROR; + return frame_(out_frame); +} + +int Launcher::Input(const renpy_mobile_input_t& event) { + if (!initialized_ || !input_) return RENPY_MOBILE_INVALID_STATE; + VideoEnvironmentScope environment; + if (!environment.Apply(video_environment_values_, video_environment_defined_)) return RENPY_MOBILE_ERROR; + return input_(&event); +} + +int Launcher::Pause() { + if (!initialized_ || !pause_) return RENPY_MOBILE_INVALID_STATE; + VideoEnvironmentScope environment; + if (!environment.Apply(video_environment_values_, video_environment_defined_)) return RENPY_MOBILE_ERROR; + return pause_(); +} + +int Launcher::Resume() { + if (!initialized_ || !resume_) return RENPY_MOBILE_INVALID_STATE; + VideoEnvironmentScope environment; + if (!environment.Apply(video_environment_values_, video_environment_defined_)) return RENPY_MOBILE_ERROR; + return resume_(); +} + +int Launcher::TextInputState(uint32_t* active) { + if (!initialized_ || !active) return RENPY_MOBILE_INVALID_STATE; + VideoEnvironmentScope environment; + if (!environment.Apply(video_environment_values_, video_environment_defined_)) return RENPY_MOBILE_ERROR; + return text_input_state_ ? text_input_state_(active) : RENPY_MOBILE_NOT_IMPLEMENTED; +} + +int Launcher::SetSurfaceSize(uint32_t width, uint32_t height) { + if (!initialized_) return RENPY_MOBILE_INVALID_STATE; + VideoEnvironmentScope environment; + if (!environment.Apply(video_environment_values_, video_environment_defined_)) return RENPY_MOBILE_ERROR; + return surface_size_ ? surface_size_(width, height) : RENPY_MOBILE_NOT_IMPLEMENTED; +} + +void Launcher::Shutdown() { + if (initialized_ && shutdown_) { + VideoEnvironmentScope environment; + if (!environment.Apply(video_environment_values_, video_environment_defined_)) { + last_error_ = "Could not select the Ren'Py video environment for shutdown"; + } + shutdown_(); + } + initialized_ = false; + finished_ = false; +} + +void Launcher::Unload() { +#if defined(__ANDROID__) + if (library_handle_) dlclose(library_handle_); +#endif + library_handle_ = nullptr; + init_ = nullptr; + bootstrap_ = nullptr; + tick_ = nullptr; + frame_ = nullptr; + input_ = nullptr; + pause_ = nullptr; + resume_ = nullptr; + text_input_state_ = nullptr; + surface_size_ = nullptr; + bind_window_ = nullptr; + shutdown_ = nullptr; + resolved_ = false; +} + +} // namespace aetherkiri::renpy::mobile diff --git a/bridge/renpy_runtime/python/aether_renpy_overlay.py b/bridge/renpy_runtime/python/aether_renpy_overlay.py new file mode 100644 index 000000000..f0c554123 --- /dev/null +++ b/bridge/renpy_runtime/python/aether_renpy_overlay.py @@ -0,0 +1,190 @@ +"""Opt-in frame and input bridge for Ren'Py 8.5.x. + +The hook targets ``renpy.display.core.Interface.draw_screen`` (the stable +instance method used by the SDK), captures the rendered ``surftree`` after the +real draw, and registers an input pump as a periodic callback so input is +consumed even when the interaction is idle. +""" +from __future__ import annotations + +import json +import os +import struct +import tempfile +import ctypes +from pathlib import Path + +MAGIC = b"AKRF1\0\0\0" + + +class Overlay: + def __init__(self) -> None: + frame = os.environ.get("AETHERKIRI_RENPY_FRAME") + input_path = os.environ.get("AETHERKIRI_RENPY_INPUT") + self.frame_path = Path(frame) if frame else None + self.input_path = Path(input_path) if input_path else None + error = os.environ.get("AETHERKIRI_RENPY_ERROR") + self.error_path = Path(error) if error else None + self.serial = 0 + self._input_offset = 0 + + def _report(self, where: str, error: BaseException) -> None: + """Report bridge failures without taking down the Ren'Py game.""" + if self.error_path is None: + return + try: + self.error_path.parent.mkdir(parents=True, exist_ok=True) + with self.error_path.open("a", encoding="utf-8") as stream: + stream.write(f"{where}: {type(error).__name__}: {error}\n") + except Exception: + pass + + @staticmethod + def _surface_rgba(surface) -> tuple[int, int, bytes]: + """Copy a Ren'Py pygame surface into tightly packed RGBA bytes. + + Ren'Py's pygame compatibility layer does not provide pygame.image + ``tostring``. ``Surface._pixels_address`` is the low-level pointer + exposed by the 8.5.x surface type; copy it while the renderer-owned + surface is alive, then unpack according to its channel masks. + """ + width, height = surface.get_size() + bytesize = int(surface.get_bytesize()) + pitch = int(surface.get_pitch()) + address = getattr(surface, "_pixels_address", 0) + if width <= 0 or height <= 0 or bytesize <= 0 or pitch < width * bytesize: + raise ValueError("invalid Ren'Py surface geometry") + if not isinstance(address, int) or address <= 0: + raise ValueError("Ren'Py surface has no readable pixel address") + raw = ctypes.string_at(address, pitch * height) + masks = tuple(int(v) for v in surface.get_masks()) + shifts = tuple(int(v) for v in surface.get_shifts()) + losses = tuple(int(v) for v in surface.get_losses()) + + def channel(pixel: int, index: int, default: int) -> int: + mask = masks[index] if index < len(masks) else 0 + if not mask: + return default + shift = shifts[index] if index < len(shifts) else 0 + loss = losses[index] if index < len(losses) else 0 + value = (pixel & mask) >> shift + bits = max(1, 8 - loss) + maximum = (1 << bits) - 1 + return (value * 255 + maximum // 2) // maximum + + rgba = bytearray(width * height * 4) + for y in range(height): + row = y * pitch + out = y * width * 4 + for x in range(width): + offset = row + x * bytesize + pixel = int.from_bytes(raw[offset:offset + bytesize], "little") + rgba[out:out + 4] = bytes(( + channel(pixel, 0, 0), + channel(pixel, 1, 0), + channel(pixel, 2, 0), + channel(pixel, 3, 255), + )) + out += 4 + return width, height, bytes(rgba) + + def _publish(self, surface) -> None: + if self.frame_path is None: + return + try: + width, height, pixels = self._surface_rgba(surface) + self.serial += 1 + payload = MAGIC + struct.pack(" None: + if self.input_path is None or not self.input_path.exists(): + return + host_shutdown = False + try: + import renpy.pygame as pygame + with self.input_path.open("rb") as stream: + stream.seek(self._input_offset) + while True: + raw = stream.readline() + if not raw: + break + self._input_offset = stream.tell() + event = json.loads(raw.decode("utf-8")) + attrs = dict(event.get("attributes", {})) + event_type = int(event["type"]) + if event_type == pygame.QUIT and event.get("aetherkiri_host_shutdown") is True: + host_shutdown = True + break + # The engine ABI has no SDL scancode/repeat fields. Fill + # the Ren'Py pygame defaults so a native provider can send + # just key/modifier/unicode data. + if event_type in (pygame.KEYDOWN, pygame.KEYUP): + attrs.setdefault("mod", 0) + attrs.setdefault("unicode", "") + attrs.setdefault("scancode", 0) + attrs.setdefault("repeat", False) + pygame.event.post(pygame.event.Event(event_type, attrs)) + except Exception as error: + self._report("input", error) + return + if host_shutdown: + # Host destruction is already the user's decision to close this + # session. Raise Ren'Py's orderly quit outside the input error + # handler so its QuitException reaches bootstrap cleanup. + import renpy + renpy.quit(confirm=False) + + def _draw_screen(self, interface, original, *args, **kwargs): + result = original(interface, *args, **kwargs) + try: + # Interface.draw_screen stores the actual rendered tree on itself. + # Ren'Py exposes the active renderer as the ``draw`` attribute on + # the ``renpy.display`` module. It is not a ``renpy.display.draw`` + # importable submodule (8.5.3 raises ModuleNotFoundError here). + import renpy.display + renderer = getattr(renpy.display, "draw", None) + if renderer is not None and interface.surftree is not None: + self._publish(renderer.screenshot(interface.surftree)) + except Exception as error: + self._report("draw", error) + pass + return result + + def install(self) -> None: + import renpy.config + import renpy.display.core + + klass = renpy.display.core.Interface + original = klass.draw_screen + if getattr(original, "_aetherkiri_overlay", False): + return + + def draw_screen(interface, *args, **kwargs): + return self._draw_screen(interface, original, *args, **kwargs) + + draw_screen._aetherkiri_overlay = True + klass.draw_screen = draw_screen + # PERIODIC events run while the interface waits, unlike a draw hook. + renpy.config.periodic_callbacks.append(self._inject) + + +def install() -> None: + if os.environ.get("AETHERKIRI_RENPY_OVERLAY") == "1": + Overlay().install() diff --git a/bridge/renpy_runtime/src/renpy_mobile_adapter.cpp b/bridge/renpy_runtime/src/renpy_mobile_adapter.cpp new file mode 100644 index 000000000..118a23daf --- /dev/null +++ b/bridge/renpy_runtime/src/renpy_mobile_adapter.cpp @@ -0,0 +1,384 @@ +#include "renpy_mobile_adapter.h" + +#if defined(__ANDROID__) +#include "renpy_android_host.h" +#include +#include +#include + +// These accessors are exported by engine_api's existing Android JNI bridge. +// They let the adapter validate the already-running VM/context without +// constructing or launching a second Activity. +extern JNIEnv* krkr_GetJNIEnv(); +extern JavaVM* krkr_GetJavaVM(); +extern jobject krkr_GetApplicationContext(); +extern jobject krkr_GetHostActivity(); +extern ANativeWindow* krkr_GetNativeWindow(); +#endif + +namespace aetherkiri::renpy::mobile { + +#if defined(__ANDROID__) +namespace { + +bool ClearJavaException(JNIEnv* env) { + if (!env->ExceptionCheck()) return false; + env->ExceptionClear(); + return true; +} + +bool RequireStaticMethod(JNIEnv* env, jclass klass, const char* class_name, + const char* method_name, const char* signature, + std::string* error) { + if (env->GetStaticMethodID(klass, method_name, signature) != nullptr) { + return true; + } + ClearJavaException(env); + *error = "Ren'Py mobile preflight: Java callback " + + std::string(class_name) + "." + method_name + signature + + " is missing"; + return false; +} + +bool RequireInstanceMethod(JNIEnv* env, jclass klass, const char* class_name, + const char* method_name, const char* signature, + std::string* error) { + if (env->GetMethodID(klass, method_name, signature) != nullptr) { + return true; + } + ClearJavaException(env); + *error = "Ren'Py mobile preflight: Java callback " + + std::string(class_name) + "." + method_name + signature + + " is missing"; + return false; +} + +jclass FindRequiredClass(JNIEnv* env, const char* class_name, + std::string* error) { + jclass klass = env->FindClass(class_name); + if (klass != nullptr) return klass; + ClearJavaException(env); + + // Preflight normally runs from a native Godot thread. FindClass uses that + // thread's loader, which may not be the APK class loader, so retry through + // the already-bound Activity before declaring a callback unavailable. + jobject activity = krkr_GetHostActivity(); + if (activity != nullptr) { + jclass activity_class = env->GetObjectClass(activity); + if (activity_class != nullptr) { + jmethodID get_loader = + env->GetMethodID(activity_class, "getClassLoader", + "()Ljava/lang/ClassLoader;"); + if (get_loader != nullptr) { + jobject loader = env->CallObjectMethod(activity, get_loader); + jclass loader_class = env->FindClass("java/lang/ClassLoader"); + ClearJavaException(env); + if (loader != nullptr && loader_class != nullptr) { + jmethodID load_class = + env->GetMethodID(loader_class, "loadClass", + "(Ljava/lang/String;)Ljava/lang/Class;"); + if (load_class != nullptr) { + std::string binary_name(class_name); + for (char& character : binary_name) { + if (character == '/') character = '.'; + } + jstring name = env->NewStringUTF(binary_name.c_str()); + jobject loaded = env->CallObjectMethod(loader, load_class, name); + env->DeleteLocalRef(name); + if (loaded != nullptr && !ClearJavaException(env)) { + env->DeleteLocalRef(loader); + env->DeleteLocalRef(loader_class); + env->DeleteLocalRef(activity_class); + return reinterpret_cast(loaded); + } + ClearJavaException(env); + } + } + if (loader != nullptr) env->DeleteLocalRef(loader); + if (loader_class != nullptr) env->DeleteLocalRef(loader_class); + } + ClearJavaException(env); + env->DeleteLocalRef(activity_class); + } + } + *error = "Ren'Py mobile preflight: required Java class " + + std::string(class_name) + + " is unavailable; the staged RAPT sources are assets-only and " + "must be compiled into the host APK"; + return nullptr; +} + +bool CheckJavaCallbacks(JNIEnv* env, std::string* error) { + jclass sdl = FindRequiredClass(env, "org/libsdl/app/SDLActivity", error); + if (sdl == nullptr) return false; + const bool sdl_ok = + RequireStaticMethod(env, sdl, "org.libsdl.app.SDLActivity", + "nativeSetupJNI", "()I", error) && + RequireStaticMethod(env, sdl, "org.libsdl.app.SDLActivity", + "nativeRunMain", + "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/Object;)I", + error) && + RequireStaticMethod(env, sdl, "org.libsdl.app.SDLActivity", + "onNativeSurfaceCreated", "()V", error) && + RequireStaticMethod(env, sdl, "org.libsdl.app.SDLActivity", + "onNativeSurfaceChanged", "()V", error) && + RequireStaticMethod(env, sdl, "org.libsdl.app.SDLActivity", + "onNativeSurfaceDestroyed", "()V", error) && + RequireStaticMethod(env, sdl, "org.libsdl.app.SDLActivity", + "nativeSetScreenResolution", "(IIIIF)V", error) && + RequireStaticMethod(env, sdl, "org.libsdl.app.SDLActivity", + "onNativeResize", "()V", error) && + RequireStaticMethod(env, sdl, "org.libsdl.app.SDLActivity", + "onNativeKeyDown", "(I)V", error) && + RequireStaticMethod(env, sdl, "org.libsdl.app.SDLActivity", + "onNativeKeyUp", "(I)V", error) && + RequireStaticMethod(env, sdl, "org.libsdl.app.SDLActivity", + "onNativeTouch", "(IIIFFF)V", error) && + RequireStaticMethod(env, sdl, "org.libsdl.app.SDLActivity", + "nativePause", "()V", error) && + RequireStaticMethod(env, sdl, "org.libsdl.app.SDLActivity", + "nativeResume", "()V", error) && + RequireStaticMethod(env, sdl, "org.libsdl.app.SDLActivity", + "nativeQuit", "()V", error) && + RequireStaticMethod(env, sdl, "org.libsdl.app.SDLActivity", + "getNativeSurface", "()Landroid/view/Surface;", + error) && + RequireStaticMethod(env, sdl, "org.libsdl.app.SDLActivity", + "getContext", "()Landroid/content/Context;", error); + env->DeleteLocalRef(sdl); + if (!sdl_ok) return false; + + jclass python = + FindRequiredClass(env, "org/renpy/android/PythonSDLActivity", error); + if (python == nullptr) return false; + const bool python_ok = + RequireInstanceMethod(env, python, "org.renpy.android.PythonSDLActivity", + "nativeSetEnv", + "(Ljava/lang/String;Ljava/lang/String;)V", error); + env->DeleteLocalRef(python); + return python_ok; +} + +const char* MissingNativeExport(void* handle) { + static constexpr const char* kRequired[] = { + "SDL_main", + "JNI_OnLoad", + "SDL_AndroidGetJNIEnv", + "SDL_AndroidGetActivity", + "Java_org_libsdl_app_SDLActivity_nativeSetupJNI", + "Java_org_libsdl_app_SDLActivity_nativeRunMain", + "Java_org_libsdl_app_SDLActivity_onNativeSurfaceCreated", + "Java_org_libsdl_app_SDLActivity_onNativeSurfaceChanged", + "Java_org_libsdl_app_SDLActivity_onNativeSurfaceDestroyed", + "Java_org_libsdl_app_SDLActivity_nativeSetScreenResolution", + "Java_org_libsdl_app_SDLActivity_onNativeResize", + "Java_org_libsdl_app_SDLActivity_onNativeKeyDown", + "Java_org_libsdl_app_SDLActivity_onNativeKeyUp", + "Java_org_libsdl_app_SDLActivity_onNativeTouch", + "Java_org_libsdl_app_SDLActivity_nativePause", + "Java_org_libsdl_app_SDLActivity_nativeResume", + "Java_org_libsdl_app_SDLActivity_nativeQuit", + "Java_org_libsdl_app_SDLActivity_getNativeSurface", + "Java_org_libsdl_app_SDLActivity_getContext", + "Java_org_renpy_android_PythonSDLActivity_nativeSetEnv", + }; + for (const char* symbol : kRequired) { + dlerror(); + if (dlsym(handle, symbol) == nullptr) return symbol; + } + return nullptr; +} + +} // namespace +#endif + +engine_result_t BootstrapAdapter::Preflight(const BootstrapRequest& request) { + if (request.game_root_path_utf8 == nullptr || + request.game_root_path_utf8[0] == '\0') { + last_error_ = "Ren'Py mobile bootstrap requires a non-empty game root"; + return ENGINE_RESULT_INVALID_ARGUMENT; + } + if (running_) { + last_error_ = "Ren'Py mobile bootstrap is already running"; + return ENGINE_RESULT_INVALID_STATE; + } + +#if !defined(__ANDROID__) + (void)request; + last_error_ = + "Ren'Py mobile preflight is only available on an Android host"; + return ENGINE_RESULT_NOT_SUPPORTED; +#else + void* host_activity = request.existing_host_activity; + if (host_activity == nullptr) host_activity = krkr_GetHostActivity(); + if (host_activity == nullptr) { + last_error_ = + "Ren'Py mobile preflight requires the existing host Activity; " + "a second Activity is never created"; + return ENGINE_RESULT_NOT_SUPPORTED; + } + if (krkr_GetJavaVM() == nullptr || krkr_GetApplicationContext() == nullptr) { + last_error_ = + "Ren'Py mobile preflight requires the existing Android JavaVM and " + "Application Context from EngineBridge"; + return ENGINE_RESULT_NOT_SUPPORTED; + } + + JNIEnv* env = krkr_GetJNIEnv(); + if (env == nullptr) { + last_error_ = + "Ren'Py mobile preflight could not attach the current thread to the " + "Android JavaVM"; + return ENGINE_RESULT_NOT_SUPPORTED; + } + if (!CheckJavaCallbacks(env, &last_error_)) { + return ENGINE_RESULT_NOT_SUPPORTED; + } + + ANativeWindow* window = krkr_GetNativeWindow(); + if (window == nullptr) { + last_error_ = + "Ren'Py mobile preflight requires a bound host SDL surface; call " + "EngineBridge.nativeSetSurface before opening the game"; + return ENGINE_RESULT_NOT_SUPPORTED; + } + const int32_t width = ANativeWindow_getWidth(window); + const int32_t height = ANativeWindow_getHeight(window); + ANativeWindow_release(window); + if (width <= 0 || height <= 0) { + last_error_ = + "Ren'Py mobile preflight found a host SDL surface with invalid " + "dimensions"; + return ENGINE_RESULT_NOT_SUPPORTED; + } + + if (native_library_handle_ != nullptr) { + dlclose(native_library_handle_); + native_library_handle_ = nullptr; + } + void* library = dlopen("librenpython.so", RTLD_NOW | RTLD_LOCAL); + if (library == nullptr) { + const char* detail = dlerror(); + last_error_ = "Ren'Py mobile preflight could not load librenpython.so"; + if (detail != nullptr) { + last_error_.append(": "); + last_error_.append(detail); + } + return ENGINE_RESULT_NOT_SUPPORTED; + } + if (const char* missing = MissingNativeExport(library); missing != nullptr) { + last_error_ = "Ren'Py mobile preflight: librenpython.so is missing export "; + last_error_.append(missing); + dlclose(library); + return ENGINE_RESULT_NOT_SUPPORTED; + } + native_library_handle_ = library; + last_error_ = + "Ren'Py mobile preflight passed host Activity, Java callbacks, SDL " + "surface, and librenpython exports; SDL/Python lifecycle bridge is not " + "started by this boundary"; + return ENGINE_RESULT_NOT_SUPPORTED; +#endif +} + +engine_result_t BootstrapAdapter::Start(const BootstrapRequest& request) { + return Preflight(request); +} + +engine_result_t BootstrapAdapter::PrepareRuntime( + const BootstrapRequest& request, uint32_t width, uint32_t height, + std::string* private_root, std::string* apk_path) { + if (!private_root || !apk_path || !width || !height || + !request.game_root_path_utf8 || !request.game_root_path_utf8[0]) { + return ENGINE_RESULT_INVALID_ARGUMENT; + } +#if !defined(__ANDROID__) + last_error_ = "Ren'Py runtime extraction requires an Android host"; + return ENGINE_RESULT_NOT_SUPPORTED; +#else + JNIEnv* env = krkr_GetJNIEnv(); + if (!env) { last_error_ = "Ren'Py could not attach to the Android VM"; return ENGINE_RESULT_INVALID_STATE; } + AndroidLocalReference activity_ref(env, env->NewLocalRef(request.existing_host_activity + ? static_cast(request.existing_host_activity) : krkr_GetHostActivity())); + last_error_ = "Ren'Py could not retain the existing Godot Activity"; + if (AndroidHostException(env, &last_error_) || + !RequireAndroidHostActivity(env, activity_ref.get(), &last_error_)) + return ENGINE_RESULT_INVALID_STATE; + jobject activity = activity_ref.get(); + jclass bridge = FindRequiredClass(env, "org/github/krkr2/aetherkiri/RenPyMobileBridge", &last_error_); + if (!bridge) return ENGINE_RESULT_NOT_SUPPORTED; + jmethodID bind = env->GetStaticMethodID(bridge, "bindHostActivity", "(Landroid/app/Activity;)V"); + jmethodID extract = env->GetStaticMethodID(bridge, "preparePrivateRoot", "()Ljava/lang/String;"); + jmethodID prepare = env->GetStaticMethodID(bridge, "prepareRuntime", "(II)V"); + jmethodID apk = env->GetStaticMethodID(bridge, "getApkPath", "()Ljava/lang/String;"); + if (!bind || !extract || !prepare || !apk || ClearJavaException(env)) { + env->DeleteLocalRef(bridge); + last_error_ = "Ren'Py Android host bridge is missing extraction/SDL initialization callbacks"; + return ENGINE_RESULT_NOT_SUPPORTED; + } + env->CallStaticVoidMethod(bridge, bind, activity); + jstring root = nullptr, archive = nullptr; + if (!env->ExceptionCheck()) root = static_cast(env->CallStaticObjectMethod(bridge, extract)); + if (!env->ExceptionCheck()) archive = static_cast(env->CallStaticObjectMethod(bridge, apk)); + if (!env->ExceptionCheck()) env->CallStaticVoidMethod(bridge, prepare, static_cast(width), static_cast(height)); + if (env->ExceptionCheck() || !root || !archive) { + jthrowable exception = env->ExceptionOccurred(); + env->ExceptionClear(); + last_error_ = "Ren'Py Android payload extraction/SDL initialization failed"; + if (exception) { + jclass exception_class = env->GetObjectClass(exception); + jmethodID describe = env->GetMethodID(exception_class, "toString", "()Ljava/lang/String;"); + jstring detail = describe ? static_cast(env->CallObjectMethod(exception, describe)) : nullptr; + if (detail && !ClearJavaException(env)) { + const char* utf8 = env->GetStringUTFChars(detail, nullptr); + if (utf8) { last_error_ += ": "; last_error_ += utf8; env->ReleaseStringUTFChars(detail, utf8); } + } + if (detail) env->DeleteLocalRef(detail); + env->DeleteLocalRef(exception_class); + env->DeleteLocalRef(exception); + } + if (root) env->DeleteLocalRef(root); + if (archive) env->DeleteLocalRef(archive); + env->DeleteLocalRef(bridge); + return ENGINE_RESULT_INTERNAL_ERROR; + } + const char* root_utf8 = env->GetStringUTFChars(root, nullptr); + const char* apk_utf8 = env->GetStringUTFChars(archive, nullptr); + if (root_utf8) *private_root = root_utf8; + if (apk_utf8) *apk_path = apk_utf8; + if (root_utf8) env->ReleaseStringUTFChars(root, root_utf8); + if (apk_utf8) env->ReleaseStringUTFChars(archive, apk_utf8); + env->DeleteLocalRef(root); + env->DeleteLocalRef(archive); + env->DeleteLocalRef(bridge); + if (private_root->empty() || apk_path->empty() || ClearJavaException(env)) return ENGINE_RESULT_INTERNAL_ERROR; + running_ = true; + last_error_.clear(); + return ENGINE_RESULT_OK; +#endif +} + +engine_result_t BootstrapAdapter::Stop() { + // Release host helpers after the provider has stopped Ren'Py; this is also + // idempotent after extraction/bootstrap fails. +#if defined(__ANDROID__) + if (JNIEnv* env = krkr_GetJNIEnv(); env && running_) { + jclass bridge = FindRequiredClass(env, "org/github/krkr2/aetherkiri/RenPyMobileBridge", &last_error_); + if (bridge) { + jmethodID stop = env->GetStaticMethodID(bridge, "stopRuntime", "()V"); + if (stop) env->CallStaticVoidMethod(bridge, stop); + ClearJavaException(env); + env->DeleteLocalRef(bridge); + } + } + if (native_library_handle_ != nullptr) { + dlclose(native_library_handle_); + native_library_handle_ = nullptr; + } +#endif + running_ = false; + last_error_.clear(); + return ENGINE_RESULT_OK; +} + +} // namespace aetherkiri::renpy::mobile diff --git a/bridge/renpy_runtime/src/renpy_runtime_ios_adapter.cpp b/bridge/renpy_runtime/src/renpy_runtime_ios_adapter.cpp new file mode 100644 index 000000000..6188c0297 --- /dev/null +++ b/bridge/renpy_runtime/src/renpy_runtime_ios_adapter.cpp @@ -0,0 +1,65 @@ +#include "renpy_runtime_ios_adapter.h" + +#include +#include + +namespace { + +std::mutex g_adapter_mutex; +const renpy_ios_inprocess_adapter_v1_t* g_adapter = nullptr; + +const renpy_ios_launcher_contract_v1_t kLauncherContract = { + sizeof(renpy_ios_launcher_contract_v1_t), + AETHERKIRI_RENPY_IOS_LAUNCHER_CONTRACT_API_VERSION, + RENPY_IOS_LAUNCHER_MODE_BLOCKING_PY_MAIN, + "launcher_main", + "Py_RunMain", + "Renios launcher_main enters Py_RunMain and owns a blocking Python loop; " + "a host-tick/frame/input bridge requires a rebuilt split launcher" + " (init/tick/shutdown) before gameplay can be enabled", +}; + +bool HasRequiredPrefix(const renpy_ios_inprocess_adapter_v1_t* adapter) { + if (!adapter || adapter->struct_size < + offsetof(renpy_ios_inprocess_adapter_v1_t, close_game) + + sizeof(adapter->close_game)) { + return false; + } + const uint32_t expected_major = + (AETHERKIRI_RENPY_IOS_ADAPTER_API_VERSION >> 24u) & 0xffu; + const uint32_t adapter_major = (adapter->api_version >> 24u) & 0xffu; + if (adapter_major != expected_major || !adapter->create || + !adapter->destroy || !adapter->open_game || + !adapter->tick || !adapter->pause || !adapter->resume || + !adapter->close_game || !adapter->get_last_error) { + return false; + } + return true; +} + +} // namespace + +extern "C" { + +const renpy_ios_launcher_contract_v1_t* +renpy_get_ios_launcher_contract(void) { + return &kLauncherContract; +} + +engine_result_t renpy_install_ios_inprocess_adapter( + const renpy_ios_inprocess_adapter_v1_t* adapter) { + if (adapter != nullptr && !HasRequiredPrefix(adapter)) { + return ENGINE_RESULT_INVALID_ARGUMENT; + } + std::lock_guard lock(g_adapter_mutex); + g_adapter = adapter; + return ENGINE_RESULT_OK; +} + +const renpy_ios_inprocess_adapter_v1_t* +renpy_get_ios_inprocess_adapter(void) { + std::lock_guard lock(g_adapter_mutex); + return g_adapter; +} + +} // extern "C" diff --git a/bridge/renpy_runtime/src/renpy_runtime_mobile_provider.cpp b/bridge/renpy_runtime/src/renpy_runtime_mobile_provider.cpp new file mode 100644 index 000000000..2c966076e --- /dev/null +++ b/bridge/renpy_runtime/src/renpy_runtime_mobile_provider.cpp @@ -0,0 +1,532 @@ +#include "renpy_runtime.h" +#include "renpy_project.h" +#include "engine_runtime_provider.h" +#include "renpy_mobile_loader.h" +#include "renpy_input_mapping.h" +#if defined(__ANDROID__) +#include "renpy_mobile_adapter.h" +#endif +#if defined(AETHERKIRI_RENPY_IOS) +#include "renpy_runtime_ios_adapter.h" +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#if defined(AETHERKIRI_RENPY_IOS) +#include +#endif + +#if !defined(__ANDROID__) && !defined(__APPLE__) && !defined(AETHERKIRI_RENPY_PROTOCOL_TEST) +#error "Ren'Py mobile provider must only be compiled for Android or Apple" +#endif + +namespace aetherkiri::renpy { +namespace { + +using mobile::Launcher; + +struct MobileRuntime final { + engine_runtime_host_v1_t host{}; + renpy_mobile_host_t launcher_host{}; + renpy_mobile_config_t launcher_config{}; + std::string game_root; + std::string private_root; + std::string argv0; + std::string apk_path; + Launcher launcher; +#if defined(__ANDROID__) + mobile::BootstrapAdapter bootstrap; +#endif + std::mutex frame_mutex; + std::vector frame_pixels; + uint32_t frame_width = 0; + uint32_t frame_height = 0; + uint32_t frame_stride = 0; + uint64_t frame_serial = 0; + uint64_t delivered_serial = 0; + bool frame_rendered = false; + bool opened = false; + bool paused = false; + uint32_t surface_width = 0; + uint32_t surface_height = 0; + std::string error = + "Ren'Py mobile runtime is not supported: official RAPT/Renios " + "inputs do not export the host lifecycle ABI"; +}; + +MobileRuntime* Cast(void* value) { + return static_cast(value); +} + +engine_result_t MapLauncherStatus(int status) { + switch (status) { + case RENPY_MOBILE_OK: + case RENPY_MOBILE_FINISHED: + return ENGINE_RESULT_OK; + case RENPY_MOBILE_INVALID_ARGUMENT: + return ENGINE_RESULT_INVALID_ARGUMENT; + case RENPY_MOBILE_INVALID_STATE: + return ENGINE_RESULT_INVALID_STATE; + case RENPY_MOBILE_NOT_IMPLEMENTED: + return ENGINE_RESULT_NOT_SUPPORTED; + default: + return ENGINE_RESULT_INTERNAL_ERROR; + } +} + +int PresentRgba(void* user_data, const uint8_t* rgba, uint32_t width, + uint32_t height, uint32_t stride) { + auto* runtime = static_cast(user_data); + if (!runtime || !rgba || width == 0 || height == 0 || + width > std::numeric_limits::max() / 4u || + stride < width * 4u) { + return -1; + } + const size_t size = static_cast(stride) * height; + if (height != 0 && size / height != stride) return -1; + std::lock_guard lock(runtime->frame_mutex); + runtime->frame_pixels.assign(rgba, rgba + size); + runtime->frame_width = width; + runtime->frame_height = height; + runtime->frame_stride = stride; + ++runtime->frame_serial; + runtime->frame_rendered = true; + return 0; +} + +void LogUtf8(void* user_data, int level, const char* message) { + auto* runtime = static_cast(user_data); + if (!runtime || !message) return; + if (level >= 3) runtime->error = message; + if (runtime->host.log) { + const uint32_t host_level = level >= 3 ? ENGINE_RUNTIME_LOG_ERROR + : level == 2 ? ENGINE_RUNTIME_LOG_WARNING + : level == 1 ? ENGINE_RUNTIME_LOG_INFO : ENGINE_RUNTIME_LOG_TRACE; + // The host persists this diagnostic before Destroy releases runtime. + runtime->host.log(runtime->host.user_data, host_level, "renpy-mobile", message); + } +} + +engine_result_t CaptureFrame(MobileRuntime* runtime) { + renpy_mobile_frame_t frame{}; + frame.struct_size = sizeof(frame); + const int status = runtime->launcher.Frame(&frame); + if (status != RENPY_MOBILE_OK) { + // A launcher may deliver frames exclusively through present_rgba. A + // missing frame is therefore not a tick failure. + return status == RENPY_MOBILE_INVALID_STATE ? ENGINE_RESULT_OK + : MapLauncherStatus(status); + } + if (!frame.rgba || frame.width == 0 || frame.height == 0 || + frame.width > std::numeric_limits::max() / 4u || + frame.stride < frame.width * 4u) { + return ENGINE_RESULT_OK; + } + const size_t size = static_cast(frame.stride) * frame.height; + if (frame.height != 0 && size / frame.height != frame.stride) { + runtime->error = "Ren'Py mobile frame stride overflows host size"; + return ENGINE_RESULT_INTERNAL_ERROR; + } + std::lock_guard lock(runtime->frame_mutex); + if (frame.serial != 0 && frame.serial == runtime->frame_serial) return ENGINE_RESULT_OK; + runtime->frame_pixels.assign(frame.rgba, frame.rgba + size); + runtime->frame_width = frame.width; + runtime->frame_height = frame.height; + runtime->frame_stride = frame.stride; + runtime->frame_serial = frame.serial != 0 ? frame.serial : runtime->frame_serial + 1; + runtime->frame_rendered = true; + return ENGINE_RESULT_OK; +} + +int32_t Probe(void*, const char* path) { + // The provider is discoverable only when a rebuilt lifecycle payload is + // linked. Official blocking RAPT/Renios archives remain invisible here. + if (!path || !path[0]) return 0; + try { + if (!HasRenpyProject(std::filesystem::u8path(path))) return 0; + static Launcher launcher; + return launcher.available() ? 100 : 0; + } catch (...) { return 0; } +} + +engine_result_t Create(void*, const engine_runtime_host_v1_t* host, + const engine_create_desc_t* desc, void** output) { + if (!host || !desc || !output || host->struct_size < sizeof(*host) || + desc->struct_size < sizeof(*desc)) { + return ENGINE_RESULT_INVALID_ARGUMENT; + } + *output = nullptr; + try { + auto runtime = std::make_unique(); + runtime->host = *host; + *output = runtime.release(); + return ENGINE_RESULT_OK; + } catch (...) { + return ENGINE_RESULT_INTERNAL_ERROR; + } +} + +void Destroy(void* value) { + if (!value) return; + auto* runtime = Cast(value); + runtime->launcher.Shutdown(); +#if defined(__ANDROID__) + runtime->bootstrap.Stop(); +#endif + delete runtime; +} + +engine_result_t Open(void* value, const char* game_root_path, + const char* startup_script) { + if (!value || !game_root_path || game_root_path[0] == '\0') { + return ENGINE_RESULT_INVALID_ARGUMENT; + } + auto* runtime = Cast(value); + if (runtime->opened) return ENGINE_RESULT_INVALID_STATE; + if (!runtime->launcher.available()) { +#if defined(__ANDROID__) + mobile::BootstrapRequest request{}; + request.game_root_path_utf8 = game_root_path; + request.startup_script_utf8 = startup_script; + const auto result = runtime->bootstrap.Start(request); + runtime->error = runtime->bootstrap.last_error(); + return result; +#elif defined(AETHERKIRI_RENPY_IOS) + const auto* contract = renpy_get_ios_launcher_contract(); + runtime->error = + "Ren'Py iOS mobile runtime is not supported: the official Renios " + "libraries are staged, but no host-owned lifecycle payload is " + "linked (a second UIKit/SDL entrypoint is forbidden)"; + if (contract && contract->limitation_utf8) { + runtime->error += " "; + runtime->error += contract->limitation_utf8; + } + return ENGINE_RESULT_NOT_SUPPORTED; +#else + runtime->error = runtime->launcher.last_error(); + return ENGINE_RESULT_NOT_SUPPORTED; +#endif + } + + // open_game_async runs on a temporary startup thread. Defer CPython, + // greenlet and GL initialization until the first host Tick, and own every + // string/callback table for the runtime's entire lifetime. + runtime->game_root = game_root_path; + runtime->argv0 = startup_script ? startup_script : ""; + runtime->launcher_host.struct_size = sizeof(runtime->launcher_host); + runtime->launcher_host.abi_version = RENPY_MOBILE_LAUNCHER_ABI_VERSION; + runtime->launcher_host.user_data = runtime; + runtime->launcher_host.present_rgba = PresentRgba; + runtime->launcher_host.log_utf8 = LogUtf8; + runtime->opened = true; + runtime->error.clear(); + return ENGINE_RESULT_OK; +} + +engine_result_t Tick(void* value, uint32_t delta_ms) { + if (!value) return ENGINE_RESULT_INVALID_ARGUMENT; + auto* runtime = Cast(value); + if (!runtime->opened) return ENGINE_RESULT_INVALID_STATE; + runtime->frame_rendered = false; + if (runtime->launcher.finished()) { + runtime->error = "Ren'Py runtime requested termination"; + return ENGINE_RESULT_INVALID_STATE; + } + if (runtime->paused) return ENGINE_RESULT_OK; + if (!runtime->launcher.initialized()) { +#if defined(__ANDROID__) + mobile::BootstrapRequest request{}; + request.game_root_path_utf8 = runtime->game_root.c_str(); + const auto prepared = runtime->bootstrap.PrepareRuntime(request, + runtime->surface_width ? runtime->surface_width : 1280u, + runtime->surface_height ? runtime->surface_height : 720u, + &runtime->private_root, &runtime->apk_path); + if (prepared != ENGINE_RESULT_OK) { runtime->error = runtime->bootstrap.last_error(); return prepared; } + if (runtime->argv0.empty()) runtime->argv0 = runtime->private_root + "/main.py"; +#elif defined(AETHERKIRI_RENPY_IOS) + CFURLRef resources = CFBundleCopyResourcesDirectoryURL(CFBundleGetMainBundle()); + char path[4096]{}; + const bool found = resources && CFURLGetFileSystemRepresentation(resources, true, + reinterpret_cast(path), sizeof(path)); + if (resources) CFRelease(resources); + if (!found) { runtime->error = "Ren'Py could not locate the app's bundled Renios Python payload"; return ENGINE_RESULT_IO_ERROR; } + runtime->private_root = std::string(path) + "/renios/base"; + if (runtime->argv0.empty()) runtime->argv0 = runtime->private_root + "/renpy.py"; +#else + runtime->private_root = runtime->game_root; + if (runtime->argv0.empty()) runtime->argv0 = "AetherKiri"; +#endif + auto& config = runtime->launcher_config; + config.struct_size = sizeof(config); + config.abi_version = RENPY_MOBILE_LAUNCHER_ABI_VERSION; + config.private_root_utf8 = runtime->private_root.c_str(); + config.public_root_utf8 = runtime->game_root.c_str(); + config.game_root_utf8 = runtime->game_root.c_str(); + config.argv0_utf8 = runtime->argv0.c_str(); + config.apk_path_utf8 = runtime->apk_path.empty() ? nullptr : runtime->apk_path.c_str(); + runtime->error.clear(); + const auto initialized = MapLauncherStatus(runtime->launcher.Init(config, runtime->launcher_host)); + if (initialized != ENGINE_RESULT_OK) { + if (runtime->error.empty()) runtime->error = runtime->launcher.last_error(); + return initialized; + } + if (runtime->surface_width && runtime->surface_height) { + const auto resized = MapLauncherStatus(runtime->launcher.SetSurfaceSize( + runtime->surface_width, runtime->surface_height)); + if (resized != ENGINE_RESULT_OK) return resized; + } + } + runtime->error.clear(); + const int status = runtime->launcher.Tick(delta_ms); + if (status == RENPY_MOBILE_FINISHED) { + runtime->error = "Ren'Py runtime requested termination"; + return ENGINE_RESULT_INVALID_STATE; + } + const auto result = MapLauncherStatus(status); + if (result != ENGINE_RESULT_OK) { + if (runtime->error.empty()) runtime->error = runtime->launcher.last_error(); + return result; + } + const auto frame_result = CaptureFrame(runtime); + if (frame_result != ENGINE_RESULT_OK) return frame_result; + return ENGINE_RESULT_OK; +} + +engine_result_t Pause(void* value) { + if (!value) return ENGINE_RESULT_INVALID_ARGUMENT; + auto* runtime = Cast(value); + if (!runtime->opened) return ENGINE_RESULT_INVALID_STATE; + const auto result = runtime->launcher.initialized() + ? MapLauncherStatus(runtime->launcher.Pause()) : ENGINE_RESULT_OK; + if (result == ENGINE_RESULT_OK) runtime->paused = true; + return result; +} + +engine_result_t Resume(void* value) { + if (!value) return ENGINE_RESULT_INVALID_ARGUMENT; + auto* runtime = Cast(value); + if (!runtime->opened) return ENGINE_RESULT_INVALID_STATE; + const auto result = runtime->launcher.initialized() + ? MapLauncherStatus(runtime->launcher.Resume()) : ENGINE_RESULT_OK; + if (result == ENGINE_RESULT_OK) runtime->paused = false; + return result; +} + +engine_result_t SetSurfaceSize(void* value, uint32_t width, uint32_t height) { + if (!value || width == 0 || height == 0) return ENGINE_RESULT_INVALID_ARGUMENT; + auto* runtime = Cast(value); + runtime->surface_width = width; + runtime->surface_height = height; + if (runtime->launcher.initialized()) return MapLauncherStatus(runtime->launcher.SetSurfaceSize(width, height)); + return ENGINE_RESULT_OK; +} + +engine_result_t FrameDesc(void* value, engine_frame_desc_t* output) { + if (!value || !output || output->struct_size < sizeof(*output)) { + return ENGINE_RESULT_INVALID_ARGUMENT; + } + auto* runtime = Cast(value); + std::lock_guard lock(runtime->frame_mutex); + output->width = runtime->frame_width; + output->height = runtime->frame_height; + output->stride_bytes = runtime->frame_stride; + output->pixel_format = ENGINE_PIXEL_FORMAT_RGBA8888; + output->frame_serial = runtime->frame_serial; + return ENGINE_RESULT_OK; +} + +engine_result_t ReadFrame(void* value, void* output, size_t output_size) { + if (!value || !output) return ENGINE_RESULT_INVALID_ARGUMENT; + auto* runtime = Cast(value); + std::lock_guard lock(runtime->frame_mutex); + if (runtime->frame_pixels.empty()) return ENGINE_RESULT_INVALID_STATE; + if (output_size < runtime->frame_pixels.size()) return ENGINE_RESULT_INVALID_ARGUMENT; + std::memcpy(output, runtime->frame_pixels.data(), runtime->frame_pixels.size()); + runtime->delivered_serial = runtime->frame_serial; + return ENGINE_RESULT_OK; +} + +engine_result_t NativeFrame(void* value, uint64_t*, uint32_t*, uint32_t*, + uint64_t*) { + // The lifecycle ABI owns an RGBA CPU buffer. It intentionally does not + // expose an SDL/GLES texture that could steal Godot's render target. + return value ? ENGINE_RESULT_NOT_SUPPORTED : ENGINE_RESULT_INVALID_ARGUMENT; +} + +engine_result_t Input(void* value, const engine_input_event_t* event) { + if (!value || !event || event->struct_size < sizeof(*event)) { + return ENGINE_RESULT_INVALID_ARGUMENT; + } + auto* runtime = Cast(value); + if (!runtime->opened) return ENGINE_RESULT_INVALID_STATE; + if (!std::isfinite(event->x) || !std::isfinite(event->y) || + !std::isfinite(event->delta_x) || !std::isfinite(event->delta_y)) return ENGINE_RESULT_INVALID_ARGUMENT; + renpy_mobile_input_t input{}; + input.struct_size = sizeof(input); + input.timestamp_ns = event->timestamp_micros * 1000ull; + input.device_id = event->pointer_id; + input.code = input_mapping::MapKeyCodeToPygame(event->key_code, + static_cast(event->reserved_u32)); + input.value = static_cast(event->type); + { + std::lock_guard lock(runtime->frame_mutex); + const auto width = runtime->frame_width ? runtime->frame_width : runtime->surface_width; + const auto height = runtime->frame_height ? runtime->frame_height : runtime->surface_height; + input.x = static_cast(event->x / (width ? width : 1280u)); + input.y = static_cast(event->y / (height ? height : 720u)); + } + input.delta_x = static_cast(event->delta_x); + input.delta_y = static_cast(event->delta_y); + input.modifiers = input_mapping::MapModifiersToPygame(event->modifiers); + input.repeat = input_mapping::IsAetherKeyRepeat(event->modifiers) ? 1u : 0u; + input.pressure = (event->type == ENGINE_INPUT_EVENT_POINTER_UP) ? 0.0f : 1.0f; + std::string text; + switch (event->type) { + case ENGINE_INPUT_EVENT_POINTER_DOWN: + case ENGINE_INPUT_EVENT_POINTER_MOVE: + case ENGINE_INPUT_EVENT_POINTER_UP: + case ENGINE_INPUT_EVENT_POINTER_SCROLL: + input.type = RENPY_MOBILE_INPUT_POINTER; + input.code = input_mapping::MapPointerButtonToPygame(event->button); + break; + case ENGINE_INPUT_EVENT_KEY_DOWN: + case ENGINE_INPUT_EVENT_KEY_UP: + input.type = RENPY_MOBILE_INPUT_KEY; + break; + case ENGINE_INPUT_EVENT_TEXT_INPUT: { + input.type = RENPY_MOBILE_INPUT_TEXT; + uint32_t cp = event->unicode_codepoint; + if (cp == 0 || cp > 0x10ffffu || (cp >= 0xd800u && cp <= 0xdfffu)) return ENGINE_RESULT_INVALID_ARGUMENT; + if (cp <= 0x7fu) text.push_back(static_cast(cp)); + else if (cp <= 0x7ffu) { + text.push_back(static_cast(0xc0u | (cp >> 6))); + text.push_back(static_cast(0x80u | (cp & 0x3fu))); + } else if (cp <= 0xffffu) { + text.push_back(static_cast(0xe0u | (cp >> 12))); + text.push_back(static_cast(0x80u | ((cp >> 6) & 0x3fu))); + text.push_back(static_cast(0x80u | (cp & 0x3fu))); + } else if (cp <= 0x10ffffu) { + text.push_back(static_cast(0xf0u | (cp >> 18))); + text.push_back(static_cast(0x80u | ((cp >> 12) & 0x3fu))); + text.push_back(static_cast(0x80u | ((cp >> 6) & 0x3fu))); + text.push_back(static_cast(0x80u | (cp & 0x3fu))); + } + input.text_utf8 = text.c_str(); + break; + } + case ENGINE_INPUT_EVENT_BACK: + input.type = RENPY_MOBILE_INPUT_KEY; + input.code = 27; // Ren'Py maps Android Back to Escape/game menu. + break; + default: + return ENGINE_RESULT_INVALID_ARGUMENT; + } + return MapLauncherStatus(runtime->launcher.Input(input)); +} + +engine_result_t Rendered(void* value, uint32_t* output) { + if (!value || !output) return ENGINE_RESULT_INVALID_ARGUMENT; + auto* runtime = Cast(value); + *output = runtime->frame_rendered ? 1u : 0u; + return ENGINE_RESULT_OK; +} + +engine_result_t Renderer(void* value, char* buffer, uint32_t size) { + if (!value || !buffer || size == 0) return ENGINE_RESULT_INVALID_ARGUMENT; + constexpr char kRenderer[] = + "Ren'Py mobile host lifecycle (host-owned RGBA frame)"; + if (size < sizeof(kRenderer)) { + Cast(value)->error = "Ren'Py mobile renderer description buffer is too small"; + return ENGINE_RESULT_INVALID_ARGUMENT; + } + std::memcpy(buffer, kRenderer, sizeof(kRenderer)); + return ENGINE_RESULT_OK; +} + +engine_result_t Unsupported(void* value) { + if (!value) return ENGINE_RESULT_INVALID_ARGUMENT; + Cast(value)->error = + "Ren'Py mobile runtime operation requires the rebuilt host lifecycle ABI"; + return ENGINE_RESULT_NOT_SUPPORTED; +} + +engine_result_t UnsupportedOption(void* value, const engine_option_t* option) { + if (!value || !option || !option->key_utf8 || !option->value_utf8) { + return ENGINE_RESULT_INVALID_ARGUMENT; + } + return Unsupported(value); +} + +engine_result_t TextState(void* value, uint32_t* active) { + if (!value || !active) return ENGINE_RESULT_INVALID_ARGUMENT; + auto* runtime = Cast(value); + *active = 0; + if (!runtime->launcher.initialized() || runtime->launcher.finished()) return ENGINE_RESULT_OK; + return MapLauncherStatus(runtime->launcher.TextInputState(active)); +} + +engine_result_t DebugInfo(void* value, char* buffer, uint32_t size, uint32_t* written) { + if (!value || !written) return ENGINE_RESULT_INVALID_ARGUMENT; + auto* runtime = Cast(value); + std::ostringstream json; + json << "{\"runtime\":\"renpy\",\"initialized\":" << (runtime->launcher.initialized() ? "true" : "false") + << ",\"paused\":" << (runtime->paused ? "true" : "false") + << ",\"exited\":" << (runtime->launcher.finished() ? "true" : "false") + << ",\"frame_serial\":" << runtime->frame_serial << "}"; + const auto text = json.str(); + *written = static_cast(text.size()); + if (!buffer || size <= text.size()) return ENGINE_RESULT_INVALID_ARGUMENT; + std::memcpy(buffer, text.c_str(), text.size() + 1u); + return ENGINE_RESULT_OK; +} + +const char* Error(void* value) { + return value ? Cast(value)->error.c_str() : "Invalid Ren'Py mobile runtime"; +} + +const engine_runtime_provider_v1_t& Provider() { + static const engine_runtime_provider_v1_t provider = [] { + engine_runtime_provider_v1_t p{}; + p.struct_size = sizeof(p); + p.api_version = ENGINE_RUNTIME_PROVIDER_API_VERSION; + p.runtime_id_utf8 = "renpy"; + p.display_name_utf8 = "Ren'Py (mobile host lifecycle)"; + p.priority = 60; + p.probe = Probe; + p.create = Create; + p.destroy = Destroy; + p.open_game = Open; + p.tick = Tick; + p.pause = Pause; + p.resume = Resume; + p.set_option = UnsupportedOption; + p.set_surface_size = SetSurfaceSize; + p.get_frame_desc = FrameDesc; + p.read_frame_rgba = ReadFrame; + p.get_godot_native_frame_texture = NativeFrame; + p.send_input = Input; + p.get_frame_rendered_flag = Rendered; + p.get_renderer_info = Renderer; + p.get_text_input_state = TextState; + p.get_plugin_debug_info = DebugInfo; + p.get_last_error = Error; + return p; + }(); + return provider; +} + +} // namespace + +void RegisterRuntimeProvider() { + static std::once_flag once; + std::call_once(once, [] { engine_register_runtime_provider(&Provider()); }); +} + +} // namespace aetherkiri::renpy diff --git a/bridge/renpy_runtime/src/renpy_runtime_provider.cpp b/bridge/renpy_runtime/src/renpy_runtime_provider.cpp new file mode 100644 index 000000000..f7ba13a4c --- /dev/null +++ b/bridge/renpy_runtime/src/renpy_runtime_provider.cpp @@ -0,0 +1,1162 @@ +#include "renpy_input_mapping.h" +#include "renpy_runtime.h" +#include "renpy_project.h" +#include "engine_runtime_provider.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#define NOMINMAX +#include +#else +#if defined(__APPLE__) +#include +#endif +#include +#include +#include +#include +#include +#include +#if defined(__APPLE__) +#include +#else +extern char** environ; +#endif +#endif + +#ifndef AETHERKIRI_RENPY_SDK_ROOT +#define AETHERKIRI_RENPY_SDK_ROOT "" +#endif +#ifndef AETHERKIRI_RENPY_OVERLAY_SOURCE +#define AETHERKIRI_RENPY_OVERLAY_SOURCE "" +#endif + +namespace aetherkiri::renpy { +namespace { +namespace fs = std::filesystem; +#if !defined(_WIN32) +char** ProcessEnvironment() { +#if defined(__APPLE__) + return *_NSGetEnviron(); +#else + return environ; +#endif +} +#endif + +constexpr const char* kOverlayFileName = "aetherkiri-renpy-overlay.py"; + +fs::path CurrentExecutablePath() { +#if defined(_WIN32) + std::wstring buffer(MAX_PATH, L'\0'); + for (;;) { + const DWORD size = GetModuleFileNameW( + nullptr, buffer.data(), static_cast(buffer.size())); + if (size == 0) return {}; + if (size < buffer.size() - 1) { + buffer.resize(size); + return fs::path(buffer); + } + buffer.resize(buffer.size() * 2); + } +#elif defined(__APPLE__) + uint32_t size = 0; + if (_NSGetExecutablePath(nullptr, &size) != -1 || size == 0) return {}; + std::string buffer(size, '\0'); + if (_NSGetExecutablePath(buffer.data(), &size) != 0) return {}; + return fs::path(buffer.c_str()); +#else + std::error_code ec; + const fs::path path = fs::read_symlink("/proc/self/exe", ec); + return ec ? fs::path() : path; +#endif +} + +fs::path BundledResourcePath(const char* filename) { + const fs::path executable = CurrentExecutablePath(); + if (executable.empty()) return {}; + const fs::path executable_dir = executable.parent_path(); +#if defined(__APPLE__) + const std::array candidates = { + executable_dir / filename, + executable_dir / ".." / "Resources" / filename, + executable_dir / ".." / "Frameworks" / filename, + }; +#else + const std::array candidates = { + executable_dir / filename, + executable_dir / "resources" / filename, + }; +#endif + std::error_code ec; + for (const fs::path& candidate : candidates) { + if (fs::is_regular_file(candidate, ec)) return candidate; + } + return {}; +} + +fs::path BundledSdkPath() { + const fs::path executable = CurrentExecutablePath(); + if (executable.empty()) return {}; + const fs::path executable_dir = executable.parent_path(); +#if defined(__APPLE__) + const std::array candidates = { + executable_dir / ".." / "Resources" / "renpy-sdk", + executable_dir / ".." / "Frameworks" / "renpy-sdk", + executable_dir / "renpy-sdk", + executable_dir / ".." / "Resources" / "RenPy", + }; +#else + const std::array candidates = { + executable_dir / "renpy-sdk", + executable_dir / "resources" / "renpy-sdk", + }; +#endif + std::error_code ec; + for (const fs::path& candidate : candidates) { + if (fs::is_directory(candidate, ec)) return candidate; + } + return {}; +} + +fs::path ResolveOverlaySource() { + std::error_code ec; + if (const char* configured = std::getenv("AETHERKIRI_RENPY_OVERLAY_SOURCE"); + configured && *configured && + fs::is_regular_file(fs::u8path(configured), ec)) { + return fs::u8path(configured); + } + const fs::path compiled = fs::u8path(AETHERKIRI_RENPY_OVERLAY_SOURCE); + if (!compiled.empty() && fs::is_regular_file(compiled, ec)) return compiled; + const fs::path bundled = BundledResourcePath(kOverlayFileName); + if (!bundled.empty()) return bundled; + return {}; +} + +const char* UnsupportedFrameMessage() { + return "Ren'Py frame bridge has not published a frame yet"; +} + +constexpr std::array kFrameMagic = { + {'A', 'K', 'R', 'F', '1', '\0', '\0', '\0'}}; +constexpr size_t kFrameHeaderSize = 8 + 4 + 4 + 8 + 4; +constexpr uint32_t kFramePixelFormatRgba8888 = ENGINE_PIXEL_FORMAT_RGBA8888; + +uint32_t ReadU32(const uint8_t* data) { + return static_cast(data[0]) | + (static_cast(data[1]) << 8u) | + (static_cast(data[2]) << 16u) | + (static_cast(data[3]) << 24u); +} + +uint64_t ReadU64(const uint8_t* data) { + uint64_t value = 0; + for (int i = 0; i < 8; ++i) + value |= static_cast(data[i]) << (8u * i); + return value; +} + +std::string Hex(uint64_t value) { + std::ostringstream stream; + stream << std::hex << value; + return stream.str(); +} + +std::string JsonNumber(double value) { + if (!std::isfinite(value)) return "0"; + std::ostringstream stream; + stream << std::setprecision(17) << value; + return stream.str(); +} + +using namespace input_mapping; + +std::string Utf8Codepoint(uint32_t value) { + if (value == 0 || value > 0x10ffffu || + (value >= 0xd800u && value <= 0xdfffu)) return {}; + std::string result; + if (value <= 0x7fu) { + result.push_back(static_cast(value)); + } else if (value <= 0x7ffu) { + result.push_back(static_cast(0xc0u | (value >> 6u))); + result.push_back(static_cast(0x80u | (value & 0x3fu))); + } else if (value <= 0xffffu) { + result.push_back(static_cast(0xe0u | (value >> 12u))); + result.push_back(static_cast(0x80u | ((value >> 6u) & 0x3fu))); + result.push_back(static_cast(0x80u | (value & 0x3fu))); + } else { + result.push_back(static_cast(0xf0u | (value >> 18u))); + result.push_back(static_cast(0x80u | ((value >> 12u) & 0x3fu))); + result.push_back(static_cast(0x80u | ((value >> 6u) & 0x3fu))); + result.push_back(static_cast(0x80u | (value & 0x3fu))); + } + return result; +} + +std::string JsonEscape(const std::string& value) { + std::ostringstream stream; + stream << '"'; + for (unsigned char c : value) { + switch (c) { + case '"': stream << "\\\""; break; + case '\\': stream << "\\\\"; break; + case '\b': stream << "\\b"; break; + case '\f': stream << "\\f"; break; + case '\n': stream << "\\n"; break; + case '\r': stream << "\\r"; break; + case '\t': stream << "\\t"; break; + default: + if (c < 0x20u) { + stream << "\\u00" << std::hex << std::setw(2) + << std::setfill('0') << static_cast(c) + << std::dec << std::setfill(' '); + } else { + stream << static_cast(c); + } + break; + } + } + stream << '"'; + return stream.str(); +} + +fs::path FindLauncher(const fs::path& sdk) { +#if defined(_WIN32) + const char* names[] = {"renpy.exe", "renpy"}; +#elif defined(__APPLE__) + const char* names[] = {"renpy", "renpy.sh"}; +#else + const char* names[] = {"renpy", "renpy.sh"}; +#endif + std::error_code ec; + for (const char* name : names) { + const fs::path candidate = sdk / name; + if (fs::is_regular_file(candidate, ec)) return candidate; + } + return {}; +} + +#if defined(_WIN32) +std::wstring QuoteWindowsArg(const std::wstring& arg) { + // CommandLineToArgvW-compatible quoting. Never pass user input through a + // shell and preserve trailing backslashes before a closing quote. + std::wstring result = L"\""; + size_t slashes = 0; + for (wchar_t c : arg) { + if (c == L'\\') { ++slashes; continue; } + if (c == L'\"') { + result.append(slashes * 2 + 1, L'\\'); + result.push_back(L'\"'); + slashes = 0; + continue; + } + result.append(slashes, L'\\'); + slashes = 0; + result.push_back(c); + } + result.append(slashes * 2, L'\\'); + result.push_back(L'\"'); + return result; +} + +std::wstring Utf8ToWide(const std::string& value) { + if (value.empty()) return {}; + const int size = MultiByteToWideChar(CP_UTF8, 0, value.data(), + static_cast(value.size()), nullptr, 0); + if (size <= 0) return {}; + std::wstring result(static_cast(size), L'\0'); + if (MultiByteToWideChar(CP_UTF8, 0, value.data(), + static_cast(value.size()), result.data(), size) <= 0) + return {}; + return result; +} +#endif + +class SdkProcess final { +public: + SdkProcess() = default; + ~SdkProcess() { Stop(); } + SdkProcess(const SdkProcess&) = delete; + SdkProcess& operator=(const SdkProcess&) = delete; + + bool Start(const fs::path& launcher, const fs::path& project, + const std::vector>& environment, + std::string* error) { + Stop(); +#if defined(_WIN32) + std::wstring launcher_w = launcher.wstring(); + std::wstring command = QuoteWindowsArg(launcher_w) + L" " + + QuoteWindowsArg(project.wstring()); + std::wstring environment_block; + if (!environment.empty()) { + std::vector values; + LPWCH current = GetEnvironmentStringsW(); + if (current == nullptr) { + if (error) *error = "failed to read process environment"; + return false; + } + for (LPWCH entry = current; *entry != L'\0'; + entry += std::wcslen(entry) + 1) + values.emplace_back(entry); + FreeEnvironmentStringsW(current); + for (const auto& [key, value] : environment) { + const std::wstring wide_key = Utf8ToWide(key); + const std::wstring wide_value = Utf8ToWide(value); + const std::wstring prefix = wide_key + L"="; + const auto found = std::find_if( + values.begin(), values.end(), [&](const std::wstring& existing) { + return existing.compare(0, prefix.size(), prefix) == 0; + }); + const std::wstring replacement = prefix + wide_value; + if (found == values.end()) values.push_back(replacement); + else *found = replacement; + } + for (const std::wstring& value : values) { + environment_block.append(value); + environment_block.push_back(L'\0'); + } + environment_block.push_back(L'\0'); + } + STARTUPINFOW startup{}; + startup.cb = sizeof(startup); + PROCESS_INFORMATION process{}; + std::vector mutable_command(command.begin(), command.end()); + mutable_command.push_back(L'\0'); + const DWORD creation_flags = CREATE_NO_WINDOW | + (environment_block.empty() ? 0u : CREATE_UNICODE_ENVIRONMENT); + if (!CreateProcessW(launcher_w.c_str(), mutable_command.data(), nullptr, + nullptr, FALSE, + creation_flags | CREATE_SUSPENDED, nullptr, + environment_block.empty() ? nullptr + : environment_block.data(), + &startup, &process)) { + if (error) *error = "failed to launch Ren'Py SDK: " + + std::to_string(GetLastError()); + return false; + } + // A Ren'Py launcher may hand work to Python helper processes. A Job + // Object makes those descendants part of this runtime's ownership + // tree, so closing the runtime cannot leave them behind. + HANDLE job = CreateJobObjectW(nullptr, nullptr); + if (job == nullptr) { + TerminateProcess(process.hProcess, 0xC000013A); + (void)WaitForSingleObject(process.hProcess, 2000); + CloseHandle(process.hThread); + CloseHandle(process.hProcess); + if (error) *error = "failed to create Ren'Py process job: " + + std::to_string(GetLastError()); + return false; + } + JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits{}; + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation, + &limits, sizeof(limits)) || + !AssignProcessToJobObject(job, process.hProcess)) { + const DWORD failure = GetLastError(); + TerminateProcess(process.hProcess, 0xC000013A); + (void)WaitForSingleObject(process.hProcess, 2000); + CloseHandle(process.hThread); + CloseHandle(process.hProcess); + CloseHandle(job); + if (error) *error = "failed to own Ren'Py process tree: " + + std::to_string(failure); + return false; + } + if (ResumeThread(process.hThread) == static_cast(-1)) { + const DWORD failure = GetLastError(); + TerminateProcess(process.hProcess, 0xC000013A); + (void)WaitForSingleObject(process.hProcess, 2000); + CloseHandle(process.hThread); + CloseHandle(process.hProcess); + CloseHandle(job); + if (error) *error = "failed to start Ren'Py process: " + + std::to_string(failure); + return false; + } + CloseHandle(process.hThread); + process_ = process.hProcess; + job_ = job; + return true; +#else + const std::string exe = launcher.string(); + const std::string root = project.string(); + char* const argv[] = {const_cast(exe.c_str()), + const_cast(root.c_str()), nullptr}; + std::vector environment_storage; + std::vector environment_argv; + if (!environment.empty()) { + for (char** entry = ProcessEnvironment(); entry != nullptr && *entry != nullptr; + ++entry) + environment_storage.emplace_back(*entry); + for (const auto& [key, value] : environment) { + const std::string prefix = key + "="; + const auto found = std::find_if( + environment_storage.begin(), environment_storage.end(), + [&](const std::string& existing) { + return existing.compare(0, prefix.size(), prefix) == 0; + }); + const std::string replacement = prefix + value; + if (found == environment_storage.end()) + environment_storage.push_back(replacement); + else + *found = replacement; + } + environment_argv.reserve(environment_storage.size() + 1); + for (std::string& entry : environment_storage) + environment_argv.push_back(entry.data()); + environment_argv.push_back(nullptr); + } + // posix_spawn avoids running C++ code in a post-fork child of the + // multithreaded Godot host. It also preserves argv boundaries and + // never invokes a shell. Put the SDK and any children it starts in a + // private process group so shutdown cannot leave a Python child behind. + posix_spawnattr_t attributes; + if (posix_spawnattr_init(&attributes) != 0) { + if (error) *error = "failed to initialize Ren'Py process attributes"; + return false; + } + const short flags = POSIX_SPAWN_SETPGROUP; + int result = posix_spawnattr_setflags(&attributes, flags); + if (result == 0) result = posix_spawnattr_setpgroup(&attributes, 0); + pid_t child = -1; + if (result == 0) { + result = posix_spawn(&child, exe.c_str(), nullptr, &attributes, + argv, environment.empty() ? ProcessEnvironment() + : environment_argv.data()); + } + (void)posix_spawnattr_destroy(&attributes); + if (result != 0) { + if (error) *error = "failed to launch Ren'Py SDK: " + + std::string(std::strerror(result)); + return false; + } + leader_pid_ = child; + process_group_id_ = child; + return true; +#endif + } + + bool Running(std::string* error) { +#if defined(_WIN32) + if (process_ == nullptr) return false; + const DWORD result = WaitForSingleObject(process_, 0); + if (result == WAIT_TIMEOUT) return true; + if (result == WAIT_OBJECT_0) { + DWORD exit_code = 0; + GetExitCodeProcess(process_, &exit_code); + if (error) *error = "Ren'Py SDK exited with status " + + std::to_string(exit_code); + CloseHandle(process_); process_ = nullptr; + return false; + } + if (error) *error = "failed to query Ren'Py SDK process"; + return false; +#else + if (leader_pid_ <= 0) return false; + int status = 0; + pid_t result = -1; + do { + result = waitpid(leader_pid_, &status, WNOHANG); + } while (result < 0 && errno == EINTR); + if (result == 0) return true; + if (result == leader_pid_) { + leader_pid_ = -1; + if (error) { + if (WIFEXITED(status)) *error = "Ren'Py SDK exited with status " + + std::to_string(WEXITSTATUS(status)); + else if (WIFSIGNALED(status)) *error = "Ren'Py SDK terminated by signal " + + std::to_string(WTERMSIG(status)); + else *error = "Ren'Py SDK exited"; + } + return false; + } + if (result < 0 && errno != EINTR && error) *error = std::strerror(errno); + return result == 0; +#endif + } + + void Pause(bool pause) { +#if defined(_WIN32) + // The Windows SDK does not expose a stable suspend API; leave process + // ownership intact and let the caller receive NOT_SUPPORTED. + (void)pause; +#else + if (leader_pid_ > 0) (void)kill(process_group_id_ > 0 ? -process_group_id_ + : leader_pid_, + pause ? SIGSTOP : SIGCONT); +#endif + } + + void Stop() { +#if defined(_WIN32) + if (process_ != nullptr) { + if (WaitForSingleObject(process_, 0) == WAIT_TIMEOUT) { + TerminateProcess(process_, 0xC000013A); + WaitForSingleObject(process_, 2000); + } + CloseHandle(process_); process_ = nullptr; + } + if (job_ != nullptr) { + // JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE terminates any descendants + // that outlived the launcher, including after its handle closed. + CloseHandle(job_); job_ = nullptr; + } +#else + StopPosix(); +#endif + } + + bool valid() const { +#if defined(_WIN32) + return process_ != nullptr; +#else + return leader_pid_ > 0; +#endif + } + +private: +#if defined(_WIN32) + HANDLE process_ = nullptr; + HANDLE job_ = nullptr; +#else + pid_t leader_pid_ = -1; + pid_t process_group_id_ = -1; +#endif + +#if !defined(_WIN32) + void StopPosix() { + const pid_t leader = leader_pid_; + const pid_t group = process_group_id_; + if (leader <= 0 && group <= 0) return; + + auto group_exists = [group] { + if (group <= 0) return false; + errno = 0; + const int result = kill(-group, 0); + return result == 0 || errno == EPERM; + }; + auto reap_leader = [this] { + if (leader_pid_ <= 0) return false; + int status = 0; + for (;;) { + const pid_t result = waitpid(leader_pid_, &status, WNOHANG); + if (result == leader_pid_ || (result < 0 && errno == ECHILD)) { + leader_pid_ = -1; + return true; + } + if (result < 0 && errno == EINTR) continue; + return false; + } + }; + + // SIGSTOP also stops the leader, so always continue the group before + // asking it to terminate. Otherwise a paused SDK survives the grace + // period and is only killed, skipping normal signal handling. + if (group_exists()) { + (void)kill(-group, SIGCONT); + (void)kill(-group, SIGTERM); + } else { + (void)reap_leader(); + } + + // Bound graceful shutdown. We only signal a group while kill(..., 0) + // confirms that the original process group still exists; this avoids + // sending a stale PGID to an unrelated process after it disappears. + for (int i = 0; i < 200; ++i) { + (void)reap_leader(); + if (!group_exists()) { + leader_pid_ = -1; + process_group_id_ = -1; + return; + } + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + if (group_exists()) { + (void)kill(-group, SIGCONT); + (void)kill(-group, SIGKILL); + } + // A leader that ignored SIGTERM is still our child; reap it without + // waiting on arbitrary descendants. + if (leader_pid_ > 0) { + for (;;) { + const pid_t result = waitpid(leader_pid_, nullptr, 0); + if (result == leader_pid_ || (result < 0 && errno == ECHILD)) break; + if (result < 0 && errno == EINTR) continue; + break; + } + } + leader_pid_ = -1; + process_group_id_ = -1; + } +#endif +}; + +struct Runtime { + engine_runtime_host_v1_t host{}; + fs::path sdk_root = fs::path(AETHERKIRI_RENPY_SDK_ROOT); + bool sdk_path_explicit = false; + fs::path project; + fs::path bridge_root; + fs::path staged_module; + fs::path staged_loader; + fs::path frame_path; + fs::path input_path; + fs::path error_path; + bool staged_libs_directory = false; + uint32_t frame_width = 0; + uint32_t frame_height = 0; + uint64_t frame_serial = 0; + std::vector frame_pixels; + std::mutex frame_mutex; + SdkProcess process; + bool opened = false; + bool paused = false; + std::string error; + + ~Runtime() { + if (opened && !input_path.empty() && process.valid()) { + if (paused) process.Pause(false); + // Give the overlay a chance to run Ren'Py's normal cleanup before + // bounded process-tree termination. A signal alone becomes an + // SDL quit event and can open a game's confirmation dialog. + std::ofstream stream(input_path, std::ios::binary | std::ios::app); + stream << "{\"type\":256,\"attributes\":{},\"aetherkiri_host_shutdown\":true}\n"; + stream.close(); + const auto deadline = std::chrono::steady_clock::now() + + std::chrono::milliseconds(1000); + while (process.Running(nullptr) && std::chrono::steady_clock::now() < deadline) + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + process.Stop(); + CleanupBridge(); + } + + void CleanupBridge() { + std::error_code ec; + if (!staged_module.empty()) fs::remove(staged_module, ec); + ec.clear(); + if (!staged_loader.empty()) fs::remove(staged_loader, ec); + ec.clear(); + if (staged_libs_directory) { + const fs::path libs = project / "game" / "libs"; + fs::remove(libs, ec); + } + ec.clear(); + if (!frame_path.empty()) fs::remove(frame_path, ec); + ec.clear(); + if (!input_path.empty()) fs::remove(input_path, ec); + ec.clear(); + if (!error_path.empty()) fs::remove(error_path, ec); + ec.clear(); + if (!bridge_root.empty()) fs::remove_all(bridge_root, ec); + staged_module.clear(); + staged_loader.clear(); + bridge_root.clear(); + frame_path.clear(); + input_path.clear(); + error_path.clear(); + staged_libs_directory = false; + } +}; + +Runtime* Cast(void* value) { return static_cast(value); } + +std::atomic g_bridge_counter{0}; + +uint64_t CurrentProcessIdValue() { +#if defined(_WIN32) + return static_cast(GetCurrentProcessId()); +#else + return static_cast(getpid()); +#endif +} + +bool WriteTextFile(const fs::path& path, const std::string& text, + std::string* error) { + std::ofstream stream(path, std::ios::binary | std::ios::trunc); + if (!stream) { + if (error) *error = "failed to stage Ren'Py overlay file: " + path.string(); + return false; + } + stream.write(text.data(), static_cast(text.size())); + stream.flush(); + if (!stream) { + if (error) *error = "failed to write Ren'Py overlay file: " + path.string(); + return false; + } + return true; +} + +bool PrepareBridge(Runtime& runtime) { + runtime.CleanupBridge(); + const fs::path source = ResolveOverlaySource(); + std::error_code ec; + if (source.empty() || !fs::is_regular_file(source, ec)) { + runtime.error = + "Ren'Py overlay source is unavailable in this build or bundle; " + "frame bridge cannot be activated"; + return false; + } + std::ifstream source_stream(source, std::ios::binary); + if (!source_stream) { + runtime.error = "failed to read Ren'Py overlay source: " + source.string(); + return false; + } + const std::string source_text((std::istreambuf_iterator(source_stream)), + std::istreambuf_iterator()); + if (source_text.empty()) { + runtime.error = "Ren'Py overlay source is empty"; + return false; + } + + const fs::path libs = runtime.project / "game" / "libs"; + if (fs::exists(libs, ec)) { + if (!fs::is_directory(libs, ec)) { + runtime.error = "Ren'Py game/libs exists but is not a directory"; + return false; + } + } else { + ec.clear(); + if (!fs::create_directory(libs, ec)) { + runtime.error = "Ren'Py game/libs is not writable: " + ec.message(); + return false; + } + runtime.staged_libs_directory = true; + } + + const uint64_t id = g_bridge_counter.fetch_add(1) + 1; + const std::string token = "ak_" + Hex(CurrentProcessIdValue()) + "_" + + Hex(id) + "_" + + Hex(static_cast( + std::chrono::steady_clock::now() + .time_since_epoch() + .count())); + runtime.staged_module = libs / ("aetherkiri_overlay_" + token + ".py"); + runtime.staged_loader = libs / ("aetherkiri_loader_" + token + ".rpe.py"); + runtime.frame_path = fs::temp_directory_path() / + ("aetherkiri-renpy-frame-" + token + ".bin"); + runtime.input_path = fs::temp_directory_path() / + ("aetherkiri-renpy-input-" + token + ".jsonl"); + runtime.error_path = fs::temp_directory_path() / + ("aetherkiri-renpy-error-" + token + ".log"); + runtime.bridge_root = fs::temp_directory_path() / + ("aetherkiri-renpy-bridge-" + token); + ec.clear(); + if (!fs::create_directory(runtime.bridge_root, ec)) { + if (fs::is_directory(runtime.bridge_root, ec)) { + runtime.error = "Ren'Py bridge transport directory already exists"; + } else { + runtime.error = + "failed to create Ren'Py bridge transport directory: " + + ec.message(); + } + runtime.CleanupBridge(); + return false; + } + + const std::string module_name = runtime.staged_module.stem().string(); + const std::string loader = + "import os, sys\n" + "if os.environ.get('AETHERKIRI_RENPY_OVERLAY') == '1':\n" + " sys.dont_write_bytecode = True\n" + " sys.path.insert(0, os.path.dirname(__file__))\n" + " import " + module_name + "\n" + " " + module_name + ".install()\n"; + if (!WriteTextFile(runtime.staged_module, source_text, &runtime.error) || + !WriteTextFile(runtime.staged_loader, loader, &runtime.error)) { + runtime.CleanupBridge(); + return false; + } + std::ofstream(runtime.input_path, std::ios::binary | std::ios::trunc).close(); + if (!fs::exists(runtime.input_path, ec)) { + runtime.error = "failed to create Ren'Py input transport"; + runtime.CleanupBridge(); + return false; + } + return true; +} + +bool RefreshFrame(Runtime& runtime) { + if (runtime.frame_path.empty()) return false; + std::ifstream stream(runtime.frame_path, std::ios::binary); + if (!stream) return false; + std::array header{}; + stream.read(reinterpret_cast(header.data()), + static_cast(header.size())); + if (stream.gcount() != static_cast(header.size())) return false; + if (!std::equal(kFrameMagic.begin(), kFrameMagic.end(), header.begin())) { + runtime.error = "Ren'Py frame transport has an invalid magic header"; + return false; + } + const uint32_t width = ReadU32(header.data() + 8); + const uint32_t height = ReadU32(header.data() + 12); + const uint64_t serial = ReadU64(header.data() + 16); + const uint32_t payload_size = ReadU32(header.data() + 24); + const uint64_t expected = static_cast(width) * height * 4u; + if (width == 0 || height == 0 || serial == 0 || expected != payload_size || + expected > 256u * 1024u * 1024u) { + runtime.error = "Ren'Py frame transport has invalid dimensions or payload"; + return false; + } + std::vector pixels(payload_size); + stream.read(reinterpret_cast(pixels.data()), + static_cast(pixels.size())); + if (stream.gcount() != static_cast(pixels.size())) return false; + { + std::lock_guard guard(runtime.frame_mutex); + if (serial <= runtime.frame_serial) return true; + runtime.frame_width = width; + runtime.frame_height = height; + runtime.frame_serial = serial; + runtime.frame_pixels = std::move(pixels); + runtime.error.clear(); + } + return true; +} + +void RefreshDiagnostics(Runtime& runtime) { + if (runtime.error_path.empty()) return; + std::ifstream stream(runtime.error_path, std::ios::binary); + if (!stream) return; + std::string text((std::istreambuf_iterator(stream)), + std::istreambuf_iterator()); + if (text.size() > 4096) text.erase(0, text.size() - 4096); + if (!text.empty()) runtime.error = "Ren'Py overlay: " + text; +} + +bool AppendInput(Runtime& runtime, const std::string& line) { + if (runtime.input_path.empty()) return false; + std::ofstream stream(runtime.input_path, std::ios::binary | std::ios::app); + if (!stream) { + runtime.error = "failed to open Ren'Py input transport"; + return false; + } + stream << line << '\n'; + stream.flush(); + if (!stream) { + runtime.error = "failed to write Ren'Py input transport"; + return false; + } + return true; +} + +int32_t Probe(void*, const char* path) { + if (!path || !*path) return 0; + try { return HasRenpyProject(fs::u8path(path)) ? 65 : 0; } + catch (...) { return 0; } +} + +engine_result_t Create(void*, const engine_runtime_host_v1_t* host, + const engine_create_desc_t* desc, void** output) { + if (!host || !desc || !output || host->struct_size < sizeof(*host) || + desc->struct_size < sizeof(*desc)) return ENGINE_RESULT_INVALID_ARGUMENT; + *output = nullptr; + try { + auto runtime = std::make_unique(); + runtime->host = *host; + *output = runtime.release(); + return ENGINE_RESULT_OK; + } catch (...) { return ENGINE_RESULT_INTERNAL_ERROR; } +} +void Destroy(void* value) { delete Cast(value); } + +engine_result_t Open(void* value, const char* path, const char* startup) { + if (!value || !path) return ENGINE_RESULT_INVALID_ARGUMENT; + auto& runtime = *Cast(value); + if (runtime.opened) { + runtime.error = "Ren'Py game is already open"; + return ENGINE_RESULT_INVALID_STATE; + } + (void)startup; // Ren'Py selects its own script from game/options.rpy. + runtime.project = fs::u8path(path); + std::error_code ec; + if (const char* configured = std::getenv("AETHERKIRI_RENPY_SDK_ROOT"); + configured && *configured && !runtime.sdk_path_explicit) { + runtime.sdk_root = fs::u8path(configured); + } else if (!runtime.sdk_path_explicit && (runtime.sdk_root.empty() || + !fs::is_directory(runtime.sdk_root, ec))) { + const fs::path bundled = BundledSdkPath(); + if (!bundled.empty()) runtime.sdk_root = bundled; + } + if (!HasRenpyProject(runtime.project)) { + runtime.error = "directory is not a Ren'Py project (expected game/script.rpy)"; + return ENGINE_RESULT_INVALID_ARGUMENT; + } + if (runtime.sdk_root.empty() || !fs::is_directory(runtime.sdk_root, ec)) { + runtime.error = "Ren'Py SDK path is not configured; set AETHERKIRI_RENPY_SDK_ROOT"; + return ENGINE_RESULT_NOT_SUPPORTED; + } + const fs::path launcher = FindLauncher(runtime.sdk_root); + if (launcher.empty()) { + runtime.error = "Ren'Py SDK launcher not found under configured SDK path"; + return ENGINE_RESULT_NOT_SUPPORTED; + } + if (!PrepareBridge(runtime)) return ENGINE_RESULT_NOT_SUPPORTED; + const std::vector> environment = { + {"AETHERKIRI_RENPY_OVERLAY", "1"}, + {"AETHERKIRI_RENPY_FRAME", runtime.frame_path.string()}, + {"AETHERKIRI_RENPY_INPUT", runtime.input_path.string()}, + {"AETHERKIRI_RENPY_ERROR", runtime.error_path.string()}, + {"RENPY_RENDERER", "sw"}, + }; + if (!runtime.process.Start(launcher, runtime.project, environment, + &runtime.error)) { + runtime.CleanupBridge(); + return ENGINE_RESULT_IO_ERROR; + } + if (!runtime.process.Running(&runtime.error)) { + runtime.error = runtime.error.empty() + ? "Ren'Py SDK exited during startup" : runtime.error; + runtime.CleanupBridge(); + return ENGINE_RESULT_IO_ERROR; + } + runtime.opened = true; + return ENGINE_RESULT_OK; +} + +engine_result_t Tick(void* value, uint32_t) { + if (!value) return ENGINE_RESULT_INVALID_ARGUMENT; + auto& runtime = *Cast(value); + if (!runtime.opened) return ENGINE_RESULT_INVALID_STATE; + if (!runtime.process.Running(&runtime.error)) { + runtime.opened = false; + runtime.CleanupBridge(); + return ENGINE_RESULT_INVALID_STATE; + } + (void)RefreshFrame(runtime); + RefreshDiagnostics(runtime); + return ENGINE_RESULT_OK; +} +engine_result_t Pause(void* value) { + if (!value) return ENGINE_RESULT_INVALID_ARGUMENT; + auto& runtime = *Cast(value); + if (!runtime.opened || !runtime.process.valid()) return ENGINE_RESULT_INVALID_STATE; +#if defined(_WIN32) + runtime.error = "Ren'Py process pause is unavailable on Windows"; + return ENGINE_RESULT_NOT_SUPPORTED; +#else + runtime.process.Pause(true); runtime.paused = true; return ENGINE_RESULT_OK; +#endif +} +engine_result_t Resume(void* value) { + if (!value) return ENGINE_RESULT_INVALID_ARGUMENT; + auto& runtime = *Cast(value); + if (!runtime.opened || !runtime.process.valid()) return ENGINE_RESULT_INVALID_STATE; +#if defined(_WIN32) + runtime.error = "Ren'Py process resume is unavailable on Windows"; + return ENGINE_RESULT_NOT_SUPPORTED; +#else + runtime.process.Pause(false); runtime.paused = false; return ENGINE_RESULT_OK; +#endif +} +engine_result_t Option(void* value, const engine_option_t* option) { + if (!value || !option || !option->key_utf8 || !option->value_utf8) + return ENGINE_RESULT_INVALID_ARGUMENT; + auto& runtime = *Cast(value); + const std::string key = option->key_utf8; + if (key == "renpy_sdk_path") { + if (runtime.opened) { + runtime.error = "renpy_sdk_path must be set before opening a game"; + return ENGINE_RESULT_INVALID_STATE; + } + runtime.sdk_root = fs::u8path(option->value_utf8); + runtime.sdk_path_explicit = true; + return ENGINE_RESULT_OK; + } + return key.rfind("renpy_", 0) == 0 ? ENGINE_RESULT_NOT_SUPPORTED + : ENGINE_RESULT_OK; +} +engine_result_t Resize(void* value, uint32_t width, uint32_t height) { + if (!value || width == 0 || height == 0) return ENGINE_RESULT_INVALID_ARGUMENT; + Cast(value)->error = + "Ren'Py SDK surface size is controlled by game/options.rpy"; + return ENGINE_RESULT_NOT_SUPPORTED; +} +engine_result_t Frame(void* value, engine_frame_desc_t* frame) { + if (!value || !frame || frame->struct_size < sizeof(*frame)) + return ENGINE_RESULT_INVALID_ARGUMENT; + auto& runtime = *Cast(value); + (void)RefreshFrame(runtime); + RefreshDiagnostics(runtime); + std::lock_guard guard(runtime.frame_mutex); + if (runtime.frame_pixels.empty()) { + runtime.error = UnsupportedFrameMessage(); + return ENGINE_RESULT_NOT_SUPPORTED; + } + frame->width = runtime.frame_width; + frame->height = runtime.frame_height; + frame->stride_bytes = runtime.frame_width * 4u; + frame->pixel_format = kFramePixelFormatRgba8888; + frame->frame_serial = runtime.frame_serial; + return ENGINE_RESULT_OK; +} +engine_result_t Read(void* value, void* pixels, size_t size) { + if (!value || !pixels || size == 0) return ENGINE_RESULT_INVALID_ARGUMENT; + auto& runtime = *Cast(value); + (void)RefreshFrame(runtime); + RefreshDiagnostics(runtime); + std::lock_guard guard(runtime.frame_mutex); + if (runtime.frame_pixels.empty()) { + runtime.error = UnsupportedFrameMessage(); + return ENGINE_RESULT_NOT_SUPPORTED; + } + if (size < runtime.frame_pixels.size()) { + runtime.error = "Ren'Py frame buffer is too small"; + return ENGINE_RESULT_INVALID_ARGUMENT; + } + std::memcpy(pixels, runtime.frame_pixels.data(), runtime.frame_pixels.size()); + return ENGINE_RESULT_OK; +} +engine_result_t NativeFrame(void* value, uint64_t* texture, uint32_t* width, + uint32_t* height, uint64_t* serial) { + if (!value || !texture || !width || !height || !serial) + return ENGINE_RESULT_INVALID_ARGUMENT; + Cast(value)->error = + "Ren'Py native GPU texture import is unavailable; use RGBA frame data"; + return ENGINE_RESULT_NOT_SUPPORTED; +} +engine_result_t Input(void* value, const engine_input_event_t* event) { + if (!value || !event || event->struct_size < sizeof(*event)) + return ENGINE_RESULT_INVALID_ARGUMENT; + auto& runtime = *Cast(value); + if (!runtime.opened) return ENGINE_RESULT_INVALID_STATE; + int event_type = 0; + std::ostringstream attrs; + switch (event->type) { + case ENGINE_INPUT_EVENT_POINTER_DOWN: + event_type = 1025; // pygame.MOUSEBUTTONDOWN + attrs << "\"pos\":[" << JsonNumber(event->x) << ',' + << JsonNumber(event->y) << "],\"button\":" + << MapPointerButtonToPygame(event->button); + break; + case ENGINE_INPUT_EVENT_POINTER_MOVE: + event_type = 1024; // pygame.MOUSEMOTION + attrs << "\"pos\":[" << JsonNumber(event->x) << ',' + << JsonNumber(event->y) << "],\"rel\":[" + << JsonNumber(event->delta_x) << ',' << JsonNumber(event->delta_y) + << "]"; + break; + case ENGINE_INPUT_EVENT_POINTER_UP: + event_type = 1026; // pygame.MOUSEBUTTONUP + attrs << "\"pos\":[" << JsonNumber(event->x) << ',' + << JsonNumber(event->y) << "],\"button\":" + << MapPointerButtonToPygame(event->button); + break; + case ENGINE_INPUT_EVENT_POINTER_SCROLL: + event_type = 1027; // pygame.MOUSEWHEEL + attrs << "\"x\":" << JsonNumber(event->delta_x) + << ",\"y\":" << JsonNumber(event->delta_y) + << ",\"precise_x\":" << JsonNumber(event->delta_x) + << ",\"precise_y\":" << JsonNumber(event->delta_y); + break; + case ENGINE_INPUT_EVENT_KEY_DOWN: + event_type = 768; // pygame.KEYDOWN + attrs << "\"key\":" << MapKeyCodeToPygame(event->key_code, + static_cast(event->reserved_u32)) + << ",\"mod\":" << MapModifiersToPygame(event->modifiers) + << ",\"unicode\":" + << JsonEscape(Utf8Codepoint(event->unicode_codepoint)) + << ",\"repeat\":" + << (IsAetherKeyRepeat(event->modifiers) ? "true" : "false"); + break; + case ENGINE_INPUT_EVENT_KEY_UP: + event_type = 769; // pygame.KEYUP + attrs << "\"key\":" << MapKeyCodeToPygame(event->key_code, + static_cast(event->reserved_u32)) + << ",\"mod\":" << MapModifiersToPygame(event->modifiers) + << ",\"unicode\":" + << JsonEscape(Utf8Codepoint(event->unicode_codepoint)) + << ",\"repeat\":false"; + break; + case ENGINE_INPUT_EVENT_TEXT_INPUT: + event_type = 771; // pygame.TEXTINPUT + attrs << "\"text\":" + << JsonEscape(Utf8Codepoint(event->unicode_codepoint)); + break; + case ENGINE_INPUT_EVENT_BACK: + event_type = 768; // pygame.KEYDOWN / Escape + attrs << "\"key\":27,\"mod\":0,\"unicode\":\"\""; + break; + default: + runtime.error = "unsupported Ren'Py input event type"; + return ENGINE_RESULT_INVALID_ARGUMENT; + } + std::ostringstream line; + line << "{\"type\":" << event_type << ",\"attributes\":{" + << attrs.str() << "}}"; + return AppendInput(runtime, line.str()) ? ENGINE_RESULT_OK + : ENGINE_RESULT_IO_ERROR; +} +engine_result_t Rendered(void* value, uint32_t* rendered) { + if (!value || !rendered) return ENGINE_RESULT_INVALID_ARGUMENT; + auto& runtime = *Cast(value); + (void)RefreshFrame(runtime); + std::lock_guard guard(runtime.frame_mutex); + *rendered = runtime.frame_serial != 0 ? 1u : 0u; + return ENGINE_RESULT_OK; +} +engine_result_t Renderer(void* value, char* buffer, uint32_t size) { + if (!value || !buffer || size == 0) return ENGINE_RESULT_INVALID_ARGUMENT; + const char* text = "Ren'Py official SDK subprocess (AKRF1 RGBA bridge)"; + const size_t required = std::strlen(text) + 1; + if (size < required) return ENGINE_RESULT_INVALID_ARGUMENT; + std::memcpy(buffer, text, required); + return ENGINE_RESULT_OK; +} +engine_result_t TextInput(void* value, uint32_t* flags) { + if (!value || !flags) return ENGINE_RESULT_INVALID_ARGUMENT; + *flags = 0; return ENGINE_RESULT_OK; +} +const char* Error(void* value) { + return value ? Cast(value)->error.c_str() : "Invalid Ren'Py runtime"; +} + +const engine_runtime_provider_v1_t& Provider() { + static const engine_runtime_provider_v1_t provider = [] { + engine_runtime_provider_v1_t p{}; + p.struct_size = sizeof(p); + p.api_version = ENGINE_RUNTIME_PROVIDER_API_VERSION; + p.runtime_id_utf8 = "renpy"; + p.display_name_utf8 = "Ren'Py (official SDK)"; + p.priority = 60; + p.probe = Probe; p.create = Create; p.destroy = Destroy; + p.open_game = Open; p.tick = Tick; p.pause = Pause; p.resume = Resume; + p.set_option = Option; p.set_surface_size = Resize; + p.get_frame_desc = Frame; p.read_frame_rgba = Read; + p.get_godot_native_frame_texture = NativeFrame; + p.send_input = Input; p.get_frame_rendered_flag = Rendered; + p.get_renderer_info = Renderer; p.get_text_input_state = TextInput; + p.get_last_error = Error; + return p; + }(); + return provider; +} +} // namespace + +void RegisterRuntimeProvider() { + static std::once_flag once; + std::call_once(once, [] { engine_register_runtime_provider(&Provider()); }); +} + +} // namespace aetherkiri::renpy diff --git a/bridge/renpy_runtime/tests/android_host_binding.cpp b/bridge/renpy_runtime/tests/android_host_binding.cpp new file mode 100644 index 000000000..786991e6e --- /dev/null +++ b/bridge/renpy_runtime/tests/android_host_binding.cpp @@ -0,0 +1,258 @@ +// Real JNI headers with a controlled function table; this is a protocol test, +// not an Android VM, SDL initialization, or device gameplay test. +#include "renpy_android_host.h" +#include "renpy_mobile_adapter.h" +#include "renpy_mobile_loader.h" +#include "renpy_runtime.h" +#include "engine_runtime_provider.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +int activity_token, media_token, activity_class_token, bridge_token, throwable_token; +int throwable_class_token, root_token, apk_token, detail_token, generic_method_token; +int bind_token, extract_token, prepare_token, apk_method_token, stop_token, describe_token; +jobject activity = reinterpret_cast(&activity_token); +jobject bound_activity; +bool no_activity, no_bridge, no_bind, bind_throws, extraction_throws, prepare_throws; +bool pending_exception, payload_available = true; +bool java_load_fails, java_vm_ready; +int local_refs, python_initializations, bind_count; +std::vector calls; +const engine_runtime_provider_v1_t* provider; + +jobject Local(jobject value) { if (value) ++local_refs; return value; } +template T Object(int* token) { return reinterpret_cast(token); } +jclass FindClass(JNIEnv*, const char* name) { + assert(std::strcmp(name, "android/app/Activity") == 0 || + std::strcmp(name, "org/github/krkr2/aetherkiri/RenPyMobileBridge") == 0); + return static_cast(Local(std::strcmp(name, "android/app/Activity") == 0 + ? Object(&activity_class_token) : Object(&bridge_token))); +} +jobject NewLocalRef(JNIEnv*, jobject value) { return Local(value); } +void DeleteLocalRef(JNIEnv*, jobject value) { assert(value && local_refs > 0); --local_refs; } +jboolean IsInstanceOf(JNIEnv*, jobject value, jclass klass) { + assert(klass == Object(&activity_class_token)); + return value == activity; +} +jboolean ExceptionCheck(JNIEnv*) { return pending_exception; } +jthrowable ExceptionOccurred(JNIEnv*) { + return pending_exception ? static_cast(Local(Object(&throwable_token))) : nullptr; +} +void ExceptionClear(JNIEnv*) { pending_exception = false; } +jclass GetObjectClass(JNIEnv*, jobject value) { + assert(value == Object(&throwable_token)); + return static_cast(Local(Object(&throwable_class_token))); +} +jmethodID GetMethodID(JNIEnv*, jclass, const char* name, const char* signature) { + if (std::strcmp(name, "toString") == 0) { + assert(std::strcmp(signature, "()Ljava/lang/String;") == 0); + return Object(&describe_token); + } + return Object(&generic_method_token); +} +jmethodID GetStaticMethodID(JNIEnv*, jclass, const char* name, const char* signature) { + if (std::strcmp(name, "bindHostActivity") == 0) { + assert(std::strcmp(signature, "(Landroid/app/Activity;)V") == 0); + if (no_bind) { pending_exception = true; return nullptr; } + return Object(&bind_token); + } + if (std::strcmp(name, "preparePrivateRoot") == 0) return Object(&extract_token); + if (std::strcmp(name, "prepareRuntime") == 0) return Object(&prepare_token); + if (std::strcmp(name, "getApkPath") == 0) return Object(&apk_method_token); + if (std::strcmp(name, "stopRuntime") == 0) return Object(&stop_token); + return Object(&generic_method_token); +} +void CallStaticVoidMethodV(JNIEnv*, jclass klass, jmethodID method, va_list args) { + assert(klass == Object(&bridge_token)); + if (method == Object(&bind_token)) { + assert(va_arg(args, jobject) == activity); // Never the poisoned media slot. + calls.push_back("bind"); + if (bind_throws) { pending_exception = true; return; } + bound_activity = activity; // Simulates Bridge.nativeSetHostActivity's global ref. + ++bind_count; + } else if (method == Object(&prepare_token)) { + assert(va_arg(args, jint) == 1280 && va_arg(args, jint) == 720); + calls.push_back("prepare"); + if (prepare_throws) pending_exception = true; + } else { + assert(method == Object(&stop_token)); + calls.push_back("stop"); + } +} +jobject CallStaticObjectMethodV(JNIEnv*, jclass, jmethodID method, va_list) { + if (method == Object(&extract_token)) { + calls.push_back("extract"); + if (extraction_throws) { pending_exception = true; return nullptr; } + return Local(Object(&root_token)); + } + assert(method == Object(&apk_method_token)); + calls.push_back("apk"); + return Local(Object(&apk_token)); +} +jobject CallObjectMethodV(JNIEnv*, jobject, jmethodID method, va_list) { + assert(method == Object(&describe_token)); + return Local(Object(&detail_token)); +} +const char* GetStringUTFChars(JNIEnv*, jstring value, jboolean*) { + if (value == Object(&root_token)) return "/private"; + if (value == Object(&apk_token)) return "/app.apk"; + assert(value == Object(&detail_token)); + return "java.lang.IllegalStateException: actual fixture Java failure"; +} +void ReleaseStringUTFChars(JNIEnv*, jstring, const char*) {} +using FunctionTable = std::remove_const_t>; +FunctionTable table{}; +JNIEnv env{&table}; +jobject ResolveActivity(JNIEnv*) { calls.push_back("get_activity"); return no_activity ? nullptr : Local(activity); } +jclass ResolveClass(JNIEnv*, const char* name) { + assert(std::strcmp(name, "org/github/krkr2/aetherkiri/RenPyMobileBridge") == 0); + calls.push_back("app_class_loader"); + return no_bridge ? nullptr : static_cast(Local(Object(&bridge_token))); +} +bool LoadBridge(std::string* error) { + calls.push_back("java_load"); + if (java_load_fails) { *error = "Java load failed"; return false; } + java_vm_ready = true; // Actual JNI_OnLoad owns this step in the app. + return true; +} +JNIEnv* ResolveEnv() { + assert(java_vm_ready); + calls.push_back("jni_env"); + return &env; +} +void CheckClean() { assert(local_refs == 0 && !pending_exception); } +} // namespace + +JNIEnv* krkr_GetJNIEnv() { return &env; } +JavaVM* krkr_GetJavaVM() { return Object(&activity_token); } +jobject krkr_GetApplicationContext() { return activity; } +jobject krkr_GetHostActivity() { return bound_activity; } +struct ANativeWindow; +ANativeWindow* krkr_GetNativeWindow() { return nullptr; } +extern "C" int32_t ANativeWindow_getWidth(ANativeWindow*) { return 0; } +extern "C" int32_t ANativeWindow_getHeight(ANativeWindow*) { return 0; } +extern "C" void ANativeWindow_release(ANativeWindow*) {} +extern "C" engine_result_t engine_register_runtime_provider(const engine_runtime_provider_v1_t* value) { + provider = value; + return ENGINE_RESULT_OK; +} + +// Controlled launcher only. Real PrepareRuntime and provider are linked below. +namespace aetherkiri::renpy::mobile { +Launcher::Launcher() = default; +Launcher::~Launcher() = default; +bool Launcher::available() { return payload_available; } +int Launcher::Init(const renpy_mobile_config_t& config, const renpy_mobile_host_t&) { + assert(bound_activity == activity); + assert(std::strcmp(config.private_root_utf8, "/private") == 0); + assert(std::strcmp(config.apk_path_utf8, "/app.apk") == 0); + assert(std::strcmp(config.argv0_utf8, "/private/main.py") == 0); + calls.push_back("python_init"); + ++python_initializations; + initialized_ = true; + return RENPY_MOBILE_OK; +} +int Launcher::Tick(uint32_t) { calls.push_back("tick"); return RENPY_MOBILE_OK; } +int Launcher::Frame(renpy_mobile_frame_t*) { return RENPY_MOBILE_INVALID_STATE; } +int Launcher::Input(const renpy_mobile_input_t&) { return RENPY_MOBILE_OK; } +int Launcher::Pause() { return RENPY_MOBILE_OK; } +int Launcher::Resume() { return RENPY_MOBILE_OK; } +int Launcher::TextInputState(uint32_t* value) { *value = 0; return RENPY_MOBILE_OK; } +int Launcher::SetSurfaceSize(uint32_t, uint32_t) { return RENPY_MOBILE_OK; } +void Launcher::Shutdown() { initialized_ = false; } +} // namespace aetherkiri::renpy::mobile + +int main() { + table.FindClass = FindClass; + table.NewLocalRef = NewLocalRef; + table.DeleteLocalRef = DeleteLocalRef; + table.IsInstanceOf = IsInstanceOf; + table.ExceptionCheck = ExceptionCheck; + table.ExceptionOccurred = ExceptionOccurred; + table.ExceptionClear = ExceptionClear; + table.GetObjectClass = GetObjectClass; + table.GetMethodID = GetMethodID; + table.GetStaticMethodID = GetStaticMethodID; + table.CallStaticVoidMethodV = CallStaticVoidMethodV; + table.CallStaticObjectMethodV = CallStaticObjectMethodV; + table.CallObjectMethodV = CallObjectMethodV; + table.GetStringUTFChars = GetStringUTFChars; + table.ReleaseStringUTFChars = ReleaseStringUTFChars; + using namespace aetherkiri::renpy::mobile; + std::string error; + auto bind = [&] { return BindRenPyAndroidHost(&env, ResolveActivity, ResolveClass, &error); }; + assert(!BindRenPyAndroidHost(nullptr, ResolveActivity, ResolveClass, &error)); + assert(error.find("Android VM") != std::string::npos); + no_activity = true; assert(!bind()); no_activity = false; CheckClean(); + assert(!RequireAndroidHostActivity(&env, Object(&media_token), &error)); CheckClean(); + no_bridge = true; assert(!bind()); no_bridge = false; CheckClean(); + no_bind = true; assert(!bind()); no_bind = false; CheckClean(); + assert(error.find("actual fixture Java failure") != std::string::npos); + bind_throws = true; assert(!bind()); bind_throws = false; CheckClean(); + assert(bound_activity == nullptr && python_initializations == 0); + calls.clear(); + java_load_fails = true; + assert(!PrepareRenPyAndroidHost(LoadBridge, ResolveEnv, ResolveActivity, ResolveClass, &error)); + assert(!java_vm_ready && calls == std::vector{"java_load"}); CheckClean(); + java_load_fails = false; calls.clear(); + assert(PrepareRenPyAndroidHost(LoadBridge, ResolveEnv, ResolveActivity, ResolveClass, &error)); + assert(error.empty()); CheckClean(); + assert((calls == std::vector{"java_load", "jni_env", "get_activity", "app_class_loader", "bind"})); + assert(bound_activity == activity && bind_count == 1); // Valid after locals are released. + + aetherkiri::renpy::RegisterRuntimeProvider(); + const auto root = std::filesystem::temp_directory_path() / ("aether-renpy-host-" + + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); + std::filesystem::create_directories(root / "game"); + assert(provider->probe(nullptr, nullptr) == 0 && provider->probe(nullptr, "") == 0); + assert(provider->probe(nullptr, root.string().c_str()) == 0); + std::ofstream(root / "game" / "unrelated.txt") << "unrelated"; + assert(provider->probe(nullptr, root.string().c_str()) == 0); + for (const char* name : {"script.rpy", "script.rpyc", "options.rpy"}) { + std::ofstream(root / "game" / name) << "marker"; + assert(provider->probe(nullptr, root.string().c_str()) == 100); + payload_available = false; + assert(provider->probe(nullptr, root.string().c_str()) == 0); + payload_available = true; + std::filesystem::remove(root / "game" / name); + } + std::ofstream(root / "script.rpy") << "label start: pass"; + assert(provider->probe(nullptr, root.string().c_str()) == 100); + assert(provider->probe(nullptr, (root / "script.rpy").string().c_str()) == 0); + engine_runtime_host_v1_t host{}; + host.struct_size = sizeof(host); + host.reserved_ptr[1] = &media_token; // Actual dispatcher slot belongs to media, never JNI. + engine_create_desc_t desc{}; desc.struct_size = sizeof(desc); + void* runtime = nullptr; + assert(provider->create(nullptr, &host, &desc, &runtime) == ENGINE_RESULT_OK); + calls.clear(); + assert(provider->open_game(runtime, root.string().c_str(), nullptr) == ENGINE_RESULT_OK); + assert(python_initializations == 0 && calls.empty()); // Async Open preserves owner-thread initialization. + bound_activity = nullptr; + assert(provider->tick(runtime, 16) == ENGINE_RESULT_INVALID_STATE); + assert(std::strstr(provider->get_last_error(runtime), "Godot Activity")); + assert(python_initializations == 0 && calls.empty()); CheckClean(); + assert(bind()); calls.clear(); + extraction_throws = true; + assert(provider->tick(runtime, 16) == ENGINE_RESULT_INTERNAL_ERROR); + assert(std::strstr(provider->get_last_error(runtime), "actual fixture Java failure")); + assert(python_initializations == 0 && calls.back() == "extract"); CheckClean(); + extraction_throws = false; prepare_throws = true; calls.clear(); + assert(provider->tick(runtime, 16) == ENGINE_RESULT_INTERNAL_ERROR); + assert(python_initializations == 0 && calls.back() == "prepare"); CheckClean(); + prepare_throws = false; calls.clear(); + assert(provider->tick(runtime, 16) == ENGINE_RESULT_OK && python_initializations == 1); + assert((calls == std::vector{"bind", "extract", "apk", "prepare", "python_init", "tick"})); + assert(host.reserved_ptr[1] == &media_token); CheckClean(); + provider->destroy(runtime); CheckClean(); + std::filesystem::remove_all(root); +} diff --git a/bridge/renpy_runtime/tests/input_mapping.cpp b/bridge/renpy_runtime/tests/input_mapping.cpp new file mode 100644 index 000000000..2f7126541 --- /dev/null +++ b/bridge/renpy_runtime/tests/input_mapping.cpp @@ -0,0 +1,208 @@ +#include "renpy_runtime.h" +#include "engine_api.h" +#include "renpy_godot_input.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#include +#else +#include +#endif + +#ifndef AETHERKIRI_RENPY_TEST_OVERLAY_SOURCE +#define AETHERKIRI_RENPY_TEST_OVERLAY_SOURCE "" +#endif + +namespace { +namespace fs = std::filesystem; + +fs::path MakeProject(const fs::path& root) { + fs::create_directories(root / "game"); + std::ofstream(root / "game" / "script.rpy") + << "label start:\n pause\n"; + return root; +} + +void WriteFakeLauncher(const fs::path& sdk) { + fs::create_directories(sdk); + std::ofstream launcher(sdk / "renpy"); + launcher << "#!/bin/sh\n" + << "project=$1\n" + << "printf '%s\\n' \"$AETHERKIRI_RENPY_INPUT\" > \"$project/input-path\"\n" + << "trap 'exit 0' TERM INT\n" + << "while :; do sleep 0.01; done\n"; + launcher.close(); +#if defined(_WIN32) + (void)std::system(("chmod +x " + (sdk / "renpy").string()).c_str()); +#else + fs::permissions(sdk / "renpy", + fs::perms::owner_exec | fs::perms::owner_read | + fs::perms::owner_write, + fs::perm_options::add); +#endif +} + +bool WaitForFile(const fs::path& path) { + for (int i = 0; i < 200; ++i) { + if (fs::is_regular_file(path)) return true; + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } + return false; +} + +std::string ReadInput(const fs::path& path) { + std::ifstream input(path, std::ios::binary); + return std::string((std::istreambuf_iterator(input)), + std::istreambuf_iterator()); +} + +void Send(engine_handle_t handle, uint32_t type, int button = 0, + int key_code = 0, int modifiers = 0, uint32_t unicode = 0) { + engine_input_event_t event{}; + event.struct_size = sizeof(event); + event.type = type; + event.button = button; + event.key_code = key_code; + event.modifiers = modifiers; + event.unicode_codepoint = unicode; + assert(engine_send_input(handle, &event) == ENGINE_RESULT_OK); +} + +void CheckInputMapping() { + aetherkiri::renpy::RegisterRuntimeProvider(); + const fs::path root = fs::temp_directory_path() / + ("aetherkiri-renpy-input-mapping-" + + std::to_string(std::chrono::steady_clock::now() + .time_since_epoch() + .count())); + const fs::path sdk = root / "sdk"; + std::error_code ec; + fs::create_directories(root, ec); + MakeProject(root); + WriteFakeLauncher(sdk); + + aetherkiri::renpy::RegisterRuntimeProvider(); + engine_create_desc_t desc{}; + desc.struct_size = sizeof(desc); + desc.api_version = ENGINE_API_VERSION; + const std::string writable = (root / "host-data").string(); + desc.writable_path_utf8 = writable.c_str(); + engine_handle_t handle = nullptr; + assert(engine_create(&desc, &handle) == ENGINE_RESULT_OK); + + engine_option_t option{}; + option.key_utf8 = "runtime"; + option.value_utf8 = "renpy"; + assert(engine_set_option(handle, &option) == ENGINE_RESULT_OK); + const std::string sdk_text = sdk.string(); + option.key_utf8 = "renpy_sdk_path"; + option.value_utf8 = sdk_text.c_str(); + assert(engine_set_option(handle, &option) == ENGINE_RESULT_OK); + const std::string project = root.string(); + assert(engine_open_game(handle, project.c_str(), nullptr) == ENGINE_RESULT_OK); + + const fs::path input_path_marker = root / "input-path"; + assert(WaitForFile(input_path_marker)); + std::ifstream marker(input_path_marker); + std::string input_path_text; + std::getline(marker, input_path_text); + assert(!input_path_text.empty()); + const fs::path input_path(input_path_text); + + // Aether/Godot pointer buttons are 0=left, 1=right, 2=middle. + Send(handle, ENGINE_INPUT_EVENT_POINTER_DOWN, 0); + Send(handle, ENGINE_INPUT_EVENT_POINTER_DOWN, 1); + Send(handle, ENGINE_INPUT_EVENT_POINTER_DOWN, 2); + // Hardware key path uses the VK values emitted by main.gd. + Send(handle, ENGINE_INPUT_EVENT_KEY_DOWN, 0, 0x41, + 0x01 | 0x04 | 0x80, 'A'); + // Godot's native Key enum and modifier-mask path are accepted as well. + Send(handle, ENGINE_INPUT_EVENT_KEY_DOWN, 0, 4194321, + (1 << 25) | (1 << 28)); // KEY_RIGHT, shift+ctrl + Send(handle, ENGINE_INPUT_EVENT_KEY_DOWN, 0, 0x70); // VK_F1 + Send(handle, ENGINE_INPUT_EVENT_KEY_DOWN, 0, 0x08); // VK_BACK + Send(handle, ENGINE_INPUT_EVENT_KEY_DOWN, 0, 0x09); // VK_TAB + Send(handle, ENGINE_INPUT_EVENT_KEY_DOWN, 0, 0x0d); // VK_RETURN + Send(handle, ENGINE_INPUT_EVENT_KEY_DOWN, 0, 0x20); // VK_SPACE + Send(handle, ENGINE_INPUT_EVENT_KEY_DOWN, 0, 0x00a5); // VK_RALT + // Existing SDL/Pygame values remain valid for compatibility. + Send(handle, ENGINE_INPUT_EVENT_KEY_UP, 0, 1073741905); + namespace mapping = aetherkiri::renpy::input_mapping; + auto send_godot = [&](bool pressed, int code, int modifiers, uint32_t unicode) { + return mapping::SendGodotKeyEvents([&](const engine_input_event_t& event) { + return engine_send_input(handle, &event); + }, pressed, code, modifiers, unicode); + }; + assert(send_godot(true, '.', mapping::kGodotModifierShift, '>') == ENGINE_RESULT_OK); + assert(send_godot(false, '.', mapping::kGodotModifierShift, '>') == ENGINE_RESULT_OK); + assert(send_godot(true, '/', 0, '/') == ENGINE_RESULT_OK); + assert(send_godot(true, mapping::kGodotSpecial + 8, 0, 0) == ENGINE_RESULT_OK); // Delete + assert(send_godot(true, mapping::kGodotSpecial + 5, 0, '\r') == ENGINE_RESULT_OK); // Enter + assert(send_godot(true, 'C', mapping::kGodotModifierControl, 'c') == ENGINE_RESULT_OK); + assert(send_godot(true, 0, 0, 0x1f642) == ENGINE_RESULT_OK); // committed software text + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + const std::string lines = ReadInput(input_path); + assert(lines.find("\"type\":1025,\"attributes\":{\"pos\":[0,0],\"button\":1}") != + std::string::npos); + assert(lines.find("\"button\":3}") != std::string::npos); + assert(lines.find("\"button\":2}") != std::string::npos); + assert(lines.find("\"key\":97,\"mod\":195,\"unicode\":\"A\",\"repeat\":true") != + std::string::npos); + assert(lines.find("\"key\":1073741903,\"mod\":195") != + std::string::npos); + assert(lines.find("\"key\":1073741882,\"mod\":0") != + std::string::npos); + assert(lines.find("\"key\":8,\"mod\":0") != std::string::npos); + assert(lines.find("\"key\":9,\"mod\":0") != std::string::npos); + assert(lines.find("\"key\":13,\"mod\":0") != std::string::npos); + assert(lines.find("\"key\":32,\"mod\":0") != std::string::npos); + assert(lines.find("\"key\":1073742054,\"mod\":0") != std::string::npos); + assert(lines.find("\"type\":769,\"attributes\":{\"key\":1073741905") != + std::string::npos); + assert(lines.find("\"type\":768,\"attributes\":{\"key\":46,\"mod\":3,\"unicode\":\"\",\"repeat\":false}") != + std::string::npos); + assert(lines.find("\"type\":769,\"attributes\":{\"key\":46,\"mod\":3,\"unicode\":\"\",\"repeat\":false}") != + std::string::npos); + assert(lines.find("\"type\":768,\"attributes\":{\"key\":47,\"mod\":0,\"unicode\":\"\",\"repeat\":false}") != + std::string::npos); + assert(lines.find("\"type\":768,\"attributes\":{\"key\":127,\"mod\":0,\"unicode\":\"\",\"repeat\":false}") != + std::string::npos); + assert(lines.find("\"type\":768,\"attributes\":{\"key\":13,\"mod\":0,\"unicode\":\"\",\"repeat\":false}") != + std::string::npos); + assert(lines.find("\"type\":768,\"attributes\":{\"key\":99,\"mod\":192,\"unicode\":\"\",\"repeat\":false}") != + std::string::npos); + assert(lines.find("\"type\":771,\"attributes\":{\"text\":\">\"}") != std::string::npos); + assert(lines.find("\"type\":771,\"attributes\":{\"text\":\"/\"}") != std::string::npos); + assert(lines.find("\"type\":771,\"attributes\":{\"text\":\"\xf0\x9f\x99\x82\"}") != std::string::npos); + assert(lines.find("\"text\":\"c\"") == std::string::npos); + assert(lines.find("\"text\":\"\\r\"") == std::string::npos && lines.find("\"text\":\"\\n\"") == std::string::npos); + // Exactly three commits: shifted period, slash and the software emoji. + size_t text_events = 0; + for (size_t pos = 0; (pos = lines.find("\"type\":771", pos)) != std::string::npos; ++pos) + ++text_events; + assert(text_events == 3); + + assert(engine_destroy(handle) == ENGINE_RESULT_OK); + fs::remove_all(root, ec); +} +} // namespace + +int main() { +#if defined(_WIN32) + // The fixture launcher is a POSIX shell script. The provider mapping is + // platform-independent; the existing provider test covers Windows SDK + // process startup separately. + return 0; +#else + CheckInputMapping(); + return 0; +#endif +} diff --git a/bridge/renpy_runtime/tests/ios_adapter.cpp b/bridge/renpy_runtime/tests/ios_adapter.cpp new file mode 100644 index 000000000..b855111c2 --- /dev/null +++ b/bridge/renpy_runtime/tests/ios_adapter.cpp @@ -0,0 +1,66 @@ +#include "renpy_runtime_ios_adapter.h" + +#include + +namespace { + +engine_result_t Create(void*, const engine_runtime_host_v1_t*, + const engine_create_desc_t*, void**) { + return ENGINE_RESULT_NOT_SUPPORTED; +} +void Destroy(void*) {} +engine_result_t Open(void*, const char*, const char*) { + return ENGINE_RESULT_NOT_SUPPORTED; +} +engine_result_t Tick(void*, uint32_t) { return ENGINE_RESULT_NOT_SUPPORTED; } +engine_result_t Pause(void*) { return ENGINE_RESULT_NOT_SUPPORTED; } +engine_result_t Resume(void*) { return ENGINE_RESULT_NOT_SUPPORTED; } +engine_result_t Close(void*) { return ENGINE_RESULT_NOT_SUPPORTED; } +const char* Error(void*) { return "test adapter"; } + +} // namespace + +int main() { + const auto* contract = renpy_get_ios_launcher_contract(); + assert(contract != nullptr); + assert(contract->struct_size == sizeof(*contract)); + assert(contract->api_version == + AETHERKIRI_RENPY_IOS_LAUNCHER_CONTRACT_API_VERSION); + assert(contract->mode == RENPY_IOS_LAUNCHER_MODE_BLOCKING_PY_MAIN); + assert(contract->launcher_symbol_utf8 != nullptr); + assert(contract->blocking_symbol_utf8 != nullptr); + assert(contract->limitation_utf8 != nullptr); + + // The default product build has no linked Renios closure. + assert(renpy_get_ios_inprocess_adapter() == nullptr); + + renpy_ios_inprocess_adapter_v1_t invalid{}; + invalid.struct_size = sizeof(invalid); + invalid.api_version = 0; + assert(renpy_install_ios_inprocess_adapter(&invalid) == + ENGINE_RESULT_INVALID_ARGUMENT); + assert(renpy_get_ios_inprocess_adapter() == nullptr); + + renpy_ios_inprocess_adapter_v1_t adapter{}; + adapter.struct_size = sizeof(adapter); + adapter.api_version = AETHERKIRI_RENPY_IOS_ADAPTER_API_VERSION; + adapter.create = Create; + adapter.destroy = Destroy; + adapter.open_game = Open; + adapter.tick = Tick; + adapter.pause = Pause; + adapter.resume = Resume; + adapter.close_game = Close; + adapter.get_last_error = Error; + assert(renpy_install_ios_inprocess_adapter(&adapter) == ENGINE_RESULT_OK); + assert(renpy_get_ios_inprocess_adapter() == &adapter); + + // Registration is only a boundary test. Until the provider's complete + // lifecycle/rendering/input bridge is linked, game open stays unsupported. + assert(adapter.open_game(nullptr, nullptr, nullptr) == + ENGINE_RESULT_NOT_SUPPORTED); + + assert(renpy_install_ios_inprocess_adapter(nullptr) == ENGINE_RESULT_OK); + assert(renpy_get_ios_inprocess_adapter() == nullptr); + return 0; +} diff --git a/bridge/renpy_runtime/tests/java_boolean_godot.cpp b/bridge/renpy_runtime/tests/java_boolean_godot.cpp new file mode 100644 index 000000000..ba3a45eb6 --- /dev/null +++ b/bridge/renpy_runtime/tests/java_boolean_godot.cpp @@ -0,0 +1,120 @@ +// Loaded by a real Godot executable through its generated GDExtension ABI. +// The engine creates/reads/destroys all Variants; only the production gate is +// templated. This does not execute Android's JavaClassWrapper or JNI. +#include "gdextension_interface.h" +#include "renpy_java_boolean.h" + +#include +#include +#include +#include +#include + +namespace { +GDExtensionInterfaceVariantGetType variant_type; +GDExtensionInterfaceVariantNewNil new_nil; +GDExtensionInterfaceVariantDestroy destroy_variant; +GDExtensionInterfaceGetVariantFromTypeConstructor from_type; +GDExtensionInterfaceGetVariantToTypeConstructor to_type; +GDExtensionInterfaceStringNewWithUtf8Chars new_string; +GDExtensionInterfaceVariantGetPtrDestructor type_destructor; +GDExtensionInterfaceGetGodotVersion godot_version; + +class RealVariant { + alignas(std::max_align_t) unsigned char storage_[AETHER_TEST_VARIANT_SIZE]; + +public: + static constexpr auto BOOL = GDEXTENSION_VARIANT_TYPE_BOOL; + static constexpr auto INT = GDEXTENSION_VARIANT_TYPE_INT; + + RealVariant(GDExtensionVariantType type, void *value) { + if (type == GDEXTENSION_VARIANT_TYPE_NIL) { + new_nil(storage_); + } else { + from_type(type)(storage_, value); + } + } + RealVariant(const RealVariant &) = delete; + RealVariant &operator=(const RealVariant &) = delete; + ~RealVariant() { destroy_variant(storage_); } + + GDExtensionVariantType get_type() const { return variant_type(storage_); } + explicit operator bool() const { + if (get_type() != BOOL) std::abort(); + GDExtensionBool value; + to_type(BOOL)(&value, const_cast(storage_)); + return value != 0; + } + explicit operator int64_t() const { + if (get_type() != INT) std::abort(); + int64_t value; + to_type(INT)(&value, const_cast(storage_)); + return value; + } +}; + +int checks = 0; +void check(GDExtensionVariantType type, void *value, bool expected) { + RealVariant actual(type, value); + if (actual.get_type() != type || + renpy_mobile_runtime::AndroidJavaBooleanResultIsTrue(actual) != expected) { + std::fprintf(stderr, "Java boolean gate failed for real Godot Variant type %d\n", type); + std::abort(); + } + ++checks; +} + +void initialize(void *, GDExtensionInitializationLevel level) { + if (level != GDEXTENSION_INITIALIZATION_SCENE) return; + GDExtensionBool true_value = 1, false_value = 0; + check(GDEXTENSION_VARIANT_TYPE_BOOL, &true_value, true); + check(GDEXTENSION_VARIANT_TYPE_BOOL, &false_value, false); + for (int64_t value : {int64_t(1), int64_t(0), int64_t(-1), int64_t(2), INT64_MAX}) { + check(GDEXTENSION_VARIANT_TYPE_INT, &value, value == 1); + } + for (double value : {0.0, 1.0}) { + check(GDEXTENSION_VARIANT_TYPE_FLOAT, &value, false); + } + check(GDEXTENSION_VARIANT_TYPE_NIL, nullptr, false); + for (const char *text : {"true", "1"}) { + alignas(std::max_align_t) unsigned char string_storage[AETHER_TEST_STRING_SIZE]; + new_string(string_storage, text); + check(GDEXTENSION_VARIANT_TYPE_STRING, string_storage, false); + type_destructor(GDEXTENSION_VARIANT_TYPE_STRING)(string_storage); + } + + GDExtensionGodotVersion version; + godot_version(&version); + const char *result_path = std::getenv("AETHER_VARIANT_RESULT"); + FILE *result = result_path ? std::fopen(result_path, "w") : nullptr; + if (!result) std::abort(); + std::fprintf(result, + "{\"passed\":true,\"checks\":%d,\"godot\":\"%u.%u.%u\"," + "\"android_java_executed\":false}\n", + checks, version.major, version.minor, version.patch); + if (std::fclose(result) != 0) std::abort(); + std::printf("Actual Godot %u.%u.%u Variant boolean gate: %d checks passed\n", + version.major, version.minor, version.patch, checks); +} +void deinitialize(void *, GDExtensionInitializationLevel) {} +} // namespace + +extern "C" GDExtensionBool aether_java_boolean_test_init( + GDExtensionInterfaceGetProcAddress get_proc, + GDExtensionClassLibraryPtr, GDExtensionInitialization *initialization) { +#define LOAD(name, type, proc) name = reinterpret_cast(get_proc(proc)); if (!name) return false + LOAD(variant_type, GDExtensionInterfaceVariantGetType, "variant_get_type"); + LOAD(new_nil, GDExtensionInterfaceVariantNewNil, "variant_new_nil"); + LOAD(destroy_variant, GDExtensionInterfaceVariantDestroy, "variant_destroy"); + LOAD(from_type, GDExtensionInterfaceGetVariantFromTypeConstructor, "get_variant_from_type_constructor"); + LOAD(to_type, GDExtensionInterfaceGetVariantToTypeConstructor, "get_variant_to_type_constructor"); + LOAD(new_string, GDExtensionInterfaceStringNewWithUtf8Chars, "string_new_with_utf8_chars"); + LOAD(type_destructor, GDExtensionInterfaceVariantGetPtrDestructor, "variant_get_ptr_destructor"); + LOAD(godot_version, GDExtensionInterfaceGetGodotVersion, "get_godot_version"); +#undef LOAD + initialization->minimum_initialization_level = GDEXTENSION_INITIALIZATION_SCENE; + initialization->userdata = nullptr; + initialization->initialize = initialize; + initialization->deinitialize = deinitialize; + return true; +} diff --git a/bridge/renpy_runtime/tests/mobile_adapter.cpp b/bridge/renpy_runtime/tests/mobile_adapter.cpp new file mode 100644 index 000000000..8acf0d634 --- /dev/null +++ b/bridge/renpy_runtime/tests/mobile_adapter.cpp @@ -0,0 +1,46 @@ +#include "renpy_mobile_adapter.h" + +#include +#include + +int main() { + using aetherkiri::renpy::mobile::BootstrapAdapter; + using aetherkiri::renpy::mobile::BootstrapRequest; + + BootstrapAdapter adapter; + + BootstrapRequest invalid{}; + assert(adapter.Start(invalid) == ENGINE_RESULT_INVALID_ARGUMENT); + assert(!adapter.running()); + + BootstrapRequest request{}; + request.game_root_path_utf8 = "/game"; + int host_token = 1; + request.existing_host_activity = &host_token; + assert(adapter.Start(request) == ENGINE_RESULT_NOT_SUPPORTED); + assert(!adapter.running()); + const char* error = adapter.last_error().c_str(); +#if defined(__ANDROID__) + // Android may stop at the JavaVM/context boundary before reaching the + // dependency guard; both are deliberate NOT_SUPPORTED contracts. + assert(std::strstr(error, "RAPT") != nullptr || + std::strstr(error, "JavaVM") != nullptr); +#else + assert(std::strstr(error, "Android host") != nullptr); +#endif + + // The provider may call Stop during destruction even after a failed Start. + assert(adapter.Stop() == ENGINE_RESULT_OK); + assert(!adapter.running()); + assert(adapter.last_error().empty()); + + request.existing_host_activity = nullptr; + assert(adapter.Start(request) == ENGINE_RESULT_NOT_SUPPORTED); +#if defined(__ANDROID__) + assert(std::strstr(adapter.last_error().c_str(), "second Activity") != + nullptr); +#else + assert(std::strstr(adapter.last_error().c_str(), "Android host") != nullptr); +#endif + return 0; +} diff --git a/bridge/renpy_runtime/tests/mobile_lifecycle.cpp b/bridge/renpy_runtime/tests/mobile_lifecycle.cpp new file mode 100644 index 000000000..8613c42d3 --- /dev/null +++ b/bridge/renpy_runtime/tests/mobile_lifecycle.cpp @@ -0,0 +1,137 @@ +#include "renpy_mobile_launcher.h" +#include + +#include +#include +#include +#include + +extern "C" PyObject* PyInit__aether_host(); +namespace { +int diagnostics; +void Log(void*, int, const char*) { ++diagnostics; } +long PythonLong(const char* expression) { + PyObject* globals = PyModule_GetDict(PyImport_AddModule("__main__")); + PyObject* result = PyRun_String(expression, Py_eval_input, globals, globals); + assert(result); + const long value = PyLong_AsLong(result); + Py_DECREF(result); + return value; +} +} // namespace + +// This exercises the native boundary with a real CPython interpreter and a +// controlled Python module. It is not a Ren'Py game or device acceptance test. +int main() { + assert(PyImport_AppendInittab("_aether_host", PyInit__aether_host) == 0); + Py_Initialize(); + assert(PyRun_SimpleString(R"PY( +import sys, types +renpy = types.ModuleType('renpy') +renpy.__path__ = [] +sys.modules['renpy'] = renpy +mobile = types.ModuleType('renpy.aether_mobile') +sys.modules['renpy.aether_mobile'] = mobile +mobile._ticks = 0 +mobile._stops = 0 +mobile._paused = False +mobile._inputs = [] +mobile._frame = (bytes([1, 2, 3, 255, 4, 5, 6, 255]), 2, 1, 9) +def start(root, argv0): + mobile._root, mobile._argv0 = root, argv0 +def tick(budget): + mobile._ticks += 1 + return 'finished' if mobile._ticks >= 3 else 'yield' +def stop(): + mobile._stops += 1 +def pause(): + mobile._paused = True +def resume(): + mobile._paused = False +mobile.cooperative_start = start +mobile.cooperative_tick = tick +mobile.cooperative_stop = stop +mobile.cooperative_pause = pause +mobile.cooperative_resume = resume +mobile.cooperative_frame = lambda: mobile._frame +mobile.cooperative_input = lambda event: mobile._inputs.append(dict(event)) +mobile.cooperative_text_input_state = lambda: True +mobile.cooperative_set_surface_size = lambda w, h: None +)PY") == 0); + renpy_mobile_config_t config{}; + config.struct_size = sizeof(config); + config.abi_version = RENPY_MOBILE_LAUNCHER_ABI_VERSION; + config.game_root_utf8 = "/project"; + config.private_root_utf8 = "/runtime"; + config.argv0_utf8 = "/runtime/main.py"; + renpy_mobile_host_t host{}; + host.struct_size = sizeof(host); + host.abi_version = RENPY_MOBILE_LAUNCHER_ABI_VERSION; + host.log_utf8 = Log; + assert(renpy_mobile_init(&config, &host) == RENPY_MOBILE_OK); + std::memset(&host, 0, sizeof(host)); // native must own the callback table. + assert(PythonLong("mobile._root == '/project'") == 1); + assert(PythonLong("mobile._ticks") == 0); // init must not execute gameplay. + assert(renpy_mobile_tick(16) == RENPY_MOBILE_OK); + + renpy_mobile_frame_t frame{}; + frame.struct_size = sizeof(frame); + assert(renpy_mobile_frame(&frame) == RENPY_MOBILE_OK); + assert(frame.width == 2 && frame.height == 1 && frame.stride == 8 && frame.serial == 9); + assert(frame.rgba[0] == 1 && frame.rgba[7] == 255); + assert(PyRun_SimpleString("mobile._frame = (b'bad', 2, 1, 10)") == 0); + assert(renpy_mobile_frame(&frame) == RENPY_MOBILE_ERROR); + + char text[] = "\xe4\xbd\xa0\xf0\x9f\x99\x82"; + renpy_mobile_input_t event{}; + event.struct_size = sizeof(event); + event.type = RENPY_MOBILE_INPUT_TEXT; + event.text_utf8 = text; + assert(renpy_mobile_input(&event) == RENPY_MOBILE_OK); + std::memset(text, 'x', sizeof(text) - 1); + assert(PythonLong("mobile._inputs[-1]['text'] == '\u4f60\U0001f642'") == 1); + for (int action : {RENPY_MOBILE_POINTER_DOWN, RENPY_MOBILE_POINTER_MOVE, + RENPY_MOBILE_POINTER_UP, RENPY_MOBILE_POINTER_SCROLL}) { + event.type = RENPY_MOBILE_INPUT_POINTER; + event.value = action; + event.text_utf8 = nullptr; + event.device_id = 7; + event.code = 3; + event.delta_y = -2; + assert(renpy_mobile_input(&event) == RENPY_MOBILE_OK); + } + assert(PythonLong("[e['value'] for e in mobile._inputs[-4:]] == [1,2,3,4]") == 1); + assert(PythonLong("mobile._inputs[-1]['device_id'] == 7 and mobile._inputs[-1]['delta_y'] == -2") == 1); + uint32_t active = 0; + assert(renpy_mobile_text_input_state(&active) == RENPY_MOBILE_OK && active == 1); + assert(renpy_mobile_pause() == RENPY_MOBILE_OK); + assert(renpy_mobile_tick(16) == RENPY_MOBILE_OK && PythonLong("mobile._ticks") == 1); + assert(renpy_mobile_resume() == RENPY_MOBILE_OK); + assert(renpy_mobile_tick(16) == RENPY_MOBILE_OK); + assert(renpy_mobile_tick(16) == RENPY_MOBILE_FINISHED); + assert(renpy_mobile_set_surface_size(1280, 720) == RENPY_MOBILE_OK); + std::thread wrong_thread([&] { + assert(renpy_mobile_frame(&frame) == RENPY_MOBILE_INVALID_STATE); + }); + wrong_thread.join(); + assert(diagnostics > 0); // callback remains valid after caller table dies. + assert(PyRun_SimpleString("mobile._stop_failure = True\n" + "def stop():\n" + " mobile._stops += 1\n" + " if mobile._stop_failure: raise RuntimeError('terminal cleanup failed')\n" + "mobile.cooperative_stop = stop\n") == 0); + const int before_failure = diagnostics; + renpy_mobile_shutdown(); + assert(diagnostics > before_failure); // failures cannot pretend cleanup succeeded. + host.struct_size = sizeof(host); + host.abi_version = RENPY_MOBILE_LAUNCHER_ABI_VERSION; + assert(renpy_mobile_init(&config, &host) == RENPY_MOBILE_INVALID_STATE); + assert(PyRun_SimpleString("mobile._stop_failure = False") == 0); + renpy_mobile_shutdown(); + assert(Py_IsInitialized()); // the host owns interpreter/process lifetime. + assert(PythonLong("mobile._stops") == 2); + renpy_mobile_shutdown(); + assert(PythonLong("mobile._stops") == 2); + assert(renpy_mobile_tick(16) == RENPY_MOBILE_INVALID_STATE); + Py_Finalize(); +} diff --git a/bridge/renpy_runtime/tests/mobile_provider.cpp b/bridge/renpy_runtime/tests/mobile_provider.cpp new file mode 100644 index 000000000..5bb365d9b --- /dev/null +++ b/bridge/renpy_runtime/tests/mobile_provider.cpp @@ -0,0 +1,28 @@ +#include "renpy_runtime.h" +#include "engine_api.h" +#include "engine_runtime_provider.h" + +#include +#include + +int main() { + aetherkiri::renpy::RegisterRuntimeProvider(); + assert(engine_probe_runtime_provider("renpy", "/not-a-project") == 0); + + engine_create_desc_t desc{}; + desc.struct_size = sizeof(desc); + desc.api_version = ENGINE_API_VERSION; + engine_handle_t handle = nullptr; + assert(engine_create(&desc, &handle) == ENGINE_RESULT_OK); + + engine_option_t option{}; + option.key_utf8 = "runtime"; + option.value_utf8 = "renpy"; + assert(engine_set_option(handle, &option) == ENGINE_RESULT_OK); + assert(engine_open_game(handle, "/not-a-project", nullptr) == + ENGINE_RESULT_NOT_SUPPORTED); + const char* error = engine_get_last_error(handle); + assert(error && std::strstr(error, "mobile") != nullptr); + assert(engine_destroy(handle) == ENGINE_RESULT_OK); + return 0; +} diff --git a/bridge/renpy_runtime/tests/mobile_provider_protocol.cpp b/bridge/renpy_runtime/tests/mobile_provider_protocol.cpp new file mode 100644 index 000000000..7f4f57b52 --- /dev/null +++ b/bridge/renpy_runtime/tests/mobile_provider_protocol.cpp @@ -0,0 +1,252 @@ +#include "renpy_runtime.h" +#include "engine_runtime_provider.h" +#include "renpy_mobile_launcher.h" +#include "renpy_godot_input.h" + +#include +#include +#include +#include +#include +#include + +namespace { +const engine_runtime_provider_v1_t* provider; +const renpy_mobile_host_t* callbacks; +int starts; +bool finish; +bool push_frame = true; +renpy_mobile_input_t last_input{}; +std::string last_text; +struct InputRecord { + renpy_mobile_input_t input; + std::string text; +}; +std::vector inputs; +struct LogEntry { + uint32_t level; + std::string subsystem; + std::string message; + bool during_shutdown; +}; +std::vector host_logs; +bool shutting_down; +void HostLog(void* user_data, uint32_t level, const char* subsystem, const char* message) { + assert(user_data == &host_logs); + host_logs.push_back({level, subsystem, message, shutting_down}); +} +} // namespace + +// A controlled launcher verifies the provider protocol. This is explicitly +// separate from actual Ren'Py rendering and Android/iOS acceptance. +extern "C" engine_result_t engine_register_runtime_provider(const engine_runtime_provider_v1_t* value) { + provider = value; + return ENGINE_RESULT_OK; +} +extern "C" int renpy_mobile_bootstrap(const renpy_mobile_config_t*, const renpy_mobile_host_t*) { return 0; } +extern "C" int renpy_mobile_init(const renpy_mobile_config_t* config, const renpy_mobile_host_t* host) { + assert(std::strcmp(config->game_root_utf8, "/project") == 0); + callbacks = host; + ++starts; + return 0; +} +extern "C" int renpy_mobile_tick(uint32_t) { + assert(callbacks->struct_size == sizeof(*callbacks)); + if (push_frame) { + const uint8_t pixels[] = {1, 2, 3, 255}; + assert(callbacks->present_rgba(callbacks->user_data, pixels, 1, 1, 4) == 0); + } + return finish ? RENPY_MOBILE_FINISHED : RENPY_MOBILE_OK; +} +extern "C" int renpy_mobile_frame(renpy_mobile_frame_t*) { return RENPY_MOBILE_INVALID_STATE; } +extern "C" int renpy_mobile_input(const renpy_mobile_input_t* input) { + last_input = *input; + last_text = input->text_utf8 ? input->text_utf8 : ""; + inputs.push_back({last_input, last_text}); + inputs.back().input.text_utf8 = nullptr; + return 0; +} +extern "C" int renpy_mobile_pause() { return 0; } +extern "C" int renpy_mobile_resume() { return 0; } +extern "C" int renpy_mobile_bind_window(void*) { return 0; } +extern "C" int renpy_mobile_text_input_state(uint32_t* active) { *active = 1; return 0; } +extern "C" int renpy_mobile_set_surface_size(uint32_t, uint32_t) { return 0; } +extern "C" void renpy_mobile_shutdown() { + assert(callbacks); + shutting_down = true; + callbacks->log_utf8(callbacks->user_data, 3, + "Ren'Py terminal cleanup failed; retained runtime resources require a host application restart"); + shutting_down = false; + callbacks = nullptr; +} + +int main() { + aetherkiri::renpy::RegisterRuntimeProvider(); + assert(provider && provider->probe(nullptr, nullptr) == 0); + assert(provider->probe(nullptr, "/not-a-renpy-project") == 0); + engine_runtime_host_v1_t host{}; + host.struct_size = sizeof(host); + host.log = HostLog; + host.user_data = &host_logs; + engine_create_desc_t desc{}; + desc.struct_size = sizeof(desc); + void* runtime = nullptr; + assert(provider->create(nullptr, &host, &desc, &runtime) == ENGINE_RESULT_OK); + char project[] = "/project"; + assert(provider->open_game(runtime, project, nullptr) == ENGINE_RESULT_OK); + std::memset(project, 'x', sizeof(project) - 1); + assert(starts == 0); // async Open cannot bind Python/greenlet to its thread. + assert(provider->tick(runtime, 16) == ENGINE_RESULT_OK && starts == 1); + callbacks->log_utf8(callbacks->user_data, 0, "native trace"); + callbacks->log_utf8(callbacks->user_data, 1, "native info"); + callbacks->log_utf8(callbacks->user_data, 2, "native warning"); + callbacks->log_utf8(callbacks->user_data, 3, "native error"); + assert(host_logs.size() == 4); + assert(host_logs[0].level == ENGINE_RUNTIME_LOG_TRACE); + assert(host_logs[1].level == ENGINE_RUNTIME_LOG_INFO); + assert(host_logs[2].level == ENGINE_RUNTIME_LOG_WARNING); + assert(host_logs[3].level == ENGINE_RUNTIME_LOG_ERROR); + assert(std::strcmp(provider->get_last_error(runtime), "native error") == 0); + for (const auto& entry : host_logs) assert(entry.subsystem == "renpy-mobile"); + callbacks->log_utf8(callbacks->user_data, 3, nullptr); + assert(host_logs.size() == 4); + uint32_t rendered = 0; + assert(provider->get_frame_rendered_flag(runtime, &rendered) == ENGINE_RESULT_OK && rendered == 1); + uint8_t pixels[4]{}; + assert(provider->read_frame_rgba(runtime, pixels, sizeof(pixels)) == ENGINE_RESULT_OK && pixels[0] == 1); + push_frame = false; + assert(provider->tick(runtime, 16) == ENGINE_RESULT_OK); + assert(provider->get_frame_rendered_flag(runtime, &rendered) == ENGINE_RESULT_OK && rendered == 0); + engine_input_event_t event{}; + event.struct_size = sizeof(event); + event.type = ENGINE_INPUT_EVENT_POINTER_DOWN; + event.x = .5; + event.y = .25; + event.button = 1; + assert(provider->send_input(runtime, &event) == ENGINE_RESULT_OK); + assert(last_input.type == RENPY_MOBILE_INPUT_POINTER && last_input.value == RENPY_MOBILE_POINTER_DOWN && last_input.code == 3); + assert(last_input.x == .5f && last_input.y == .25f); + event.type = ENGINE_INPUT_EVENT_KEY_DOWN; + event.key_code = 0x41; + event.modifiers = 0x01 | 0x04 | 0x80; + assert(provider->send_input(runtime, &event) == ENGINE_RESULT_OK); + assert(last_input.code == 'a' && last_input.modifiers == 195 && last_input.repeat == 1); + // The explicit Godot namespace preserves every printable key, including + // punctuation/lowercase values that collide with legacy Windows VKs. + namespace mapping = aetherkiri::renpy::input_mapping; + std::vector raw_events; + auto send_godot = [&](bool pressed, int code, int modifiers, uint32_t unicode) { + return mapping::SendGodotKeyEvents([&](const engine_input_event_t& input) { + raw_events.push_back(input); + return provider->send_input(runtime, &input); + }, pressed, code, modifiers, unicode); + }; + for (int code = 32; code <= 126; ++code) { + const size_t before = inputs.size(); + assert(send_godot(true, code, 0, code) == ENGINE_RESULT_OK); + assert(inputs.size() == before + 2); + const auto& key = inputs[before].input; + assert(key.type == RENPY_MOBILE_INPUT_KEY && key.value == ENGINE_INPUT_EVENT_KEY_DOWN); + assert(key.code == (code >= 'A' && code <= 'Z' ? code + 'a' - 'A' : code)); + assert(inputs[before + 1].input.type == RENPY_MOBILE_INPUT_TEXT); + assert(inputs[before + 1].text == std::string(1, static_cast(code))); + assert(raw_events[raw_events.size() - 2].unicode_codepoint == 0); + assert(raw_events[raw_events.size() - 2].reserved_u32 == + static_cast(mapping::KeyCodeSpace::Godot)); + assert(raw_events.back().reserved_u32 == 0 && raw_events.back().key_code == 0); + const size_t release_before = inputs.size(); + assert(send_godot(false, code, 0, code) == ENGINE_RESULT_OK); + assert(inputs.size() == release_before + 1 && last_input.value == ENGINE_INPUT_EVENT_KEY_UP); + } + const std::pair special_keys[] = { + {1, 27}, {2, 9}, {3, 9}, {4, 8}, {5, 13}, + {6, mapping::kPygameScancodeMask + 88}, {7, mapping::kPygameScancodeMask + 73}, + {8, 127}, {13, mapping::kPygameScancodeMask + 74}, + {14, mapping::kPygameScancodeMask + 77}, {15, mapping::kPygameScancodeMask + 80}, + {16, mapping::kPygameScancodeMask + 82}, {17, mapping::kPygameScancodeMask + 79}, + {18, mapping::kPygameScancodeMask + 81}, {19, mapping::kPygameScancodeMask + 75}, + {20, mapping::kPygameScancodeMask + 78}, {28, mapping::PygameFunctionKey(1)}, + {51, mapping::PygameFunctionKey(24)}, {132, mapping::kPygameScancodeMask + 99}, + }; + for (const auto& [code, expected] : special_keys) { + const size_t before = inputs.size(); + // Even a platform newline accompanying Enter is a key command only. + assert(send_godot(true, mapping::kGodotSpecial + code, 0, + code == 5 || code == 6 ? '\n' : 0) == ENGINE_RESULT_OK); + assert(inputs.size() == before + 1 && last_input.code == expected && last_text.empty()); + assert(raw_events.back().unicode_codepoint == 0); + } + const size_t ctrl_before = inputs.size(); + assert(send_godot(true, 'A', mapping::kGodotModifierControl | mapping::kGodotModifierShift | + mapping::kAetherModifierEcho, 'A') == ENGINE_RESULT_OK); + assert(inputs.size() == ctrl_before + 1 && last_input.code == 'a'); + assert(last_input.modifiers == 195 && last_input.repeat == 1); + const size_t meta_before = inputs.size(); + assert(send_godot(true, 'C', mapping::kGodotModifierMeta, 'c') == ENGINE_RESULT_OK); + assert(inputs.size() == meta_before + 1 && last_input.modifiers == mapping::kPygameKmodGui); + const size_t shift_before = inputs.size(); + assert(send_godot(true, '.', mapping::kGodotModifierShift, '>') == ENGINE_RESULT_OK); + assert(inputs.size() == shift_before + 2 && inputs[shift_before].input.code == '.'); + assert(inputs[shift_before].input.modifiers == mapping::kPygameKmodShift && last_text == ">"); + const size_t soft_before = inputs.size(); + assert(send_godot(true, 0, 0, 0x1f642) == ENGINE_RESULT_OK); + assert(inputs.size() == soft_before + 1 && last_input.type == RENPY_MOBILE_INPUT_TEXT); + assert(last_text == "\xf0\x9f\x99\x82"); + for (uint32_t unicode : {0x4f60u, 0x1f642u}) { + const size_t before = inputs.size(); + // Godot KEY_UNKNOWN is nonzero and remains an unknown key, not a VK. + assert(send_godot(true, mapping::kGodotCodeMask, 0, unicode) == ENGINE_RESULT_OK); + assert(inputs.size() == before + 2 && inputs[before].input.code == 0); + assert(inputs.back().input.type == RENPY_MOBILE_INPUT_TEXT && !inputs.back().text.empty()); + } + const size_t altgr_before = inputs.size(); + assert(send_godot(true, 'E', mapping::kGodotModifierControl | mapping::kGodotModifierAlt, + 0x20ac) == ENGINE_RESULT_OK); + assert(inputs.size() == altgr_before + 2 && last_text == "\xe2\x82\xac"); + for (uint32_t unicode : {0u, 8u, 9u, 10u, 13u, 127u, 0xd800u, 0x110000u}) { + const size_t before = inputs.size(); + assert(send_godot(true, 0, 0, unicode) == ENGINE_RESULT_OK && inputs.size() == before); + } + int calls = 0; + assert(mapping::SendGodotKeyEvents([&](const engine_input_event_t&) { + ++calls; + return ENGINE_RESULT_IO_ERROR; + }, true, '.', 0, '.') == ENGINE_RESULT_IO_ERROR && calls == 1); + // Unmarked events retain the old VK and direct SDL contracts. + event.key_code = 0x2e; + event.modifiers = 0; + assert(provider->send_input(runtime, &event) == ENGINE_RESULT_OK && last_input.code == 127); + event.key_code = 0x70; + assert(provider->send_input(runtime, &event) == ENGINE_RESULT_OK && last_input.code == mapping::PygameFunctionKey(1)); + event.key_code = mapping::kPygameScancodeMask + 79; + assert(provider->send_input(runtime, &event) == ENGINE_RESULT_OK && last_input.code == event.key_code); + event.type = ENGINE_INPUT_EVENT_TEXT_INPUT; + event.unicode_codepoint = 0x1f642; + assert(provider->send_input(runtime, &event) == ENGINE_RESULT_OK && last_text == "\xf0\x9f\x99\x82"); + event.unicode_codepoint = 0xd800; + assert(provider->send_input(runtime, &event) == ENGINE_RESULT_INVALID_ARGUMENT); + event.type = ENGINE_INPUT_EVENT_POINTER_MOVE; + event.x = std::nan(""); + assert(provider->send_input(runtime, &event) == ENGINE_RESULT_INVALID_ARGUMENT); + uint32_t active = 0; + assert(provider->get_text_input_state(runtime, &active) == ENGINE_RESULT_OK && active == 1); + char debug[256]{}; + uint32_t written = 0; + assert(provider->pause(runtime) == ENGINE_RESULT_OK); + assert(provider->get_plugin_debug_info(runtime, debug, sizeof(debug), &written) == ENGINE_RESULT_OK); + assert(std::strstr(debug, "\"paused\":true")); + assert(provider->resume(runtime) == ENGINE_RESULT_OK); + finish = true; + assert(provider->tick(runtime, 16) == ENGINE_RESULT_INVALID_STATE); + assert(std::strstr(provider->get_last_error(runtime), "runtime requested termination")); + assert(provider->get_plugin_debug_info(runtime, debug, sizeof(debug), &written) == ENGINE_RESULT_OK); + assert(std::strstr(debug, "\"exited\":true")); + provider->destroy(runtime); + assert(!callbacks); + // Destroy remains void. The launcher error reaches the owned host sink + // synchronously during shutdown and survives deletion of MobileRuntime. + assert(host_logs.size() == 5 && host_logs.back().during_shutdown); + assert(host_logs.back().level == ENGINE_RUNTIME_LOG_ERROR); + assert(host_logs.back().subsystem == "renpy-mobile"); + assert(host_logs.back().message.find("terminal cleanup failed") != std::string::npos); +} diff --git a/bridge/renpy_runtime/tests/provider.cpp b/bridge/renpy_runtime/tests/provider.cpp new file mode 100644 index 000000000..0adaac958 --- /dev/null +++ b/bridge/renpy_runtime/tests/provider.cpp @@ -0,0 +1,257 @@ +#include "renpy_runtime.h" +#include "engine_api.h" +#include "engine_runtime_provider.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef AETHERKIRI_RENPY_TEST_SDK_ROOT +#define AETHERKIRI_RENPY_TEST_SDK_ROOT "" +#endif + +#if !defined(_WIN32) +#include +#include +#include +#endif + +namespace { +std::filesystem::path MakeProject() { + const auto root = std::filesystem::temp_directory_path() / + "aetherkiri-renpy-provider-test"; + std::error_code ec; + std::filesystem::remove_all(root, ec); + std::filesystem::create_directories(root / "game", ec); + std::ofstream(root / "game" / "script.rpy") << "label start:\n pass\n"; + return root; +} + +#if !defined(_WIN32) +bool ProcessExists(pid_t pid) { + if (pid <= 0) return false; + errno = 0; + return kill(pid, 0) == 0 || errno == EPERM; +} + +pid_t ReadPid(const std::filesystem::path& path) { + for (int i = 0; i < 100; ++i) { + std::ifstream input(path); + pid_t pid = -1; + if (input >> pid && pid > 0) return pid; + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + return -1; +} + +void WriteFakeLauncher(const std::filesystem::path& sdk) { + std::ofstream launcher(sdk / "renpy"); + launcher << "#!/bin/sh\n" + << "project=$1\n" + << "if [ -f \"$project/ignore-term\" ]; then\n" + << " (trap '' TERM; while :; do sleep 1; done) &\n" + << "else\n" + << " (while :; do sleep 1; done) &\n" + << "fi\n" + << "child=$!\n" + << "echo $child > \"$project/descendant.pid\"\n" + << "if [ -f \"$project/exit-leader\" ]; then exit 0; fi\n" + << "trap 'exit 0' TERM INT\n" + << "wait $child\n"; + launcher.close(); + std::filesystem::permissions( + sdk / "renpy", + std::filesystem::perms::owner_exec | std::filesystem::perms::owner_read | + std::filesystem::perms::owner_write, + std::filesystem::perm_options::add); +} + +void CheckProcessGroupShutdown() { + namespace fs = std::filesystem; + const auto root = fs::temp_directory_path() / + "aetherkiri-renpy-lifecycle-test"; + std::error_code ec; + fs::remove_all(root, ec); + fs::create_directories(root / "sdk", ec); + fs::create_directories(root / "game", ec); + std::ofstream(root / "game" / "script.rpy") << "label start:\n pass\n"; + WriteFakeLauncher(root / "sdk"); + + auto open = [&](const fs::path& project) { + engine_create_desc_t desc{}; + desc.struct_size = sizeof(desc); + desc.api_version = ENGINE_API_VERSION; + const auto writable = root.u8string(); + desc.writable_path_utf8 = writable.c_str(); + engine_handle_t handle = nullptr; + assert(engine_create(&desc, &handle) == ENGINE_RESULT_OK); + engine_option_t option{}; + option.key_utf8 = "runtime"; + option.value_utf8 = "renpy"; + assert(engine_set_option(handle, &option) == ENGINE_RESULT_OK); + const auto sdk = (root / "sdk").u8string(); + option.key_utf8 = "renpy_sdk_path"; + option.value_utf8 = sdk.c_str(); + assert(engine_set_option(handle, &option) == ENGINE_RESULT_OK); + const auto path = project.u8string(); + const engine_result_t result = engine_open_game(handle, path.c_str(), nullptr); + return std::pair{handle, result}; + }; + + // The launcher exits after handing work to a descendant. Tick observes + // the leader reap, then destruction must still clean up its process group. + std::ofstream(root / "exit-leader").put('\n'); + std::ofstream(root / "ignore-term").put('\n'); + auto exited = open(root); + std::filesystem::remove(root / "exit-leader", ec); + const pid_t exited_child = ReadPid(root / "descendant.pid"); + assert(exited_child > 0); + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + (void)engine_tick(exited.first, 0); + engine_destroy(exited.first); + for (int i = 0; i < 100 && ProcessExists(exited_child); ++i) + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + assert(!ProcessExists(exited_child)); + + // A stopped process group must be continued before TERM. The bounded + // shutdown should finish well below the two-second SIGTERM grace period. + std::filesystem::remove(root / "ignore-term", ec); + auto paused = open(root); + assert(paused.second == ENGINE_RESULT_OK); + const pid_t paused_child = ReadPid(root / "descendant.pid"); + assert(paused_child > 0); + assert(engine_pause(paused.first) == ENGINE_RESULT_OK); + const auto started = std::chrono::steady_clock::now(); + engine_destroy(paused.first); + const auto elapsed = std::chrono::duration_cast( + std::chrono::steady_clock::now() - started); + assert(elapsed.count() < 3500); + for (int i = 0; i < 100 && ProcessExists(paused_child); ++i) + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + assert(!ProcessExists(paused_child)); + fs::remove_all(root, ec); +} +#endif + +void CheckFrameInputBridge() { + namespace fs = std::filesystem; + const auto root = fs::temp_directory_path() / + ("aetherkiri-renpy-frame-test-" + + std::to_string(std::chrono::steady_clock::now() + .time_since_epoch() + .count())); + std::error_code ec; + fs::create_directories(root / "game", ec); + std::ofstream(root / "game" / "options.rpy") + << "define config.name = \"AetherKiri bridge test\"\n" + << "define config.version = \"1\"\n" + << "define config.screen_width = 640\n" + << "define config.screen_height = 360\n" + << "define config.gl_enable = False\n" + << "define config.performance_test = False\n"; + std::ofstream(root / "game" / "script.rpy") << R"R(label main_menu: + jump start +label start: + scene Solid("#194a72") + menu: + "Input bridge continue": + $ open(renpy.config.gamedir + "/input-ok", "w").write("continue\n") + $ renpy.quit() + "Input bridge finish": + $ open(renpy.config.gamedir + "/input-ok", "w").write("finish\n") + $ renpy.quit() +)R"; +#if defined(_WIN32) + _putenv_s("SDL_VIDEODRIVER", "dummy"); + _putenv_s("SDL_AUDIODRIVER", "dummy"); + _putenv_s("RENPY_RENDERER", "sw"); +#else + setenv("SDL_VIDEODRIVER", "dummy", 1); + setenv("SDL_AUDIODRIVER", "dummy", 1); + setenv("RENPY_RENDERER", "sw", 1); +#endif + engine_create_desc_t desc{}; + desc.struct_size = sizeof(desc); + desc.api_version = ENGINE_API_VERSION; + const auto writable = (root / "host-data").u8string(); + desc.writable_path_utf8 = writable.c_str(); + engine_handle_t handle = nullptr; + assert(engine_create(&desc, &handle) == ENGINE_RESULT_OK); + engine_option_t option{}; + option.key_utf8 = "runtime"; + option.value_utf8 = "renpy"; + assert(engine_set_option(handle, &option) == ENGINE_RESULT_OK); + option.key_utf8 = "renpy_sdk_path"; + option.value_utf8 = AETHERKIRI_RENPY_TEST_SDK_ROOT; + assert(engine_set_option(handle, &option) == ENGINE_RESULT_OK); + const auto path = root.u8string(); + assert(engine_open_game(handle, path.c_str(), nullptr) == ENGINE_RESULT_OK); + engine_frame_desc_t frame{}; + frame.struct_size = sizeof(frame); + bool ready = false; + for (int i = 0; i < 200; ++i) { + assert(engine_tick(handle, 16) == ENGINE_RESULT_OK); + if (engine_get_frame_desc(handle, &frame) == ENGINE_RESULT_OK) { + ready = true; + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + } + assert(ready && frame.width == 640 && frame.height == 360 && + frame.stride_bytes == 640 * 4 && frame.frame_serial != 0); + std::vector pixels(frame.stride_bytes * frame.height); + assert(engine_read_frame_rgba(handle, pixels.data(), pixels.size()) == + ENGINE_RESULT_OK); + size_t nonzero = 0; + for (uint8_t value : pixels) nonzero += value != 0; + assert(nonzero != 0); + engine_input_event_t event{}; + event.struct_size = sizeof(event); + event.type = ENGINE_INPUT_EVENT_KEY_DOWN; + event.key_code = 1073741905; + assert(engine_send_input(handle, &event) == ENGINE_RESULT_OK); + event.key_code = 13; + assert(engine_send_input(handle, &event) == ENGINE_RESULT_OK); + for (int i = 0; i < 100 && !fs::exists(root / "game" / "input-ok"); ++i) { + assert(engine_tick(handle, 16) == ENGINE_RESULT_OK); + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + } + assert(fs::exists(root / "game" / "input-ok")); + assert(engine_destroy(handle) == ENGINE_RESULT_OK); + assert(!fs::exists(root / "game" / "libs")); + std::ofstream(root / "game" / "libs").put('\n'); + engine_handle_t blocked = nullptr; + assert(engine_create(&desc, &blocked) == ENGINE_RESULT_OK); + option.key_utf8 = "runtime"; + option.value_utf8 = "renpy"; + assert(engine_set_option(blocked, &option) == ENGINE_RESULT_OK); + option.key_utf8 = "renpy_sdk_path"; + option.value_utf8 = AETHERKIRI_RENPY_TEST_SDK_ROOT; + assert(engine_set_option(blocked, &option) == ENGINE_RESULT_OK); + assert(engine_open_game(blocked, path.c_str(), nullptr) == + ENGINE_RESULT_NOT_SUPPORTED); + assert(engine_destroy(blocked) == ENGINE_RESULT_OK); + fs::remove(root / "game" / "libs", ec); + fs::remove_all(root, ec); +} +} + +int main() { + const auto project = MakeProject(); + aetherkiri::renpy::RegisterRuntimeProvider(); + assert(engine_get_runtime_provider_count() > 0); + assert(engine_probe_runtime_provider("renpy", project.u8string().c_str()) > 0); +#if !defined(_WIN32) + CheckProcessGroupShutdown(); +#endif + CheckFrameInputBridge(); + std::error_code ec; + std::filesystem::remove_all(project, ec); + return 0; +} diff --git a/bridge/rfvp_runtime/CMakeLists.txt b/bridge/rfvp_runtime/CMakeLists.txt index 36ac585eb..020b2218f 100644 --- a/bridge/rfvp_runtime/CMakeLists.txt +++ b/bridge/rfvp_runtime/CMakeLists.txt @@ -15,28 +15,34 @@ find_program(RFVP_CARGO cargo REQUIRED) find_program(RFVP_RUSTC rustc REQUIRED) execute_process(COMMAND "${RFVP_RUSTC}" -vV OUTPUT_VARIABLE rust_version COMMAND_ERROR_IS_FATAL ANY) string(REGEX MATCH "host: ([^\n]+)" unused "${rust_version}") -set(AETHERKIRI_RFVP_RUST_TARGET "${CMAKE_MATCH_1}" CACHE STRING "Rust target triple for rfvp") +set(rfvp_default_rust_target "${CMAKE_MATCH_1}") if(APPLE AND CMAKE_OSX_ARCHITECTURES) list(LENGTH CMAKE_OSX_ARCHITECTURES arch_count) if(NOT arch_count EQUAL 1) message(FATAL_ERROR "Build rfvp once per architecture before combining universal libraries") endif() if(IOS) - if(CMAKE_OSX_SYSROOT STREQUAL "iphonesimulator") + # Ninja and Makefile generators receive a resolved SDK path. Match + # its basename case-insensitively, including versioned Apple SDKs. + get_filename_component(rfvp_ios_sdk_name "${CMAKE_OSX_SYSROOT}" NAME) + string(TOLOWER "${rfvp_ios_sdk_name}" rfvp_ios_sdk_name) + if(rfvp_ios_sdk_name MATCHES "^iphonesimulator([0-9]+([.][0-9]+)*)?([.]sdk)?$") if(CMAKE_OSX_ARCHITECTURES STREQUAL "arm64") - set(AETHERKIRI_RFVP_RUST_TARGET "aarch64-apple-ios-sim") + set(rfvp_default_rust_target "aarch64-apple-ios-sim") elseif(CMAKE_OSX_ARCHITECTURES STREQUAL "x86_64") - set(AETHERKIRI_RFVP_RUST_TARGET "x86_64-apple-ios") + set(rfvp_default_rust_target "x86_64-apple-ios") endif() elseif(CMAKE_OSX_ARCHITECTURES STREQUAL "arm64") - set(AETHERKIRI_RFVP_RUST_TARGET "aarch64-apple-ios") + set(rfvp_default_rust_target "aarch64-apple-ios") endif() elseif(CMAKE_OSX_ARCHITECTURES STREQUAL "arm64") - set(AETHERKIRI_RFVP_RUST_TARGET "aarch64-apple-darwin") + set(rfvp_default_rust_target "aarch64-apple-darwin") elseif(CMAKE_OSX_ARCHITECTURES STREQUAL "x86_64") - set(AETHERKIRI_RFVP_RUST_TARGET "x86_64-apple-darwin") + set(rfvp_default_rust_target "x86_64-apple-darwin") endif() endif() +# Compute the default first so an explicit cache override is not shadowed. +set(AETHERKIRI_RFVP_RUST_TARGET "${rfvp_default_rust_target}" CACHE STRING "Rust target triple for rfvp") include(cmake/PrepareRfvpSources.cmake) set(rfvp_prepared "${CMAKE_CURRENT_BINARY_DIR}/prepared") aetherkiri_prepare_rfvp("${AETHERKIRI_RFVP_PROJECT_ROOT}" "${AETHERKIRI_RFVP_DIR}" "${rfvp_prepared}") diff --git a/bridge/siglus_runtime/cmake/BuildSiglusRs.cmake b/bridge/siglus_runtime/cmake/BuildSiglusRs.cmake index bb68b29a1..ad61937a9 100644 --- a/bridge/siglus_runtime/cmake/BuildSiglusRs.cmake +++ b/bridge/siglus_runtime/cmake/BuildSiglusRs.cmake @@ -34,9 +34,12 @@ function(aetherkiri_deduce_rust_target out_triple) return() endif() if(IOS) - # CMAKE_OSX_ARCHITECTURES drives the device/simulator distinction; - # simulator builds may be arm64 or x86_64 depending on the host. - if(CMAKE_OSX_SYSROOT MATCHES "iphonesimulator") + # CMake resolves SDK aliases to paths for non-Xcode generators. + # Inspect the SDK basename, rather than a possibly misleading parent + # directory, and accept Apple's mixed-case/versioned SDK names. + get_filename_component(siglus_ios_sdk_name "${CMAKE_OSX_SYSROOT}" NAME) + string(TOLOWER "${siglus_ios_sdk_name}" siglus_ios_sdk_name) + if(siglus_ios_sdk_name MATCHES "^iphonesimulator([0-9]+([.][0-9]+)*)?([.]sdk)?$") if(CMAKE_OSX_ARCHITECTURES MATCHES "x86_64") set(${out_triple} "x86_64-apple-ios" PARENT_SCOPE) else() diff --git a/cmake/AetherKiriMinori.cmake b/cmake/AetherKiriMinori.cmake new file mode 100644 index 000000000..5ac9da359 --- /dev/null +++ b/cmake/AetherKiriMinori.cmake @@ -0,0 +1,32 @@ +# Keep Minori's existing override interface. Its pinned CMake 3.19 policy +# scope may remove a normal variable when it first creates this cache entry, +# so create the typed entry before supplying an automatic local default. +set(AETHERKIRI_RUST_TARGET "" CACHE STRING + "Rust target triple for the Minori runtime (auto-detected when empty)") + +function(aetherkiri_add_minori_runtime source_dir) + if(CMAKE_SYSTEM_NAME STREQUAL "iOS" AND NOT AETHERKIRI_RUST_TARGET) + get_filename_component(minori_ios_sdk_name "${CMAKE_OSX_SYSROOT}" NAME) + string(TOLOWER "${minori_ios_sdk_name}" minori_ios_sdk_name) + if(minori_ios_sdk_name MATCHES "^iphonesimulator([0-9]+([.][0-9]+)*)?([.]sdk)?$") + set(minori_ios_arch "${CMAKE_OSX_ARCHITECTURES}") + if(NOT minori_ios_arch) + set(minori_ios_arch "${CMAKE_SYSTEM_PROCESSOR}") + endif() + if(minori_ios_arch STREQUAL "arm64" OR minori_ios_arch STREQUAL "aarch64") + set(AETHERKIRI_RUST_TARGET "aarch64-apple-ios-sim") + elseif(minori_ios_arch STREQUAL "x86_64") + set(AETHERKIRI_RUST_TARGET "x86_64-apple-ios") + else() + message(FATAL_ERROR + "Minori has no Rust target mapping for iOS Simulator architecture ${minori_ios_arch}") + endif() + else() + set(AETHERKIRI_RUST_TARGET "aarch64-apple-ios") + endif() + endif() + # Automatic selection stays local to this invocation. The cache remains + # empty, so reconfiguring from device to Simulator recomputes the default. + # Preserve the actual SDK path and all upstream Cargo/build rules. + add_subdirectory("${source_dir}" "${CMAKE_CURRENT_BINARY_DIR}/packages/AetherMinori") +endfunction() diff --git a/cmake/engine_api_elf.map b/cmake/engine_api_elf.map index 06976bfd3..a50a3456e 100644 --- a/cmake/engine_api_elf.map +++ b/cmake/engine_api_elf.map @@ -3,11 +3,14 @@ engine_*; JNI_OnLoad; Java_org_github_krkr2_aetherkiri_EngineBridge_*; + Java_org_github_krkr2_aetherkiri_RenPyMobileBridge_*; + Java_org_libsdl_app_SDLActivity_*; _Z14krkr_GetJNIEnvv; _Z14krkr_GetJavaVMv; _Z20krkr_GetNativeWindowv; _Z25krkr_GetSurfaceDimensions*; _Z26krkr_GetApplicationContextv; + _Z20krkr_GetHostActivityv; _Z16TVPTransformText*; _Z15TVPPrefetchText*; _Z27TVPSetTextTransformCallback*; diff --git a/demos/aetherkiri-renpy/README.md b/demos/aetherkiri-renpy/README.md new file mode 100644 index 000000000..43717ab02 --- /dev/null +++ b/demos/aetherkiri-renpy/README.md @@ -0,0 +1,124 @@ +# AetherKiri Ren'Py fixture + +This is a real, minimal Ren'Py project used to validate the AetherKiri +Ren'Py runtime path. It exercises: + +- startup and scene rendering with a bundled SVG image +- text rendering and menu navigation +- pointer/touch activation of screen buttons +- `renpy.input` text entry +- a blocking `renpy.pause` that the host must pause/resume around +- returning from the script and an explicit quit action + +Run it with an official Ren'Py SDK: + +```bash +RENPY_SDK=/path/to/renpy-sdk bash ../../tools/run_renpy_smoke.sh +``` + +The automated smoke command compiles the project and initializes Ren'Py. A +device or simulator run is still required to verify the host lifecycle and +input behavior on mobile. + +## A small manual Android phone check + +An APK that passes compilation, payload checks and signing is a test candidate; +it does not establish that mobile gameplay works. Use the ARM64 artifact on an +Android 8 or later phone with a 64-bit ARM system. The x86_64 artifact is for +that architecture, usually an emulator. + +The reproduction workflow's coinstallable Debug build uses package +`org.aetherkiri.renpy.debug`, launcher label **Aether RenPy Test**, and a +disposable signing key. It keeps the production application's installation and +data separate. Earlier APKs use `org.github.krkr2.aetherkiri` and may conflict +with an installed application signed by another key. If Android rejects that +installation, stop and use the coinstallable build. Do not uninstall or clear +an existing application as an automatic workaround. A later test build can +also have a different Debug key; keep any test saves before choosing how to +replace it. + +1. Download `renpy-installable-debug-arm64-v8a` from the successful workflow + run, unzip it on the phone, and open `Aether-debug.apk`. Follow Android's + installation prompt for the chosen download source. +2. Download `renpy-demo-project` from the same run and unzip its `game` folder + inside `Download/aetherkiri-renpy` in the phone's internal storage. + Keep `game/script.rpy`, `game/options.rpy`, and `game/scene.svg` together. + No desktop Ren'Py SDK or device-request JSON is needed for this manual flow. +3. Open **Aether RenPy Test**, complete the existing first-run agreement and + file-access prompts, and choose **Import**. In the system folder picker, + navigate through **Internal storage → Download → aetherkiri-renpy** and + select that directory containing `game`, then open its detail page and + start it. This route gives the native loader the primary storage path; + other document-provider locations still need separate import coverage. +4. The real demo should show its scene and dialogue. Advance the dialogue, + tap **Start**, enter a name with the system keyboard, and confirm. Check + that the dialogue contains the name. At the menu, press the phone's Home + button, return to the app, and try **Enter text** again. **Pause / resume** + also exercises a short in-game pause; it does not replace the Home check. +5. Choose **Quit** and confirm if prompted. Check that the normal application + library returns and remains responsive. A new Ren'Py session requires a + host-process restart: use Android's **Force stop** for this test application + before reopening it. Clearing data or uninstalling is unnecessary. + +Record the workflow run, APK filename, Android version, phone model, what +appeared on screen, input/lifecycle results, and any error. Do not report a +pass if it only reaches the library, shows a blank screen, or fails to accept +input. This manual check does not produce the independent automated evidence +below. Its Unicode/IME composition and prolonged background behavior need +separate coverage. + +For a real **cloud-hosted Android** debug APK and authorized device/emulator: + +```bash +python3 tools/run_renpy_android_device_acceptance.py \ + --apk out/godot/android/debug/Aether-debug.apk \ + --serial emulator-5554 --cloud-device \ + --output-dir /tmp/aether-renpy-android-acceptance +``` + +Run this command from the repository root inside the cloud task. It installs the +APK with adb, uses Android `run-as` to stage this public demo and a one-shot debug +request in the app's private test directory, and launches the actual app. It +requires a debuggable APK containing the native Ren'Py runtime; stock RAPT alone +does not satisfy the host lifecycle ABI. It does not use a user computer. + +For the coinstallable reproduction APK, add +`--package org.aetherkiri.renpy.debug`; the default targets the normal product +package. Both commands run only on the authorized cloud device. + +The device mode is enabled only by `game/aether-device-request.json`. Real +Ren'Py statements write a fresh run ID into `aether-device-checkpoints.jsonl`. +The harness requires actual OS touch down/up, Android text/Enter, a visible +system keyboard requested by native text-input state, Home/foreground lifecycle +pause/resume, another game touch after resume, and explicit `renpy.quit` followed +by observed native exit, engine teardown and removal of the actual Android +activity. The automated Android observer exits its host after this check; +the manual normal-app flow above also checks the library's responsiveness. +[Android may retain a cached process](https://developer.android.com/guide/components/activities/process-lifecycle); +its PID is recorded separately. +The regular interactive fixture +remains available when the request file is absent. + +Evidence includes APK SHA-256, device build fingerprint, adb command results, +the Ren'Py script checkpoints, native provider RGBA PNGs, OS screenshots, +pixel-comparison reports, native pause/resume/exit state, the flushed per-game +engine log, and process logcat. +Screenshots must show the same nonblank pixels as the provider's real frame; +the script's actual idle/hover button and stage-marker colors must also appear. +Resume requires a new native frame serial after the pause, then a visible +WAIT-to-RESUMED marker change after the next actual system touch. Missing or +unreadable engine logs and recorded Ren'Py errors fail acceptance. Native +shutdown returns void, so these log checks do not infer a successful status. +Viewport fallback images, colorful system screens, startup-only checks, and +simulated provider frames cannot satisfy this gameplay run. + +`result.json` distinguishes `passed`, `failed`, and `not_run`. Exit status is +respectively 0, 1, or 2. Missing tools, device, APK, or permitted debug staging +produce `not_run`; a timeout or missing gameplay/frame/lifecycle evidence after +launch is a failure. Neither result means mobile gameplay passed. This Linux +cloud task cannot execute iOS Simulator acceptance without an authorized macOS +cloud runner with Xcode/iOS Simulator; iOS execution remains unverified. + +The automatic Android text check covers an ASCII string and Enter from the +system keyboard path. Unicode text and IME composition need separate real +device coverage before claiming those input methods were tested. diff --git a/demos/aetherkiri-renpy/game/options.rpy b/demos/aetherkiri-renpy/game/options.rpy new file mode 100644 index 000000000..39af09cd1 --- /dev/null +++ b/demos/aetherkiri-renpy/game/options.rpy @@ -0,0 +1,8 @@ +# AetherKiri's minimal Ren'Py runtime fixture. + +define config.name = "AetherKiri Ren'Py Fixture" +define config.version = "1.0" +define config.window_title = "AetherKiri Ren'Py Fixture" +define config.screen_width = 960 +define config.screen_height = 540 +define config.developer = True diff --git a/demos/aetherkiri-renpy/game/scene.svg b/demos/aetherkiri-renpy/game/scene.svg new file mode 100644 index 000000000..45488dfaa --- /dev/null +++ b/demos/aetherkiri-renpy/game/scene.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + AetherKiri + Ren'Py runtime fixture + diff --git a/demos/aetherkiri-renpy/game/script.rpy b/demos/aetherkiri-renpy/game/script.rpy new file mode 100644 index 000000000..1320a1782 --- /dev/null +++ b/demos/aetherkiri-renpy/game/script.rpy @@ -0,0 +1,136 @@ +define narrator = Character(None) +define fixture = Character("Fixture") + +default player_name = "" + +image bg fixture = "scene.svg" + +# Device evidence comes from executed Ren'Py statements, never from the host. +# The request is staged only into the debug app's private test project. +init python: + import json + import os + import time + + aether_device_request = {} + try: + with open(os.path.join(config.gamedir, "aether-device-request.json"), "r") as request_file: + aether_device_request = json.load(request_file) + except (OSError, ValueError): + pass + + if aether_device_request.get("run_id"): + # Supported by Ren'Py's common/00start.rpy. The device flow starts at + # this game's actual start label, independently of SDK menu layout. + os.environ["RENPY_SKIP_MAIN_MENU"] = "1" + + # Only the private device flow renders these landmarks. Each color changes + # in the executed game, independently of the host's lifecycle observations. + aether_device_markers = { + "start_ready": ("START", "#28b4d2", [40, 180, 210]), + "post_text_ready": ("WAIT", "#416add", [65, 106, 221]), + "quit_ready": ("RESUMED", "#eba440", [235, 164, 64]), + } + + def aether_device_checkpoint(stage, **details): + if not aether_device_request.get("run_id"): + return + if stage in aether_device_markers: + marker_name, marker_color, marker_rgb = aether_device_markers[stage] + details.update(logical_canvas=[config.screen_width, config.screen_height], + button_bounds=[360, 222, 240, 96], + button_rgbs=[[23, 52, 76], [37, 106, 136]], + marker_bounds=[360, 350, 240, 40], + marker_name=marker_name, marker_rgb=marker_rgb) + entry = dict(run_id=aether_device_request["run_id"], stage=stage, + source="renpy-script", monotonic=time.monotonic(), details=details) + with open(os.path.join(config.gamedir, "aether-device-checkpoints.jsonl"), "a") as checkpoint_file: + checkpoint_file.write(json.dumps(entry, ensure_ascii=False) + "\n") + checkpoint_file.flush() + os.fsync(checkpoint_file.fileno()) + +screen fixture_device_button(caption, marker_name, marker_color): + modal True + text "AetherKiri real Ren'Py device acceptance" xpos 48 ypos 168 size 28 color "#ffffff" + textbutton caption: + xpos 360 + ypos 222 + xsize 240 + ysize 96 + text_size 24 + background Solid("#17344c") + hover_background Solid("#256a88") + action Return() + add Solid(marker_color) xpos 360 ypos 350 xsize 240 ysize 40 + text marker_name xpos 480 ypos 370 anchor (0.5, 0.5) size 24 color "#ffffff" + +screen fixture_start(): + modal True + frame: + xalign 0.5 + yalign 0.78 + padding (24, 18) + vbox: + spacing 12 + text "Tap Start to continue" size 28 xalign 0.5 + textbutton "Start" action Return() xalign 0.5 + +screen fixture_menu(): + frame: + xalign 0.5 + yalign 0.82 + padding (20, 14) + hbox: + spacing 18 + textbutton "Pause / resume" action Return("pause") + textbutton "Enter text" action Return("input") + textbutton "Quit" action Return("quit") + +label start: + if aether_device_request.get("run_id"): + jump aether_device_acceptance + scene bg fixture with fade + narrator "AetherKiri Ren'Py runtime fixture started." + narrator "This screen is rendered by the real Ren'Py project." + call screen fixture_start + + $ player_name = renpy.input("Enter your name:", default="Player") + $ player_name = player_name.strip() + if not player_name: + $ player_name = "Player" + fixture "Hello, [player_name]. Text input reached the game." + +label interaction: + call screen fixture_menu + if _return == "pause": + narrator "The game is entering a short blocking pause." + $ renpy.pause(0.25) + narrator "The pause returned; the host can resume the runtime now." + jump interaction + elif _return == "input": + $ player_name = renpy.input("Update your name:", default=player_name) + $ player_name = player_name.strip() or "Player" + fixture "The current name is [player_name]." + jump interaction + else: + $ renpy.quit() + return + +label aether_device_acceptance: + scene bg fixture + $ aether_device_checkpoint("start_ready", button=[480, 270]) + call screen fixture_device_button("Start", aether_device_markers["start_ready"][0], aether_device_markers["start_ready"][1]) + $ aether_device_checkpoint("touch_received") + $ aether_device_checkpoint("text_ready") + $ player_name = renpy.input("Enter the cloud acceptance name:", default="", length=32) + $ aether_device_checkpoint("text_received", text=player_name) + # The host must background and resume the real application while this + # interaction is waiting, then deliver another real system touch. + $ aether_device_checkpoint("post_text_ready", button=[480, 270]) + call screen fixture_device_button("Continue after resume", aether_device_markers["post_text_ready"][0], aether_device_markers["post_text_ready"][1]) + $ aether_device_checkpoint("resumed_touch_received") + $ aether_device_checkpoint("quit_ready", button=[480, 270]) + call screen fixture_device_button("Quit", aether_device_markers["quit_ready"][0], aether_device_markers["quit_ready"][1]) + $ aether_device_checkpoint("quit_requested") + $ renpy.quit(confirm=False) + return diff --git a/scripts/build_android.sh b/scripts/build_android.sh index 89ae27b76..6ef938e31 100755 --- a/scripts/build_android.sh +++ b/scripts/build_android.sh @@ -22,11 +22,27 @@ if [[ "$BUILD_TYPE_LOWER" != "debug" && "$BUILD_TYPE_LOWER" != "release" ]]; the exit 1 fi +case "$ABIS" in + arm64-v8a) ;; + x86_64) + if [[ "$BUILD_TYPE_LOWER" != "debug" ]]; then + echo "Error: x86_64 is the dedicated cloud emulator debug profile. Use debug --abi=x86_64." >&2 + exit 1 + fi + ;; + *) + echo "Error: Select one Android ABI: arm64-v8a (default) or x86_64 (debug cloud emulator)." >&2 + exit 1 + ;; +esac + ANDROID_HOME="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$HOME/Library/Android/sdk}}" GODOT_BIN="${GODOT_BIN:-/Applications/Godot.app/Contents/MacOS/Godot}" GODOT_TEMPLATE_DIR="${GODOT_TEMPLATE_DIR:-$HOME/Library/Application Support/Godot/export_templates/4.7.2.stable}" GODOT_APP_DIR="$PROJECT_ROOT/apps/godot_app" PARALLEL_JOBS="${JOBS:-8}" +RENPY_MOBILE_ROOT="${AETHERKIRI_RENPY_MOBILE_ROOT:-${RENPY_MOBILE_ROOT:-$PROJECT_ROOT/out/renpy-mobile-source/payload}}" +RENPY_PRIVATE_ROOT="${AETHERKIRI_RENPY_ANDROID_PRIVATE_ASSETS:-$RENPY_MOBILE_ROOT/rapt/runtime/private}" ensure_vcpkg() { if [[ -f "$PROJECT_ROOT/.devtools/vcpkg/.vcpkg-root" ]]; then @@ -86,9 +102,6 @@ ensure_host_rust() { esac } -ensure_vcpkg -ensure_host_rust - # Resolve a Rust toolchain that can build for the requested Android target. # Homebrew's Rust formula ships without Android targets, so a cargo found in # PATH may fail with E0463 ("can't find crate for core") even though rustup @@ -207,6 +220,12 @@ export ANDROID_HOME export ANDROID_SDK_ROOT="$ANDROID_HOME" export ANDROID_NDK_HOME="$ANDROID_NDK_HOME_RESOLVED" export ANDROID_NDK="$ANDROID_NDK_HOME_RESOLVED" +if [[ ! -d "$ANDROID_HOME" ]]; then + echo "Error: Android SDK not found at $ANDROID_HOME." >&2 + exit 1 +fi +ensure_vcpkg +ensure_host_rust # The Android export uses a custom Gradle build template (minSdk 26 lives in # export_presets.cfg). The template is generated content and gitignored; @@ -229,6 +248,41 @@ if [[ ! -f "$GODOT_APP_DIR/android/.build_version" || ! -f "$GODOT_APP_DIR/andro : > "$GODOT_APP_DIR/android/build/.gdignore" fi +stage_renpy_android_support() { + local renpy_enabled="${AETHERKIRI_ENABLE_RENPY:-OFF}" + local renpy_enabled_lower + renpy_enabled_lower="$(printf '%s' "$renpy_enabled" | tr '[:upper:]' '[:lower:]')" + case "$renpy_enabled_lower" in + off|false|0|no|"") + return 0 + ;; + on|true|1|yes) + ;; + *) + echo "Error: AETHERKIRI_ENABLE_RENPY must be ON/OFF or true/false" >&2 + exit 1 + ;; + esac + + local mobile_root="$RENPY_MOBILE_ROOT" + if [[ -z "$mobile_root" ]]; then + echo "Error: AETHERKIRI_ENABLE_RENPY=ON requires AETHERKIRI_RENPY_MOBILE_ROOT or RENPY_MOBILE_ROOT" >&2 + echo " Build the native lifecycle payload with tools/run_renpy_mobile_source_build.sh --mode build first." >&2 + exit 1 + fi + + local stage_args=( + --mobile-root "$mobile_root" + --godot-build "$GODOT_APP_DIR/android/build" + --private-assets "$RENPY_PRIVATE_ROOT" + --abis "$ABIS" + ) + echo "==> Staging rebuilt Ren'Py lifecycle runtime into the Godot export" + bash "$PROJECT_ROOT/tools/stage_renpy_android_support.sh" "${stage_args[@]}" +} + +stage_renpy_android_support + command -v cmake >/dev/null NINJA_BIN="${CMAKE_MAKE_PROGRAM:-$(command -v ninja || command -v ninja-build || true)}" if [[ -z "$NINJA_BIN" ]]; then @@ -321,9 +375,14 @@ build_abi() { local vcpkg_triplet_dir local libomp_path local android_strip_path + local vcpkg_target_triplet + local openmp_arch local cmake_config_args=( -D "CMAKE_MAKE_PROGRAM=$CMAKE_MAKE_PROGRAM" -D "AETHERKIRI_ENABLE_INTERNAL=${AETHERKIRI_ENABLE_INTERNAL:-ON}" + -D "AETHERKIRI_ENABLE_RENPY=${AETHERKIRI_ENABLE_RENPY:-OFF}" + -D "AETHERKIRI_RENPY_MOBILE_LIBRARY=$RENPY_MOBILE_ROOT/rapt/prototype/renpyandroid/src/main/jniLibs/$abi/librenpython.so" + -D "AETHERKIRI_RENPY_PRIVATE_ROOT=$RENPY_PRIVATE_ROOT" -D "AETHERKIRI_ENABLE_SOFTPAL_RUNTIME=${AETHERKIRI_ENABLE_SOFTPAL_RUNTIME:-OFF}" -D "AETHERKIRI_SOFTPAL_DIR=${AETHERKIRI_SOFTPAL_DIR:-$PROJECT_ROOT/packages/AetherSoftPal}" ) @@ -332,28 +391,37 @@ build_abi() { arm64-v8a) cmake_config_preset="Android arm64 ${BUILD_TYPE_CAP} Config" cmake_build_preset="Android arm64 ${BUILD_TYPE_CAP} Build" + vcpkg_target_triplet="arm64-android" + openmp_arch="aarch64" ensure_android_rust "aarch64-linux-android" ;; + x86_64) + cmake_config_preset="Android x64 Debug Config" + cmake_build_preset="Android x64 Debug Build" + vcpkg_target_triplet="x64-android" + openmp_arch="x86_64" + ensure_android_rust "x86_64-linux-android" + ;; *) - echo "Error: Android ABI '$abi' is not wired for the Godot migration yet. Use arm64-v8a." >&2 + echo "Error: Unsupported Android ABI '$abi'." >&2 exit 1 ;; esac cmake_build_dir="$PROJECT_ROOT/out/android/$abi/$BUILD_TYPE_LOWER" godot_bin_dir="$GODOT_APP_DIR/bin/android/$abi/$BUILD_TYPE_LOWER" - vcpkg_triplet_dir="$cmake_build_dir/vcpkg_installed/arm64-android" + vcpkg_triplet_dir="$cmake_build_dir/vcpkg_installed/$vcpkg_target_triplet" echo "==> Building Android native libraries ($abi, $BUILD_TYPE_LOWER)" if [[ "${SKIP_ANDROID_VCPKG_INSTALL:-}" == "1" ]]; then - if [[ ! -d "$VCPKG_ROOT/installed/arm64-android" ]]; then - echo "Error: SKIP_ANDROID_VCPKG_INSTALL=1 but prebuilt vcpkg triplet is missing: $VCPKG_ROOT/installed/arm64-android" >&2 + if [[ ! -d "$VCPKG_ROOT/installed/$vcpkg_target_triplet" ]]; then + echo "Error: SKIP_ANDROID_VCPKG_INSTALL=1 but prebuilt vcpkg triplet is missing: $VCPKG_ROOT/installed/$vcpkg_target_triplet" >&2 exit 1 fi mkdir -p "$cmake_build_dir" rm -rf "$cmake_build_dir/vcpkg_installed" ln -s "$VCPKG_ROOT/installed" "$cmake_build_dir/vcpkg_installed" - ensure_android_godot_cpp_package_config "$cmake_build_dir/vcpkg_installed/arm64-android" + ensure_android_godot_cpp_package_config "$vcpkg_triplet_dir" cmake_config_args+=( -D "VCPKG_MANIFEST_INSTALL=OFF" -D "VCPKG_INSTALLED_DIR=$cmake_build_dir/vcpkg_installed" @@ -366,11 +434,12 @@ build_abi() { mkdir -p "$godot_bin_dir" copy_android_so "$cmake_build_dir/abi/libengine_api.so" "$godot_bin_dir/libengine_api.so" copy_android_so "$cmake_build_dir/bridge/godot_extension/libaether_kiri_godot.so" "$godot_bin_dir/libaether_kiri_godot.so" - copy_android_so "$vcpkg_triplet_dir/lib/libSDL2.so" "$godot_bin_dir/libSDL2.so" - libomp_path="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/darwin-x86_64/lib/clang/19/lib/linux/aarch64/libomp.so" - if [[ ! -f "$libomp_path" ]]; then - libomp_path="$(find "$ANDROID_NDK_HOME/toolchains/llvm/prebuilt" -path '*/lib/linux/aarch64/libomp.so' -print -quit)" + local sdl_library="$vcpkg_triplet_dir/lib/libSDL2.so" + if [[ "$BUILD_TYPE_LOWER" == debug && -f "$vcpkg_triplet_dir/debug/lib/libSDL2.so" ]]; then + sdl_library="$vcpkg_triplet_dir/debug/lib/libSDL2.so" fi + copy_android_so "$sdl_library" "$godot_bin_dir/libSDL2.so" + libomp_path="$(find "$ANDROID_NDK_HOME/toolchains/llvm/prebuilt" -path "*/lib/linux/$openmp_arch/libomp.so" -print -quit)" if [[ -z "$libomp_path" || ! -f "$libomp_path" ]]; then echo "Error: Android OpenMP runtime libomp.so not found under $ANDROID_NDK_HOME." >&2 exit 1 @@ -400,30 +469,68 @@ for abi in "${ABI_LIST[@]}"; do done if [[ ! -x "$GODOT_BIN" ]]; then - echo "Warning: Godot not found at $GODOT_BIN; native libraries were staged only." >&2 + echo "Error: Godot not found at $GODOT_BIN; cannot produce the requested Android APK." >&2 + exit 1 elif [[ ! -f "$GODOT_TEMPLATE_DIR/android_debug.apk" || ! -f "$GODOT_TEMPLATE_DIR/android_release.apk" ]]; then - echo "Warning: Godot Android export templates are missing in $GODOT_TEMPLATE_DIR; native libraries were staged only." >&2 + echo "Error: Godot Android export templates are missing in $GODOT_TEMPLATE_DIR; cannot produce an APK." >&2 echo " Expected android_debug.apk and android_release.apk." >&2 + exit 1 else echo "==> Exporting Godot Android APK" - mkdir -p "$PROJECT_ROOT/out/godot/android/$BUILD_TYPE_LOWER" - export_path="$PROJECT_ROOT/out/godot/android/$BUILD_TYPE_LOWER/Aether-$BUILD_TYPE_LOWER.apk" + export_dir="$PROJECT_ROOT/out/godot/android/$BUILD_TYPE_LOWER" + export_path="$export_dir/Aether-$BUILD_TYPE_LOWER.apk" export_preset="Android ${BUILD_TYPE_CAP}" + if [[ "$ABIS" == "x86_64" ]]; then + export_dir="$PROJECT_ROOT/out/godot/android/x86_64/debug" + export_path="$export_dir/Aether-emulator-debug.apk" + export_preset="Android Emulator Debug" + fi + mkdir -p "$export_dir" export_mode="--export-debug" if [[ "$BUILD_TYPE_LOWER" == "release" ]]; then export_mode="--export-release" fi + android_export_app_dir="$GODOT_APP_DIR" + android_test_package="${AETHERKIRI_ANDROID_TEST_PACKAGE_ID:-}" + if [[ -n "$android_test_package" ]]; then + if [[ "$BUILD_TYPE_LOWER" != "debug" ]]; then + echo "Error: AETHERKIRI_ANDROID_TEST_PACKAGE_ID is allowed only for a Debug test export." >&2 + exit 1 + fi + android_test_export_root="$(mktemp -d "${TMPDIR:-/tmp}/aether-renpy-android-export.XXXXXX")" + # This is an individually created disposable export directory, never + # the checkout, dependency cache, or an existing application directory. + trap 'rm -rf "$android_test_export_root"' EXIT + python3 "$PROJECT_ROOT/tools/prepare_renpy_android_test_export.py" \ + --source-project "$GODOT_APP_DIR" --destination "$android_test_export_root" \ + --preset-name "$export_preset" --package-id "$android_test_package" + android_export_app_dir="$android_test_export_root" + fi godot_export_command=("$GODOT_BIN") macos_host_extension="$GODOT_APP_DIR/bin/macos/$BUILD_TYPE_LOWER/libaether_kiri_godot.dylib" if [[ "$(uname -m)" == "arm64" && -f "$macos_host_extension" && "$(lipo -archs "$macos_host_extension" 2>/dev/null || true)" == "x86_64" ]]; then # This private native host extension is currently x86_64-only. Match # the editor process to the staged host extension while - # it imports the project; the exported Android libraries remain arm64. + # it imports the project; the exported Android ABI is selected above. godot_export_command=(arch -x86_64 "$GODOT_BIN") fi - "${godot_export_command[@]}" --headless --path "$GODOT_APP_DIR" \ + "${godot_export_command[@]}" --headless --path "$android_export_app_dir" \ "$export_mode" "$export_preset" "$export_path" + [[ -s "$export_path" ]] || { echo "Android export did not create an APK: $export_path" >&2; exit 1; } + case "${AETHERKIRI_ENABLE_RENPY:-OFF}" in + ON|TRUE|YES|1|on|true|yes) + python3 "$PROJECT_ROOT/tools/inspect_renpy_android_apk.py" \ + --apk "$export_path" --abi "$ABIS" \ + --godot-build "$android_export_app_dir/android/build" \ + --output-dir "$export_dir/diagnostics" \ + --export-presets "$android_export_app_dir/export_presets.cfg" \ + --export-preset "$export_preset" --build-type "$BUILD_TYPE_LOWER" \ + --android-sdk "$ANDROID_HOME" --android-ndk "$ANDROID_NDK_HOME" + python3 "$PROJECT_ROOT/tools/validate_renpy_mobile_payload.py" \ + --platform android --abi "$ABIS" --apk "$export_path" + ;; + esac fi -echo "Android build output: $PROJECT_ROOT/out/godot/android/$BUILD_TYPE_LOWER" +echo "Android build output: $export_path" diff --git a/scripts/build_ios.sh b/scripts/build_ios.sh index c3ff4f573..012e577cd 100755 --- a/scripts/build_ios.sh +++ b/scripts/build_ios.sh @@ -6,20 +6,34 @@ PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" BUILD_TYPE="debug" SIMULATOR=false -SIMULATOR_ARCH="${IOS_SIMULATOR_ARCH:-x86_64}" +SIMULATOR_ARCH="${IOS_SIMULATOR_ARCH:-$(uname -m)}" PACKAGE_IPA=false +RENPY_FRAMEWORK_ONLY=false for arg in "$@"; do case "$arg" in debug|release|Debug|Release) BUILD_TYPE="$arg" ;; --simulator) SIMULATOR=true ;; --simulator-arch=*) SIMULATOR_ARCH="${arg#*=}" ;; --package-ipa|--unsigned-ipa|--ipa) PACKAGE_IPA=true ;; + --renpy-framework-only) RENPY_FRAMEWORK_ONLY=true ;; *) echo "[WARN] Unknown iOS build argument ignored: $arg" ;; esac done BUILD_TYPE_LOWER="$(echo "$BUILD_TYPE" | tr '[:upper:]' '[:lower:]')" BUILD_TYPE_CAP="$(echo "${BUILD_TYPE_LOWER:0:1}" | tr '[:lower:]' '[:upper:]')${BUILD_TYPE_LOWER:1}" +if [[ "$BUILD_TYPE_LOWER" != debug && "$BUILD_TYPE_LOWER" != release ]]; then + echo "Error: Use debug or release for the iOS app build." >&2 + exit 1 +fi +if [[ "$SIMULATOR" == true && "$BUILD_TYPE_LOWER" != debug ]]; then + echo "Error: The iOS Simulator app currently uses the Debug CMake/export templates; select debug." >&2 + exit 1 +fi +if [[ "$RENPY_FRAMEWORK_ONLY" == true && "$PACKAGE_IPA" == true ]]; then + echo "Error: --renpy-framework-only does not build an application or package an IPA." >&2 + exit 1 +fi if [[ "$SIMULATOR" == true ]]; then if [[ "$SIMULATOR_ARCH" == "x86_64" || "$SIMULATOR_ARCH" == "x64" ]]; then @@ -53,7 +67,29 @@ GODOT_APP_DIR="$PROJECT_ROOT/apps/godot_app" GODOT_BIN_DIR="$GODOT_APP_DIR/bin/$GODOT_TRIPLET_DIR" RUNTIME_CJK_FONT_SOURCE="$GODOT_APP_DIR/assets/fonts/aetherkiri-runtime-cjk.otf" RUNTIME_SYMBOL_FONT_SOURCE="$GODOT_APP_DIR/assets/fonts/aetherkiri-runtime-symbols.ttf" +# When Ren'Py is enabled, isolate the rebuilt Renios native closure in its own +# framework and copy its Python resources into the app. It must never +# copy or compile Renios' prototype main.c: Godot already owns the process and +# the UIKit/SDL application entrypoint. +RENPY_MOBILE_ROOT="${AETHERKIRI_RENPY_MOBILE_ROOT:-${RENPY_MOBILE_ROOT:-$PROJECT_ROOT/out/renpy-mobile-source/payload}}" +RENPY_RENIOS_BASE="${AETHERKIRI_RENPY_RENIOS_BASE:-$RENPY_MOBILE_ROOT/renios/prototype/base}" PARALLEL_JOBS="${JOBS:-8}" +IOS_EXPORT_DIR="$PROJECT_ROOT/out/godot/ios/$BUILD_TYPE_LOWER" +if [[ "$SIMULATOR" == true ]]; then + IOS_EXPORT_DIR="$PROJECT_ROOT/out/godot/ios-simulator-$SIMULATOR_ARCH/$BUILD_TYPE_LOWER" +fi +RENPY_FRAMEWORK_NAME=AetherRenPyRuntime +RENPY_FRAMEWORK_OUTPUT="$CMAKE_BUILD_DIR/renpy-native/$RENPY_FRAMEWORK_NAME.framework" +if [[ "$(uname -s)" != Darwin ]] || ! command -v xcrun >/dev/null 2>&1 || ! command -v xcodebuild >/dev/null 2>&1; then + echo "Error: iOS app packaging requires a cloud macOS host with Xcode and its licensed iPhoneOS/iPhoneSimulator SDK; this host has no Apple build tools." >&2 + exit 1 +fi +IOS_SDK=iphoneos +[[ "$SIMULATOR" == true ]] && IOS_SDK=iphonesimulator +xcrun --sdk "$IOS_SDK" --show-sdk-path >/dev/null || { + echo "Error: Xcode lacks the $IOS_SDK SDK required for this iOS target." >&2 + exit 1 +} FORCE_LOAD_PLUGIN_ARCHIVES=( "libSDL2.a" "libkrkr2plugin.a" @@ -79,7 +115,7 @@ FORCE_LOAD_PLUGIN_SOURCES=( PRIVATE_RUNTIME_ARCHIVE_LISTER="$PROJECT_ROOT/packages/AetherInternal/tools/list_ios_runtime_archives.sh" if [[ -x "$PRIVATE_RUNTIME_ARCHIVE_LISTER" ]]; then private_runtime_target="device" - if [[ "$SIMULATOR" == true ]]; then + if [[ "${SIMULATOR:-false}" == true ]]; then private_runtime_target="simulator-$SIMULATOR_ARCH" fi while IFS=$'\t' read -r archive source; do @@ -164,32 +200,17 @@ export CMAKE_MAKE_PROGRAM="$NINJA_BIN" preflight_simulator_template_arch() { local arch="$1" local template="$2" - local tmpdir - local libgodot - local info if [[ ! -f "$template" ]]; then return fi - - tmpdir="$(mktemp -d /tmp/aetherkiri-ios-template.XXXXXX)" - libgodot="$tmpdir/libgodot.ios.debug.xcframework/ios-arm64_x86_64-simulator/libgodot.a" - unzip -q "$template" \ - 'libgodot.ios.debug.xcframework/ios-arm64_x86_64-simulator/libgodot.a' \ - -d "$tmpdir" - info="$(lipo -archs "$libgodot" 2>/dev/null || true)" - rm -rf "$tmpdir" - - if [[ " $info " != *" $arch "* ]]; then - echo "Error: Godot iOS simulator export template does not contain '$arch'." >&2 - echo " $template" >&2 - echo " architectures: ${info:-unknown}" >&2 - echo " Install or build a Godot export template with an $arch simulator slice, or use --simulator-arch=x86_64." >&2 - exit 1 - fi + # Check real objects in both engine/camera archives, not only the plist's + # advertised CPU list. Device arm64 objects cannot satisfy this check. + python3 "$PROJECT_ROOT/tools/build_godot_ios_simulator_template.py" verify \ + --template "$template" --arch "$arch" } -if [[ "$SIMULATOR" == true ]]; then +if [[ "$SIMULATOR" == true && "$RENPY_FRAMEWORK_ONLY" == false ]]; then preflight_simulator_template_arch "$SIMULATOR_ARCH" "$GODOT_EXPORT_TEMPLATE" fi @@ -294,11 +315,321 @@ EOF libtool -static -o "$output" "$object" } +renios_enabled() { + case "${AETHERKIRI_ENABLE_RENPY:-OFF}" in + ON|TRUE|YES|1|on|true|yes) return 0 ;; + *) return 1 ;; + esac +} + +renpy_device_gles_enabled() { + case "${AETHERKIRI_RENPY_IOS_DEVICE_GLES:-OFF}" in + ON|TRUE|YES|1|on|true|yes) return 0 ;; + *) return 1 ;; + esac +} + +if renpy_device_gles_enabled; then + if [[ "$SIMULATOR" == true || "$BUILD_TYPE_LOWER" != debug ]] || ! renios_enabled; then + echo "Error: AETHERKIRI_RENPY_IOS_DEVICE_GLES requires a Debug device build with Ren'Py enabled." >&2 + exit 1 + fi + if [[ "$RENPY_FRAMEWORK_ONLY" == false ]]; then + python3 "$PROJECT_ROOT/tools/build_godot_ios_device_template.py" verify \ + --template "$GODOT_EXPORT_TEMPLATE" + fi +fi + +renios_link_enabled() { + case "${AETHERKIRI_RENPY_RENIOS_LINK:-ON}" in + ON|TRUE|YES|1|on|true|yes) return 0 ;; + *) return 1 ;; + esac +} + +renios_prototype_root() { + [[ -n "$RENPY_MOBILE_ROOT" ]] || return 1 + local root="$RENPY_MOBILE_ROOT/renios/prototype" + [[ -d "$root" ]] || return 1 + [[ -d "$root/prebuilt" ]] || return 1 + printf '%s\n' "$root" +} + +renios_configuration() { + if [[ "${SIMULATOR:-false}" == true ]]; then + printf 'debug\n' + else + # Upstream Renios debug archives contain simulator slices only; + # release archives contain the device arm64 slice for either app mode. + printf 'release\n' + fi +} + +renios_prebuilt_root() { + local prototype + prototype="$(renios_prototype_root 2>/dev/null || true)" + [[ -n "$prototype" ]] || return 1 + # Renios release archives are device arm64 only. The debug archive is a + # universal simulator/device slice, so use it for every simulator export + # even when the surrounding Godot export is a release configuration. + local configuration + configuration="$(renios_configuration)" + local prebuilt="$prototype/prebuilt/$configuration" + if [[ ! -d "$prebuilt" ]]; then + echo "Error: Renios prebuilt directory is missing: $prebuilt" >&2 + return 1 + fi + printf '%s\n' "$prebuilt" +} + +renios_archive_is_excluded() { + case "$(basename "$1")" in + # SDL2main owns the UIKit/SDL application entrypoint in the official + # prototype. Godot's application must supply the only entrypoint. + libSDL2main.a|libSDL2_test.a|libSDL3main.a|libSDL3_test.a|libmockrt.a) return 0 ;; + *) return 1 ;; + esac +} + +collect_renios_archives() { + local prebuilt="$1" + local archive + [[ -d "$prebuilt" ]] || return 1 + # Keep the runtime roots first for deterministic archive inspection, then + # append every transitive archive shipped by Renios. The latter is the + # complete native dependency closure for the selected SDK/configuration. + local root_name + for root_name in librenpython.a librenpy.a libpython3.12.a libSDL2.a libSDL2_image.a; do + archive="$prebuilt/$root_name" + [[ -f "$archive" ]] || { + echo "Error: Renios runtime archive is missing: $archive" >&2 + return 1 + } + renios_archive_is_excluded "$archive" || printf '%s\n' "$archive" + done + while IFS= read -r archive; do + [[ -f "$archive" ]] || continue + renios_archive_is_excluded "$archive" && continue + case "$(basename "$archive")" in + librenpython.a|librenpy.a|libpython3.12.a|libSDL2.a|libSDL2_image.a) ;; + *) printf '%s\n' "$archive" ;; + esac + done < <(find "$prebuilt" -maxdepth 1 -type f -name 'lib*.a' | sort) +} + +renios_archive_names() { + local prebuilt="$1" + collect_renios_archives "$prebuilt" | xargs -n1 basename | paste -sd, - +} + +renios_launcher_probe() { + renios_enabled || return 0 + renios_link_enabled || { + echo "Error: Ren'Py enabled app builds require AETHERKIRI_RENPY_RENIOS_LINK=ON; disabling native linking cannot produce a runnable game." >&2 + return 1 + } + local prebuilt arch=arm64 + [[ "$SIMULATOR" == true ]] && arch="$SIMULATOR_ARCH" + prebuilt="$(renios_prebuilt_root)" + collect_renios_archives "$prebuilt" >/dev/null + [[ -d "$RENPY_MOBILE_ROOT/renios/prototype/Frameworks/MetalANGLE.xcframework" ]] || { + echo "Error: Renios MetalANGLE.xcframework is missing." >&2 + return 1 + } + python3 "$PROJECT_ROOT/tools/validate_renpy_mobile_payload.py" \ + --platform ios --arch "$arch" --sdk "$IOS_SDK" \ + --library "$prebuilt/librenpython.a" --private-root "$RENPY_RENIOS_BASE" +} + +renios_metalangle_framework() { + local arch=arm64 framework_source + [[ "$SIMULATOR" == true ]] && arch="$SIMULATOR_ARCH" + framework_source="$RENPY_MOBILE_ROOT/renios/prototype/Frameworks/MetalANGLE.xcframework" + python3 - "$framework_source" "$IOS_SDK" "$arch" <<'PY' +import pathlib, plistlib, sys +root=pathlib.Path(sys.argv[1]); simulator=sys.argv[2]=='iphonesimulator'; arch=sys.argv[3] +info=plistlib.loads((root/'Info.plist').read_bytes()) +for library in info['AvailableLibraries']: + if (library.get('SupportedPlatform') == 'ios' and + (library.get('SupportedPlatformVariant') == 'simulator') == simulator and + arch in library['SupportedArchitectures']): + print(root/library['LibraryIdentifier']/library['LibraryPath']) + break +else: + raise SystemExit('MetalANGLE has no matching iOS SDK/architecture slice') +PY +} + +build_renios_runtime_framework() { + renios_enabled || return 0 + local prebuilt arch=arm64 metalangle prototype sdk_path + [[ "$SIMULATOR" == true ]] && arch="$SIMULATOR_ARCH" + prebuilt="$(renios_prebuilt_root)" + metalangle="$(renios_metalangle_framework)" + [[ -f "$metalangle/MetalANGLE" ]] || { echo "Error: Missing MetalANGLE framework slice." >&2; return 1; } + mkdir -p "$RENPY_FRAMEWORK_OUTPUT" + local export_list="$CMAKE_BUILD_DIR/renpy-native/exports.txt" symbol + : > "$export_list" + for symbol in bootstrap bind_window init tick frame input pause resume shutdown text_input_state set_surface_size; do + printf '_renpy_mobile_%s\n' "$symbol" >> "$export_list" + done + local archive_link_args=() archive + while IFS= read -r archive; do + case "$(basename "$archive")" in + librenpython.a) archive_link_args+=(-Xlinker -force_load -Xlinker "$archive") ;; + *) archive_link_args+=("$archive") ;; + esac + done < <(collect_renios_archives "$prebuilt") + local minimum_flag="-miphoneos-version-min=${IOS_MIN_VERSION:-16.0}" + [[ "$SIMULATOR" == true ]] && minimum_flag="-mios-simulator-version-min=${IOS_MIN_VERSION:-16.0}" + prototype="$(renios_prototype_root)" + sdk_path="$(xcrun --sdk "$IOS_SDK" --show-sdk-path)" + local support_objects=() support_name support_source support_object + # Ren'Py's iOS init code discovers these original Renios classes through + # pyobjus. They are app sources upstream, so the prebuilt archives omit + # them. Keep them in the runtime framework without its main.c entrypoint. + for support_name in Log IAPHelper; do + support_source="$prototype/$support_name.m" + support_object="$CMAKE_BUILD_DIR/renpy-native/$support_name.o" + [[ -f "$support_source" ]] || { + echo "Error: Required Renios support source is missing: $support_source" >&2 + return 1 + } + xcrun --sdk "$IOS_SDK" clang -arch "$arch" -isysroot "$sdk_path" \ + "$minimum_flag" -fobjc-arc -fmodules -include UIKit/UIKit.h \ + -c "$support_source" -o "$support_object" + support_objects+=("$support_object") + done + # Keep Ren'Py's Python/SDL/FFmpeg symbols inside a separate two-level + # namespace. They must not replace the host's vcpkg library definitions. + # Keep every adapter/API object, including unreferenced lifecycle exports. + # Its bootstrap calls init_librenpy; the genuine Ren'Py and CPython init + # tables then reference their module initializers. Link all dependencies + # normally: variant JPEG/HarfBuzz archives and librenpy's repeated inittab + # member contain duplicate definitions when force-loaded together. + xcrun --sdk "$IOS_SDK" clang++ -arch "$arch" -isysroot "$sdk_path" "$minimum_flag" -dynamiclib \ + "${support_objects[@]}" \ + "${archive_link_args[@]}" \ + -Wl,-exported_symbols_list,"$export_list" \ + -Wl,-install_name,"@rpath/$RENPY_FRAMEWORK_NAME.framework/$RENPY_FRAMEWORK_NAME" \ + -Wl,-rpath,@loader_path/.. \ + -F"$(dirname "$metalangle")" -framework MetalANGLE \ + -framework Foundation -framework UIKit -framework QuartzCore \ + -framework CoreAudio -framework AudioToolbox -framework AVFoundation \ + -framework CoreVideo -framework CoreMedia -framework VideoToolbox \ + -framework CoreGraphics -framework CoreFoundation -framework GameController \ + -framework CoreMotion -framework Security -framework SystemConfiguration \ + -framework Metal -framework StoreKit -liconv -lz -lbz2 \ + -o "$RENPY_FRAMEWORK_OUTPUT/$RENPY_FRAMEWORK_NAME" + local class_symbols="$CMAKE_BUILD_DIR/renpy-native/support-class-symbols.txt" class_symbol + xcrun nm -U -arch "$arch" "$RENPY_FRAMEWORK_OUTPUT/$RENPY_FRAMEWORK_NAME" > "$class_symbols" + for support_name in Log IAPHelper; do + class_symbol='_OBJC_CLASS_$_'"$support_name" + awk -v symbol="$class_symbol" '$NF == symbol { found=1 } END { exit !found }' "$class_symbols" || { + echo "Error: Ren'Py runtime framework lacks required Objective-C class $support_name." >&2 + return 1 + } + done + python3 - "$RENPY_FRAMEWORK_OUTPUT/Info.plist" "$RENPY_FRAMEWORK_NAME" "$IOS_SDK" <<'PY' +import pathlib, plistlib, sys +plist={'CFBundleIdentifier':'org.aetherkiri.renpy-runtime', 'CFBundleExecutable':sys.argv[2], + 'CFBundleName':sys.argv[2], 'CFBundlePackageType':'FMWK', 'CFBundleVersion':'1', + 'CFBundleShortVersionString':'8.5.3', 'MinimumOSVersion':'16.0', + 'CFBundleSupportedPlatforms':['iPhoneSimulator' if sys.argv[3]=='iphonesimulator' else 'iPhoneOS']} +pathlib.Path(sys.argv[1]).write_bytes(plistlib.dumps(plist)) +PY + codesign --force --sign - "$RENPY_FRAMEWORK_OUTPUT" + python3 "$PROJECT_ROOT/tools/validate_renpy_mobile_payload.py" \ + --platform ios --arch "$arch" --sdk "$IOS_SDK" --executable \ + --library "$RENPY_FRAMEWORK_OUTPUT/$RENPY_FRAMEWORK_NAME" + xcrun otool -L "$RENPY_FRAMEWORK_OUTPUT/$RENPY_FRAMEWORK_NAME" \ + > "$CMAKE_BUILD_DIR/renpy-native/linked-libraries.txt" +} + +stage_renios_ios_resources() { + local export_root="$1" + if ! renios_enabled; then + return 0 + fi + local prototype + prototype="$(renios_prototype_root 2>/dev/null || true)" + if [[ -z "$prototype" ]]; then + echo "Error: Ren'Py enabled iOS build is missing the rebuilt Renios support root." >&2 + return 1 + fi + + local app_root="$export_root/Aether" + local resource_root="$app_root/renios" + local resource_source="$prototype" + mkdir -p "$resource_root" + # These are the official Renios app resources. Do not copy main.c, + # prototype.xcodeproj, or any source file: this export remains a Godot + # application and has one host-owned lifecycle. + local item + for item in "Launch Screen.storyboard" LaunchImage-background.png LaunchImage-foreground.png Info.plist Media.xcassets; do + if [[ -e "$resource_source/$item" ]]; then + rm -rf "$resource_root/$item" + cp -R "$resource_source/$item" "$resource_root/" + fi + done + if [[ -d "$RENPY_RENIOS_BASE" ]]; then + rm -rf "$resource_root/base" + cp -R "$RENPY_RENIOS_BASE" "$resource_root/base" + elif [[ -d "$resource_source/base" ]]; then + rm -rf "$resource_root/base" + cp -R "$resource_source/base" "$resource_root/base" + fi + + # Embed the selected real framework slice. Only the isolated runtime links + # MetalANGLE; Godot's own GL imports must resolve to Apple's OpenGLES. + local metalangle + metalangle="$(renios_metalangle_framework)" + [[ -f "$metalangle/MetalANGLE" ]] || { echo "Error: Missing MetalANGLE framework slice." >&2; return 1; } + mkdir -p "$app_root/Frameworks" + rm -rf "$app_root/Frameworks/MetalANGLE.xcframework" "$app_root/Frameworks/MetalANGLE.framework" + cp -R "$metalangle" "$app_root/Frameworks/MetalANGLE.framework" + [[ -d "$RENPY_FRAMEWORK_OUTPUT" ]] || { echo "Error: Ren'Py runtime framework was not built." >&2; return 1; } + mkdir -p "$app_root/Frameworks" + rm -rf "$app_root/Frameworks/$RENPY_FRAMEWORK_NAME.framework" + cp -R "$RENPY_FRAMEWORK_OUTPUT" "$app_root/Frameworks/" + + local prebuilt + prebuilt="$(renios_prebuilt_root 2>/dev/null || true)" + if [[ -z "$prebuilt" ]]; then + echo "Error: Renios prebuilt closure is unavailable." >&2 + return 1 + fi + { + printf 'Renios archive: %s\n' "$RENPY_MOBILE_ROOT" + if [[ -f "$RENPY_MOBILE_ROOT/renios/.aetherkiri-sha256" ]]; then + printf 'Renios archive SHA-256: %s\n' "$(cat "$RENPY_MOBILE_ROOT/renios/.aetherkiri-sha256")" + fi + printf 'Configuration: %s\n' "$(renios_configuration)" + printf 'Isolated runtime framework: %s.framework\n' "$RENPY_FRAMEWORK_NAME" + printf 'Framework static inputs: %s\n' "$(renios_archive_names "$prebuilt")" + printf 'Excluded archives: libSDL2main.a,libSDL2_test.a,libSDL3main.a,libSDL3_test.a,libmockrt.a (host entrypoint/test/Linux SDK shim)\n' + if [[ -d "$resource_root/base" ]]; then + printf 'Base resources: bundled\n' + else + printf 'Base resources: none (Renios archive is game-agnostic)\n' + fi + printf 'Host entrypoint: Godot (Renios main.c intentionally omitted)\n' + printf 'Native lifecycle: framework linked and export validated\n' + printf 'Gameplay verification: requires device/simulator execution\n' + } > "$resource_root/renios-manifest.txt" +} + combine_ios_static_extension() { local output="$1" local triplet="$2" + # Validate the lifecycle fork and its Python resources before staging the + # host extension. Never invoke the process-owning launcher_main entrypoint. + renios_launcher_probe local vcpkg_triplet_root="$CMAKE_BUILD_DIR/vcpkg_installed/$triplet" local vcpkg_lib_dir="$vcpkg_triplet_root/lib" + if [[ "$BUILD_TYPE_LOWER" == debug && -d "$vcpkg_triplet_root/debug/lib" ]]; then + vcpkg_lib_dir="$vcpkg_triplet_root/debug/lib" + fi local cubism_package_root="${AETHERKIRI_INTERNAL_DIR:-$PROJECT_ROOT/packages/AetherInternal}" local cubism_core_lib="$cubism_package_root/third_party/cubism/Core/lib/ios/Release-iphoneos/libLive2DCubismCore.a" local godot_cpp_arch="arm64" @@ -340,6 +671,13 @@ combine_ios_static_extension() { if [[ -f "$siglus_runtime_lib" ]]; then libs+=("$siglus_runtime_lib") fi + local renpy_runtime_lib="$CMAKE_BUILD_DIR/bridge/renpy_runtime/libaether_renpy_runtime.a" + if [[ -f "$renpy_runtime_lib" ]]; then + # CMake's private archive dependency is not propagated by Godot's + # static iOS exporter; merge the provider registration object here so + # the guarded adapter boundary is present in the final app. + libs+=("$renpy_runtime_lib") + fi while IFS= read -r siglus_vm_lib; do if [[ -n "$siglus_vm_lib" && -f "$siglus_vm_lib" ]]; then libs+=("$siglus_vm_lib") @@ -420,29 +758,31 @@ stage_force_load_plugin_archives() { if [[ ! -f "$resolved" ]]; then resolved="$PROJECT_ROOT/$source" fi - cp -f "$resolved" "$destination/" 2>/dev/null || true + if [[ ! -f "$resolved" && "$source" == vcpkg_installed/*/lib/libSDL2.a ]]; then + resolved="$CMAKE_BUILD_DIR/vcpkg_installed/$VCPKG_TRIPLET_DIR/debug/lib/libSDL2d.a" + [[ -f "$resolved" ]] || resolved="$CMAKE_BUILD_DIR/vcpkg_installed/$VCPKG_TRIPLET_DIR/debug/lib/libSDL2.a" + fi + if [[ ! -f "$resolved" ]]; then + echo "Error: Required force-loaded iOS archive is missing: $source" >&2 + return 1 + fi + cp -f "$resolved" "$destination/$(basename "$source")" done } verify_exported_simulator_template_arch() { local export_root="$1" local arch="$2" - local libgodot="$export_root/Aether.xcframework/ios-arm64_x86_64-simulator/libgodot.a" - local info - - if [[ ! -f "$libgodot" ]]; then - echo "Error: exported Godot simulator template is missing: $libgodot" >&2 - exit 1 - fi + python3 "$PROJECT_ROOT/tools/build_godot_ios_simulator_template.py" verify \ + --template "$GODOT_EXPORT_TEMPLATE" --arch "$arch" --exported-dir "$export_root" \ + > "$export_root/godot-simulator-template-evidence.json" +} - info="$(lipo -archs "$libgodot" 2>/dev/null || true)" - if [[ " $info " != *" $arch "* ]]; then - echo "Error: Godot iOS simulator export template does not contain '$arch'." >&2 - echo " $libgodot" >&2 - echo " architectures: ${info:-unknown}" >&2 - echo " Install or build a Godot export template with an $arch simulator slice, or use --simulator-arch=x86_64." >&2 - exit 1 - fi +verify_exported_renpy_device_template() { + local export_root="$1" + python3 "$PROJECT_ROOT/tools/build_godot_ios_device_template.py" verify \ + --template "$GODOT_EXPORT_TEMPLATE" --exported-dir "$export_root" \ + > "$export_root/godot-device-template-evidence.json" } stage_ios_runtime_fonts() { @@ -479,6 +819,40 @@ patch_ios_runtime_font_resources() { perl -0pi -e 's@(\t\tD0BCFE3218AEBDA2004A7AAE /\* Resources \*/ = \{\n\t\t\tisa = PBXResourcesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = \(\n)@$1\t\t\t\tA3F001000000000000000001 /* default.otf in Resources */,\n\t\t\t\tA3F001000000000000000003 /* fonts in Resources */,\n@' "$project_file" } +patch_ios_renios_resources() { + local project_file="$1" + local export_root="$2" + local resource_dir="$export_root/Aether/renios" + local framework="$export_root/Aether/Frameworks/MetalANGLE.framework" + [[ -f "$project_file" ]] || return 0 + [[ -d "$resource_dir" ]] || return 0 + + # Add the Renios resources as a folder reference. This keeps the + # original names/relative paths intact while avoiding the prototype's + # application-entrypoint source files. + if ! grep -Fq 'A3F002000000000000000003 /* renios in Resources */' "$project_file"; then + perl -0pi -e 's@(/\* Begin PBXBuildFile section \*/\n)@$1\t\tA3F002000000000000000003 /* renios in Resources */ = {isa = PBXBuildFile; fileRef = A3F002000000000000000004 /* renios */; };\n@' "$project_file" + perl -0pi -e 's@(/\* Begin PBXFileReference section \*/\n)@$1\t\tA3F002000000000000000004 /* renios */ = {isa = PBXFileReference; lastKnownFileType = folder; path = renios; sourceTree = ""; };\n@' "$project_file" + perl -0pi -e 's@(\t\tD0BCFE4118AEBDA2004A7AAE /\* Aether \*/ = \{\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = \(\n)@$1\t\t\t\tA3F002000000000000000004 /* renios */,\n@' "$project_file" + perl -0pi -e 's@(\t\tD0BCFE3218AEBDA2004A7AAE /\* Resources \*/ = \{\n\t\t\tisa = PBXResourcesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = \(\n)@$1\t\t\t\tA3F002000000000000000003 /* renios in Resources */,\n@' "$project_file" + if ! grep -Fq 'A3F002000000000000000004 /* renios */,' "$project_file"; then + # Godot's object IDs are stable in current exports, but retain a + # semantic fallback for a future exporter that renumbers them. + perl -0pi -e 's@(\n\t\t[0-9A-F]+ /\* [^*]+ \*/ = \{\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = \(\n)@$1\t\t\t\tA3F002000000000000000004 /* renios */,\n@'s "$project_file" + fi + if ! grep -Fq 'A3F002000000000000000003 /* renios in Resources */,' "$project_file"; then + perl -0pi -e 's@(\n\t\t[0-9A-F]+ /\* [^*]+ \*/ = \{\n\t\t\tisa = PBXResourcesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = \(\n)@$1\t\t\t\tA3F002000000000000000003 /* renios in Resources */,\n@'s "$project_file" + fi + fi + + # The native framework records its own MetalANGLE load command. Embed the + # selected slice without adding MetalANGLE to the host's link inputs. + python3 "$PROJECT_ROOT/tools/patch_renpy_ios_runtime_project.py" "$project_file" \ + "$export_root/Aether/Frameworks/$RENPY_FRAMEWORK_NAME.framework" \ + --metalangle-framework "$framework" + +} + patch_ios_export_project() { local project_file="$1/project.pbxproj" local export_root @@ -489,12 +863,17 @@ patch_ios_export_project() { local export_build_type="$3" local flags flags='$(LD_CLASSIC_$(XCODE_VERSION_ACTUAL)) -Wl,-U,_aether_kiri_library_init' - flags+=" -Wl,-force_load,Aether/bin/ios/$export_build_type/$IOS_SDK_COMPAT_ARCHIVE" + flags+=" -Wl,-force_load,Aether/bin/$GODOT_TRIPLET_DIR/$IOS_SDK_COMPAT_ARCHIVE" local archive for archive in "${FORCE_LOAD_PLUGIN_ARCHIVES[@]}"; do - flags+=" -Wl,-force_load,Aether/bin/ios/$export_build_type/$archive" + flags+=" -Wl,-force_load,Aether/bin/$GODOT_TRIPLET_DIR/$archive" done - flags+=' -liconv -framework Accelerate -framework AudioToolbox -framework AVFoundation -framework CoreAudio -framework CoreBluetooth -framework CoreHaptics -framework CoreMedia -framework CoreMotion -framework CoreVideo -framework GameController -framework VideoToolbox -framework CoreGraphics -framework QuartzCore -framework Metal -framework MetalKit -framework OpenGLES -framework Security -framework StoreKit -framework SystemConfiguration -framework MobileCoreServices' + flags+=' -liconv -framework Accelerate -framework AudioToolbox -framework AVFoundation -framework CoreAudio -framework CoreBluetooth -framework CoreFoundation -framework CoreHaptics -framework CoreMedia -framework CoreMotion -framework CoreVideo -framework CoreServices -framework GameController -framework ImageIO -framework VideoToolbox -framework CoreGraphics -framework QuartzCore -framework Metal -framework MetalKit -framework OpenGLES -framework Security -framework StoreKit -framework SystemConfiguration -framework MobileCoreServices' + if renios_enabled; then + # The resource staging step places this official dynamic framework in + # Aether/Frameworks and the PBX patch embeds it in the app bundle. + flags+=" -F Aether/Frameworks -Wl,-needed_framework,$RENPY_FRAMEWORK_NAME -Wl,-rpath,@executable_path/Frameworks" + fi if [[ -f "$project_file" ]]; then FLAGS="$flags" perl -0pi -e 's/OTHER_LDFLAGS = "[^"]*";/"OTHER_LDFLAGS = \"" . $ENV{FLAGS} . "\";"/eg' "$project_file" @@ -511,6 +890,7 @@ patch_ios_export_project() { fi stage_ios_runtime_fonts "$export_root" patch_ios_runtime_font_resources "$project_file" + patch_ios_renios_resources "$project_file" "$export_root" fi if [[ -f "$dummy_cpp" ]] && ! grep -Fq '__swift_FORCE_LOAD_$_swift_Builtin_float' "$dummy_cpp"; then cat >> "$dummy_cpp" <<'EOF' @@ -541,6 +921,65 @@ EOF fi } +validate_renios_app() { + renios_enabled || return 0 + local app="$1" arch="$2" sdk="$3" + local framework="$app/Frameworks/$RENPY_FRAMEWORK_NAME.framework/$RENPY_FRAMEWORK_NAME" + [[ -f "$framework" && -f "$app/Frameworks/MetalANGLE.framework/MetalANGLE" ]] || { + echo "Error: The packaged app is missing its executable Ren'Py/MetalANGLE frameworks." >&2 + return 1 + } + python3 "$PROJECT_ROOT/tools/validate_renpy_mobile_payload.py" \ + --platform ios --arch "$arch" --sdk "$sdk" --executable --library "$framework" \ + --private-root "$app/renios/base" + local host_libraries runtime_libraries host_imports runtime_imports + host_libraries="$(xcrun otool -L "$app/Aether")" + runtime_libraries="$(xcrun otool -L "$framework")" + grep -Fq "@rpath/$RENPY_FRAMEWORK_NAME.framework/$RENPY_FRAMEWORK_NAME" <<< "$host_libraries" || { + echo "Error: The final app does not load its isolated Ren'Py framework." >&2 + return 1 + } + if grep -Fq 'MetalANGLE.framework/MetalANGLE' <<< "$host_libraries"; then + echo "Error: The host app directly links MetalANGLE and can bind Godot GL calls to Ren'Py's graphics context." >&2 + return 1 + fi + grep -Fq '/OpenGLES.framework/OpenGLES' <<< "$host_libraries" || { + echo "Error: The host app does not link its Apple OpenGLES backend." >&2 + return 1 + } + grep -Fq 'MetalANGLE.framework/MetalANGLE' <<< "$runtime_libraries" || { + echo "Error: The Ren'Py framework does not load its MetalANGLE backend." >&2 + return 1 + } + if grep -Fq '/OpenGLES.framework/OpenGLES' <<< "$runtime_libraries"; then + echo "Error: The Ren'Py framework also links Apple's incompatible OpenGLES backend." >&2 + return 1 + fi + # Preserve the real linked image's undefined symbol bindings as build + # evidence. The load-command gates above remain valid after stripping. + host_imports="$(xcrun nm -m -u "$app/Aether")" + runtime_imports="$(xcrun nm -m -u "$framework")" + printf '%s\n' "$host_libraries" "$host_imports" > "$CMAKE_BUILD_DIR/renpy-native/host-linkage.txt" + printf '%s\n' "$runtime_libraries" "$runtime_imports" > "$CMAKE_BUILD_DIR/renpy-native/runtime-linkage.txt" + if grep -Eq '_gl[A-Za-z0-9_]+ .*\(from MetalANGLE\)' <<< "$host_imports" || \ + grep -Eq '_(egl|gl)[A-Za-z0-9_]+ .*\(from OpenGLES\)' <<< "$runtime_imports"; then + echo "Error: The final GL/EGL imports bind to the other engine's graphics backend." >&2 + return 1 + fi + echo "==> Actual host GL imports (Apple OpenGLES) and Ren'Py EGL/GL imports (MetalANGLE):" + grep -E '_(egl|gl)[A-Za-z0-9_]+ .*\(from (MetalANGLE|OpenGLES)\)' <<< "$host_imports" || true + grep -E '_(egl|gl)[A-Za-z0-9_]+ .*\(from (MetalANGLE|OpenGLES)\)' <<< "$runtime_imports" || true + # Objective-C class names are process-global, even with two-level native + # namespaces. The offscreen SDL build must exclude UIKit classes and prefix + # its audio listener; otherwise it collides with Godot's host SDL backend. + local runtime_symbols + runtime_symbols="$(xcrun nm "$framework")" + if grep -Eq '_OBJC_CLASS_\$_(SDL_|SDLEAGLContext|SDLInterruptionListener)' <<< "$runtime_symbols"; then + echo "Error: Ren'Py's framework still contains host SDL Objective-C classes." >&2 + return 1 + fi +} + package_ios_unsigned_ipa() { local export_dir="$1" local build_type_lower="$2" @@ -602,6 +1041,7 @@ package_ios_unsigned_ipa() { echo "==> Removing non-runtime symbols from iOS Release executable..." "$PROJECT_ROOT/tools/strip_runtime_symbols.sh" macho-executable "$app_binary" fi + validate_renios_app "$export_dir/build/Aether.app" arm64 iphoneos echo "==> Packaging into unsigned .ipa..." mkdir -p "$export_dir/Payload" @@ -612,18 +1052,55 @@ package_ios_unsigned_ipa() { echo "Unsigned IPA created: $export_dir/Aether-${config_cap}-Unsigned.ipa" } -with_ios_only_gdextension() { +package_ios_simulator_app() { + local export_dir="$1" config_cap=Release + [[ "$BUILD_TYPE_LOWER" == debug ]] && config_cap=Debug + local output_dir="$export_dir/build-simulator-$SIMULATOR_ARCH" + xcodebuild build \ + -project "$export_dir/Aether.xcodeproj" -scheme Aether \ + -configuration "$config_cap" -sdk iphonesimulator \ + -destination 'generic/platform=iOS Simulator' \ + "ARCHS=$SIMULATOR_ARCH" ONLY_ACTIVE_ARCH=YES \ + DEVELOPMENT_TEAM="" \ + CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO \ + "CONFIGURATION_BUILD_DIR=$output_dir" + [[ -s "$output_dir/Aether.app/Aether" ]] || { + echo "Error: Xcode did not produce a runnable iOS simulator app." >&2 + return 1 + } + validate_renios_app "$output_dir/Aether.app" "$SIMULATOR_ARCH" iphonesimulator + while IFS= read -r framework; do codesign --force --sign - "$framework"; done \ + < <(find "$output_dir/Aether.app/Frameworks" -maxdepth 1 -type d -name '*.framework') + codesign --force --sign - "$output_dir/Aether.app" + codesign --verify --deep --strict "$output_dir/Aether.app" + (cd "$output_dir" && zip -qry "Aether-$SIMULATOR_ARCH-Simulator.zip" Aether.app) + echo "Runnable simulator app: $output_dir/Aether.app (device gameplay unverified)" +} + +with_ios_only_gdextension() ( local gdextension_file="$GODOT_APP_DIR/aether_kiri.gdextension" - local backup_file - backup_file="$(mktemp /tmp/aetherkiri-gdextension.XXXXXX)" - - cp "$gdextension_file" "$backup_file" - restore_gdextension() { - trap - RETURN - cp "$backup_file" "$gdextension_file" - rm -f "$backup_file" + local presets_file="$GODOT_APP_DIR/export_presets.cfg" + local project_file="$GODOT_APP_DIR/project.godot" + local backup_dir + backup_dir="$(mktemp -d /tmp/aetherkiri-ios-export.XXXXXX)" + local backup_file="$backup_dir/aether_kiri.gdextension" + + cp -p "$gdextension_file" "$backup_file" + cp -p "$presets_file" "$backup_dir/export_presets.cfg" + if renpy_device_gles_enabled; then + cp -p "$project_file" "$backup_dir/project.godot" + fi + restore_ios_export_inputs() { + cp -p "$backup_file" "$gdextension_file" + cp -p "$backup_dir/export_presets.cfg" "$presets_file" + if [[ -f "$backup_dir/project.godot" ]]; then + cp -p "$backup_dir/project.godot" "$project_file" + fi + rm -rf "$backup_dir" } - trap restore_gdextension RETURN + # This subshell restores all modified inputs on successful export or failure, + # without changing the caller's traps or committing temporary presets. + trap restore_ios_export_inputs EXIT awk ' BEGIN { skip = 0 } @@ -633,11 +1110,38 @@ with_ios_only_gdextension() { !skip || !/^macos\./ { print } ' "$backup_file" | grep -v '^macos\.' > "$gdextension_file" + # Godot reads custom_template from this preset, not GODOT_EXPORT_TEMPLATE. + # Bind the same verified ZIP to the actual export, then restore the preset. + if renpy_device_gles_enabled; then + python3 "$PROJECT_ROOT/tools/build_godot_ios_device_template.py" profile \ + --presets-source "$backup_dir/export_presets.cfg" --presets-destination "$presets_file" \ + --project-source "$backup_dir/project.godot" --project-destination "$project_file" \ + --template "$GODOT_EXPORT_TEMPLATE" + else + python3 "$PROJECT_ROOT/tools/build_godot_ios_simulator_template.py" preset \ + --source "$backup_dir/export_presets.cfg" --destination "$presets_file" \ + --preset "$EXPORT_PRESET" --template "$GODOT_EXPORT_TEMPLATE" --mode "$BUILD_TYPE_LOWER" + fi "$GODOT_BIN" --headless --path "$GODOT_APP_DIR" \ - "$EXPORT_MODE" "$EXPORT_PRESET" "$PROJECT_ROOT/out/godot/ios/$BUILD_TYPE_LOWER/Aether.xcodeproj" -} + "$EXPORT_MODE" "$EXPORT_PRESET" "$IOS_EXPORT_DIR/Aether.xcodeproj" +) -echo "==> Building native engine and Godot extension" +if [[ "$RENPY_FRAMEWORK_ONLY" == true ]]; then + if ! renios_enabled; then + echo "Error: --renpy-framework-only requires AETHERKIRI_ENABLE_RENPY=ON." >&2 + exit 1 + fi + echo "==> Building and validating the real Ren'Py runtime framework only" +else + echo "==> Building native engine and Godot extension" +fi +renios_launcher_probe +build_renios_runtime_framework +if [[ "$RENPY_FRAMEWORK_ONLY" == true ]]; then + echo "Verified Ren'Py native framework: $RENPY_FRAMEWORK_OUTPUT" + echo "Framework-only mode: iOS application build NOT_RUN; gameplay NOT_RUN." + exit 0 +fi cmake_config_args=( -D "CMAKE_MAKE_PROGRAM=$CMAKE_MAKE_PROGRAM" -D "AETHERKIRI_ENABLE_INTERNAL=${AETHERKIRI_ENABLE_INTERNAL:-ON}" @@ -647,6 +1151,9 @@ cmake_config_args=( -D "AETHERKIRI_OBFUSCATOR_PLUGIN=${AETHERKIRI_OBFUSCATOR_PLUGIN:-}" -D "AETHERKIRI_OBFUSCATION_BUILD_ID=${AETHERKIRI_OBFUSCATION_BUILD_ID:-local}" -D "AETHERKIRI_ENABLE_RFVP=${AETHERKIRI_ENABLE_RFVP:-OFF}" + -D "AETHERKIRI_ENABLE_RENPY=${AETHERKIRI_ENABLE_RENPY:-OFF}" + -D "AETHERKIRI_RENPY_MOBILE_LIBRARY=$RENPY_MOBILE_ROOT/renios/prototype/prebuilt/$(renios_configuration)/librenpython.a" + -D "AETHERKIRI_RENPY_PRIVATE_ROOT=$RENPY_RENIOS_BASE" ) if [[ -n "${RFVP_CARGO:-}" ]]; then cmake_config_args+=(-D "RFVP_CARGO=$RFVP_CARGO") @@ -679,44 +1186,51 @@ stage_force_load_plugin_archives "$GODOT_BIN_DIR" if [[ -f "$CMAKE_BUILD_DIR/bridge/godot_extension/libaether_kiri_godot.a" ]]; then combine_ios_static_extension "$GODOT_BIN_DIR/libaether_kiri_godot.a" "$VCPKG_TRIPLET_DIR" fi -if [[ "$SIMULATOR" == true ]]; then - GODOT_EXPORT_BIN_DIR="$GODOT_APP_DIR/bin/ios/$BUILD_TYPE_LOWER" - mkdir -p "$GODOT_EXPORT_BIN_DIR" - cp -f "$CMAKE_BUILD_DIR/abi/libengine_api.a" "$GODOT_EXPORT_BIN_DIR/" 2>/dev/null || true - cp -f "$GODOT_BIN_DIR/libaether_kiri_godot.a" "$GODOT_EXPORT_BIN_DIR/" 2>/dev/null || true - cp -f "$GODOT_BIN_DIR/$IOS_SDK_COMPAT_ARCHIVE" "$GODOT_EXPORT_BIN_DIR/" 2>/dev/null || true - stage_force_load_plugin_archives "$GODOT_EXPORT_BIN_DIR" -fi if [[ ! -x "$GODOT_BIN" ]]; then + if [[ "$PACKAGE_IPA" == true || "$SIMULATOR" == true ]]; then + echo "Error: Godot not found at $GODOT_BIN; cannot package an iOS app." >&2 + exit 1 + fi echo "Warning: Godot not found at $GODOT_BIN; native libraries were staged only." >&2 elif [[ ! -f "$GODOT_EXPORT_TEMPLATE" ]]; then + if [[ "$PACKAGE_IPA" == true || "$SIMULATOR" == true ]]; then + echo "Error: Godot iOS export template missing at $GODOT_EXPORT_TEMPLATE; cannot package an iOS app." >&2 + exit 1 + fi echo "Warning: Godot iOS export template missing at $GODOT_EXPORT_TEMPLATE; native libraries were staged only." >&2 else echo "==> Exporting Godot iOS project" - mkdir -p "$PROJECT_ROOT/out/godot/ios/$BUILD_TYPE_LOWER" + mkdir -p "$IOS_EXPORT_DIR" EXPORT_PRESET="iOS Debug" EXPORT_MODE="--export-debug" if [[ "$BUILD_TYPE_LOWER" == "release" ]]; then EXPORT_PRESET="iOS Release" EXPORT_MODE="--export-release" fi + if [[ "$SIMULATOR" == true ]]; then + EXPORT_PRESET="iOS Simulator Debug" + [[ "$SIMULATOR_ARCH" == x86_64 ]] && EXPORT_PRESET="iOS Simulator x64 Debug" + fi with_ios_only_gdextension if [[ "$SIMULATOR" == true ]]; then - verify_exported_simulator_template_arch "$PROJECT_ROOT/out/godot/ios/$BUILD_TYPE_LOWER" "$SIMULATOR_ARCH" + verify_exported_simulator_template_arch "$IOS_EXPORT_DIR" "$SIMULATOR_ARCH" + elif renpy_device_gles_enabled; then + verify_exported_renpy_device_template "$IOS_EXPORT_DIR" fi - stage_force_load_plugin_archives "$PROJECT_ROOT/out/godot/ios/$BUILD_TYPE_LOWER/Aether/bin/ios/$BUILD_TYPE_LOWER" - cp -f "$GODOT_BIN_DIR/$IOS_SDK_COMPAT_ARCHIVE" "$PROJECT_ROOT/out/godot/ios/$BUILD_TYPE_LOWER/Aether/bin/ios/$BUILD_TYPE_LOWER/" 2>/dev/null || true + stage_renios_ios_resources "$IOS_EXPORT_DIR" + stage_force_load_plugin_archives "$IOS_EXPORT_DIR/Aether/bin/$GODOT_TRIPLET_DIR" + cp -f "$GODOT_BIN_DIR/$IOS_SDK_COMPAT_ARCHIVE" "$IOS_EXPORT_DIR/Aether/bin/$GODOT_TRIPLET_DIR/" PATCH_ARCH="arm64" if [[ "$SIMULATOR" == true ]]; then PATCH_ARCH="$SIMULATOR_ARCH" fi - patch_ios_export_project "$PROJECT_ROOT/out/godot/ios/$BUILD_TYPE_LOWER/Aether.xcodeproj" "$PATCH_ARCH" "$BUILD_TYPE_LOWER" + patch_ios_export_project "$IOS_EXPORT_DIR/Aether.xcodeproj" "$PATCH_ARCH" "$BUILD_TYPE_LOWER" if [[ "$PACKAGE_IPA" == true && "$SIMULATOR" == false ]]; then - package_ios_unsigned_ipa "$PROJECT_ROOT/out/godot/ios/$BUILD_TYPE_LOWER" "$BUILD_TYPE_LOWER" - elif [[ "$PACKAGE_IPA" == true && "$SIMULATOR" == true ]]; then - echo "[WARN] --package-ipa is ignored when building for simulator." + package_ios_unsigned_ipa "$IOS_EXPORT_DIR" "$BUILD_TYPE_LOWER" + elif [[ "$SIMULATOR" == true ]]; then + package_ios_simulator_app "$IOS_EXPORT_DIR" fi fi -echo "iOS build output: $PROJECT_ROOT/out/godot/ios/$BUILD_TYPE_LOWER" +echo "iOS build output: $IOS_EXPORT_DIR" diff --git a/scripts/build_linux.sh b/scripts/build_linux.sh index 33c512e9f..1d69654a3 100755 --- a/scripts/build_linux.sh +++ b/scripts/build_linux.sh @@ -169,6 +169,29 @@ stage_export_runtime_libraries() { -exec cp -Lf {} "$GODOT_EXPORT_DIR/" \; } +stage_renpy_overlay() { + [[ "${renpy_enabled_normalized:-OFF}" == "ON" ]] || return 0 + local destination="$1" + local source="$PROJECT_ROOT/bridge/renpy_runtime/python/aether_renpy_overlay.py" + [[ -f "$source" ]] || { + echo "Error: Ren'Py overlay source is missing: $source" >&2 + exit 1 + } + cp -f "$source" "$destination/aetherkiri-renpy-overlay.py" +} + +stage_renpy_sdk() { + [[ "${renpy_enabled_normalized:-OFF}" == "ON" ]] || return 0 + local destination="$1/renpy-sdk" + [[ -d "${renpy_sdk_root:-}" ]] || { + echo "Error: Ren'Py SDK root is missing: ${renpy_sdk_root:-}" >&2 + exit 1 + } + rm -rf "$destination" + mkdir -p "$(dirname "$destination")" + cp -a "$renpy_sdk_root" "$destination" +} + stage_release_extension_for_editor_scan() { if [[ "$BUILD_TYPE_LOWER" != "release" ]]; then return @@ -183,17 +206,44 @@ stage_release_extension_for_editor_scan() { -exec cp -Lf {} "$debug_dir/" \; } +renpy_cmake_args=() +renpy_enabled="${AETHERKIRI_ENABLE_RENPY:-OFF}" +renpy_enabled_normalized="$(printf '%s' "$renpy_enabled" | tr '[:lower:]' '[:upper:]')" +case "$renpy_enabled_normalized" in + ON|TRUE|YES|1) + renpy_enabled_normalized="ON" + renpy_sdk_root="${AETHERKIRI_RENPY_SDK_ROOT:-}" + if [[ -z "$renpy_sdk_root" || ! -d "$renpy_sdk_root" ]]; then + echo "Error: AETHERKIRI_ENABLE_RENPY requires AETHERKIRI_RENPY_SDK_ROOT" >&2 + exit 1 + fi + renpy_cmake_args+=( + -D "AETHERKIRI_ENABLE_RENPY=ON" + -D "AETHERKIRI_RENPY_SDK_ROOT=$renpy_sdk_root" + ) + ;; + OFF|FALSE|NO|0|'') + renpy_cmake_args+=( -D "AETHERKIRI_ENABLE_RENPY=OFF" ) + ;; + *) + echo "Error: AETHERKIRI_ENABLE_RENPY must be ON/OFF or true/false" >&2 + exit 1 + ;; +esac + echo "==> Building Linux engine and Godot extension" cmake --preset "$CMAKE_CONFIG_PRESET" \ -D "CMAKE_MAKE_PROGRAM=$CMAKE_MAKE_PROGRAM" \ -D "AETHERKIRI_ENABLE_INTERNAL=${AETHERKIRI_ENABLE_INTERNAL:-ON}" \ -D "AETHERKIRI_ENABLE_SOFTPAL_RUNTIME=${AETHERKIRI_ENABLE_SOFTPAL_RUNTIME:-OFF}" \ - -D "AETHERKIRI_SOFTPAL_DIR=${AETHERKIRI_SOFTPAL_DIR:-$PROJECT_ROOT/packages/AetherSoftPal}" + -D "AETHERKIRI_SOFTPAL_DIR=${AETHERKIRI_SOFTPAL_DIR:-$PROJECT_ROOT/packages/AetherSoftPal}" \ + "${renpy_cmake_args[@]}" cmake --build --preset "$CMAKE_BUILD_PRESET" -- -j"$PARALLEL_JOBS" mkdir -p "$GODOT_BIN_DIR" cp -f "$CMAKE_BUILD_DIR/abi/libengine_api.so" "$GODOT_BIN_DIR/" cp -f "$CMAKE_BUILD_DIR/bridge/godot_extension/libaether_kiri_godot.so" "$GODOT_BIN_DIR/" +stage_renpy_overlay "$GODOT_BIN_DIR" stage_all_vcpkg_runtime_libraries if [[ "$BUILD_TYPE_LOWER" == "release" ]]; then echo "==> Removing non-runtime symbols from staged Linux Release libraries" @@ -227,6 +277,8 @@ echo "==> Exporting Linux Godot application" "$GODOT_EXPORT_MODE" "$GODOT_EXPORT_PRESET" "$GODOT_EXPORT_APP" stage_export_runtime_libraries +stage_renpy_overlay "$GODOT_EXPORT_DIR" +stage_renpy_sdk "$GODOT_EXPORT_DIR" if [[ "$BUILD_TYPE_LOWER" == "release" ]]; then echo "==> Removing non-runtime symbols from exported Linux Release application" strip_linux_runtime_symbols "$GODOT_EXPORT_DIR" diff --git a/scripts/build_macos.sh b/scripts/build_macos.sh index 7712a3b79..b318e96a6 100755 --- a/scripts/build_macos.sh +++ b/scripts/build_macos.sh @@ -136,6 +136,29 @@ strip_macos_runtime_symbols() { "$PROJECT_ROOT/tools/strip_runtime_symbols.sh" macho "$@" } +stage_renpy_overlay() { + [[ "${renpy_enabled_normalized:-OFF}" == "ON" ]] || return 0 + local destination="$1" + local source="$PROJECT_ROOT/bridge/renpy_runtime/python/aether_renpy_overlay.py" + [[ -f "$source" ]] || { + echo "Error: Ren'Py overlay source is missing: $source" >&2 + exit 1 + } + cp -f "$source" "$destination/aetherkiri-renpy-overlay.py" +} + +stage_renpy_sdk() { + [[ "${renpy_enabled_normalized:-OFF}" == "ON" ]] || return 0 + local destination="$1/renpy-sdk" + [[ -d "${renpy_sdk_root:-}" ]] || { + echo "Error: Ren'Py SDK root is missing: ${renpy_sdk_root:-}" >&2 + exit 1 + } + rm -rf "$destination" + mkdir -p "$(dirname "$destination")" + cp -a "$renpy_sdk_root" "$destination" +} + thin_macos_executable_to_arch() { local executable="$1" local architectures @@ -163,6 +186,29 @@ cmake_config_args=( -D "AETHERKIRI_ENABLE_SOFTPAL_RUNTIME=${AETHERKIRI_ENABLE_SOFTPAL_RUNTIME:-OFF}" -D "AETHERKIRI_SOFTPAL_DIR=${AETHERKIRI_SOFTPAL_DIR:-$PROJECT_ROOT/packages/AetherSoftPal}" ) +renpy_enabled="${AETHERKIRI_ENABLE_RENPY:-OFF}" +renpy_enabled_normalized="$(printf '%s' "$renpy_enabled" | tr '[:lower:]' '[:upper:]')" +case "$renpy_enabled_normalized" in + ON|TRUE|YES|1) + renpy_enabled_normalized="ON" + renpy_sdk_root="${AETHERKIRI_RENPY_SDK_ROOT:-}" + if [[ -z "$renpy_sdk_root" || ! -d "$renpy_sdk_root" ]]; then + echo "Error: AETHERKIRI_ENABLE_RENPY requires AETHERKIRI_RENPY_SDK_ROOT" >&2 + exit 1 + fi + cmake_config_args+=( + -D "AETHERKIRI_ENABLE_RENPY=ON" + -D "AETHERKIRI_RENPY_SDK_ROOT=$renpy_sdk_root" + ) + ;; + OFF|FALSE|NO|0|'') + cmake_config_args+=( -D "AETHERKIRI_ENABLE_RENPY=OFF" ) + ;; + *) + echo "Error: AETHERKIRI_ENABLE_RENPY must be ON/OFF or true/false" >&2 + exit 1 + ;; +esac if [[ "${SKIP_VCPKG_INSTALL:-}" == "1" ]]; then if [[ ! -d "$VCPKG_ROOT/installed/$VCPKG_TRIPLET" ]]; then echo "Error: SKIP_VCPKG_INSTALL=1 but prebuilt vcpkg triplet is missing: $VCPKG_ROOT/installed/$VCPKG_TRIPLET" >&2 @@ -183,6 +229,7 @@ cmake --build --preset "$CMAKE_BUILD_PRESET" -- -j"$PARALLEL_JOBS" mkdir -p "$GODOT_BIN_DIR" cp -f "$CMAKE_BUILD_DIR/abi/libengine_api.dylib" "$GODOT_BIN_DIR/" cp -f "$CMAKE_BUILD_DIR/bridge/godot_extension/libaether_kiri_godot.dylib" "$GODOT_BIN_DIR/" +stage_renpy_overlay "$GODOT_BIN_DIR" if [[ "$BUILD_TYPE_LOWER" == "release" ]]; then echo "==> Removing non-runtime symbols from staged macOS Release libraries" strip_macos_runtime_symbols \ @@ -233,6 +280,8 @@ else fi cp -f "$GODOT_BIN_DIR/libengine_api.dylib" "$GODOT_EXPORT_APP/Contents/Frameworks/" cp -f "$GODOT_BIN_DIR/libaether_kiri_godot.dylib" "$GODOT_EXPORT_APP/Contents/Frameworks/" + stage_renpy_overlay "$GODOT_EXPORT_APP/Contents/Frameworks" + stage_renpy_sdk "$GODOT_EXPORT_APP/Contents/Resources" echo "==> Thinning exported macOS executable to $MACOS_ARCH" thin_macos_executable_to_arch "$GODOT_EXPORT_APP/Contents/MacOS/Aether" if [[ "$BUILD_TYPE_LOWER" == "release" ]]; then diff --git a/tests/fixtures/renpy_smoke/.gitignore b/tests/fixtures/renpy_smoke/.gitignore new file mode 100644 index 000000000..e39324253 --- /dev/null +++ b/tests/fixtures/renpy_smoke/.gitignore @@ -0,0 +1,10 @@ +# Generated by the Ren'Py SDK. +*.rpyc +*.rpymc +game/cache/ +game/saves/ +cache/ +saves/ +log.txt +errors.txt +trace.txt diff --git a/tests/fixtures/renpy_smoke/README.md b/tests/fixtures/renpy_smoke/README.md new file mode 100644 index 000000000..ff97cc87e --- /dev/null +++ b/tests/fixtures/renpy_smoke/README.md @@ -0,0 +1,25 @@ +# Ren'Py SDK smoke fixture + +This is a source-only Ren'Py project in the layout produced by the official +Ren'Py SDK launcher. It intentionally has no images, audio, compiled scripts, +or SDK files. The script uses only built-in displayables and exercises startup, +dialogue, menu input, and a clean return from `label start`. + +Run the smoke check with an extracted official Ren'Py SDK: + +```sh +RENPY_SDK=/path/to/renpy-8.x-sdk tools/run_renpy_smoke.sh +``` + +`tools/run_renpy_smoke.sh` copies the fixture to a temporary directory before +asking the SDK to compile and initialize it. SDK-generated files are removed +with that temporary directory, so this fixture remains source-only. + +This automated check covers script compilation and project initialization only; +it does not exercise dialogue, choice input, rendering, audio, or the AetherKiri +provider. For interactive verification, run the fixture with the SDK launcher +and choose Start. Do not interpret this check as AetherKiri runtime support. + +The SDK is not downloaded or bundled by the runner. See the official +[SDK downloads](https://www.renpy.org/latest.html) and +[command-line documentation](https://www.renpy.org/doc/html/cli.html). diff --git a/tests/fixtures/renpy_smoke/game/options.rpy b/tests/fixtures/renpy_smoke/game/options.rpy new file mode 100644 index 000000000..c09b624d4 --- /dev/null +++ b/tests/fixtures/renpy_smoke/game/options.rpy @@ -0,0 +1,17 @@ +# Source-only options for the official Ren'Py SDK smoke fixture. +# Keep generated .rpyc and cache files out of this repository. + +define config.name = "AetherKiri Ren'Py Smoke" +define config.version = "1.0" +define config.window_title = "AetherKiri Ren'Py Smoke" + +define config.screen_width = 640 +define config.screen_height = 360 + +define config.developer = True +define config.performance_test = False +define config.has_sound = False +define config.has_music = False +define config.has_voice = False + +define build.name = "aetherkiri-renpy-smoke" diff --git a/tests/fixtures/renpy_smoke/game/script.rpy b/tests/fixtures/renpy_smoke/game/script.rpy new file mode 100644 index 000000000..e7c9936cb --- /dev/null +++ b/tests/fixtures/renpy_smoke/game/script.rpy @@ -0,0 +1,26 @@ +# Minimal source fixture for the official Ren'Py SDK. +define smoke = Character("Smoke") +default smoke_choice = "" + +label start: + scene black + with None + + "AetherKiri Ren'Py SDK smoke fixture." + $ smoke_choice = renpy.call_screen("aetherkiri_choice") + $ open(renpy.config.gamedir + "/aetherkiri-choice", "w").write(smoke_choice + "\n") + if smoke_choice == "continue": + smoke "Choice input is working." + else: + smoke "The default path is working." + "Ren'Py smoke test complete." + return + +screen aetherkiri_choice: + modal True + key "K_RETURN" action Return("continue") + button: + xysize (640, 360) + background None + action Return("continue") + text "Continue the smoke test" diff --git a/tests/fixtures/renpy_smoke/project.json b/tests/fixtures/renpy_smoke/project.json new file mode 100644 index 000000000..3c6fe776b --- /dev/null +++ b/tests/fixtures/renpy_smoke/project.json @@ -0,0 +1,3 @@ +{ + "display_name": "AetherKiri Ren'Py Smoke" +} diff --git a/tools/apply_renpy_mobile_patch_series.py b/tools/apply_renpy_mobile_patch_series.py new file mode 100644 index 000000000..61afc3ad6 --- /dev/null +++ b/tools/apply_renpy_mobile_patch_series.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Apply or verify a complete patch series against its genuine pinned source. + +Later patches can edit earlier patch contexts. Verify the series' final files +against a fresh pinned archive instead of independently reversing each patch. +Partial/stale series and unrelated tracked source changes are rejected. +""" +import argparse +import hashlib +import os +from pathlib import Path +import re +import subprocess +import tarfile +import tempfile + + +def snapshot(path): + if path.is_symlink(): + return ("symlink", os.readlink(path)) + if not path.exists(): + return None + if not path.is_file(): + return ("unexpected-directory",) + return ("file", hashlib.sha256(path.read_bytes()).hexdigest(), path.stat().st_mode & 0o111) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--checkout", required=True, type=Path) + parser.add_argument("--pin", required=True) + parser.add_argument("patches", nargs="+", type=Path) + args = parser.parse_args() + checkout = args.checkout.resolve() + patches = [path.resolve() for path in args.patches] + actual = subprocess.check_output(["git", "-C", str(checkout), "rev-parse", "HEAD"], text=True).strip() + if actual != args.pin: + raise SystemExit(f"Unsupported source revision: {actual}; expected {args.pin}") + paths = set() + for patch in patches: + for before, after in re.findall(r"^diff --git a/(\S+) b/(\S+)$", patch.read_text(), re.M): + for name in (before, after): + path = Path(name) + if path.is_absolute() or ".." in path.parts: + raise SystemExit("Patch contains a path outside the checkout: " + name) + paths.add(name) + if not paths: + raise SystemExit("Patch series contains no source paths") + changed = set(subprocess.check_output(["git", "-C", str(checkout), "diff", "--name-only", "HEAD"], text=True).splitlines()) + unrelated = changed - paths + if unrelated: + raise SystemExit("Unexpected tracked source drift outside the patch series: " + ", ".join(sorted(unrelated))) + tracked = set(subprocess.check_output(["git", "-C", str(checkout), "ls-files"], text=True).splitlines()) + with tempfile.TemporaryDirectory(prefix="renpy-complete-patch-series-") as temporary: + expected = Path(temporary) + archive = subprocess.Popen(["git", "-C", str(checkout), "archive", args.pin, *sorted(paths & tracked)], stdout=subprocess.PIPE) + assert archive.stdout is not None + with tarfile.open(fileobj=archive.stdout, mode="r|*") as source: + source.extractall(expected, filter="data") + if archive.wait(): + raise SystemExit("Could not archive the genuine pinned patch inputs") + pristine = {name: snapshot(expected / name) for name in paths} + for patch in patches: + subprocess.run(["git", "apply", "--check", str(patch)], cwd=expected, check=True) + subprocess.run(["git", "apply", str(patch)], cwd=expected, check=True) + patched = {name: snapshot(expected / name) for name in paths} + current = {name: snapshot(checkout / name) for name in paths} + if current == patched: + print("Complete pinned patch series already applied and verified: " + str(checkout)) + return + if current != pristine: + drift = sorted(name for name in paths if current[name] != patched[name]) + raise SystemExit("Source drift or partial/stale patch series; use a clean pinned checkout. Differing files: " + ", ".join(drift)) + for patch in patches: + subprocess.run(["git", "apply", "--check", str(patch)], cwd=checkout, check=True) + subprocess.run(["git", "apply", str(patch)], cwd=checkout, check=True) + print("applied: " + patch.name) + if any(snapshot(checkout / name) != patched[name] for name in paths): + raise SystemExit("Source changed while applying the patch series") + print("Complete pinned patch series applied and verified: " + str(checkout)) + + +if __name__ == "__main__": + main() diff --git a/tools/build_godot_ios_device_template.py b/tools/build_godot_ios_device_template.py new file mode 100644 index 000000000..baa1563cd --- /dev/null +++ b/tools/build_godot_ios_device_template.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +"""Build a real Godot 4.7.2 GLES iPhoneOS debug template for Ren'Py. + +Only device/debug engine and camera archives change. No signing identity, +device execution, replacement SDK or license acceptance is performed here. +""" +import argparse +import configparser +import json +import os +from pathlib import Path +import plistlib +import shlex +import shutil +import subprocess +import sys +import tempfile +import zipfile + +import build_godot_ios_simulator_template as common + +FEATURE = "renpy_ios_device_gles" +DEVICE = "ios-arm64" +BUILD_FLAGS = ("platform=ios", "target=template_debug", "arch=arm64", + "ios_simulator=no", "vulkan=no", "metal=no", "opengl3=yes", + "generate_bundle=no", "sdl=no") + + +def member(library): + return f"{library}.ios.debug.xcframework/{DEVICE}/{library}.a" + + +def verify_template(template, exported=None, require_sdl_free=False, nm=None): + result = {} + with zipfile.ZipFile(template) as source, tempfile.TemporaryDirectory(prefix="godot-device-verify-") as work: + common.require(len(source.namelist()) == len(set(source.namelist())), "Duplicate template ZIP members") + for library, suffix in common.LIBRARIES.items(): + framework = f"{library}.ios.debug.xcframework" + metadata = plistlib.loads(source.read(f"{framework}/Info.plist")) + candidates = [entry for entry in metadata["AvailableLibraries"] + if entry.get("LibraryIdentifier") == DEVICE] + common.require(len(candidates) == 1, f"Missing or ambiguous device metadata: {framework}") + info = candidates[0] + common.require(info.get("SupportedPlatform") == "ios" and not info.get("SupportedPlatformVariant") + and info.get("LibraryPath") == f"{library}.a" + and info.get("SupportedArchitectures") == ["arm64"], f"Invalid device metadata: {framework}") + path = Path(work) / (library + ".a") + with source.open(member(library)) as src, path.open("wb") as dest: + shutil.copyfileobj(src, dest) + identities = common.archive_identity(path, expected_platform=2) + common.require(len(identities) == 1 and identities[0]["architecture"] == "arm64", "Device archive must be thin arm64") + if require_sdl_free: + common.reject_sdl_symbols(path, nm=nm) + library_digest = common.digest(path) + if exported: + actual_path = Path(exported) / f"Aether{suffix}.xcframework/{DEVICE}/{library}.a" + if not suffix or actual_path.exists(): + common.require(actual_path.is_file() and common.digest(actual_path) == library_digest, + f"Actual Godot export did not use the verified device template: {actual_path}") + result[library] = {"sha256": library_digest, "slices": identities} + return result + + +def replace_section_value(text, section, key, value): + lines, inside, found, sections = [], False, False, 0 + for line in text.splitlines(keepends=True): + if line.startswith("["): + if inside and not found: + lines.append(key + "=" + value + "\n") + inside = line.strip() == "[" + section + "]" + sections += int(inside) + if inside and line.split("=", 1)[0].strip() == key: + common.require(not found, f"Duplicate {key}") + line, found = key + "=" + value + "\n", True + lines.append(line) + if inside and not found: + lines.append(key + "=" + value + "\n") + common.require(sections == 1, f"Expected exactly one [{section}]") + return "".join(lines) + + +def patch_device_profile(presets_source, presets_destination, project_source, project_destination, template): + # This operation is scoped to a copied Debug device preset. It never + # changes the ordinary device preset, global renderer or Simulator tags. + common.require(Path(presets_source).resolve() != Path(presets_destination).resolve() + and Path(project_source).resolve() != Path(project_destination).resolve(), + "Use separate profile copies or backups; preserve the ordinary source files") + common.patch_preset(presets_source, presets_destination, "iOS Debug", template, "debug") + text = Path(presets_destination).read_text() + config = configparser.ConfigParser(interpolation=None) + config.read_string(text) + sections = [section for section in config.sections() if section.startswith("preset.") + and not section.endswith(".options") and config.get(section, "name", fallback="") == '"iOS Debug"'] + common.require(len(sections) == 1, "Expected the scoped Debug device preset") + features = json.loads(config.get(sections[0], "custom_features", fallback='""')).split(",") + features = [value.strip() for value in features if value.strip()] + common.require("ios_simulator" not in features and "ios_simulator_x64" not in features, + "A device profile must never select Simulator libraries") + if FEATURE not in features: + features.append(FEATURE) + Path(presets_destination).write_text(replace_section_value(text, sections[0], "custom_features", json.dumps(",".join(features)))) + project = Path(project_source).read_text() + Path(project_destination).write_text(replace_section_value(project, "rendering", + "renderer/rendering_method." + FEATURE, '"gl_compatibility"')) + + +def build(args): + common.require(sys.platform == "darwin", "A genuine macOS/Xcode host with its iPhoneOS SDK is required") + import SCons + common.require(tuple(map(int, SCons.__version__.split(".")[:2])) >= (4, 4), "Godot requires SCons >= 4.4") + original, output, root = args.input_template.resolve(strict=True), args.output_template.resolve(), args.output_dir.resolve() + common.require(original != output, "The official input template must remain unchanged") + common.require("OSXCROSS_IOS" not in os.environ, "Use the genuine selected Xcode SDK") + root.mkdir(parents=True, exist_ok=True) + source = root / "godot-source" + common.require(not source.exists(), "Use a fresh output directory for a real device source build") + sdk = common.command(["xcrun", "--sdk", "iphoneos", "--show-sdk-path"]) + compiler = common.command(["xcrun", "--sdk", "iphoneos", "--find", "clang"]) + evidence = {"status": "building", "source_url": common.SOURCE_URL, "source_sha": common.SOURCE_SHA, + "input_template_sha256": common.digest(original), "sdk_path": sdk, + "sdk_version": common.command(["xcrun", "--sdk", "iphoneos", "--show-sdk-version"]), + "compiler": compiler, "compiler_version": common.command([compiler, "--version"]), + "xcode_version": common.command(["xcodebuild", "-version"]), + "renderer_profile": FEATURE, "signing": "not_run", "device_gameplay": "not_run"} + report = root / "evidence.json" + report.write_text(json.dumps(evidence, indent=2) + "\n") + try: + evidence["input_libraries"] = verify_template(original) + common.command(["git", "init", "--quiet", source]) + common.command(["git", "-C", source, "fetch", "--depth=1", common.SOURCE_URL, common.SOURCE_SHA]) + common.command(["git", "-C", source, "checkout", "--quiet", "--detach", "FETCH_HEAD"]) + common.require(common.command(["git", "-C", source, "rev-parse", "HEAD"]) == common.SOURCE_SHA, "Godot source pin mismatch") + toolchain = Path(compiler).resolve(strict=True).parents[2] + common.require((toolchain / "usr/bin/clang++").is_file(), "Selected compiler lacks its real Apple toolchain") + invocation = [sys.executable, "-m", "SCons", *BUILD_FLAGS, + "APPLE_SDK_PATH=" + sdk, "APPLE_TOOLCHAIN_PATH=" + str(toolchain), "-j" + str(args.jobs)] + evidence["build_command"] = invocation + print("Real Godot iPhoneOS build:", shlex.join(invocation), flush=True) + with (root / "source-build.log").open("w") as log: + subprocess.run(invocation, cwd=source, stdout=log, stderr=subprocess.STDOUT, check=True) + replacements, libraries = {}, {} + for library in common.LIBRARIES: + archive = source / f"bin/{library}.ios.template_debug.arm64.a" + identities = common.archive_identity(archive, expected_platform=2) + common.require(len(identities) == 1 and identities[0]["architecture"] == "arm64", f"Wrong source build: {archive}") + replacements[member(library)] = archive + libraries[library] = {"sha256": common.digest(archive), "objects": identities[0]["objects"], "platform": 2} + output.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile(prefix="ios-device-template-", suffix=".zip", dir=output.parent, delete=False) as temporary: + staged = Path(temporary.name) + try: + with zipfile.ZipFile(original) as template: + common.replace_template_archives(template, staged, replacements, + allowed_members={member(library) for library in common.LIBRARIES}) + verify_template(staged, require_sdl_free=True) + os.replace(staged, output) + finally: + staged.unlink(missing_ok=True) + evidence.update(status="built_and_verified", output_template_sha256=common.digest(output), libraries=libraries, + untouched_template_members="all_sha256_identical") + except BaseException as error: + evidence.update(status="failed", error=str(error)) + raise + finally: + report.write_text(json.dumps(evidence, indent=2) + "\n") + print(json.dumps(evidence, indent=2)) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="action", required=True) + builder = commands.add_parser("build") + builder.add_argument("--input-template", type=Path, required=True) + builder.add_argument("--output-template", type=Path, required=True) + builder.add_argument("--output-dir", type=Path, required=True) + builder.add_argument("--jobs", type=int, default=3) + checker = commands.add_parser("verify") + checker.add_argument("--template", type=Path, required=True) + checker.add_argument("--exported-dir", type=Path) + checker.add_argument("--nm", type=Path, help="Existing real LLVM nm for thin-archive validation on Linux") + profile = commands.add_parser("profile") + profile.add_argument("--presets-source", type=Path, required=True) + profile.add_argument("--presets-destination", type=Path, required=True) + profile.add_argument("--project-source", type=Path, required=True) + profile.add_argument("--project-destination", type=Path, required=True) + profile.add_argument("--template", type=Path, required=True) + args = parser.parse_args() + try: + if args.action == "build": + common.require(args.jobs > 0, "Build jobs must be positive") + build(args) + elif args.action == "verify": + print(json.dumps(verify_template(args.template, args.exported_dir, require_sdl_free=True, nm=args.nm), indent=2)) + else: + patch_device_profile(args.presets_source, args.presets_destination, args.project_source, + args.project_destination, args.template) + except (OSError, ValueError, KeyError, zipfile.BadZipFile, subprocess.CalledProcessError) as error: + print(f"Godot device template failed: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/build_godot_ios_simulator_template.py b/tools/build_godot_ios_simulator_template.py new file mode 100644 index 000000000..8f0b4aeca --- /dev/null +++ b/tools/build_godot_ios_simulator_template.py @@ -0,0 +1,500 @@ +#!/usr/bin/env python3 +"""Build Godot 4.7.2's real arm64 Simulator engine into a template copy. + +The official device/release libraries and the existing x86_64 Simulator slices +are preserved. This verifies build inputs and archives, not app gameplay. +""" +import argparse +import configparser +import copy +import hashlib +import json +import os +from pathlib import Path +import platform +import plistlib +import shlex +import shutil +import struct +import subprocess +import sys +import tempfile +import zipfile + +SOURCE_URL = "https://github.com/godotengine/godot.git" +SOURCE_SHA = "ed1daf0bf001b61586d9930840f2f1394092c079" +SIMULATOR = "ios-arm64_x86_64-simulator" +ARCHITECTURES = {0x100000C: "arm64", 0x1000007: "x86_64"} +LIBRARIES = {"libgodot": "", "libgodot_camera": "_camera"} +BUILD_FLAGS = ("platform=ios", "target=template_debug", "arch=arm64", + "ios_simulator=yes", "vulkan=no", "metal=no", "opengl3=yes", + "generate_bundle=no", "sdl=no") +CACHE_SCHEMA = 1 +SCONS_VERSION = "4.9.1" + + +def digest(path): + with Path(path).open("rb") as stream: + return stream_digest(stream) + + +def stream_digest(stream): + value = hashlib.sha256() + for block in iter(lambda: stream.read(1024 * 1024), b""): + value.update(block) + return value.hexdigest() + + +def require(condition, message): + if not condition: + raise ValueError(message) + + +def macho_identity(data, expected_platform=7): + require(len(data) >= 32 and data[:4] == bytes.fromhex("cffaedfe"), + "Expected a genuine little-endian Mach-O64 object (LTO bitcode is not accepted)") + cpu, _, kind, count, commands_size = struct.unpack_from("= 8 and offset + size <= limit, "Invalid Mach-O load command size") + if command == 0x32: # LC_BUILD_VERSION + require(size >= 24, "Truncated LC_BUILD_VERSION") + platforms.add(struct.unpack_from("\n", "Expected a genuine static ar archive") + end, count, architectures = offset + length, 0, set() + while stream.tell() < end: + header = stream.read(60) + require(len(header) == 60 and header[58:] == b"`\n", "Invalid static archive member header") + name = header[:16].decode("ascii").rstrip() + size = int(header[48:58]) + require(size >= 0 and stream.tell() + size <= end, "Truncated static archive member") + data = stream.read(size) + if name.startswith("#1/"): + name_size = int(name[3:]) + require(name_size <= size, "Invalid BSD archive member name") + name, data = data[:name_size].rstrip(b"\0").decode("utf-8"), data[name_size:] + if name not in ("/", "//", "/SYM64/") and not name.startswith("__.SYMDEF"): + try: + architectures.add(macho_identity(data, expected_platform)) + except ValueError as error: + raise ValueError(f"{name}: {error}") from error + count += 1 + if size % 2: + require(stream.read(1) == b"\n", "Invalid static archive alignment") + require(stream.tell() == end and count > 0 and len(architectures) == 1, + "Each archive slice must contain nonempty objects of exactly one Apple architecture") + return {"architecture": next(iter(architectures)), "objects": count, "platform": expected_platform} + + +def archive_identity(path, expected_platform=7): + require(expected_platform in (2, 7), "Only actual iPhoneOS/Simulator archives are supported") + path = Path(path) + with path.open("rb") as stream: + magic = stream.read(4) + if magic == b"!", False), + bytes.fromhex("bebafeca"): ("<", False), + bytes.fromhex("cafebabf"): (">", True), + bytes.fromhex("bfbafeca"): ("<", True)} + require(magic in formats, "Expected a thin or universal Mach-O static archive") + endian, fat64 = formats[magic] + count = struct.unpack(endian + "I", stream.read(4))[0] + require(0 < count <= 2, "Unexpected universal archive architecture count") + slices = [] + for _ in range(count): + fields = struct.unpack(endian + ("IIQQII" if fat64 else "IIIII"), + stream.read(32 if fat64 else 20)) + cpu, _, offset, length, alignment = fields[:5] + require(cpu in ARCHITECTURES and length > 0 and alignment < 32 + and offset >= 8 + count * (32 if fat64 else 20) + and offset + length <= path.stat().st_size, "Invalid universal archive slice") + require(offset % (1 << alignment) == 0, "Misaligned universal archive slice") + slices.append((cpu, offset, length)) + ranges = sorted((offset, offset + length) for _, offset, length in slices) + require(all(a[1] <= b[0] for a, b in zip(ranges, ranges[1:])), "Overlapping archive slices") + identities = [] + for cpu, offset, length in slices: + identity = thin_archive(stream, offset, length, expected_platform) + require(identity["architecture"] == ARCHITECTURES[cpu], "CPU header disagrees with archive objects") + identities.append(identity) + require(len({item["architecture"] for item in identities}) == len(identities), "Duplicate CPU slices") + return identities + + +def reject_sdl_symbols(path, arch=None, nm=None): + if nm: + require(arch is None, "Explicit LLVM nm checks use a genuine thin archive") + arguments = [str(nm), "--defined-only", "--extern-only", str(path)] + else: + require(sys.platform == "darwin", "Checking a rebuilt arm64 engine requires real Xcode nm") + arguments = ["xcrun", "nm", "-gU"] + if arch: + arguments.extend(("-arch", arch)) + arguments.append(str(path)) + listing = command(arguments) + forbidden = {"_SDL_Init", "_SDL_GetError", "_SDL_GetGamepads"} + actual = {line.split()[-1] for line in listing.splitlines() if line.split()} + require(not actual & forbidden, f"Godot's SDL3 conflicts with force-loaded host SDL2: {sorted(actual & forbidden)}") + + +def member(library): + return f"{library}.ios.debug.xcframework/{SIMULATOR}/{library}.a" + + +def verify_template(template, arch, exported=None): + result = {} + with zipfile.ZipFile(template) as source, tempfile.TemporaryDirectory(prefix="godot-ios-verify-") as work: + require(len(source.namelist()) == len(set(source.namelist())), "Duplicate template ZIP members") + for library, suffix in LIBRARIES.items(): + framework = f"{library}.ios.debug.xcframework" + metadata = plistlib.loads(source.read(f"{framework}/Info.plist")) + candidates = [entry for entry in metadata["AvailableLibraries"] + if entry.get("LibraryIdentifier") == SIMULATOR] + require(len(candidates) == 1, f"Missing or ambiguous Simulator metadata: {framework}") + info = candidates[0] + require(info.get("SupportedPlatform") == "ios" and info.get("SupportedPlatformVariant") == "simulator" + and info.get("LibraryPath") == f"{library}.a", f"Invalid Simulator metadata: {framework}") + path = Path(work) / (library + ".a") + with source.open(member(library)) as src, path.open("wb") as dest: + shutil.copyfileobj(src, dest) + identities = archive_identity(path) + actual = {entry["architecture"] for entry in identities} + require(arch in actual, f"Godot {framework} lacks actual {arch} Simulator objects: {sorted(actual)}") + # The original x64-only official ZIP incorrectly advertises arm64. + # The repaired arm64 ZIP must have exact, truthful metadata. + if arch == "arm64": + require(actual == set(info["SupportedArchitectures"]) == {"arm64", "x86_64"}, + f"Simulator metadata differs from real CPU slices: {framework}") + reject_sdl_symbols(path, arch="arm64") + library_digest = digest(path) + if exported: + actual_path = Path(exported) / f"Aether{suffix}.xcframework/{SIMULATOR}/{library}.a" + # Godot only exports the optional camera framework when enabled. + if not suffix or actual_path.exists(): + require(actual_path.is_file() and digest(actual_path) == library_digest, + f"Actual Godot export did not use the verified template: {actual_path}") + result[library] = {"sha256": library_digest, "slices": identities} + return result + + +def patch_preset(source, destination, name, template, mode): + text = Path(source).read_text() + config = configparser.ConfigParser(interpolation=None, strict=True) + config.read_string(text) + sections = [section for section in config.sections() if section.startswith("preset.") + and not section.endswith(".options") and config.get(section, "name", fallback="") == json.dumps(name)] + require(len(sections) == 1, f"Expected one export preset named {name}") + section = sections[0] + require(config.get(section, "platform") == '"iOS"', "Custom iOS template requires an iOS export preset") + options, key = section + ".options", "custom_template/" + mode + require(options in config, f"Missing {options}") + value = json.dumps(str(Path(template).resolve(strict=True)), ensure_ascii=False) + lines, inside, found = [], False, False + for line in text.splitlines(keepends=True): + if line.startswith("["): + if inside and not found: + lines.append(key + "=" + value + "\n") + inside = line.strip() == "[" + options + "]" + if inside and line.split("=", 1)[0].strip() == key: + require(not found, f"Duplicate {key}") + line, found = key + "=" + value + "\n", True + lines.append(line) + if inside and not found: + lines.append(key + "=" + value + "\n") + Path(destination).write_text("".join(lines)) + + +def command(arguments, *, cwd=None): + result = subprocess.run(list(map(str, arguments)), cwd=cwd, text=True, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=True) + return result.stdout.strip() + + +def cache_identity(original): + """Identify genuine selected build tools; no environment dump is needed.""" + require(sys.platform == "darwin", "A cache identity requires genuine selected macOS/Xcode tools") + require("OSXCROSS_IOS" not in os.environ, "Use genuine selected Xcode tools, not an osxcross SDK") + import SCons + require(SCons.__version__ == SCONS_VERSION, "The Simulator cache requires SCons==" + SCONS_VERSION) + package = Path(SCons.__file__).resolve(strict=True).parent + package_files = {str(path.relative_to(package)): digest(path) for path in sorted(package.rglob("*.py"))} + require(package_files, "The installed SCons package has no genuine Python sources") + compiler = Path(command(["xcrun", "--sdk", "iphonesimulator", "--find", "clang"])) + sdk = Path(command(["xcrun", "--sdk", "iphonesimulator", "--show-sdk-path"])).resolve(strict=True) + return {"schema": CACHE_SCHEMA, "source_url": SOURCE_URL, "source_sha": SOURCE_SHA, + "build_flags": list(BUILD_FLAGS), "input_template_sha256": digest(original), + "builder_sha256": digest(__file__), "host_architecture": platform.machine(), + "developer_path": str(Path(command(["xcode-select", "-p"])).resolve(strict=True)), + "sdk_path": str(sdk), + "sdk_version": command(["xcrun", "--sdk", "iphonesimulator", "--show-sdk-version"]), + "compiler": str(compiler), "compiler_sha256": digest(compiler), + "compiler_version": command([compiler, "--version"]), + "xcode_version": command(["xcodebuild", "-version"]), + "scons_version": SCons.__version__, + "scons_sources_sha256": hashlib.sha256(json.dumps(package_files, sort_keys=True).encode()).hexdigest()} + + +def cache_key(identity): + value = hashlib.sha256(json.dumps(identity, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + return f"godot-ios-simulator-v{CACHE_SCHEMA}-{value}" + + +def unchanged_member_hashes(original, rebuilt): + """Recheck every original member except the two rebuilt debug archives.""" + replacements = {member(library) for library in LIBRARIES} + result = {} + with zipfile.ZipFile(original) as before, zipfile.ZipFile(rebuilt) as after: + require(len(before.namelist()) == len(set(before.namelist())) + and len(after.namelist()) == len(set(after.namelist())), "Duplicate template ZIP members") + require(before.namelist() == after.namelist(), "Cached template member set/order differs from the official ZIP") + require(before.comment == after.comment, "Cached template ZIP comment differs from the official ZIP") + require(replacements <= set(before.namelist()), "Official template lacks the selected Simulator archives") + for name in before.namelist(): + if name in replacements: + continue + with before.open(name) as source, after.open(name) as cached: + result[name] = stream_digest(source) + require(result[name] == stream_digest(cached), f"Untouched template member changed: {name}") + return result + + +def verify_cache_metadata(evidence, identity, output_digest): + """Reject incomplete/stale evidence before accepting any cached artifact.""" + require(evidence.get("status") == "built_and_verified" + and evidence.get("source_build") == "built_and_verified", "Cache lacks successful genuine source-build evidence") + require(evidence.get("cache_identity") == identity and evidence.get("cache_key") == cache_key(identity), + "Cached source/template/toolchain identity differs from the current inputs") + for name in ("source_url", "source_sha", "input_template_sha256", "sdk_path", "sdk_version", + "compiler", "compiler_version", "xcode_version"): + require(evidence.get(name) == identity[name], "Cached build evidence differs for " + name) + require(evidence.get("output_template_sha256") == output_digest, "Cached template ZIP digest differs from its build evidence") + invocation = evidence.get("build_command", []) + toolchain = Path(identity["compiler"]).resolve(strict=True).parents[2] + expected = ["-m", "SCons", *BUILD_FLAGS, "APPLE_SDK_PATH=" + identity["sdk_path"], + "APPLE_TOOLCHAIN_PATH=" + str(toolchain)] + require(isinstance(invocation, list) and len(invocation) == len(expected) + 2 + and invocation[1:-1] == expected and isinstance(invocation[-1], str) + and invocation[-1].startswith("-j") and invocation[-1][2:].isdigit() + and int(invocation[-1][2:]) > 0, "Cached build command differs from the pinned production build") + + +def verify_cached_libraries(original, template): + """Inspect actual objects and use real lipo to prove thin-slice retention.""" + require(sys.platform == "darwin", "Cache archive verification requires genuine Xcode lipo/nm") + inputs = verify_template(original, "x86_64") + outputs = verify_template(template, "arm64") # Every object/platform plus rebuilt arm64 SDL3 rejection. + proofs = {} + with zipfile.ZipFile(original) as source, zipfile.ZipFile(template) as cached, \ + tempfile.TemporaryDirectory(prefix="godot-ios-cache-archives-") as temporary: + root = Path(temporary) + for library in LIBRARIES: + base, merged = root / (library + "-official.a"), root / (library + "-merged.a") + for archive, destination in ((source, base), (cached, merged)): + with archive.open(member(library)) as src, destination.open("wb") as dest: + shutil.copyfileobj(src, dest) + original_x64, retained_x64, arm = [root / (library + suffix) for suffix in + ("-official-x86_64.a", "-retained-x86_64.a", "-arm64.a")] + if len(inputs[library]["slices"]) == 1: + shutil.copyfile(base, original_x64) + else: + command(["xcrun", "lipo", base, "-thin", "x86_64", "-output", original_x64]) + command(["xcrun", "lipo", merged, "-thin", "x86_64", "-output", retained_x64]) + command(["xcrun", "lipo", merged, "-thin", "arm64", "-output", arm]) + require(digest(original_x64) == digest(retained_x64), "Official x64 archive bytes changed in the cache") + slices = {item["architecture"]: item for item in outputs[library]["slices"]} + proofs[library] = {"arm64_sha256": digest(arm), "official_x86_64_sha256": digest(original_x64), + "merged_sha256": digest(merged), "arm64_objects": slices["arm64"]["objects"]} + return inputs, proofs + + +def verify_cache(args): + original, template = args.input_template.resolve(strict=True), args.template.resolve(strict=True) + evidence_path = args.evidence.resolve(strict=True) + if args.report: + require(args.report.resolve() not in (original, template, evidence_path), "Cache verification must preserve its inputs/evidence") + identity = cache_identity(original) + evidence = json.loads(evidence_path.read_text()) + require(isinstance(evidence, dict), "Cache evidence must be an object") + verify_cache_metadata(evidence, identity, digest(template)) + inputs, libraries = verify_cached_libraries(original, template) + unchanged = unchanged_member_hashes(original, template) + require(evidence.get("input_libraries") == inputs and evidence.get("libraries") == libraries, + "Actual cached archive/slice digests differ from the genuine build evidence") + require(evidence.get("untouched_member_sha256") == unchanged + and evidence.get("untouched_template_members") == "all_sha256_identical", "Cache lacks exact untouched-member evidence") + result = {"status": "cache_payload_verified", "cache_key": cache_key(identity), + "input_template_sha256": identity["input_template_sha256"], "output_template_sha256": digest(template), + "libraries": libraries, "untouched_member_count": len(unchanged), + "source_build": "not_run", "source_build_performed_by_this_check": False, + "app": "not_run", "gameplay": "not_run"} + if args.report: + args.report.parent.mkdir(parents=True, exist_ok=True) + args.report.write_text(json.dumps(result, indent=2) + "\n") + return result + + +def replace_template_archives(template, destination, replacements, allowed_members=None): + require(len(template.namelist()) == len(set(template.namelist())), "Duplicate template ZIP members") + simulator_members = {member(library) for library in LIBRARIES} + device_members = {f"{library}.ios.debug.xcframework/ios-arm64/{library}.a" for library in LIBRARIES} + allowed = simulator_members if allowed_members is None else set(allowed_members) + require(allowed <= simulator_members | device_members and set(replacements) <= allowed, + "Only the explicitly selected debug engine/camera archives may be replaced") + with zipfile.ZipFile(destination, "w") as patched: + patched.comment = template.comment + for info in template.infolist(): + with (replacements[info.filename].open("rb") if info.filename in replacements + else template.open(info)) as src, patched.open(copy.copy(info), "w") as dest: + shutil.copyfileobj(src, dest) + with zipfile.ZipFile(destination) as patched: + require(template.namelist() == patched.namelist(), "Template member set/order changed") + for name in template.namelist(): + if name not in replacements: + with template.open(name) as before, patched.open(name) as after: + require(stream_digest(before) == stream_digest(after), f"Untouched template member changed: {name}") + + +def build(args): + require(sys.platform == "darwin", "A genuine macOS/Xcode host is required; no cross-SDK substitute is accepted") + original, output, root = args.input_template.resolve(strict=True), args.output_template.resolve(), args.output_dir.resolve() + require(original != output, "The official input template must remain unchanged") + require("OSXCROSS_IOS" not in os.environ, "Use genuine selected Xcode tools, not an osxcross SDK") + root.mkdir(parents=True, exist_ok=True) + source = root / "godot-source" + require(not source.exists(), "Use a fresh output directory for a real source build") + identity = cache_identity(original) + sdk, compiler = identity["sdk_path"], identity["compiler"] + evidence = {"status": "building", "source_url": SOURCE_URL, "source_sha": SOURCE_SHA, + "input_template_sha256": digest(original), "sdk_path": sdk, + "sdk_version": identity["sdk_version"], + "compiler": compiler, "compiler_version": identity["compiler_version"], + "xcode_version": identity["xcode_version"], "gameplay": "not_run", + "cache_identity": identity, "cache_key": cache_key(identity), "source_build": "building"} + report = root / "evidence.json" + report.write_text(json.dumps(evidence, indent=2) + "\n") + try: + evidence["input_libraries"] = verify_template(original, "x86_64") + command(["git", "init", "--quiet", source]) + command(["git", "-C", source, "fetch", "--depth=1", SOURCE_URL, SOURCE_SHA]) + command(["git", "-C", source, "checkout", "--quiet", "--detach", "FETCH_HEAD"]) + require(command(["git", "-C", source, "rev-parse", "HEAD"]) == SOURCE_SHA, "Godot source pin mismatch") + toolchain = Path(compiler).resolve(strict=True).parents[2] + require((toolchain / "usr/bin/clang++").is_file(), "Selected compiler lacks its real Apple toolchain") + invocation = [sys.executable, "-m", "SCons", *BUILD_FLAGS, + "APPLE_SDK_PATH=" + sdk, "APPLE_TOOLCHAIN_PATH=" + str(toolchain), "-j" + str(args.jobs)] + evidence["build_command"] = invocation + print("Real Godot Simulator build:", shlex.join(invocation), flush=True) + with (root / "source-build.log").open("w") as log: + subprocess.run(invocation, cwd=source, stdout=log, stderr=subprocess.STDOUT, check=True) + replacements, libraries = {}, {} + with zipfile.ZipFile(original) as template: + for library in LIBRARIES: + arm = source / f"bin/{library}.ios.template_debug.arm64.simulator.a" + identities = archive_identity(arm) + require(len(identities) == 1 and identities[0]["architecture"] == "arm64", f"Wrong source build: {arm}") + base = root / (library + "-official.a") + with template.open(member(library)) as src, base.open("wb") as dest: + shutil.copyfileobj(src, dest) + original_slices = archive_identity(base) + require({entry["architecture"] for entry in original_slices} <= {"arm64", "x86_64"} + and any(entry["architecture"] == "x86_64" for entry in original_slices), "Official x64 slice missing") + x64 = root / (library + "-x86_64.a") + if len(original_slices) == 1: + shutil.copyfile(base, x64) + else: + command(["xcrun", "lipo", base, "-thin", "x86_64", "-output", x64]) + merged = root / (library + "-simulator.a") + command(["xcrun", "lipo", "-create", x64, arm, "-output", merged]) + retained = root / (library + "-retained-x86_64.a") + command(["xcrun", "lipo", merged, "-thin", "x86_64", "-output", retained]) + require(digest(x64) == digest(retained), "Official x64 archive bytes changed") + retained_arm = root / (library + "-retained-arm64.a") + command(["xcrun", "lipo", merged, "-thin", "arm64", "-output", retained_arm]) + require(digest(arm) == digest(retained_arm), "Source-built arm64 archive bytes changed") + require({entry["architecture"] for entry in archive_identity(merged)} == {"arm64", "x86_64"}, "Bad merged archive") + replacements[member(library)] = merged + libraries[library] = {"arm64_sha256": digest(arm), "official_x86_64_sha256": digest(x64), + "merged_sha256": digest(merged), "arm64_objects": identities[0]["objects"]} + output.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile(prefix="ios-template-", suffix=".zip", dir=output.parent, delete=False) as temporary: + staged = Path(temporary.name) + try: + replace_template_archives(template, staged, replacements) + verify_template(staged, "arm64") + os.replace(staged, output) + finally: + staged.unlink(missing_ok=True) + evidence.update(status="built_and_verified", output_template_sha256=digest(output), libraries=libraries, + untouched_template_members="all_sha256_identical", source_build="built_and_verified", + untouched_member_sha256=unchanged_member_hashes(original, output)) + except BaseException as error: + evidence.update(status="failed", source_build="failed", error=str(error)) + raise + finally: + report.write_text(json.dumps(evidence, indent=2) + "\n") + print(json.dumps(evidence, indent=2)) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="action", required=True) + builder = commands.add_parser("build") + builder.add_argument("--input-template", type=Path, required=True) + builder.add_argument("--output-template", type=Path, required=True) + builder.add_argument("--output-dir", type=Path, required=True) + builder.add_argument("--jobs", type=int, default=3) + checker = commands.add_parser("verify") + checker.add_argument("--template", type=Path, required=True) + checker.add_argument("--arch", choices=("arm64", "x86_64"), required=True) + checker.add_argument("--exported-dir", type=Path) + key = commands.add_parser("cache-key", help="Print one exact cache key for genuine current Xcode/SCons inputs") + key.add_argument("--input-template", type=Path, required=True) + cached = commands.add_parser("verify-cache", help="Validate cached ZIP/evidence without running a source build") + cached.add_argument("--input-template", type=Path, required=True) + cached.add_argument("--template", type=Path, required=True) + cached.add_argument("--evidence", type=Path, required=True) + cached.add_argument("--report", type=Path, help="Write a separate verification report; build evidence stays unchanged") + preset = commands.add_parser("preset") + preset.add_argument("--source", type=Path, required=True) + preset.add_argument("--destination", type=Path, required=True) + preset.add_argument("--preset", required=True) + preset.add_argument("--template", type=Path, required=True) + preset.add_argument("--mode", choices=("debug", "release"), required=True) + args = parser.parse_args() + try: + if args.action == "build": + require(args.jobs > 0, "Build jobs must be positive") + build(args) + elif args.action == "verify": + print(json.dumps(verify_template(args.template, args.arch, args.exported_dir), indent=2)) + elif args.action == "cache-key": + print(cache_key(cache_identity(args.input_template.resolve(strict=True)))) + elif args.action == "verify-cache": + print(json.dumps(verify_cache(args), indent=2)) + else: + patch_preset(args.source, args.destination, args.preset, args.template, args.mode) + except (OSError, ValueError, KeyError, zipfile.BadZipFile, struct.error, subprocess.CalledProcessError) as error: + print(f"Godot Simulator template failed: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/godot_ios_device_template/README.md b/tools/godot_ios_device_template/README.md new file mode 100644 index 000000000..374299cfb --- /dev/null +++ b/tools/godot_ios_device_template/README.md @@ -0,0 +1,55 @@ +# Real Ren'Py iPhoneOS debug template + +The official Godot 4.7.2 device/debug archive contains strong SDL3 symbols, +including `SDL_Init` and `SDL_GetError`. This application force-loads its existing +SDL2 host libraries. The dedicated Ren'Py device profile therefore builds a +real Godot engine without the SDL3 input driver; native iOS display, touch and +keyboard handling remain enabled and Ren'Py retains its real SDL2 runtime. + +`build_godot_ios_device_template.py` uses official commit +`ed1daf0bf001b61586d9930840f2f1394092c079`, the selected cloud Xcode iPhoneOS +SDK and its real Apple compiler. Its explicit options are `platform=ios`, +`target=template_debug`, `arch=arm64`, `ios_simulator=no`, `vulkan=no`, +`metal=no`, `opengl3=yes`, `generate_bundle=no`, and `sdl=no`. No alternate SDK, +Vulkan SDK, new license acceptance, signing identity or device access is used. + +The helper checks every real archive object for device platform 2 and arm64, +then replaces only the copied ZIP's two device/debug engine/camera members. +Real native symbol inspection also rejects the host's SDL collision symbols in +the rebuilt archives. The original official template is accepted only as a +source-build input, never as a validated SDL-free device export. +All Simulator, release and metadata members retain their original content +hashes. `evidence.json` records the source pin, actual compiler/SDK, exact +command, archive/template hashes and the actual source-build result. Source +and bundle validation cannot prove installation or phone gameplay. + +The opt-in profile binds the copied `iOS Debug` preset to the exact new template, +adds only `renpy_ios_device_gles` and temporarily adds the corresponding +Compatibility rendering override to `project.godot`. It does not use a +Simulator feature or alter the ordinary device renderer. Export inputs must +be restored on success or failure by the application build script. + +With a fresh output directory on a cloud Mac using existing licensed Xcode: + +```bash +python3 tools/build_godot_ios_device_template.py build \ + --input-template "$GODOT_EXPORT_TEMPLATE" \ + --output-template "$RUNNER_TEMP/godot-renpy-device/ios.zip" \ + --output-dir "$RUNNER_TEMP/godot-renpy-device" --jobs 3 +export GODOT_EXPORT_TEMPLATE="$RUNNER_TEMP/godot-renpy-device/ios.zip" +export AETHERKIRI_RENPY_IOS_DEVICE_GLES=ON +export AETHERKIRI_ENABLE_RENPY=ON +bash scripts/build_ios.sh debug --package-ipa +``` + +The opt-in flag requires a Debug device build with Ren'Py enabled. The script +backs up and restores all three export input files, checks the real exported +device archives against the verified template, then performs actual unsigned +`xcodebuild` packaging. The resulting unsigned IPA still requires authorized +signing before installation; this workflow does not establish phone gameplay. + +`test_godot_ios_device_template.py` inspects the real official device archives, +compiles real Apple-target objects to reject the wrong device/Simulator +platform, checks a full actual ZIP roundtrip, and uses genuine Godot 4.7.2 to +export/load an iOS PCK. The PCK check establishes the custom feature and +selected renderer setting; it does not execute an iOS renderer or Ren'Py. diff --git a/tools/godot_ios_device_template/profile_acceptance.gd b/tools/godot_ios_device_template/profile_acceptance.gd new file mode 100644 index 000000000..bb5e27f22 --- /dev/null +++ b/tools/godot_ios_device_template/profile_acceptance.gd @@ -0,0 +1,15 @@ +extends SceneTree + +func _initialize() -> void: + var expected := OS.get_environment("EXPECT_RENPY_DEVICE_PROFILE") == "1" + var feature := OS.has_feature("renpy_ios_device_gles") + var base := str(ProjectSettings.get_setting("rendering/renderer/rendering_method")) + var selected := str(ProjectSettings.get_setting_with_override("rendering/renderer/rendering_method")) + var valid := feature == expected and base == "mobile" and not OS.has_feature("ios_simulator") + valid = valid and selected == ("gl_compatibility" if expected else "mobile") + print("DEVICE_PROFILE_EVIDENCE=" + JSON.stringify({ + "feature": feature, "base": base, "selected": selected, + "ios_simulator": OS.has_feature("ios_simulator"), "valid": valid, + "scope": "Real Godot project/PCK settings; no iOS graphics or gameplay execution" + })) + quit(0 if valid else 1) diff --git a/tools/godot_ios_simulator_template/README.md b/tools/godot_ios_simulator_template/README.md new file mode 100644 index 000000000..dd98dfc58 --- /dev/null +++ b/tools/godot_ios_simulator_template/README.md @@ -0,0 +1,48 @@ +# Real Godot 4.7.2 arm64 Simulator template + +The official 4.7.2 `ios.zip` advertises arm64 and x86_64 Simulator support in +its plists, but its debug engine and camera Simulator archives contain only +x86_64 objects. `build_godot_ios_simulator_template.py` builds both missing +arm64 archives from official Godot commit +`ed1daf0bf001b61586d9930840f2f1394092c079` using the selected cloud Xcode +iPhoneSimulator SDK. It uses Compatibility rendering, which the application's +Simulator preset also selects. The engine's SDL3 joystick driver is disabled +(`sdl=no`) because this application force-loads its existing SDL2 host libraries; +Godot's native iOS display, touch and keyboard paths remain in the engine, and +Ren'Py retains its real SDL2 runtime. It does not download an alternate SDK or +accept SDK licenses. + +On a cloud Apple Silicon Mac with existing licensed Xcode and Python/SCons: + +```bash +python3 tools/build_godot_ios_simulator_template.py build \ + --input-template "$GODOT_EXPORT_TEMPLATE" \ + --output-template "$RUNNER_TEMP/godot-arm64-simulator/ios.zip" \ + --output-dir "$RUNNER_TEMP/godot-arm64-simulator" --jobs 3 +export GODOT_EXPORT_TEMPLATE="$RUNNER_TEMP/godot-arm64-simulator/ios.zip" +bash scripts/build_ios.sh debug --simulator --simulator-arch=arm64 +``` + +Use a fresh output directory for each source build. Both original x86_64 debug +Simulator slices are preserved byte for byte with real `lipo`; only the two +debug Simulator archive members change in the copied ZIP. Every other member, +including all device/release slices and metadata, retains its original payload +hash. Every archive object must identify the actual Simulator platform 7, and +the corrected universal archives must agree with the plist architecture lists. +`evidence.json` records the fixed source commit, compiler, SDK, exact build +command, template/archive hashes and actual build status. This is source-build +evidence; it does not prove an application launched or a game was playable. + +`scripts/build_ios.sh` temporarily binds the selected export preset's +`custom_template/debug` to this exact template and restores both the preset and +GDExtension configuration on success or failure. It checks every Simulator +archive on entry and verifies the actual exported archives against the same +template hashes. Cached templates therefore undergo the same binary checks. +Continue with the real XCUITest gameplay harness in +[`tools/renpy_ios_acceptance/README.md`](../renpy_ios_acceptance/README.md). + +`test_godot_ios_simulator_template.py --template ORIGINAL_IOS_ZIP` inspects the +real official archives and compiles real LLVM/Xcode objects to check CPU and +platform rejection. Darwin also checks genuine `lipo` universal archives. Linux +can run the thin-object checks with `--clang PATH --ar PATH` using existing +LLVM tools; it does not build Godot, use an Apple SDK or execute iOS software. diff --git a/tools/inspect_renpy_android_apk.py b/tools/inspect_renpy_android_apk.py new file mode 100644 index 000000000..28fb8cc34 --- /dev/null +++ b/tools/inspect_renpy_android_apk.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +"""Record actual APK contents and compare raw Ren'Py assets/libraries with staging. + +This is packaging evidence, never an installation or gameplay acceptance test. +""" + +import argparse +import hashlib +import json +import os +import re +import shutil +import struct +import subprocess +import tempfile +import zipfile +from pathlib import Path + +from prepare_renpy_android_test_export import read_android_preset +from validate_renpy_mobile_payload import LIFECYCLE_SYMBOLS + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open('rb') as stream: + for block in iter(lambda: stream.read(1024 * 1024), b''): + digest.update(block) + return digest.hexdigest() + + +def parse_badging(text: str) -> dict: + package = re.search(r'^package:\s*(.*)$', text, re.MULTILINE) + # Current aapt2 uses minSdkVersion; older SDK tools used sdkVersion. + # Both describe the actual compiled minimum, never a default. + minimums = re.findall(r"^(?:minSdkVersion|sdkVersion):'(\d+)'", text, re.MULTILINE) + native = re.search(r'^native-code:\s*(.*)$', text, re.MULTILINE) + if not package or not minimums or not native: + raise ValueError('aapt badging lacks package, minimum SDK, or native ABI metadata') + if len(set(minimums)) != 1: + raise ValueError('aapt badging reports conflicting minimum SDK versions') + fields = dict(re.findall(r"(\w+)='([^']*)'", package.group(1))) + target = re.search(r"^targetSdkVersion:'(\d+)'", text, re.MULTILINE) + return {'package': fields['name'], 'version_code': int(fields['versionCode']), + 'version_name': fields['versionName'], 'min_sdk': int(minimums[0]), + 'target_sdk': int(target.group(1)) if target else None, + 'debuggable': bool(re.search(r'^application-debuggable\s*$', text, re.MULTILINE)), + 'native_abis': re.findall(r"'([^']+)'", native.group(1))} + + +def sdk_tool(root: Path, pattern: str) -> Path: + tools = [path for path in root.glob(pattern) if path.is_file() and os.access(path, os.X_OK)] + if not tools: + raise ValueError(f'An installed Android SDK tool is required: {root}/{pattern}') + return sorted(tools, key=lambda path: [int(token) for token in re.findall(r'\d+', str(path))])[-1] + + +def command_evidence(command: list[str], path: Path) -> str: + result = subprocess.run(command, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + timeout=60) + path.write_text(result.stdout) + if result.returncode: + raise ValueError(f'SDK command failed ({result.returncode}); see {path}') + return result.stdout + + +def audit_android_manifest(apk: Path, sdk: Path, expected: dict, abi: str, + debuggable: bool, output: Path) -> dict: + try: + tool = sdk_tool(sdk, 'build-tools/*/aapt2') + except ValueError: + tool = sdk_tool(sdk, 'build-tools/*/aapt') + badging = command_evidence([str(tool), 'dump', 'badging', str(apk)], + output / 'apk-android-badging.txt') + # Public package metadata also belongs in the actual build log, so a tool + # format change can be diagnosed without republishing the raw evidence ZIP. + public_fields = [line for line in badging.splitlines() if re.match( + r'^(?:package:|minSdkVersion:|sdkVersion:|targetSdkVersion:|native-code:|application-debuggable\s*$)', line)] + print('Actual SDK badging metadata:', json.dumps(public_fields)) + actual = parse_badging(badging) + expected = dict(expected, debuggable=debuggable, native_abis=[abi]) + errors = [f'Compiled AndroidManifest {key}: expected {value!r}, got {actual.get(key)!r}' + for key, value in expected.items() if actual.get(key) != value] + return {'status': 'failed' if errors else 'passed', 'tool': str(tool), + 'actual': actual, 'expected': expected, 'errors': errors} + + +def audit_native_elf(archive: zipfile.ZipFile, abi: str, ndk: Path, + minimum_sdk: int, output: Path) -> dict: + reader = sdk_tool(ndk, 'toolchains/llvm/prebuilt/*/bin/llvm-readelf') + nm = reader.with_name('llvm-nm') + if not nm.is_file() or not os.access(nm, os.X_OK): + raise ValueError(f'Installed Android NDK llvm-nm is required: {nm}') + triple, machine = {'arm64-v8a': ('aarch64-linux-android', 183), + 'x86_64': ('x86_64-linux-android', 62)}[abi] + platform_libs = reader.parent.parent / 'sysroot/usr/lib' / triple / str(minimum_sdk) + if not platform_libs.is_dir(): + raise ValueError(f'NDK platform stubs are unavailable for SDK {minimum_sdk}: {platform_libs}') + system = {path.name for path in platform_libs.glob('*.so')} + names = [name for name in archive.namelist() if name.startswith(f'lib/{abi}/') and name.endswith('.so')] + packaged = {Path(name).name for name in names} + libraries, errors = {}, [] + with tempfile.TemporaryDirectory(prefix='aether-apk-native-audit-') as temporary: + work = Path(temporary) + for name in sorted(names): + basename = name.removeprefix(f'lib/{abi}/') + if not re.fullmatch(r'lib[A-Za-z0-9_+.-]+\.so', basename) or '..' in basename: + raise ValueError(f'Unsafe APK native path: {name}') + library = work / basename + with archive.open(name) as source, library.open('wb') as destination: + shutil.copyfileobj(source, destination) + with library.open('rb') as stream: + header = stream.read(20) + if len(header) < 20 or header[:6] != b'\x7fELF\x02\x01' or struct.unpack_from(' bool: + output.mkdir(parents=True, exist_ok=True) + result = {'artifact_kind': 'diagnostic_apk', 'gameplay': 'unverified', + 'apk': str(apk), 'abi': abi, 'package_bytes': 'failed', 'files': {}, 'errors': [], + 'android_manifest': {'status': 'not_run'}, 'native_elf': {'status': 'not_run'}} + try: + result['apk_sha256'] = sha256(apk) + with zipfile.ZipFile(apk) as archive: + names = archive.namelist() + (output / 'apk-contents.txt').write_text('\n'.join(sorted(names)) + '\n') + if len(names) != len(set(names)): + result['errors'].append('APK contains duplicate ZIP paths') + if 'assets/renpy_mobile/manifest.properties' in names: + raw_manifest = archive.read('assets/renpy_mobile/manifest.properties').decode('utf-8') + (output / 'apk-renpy-manifest.properties').write_text(raw_manifest) + result['renpy_payload_manifest'] = raw_manifest + expected = {} + assets = build / 'renpy_assets' + for source in assets.rglob('*'): + if source.is_file(): + expected['assets/' + source.relative_to(assets).as_posix()] = source + manifest = assets / 'renpy_mobile/manifest.properties' + registrations = [line.split('=', 1)[1] for line in manifest.read_text().splitlines() + if line.startswith('staged_native_files=')] + if len(registrations) != 1 or not registrations[0]: + raise ValueError('Staging manifest must register its Ren\'Py native files exactly once') + for native_file in registrations[0].split(','): + # Only Ren'Py owns this exact set. Unrelated host libraries may + # legitimately change bytes under AGP's normal stripping policy. + if not re.fullmatch(r'(arm64-v8a|x86_64)/lib[A-Za-z0-9_+.-]+\.so', native_file) or '..' in native_file: + raise ValueError(f'Invalid staged native path: {native_file}') + native_abi, name = native_file.split('/') + if native_abi == abi: + expected[f'lib/{abi}/{name}'] = build / 'src/main/jniLibs' / native_file + for required in ('assets/renpy_mobile/private/main.py', f'lib/{abi}/librenpython.so'): + if required not in expected: + result['errors'].append(f'Staging lacks required input: {required}') + for name, source in sorted(expected.items()): + evidence = {'source_sha256': sha256(source), 'source_size': source.stat().st_size} + if name not in names: + result['errors'].append(f'APK lacks staged raw file: {name}') + evidence['match'] = False + else: + digest = hashlib.sha256() + with archive.open(name) as stream: + for block in iter(lambda: stream.read(1024 * 1024), b''): + digest.update(block) + evidence.update(apk_sha256=digest.hexdigest(), apk_size=archive.getinfo(name).file_size) + evidence['match'] = (evidence['source_sha256'] == evidence['apk_sha256'] and + evidence['source_size'] == evidence['apk_size']) + if not evidence['match']: + result['errors'].append(f'APK file differs from staged input: {name}') + result['files'][name] = evidence + result['package_bytes'] = 'failed' if result['errors'] else 'passed' + if export_presets is not None: + if not export_preset or build_type not in ('debug', 'release') or android_sdk is None or android_ndk is None: + raise ValueError('Compiled manifest audit requires export preset, build type, installed SDK and NDK') + _, _, expected = read_android_preset(export_presets, export_preset) + result['android_manifest'] = {'status': 'failed'} + result['android_manifest'] = audit_android_manifest(apk, android_sdk, expected, abi, + build_type == 'debug', output) + result['errors'].extend(result['android_manifest']['errors']) + result['native_elf'] = {'status': 'failed'} + result['native_elf'] = audit_native_elf(archive, abi, android_ndk, expected['min_sdk'], output) + result['errors'].extend(result['native_elf']['errors']) + except (OSError, zipfile.BadZipFile, RuntimeError, ValueError, KeyError, subprocess.SubprocessError) as exc: + result['errors'].append(str(exc)) + passed = not result['errors'] + (output / 'apk-payload-checksums.json').write_text(json.dumps(result, indent=2) + '\n') + print(f"Actual APK raw Ren'Py bytes: {result['package_bytes']}; gameplay unverified. Evidence: {output}") + if export_presets is not None: + print('Actual compiled AndroidManifest:', json.dumps(result['android_manifest'])) + print('Actual packaged native ELF audit:', result['native_elf']['status']) + native = result['native_elf'].get('libraries', {}).get(f'lib/{abi}/librenpython.so') + if native: + print('Actual librenpython.so SHA256 / DT_NEEDED / exports:', json.dumps(native)) + for error in result['errors'][:10]: + print(error) + return passed + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--apk', required=True, type=Path) + parser.add_argument('--godot-build', required=True, type=Path) + parser.add_argument('--abi', required=True, choices=('arm64-v8a', 'x86_64')) + parser.add_argument('--output-dir', required=True, type=Path) + parser.add_argument('--export-presets', type=Path, + help='require a real SDK compiled-manifest and packaged ELF audit') + parser.add_argument('--export-preset') + parser.add_argument('--build-type', choices=('debug', 'release')) + parser.add_argument('--android-sdk', type=Path) + parser.add_argument('--android-ndk', type=Path) + args = parser.parse_args() + audit_arguments = (args.export_presets, args.export_preset, args.build_type, args.android_sdk, args.android_ndk) + if any(argument is not None for argument in audit_arguments) and not all(argument is not None for argument in audit_arguments): + parser.error('compiled APK audit requires all --export-presets/--export-preset/--build-type/--android-sdk/--android-ndk options') + raise SystemExit(0 if inspect(args.apk, args.godot_build, args.abi, args.output_dir, + export_presets=args.export_presets, export_preset=args.export_preset, + build_type=args.build_type, android_sdk=args.android_sdk, + android_ndk=args.android_ndk) else 1) + + +if __name__ == '__main__': + main() diff --git a/tools/install_renpy_mobile_support.sh b/tools/install_renpy_mobile_support.sh new file mode 100644 index 000000000..6c6f6b593 --- /dev/null +++ b/tools/install_renpy_mobile_support.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +# Fetch and stage official Ren'Py mobile support archives. This prepares +# toolchain inputs only; it does not make the desktop provider mobile-capable. +set -euo pipefail + +readonly RENPY_VERSION="8.5.3" +readonly RENPY_DOWNLOAD_ROOT="https://www.renpy.org/dl/$RENPY_VERSION" +readonly RENPY_RAPT_ARCHIVE="renpy-$RENPY_VERSION-rapt.zip" +readonly RENPY_RAPT_SHA256="8a12be34a2f5238d125ff6dd76a56772fd8e44838af864f81bca82c1059b00e6" +readonly RENPY_RENIOS_ARCHIVE="renpy-$RENPY_VERSION-renios.zip" +readonly RENPY_RENIOS_SHA256="c4fae153e8276ed0faed5e84ea3e0b7c4bf337f0e3208e9130c6a41748a83b2b" + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +destination="${AETHERKIRI_RENPY_MOBILE_ROOT:-$repo_root/.aetherkiri-cache/renpy-mobile/$RENPY_VERSION}" +cache_dir="${AETHERKIRI_RENPY_MOBILE_CACHE_DIR:-$repo_root/.aetherkiri-cache/renpy-mobile/downloads}" +platform="both" +print_config=false + +usage() { + cat <<'USAGE' +Usage: tools/install_renpy_mobile_support.sh [options] + +Download and stage the pinned official Ren'Py 8.5.3 mobile support packages. +The output contains rapt/ and/or renios/. It is not a playable mobile runtime. + +Options: + --platform PLATFORM android, ios, or both (default: both) + --destination PATH support root to create + --cache-dir PATH archive download cache + --print-config print archive metadata and exit + -h, --help show this help +USAGE +} + +while (($#)); do + case "$1" in + --platform) (($# >= 2)) || { echo "--platform requires a value" >&2; exit 2; }; platform="$2"; shift 2 ;; + --destination) (($# >= 2)) || { echo "--destination requires a value" >&2; exit 2; }; destination="$2"; shift 2 ;; + --cache-dir) (($# >= 2)) || { echo "--cache-dir requires a value" >&2; exit 2; }; cache_dir="$2"; shift 2 ;; + --print-config) print_config=true; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;; + esac +done + +case "$platform" in + android|ios|both) ;; + *) echo "platform must be android, ios, or both (got '$platform')" >&2; exit 2 ;; +esac + +if [[ "$print_config" == true ]]; then + printf 'RENPY_VERSION=%s\n' "$RENPY_VERSION" + printf 'RENPY_PLATFORM=%s\n' "$platform" + printf 'RENPY_RAPT_ARCHIVE=%s\n' "$RENPY_RAPT_ARCHIVE" + printf 'RENPY_RAPT_SHA256=%s\n' "$RENPY_RAPT_SHA256" + printf 'RENPY_RAPT_URL=%s/%s\n' "$RENPY_DOWNLOAD_ROOT" "$RENPY_RAPT_ARCHIVE" + printf 'RENPY_RENIOS_ARCHIVE=%s\n' "$RENPY_RENIOS_ARCHIVE" + printf 'RENPY_RENIOS_SHA256=%s\n' "$RENPY_RENIOS_SHA256" + printf 'RENPY_RENIOS_URL=%s/%s\n' "$RENPY_DOWNLOAD_ROOT" "$RENPY_RENIOS_ARCHIVE" + printf 'RENPY_MOBILE_ROOT=%s\n' "$destination" + exit 0 +fi + +for command in curl unzip; do + command -v "$command" >/dev/null 2>&1 || { echo "required command not found: $command" >&2; exit 1; } +done +if ! command -v sha256sum >/dev/null 2>&1 && ! command -v shasum >/dev/null 2>&1; then + echo "required command not found: sha256sum or shasum" >&2 + exit 1 +fi +mkdir -p "$cache_dir" "$(dirname "$destination")" + +verify_archive() { + local archive_path="$1" expected="$2" + [[ -f "$archive_path" ]] || return 1 + if command -v sha256sum >/dev/null 2>&1; then + printf '%s %s\n' "$expected" "$archive_path" | sha256sum --check --status - + else + [[ "$(shasum -a 256 "$archive_path" | awk '{ print $1 }')" == "$expected" ]] + fi +} + +download_archive() { + local archive="$1" expected="$2" archive_path="$cache_dir/$1" + if ! verify_archive "$archive_path" "$expected"; then + rm -f "$archive_path" "$archive_path.part" + echo "Downloading official Ren'Py $RENPY_VERSION support: $archive" >&2 + curl --fail --location --retry 5 --retry-delay 3 --retry-all-errors \ + --output "$archive_path.part" "$RENPY_DOWNLOAD_ROOT/$archive" + mv -f "$archive_path.part" "$archive_path" + verify_archive "$archive_path" "$expected" || { echo "SHA-256 verification failed for $archive_path" >&2; rm -f "$archive_path"; exit 1; } + fi + printf '%s\n' "$archive_path" +} + +safe_zip_members() { + local archive_path="$1" + unzip -Z1 "$archive_path" | while IFS= read -r member; do + case "$member" in + /*|../*|*/../*|*/..|..|*\\*) echo "Refusing archive with unsafe path: $member" >&2; exit 1 ;; + esac + done +} + +stage_archive() { + local archive_path="$1" expected_root="$2" + safe_zip_members "$archive_path" + local temporary + temporary="$(mktemp -d "${TMPDIR:-/tmp}/aetherkiri-renpy-mobile.XXXXXX")" + unzip -q "$archive_path" -d "$temporary" + [[ -d "$temporary/$expected_root" ]] || { echo "Ren'Py archive did not contain expected $expected_root/ directory" >&2; rm -rf "$temporary"; exit 1; } + mkdir -p "$destination" + rm -rf "$destination/$expected_root" + mv "$temporary/$expected_root" "$destination/$expected_root" + rm -rf "$temporary" + case "$expected_root" in + rapt) checksum="$RENPY_RAPT_SHA256" ;; + renios) checksum="$RENPY_RENIOS_SHA256" ;; + esac + printf '%s %s\n' "$checksum" "$(basename "$archive_path")" > "$destination/$expected_root/.aetherkiri-sha256" +} + +case "$platform" in + android) archive="$(download_archive "$RENPY_RAPT_ARCHIVE" "$RENPY_RAPT_SHA256")"; stage_archive "$archive" rapt ;; + ios) archive="$(download_archive "$RENPY_RENIOS_ARCHIVE" "$RENPY_RENIOS_SHA256")"; stage_archive "$archive" renios ;; + both) + archive="$(download_archive "$RENPY_RAPT_ARCHIVE" "$RENPY_RAPT_SHA256")"; stage_archive "$archive" rapt + archive="$(download_archive "$RENPY_RENIOS_ARCHIVE" "$RENPY_RENIOS_SHA256")"; stage_archive "$archive" renios + ;; +esac + +printf 'Ren\x27Py mobile support staged: %s\n' "$destination" +printf 'RENPY_MOBILE_ROOT=%s\n' "$destination" +printf 'RENPY_MOBILE_PLAYABLE=false\n' diff --git a/tools/install_renpy_sdk.sh b/tools/install_renpy_sdk.sh new file mode 100755 index 000000000..397da6ef6 --- /dev/null +++ b/tools/install_renpy_sdk.sh @@ -0,0 +1,228 @@ +#!/usr/bin/env bash +# Download and unpack the pinned official Ren'Py desktop SDK for CI/development. +# The archive is never checked into this repository. Every cached archive is +# verified before extraction, including archives restored by CI cache actions. +set -euo pipefail + +readonly RENPY_VERSION="8.5.3" +readonly RENPY_DOWNLOAD_ROOT="https://www.renpy.org/dl/${RENPY_VERSION}" +readonly RENPY_LINUX_ARCHIVE="renpy-${RENPY_VERSION}-sdk.tar.bz2" +readonly RENPY_LINUX_SHA256="eb0a9be7f0fb13632fe25ceade9a8bed5a1b4d6b6e83bd19eeeb29e1a1bb4a45" +readonly RENPY_MACOS_ARM_ARCHIVE="renpy-${RENPY_VERSION}-sdkarm.tar.bz2" +readonly RENPY_MACOS_ARM_SHA256="0579782517f203ba3535dcc2dab54e34bfc318f2f2a7510a5130b6f809f901f6" +readonly RENPY_WINDOWS_ARCHIVE="renpy-${RENPY_VERSION}-sdk.zip" +readonly RENPY_WINDOWS_SHA256="ff57648f9c04f27e381c48af6d8e3ee3cdec296bed4d3831f47f09b0a71b505e" + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +platform="$(uname -s | tr '[:upper:]' '[:lower:]')" +case "$platform" in + mingw*|msys*|cygwin*) platform="windows" ;; +esac +arch="$(uname -m)" +destination="${AETHERKIRI_RENPY_SDK_ROOT:-${repo_root}/.aetherkiri-cache/renpy-sdk/${RENPY_VERSION}}" +cache_dir="${AETHERKIRI_RENPY_SDK_CACHE_DIR:-${repo_root}/.aetherkiri-cache/renpy-sdk/downloads}" +print_config=false + +usage() { + cat <<'USAGE' +Usage: tools/install_renpy_sdk.sh [options] + +Download and extract the pinned official Ren'Py 8.5.3 desktop SDK. The +archive is SHA-256 verified before it is used. Existing verified SDK roots are +reused without downloading or extracting again. + +Options: + --platform PLATFORM linux, darwin, or windows (default: host platform) + --arch ARCH x86_64 or arm64 (default: host architecture) + --destination PATH SDK extraction directory + --cache-dir PATH Download cache directory + --print-config Print the selected archive metadata and exit + -h, --help Show this help + +Environment: + AETHERKIRI_RENPY_SDK_ROOT Default extraction directory + AETHERKIRI_RENPY_SDK_CACHE_DIR Default download cache directory +USAGE +} + +while (($#)); do + case "$1" in + --platform) + (($# >= 2)) || { echo "--platform requires a value" >&2; exit 2; } + platform="$2"; shift 2 ;; + --arch) + (($# >= 2)) || { echo "--arch requires a value" >&2; exit 2; } + arch="$2"; shift 2 ;; + --destination) + (($# >= 2)) || { echo "--destination requires a value" >&2; exit 2; } + destination="$2"; shift 2 ;; + --cache-dir) + (($# >= 2)) || { echo "--cache-dir requires a value" >&2; exit 2; } + cache_dir="$2"; shift 2 ;; + --print-config) print_config=true; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;; + esac +done + +case "$platform" in + linux) + # Ren'Py's sdk archive contains the x86_64 desktop runtime used by the + # Linux product job. ARM Linux builds are intentionally not enabled. + [[ "$arch" == "x86_64" || "$arch" == "amd64" ]] || { + echo "Ren'Py SDK is only enabled for Linux x86_64 (requested $arch)" >&2 + exit 1 + } + archive="$RENPY_LINUX_ARCHIVE" + sha256="$RENPY_LINUX_SHA256" + ;; + darwin|macos) + [[ "$arch" == "arm64" || "$arch" == "aarch64" || "$arch" == "x86_64" || "$arch" == "amd64" ]] || { + echo "Ren'Py SDK is only enabled for macOS arm64/x86_64 (requested $arch)" >&2 + exit 1 + } + platform="darwin" + if [[ "$arch" == "arm64" || "$arch" == "aarch64" ]]; then + # sdkarm is the official Apple Silicon archive. It includes the + # universal macOS launcher and arm64 support files. + archive="$RENPY_MACOS_ARM_ARCHIVE" + sha256="$RENPY_MACOS_ARM_SHA256" + else + # The regular SDK archive contains the universal macOS launcher + # used by Intel hosts (and the Linux x86_64 runtime). + archive="$RENPY_LINUX_ARCHIVE" + sha256="$RENPY_LINUX_SHA256" + fi + ;; + windows) + [[ "$arch" == "x86_64" || "$arch" == "amd64" ]] || { + echo "Ren'Py SDK is only enabled for Windows x86_64 (requested $arch)" >&2 + exit 1 + } + archive="$RENPY_WINDOWS_ARCHIVE" + sha256="$RENPY_WINDOWS_SHA256" + ;; + *) + echo "Ren'Py SDK is not available for platform '$platform' (desktop Linux/macOS only)" >&2 + exit 1 + ;; +esac + +archive_url="${RENPY_DOWNLOAD_ROOT}/${archive}" +archive_path="${cache_dir}/${archive}" + +if [[ "$print_config" == true ]]; then + printf 'RENPY_VERSION=%s\nRENPY_PLATFORM=%s\nRENPY_ARCH=%s\nRENPY_ARCHIVE=%s\nRENPY_SHA256=%s\nRENPY_URL=%s\nRENPY_SDK_ROOT=%s\n' \ + "$RENPY_VERSION" "$platform" "$arch" "$archive" "$sha256" "$archive_url" "$destination" + exit 0 +fi + +require_command() { + command -v "$1" >/dev/null 2>&1 || { + echo "required command not found: $1" >&2 + exit 1 + } +} +require_command curl +if [[ "$platform" == "windows" ]]; then + if ! command -v unzip >/dev/null 2>&1 && + ! command -v 7z >/dev/null 2>&1; then + echo "required command not found: unzip or 7z" >&2 + exit 1 + fi +else + require_command tar +fi +if ! command -v sha256sum >/dev/null 2>&1 && + ! command -v shasum >/dev/null 2>&1; then + echo "required command not found: sha256sum or shasum" >&2 + exit 1 +fi + +mkdir -p "$cache_dir" "$(dirname "$destination")" +verify_archive() { + [[ -f "$archive_path" ]] || return 1 + if command -v sha256sum >/dev/null 2>&1; then + printf '%s %s\n' "$sha256" "$archive_path" | sha256sum --check --status - + return + fi + local actual + actual="$(shasum -a 256 "$archive_path" | awk '{ print $1 }')" + [[ "$actual" == "$sha256" ]] +} +if ! verify_archive; then + rm -f "$archive_path" "$archive_path.part" + echo "Downloading official Ren'Py ${RENPY_VERSION} SDK (${platform}/${arch})" + curl --fail --location --retry 5 --retry-delay 3 --retry-all-errors \ + --output "$archive_path.part" "$archive_url" + mv -f "$archive_path.part" "$archive_path" + if ! verify_archive; then + echo "SHA-256 verification failed for $archive_path" >&2 + rm -f "$archive_path" + exit 1 + fi +fi + +# A previous interrupted extraction must never be mistaken for a valid SDK. +launcher="$destination/renpy.sh" +if [[ "$platform" == "windows" ]]; then + launcher="$destination/renpy.exe" +fi +if [[ -x "$launcher" ]]; then + printf 'Ren\x27Py SDK already installed: %s\n' "$destination" + printf 'RENPY_SDK_ROOT=%s\n' "$destination" + exit 0 +fi + +extract_root="$(mktemp -d "${TMPDIR:-/tmp}/aetherkiri-renpy-sdk.XXXXXX")" +cleanup() { rm -rf "$extract_root"; } +trap cleanup EXIT +# Ensure the archive cannot write outside the temporary extraction root. The +# official archive has one relative top-level directory; reject malformed +# archives before extraction on every desktop host. +list_archive_members() { + if [[ "$platform" == "windows" ]]; then + if command -v unzip >/dev/null 2>&1; then + unzip -Z1 "$archive_path" + else + 7z l -slt "$archive_path" | sed -n 's/^Path = //p' + fi + else + tar -tjf "$archive_path" + fi +} +if ! list_archive_members | while IFS= read -r member; do + case "$member" in + /*|../*|*/../*|*/..|..|*\\*) + echo "Refusing archive with an unsafe path: $member" >&2 + exit 1 + ;; + esac +done; then + exit 1 +fi +if [[ "$platform" == "windows" ]]; then + if command -v unzip >/dev/null 2>&1; then + unzip -q "$archive_path" -d "$extract_root" + else + 7z x -y "-o$extract_root" "$archive_path" >/dev/null + fi +else + tar -xjf "$archive_path" -C "$extract_root" +fi +extracted="$(find "$extract_root" -mindepth 1 -maxdepth 1 -type d -print -quit)" +launcher_name="renpy.sh" +if [[ "$platform" == "windows" ]]; then + launcher_name="renpy.exe" +fi +if [[ -z "$extracted" || ! -f "$extracted/$launcher_name" ]]; then + echo "Official Ren'Py archive did not contain $launcher_name" >&2 + exit 1 +fi +rm -rf "$destination" +mv "$extracted" "$destination" +if [[ "$platform" != "windows" ]]; then + chmod +x "$destination/renpy.sh" +fi +printf 'Ren\x27Py SDK installed: %s\n' "$destination" +printf 'RENPY_SDK_ROOT=%s\n' "$destination" diff --git a/tools/ios_public_vcpkg_cache.py b/tools/ios_public_vcpkg_cache.py new file mode 100644 index 000000000..e0f21dfcb --- /dev/null +++ b/tools/ios_public_vcpkg_cache.py @@ -0,0 +1,434 @@ +#!/usr/bin/env python3 +"""Validate public vcpkg files-provider ZIPs; never bypass manifest/ABI install. + +Only successful package archives belong in this cache. SDKs, installed trees, +sources, build trees, credentials and compiler logs are outside its boundary. +The Apple cache family fixes the actual tools/profile, while vcpkg recomputes +each port/dependency ABI after restore. Per-port overlay edits therefore miss +only their affected packages. Reports are separate from the cached directory. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import platform +import re +import shutil +import stat +import struct +import subprocess +import sys +import zipfile +import zlib + + +SCHEMA = 1 +HEX = re.compile(r"[0-9a-f]{64}\Z") +PORT = re.compile(r"[a-z0-9][a-z0-9-]*\Z") +TOP_LEVEL = {"include", "lib", "bin", "debug", "share", "tools", "etc"} +# Diagnostic labels only, not a root allowlist. Other root names are hashed, +# so no arbitrary filename is echoed into the Actions console. +ROOT_DIAGNOSTIC_LABELS = { + "manual-tools", "plugins", "libexec", "sbin", "man", "doc", "docs", + "cmake", "var", "Frameworks", "Applications", "sdk", +} +# Existing public Mono dependency, maintained as this repository's overlay; +# it is absent from the current and baseline builtin registries. +PUBLIC_ADDITIONAL_OVERLAYS = {"libgdiplus"} +MAX_METADATA = 1024 * 1024 +MAX_MEMBERS = 250_000 +MAX_ARCHIVE_BYTES = 4 * 1024**3 +MAX_EXPANDED_BYTES = 8 * 1024**3 +MAX_CACHE_BYTES = 32 * 1024**3 +MAX_ARCHIVES = 4096 +MAX_CENTRAL_DIRECTORY_BYTES = 32 * 1024**2 +MAX_CACHE_EXPANDED_BYTES = 32 * 1024**3 +MAX_CACHE_MEMBERS = 1_000_000 + + +class InvalidCache(ValueError): + """Fixed diagnostic codes avoid echoing untrusted archive free text.""" + + def __init__(self, code: str, *, metadata: dict | None = None): + super().__init__(code) + self.metadata = metadata + + +def rejection_message(error: Exception) -> str: + code = str(error) if isinstance(error, InvalidCache) else type(error).__name__ + message = f"Public dependency cache rejected: {code}" + if isinstance(error, InvalidCache) and code == "unexpected-package-root" and error.metadata: + detail = json.dumps(error.metadata, sort_keys=True, separators=(",", ":")) + require(len(detail) <= 512, "diagnostic-too-large") + message += " " + detail + return message + + +def require(condition: bool, code: str) -> None: + if not condition: + raise InvalidCache(code) + + +def digest(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +def canonical(value: object) -> bytes: + return json.dumps(value, sort_keys=True, separators=(",", ":")).encode() + + +def run(argv: list[str], cwd: Path | None = None) -> str: + return subprocess.check_output(argv, cwd=cwd, text=True).strip() + + +def public_ports(repository: Path, vcpkg_root: Path) -> set[str]: + config = json.loads((repository / "vcpkg-configuration.json").read_text()) + registry = config["default-registry"] + require(registry["kind"] == "builtin", "non-public-registry") + require(config.get("registries", []) == [], "additional-registry") + require(config["overlay-ports"] == ["./vcpkg/ports"], "unexpected-port-overlay") + names: set[str] = set() + for rev in (registry["baseline"], "HEAD"): + names.update(run(["git", "ls-tree", "--name-only", f"{rev}:ports"], vcpkg_root).splitlines()) + require(all(PORT.fullmatch(n) for n in names), "invalid-public-port-name") + for p in (repository / "vcpkg/ports").iterdir(): + if p.is_dir(): + require(p.name in names or p.name in PUBLIC_ADDITIONAL_OVERLAYS, "overlay-not-public-port") + manifest = json.loads((p / "vcpkg.json").read_text()) + require(manifest["name"] == p.name, "overlay-name-mismatch") + names.add(p.name) + return names + + +def apple_identity(repository: Path, vcpkg_root: Path, sdk: str, triplet: str, + internal: str) -> dict: + require(internal == "OFF", "internal-profile-enabled") + require(platform.system() == "Darwin", "real-apple-host-required") + require("OSXCROSS_IOS" not in os.environ, "cross-sdk-not-supported") + expected = {"iphonesimulator": "arm64-ios-simulator", "iphoneos": "arm64-ios"} + require(expected.get(sdk) == triplet, "sdk-triplet-mismatch") + arch = platform.machine() + require(arch in ("arm64", "x86_64"), "unsupported-apple-host") + host_triplet = "arm64-osx" if arch == "arm64" else "x64-osx" + public_ports(repository, vcpkg_root) + config = json.loads((repository / "vcpkg-configuration.json").read_text()) + compilers = {} + for name in ("clang", "clang++"): + path = Path(run(["xcrun", "--sdk", sdk, "--find", name])) + compilers[name] = {"path": str(path), "sha256": digest(path), + "version": run([str(path), "--version"])} + cmake = shutil.which("cmake") + require(cmake is not None, "cmake-missing") + sdks = {} + for name in ("macosx", sdk): + sdks[name] = { + "path": str(Path(run(["xcrun", "--sdk", name, "--show-sdk-path"])).resolve()), + "version": run(["xcrun", "--sdk", name, "--show-sdk-version"]), + "build": run(["xcrun", "--sdk", name, "--show-sdk-build-version"]), + } + return { + "schema": SCHEMA, "profile": "public-renpy-on-internal-off-ios-debug", + "host_triplet": host_triplet, "target_triplet": triplet, + "host_os": run(["sw_vers", "-productVersion"]), "host_arch": arch, + "developer": run(["xcode-select", "--print-path"]), + "xcode": run(["xcodebuild", "-version"]), "sdks": sdks, + "compilers": compilers, + "cmake": {"path": cmake, "sha256": digest(Path(cmake)), "version": run([cmake, "--version"])}, + "vcpkg": {"head": run(["git", "rev-parse", "HEAD"], vcpkg_root), + "sha256": digest(vcpkg_root / "vcpkg"), + "version": run([str(vcpkg_root / "vcpkg"), "version"])}, + "baseline": config["default-registry"]["baseline"], + "inputs": {name: digest(repository / name) for name in ( + "vcpkg.json", "vcpkg-configuration.json", "CMakePresets.json", + f"vcpkg/triplets/{triplet}.cmake", "vcpkg/meson/ios-cross.ini")}, + "host_triplet_sha256": digest(next(p for p in ( + vcpkg_root / "triplets" / f"{host_triplet}.cmake", + vcpkg_root / "triplets/community" / f"{host_triplet}.cmake") if p.is_file())), + "validator_sha256": digest(Path(__file__)), + } + + +def family(identity: dict) -> str: + return "ios-public-vcpkg-v1-" + hashlib.sha256(canonical(identity)).hexdigest() + + +def paragraphs(data: bytes) -> list[dict[str, str]]: + require(len(data) <= MAX_METADATA, "metadata-too-large") + try: + text = data.decode("utf-8") + except UnicodeDecodeError as e: + raise InvalidCache("metadata-not-utf8") from e + require("\x00" not in text, "metadata-nul") + result: list[dict[str, str]] = [] + for section in re.split(r"\n\s*\n", text.strip()): + fields: dict[str, str] = {} + key = "" + for line in section.splitlines(): + if line[:1].isspace(): + require(bool(key), "invalid-control-continuation") + fields[key] += "\n" + line + else: + require(":" in line, "invalid-control-field") + key, value = line.split(":", 1) + require(key not in fields and bool(key), "duplicate-control-field") + fields[key] = value.strip() + result.append(fields) + return result + + +def safe_member(name: str) -> str: + require(bool(name) and not any(c in name for c in ("\\", ":", "//")) + and not any(ord(c) < 32 or ord(c) == 127 for c in name), "unsafe-member") + require(not name.startswith("/") and all(p not in ("", ".", "..") for p in name.rstrip("/").split("/")), "unsafe-member") + return name.rstrip("/") + + +def preflight_central_directory(path: Path) -> None: + # Bound zipfile's central-directory allocation before it parses any entry. + size = path.stat().st_size + require(size <= MAX_ARCHIVE_BYTES, "archive-too-large") + with path.open("rb") as f: + f.seek(max(0, size - 65557)) + tail = f.read() + start = tail.rfind(b"PK\x05\x06") + require(start >= 0 and len(tail) - start >= 22, "invalid-zip-end") + _, disk, cd_disk, disk_count, count, cd_size, cd_offset, comment = struct.unpack( + "<4s4H2LH", tail[start:start + 22]) + require(start + 22 + comment == len(tail), "invalid-zip-end") + end_offset = size - len(tail) + start + require(disk == cd_disk == 0 and disk_count == count, "multipart-zip") + sentinel = count == 0xffff or cd_size == 0xffffffff or cd_offset == 0xffffffff + locator = b"" + if end_offset >= 20: + f.seek(end_offset - 20) + locator = f.read(20) + has_zip64 = locator.startswith(b"PK\x06\x07") + require(has_zip64 or not sentinel, "invalid-zip64-end") + # Like zipfile, a present locator overrides ordinary EOCD metadata, + # even when the ordinary fields do not contain ZIP64 sentinel values. + if has_zip64: + sig, disk, zip64_offset, disks = struct.unpack("<4sLQL", locator) + require(sig == b"PK\x06\x07" and disk == 0 and disks == 1, + "invalid-zip64-end") + require(zip64_offset + 56 <= end_offset - 20, "invalid-zip64-end") + f.seek(zip64_offset) + record = f.read(56) + require(len(record) == 56, "invalid-zip64-end") + sig, record_size, _, _, disk, cd_disk, disk_count, count, cd_size, cd_offset = struct.unpack( + "<4sQ2H2L4Q", record) + require(sig == b"PK\x06\x06" and 44 <= record_size <= MAX_METADATA + and zip64_offset + 12 + record_size <= end_offset - 20, + "invalid-zip64-end") + require(disk == cd_disk == 0 and disk_count == count, "multipart-zip") + end_offset = zip64_offset + require(0 < count <= MAX_MEMBERS, "archive-member-count") + require(cd_size <= MAX_CENTRAL_DIRECTORY_BYTES, "central-directory-too-large") + require(cd_offset + cd_size <= end_offset, "invalid-central-directory") + + +def validate_archive(path: Path, ports: set[str], triplets: set[str]) -> dict: + abi = path.stem + require(bool(HEX.fullmatch(abi)) and path.parent.name == abi[:2], "invalid-archive-identity") + preflight_central_directory(path) + with zipfile.ZipFile(path) as z: + infos = z.infolist() + require(0 < len(infos) <= MAX_MEMBERS, "archive-member-count") + require(sum(i.file_size for i in infos) <= MAX_EXPANDED_BYTES, "archive-expanded-size") + members: dict[str, zipfile.ZipInfo] = {} + links: dict[str, str] = {} + unexpected_members = [] + for i in infos: + n = safe_member(i.filename) + require(n not in members, "duplicate-member") + require(not i.flag_bits & 1, "encrypted-member") + mode = stat.S_IFMT(i.external_attr >> 16) + require(mode in (0, stat.S_IFREG, stat.S_IFDIR, stat.S_IFLNK), "special-member") + root = n.split("/", 1)[0] + if n not in ("CONTROL", "BUILD_INFO") and root not in TOP_LEVEL: + unexpected_members.append(n) + members[n] = i + if mode == stat.S_IFLNK: + require(i.file_size <= MAX_METADATA, "symlink-too-large") + try: + target = z.read(i).decode("utf-8") + except UnicodeDecodeError as e: + raise InvalidCache("invalid-symlink") from e + require(bool(target) and not target.startswith("/") and not any(c in target for c in ("\\", ":")) + and not any(ord(c) < 32 or ord(c) == 127 for c in target), "unsafe-symlink") + links[n] = target + for n in members: + for parent in PurePosixPath(n).parents: + require(str(parent) not in links, "member-under-symlink") + def resolve_link(n: str, seen: set[str]) -> str: + require(n not in seen, "symlink-cycle") + if n not in links: + return n + parts = n.split("/")[:-1] + for piece in links[n].split("/"): + if piece in ("", "."): + continue + if piece == "..": + require(bool(parts), "escaping-symlink") + parts.pop() + else: + parts.append(piece) + target = "/".join(parts) + require(target in members, "missing-symlink-target") + return resolve_link(target, seen | {n}) + for n in links: + resolve_link(n, set()) + require("CONTROL" in members and "CONTROL" not in links, "missing-control") + require(members["CONTROL"].file_size <= MAX_METADATA, "metadata-too-large") + control = paragraphs(z.read("CONTROL")) + core = control[0] + name = core.get("Package", "") + triplet = core.get("Architecture", "") + require(name in ports and bool(PORT.fullmatch(name)), "non-public-package") + require(triplet in triplets, "unexpected-package-triplet") + require(core.get("Abi") == abi, "control-abi-mismatch") + require(bool(core.get("Version")) and core.get("Multi-Arch") == "same", "invalid-core-control") + for feature in control[1:]: + require(feature.get("Package") == name and feature.get("Architecture") == triplet and bool(feature.get("Feature")), "invalid-feature-control") + for paragraph in control: + for dependency in paragraph.get("Depends", "").split(","): + if dependency.strip(): + dep = re.split(r"[ :\[]", dependency.strip(), maxsplit=1)[0] + require(dep in ports, "non-public-dependency") + abi_path = f"share/{name}/vcpkg_abi_info.txt" + require(abi_path in members and abi_path not in links, "missing-abi-info") + require(members[abi_path].file_size <= MAX_METADATA, "metadata-too-large") + abi_info = z.read(abi_path) + require(hashlib.sha256(abi_info).hexdigest() == abi, "abi-info-digest-mismatch") + require(f"triplet {triplet}\n".encode() in abi_info.splitlines(keepends=True), "abi-info-triplet-mismatch") + # GLib 2.84.2's non-Cocoa GIO branch installs this helper in libexec. + # The pinned public 2.84.2#2 port retains it and disables installed + # tests. Permit its exact regular-file payload after package/ABI gates; + # this does not allow a general libexec subtree or another package. + helper = "libexec/gio-launch-desktop" + glib_helper = (name == "glib" and core.get("Version") == "2.84.2" + and core.get("Port-Version") == "2" and helper in members + and helper not in links and not members[helper].is_dir() + and stat.S_IFMT(members[helper].external_attr >> 16) in (0, stat.S_IFREG)) + rejected_root = None + for n in unexpected_members: + if glib_helper and (n == helper or (n == "libexec" and members[n].is_dir())): + continue + rejected_root = n.split("/", 1)[0] + break + if rejected_root is not None: + metadata = {"abi": abi, "package": name, "triplet": triplet} + if rejected_root in ROOT_DIAGNOSTIC_LABELS: + metadata["root"] = rejected_root + else: + metadata["root_sha256"] = hashlib.sha256(rejected_root.encode()).hexdigest() + raise InvalidCache("unexpected-package-root", metadata=metadata) + # Read all bytes and check their CRC before vcpkg's unzip consumer sees them. + for i in infos: + with z.open(i) as f: + while f.read(1024 * 1024): + pass + return {"abi": abi, "sha256": digest(path), "package": name, "triplet": triplet} + + +def validate_cache(cache_dir: Path, ports: set[str], triplets: set[str]) -> dict: + require(not cache_dir.is_symlink(), "cache-root-symlink") + cache_dir.mkdir(parents=True, exist_ok=True) + archives = [] + paths = [] + total = 0 + expanded = 0 + member_count = 0 + # Reject total resource budgets before decompressing the first payload. + for bucket in sorted(cache_dir.iterdir()): + require(not bucket.is_symlink() and bucket.is_dir() and bool(re.fullmatch(r"[0-9a-f]{2}", bucket.name)), "invalid-cache-bucket") + for path in sorted(bucket.iterdir()): + require(len(paths) < MAX_ARCHIVES, "cache-archive-count") + require(not path.is_symlink() and path.is_file() and path.suffix == ".zip", "invalid-cache-file") + total += path.stat().st_size + require(total <= MAX_CACHE_BYTES, "cache-too-large") + preflight_central_directory(path) + try: + with zipfile.ZipFile(path) as z: + infos = z.infolist() + archive_expanded = sum(i.file_size for i in infos) + require(0 < len(infos) <= MAX_MEMBERS, "archive-member-count") + require(archive_expanded <= MAX_EXPANDED_BYTES, "archive-expanded-size") + expanded += archive_expanded + member_count += len(infos) + except (zipfile.BadZipFile, zlib.error, RuntimeError, NotImplementedError) as e: + raise InvalidCache("invalid-zip-payload") from e + require(expanded <= MAX_CACHE_EXPANDED_BYTES, "cache-expanded-size") + require(member_count <= MAX_CACHE_MEMBERS, "cache-member-count") + paths.append(path) + for path in paths: + try: + archives.append(validate_archive(path, ports, triplets)) + except (zipfile.BadZipFile, zlib.error, RuntimeError, NotImplementedError) as e: + raise InvalidCache("invalid-zip-payload") from e + return {"schema": SCHEMA, "status": "public_binary_archives_verified", + "archive_count": len(archives), "archives": archives, + "content_sha256": hashlib.sha256(canonical(archives)).hexdigest(), + "source_build": "not_run", "app": "not_run", "gameplay": "not_run"} + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest="command", required=True) + for command in ("family", "validate"): + p = sub.add_parser(command) + p.add_argument("--repository", type=Path, required=True) + p.add_argument("--vcpkg-root", type=Path, required=True) + p.add_argument("--sdk", choices=("iphoneos", "iphonesimulator"), required=True) + p.add_argument("--triplet", choices=("arm64-ios", "arm64-ios-simulator"), required=True) + p.add_argument("--internal", choices=("OFF",), required=True) + if command == "validate": + p.add_argument("--cache-dir", type=Path, required=True) + p.add_argument("--report", type=Path, required=True) + p.add_argument("--previous-report", type=Path) + p.add_argument("--github-output", type=Path) + args = parser.parse_args() + try: + identity = apple_identity(args.repository, args.vcpkg_root, args.sdk, args.triplet, args.internal) + key = family(identity) + if args.command == "family": + print(key) + return 0 + cache = args.cache_dir.resolve() + require(not args.report.resolve().is_relative_to(cache), "report-inside-cache") + report = validate_cache(args.cache_dir, public_ports(args.repository, args.vcpkg_root), + {args.triplet, identity["host_triplet"]}) + report["family"] = key + previous = [] + if args.previous_report: + old = json.loads(args.previous_report.read_text()) + require(old["family"] == key and old["schema"] == SCHEMA, "previous-report-family-mismatch") + previous = old["archives"] + old_pairs = {(a["abi"], a["sha256"]) for a in previous} + require(old_pairs <= {(a["abi"], a["sha256"]) for a in report["archives"]}, "existing-archive-changed") + report["new_archive_count"] = (sum((a["abi"], a["sha256"]) not in old_pairs + for a in report["archives"]) + if args.previous_report else 0) + args.report.parent.mkdir(parents=True, exist_ok=True) + args.report.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n") + summary = {k: report[k] for k in ("status", "archive_count", "new_archive_count", "content_sha256", "source_build", "app", "gameplay")} + print(json.dumps(summary, sort_keys=True)) + if args.github_output: + with args.github_output.open("a") as f: + f.write(f"archive-count={report['archive_count']}\nnew-archive-count={report['new_archive_count']}\n") + return 0 + except (InvalidCache, zipfile.BadZipFile, OSError, KeyError, ValueError, + RuntimeError, NotImplementedError, subprocess.CalledProcessError) as e: + print(rejection_message(e), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/patch_renpy_ios_runtime_project.py b/tools/patch_renpy_ios_runtime_project.py new file mode 100755 index 000000000..8b10d018e --- /dev/null +++ b/tools/patch_renpy_ios_runtime_project.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Link and embed the isolated Ren'Py framework in a real Godot Xcode export.""" + +import argparse +import re +from pathlib import Path + + +def patch(project: Path, framework: Path, metalangle: Path | None = None) -> None: + for required in (framework, metalangle): + if required is not None and not required.is_dir(): + raise ValueError(f"Rebuilt runtime framework is missing: {required}") + text = project.read_text() + if metalangle is not None and '/* MetalANGLE.xcframework */' in text: + # Replace the previous owned XCFramework entries, including its host + # link phase, when reusing an earlier Godot export directory. + text = re.sub(r'^.*A3F0030000000000000000[0-9A-F]{2}.*\n', '', text, flags=re.M) + + def insert(pattern: str, addition: str, description: str) -> None: + nonlocal text + text, count = re.subn(pattern, lambda m: m[0] + addition, text, count=1, flags=re.S) + if count != 1: + raise ValueError(f"Godot Xcode export lacks {description}; refusing an incomplete app") + + def integrate(required: Path, prefix: str, link: bool) -> None: + name = required.name + reference, linked, embedded = (prefix + suffix for suffix in ("04", "03", "05")) + if f"{reference} /* {name} */ =" in text: + return + link_object = (f"\t\t{linked} /* {name} in Frameworks */ = {{isa = PBXBuildFile; fileRef = {reference} /* {name} */; }};\n" + if link else "") + insert(r"/\* Begin PBXBuildFile section \*/\n", link_object + + f"\t\t{embedded} /* {name} in Embed Frameworks */ = {{isa = PBXBuildFile; fileRef = {reference} /* {name} */; settings = {{ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }}; }};\n", + "PBXBuildFile section") + insert(r"/\* Begin PBXFileReference section \*/\n", + f'\t\t{reference} /* {name} */ = {{isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = Frameworks/{name}; sourceTree = ""; }};\n', + "PBXFileReference section") + insert(r"[0-9A-F]+ /\* Aether \*/ = \{\s*isa = PBXGroup;\s*children = \(\n", + f"\t\t\t\t{reference} /* {name} */,\n", "Aether source group") + if link: + insert(r"/\* Begin PBXFrameworksBuildPhase section \*/.*?files = \(\n", + f"\t\t\t\t{linked} /* {name} in Frameworks */,\n", "framework link phase") + insert(r'isa = PBXCopyFilesBuildPhase;(?:(?!\};).)*?dstSubfolderSpec = 10;\s*files = \(\n', + f"\t\t\t\t{embedded} /* {name} in Embed Frameworks */,\n", "framework embed phase") + + integrate(framework, "A3F0040000000000000000", link=True) + if metalangle is not None: + # Only the isolated Ren'Py framework links MetalANGLE. Linking it into + # the host would let Godot's direct GL calls bind to the wrong context. + integrate(metalangle, "A3F0030000000000000000", link=False) + project.write_text(text) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("project", type=Path) + parser.add_argument("framework", type=Path) + parser.add_argument("--metalangle-framework", type=Path) + args = parser.parse_args() + try: + patch(args.project, args.framework, args.metalangle_framework) + except (OSError, ValueError) as exc: + parser.exit(1, f"iOS runtime integration failed: {exc}\n") + + +if __name__ == "__main__": + main() diff --git a/tools/preflight_renpy_android_device.py b/tools/preflight_renpy_android_device.py new file mode 100644 index 000000000..6867ec43d --- /dev/null +++ b/tools/preflight_renpy_android_device.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +"""Boot a real official Android emulator using existing cloud KVM access. + +This checks device availability only. It never installs an APK, executes a +Ren'Py game, changes security permissions, or accepts new SDK licenses. +""" + +from __future__ import annotations + +import argparse +import json +import os +import platform +import subprocess +import tempfile +import time +from pathlib import Path + +from run_renpy_android_device_acceptance import Failed, image_stats, png_pixels + + +class Unavailable(RuntimeError): + pass + + +class Preflight: + def __init__(self, args: argparse.Namespace): + self.args = args + self.output = args.output_dir.resolve() + self.output.mkdir(parents=True, exist_ok=True) + self.environment = dict(os.environ) + self.emulator_process = None + self.emulator_log = None + self.adb = None + self.execution_started = False + self.result = {"status": "not_run", "scope": "actual-cloud-emulator-preflight", + "execution_started": False, + "gameplay_executed": False, "apk_installed": False, + "host": {"system": platform.system(), "machine": platform.machine()}, + "serial": f"emulator-{args.port}", "checks": [], "blocker": ""} + + def command(self, label: str, command: list[str], *, timeout: int = 30, + check: bool = True, input_data: bytes | None = None) -> subprocess.CompletedProcess: + try: + streams = {"input": input_data} if input_data is not None else {"stdin": subprocess.DEVNULL} + completed = subprocess.run(command, **streams, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + timeout=timeout, env=self.environment) + except subprocess.TimeoutExpired as exc: + (self.output / f"{label}.log").write_bytes(exc.stdout or b"") + raise Unavailable(f"{label} timed out; partial real output is preserved: {exc}") from exc + except OSError as exc: + raise Unavailable(f"{label} could not complete: {exc}") from exc + (self.output / f"{label}.log").write_bytes(completed.stdout) + with (self.output / "commands.jsonl").open("a") as stream: + stream.write(json.dumps({"label": label, "command": command, + "returncode": completed.returncode, "time": time.time()}) + "\n") + if check and completed.returncode: + detail = completed.stdout.decode(errors="replace").strip()[-1800:] + raise Unavailable(f"{label} failed with exit {completed.returncode}: {detail}") + return completed + + def device(self, label: str, *parts: str, check: bool = True, timeout: int = 30): + return self.command(label, [str(self.adb), "-s", self.result["serial"], *parts], + check=check, timeout=timeout) + + def run(self) -> None: + if not self.args.cloud_emulator: + raise Unavailable("--cloud-emulator is required; only the selected cloud environment is authorized") + sdk = Path(self.args.sdk_root).resolve() if self.args.sdk_root else None + kvm = Path("/dev/kvm") + self.result["kvm"] = {"exists": kvm.exists(), "readable": os.access(kvm, os.R_OK), + "writable": os.access(kvm, os.W_OK)} + if kvm.exists(): + self.result["kvm"]["mode"] = oct(kvm.stat().st_mode & 0o777) + problems = [] + if platform.system() != "Linux" or platform.machine() != "x86_64": + problems.append("This official x86_64 emulator preflight requires an existing Linux x86_64 cloud runner") + if sdk is None or not sdk.is_dir(): + problems.append("Official Android SDK is unavailable; supply its existing ANDROID_SDK_ROOT/ANDROID_HOME") + emulator = sdk / "emulator/emulator" if sdk else None + sdkmanager = sdk / "cmdline-tools/latest/bin/sdkmanager" if sdk else None + avdmanager = sdk / "cmdline-tools/latest/bin/avdmanager" if sdk else None + for tool in (sdkmanager, avdmanager): + if tool is not None and not tool.is_file(): + problems.append(f"Existing official SDK tool is unavailable: {tool}") + if emulator is not None and emulator.is_file(): + acceleration = self.command("acceleration", [str(emulator), "-accel-check"], check=False) + self.result["acceleration_exit_code"] = acceleration.returncode + if acceleration.returncode: + problems.append("Official emulator reports that existing KVM acceleration is unavailable; see acceleration.log") + if not all(self.result["kvm"][key] for key in ("exists", "readable", "writable")): + problems.append("Existing /dev/kvm read/write access is unavailable; no permissions or security settings were changed") + if problems: + raise Unavailable("; ".join(problems)) + if not emulator.is_file(): + self.command("official-emulator-install", [str(sdkmanager), "--install", "emulator"], timeout=300) + if not emulator.is_file(): + raise Unavailable("Official emulator installation did not complete; check SDK download/license logs") + acceleration = self.command("acceleration", [str(emulator), "-accel-check"], check=False) + self.result["acceleration_exit_code"] = acceleration.returncode + if acceleration.returncode: + raise Unavailable("Official emulator reports existing KVM acceleration is unavailable; see acceleration.log") + self.result["sdk_root"] = str(sdk) + self.command("emulator-version", [str(emulator), "-version"]) + image = "system-images;android-35;google_apis;x86_64" + self.result["system_image"] = image + # sdkmanager uses its normal official repositories and existing license + # files. DEVNULL does not automatically approve a license prompt. + self.command("official-sdk-install", [str(sdkmanager), "--install", "platform-tools", image], timeout=600) + image_dir = sdk / "system-images/android-35/google_apis/x86_64" + properties = image_dir / "source.properties" + if not properties.is_file(): + raise Unavailable("Official system image was not installed; check SDK download/license logs") + (self.output / "system-image-source.properties").write_bytes(properties.read_bytes()) + self.adb = sdk / "platform-tools/adb" + if not self.adb.is_file(): + raise Unavailable("Official Android platform-tools installation did not supply adb") + self.command("adb-version", [str(self.adb), "version"]) + with tempfile.TemporaryDirectory(prefix="renpy-cloud-emulator-preflight-") as temporary: + avd_root = Path(temporary) / "avd" + avd_root.mkdir() + self.environment["ANDROID_AVD_HOME"] = str(avd_root) + avd_name = "aether-renpy-cloud-preflight" + self.command("create-official-avd", [str(avdmanager), "create", "avd", "--name", avd_name, + "--force", "--device", "pixel_2", "--package", image], input_data=b"no\n", timeout=120) + config = avd_root / f"{avd_name}.avd/config.ini" + if not config.is_file(): + raise Unavailable("Official avdmanager did not create a usable virtual device") + with config.open("a") as stream: + stream.write("\nhw.keyboard=no\n") + (self.output / "avd-config.ini").write_bytes(config.read_bytes()) + command = [str(emulator), "-avd", avd_name, "-port", str(self.args.port), "-accel", "on", + "-gpu", "swiftshader", "-no-window", "-no-audio", "-no-boot-anim", "-no-snapshot", + "-memory", "2048", "-camera-front", "none", "-camera-back", "none"] + self.result["emulator_command"] = command + self.emulator_log = (self.output / "emulator.log").open("wb") + self.emulator_process = subprocess.Popen(command, stdout=self.emulator_log, stderr=subprocess.STDOUT, + stdin=subprocess.DEVNULL, env=self.environment) + self.execution_started = True + self.result["execution_started"] = True + try: + deadline = time.monotonic() + self.args.boot_timeout + self.device("adb-wait-for-device", "wait-for-device", timeout=min(180, self.args.boot_timeout)) + while time.monotonic() < deadline: + if self.emulator_process.poll() is not None: + raise Unavailable(f"Actual emulator exited before Android boot: {self.emulator_process.returncode}; see emulator.log") + boot = self.device("boot-completed", "shell", "getprop", "sys.boot_completed", timeout=20) + if boot.stdout.decode().strip() == "1": + break + time.sleep(2) + else: + raise Unavailable(f"Actual emulator did not boot within {self.args.boot_timeout} seconds; see emulator.log") + self.device("unlock", "shell", "input", "keyevent", "82") + self.device("device-properties", "shell", "getprop") + wanted = ["ro.build.fingerprint", "ro.product.cpu.abilist", "ro.build.version.sdk", "ro.opengles.version"] + values = {} + for name in wanted: + values[name] = self.device("property-" + name, "shell", "getprop", name).stdout.decode().strip() + self.result["device_properties"] = values + if "x86_64" not in values["ro.product.cpu.abilist"].split(",") or values["ro.build.version.sdk"] != "35": + raise Unavailable("Booted device does not match the requested official Android 35 x86_64 image") + if int(values["ro.opengles.version"] or "0") < 0x30000: + raise Unavailable("Booted device does not advertise the GLES 3.0 required by the application") + surface = self.device("surfaceflinger", "shell", "dumpsys", "SurfaceFlinger").stdout.decode(errors="replace") + gles = [line.strip() for line in surface.splitlines() if "GLES:" in line] + self.result["surfaceflinger_gles"] = gles + if not gles: + raise Unavailable("Actual SurfaceFlinger did not expose a GLES renderer; see surfaceflinger.log") + screenshot = self.output / "actual-android-screen.png" + screenshot.write_bytes(self.device("screen-capture", "exec-out", "screencap", "-p").stdout) + self.result["actual_os_screen"] = image_stats(png_pixels(screenshot)) + self.result["checks"] = ["official-emulator-kvm-usable", "actual-android-boot-completed", + "adb-real-device-properties", "actual-surfaceflinger-gles", "actual-os-screen"] + self.stop(require_clean=True) + self.result["checks"].append("actual-emulator-stopped") + self.result["status"] = "passed" + finally: + self.stop(require_clean=False) + + def stop(self, *, require_clean: bool) -> None: + process = self.emulator_process + if process is None: + if self.emulator_log: + self.emulator_log.close() + self.emulator_log = None + return + clean = True + if process.poll() is None: + try: + stopped = self.device("stop-emulator", "emu", "kill", check=False, timeout=20) + clean = stopped.returncode == 0 + process.wait(timeout=25) + except (Unavailable, subprocess.TimeoutExpired): + clean = False + process.terminate() + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=10) + self.result["emulator_exit_code"] = process.returncode + self.result["emulator_stopped_cleanly"] = clean and process.returncode == 0 + self.emulator_process = None + if self.emulator_log: + self.emulator_log.close() + self.emulator_log = None + if require_clean and not self.result["emulator_stopped_cleanly"]: + raise Unavailable("Actual emulator required forced process cleanup; see stop-emulator.log/emulator.log") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--cloud-emulator", action="store_true") + parser.add_argument("--sdk-root", default=os.environ.get("ANDROID_SDK_ROOT") or os.environ.get("ANDROID_HOME", "")) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--boot-timeout", type=int, default=480) + parser.add_argument("--port", type=int, default=5554) + args = parser.parse_args() + if not 60 <= args.boot_timeout <= 600 or not 5554 <= args.port <= 5682 or args.port % 2: + parser.error("Boot timeout must be 60..600 seconds; console port must be even, 5554..5682") + preflight = Preflight(args) + code = 0 + try: + preflight.run() + except (Unavailable, Failed, OSError, ValueError) as exc: + preflight.result.update(status="failed" if preflight.execution_started else "not_run", blocker=str(exc)) + code = 1 if preflight.execution_started else 2 + finally: + preflight.stop(require_clean=False) + (preflight.output / "result.json").write_text(json.dumps(preflight.result, indent=2) + "\n") + print(json.dumps(preflight.result, indent=2)) + return code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/prepare_renpy_android_test_export.py b/tools/prepare_renpy_android_test_export.py new file mode 100644 index 000000000..df9a4ba75 --- /dev/null +++ b/tools/prepare_renpy_android_test_export.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Prepare a disposable Debug Godot export with a coinstallable test package ID.""" + +import argparse +import configparser +import json +import re +import shutil +from pathlib import Path + + +def read_android_preset(path: Path, name: str) -> tuple[configparser.ConfigParser, str, dict]: + config = configparser.ConfigParser(interpolation=None) + config.optionxform = str + with path.open() as stream: + config.read_file(stream) + sections = [section for section in config.sections() + if re.fullmatch(r'preset\.\d+', section) + and json.loads(config[section].get('name', '""')) == name] + if len(sections) != 1: + raise ValueError(f'Expected exactly one export preset named {name}') + section = sections[0] + if json.loads(config[section].get('platform', '""')) != 'Android': + raise ValueError(f'{name} is not an Android export preset') + options = config[section + '.options'] + expected = { + 'package': json.loads(options['package/unique_name']), + 'version_code': options.getint('version/code'), + 'version_name': json.loads(options['version/name']), + 'min_sdk': options.getint('gradle_build/min_sdk'), + } + return config, section + '.options', expected + + +def prepare(source: Path, destination: Path, preset: str, package: str) -> dict: + if preset not in ('Android Debug', 'Android Emulator Debug'): + raise ValueError('A coinstallable test package is supported only by Android Debug presets') + if not re.fullmatch(r'[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)+', package): + raise ValueError('Invalid Android test package ID') + source = source.resolve() + destination = destination.resolve() + if destination == source or source in destination.parents: + raise ValueError('Test export must use a separate directory outside the source project') + if destination.exists() and any(destination.iterdir()): + raise ValueError('Test export destination must be new or empty') + config, section, expected = read_android_preset(source / 'export_presets.cfg', preset) + if package == expected['package']: + raise ValueError('Test package must differ from the product package') + + def ignored(directory: str, names: list[str]) -> list[str]: + path = Path(directory) + if path == source: + return [name for name in names if name in ('.godot', '.git')] + if path == source / 'android/build': + return [name for name in names if name in ('.gradle', 'build')] + return [] + + # Copy files, rather than hard-linking mutable presets/build inputs back to + # the production checkout. Android resources and its staged raw assets stay + # available, while generated editor/Gradle output is regenerated separately. + shutil.copytree(source, destination, dirs_exist_ok=True, ignore=ignored) + config[section]['package/unique_name'] = json.dumps(package) + config[section]['package/name'] = json.dumps('Aether RenPy Test') + with (destination / 'export_presets.cfg').open('w') as stream: + config.write(stream) + return {'source_project': str(source), 'test_project': str(destination), + 'preset': preset, 'product_package': expected['package'], 'test_package': package} + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--source-project', required=True, type=Path) + parser.add_argument('--destination', required=True, type=Path) + parser.add_argument('--preset-name', required=True) + parser.add_argument('--package-id', required=True) + args = parser.parse_args() + print(json.dumps(prepare(args.source_project, args.destination, args.preset_name, args.package_id))) diff --git a/tools/renpy_assimp_acceptance/constructors.cpp b/tools/renpy_assimp_acceptance/constructors.cpp new file mode 100644 index 000000000..107eac9f0 --- /dev/null +++ b/tools/renpy_assimp_acceptance/constructors.cpp @@ -0,0 +1,70 @@ +// Execute actual pinned Assimp objects; no mobile gameplay is run. +#include "o3dgcCommon.h" +#include "o3dgcIndexedFaceSet.h" +#include "o3dgcSC3DMCEncodeParams.h" +#include "o3dgcTimer.h" +#include +#include +#include + +int main() { + using namespace o3dgc; + SC3DMCStats stats; + assert(stats.m_timeCoord == 0 && stats.m_timeNormal == 0 && stats.m_timeCoordIndex == 0); + assert(stats.m_timeReorder == 0); + assert(stats.m_streamSizeCoord == 0 && stats.m_streamSizeNormal == 0 && stats.m_streamSizeCoordIndex == 0); + for (unsigned long a = 0; a < O3DGC_SC3DMC_MAX_NUM_FLOAT_ATTRIBUTES; ++a) { + assert(stats.m_timeFloatAttribute[a] == 0 && stats.m_streamSizeFloatAttribute[a] == 0); + } + for (unsigned long a = 0; a < O3DGC_SC3DMC_MAX_NUM_INT_ATTRIBUTES; ++a) { + assert(stats.m_timeIntAttribute[a] == 0 && stats.m_streamSizeIntAttribute[a] == 0); + } + + SC3DMCEncodeParams parameters; + assert(parameters.GetNumFloatAttributes() == 0 && parameters.GetNumIntAttributes() == 0); + assert(parameters.GetCoordQuantBits() == 14 && parameters.GetNormalQuantBits() == 8); + assert(parameters.GetCoordPredMode() == O3DGC_SC3DMC_PARALLELOGRAM_PREDICTION); + assert(parameters.GetNormalPredMode() == O3DGC_SC3DMC_SURF_NORMALS_PREDICTION); + assert(parameters.GetStreamType() == O3DGC_STREAM_TYPE_ASCII); + assert(parameters.GetEncodeMode() == O3DGC_SC3DMC_ENCODE_MODE_TFAN); + for (unsigned long a = 0; a < O3DGC_SC3DMC_MAX_NUM_FLOAT_ATTRIBUTES; ++a) { + assert(parameters.GetFloatAttributeQuantBits(a) == 0); + assert(parameters.GetFloatAttributePredMode(a) == O3DGC_SC3DMC_DIFFERENTIAL_PREDICTION); + } + for (unsigned long a = 0; a < O3DGC_SC3DMC_MAX_NUM_INT_ATTRIBUTES; ++a) { + assert(parameters.GetIntAttributePredMode(a) == O3DGC_SC3DMC_NO_PREDICTION); + } + + IndexedFaceSet face_set; + assert(face_set.GetNCoordIndex() == 0 && face_set.GetNCoord() == 0 && face_set.GetNNormal() == 0); + assert(face_set.GetNumFloatAttributes() == 0 && face_set.GetNumIntAttributes() == 0); + assert(face_set.GetCoordIndex() == nullptr && face_set.GetIndexBufferID() == nullptr); + assert(face_set.GetCoord() == nullptr && face_set.GetNormal() == nullptr); + assert(face_set.GetCreaseAngle() == 30 && face_set.GetCCW() && face_set.GetSolid()); + assert(face_set.GetConvex() && face_set.GetIsTriangularMesh()); + for (int dimension = 0; dimension < 3; ++dimension) { + assert(face_set.GetCoordMin(dimension) == 0 && face_set.GetCoordMax(dimension) == 0); + assert(face_set.GetNormalMin(dimension) == 0 && face_set.GetNormalMax(dimension) == 0); + } + for (unsigned long a = 0; a < O3DGC_SC3DMC_MAX_NUM_FLOAT_ATTRIBUTES; ++a) { + assert(face_set.GetNFloatAttribute(a) == 0 && face_set.GetFloatAttributeDim(a) == 0); + assert(face_set.GetFloatAttributeType(a) == O3DGC_IFS_FLOAT_ATTRIBUTE_TYPE_UNKOWN); + assert(face_set.GetFloatAttribute(a) == nullptr); + for (unsigned long dimension = 0; dimension < O3DGC_SC3DMC_MAX_DIM_ATTRIBUTES; ++dimension) { + assert(face_set.GetFloatAttributeMin(a, dimension) == 0); + assert(face_set.GetFloatAttributeMax(a, dimension) == 0); + } + } + for (unsigned long a = 0; a < O3DGC_SC3DMC_MAX_NUM_INT_ATTRIBUTES; ++a) { + assert(face_set.GetNIntAttribute(a) == 0 && face_set.GetIntAttributeDim(a) == 0); + assert(face_set.GetIntAttributeType(a) == O3DGC_IFS_INT_ATTRIBUTE_TYPE_UNKOWN); + assert(face_set.GetIntAttribute(a) == nullptr); + } + + Timer timer; + assert(timer.GetElapsedTime() == 0); + timer.Tic(); + timer.Toc(); + assert(std::isfinite(timer.GetElapsedTime())); + std::cout << "actual Open3DGC constructor contracts PASS (host platform)\n"; +} diff --git a/tools/renpy_assimp_acceptance/ownership.cpp b/tools/renpy_assimp_acceptance/ownership.cpp new file mode 100644 index 000000000..fb255e541 --- /dev/null +++ b/tools/renpy_assimp_acceptance/ownership.cpp @@ -0,0 +1,101 @@ +// Execute actual pinned Assimp objects; no mobile gameplay is run. +#include +#include +#include +#include +#include + +static aiMesh *mesh() { + aiMesh *value = new aiMesh; + value->mNumVertices = 3; + value->mVertices = new aiVector3D[3]{{1, 2, 3}, {4, 5, 6}, {7, 8, 9}}; + value->mNumFaces = 3; + value->mFaces = new aiFace[3]; + value->mFaces[0].mNumIndices = 3; + value->mFaces[0].mIndices = new unsigned int[3]{0, 1, 2}; + value->mFaces[2].mNumIndices = 1; + value->mFaces[2].mIndices = new unsigned int[1]{2}; + return value; +} + +static aiMeshMorphAnim *morph() { + aiMeshMorphAnim *value = new aiMeshMorphAnim; + value->mNumKeys = 3; + value->mKeys = new aiMeshMorphKey[3]; + value->mKeys[0].mTime = 5; + value->mKeys[1].mTime = 13.25; + value->mKeys[1].mNumValuesAndWeights = 2; + value->mKeys[1].mValues = new unsigned int[2]{0, 3}; + value->mKeys[1].mWeights = new double[2]{0.125, 0.875}; + value->mKeys[2].mTime = 17; + value->mKeys[2].mNumValuesAndWeights = 1; + value->mKeys[2].mValues = new unsigned int[1]{2}; + value->mKeys[2].mWeights = new double[1]{0.6}; + return value; +} + +int main() { + for (bool source_first : {false, true}) { + aiMesh *source = mesh(); + aiMesh *copy = nullptr; + Assimp::SceneCombiner::Copy(©, source); + assert(copy && copy->mFaces != source->mFaces); + assert(copy->mVertices != source->mVertices); + assert(copy->mVertices[1].y == 5); + assert(copy->mFaces[0].mNumIndices == 3); + assert(copy->mFaces[0].mIndices != source->mFaces[0].mIndices); + assert(copy->mFaces[0].mIndices[2] == 2); + assert(copy->mFaces[1].mNumIndices == 0 && copy->mFaces[1].mIndices == nullptr); + assert(copy->mFaces[2].mIndices != source->mFaces[2].mIndices); + copy->mFaces[0].mIndices[1] = 42; + copy->mVertices[1].y = 73; + assert(source->mFaces[0].mIndices[1] == 1 && source->mVertices[1].y == 5); + if (source_first) { + delete source; + assert(copy->mFaces[0].mIndices[1] == 42 && copy->mVertices[1].y == 73); + delete copy; + } else { + delete copy; + assert(source->mFaces[0].mIndices[1] == 1 && source->mVertices[1].y == 5); + delete source; + } + + aiMeshMorphAnim *morph_source = morph(); + aiMeshMorphAnim *morph_copy = nullptr; + Assimp::SceneCombiner::Copy(&morph_copy, morph_source); + assert(morph_copy && morph_copy->mKeys != morph_source->mKeys); + assert(morph_copy->mKeys[0].mTime == 5); + assert(morph_copy->mKeys[0].mValues == nullptr && morph_copy->mKeys[0].mWeights == nullptr); + assert(morph_copy->mKeys[1].mTime == 13.25); + assert(morph_copy->mKeys[1].mNumValuesAndWeights == 2); + assert(morph_copy->mKeys[1].mValues != morph_source->mKeys[1].mValues); + assert(morph_copy->mKeys[1].mWeights != morph_source->mKeys[1].mWeights); + assert(morph_copy->mKeys[1].mValues[1] == 3 && morph_copy->mKeys[1].mWeights[1] == 0.875); + assert(morph_copy->mKeys[2].mTime == 17); + assert(morph_copy->mKeys[2].mValues != morph_source->mKeys[2].mValues); + assert(morph_copy->mKeys[2].mWeights != morph_source->mKeys[2].mWeights); + morph_copy->mKeys[1].mValues[1] = 42; + morph_copy->mKeys[1].mWeights[1] = 0.25; + assert(morph_source->mKeys[1].mValues[1] == 3 && morph_source->mKeys[1].mWeights[1] == 0.875); + if (source_first) { + delete morph_source; + assert(morph_copy->mKeys[1].mValues[1] == 42 && morph_copy->mKeys[1].mWeights[1] == 0.25); + delete morph_copy; + } else { + delete morph_copy; + assert(morph_source->mKeys[1].mValues[1] == 3 && morph_source->mKeys[1].mWeights[1] == 0.875); + delete morph_source; + } + } + aiMesh empty; + aiMesh *empty_copy = nullptr; + Assimp::SceneCombiner::Copy(&empty_copy, &empty); + assert(empty_copy->mFaces == nullptr && empty_copy->mVertices == nullptr); + delete empty_copy; + aiMeshMorphAnim empty_morph; + aiMeshMorphAnim *empty_morph_copy = nullptr; + Assimp::SceneCombiner::Copy(&empty_morph_copy, &empty_morph); + assert(empty_morph_copy->mKeys == nullptr); + delete empty_morph_copy; + std::cout << "actual SceneCombiner mesh/morph ownership PASS\n"; +} diff --git a/tools/renpy_freetype_acceptance/README.md b/tools/renpy_freetype_acceptance/README.md new file mode 100644 index 000000000..1dad2abd7 --- /dev/null +++ b/tools/renpy_freetype_acceptance/README.md @@ -0,0 +1,59 @@ +# FreeType native export-generator acceptance + +Run the actual FreeType 2.13.3 unpack/build/install tasks from pinned +`renpy-build` commit `7bfab40c1174f622f644b24669afd5fb167fbb79`, applying the complete +native patch series to a fresh scratch checkout: + +```sh +python tools/test_renpy_mobile_freetype_host_tools.py /path/to/renpy-build \ + --output-dir /tmp/renpy-freetype-host-tools --jobs 3 +``` + +The driver needs the pinned source checkout, Python with Jinja2, make, pkg-config +and the platform's real compiler tools. On an arm64 Mac it uses the selected +Xcode's macOS, iPhoneOS and iPhoneSimulator SDKs. On Linux it checks a native +archive; add `--ndk /path/to/android-ndk-r29` for a real Android AArch64 API 21 +cross archive. No target SDK or compiler is simulated. + +The source tar's SHA256 must be +`5c3a8e78f7b24c20b25b54ee575d6daa40007a5f4eea2845861c3409b3021747`. +The test invokes production `Context` and `Task.run`, replacing missing compiler +cache locations with genuine local compiler executables and preserving target +flags. It loads the FreeType recipe in isolation; it does not claim the full +Ren'Py dependency graph was run. The native Linux case uses the installed native +compiler rather than requiring the unrelated production Linux sysroot. + +FreeType's host `apinames` must execute and generate `ftexport.sym`, including +the existing OpenType validation and TrueType interpreter exports. Before the +actual `make install`, the driver removes the exporter and its output, requiring +the install invocation to compile and run the host exporter again. It checks +all objects in the installed static archive and checks defined FreeType API +symbols. On Mac, `apinames` must identify macOS platform 1; target objects must +identify macOS 1, iOS device 2 or iOS Simulator 7 as appropriate. CPU checks alone +cannot distinguish those platforms. On Linux, the exporter must be a native +ELF executable and the Android archive must contain AArch64 ELF objects. + +Each case preserves its production commands, unchanged target environment, +build log, actual host executable and compiler wrapper, generated export file, +actual installed archive, defined-symbol listing and result JSON with hashes. +The driver executes the host exporter; it does not execute target libraries, +launch an app, or validate gameplay. The HarfBuzz-enabled recipe receives the +same production fix, but this focused driver runs the base FreeType recipe; +the complete source pipeline must still validate its HarfBuzz dependency. + +Patch `0012-freetype-host-tools.patch` supplies a separate native C compiler +wrapper. On Darwin it selects the real macOS compiler and canonical macOS SDK, +queries the native target triple with cross deployment variables removed, and +then explicitly selects that triple and SDK. Only the host compiler child loses +the iOS/tvOS/watchOS/visionOS/DriverKit deployment variables and `SDKROOT`. +The target compiler and flags stay in the production context. Both FreeType +recipes pass `CC_BUILD` for configure and override `CCexe`, `CCexe_CFLAGS` and +`CCexe_LDFLAGS` on both make and install. This also covers configure's branch +that resets `CC_BUILD` to the target `CC` when a target conftest appears runnable. + +The upstream contracts are visible in +[configure.raw](https://github.com/freetype/freetype/blob/VER-2-13-3/builds/unix/configure.raw), +[unix-cc.in](https://github.com/freetype/freetype/blob/VER-2-13-3/builds/unix/unix-cc.in) +and [exports.mk](https://github.com/freetype/freetype/blob/VER-2-13-3/builds/exports.mk). +Upstream uses `CC_BUILD` and the make `CCexe*` variables for this tool; +`CFLAGS_BUILD` and `LDFLAGS_BUILD` are not the relevant hooks. diff --git a/tools/renpy_ios_acceptance/README.md b/tools/renpy_ios_acceptance/README.md new file mode 100644 index 000000000..c4526a9ce --- /dev/null +++ b/tools/renpy_ios_acceptance/README.md @@ -0,0 +1,71 @@ +# Cloud iOS Simulator gameplay acceptance + +This harness installs the actual Godot app, uses XCUITest for system touch, +software keyboard text, Home, foreground and game exit, and observes the bundled +native Ren'Py provider. It creates a disposable cloud Simulator unless an +explicit cloud UDID is supplied. It never substitutes a stub runtime or fake UI. + +Use a cloud Apple Silicon macOS runner with licensed Xcode/iPhoneSimulator SDK, +an installed iOS Simulator runtime, the repository's Godot editor/export template, +vcpkg/Rust build prerequisites, and the rebuilt cooperative Renios payload. The +Simulator export uses Compatibility rendering and separate simulator libraries. +Ren'Py's SDL/Python/FFmpeg closure is embedded in `AetherRenPyRuntime.framework`, +with the matching `MetalANGLE.framework` slice. No Apple developer signing +identity is needed for the Simulator; the packages are signed ad hoc. Godot's +existing Team ID is retained solely to satisfy its export-project validation; +the Xcode simulator build explicitly clears the signing team. + +From the repository root, after the actual iOS source runtime build: + +```bash +export AETHERKIRI_ENABLE_RENPY=ON +export AETHERKIRI_ENABLE_INTERNAL=OFF +export AETHERKIRI_RENPY_MOBILE_ROOT="$PWD/out/renpy-mobile-source/payload" +bash scripts/build_ios.sh debug --simulator --simulator-arch=arm64 +python3 tools/run_renpy_ios_device_acceptance.py \ + --cloud-simulator \ + --app out/godot/ios-simulator-arm64/debug/build-simulator-arm64/Aether.app \ + --output-dir out/renpy-ios-gameplay \ + --overall-timeout 600 +``` + +Public CI uses `AETHERKIRI_ENABLE_INTERNAL=OFF`; a build authorized to access +the private package can retain its normal Internal build configuration. +Archive the simulator `.app` ZIP, runtime source manifest, framework link/export +reports, the entire gameplay output (including `gameplay.xcresult`), and the +Xcode export/build logs. The gameplay directory contains real provider images, +Simulator screenshots, pixel comparisons, UI action acknowledgments, the Ren'Py +script checkpoints, native provider/lifecycle observations and system logs. + +`result.json` says `passed` only after real native images appear in OS +screenshots, actual touches and exact keyboard text reach the game, Home pauses +the native runtime, foreground resumes it with a new frame, and the game's Quit +action destroys the runtime and restores the existing application game library, +whose actual viewport pixels must also appear in the system screenshot. The +UIKit host remains foreground; terminating it is not required to exit a game. +Exit code `2` means +`not_run` because tools, runtime or the real app are missing; `1` means a real +execution did not meet the required evidence. Neither result proves phone +gameplay. Simulator results also do not establish physical iPhone support. + +The harness builds and installs the actual UI runner before it queries the +Godot app's data container and stages the private demo request. It then uses +the documented `UseDestinationArtifacts` xctestrun mode: the installed runner +and target are selected by their bundle IDs, and the test bundle is verified +inside the runner's `PlugIns` directory. Host artifact paths are excluded, so +the test action does not install the app again after staging. The existing +overall timeout still includes the runner build. This protects the staging +handoff; it does not establish that a previous timeout changed the container. +After real foreground readiness, the harness queries the target container +again and requires the staged directory identity to match. A change fails +acceptance; it never restages or substitutes evidence after the app starts. +See Apple's [command-line testing documentation](https://developer.apple.com/library/archive/technotes/tn2339/_index.html) +and the `UseDestinationArtifacts` section of `man xcodebuild.xctestrun` in the +selected Xcode toolchain. + +Host commands and acknowledgments use only the UI test runner's own +Documents directory; the game and observer independently write into the test +app's Documents directory. Only the two acceptance directories and one-shot +debug request are reset. The Simulator software-keyboard preference is restored +when the harness exits. This harness has no installation-only or skip-gameplay +mode. diff --git a/tools/renpy_ios_acceptance/RenPyAcceptance.xcodeproj/project.pbxproj b/tools/renpy_ios_acceptance/RenPyAcceptance.xcodeproj/project.pbxproj new file mode 100644 index 000000000..318487a78 --- /dev/null +++ b/tools/renpy_ios_acceptance/RenPyAcceptance.xcodeproj/project.pbxproj @@ -0,0 +1,52 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = {}; + objectVersion = 56; + objects = { + A01000000000000000000001 = {isa = PBXBuildFile; fileRef = A01000000000000000000002; }; + A01000000000000000000002 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenPyGameplayTests.swift; sourceTree = ""; }; + A01000000000000000000003 = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; path = RenPyAcceptance.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + A01000000000000000000004 = {isa = PBXGroup; children = (A01000000000000000000002, A01000000000000000000005); sourceTree = ""; }; + A01000000000000000000005 = {isa = PBXGroup; children = (A01000000000000000000003); name = Products; sourceTree = ""; }; + A01000000000000000000006 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (A01000000000000000000001); runOnlyForDeploymentPostprocessing = 0; }; + A01000000000000000000007 = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; }; + A01000000000000000000008 = {isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; }; + A01000000000000000000009 = { + isa = PBXNativeTarget; + buildConfigurationList = A0100000000000000000000C; + buildPhases = (A01000000000000000000006, A01000000000000000000007, A01000000000000000000008); + buildRules = (); dependencies = (); name = RenPyAcceptance; + productName = RenPyAcceptance; productReference = A01000000000000000000003; + productType = "com.apple.product-type.bundle.ui-testing"; + }; + A0100000000000000000000A = { + isa = PBXProject; attributes = {LastUpgradeCheck = 1600; TargetAttributes = {A01000000000000000000009 = {CreatedOnToolsVersion = 16.0; }; }; }; + buildConfigurationList = A0100000000000000000000B; + compatibilityVersion = "Xcode 14.0"; developmentRegion = en; hasScannedForEncodings = 0; + knownRegions = (en, Base); mainGroup = A01000000000000000000004; + productRefGroup = A01000000000000000000005; projectDirPath = ""; projectRoot = ""; + targets = (A01000000000000000000009); + }; + A0100000000000000000000B = {isa = XCConfigurationList; buildConfigurations = (A0100000000000000000000D); defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; + A0100000000000000000000C = {isa = XCConfigurationList; buildConfigurations = (A0100000000000000000000E); defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; + A0100000000000000000000D = {isa = XCBuildConfiguration; name = Debug; buildSettings = {SDKROOT = iphonesimulator; IPHONEOS_DEPLOYMENT_TARGET = 16.0; SWIFT_VERSION = 5.0; CLANG_ENABLE_MODULES = YES; }; }; + A0100000000000000000000E = { + isa = XCBuildConfiguration; name = Debug; + buildSettings = { + PRODUCT_NAME = "$(TARGET_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = "org.aetherkiri.renpy-ios-acceptance"; + GENERATE_INFOPLIST_FILE = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + SWIFT_VERSION = 5.0; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SDKROOT = iphonesimulator; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGNING_ALLOWED = YES; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + }; + }; + }; + rootObject = A0100000000000000000000A; +} diff --git a/tools/renpy_ios_acceptance/RenPyAcceptance.xcodeproj/xcshareddata/xcschemes/RenPyAcceptance.xcscheme b/tools/renpy_ios_acceptance/RenPyAcceptance.xcodeproj/xcshareddata/xcschemes/RenPyAcceptance.xcscheme new file mode 100644 index 000000000..39f87d120 --- /dev/null +++ b/tools/renpy_ios_acceptance/RenPyAcceptance.xcodeproj/xcshareddata/xcschemes/RenPyAcceptance.xcscheme @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/tools/renpy_ios_acceptance/RenPyGameplayTests.swift b/tools/renpy_ios_acceptance/RenPyGameplayTests.swift new file mode 100644 index 000000000..8f846338b --- /dev/null +++ b/tools/renpy_ios_acceptance/RenPyGameplayTests.swift @@ -0,0 +1,92 @@ +import Foundation +import XCTest + +// Drive the already installed Godot app through the actual Simulator UI. +// The host exchanges commands only through this test runner's own Documents +// directory; game checkpoints and provider frames are independently observed. +final class RenPyGameplayTests: XCTestCase { + func testCloudGameplay() throws { + continueAfterFailure = false + let environment = ProcessInfo.processInfo.environment + let runID = try XCTUnwrap(environment["AETHER_RENPY_RUN_ID"]) + let bundleID = try XCTUnwrap(environment["AETHER_RENPY_APP_BUNDLE_ID"]) + let timeout = Double(environment["AETHER_RENPY_TEST_TIMEOUT"] ?? "600") ?? 600 + let documents = try XCTUnwrap(FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first) + let root = documents.appendingPathComponent("aether-renpy-ui-" + runID) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let app = XCUIApplication(bundleIdentifier: bundleID) + if app.state != .runningForeground { + app.launch() + } + XCTAssertEqual(app.state, .runningForeground, "The installed app must launch on the Simulator") + try write(["run_id": runID, "bundle_id": bundleID, "ready": true], to: root.appendingPathComponent("ready.json")) + let deadline = Date().addingTimeInterval(timeout) + var previousSequence = 0 + while Date() < deadline { + let commandURL = root.appendingPathComponent("command.json") + guard let data = try? Data(contentsOf: commandURL), + let command = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + command["run_id"] as? String == runID, + let sequence = command["seq"] as? Int, + sequence > previousSequence else { + Thread.sleep(forTimeInterval: 0.1) + continue + } + previousSequence = sequence + let operation = command["op"] as? String ?? "" + var acknowledgment: [String: Any] = ["run_id": runID, "seq": sequence, "op": operation] + do { + switch operation { + case "tap": + XCTAssertEqual(app.state, .runningForeground) + let x = try XCTUnwrap(command["x"] as? Double) + let y = try XCTUnwrap(command["y"] as? Double) + XCTAssertTrue((0...1).contains(x) && (0...1).contains(y)) + app.coordinate(withNormalizedOffset: CGVector(dx: x, dy: y)).tap() + case "keyboard": + XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 15), "A real system software keyboard must be visible") + acknowledgment["keyboard_visible"] = app.keyboards.firstMatch.exists + case "type": + XCTAssertTrue(app.keyboards.firstMatch.exists) + app.typeText(try XCTUnwrap(command["text"] as? String)) + case "home": + XCUIDevice.shared.press(.home) + let background = XCTNSPredicateExpectation(predicate: NSPredicate { _, _ in + app.state == .runningBackground || app.state == .runningBackgroundSuspended + }, object: nil) + XCTAssertEqual(XCTWaiter.wait(for: [background], timeout: 15), .completed) + case "activate": + app.activate() + XCTAssertTrue(app.wait(for: .runningForeground, timeout: 15)) + case "foreground": + XCTAssertEqual(app.state, .runningForeground) + case "finish": + acknowledgment["success"] = true + try write(acknowledgment, to: root.appendingPathComponent("ack.json")) + return + default: + throw NSError(domain: "AetherRenPyUI", code: 1, userInfo: [NSLocalizedDescriptionKey: "Unknown UI operation: " + operation]) + } + let screenshot = XCUIScreen.main.screenshot() + try screenshot.pngRepresentation.write(to: root.appendingPathComponent(String(sequence) + ".png"), options: .atomic) + let attachment = XCTAttachment(screenshot: screenshot) + attachment.name = "RenPy " + operation + " " + String(sequence) + attachment.lifetime = .keepAlways + add(attachment) + acknowledgment["success"] = true + acknowledgment["app_state"] = app.state.rawValue + try write(acknowledgment, to: root.appendingPathComponent("ack.json")) + } catch { + acknowledgment["success"] = false + acknowledgment["error"] = String(describing: error) + try? write(acknowledgment, to: root.appendingPathComponent("ack.json")) + throw error + } + } + XCTFail("Cloud gameplay command deadline expired without complete gameplay evidence") + } + + private func write(_ value: [String: Any], to url: URL) throws { + try JSONSerialization.data(withJSONObject: value, options: [.sortedKeys]).write(to: url, options: .atomic) + } +} diff --git a/tools/renpy_ios_egl_probe/egl_probe.c b/tools/renpy_ios_egl_probe/egl_probe.c new file mode 100644 index 000000000..e87247c84 --- /dev/null +++ b/tools/renpy_ios_egl_probe/egl_probe.c @@ -0,0 +1,144 @@ +#include "egl_probe.h" +#include +#include +#include +#include +#include +#include +#include + +static void copy_string(char *target, size_t size, const char *source) { + snprintf(target, size, "%s", source ? source : ""); +} + +static int expect_pixel(const unsigned char *pixels, int x, int y, + int r, int g, int b, int a) { + const unsigned char *pixel = pixels + (y * AETHER_EGL_PROBE_SIZE + x) * 4; + const int expected[4] = {r, g, b, a}; + for (int c = 0; c < 4; ++c) { + const int delta = (int)pixel[c] - expected[c]; + if (delta < -1 || delta > 1) return 0; + } + return 1; +} + +int aether_run_egl_probe(aether_egl_probe_result *out) { + memset(out, 0, sizeof(*out)); + EGLDisplay display = EGL_NO_DISPLAY; + EGLSurface host_surface = EGL_NO_SURFACE, game_surface = EGL_NO_SURFACE; + EGLContext host_context = EGL_NO_CONTEXT, game_context = EGL_NO_CONTEXT; + const EGLDisplay previous_display = eglGetCurrentDisplay(); + const EGLContext previous_context = eglGetCurrentContext(); + const EGLSurface previous_draw = eglGetCurrentSurface(EGL_DRAW); + const EGLSurface previous_read = eglGetCurrentSurface(EGL_READ); + const EGLenum previous_api = eglQueryAPI(); +#define CHECK(condition, message) do { if (!(condition)) { \ + copy_string(out->error, sizeof(out->error), message); \ + out->egl_error = eglGetError(); out->gl_error = glGetError(); goto cleanup; \ +} } while (0) + PFNEGLGETPLATFORMDISPLAYEXTPROC get_platform_display = + (PFNEGLGETPLATFORMDISPLAYEXTPROC)eglGetProcAddress("eglGetPlatformDisplayEXT"); + CHECK(get_platform_display, "MetalANGLE has no eglGetPlatformDisplayEXT"); + const EGLint metal_attributes[] = { + EGL_PLATFORM_ANGLE_TYPE_ANGLE, EGL_PLATFORM_ANGLE_TYPE_METAL_ANGLE, EGL_NONE + }; + display = get_platform_display(EGL_PLATFORM_ANGLE_ANGLE, + (void *)(uintptr_t)EGL_DEFAULT_DISPLAY, metal_attributes); + CHECK(display != EGL_NO_DISPLAY, "could not create ANGLE Metal EGL display"); + EGLint egl_major = 0, egl_minor = 0; + CHECK(eglInitialize(display, &egl_major, &egl_minor), "eglInitialize failed (Metal GPU may be unavailable)"); + copy_string(out->egl_vendor, sizeof(out->egl_vendor), eglQueryString(display, EGL_VENDOR)); + copy_string(out->egl_version, sizeof(out->egl_version), eglQueryString(display, EGL_VERSION)); + CHECK(eglBindAPI(EGL_OPENGL_ES_API), "eglBindAPI GLES failed"); + const EGLint config_attributes[] = { + EGL_SURFACE_TYPE, EGL_PBUFFER_BIT, EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT, + EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8, EGL_BLUE_SIZE, 8, EGL_ALPHA_SIZE, 8, + EGL_DEPTH_SIZE, 16, EGL_NONE + }; + EGLConfig config = NULL; + EGLint config_count = 0; + CHECK(eglChooseConfig(display, config_attributes, &config, 1, &config_count) && config_count == 1, + "no RGBA8 GLES pbuffer EGL configuration"); + const EGLint surface_attributes[] = { + EGL_WIDTH, AETHER_EGL_PROBE_SIZE, EGL_HEIGHT, AETHER_EGL_PROBE_SIZE, EGL_NONE + }; + host_surface = eglCreatePbufferSurface(display, config, surface_attributes); + game_surface = eglCreatePbufferSurface(display, config, surface_attributes); + CHECK(host_surface != EGL_NO_SURFACE && game_surface != EGL_NO_SURFACE, "eglCreatePbufferSurface failed"); + out->pbuffer_created = 1; + const EGLint context3[] = {EGL_CONTEXT_CLIENT_VERSION, 3, EGL_NONE}; + const EGLint context2[] = {EGL_CONTEXT_CLIENT_VERSION, 2, EGL_NONE}; + game_context = eglCreateContext(display, config, EGL_NO_CONTEXT, context3); + out->client_version = 3; + if (game_context == EGL_NO_CONTEXT) { + (void)eglGetError(); + game_context = eglCreateContext(display, config, EGL_NO_CONTEXT, context2); + out->client_version = 2; + } + host_context = eglCreateContext(display, config, EGL_NO_CONTEXT, + out->client_version == 3 ? context3 : context2); + CHECK(game_context != EGL_NO_CONTEXT && host_context != EGL_NO_CONTEXT, "eglCreateContext failed"); + CHECK(eglMakeCurrent(display, host_surface, host_surface, host_context), "could not bind host pbuffer context"); + glClearColor(0, 1, 1, 1); + glClear(GL_COLOR_BUFFER_BIT); + CHECK(eglMakeCurrent(display, game_surface, game_surface, game_context), "could not bind game pbuffer context"); + copy_string(out->gl_vendor, sizeof(out->gl_vendor), (const char *)glGetString(GL_VENDOR)); + copy_string(out->gl_renderer, sizeof(out->gl_renderer), (const char *)glGetString(GL_RENDERER)); + copy_string(out->gl_version, sizeof(out->gl_version), (const char *)glGetString(GL_VERSION)); + glViewport(0, 0, AETHER_EGL_PROBE_SIZE, AETHER_EGL_PROBE_SIZE); + glDisable(GL_DITHER); + glEnable(GL_SCISSOR_TEST); + const float colors[4][4] = {{1, 0, 0, 1}, {0, 1, 0, 1}, {0, 0, 1, 1}, {1, 1, 1, 1}}; + for (int quadrant = 0; quadrant < 4; ++quadrant) { + glScissor((quadrant % 2) * 8, (quadrant / 2) * 8, 8, 8); + glClearColor(colors[quadrant][0], colors[quadrant][1], colors[quadrant][2], colors[quadrant][3]); + glClear(GL_COLOR_BUFFER_BIT); + } + glDisable(GL_SCISSOR_TEST); + glPixelStorei(GL_PACK_ALIGNMENT, 1); + glFinish(); + glReadPixels(0, 0, AETHER_EGL_PROBE_SIZE, AETHER_EGL_PROBE_SIZE, GL_RGBA, GL_UNSIGNED_BYTE, out->pixels); + CHECK(glGetError() == GL_NO_ERROR, "GLES clear/readback produced an error"); + for (int y = 0; y < 16; ++y) for (int x = 0; x < 16; ++x) { + int quadrant = (y / 8) * 2 + x / 8; + CHECK(expect_pixel(out->pixels, x, y, (int)colors[quadrant][0] * 255, + (int)colors[quadrant][1] * 255, (int)colors[quadrant][2] * 255, 255), + "actual pbuffer pixels did not match the four GLES clear colors"); + } + out->readback_verified = 1; + CHECK(eglSwapBuffers(display, game_surface), "pbuffer eglSwapBuffers failed"); + CHECK(eglMakeCurrent(display, host_surface, host_surface, host_context), "could not restore host context"); + CHECK(eglGetCurrentContext() == host_context && eglGetCurrentSurface(EGL_DRAW) == host_surface && + eglGetCurrentSurface(EGL_READ) == host_surface, "host EGL context/surface restoration mismatch"); + unsigned char check[4] = {0}; + glReadPixels(0, 0, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, check); + CHECK(check[0] == 0 && check[1] == 255 && check[2] == 255 && check[3] == 255, + "game rendering modified the independent host pbuffer"); + out->host_context_restored = 1; + CHECK(eglMakeCurrent(display, game_surface, game_surface, game_context), "could not resume game context"); + glClearColor(1, 1, 0, 1); + glClear(GL_COLOR_BUFFER_BIT); + glReadPixels(0, 0, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, check); + CHECK(glGetError() == GL_NO_ERROR && check[0] == 255 && check[1] == 255 && check[2] == 0 && check[3] == 255, + "resumed game pbuffer did not produce fresh yellow pixels"); + out->resume_verified = 1; + out->passed = 1; +cleanup: + if (display != EGL_NO_DISPLAY) { + eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT); + if (host_context != EGL_NO_CONTEXT) eglDestroyContext(display, host_context); + if (game_context != EGL_NO_CONTEXT) eglDestroyContext(display, game_context); + if (host_surface != EGL_NO_SURFACE) eglDestroySurface(display, host_surface); + if (game_surface != EGL_NO_SURFACE) eglDestroySurface(display, game_surface); + /* Never terminate a display that could also be owned by a host. */ + } + eglBindAPI(previous_api); + if (previous_display != EGL_NO_DISPLAY && + !eglMakeCurrent(previous_display, previous_draw, previous_read, previous_context)) { + out->passed = 0; + copy_string(out->error, sizeof(out->error), "could not restore pre-probe EGL context"); + out->egl_error = eglGetError(); + } + return out->passed ? 0 : -1; +#undef CHECK +} diff --git a/tools/renpy_ios_egl_probe/egl_probe.h b/tools/renpy_ios_egl_probe/egl_probe.h new file mode 100644 index 000000000..9c2e9c356 --- /dev/null +++ b/tools/renpy_ios_egl_probe/egl_probe.h @@ -0,0 +1,25 @@ +#pragma once +#include + +#define AETHER_EGL_PROBE_SIZE 16 +typedef struct aether_egl_probe_result { + int passed; + int pbuffer_created; + int readback_verified; + int host_context_restored; + int resume_verified; + int sdl_terminal_cleanup_verified; + int client_version; + unsigned int egl_error; + unsigned int gl_error; + char error[256]; + char egl_vendor[256]; + char egl_version[256]; + char gl_vendor[256]; + char gl_renderer[512]; + char gl_version[256]; + uint8_t pixels[AETHER_EGL_PROBE_SIZE * AETHER_EGL_PROBE_SIZE * 4]; +} aether_egl_probe_result; + +/* Executes real MetalANGLE EGL/GLES calls, without any UIKit/SDL window. */ +int aether_run_egl_probe(aether_egl_probe_result *result); diff --git a/tools/renpy_ios_egl_probe/host_gl_probe.m b/tools/renpy_ios_egl_probe/host_gl_probe.m new file mode 100644 index 000000000..c3d2005a6 --- /dev/null +++ b/tools/renpy_ios_egl_probe/host_gl_probe.m @@ -0,0 +1,207 @@ +/* Real UIKit SDL2 plus MetalANGLE coexistence probe. No SDL/GL API is replaced. */ +#define SDL_MAIN_HANDLED 1 +#import +#import +#import +#import +#include +#include +#include +#include "egl_probe.h" + +typedef struct AppleGL { + const GLubyte *(*GetString)(GLenum); + GLenum (*GetError)(void); + void (*GenFramebuffers)(GLsizei, GLuint *); + void (*BindFramebuffer)(GLenum, GLuint); + void (*DeleteFramebuffers)(GLsizei, const GLuint *); + GLenum (*CheckFramebufferStatus)(GLenum); + void (*GenRenderbuffers)(GLsizei, GLuint *); + void (*BindRenderbuffer)(GLenum, GLuint); + void (*RenderbufferStorage)(GLenum, GLenum, GLsizei, GLsizei); + void (*FramebufferRenderbuffer)(GLenum, GLenum, GLenum, GLuint); + void (*DeleteRenderbuffers)(GLsizei, const GLuint *); + void (*ClearColor)(GLfloat, GLfloat, GLfloat, GLfloat); + void (*Clear)(GLbitfield); + void (*Finish)(void); + void (*ReadPixels)(GLint, GLint, GLsizei, GLsizei, GLenum, GLenum, void *); +} AppleGL; + +static void *appleSymbol(const char *name, NSMutableDictionary *sources, NSMutableDictionary *report) { + void *symbol = SDL_GL_GetProcAddress(name); + Dl_info info = {0}; + if (!symbol || !dladdr(symbol, &info) || !info.dli_fname) { + report[@"error"] = [NSString stringWithFormat:@"Actual SDL symbol %s failed: %s", name, SDL_GetError()]; + return NULL; + } + NSString *source = [NSString stringWithUTF8String:info.dli_fname] ?: @""; + sources[[NSString stringWithUTF8String:name]] = source; + if (![source containsString:@"/OpenGLES.framework/"] || [source containsString:@"MetalANGLE"]) { + report[@"error"] = [NSString stringWithFormat:@"Host SDL resolved %s to %@", name, source]; + return NULL; + } + return symbol; +} + +static BOOL expectedColor(const unsigned char *pixels, int red, int green, int blue) { + for (int i = 0; i < 16 * 16; ++i) { + const int wanted[] = {red, green, blue, 255}; + for (int c = 0; c < 4; ++c) { + int delta = pixels[i * 4 + c] - wanted[c]; + if (delta < -1 || delta > 1) return NO; + } + } + return YES; +} + +static NSDictionary *runHostProbe(NSData **actualPixels) { + NSMutableDictionary *report = [@{ + @"probe": @"actual SDL2.32.10 UIKit Apple OpenGLES and MetalANGLE isolation", + @"gameplay_verified": @NO, @"passed": @NO, @"error": @"", + @"apple_provenance_verified": @NO, @"apple_readback_verified": @NO, + @"metalangle_readback_verified": @NO, @"apple_context_preserved": @NO, + @"apple_pixels_preserved": @NO, @"apple_resume_verified": @NO, + @"load_unload_verified": @NO, @"video_reinitialization_verified": @NO + } mutableCopy]; + NSMutableDictionary *sources = [NSMutableDictionary dictionary]; + EAGLContext *previous = [EAGLContext currentContext]; + EAGLContext *host = nil; + AppleGL gl = {0}; + GLuint framebuffer = 0, renderbuffer = 0; + unsigned char pixels[16 * 16 * 4] = {0}; + aether_egl_probe_result metal = {0}; + SDL_version version; + SDL_GetVersion(&version); + report[@"sdl_version"] = [NSString stringWithFormat:@"%d.%d.%d", version.major, version.minor, version.patch]; + report[@"symbol_sources"] = sources; +#define CHECK(value, ...) do { if (!(value)) { \ + if (![report[@"error"] length]) { report[@"error"] = (__VA_ARGS__); } goto cleanup; } } while (0) +#define LOAD(member, name) do { void *address = appleSymbol(name, sources, report); \ + if (!address) { goto cleanup; } gl.member = (__typeof__(gl.member))address; } while (0) + CHECK(version.major == 2 && version.minor == 32 && version.patch == 10, @"Unexpected actual host SDL version"); + SDL_SetMainReady(); + CHECK(!SDL_setenv("SDL_VIDEODRIVER", "uikit", 1), @"Cannot select actual UIKit driver"); + CHECK(!SDL_Init(SDL_INIT_VIDEO), [NSString stringWithFormat:@"Actual UIKit SDL init: %s", SDL_GetError()]); + CHECK(SDL_GetCurrentVideoDriver() && !strcmp(SDL_GetCurrentVideoDriver(), "uikit"), @"Actual SDL did not select UIKit"); + LOAD(GetString, "glGetString"); LOAD(GetError, "glGetError"); + LOAD(GenFramebuffers, "glGenFramebuffers"); LOAD(BindFramebuffer, "glBindFramebuffer"); + LOAD(DeleteFramebuffers, "glDeleteFramebuffers"); LOAD(CheckFramebufferStatus, "glCheckFramebufferStatus"); + LOAD(GenRenderbuffers, "glGenRenderbuffers"); LOAD(BindRenderbuffer, "glBindRenderbuffer"); + LOAD(RenderbufferStorage, "glRenderbufferStorage"); LOAD(FramebufferRenderbuffer, "glFramebufferRenderbuffer"); + LOAD(DeleteRenderbuffers, "glDeleteRenderbuffers"); LOAD(ClearColor, "glClearColor"); + LOAD(Clear, "glClear"); LOAD(Finish, "glFinish"); LOAD(ReadPixels, "glReadPixels"); + report[@"apple_provenance_verified"] = @YES; + host = [[EAGLContext alloc] initWithAPI:kEAGLRenderingAPIOpenGLES3]; + CHECK(host && host.API == kEAGLRenderingAPIOpenGLES3 && [EAGLContext setCurrentContext:host], + @"Actual Apple EAGL GLES3 context creation failed"); + report[@"apple_gles_client_version"] = @(host.API); + const GLubyte *renderer = gl.GetString(GL_RENDERER); + CHECK(renderer, @"Actual Apple context has no GL_RENDERER string"); + report[@"apple_renderer"] = [NSString stringWithUTF8String:(const char *)renderer] ?: @""; + gl.GenFramebuffers(1, &framebuffer); gl.BindFramebuffer(GL_FRAMEBUFFER, framebuffer); + gl.GenRenderbuffers(1, &renderbuffer); gl.BindRenderbuffer(GL_RENDERBUFFER, renderbuffer); + gl.RenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8, 16, 16); + gl.FramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, renderbuffer); + CHECK(gl.CheckFramebufferStatus(GL_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE, @"Actual Apple host FBO is incomplete"); + gl.ClearColor(0, 1, 1, 1); gl.Clear(GL_COLOR_BUFFER_BIT); gl.Finish(); + gl.ReadPixels(0, 0, 16, 16, GL_RGBA, GL_UNSIGNED_BYTE, pixels); + CHECK(gl.GetError() == GL_NO_ERROR && expectedColor(pixels, 0, 255, 255), @"Actual Apple host cyan pixels mismatch"); + report[@"apple_readback_verified"] = @YES; + CHECK(!aether_run_egl_probe(&metal) && metal.passed && metal.readback_verified && metal.client_version == 3, + [NSString stringWithFormat:@"Actual MetalANGLE pbuffer failed: %s", metal.error]); + report[@"metalangle_gles_client_version"] = @(metal.client_version); + report[@"metalangle_renderer"] = [NSString stringWithUTF8String:metal.gl_renderer] ?: @""; + report[@"metalangle_readback_verified"] = @YES; + CHECK([EAGLContext currentContext] == host, @"MetalANGLE changed Apple's current host EAGL context"); + report[@"apple_context_preserved"] = @YES; + CHECK(appleSymbol("glClear", sources, report), @"Actual host GL provenance changed after MetalANGLE rendering"); + gl.ReadPixels(0, 0, 16, 16, GL_RGBA, GL_UNSIGNED_BYTE, pixels); + CHECK(gl.GetError() == GL_NO_ERROR && expectedColor(pixels, 0, 255, 255), @"MetalANGLE changed actual Apple host pixels"); + report[@"apple_pixels_preserved"] = @YES; + gl.ClearColor(1, 0, 1, 1); gl.Clear(GL_COLOR_BUFFER_BIT); gl.Finish(); + gl.ReadPixels(0, 0, 16, 16, GL_RGBA, GL_UNSIGNED_BYTE, pixels); + CHECK(gl.GetError() == GL_NO_ERROR && expectedColor(pixels, 255, 0, 255), @"Apple host did not resume with fresh magenta pixels"); + report[@"apple_resume_verified"] = @YES; + *actualPixels = [NSData dataWithBytes:pixels length:sizeof(pixels)]; + CHECK(!SDL_GL_LoadLibrary(NULL), @"Actual SDL explicit GL load failed"); + SDL_GL_UnloadLibrary(); + CHECK(appleSymbol("glClear", sources, report), @"Actual SDL lost its initial preload reference"); + SDL_GL_UnloadLibrary(); + CHECK(!SDL_GL_GetProcAddress("glClear"), @"Actual SDL still returned GL after its final unload"); + SDL_ClearError(); + CHECK(!SDL_GL_LoadLibrary(NULL) && appleSymbol("glClear", sources, report), @"Actual SDL concrete Apple framework reload failed"); + report[@"load_unload_verified"] = @YES; + SDL_Quit(); + CHECK(!SDL_Init(SDL_INIT_VIDEO) && appleSymbol("glClear", sources, report), @"Actual SDL video reinitialization failed"); + report[@"video_reinitialization_verified"] = @YES; + report[@"passed"] = @YES; +cleanup: + if (host && gl.DeleteFramebuffers && gl.DeleteRenderbuffers) { + [EAGLContext setCurrentContext:host]; + if (framebuffer) gl.DeleteFramebuffers(1, &framebuffer); + if (renderbuffer) gl.DeleteRenderbuffers(1, &renderbuffer); + } + [EAGLContext setCurrentContext:previous]; + SDL_Quit(); + return report; +#undef LOAD +#undef CHECK +} + +@interface HostGLProbeDelegate : UIResponder +@property(nonatomic, strong) UIWindow *window; +@property(nonatomic, strong) UILabel *statusLabel; +@property(nonatomic, strong) UIImageView *imageView; +@end +@implementation HostGLProbeDelegate +- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)options { + (void)options; + self.window = [[UIWindow alloc] initWithFrame:UIScreen.mainScreen.bounds]; + UIViewController *controller = [UIViewController new]; + controller.view.backgroundColor = UIColor.blackColor; + self.statusLabel = [[UILabel alloc] initWithFrame:CGRectMake(24, 70, self.window.bounds.size.width - 48, 160)]; + self.statusLabel.textColor = UIColor.whiteColor; self.statusLabel.numberOfLines = 0; + self.statusLabel.text = @"Running actual UIKit SDL / Apple OpenGLES / MetalANGLE isolation…"; + self.imageView = [[UIImageView alloc] initWithFrame:CGRectMake(24, 250, 256, 256)]; + self.imageView.layer.magnificationFilter = kCAFilterNearest; + [controller.view addSubview:self.statusLabel]; [controller.view addSubview:self.imageView]; + self.window.rootViewController = controller; [self.window makeKeyAndVisible]; + dispatch_async(dispatch_get_main_queue(), ^{ + NSUInteger before = application.windows.count; + NSData *pixels = nil; + NSMutableDictionary *report = [runHostProbe(&pixels) mutableCopy]; + report[@"no_extra_uiwindow"] = @(before == application.windows.count); + report[@"windows_before"] = @(before); report[@"windows_after"] = @(application.windows.count); + if (![report[@"no_extra_uiwindow"] boolValue]) { + report[@"passed"] = @NO; + report[@"error"] = @"UIKit created an additional UIWindow"; + } + NSString *documents = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES).firstObject; + if (pixels) { + [pixels writeToFile:[documents stringByAppendingPathComponent:@"host-gl-probe.rgba"] atomically:YES]; + CGDataProviderRef provider = CGDataProviderCreateWithCFData((__bridge CFDataRef)pixels); + CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB(); + CGImageRef image = CGImageCreate(16, 16, 8, 32, 64, colorSpace, + kCGBitmapByteOrderDefault | kCGImageAlphaLast, provider, NULL, NO, kCGRenderingIntentDefault); + if (image) { + UIImage *shown = [UIImage imageWithCGImage:image]; self.imageView.image = shown; + [UIImagePNGRepresentation(shown) writeToFile:[documents stringByAppendingPathComponent:@"host-gl-probe.png"] atomically:YES]; + CGImageRelease(image); + } + CGColorSpaceRelease(colorSpace); CGDataProviderRelease(provider); + } + NSError *error = nil; + NSData *json = [NSJSONSerialization dataWithJSONObject:report options:NSJSONWritingPrettyPrinted error:&error]; + if (json) [json writeToFile:[documents stringByAppendingPathComponent:@"host-gl-probe.json"] atomically:YES]; + self.statusLabel.text = [report[@"passed"] boolValue] ? + @"PASS: real host SDL uses Apple GLES; MetalANGLE pixels remain separate.\nUnload/reload and fresh host pixels verified.\nRen’Py gameplay is not tested." : + [NSString stringWithFormat:@"FAIL: %@", report[@"error"]]; + NSLog(@"AETHER_HOST_GL_PROBE %@", [[NSString alloc] initWithData:json encoding:NSUTF8StringEncoding] ?: error.description); + }); + return YES; +} +@end + +int main(int argc, char **argv) { + @autoreleasepool { return UIApplicationMain(argc, argv, nil, NSStringFromClass(HostGLProbeDelegate.class)); } +} diff --git a/tools/renpy_ios_egl_probe/main.m b/tools/renpy_ios_egl_probe/main.m new file mode 100644 index 000000000..7263fe42f --- /dev/null +++ b/tools/renpy_ios_egl_probe/main.m @@ -0,0 +1,80 @@ +#import +#import +#include "egl_probe.h" + +@interface ProbeDelegate : UIResponder +@property(nonatomic, strong) UIWindow *window; +@property(nonatomic, strong) UILabel *statusLabel; +@property(nonatomic, strong) UIImageView *imageView; +@end + +@implementation ProbeDelegate +- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)options { + (void)options; + self.window = [[UIWindow alloc] initWithFrame:UIScreen.mainScreen.bounds]; + UIViewController *controller = [UIViewController new]; + controller.view.backgroundColor = UIColor.blackColor; + self.statusLabel = [[UILabel alloc] initWithFrame:CGRectMake(24, 70, self.window.bounds.size.width - 48, 150)]; + self.statusLabel.textColor = UIColor.whiteColor; + self.statusLabel.numberOfLines = 0; + self.statusLabel.text = @"Running MetalANGLE EGL pbuffer probe…"; + self.imageView = [[UIImageView alloc] initWithFrame:CGRectMake(24, 240, 256, 256)]; + self.imageView.layer.magnificationFilter = kCAFilterNearest; + [controller.view addSubview:self.statusLabel]; + [controller.view addSubview:self.imageView]; + self.window.rootViewController = controller; + [self.window makeKeyAndVisible]; + dispatch_async(dispatch_get_main_queue(), ^{ + const NSUInteger windowCount = application.windows.count; + aether_egl_probe_result result; + aether_run_egl_probe(&result); + const BOOL noExtraWindow = application.windows.count == windowCount; + if (!noExtraWindow) result.passed = 0; + NSString *documents = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES).firstObject; + NSMutableDictionary *report = [@{ + @"probe": @"MetalANGLE EGL pbuffer", @"gameplay_verified": @NO, + @"passed": @(result.passed), @"pbuffer_created": @(result.pbuffer_created), + @"readback_verified": @(result.readback_verified), + @"host_context_restored": @(result.host_context_restored), + @"resume_verified": @(result.resume_verified), @"no_extra_uiwindow": @(noExtraWindow), + @"sdl_terminal_cleanup_verified": @(result.sdl_terminal_cleanup_verified), + @"gles_client_version": @(result.client_version), + @"egl_error": @(result.egl_error), @"gl_error": @(result.gl_error), + @"error": [NSString stringWithUTF8String:result.error] ?: @"invalid UTF-8 diagnostic", + @"egl_vendor": [NSString stringWithUTF8String:result.egl_vendor] ?: @"", + @"egl_version": [NSString stringWithUTF8String:result.egl_version] ?: @"", + @"gl_vendor": [NSString stringWithUTF8String:result.gl_vendor] ?: @"", + @"gl_renderer": [NSString stringWithUTF8String:result.gl_renderer] ?: @"", + @"gl_version": [NSString stringWithUTF8String:result.gl_version] ?: @"", + @"pixel_width": @16, @"pixel_height": @16, + @"pixels_origin": @"bottom-left", @"thread": @"UIKit main thread", + @"windows_before": @(windowCount), @"windows_after": @(application.windows.count) + } mutableCopy]; + NSData *pixels = [NSData dataWithBytes:result.pixels length:sizeof(result.pixels)]; + [pixels writeToFile:[documents stringByAppendingPathComponent:@"egl-probe.rgba"] atomically:YES]; + CGDataProviderRef provider = CGDataProviderCreateWithCFData((__bridge CFDataRef)pixels); + CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB(); + CGImageRef image = CGImageCreate(16, 16, 8, 32, 64, colorSpace, + kCGBitmapByteOrderDefault | kCGImageAlphaLast, provider, NULL, NO, kCGRenderingIntentDefault); + if (image) { + UIImage *shown = [UIImage imageWithCGImage:image]; + self.imageView.image = shown; + [UIImagePNGRepresentation(shown) writeToFile:[documents stringByAppendingPathComponent:@"egl-probe.png"] atomically:YES]; + CGImageRelease(image); + } + CGColorSpaceRelease(colorSpace); + CGDataProviderRelease(provider); + NSError *error = nil; + NSData *json = [NSJSONSerialization dataWithJSONObject:report options:NSJSONWritingPrettyPrinted error:&error]; + if (json) [json writeToFile:[documents stringByAppendingPathComponent:@"egl-probe.json"] atomically:YES]; + self.statusLabel.text = result.passed ? @"PASS: actual EGL pbuffer pixels read back.\nHost context preserved; fresh pixels after resume.\nRen’Py gameplay is not tested." : + [NSString stringWithFormat:@"FAIL: %@\nEGL=0x%x GL=0x%x", report[@"error"], result.egl_error, result.gl_error]; + NSLog(@"AETHER_EGL_PROBE %@", [[NSString alloc] initWithData:json encoding:NSUTF8StringEncoding] ?: error.description); + }); + return YES; +} +@end + +int main(int argc, char **argv) { + @autoreleasepool { return UIApplicationMain(argc, argv, nil, NSStringFromClass(ProbeDelegate.class)); } +} diff --git a/tools/renpy_ios_egl_probe/run_host_gl_probe.sh b/tools/renpy_ios_egl_probe/run_host_gl_probe.sh new file mode 100755 index 000000000..80baa752d --- /dev/null +++ b/tools/renpy_ios_egl_probe/run_host_gl_probe.sh @@ -0,0 +1,170 @@ +#!/usr/bin/env bash +# Compile actual UIKit SDL2 and exercise its Apple GL lookup beside MetalANGLE. +set -euo pipefail +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +renpy_build="" +host_sdl="" +output="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/aether-renpy-ios-host-gl-probe" +while (($#)); do + case "$1" in + --renpy-build) renpy_build="$2"; shift 2 ;; + --host-sdl-source) host_sdl="$2"; shift 2 ;; + --output) output="$2"; shift 2 ;; + *) echo "Usage: $0 --renpy-build PATH --host-sdl-source PATH [--output PATH]" >&2; exit 2 ;; + esac +done +[[ "$(uname -s)" == Darwin ]] || { echo 'Actual UIKit/Apple GLES requires macOS/Xcode; no substitute API is used.' >&2; exit 2; } +[[ "$(git -C "$host_sdl" rev-parse HEAD)" == 5d249570393f7a37e037abf22cd6012a4cc56a71 ]] || { + echo 'Expected the actual SDL2.32.10 release commit used by the host port.' >&2; exit 1; +} +archive="$renpy_build/source/MetalANGLE.framework.ios.simulator.zip" +[[ -f "$archive" ]] || { echo "Missing actual simulator MetalANGLE framework: $archive" >&2; exit 1; } +mkdir -p "$output/input" "$output/evidence" +unzip -q -o "$archive" -d "$output/input" +framework="$output/input/MetalANGLE.framework" +arch="$(uname -m)" +sdk="$(xcrun --sdk iphonesimulator --show-sdk-path)" +xcrun lipo "$framework/MetalANGLE" -verify_arch "$arch" +xcodebuild -version > "$output/evidence/xcode-version.txt" +printf '%s\n' "$sdk" > "$output/evidence/sdk-path.txt" +shasum -a 256 "$archive" > "$output/evidence/framework-archive.sha256" +git -C "$host_sdl" rev-parse HEAD > "$output/evidence/host-sdl-source-sha.txt" +[[ ! -e "$output/host-sdl-source" ]] || { echo 'Use a fresh output directory for this actual source build.' >&2; exit 1; } +cp -R "$host_sdl" "$output/host-sdl-source" +python3 - "$repo_root" "$output" <<'PY' +import pathlib, re, subprocess, sys +repo, out = map(pathlib.Path, sys.argv[1:]) +port = repo/'vcpkg/ports/sdl2' +source = out/'host-sdl-source' +section = (port/'portfile.cmake').read_text().split(' PATCHES\n', 1)[1].split('\n)', 1)[0] +names = re.findall(r'^\s+([\w.-]+\.(?:patch|diff))\s*$', section, re.M) +if 'aetherkiri-ios-opengles-isolation.patch' not in names: + raise SystemExit('Host port does not apply the actual Apple GLES isolation patch') +evidence = out/'evidence/applied-host-sdl-patches'; evidence.mkdir() +for name in names: + patch = port/name + subprocess.run(['git', 'apply', '--check', str(patch)], cwd=source, check=True) + subprocess.run(['git', 'apply', str(patch)], cwd=source, check=True) + (evidence/name).write_bytes(patch.read_bytes()) + print('Applied actual host SDL port patch:', name) +PY +if ! cmake -S "$output/host-sdl-source" -B "$output/host-sdl-build" -G Ninja \ + -DCMAKE_SYSTEM_NAME=iOS -DCMAKE_OSX_SYSROOT="$sdk" \ + -DCMAKE_OSX_ARCHITECTURES="$arch" -DCMAKE_OSX_DEPLOYMENT_TARGET=15.0 \ + -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX="$output/host-sdl-install" \ + -DSDL_STATIC=ON -DSDL_SHARED=OFF -DSDL_TEST=OFF -DSDL_TESTS=OFF \ + -DSDL_VULKAN=OFF -DSDL_METAL=OFF -DSDL_RENDER_METAL=OFF \ + -DSDL_HIDAPI=OFF -DSDL_JOYSTICK=OFF -DSDL_SENSOR=OFF -DSDL_HAPTIC=OFF -DSDL_POWER=OFF \ + > "$output/evidence/host-sdl-configure.log" 2>&1; then + tail -100 "$output/evidence/host-sdl-configure.log" >&2; exit 1 +fi +if ! cmake --build "$output/host-sdl-build" --parallel "$(sysctl -n hw.ncpu)" \ + > "$output/evidence/host-sdl-build.log" 2>&1; then + tail -100 "$output/evidence/host-sdl-build.log" >&2; exit 1 +fi +cmake --install "$output/host-sdl-build" > "$output/evidence/host-sdl-install.log" 2>&1 +library="$output/host-sdl-install/lib/libSDL2.a" +[[ -f "$library" ]] || { echo 'Actual host SDL static archive is missing.' >&2; exit 1; } +config="$output/host-sdl-install/include/SDL2/SDL_config.h" +cp "$config" "$output/evidence/SDL_config.h" +grep '^#define SDL_VIDEO_DRIVER_UIKIT 1' "$config" +grep '^#define SDL_VIDEO_OPENGL_ES2 1' "$config" +xcrun nm -a "$library" > "$output/evidence/host-sdl-symbols.txt" +grep '_UIKit_GL_UnloadLibrary' "$output/evidence/host-sdl-symbols.txt" +app="$output/AetherRenPyHostGLProbe.app" +mkdir -p "$app/Frameworks" +cp -R "$framework" "$app/Frameworks/" +embedded="$app/Frameworks/MetalANGLE.framework" +if [[ "$(xcrun lipo "$embedded/MetalANGLE" -archs)" == *" "* ]]; then + xcrun lipo "$embedded/MetalANGLE" -thin "$arch" -output "$output/MetalANGLE.thin" + mv "$output/MetalANGLE.thin" "$embedded/MetalANGLE" +fi +codesign --force --sign - "$embedded" +cat > "$app/Info.plist" <<'PLIST' + + + +CFBundleIdentifierorg.aetherkiri.renpy-host-gl-probe +CFBundleExecutableAetherRenPyHostGLProbe +CFBundleNameAether RenPy Host GL Probe +CFBundlePackageTypeAPPL +CFBundleSupportedPlatformsiPhoneSimulator +CFBundleVersion1 +CFBundleShortVersionString1.0 +MinimumOSVersion15.0 +UIDeviceFamily12 +UILaunchScreen +UISupportedInterfaceOrientationsUIInterfaceOrientationPortrait + +PLIST +xcrun --sdk iphonesimulator clang -target "$arch-apple-ios15.0-simulator" -isysroot "$sdk" \ + -Wall -Wextra -Werror -I"$framework/Headers" \ + -c "$repo_root/tools/renpy_ios_egl_probe/egl_probe.c" -o "$output/egl_probe.o" +# Intentionally load both GL implementations in this process. Host SDL must +# select Apple by its concrete handle even though global GLES symbols coexist. +xcrun --sdk iphonesimulator clang -target "$arch-apple-ios15.0-simulator" -isysroot "$sdk" \ + -Wall -Wextra -Werror -Wno-deprecated-declarations -fobjc-arc \ + -I"$output/host-sdl-install/include/SDL2" -F"$(dirname "$framework")" \ + "$repo_root/tools/renpy_ios_egl_probe/host_gl_probe.m" "$output/egl_probe.o" "$library" \ + -framework MetalANGLE -framework OpenGLES -framework UIKit -framework Foundation \ + -framework CoreGraphics -framework CoreVideo -framework QuartzCore -framework CoreFoundation \ + -framework CoreAudio -framework AudioToolbox -framework AVFoundation -framework CoreMotion \ + -framework GameController -framework CoreHaptics -framework Metal -framework MetalKit -liconv \ + -Wl,-rpath,@executable_path/Frameworks -o "$app/AetherRenPyHostGLProbe" +codesign --force --sign - "$app" +xcrun otool -L "$app/AetherRenPyHostGLProbe" > "$output/evidence/app-linked-libraries.txt" +xcrun simctl list runtimes --json > "$output/evidence/simulator-runtimes.json" +xcrun simctl list devicetypes --json > "$output/evidence/simulator-device-types.json" +read -r runtime device_type < <(python3 - "$output/evidence" <<'PY' +import json, pathlib, sys +p = pathlib.Path(sys.argv[1]) +available = [r for r in json.loads((p/'simulator-runtimes.json').read_text())['runtimes'] + if r.get('isAvailable') and '.iOS-' in r['identifier']] +if not available: + raise SystemExit('The cloud runner has no actual iOS Simulator runtime') +runtime = max(available, key=lambda r: tuple(int(i) for i in r['version'].split('.'))) +devices = json.loads((p/'simulator-device-types.json').read_text())['devicetypes'] +for name in ('iPhone 13', 'iPhone 14', 'iPhone 15', 'iPhone 16'): + device = next((d for d in devices if d['name'] == name), None) + if device: + print(runtime['identifier'], device['identifier']); break +else: + raise SystemExit('No supported iPhone Simulator device type') +PY +) +udid="$(xcrun simctl create "Aether RenPy host GL probe ${GITHUB_RUN_ID:-$$}" "$device_type" "$runtime")" +cleanup() { + xcrun simctl shutdown "$udid" >/dev/null 2>&1 || true + xcrun simctl delete "$udid" >/dev/null 2>&1 || true +} +trap cleanup EXIT +xcrun simctl boot "$udid" +xcrun simctl bootstatus "$udid" -b +xcrun simctl install "$udid" "$app" +xcrun simctl launch --terminate-running-process "$udid" org.aetherkiri.renpy-host-gl-probe | tee "$output/evidence/simulator-launch.txt" +container="$(xcrun simctl get_app_container "$udid" org.aetherkiri.renpy-host-gl-probe data)" +for ((attempt=0; attempt<90; ++attempt)); do + if [[ -f "$container/Documents/host-gl-probe.json" ]]; then break; fi + sleep 1 +done +if [[ ! -f "$container/Documents/host-gl-probe.json" ]]; then + xcrun simctl spawn "$udid" log show --last 3m --style compact > "$output/evidence/simulator-system.log" 2>&1 || true + echo 'Actual UIKit/GL probe produced no report; inspect its crash/dyld/Metal evidence.' >&2; exit 1 +fi +cp "$container/Documents/host-gl-probe."* "$output/evidence/" +xcrun simctl io "$udid" screenshot "$output/evidence/simulator-screen.png" +python3 - "$output/evidence/host-gl-probe.json" <<'PY' +import json, pathlib, sys +report = json.loads(pathlib.Path(sys.argv[1]).read_text()) +print(json.dumps(report, indent=2)) +required = ('passed', 'apple_provenance_verified', 'apple_readback_verified', + 'metalangle_readback_verified', 'apple_context_preserved', 'apple_pixels_preserved', + 'apple_resume_verified', 'load_unload_verified', 'video_reinitialization_verified', 'no_extra_uiwindow') +if not all(report.get(k) for k in required): + raise SystemExit('Actual UIKit SDL / Apple GLES / MetalANGLE isolation failed') +if report.get('sdl_version') != '2.32.10': + raise SystemExit('Probe did not use the actual host SDL2.32.10') +if report.get('apple_gles_client_version') != 3 or report.get('metalangle_gles_client_version') != 3: + raise SystemExit('Probe did not render with both actual Apple and MetalANGLE GLES3 contexts') +print('Actual UIKit SDL selects Apple GLES; independent real GPU pixels and GL lifecycle passed. RenPy gameplay remains unverified.') +PY diff --git a/tools/renpy_ios_egl_probe/run_sdl_probe.sh b/tools/renpy_ios_egl_probe/run_sdl_probe.sh new file mode 100755 index 000000000..d94c0c0f7 --- /dev/null +++ b/tools/renpy_ios_egl_probe/run_sdl_probe.sh @@ -0,0 +1,216 @@ +#!/usr/bin/env bash +# Builds patched upstream SDL2, then runs its real offscreen EGL/GLES path in iOS Simulator. +# Requires a cloud macOS runner with Xcode and a downloaded simulator runtime. +set -euo pipefail +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +renpy_build="" +output="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/aether-renpy-ios-sdl-probe" +framework="" +while (($#)); do + case "$1" in + --renpy-build) renpy_build="$2"; shift 2 ;; + --framework) framework="$2"; shift 2 ;; + --output) output="$2"; shift 2 ;; + *) echo "Usage: $0 --renpy-build PATH [--framework PATH] [--output PATH]" >&2; exit 2 ;; + esac +done +[[ "$(uname -s)" == Darwin ]] || { echo 'This real iOS Simulator probe requires macOS/Xcode; no simulation fallback is provided.' >&2; exit 2; } +mkdir -p "$output/input" "$output/evidence" +if [[ -z "$framework" ]]; then + archive="$renpy_build/source/MetalANGLE.framework.ios.simulator.zip" + [[ -f "$archive" ]] || { echo "Missing pinned Ren'Py simulator framework: $archive" >&2; exit 1; } + unzip -q -o "$archive" -d "$output/input" + framework="$output/input/MetalANGLE.framework" + shasum -a 256 "$archive" > "$output/evidence/framework-archive.sha256" +fi +[[ -f "$framework/MetalANGLE" ]] || { echo "Invalid MetalANGLE framework: $framework" >&2; exit 1; } +arch="$(uname -m)" +xcrun lipo "$framework/MetalANGLE" -verify_arch "$arch" +xcrun lipo "$framework/MetalANGLE" -archs > "$output/evidence/framework-architectures.txt" +xcodebuild -version > "$output/evidence/xcode-version.txt" +xcrun --sdk iphonesimulator --show-sdk-path > "$output/evidence/sdk-path.txt" +# Build the actual SDL source used by Ren'Py, with every upstream patch and +# both real mobile offscreen patches. No framework or SDL API is simulated. +[[ -f "$renpy_build/source/SDL2-2.0.20.tar.gz" ]] || { echo 'Missing pinned SDL2 source archive.' >&2; exit 1; } +command -v autoconf >/dev/null || { echo 'The cloud runner needs autoconf to regenerate the patched SDL configure.' >&2; exit 1; } +mkdir -p "$output/sdl-source" "$output/sdl-build" "$output/sdl-install" +tar xzf "$renpy_build/source/SDL2-2.0.20.tar.gz" -C "$output/sdl-source" +python3 - "$repo_root" "$renpy_build" "$output" <<'PY' +import pathlib, subprocess, sys +repo, upstream, out = map(pathlib.Path, sys.argv[1:]) +patches = {p.name: p.read_bytes() for p in (upstream/'patches/SDL2-2.0.20').iterdir() + if p.suffix in ('.diff', '.patch')} +# Extract the real new SDL patches from the reviewable renpy-build patches. +# They add files; reject edits to existing files instead of approximating them. +for name in ('0004-android-offscreen-renderer.patch', '0006-ios-offscreen-renderer.patch'): + lines = (repo/'bridge/renpy_runtime/mobile_launcher/patches/native'/name).read_text().splitlines(True) + for start, line in enumerate(lines): + if not line.startswith('diff --git a/patches/SDL2-2.0.20/'): + continue + end = next((i for i in range(start + 1, len(lines)) if lines[i].startswith('diff --git ')), len(lines)) + section = lines[start:end] + if 'new file mode 100644\n' not in section: + raise SystemExit('Expected a real added SDL patch file: ' + name) + filename = line.split()[3].removeprefix('b/patches/SDL2-2.0.20/') + # Outer +++ metadata precedes its first hunk. Inside that hunk every + # added line belongs to the new patch, including nested +++ headers. + # Dropping those headers happens to work with GNU patch's git-name + # inference but BSD patch cannot create the new Objective-C file. + first_hunk = next(i for i, l in enumerate(section) if l.startswith('@@ ')) + body = ''.join(l[1:] for l in section[first_hunk + 1:] if l.startswith('+')) + patches[filename] = body.encode() +evidence = out/'evidence/applied-sdl-patches'; evidence.mkdir(exist_ok=True) +for name, body in sorted(patches.items()): + (evidence/name).write_bytes(body) + p = subprocess.run(['patch', '--batch', '-p1'], input=body, + cwd=out/'sdl-source/SDL2-2.0.20', stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + if p.returncode: + sys.stderr.buffer.write(p.stdout) + raise SystemExit('Actual upstream SDL patch failed: ' + name) +print('Applied every pinned upstream SDL patch plus mobile EGL pbuffer patches.') +PY +sdl_source="$output/sdl-source/SDL2-2.0.20" +if ! (cd "$sdl_source" && ./autogen.sh) > "$output/evidence/sdl-autogen.log" 2>&1; then + tail -80 "$output/evidence/sdl-autogen.log" >&2 + exit 1 +fi +sdk="$(xcrun --sdk iphonesimulator --show-sdk-path)" +if [[ "$arch" == arm64 ]]; then sdl_host=arm-ios-darwin21; else sdl_host=x86_64-ios-darwin21; fi +if ! ( + set -e + cd "$output/sdl-build" + CC="$(xcrun --sdk iphonesimulator --find clang)" \ + CFLAGS="-target $arch-apple-ios15.0-simulator -isysroot $sdk -O1 -fobjc-arc -DSDL_MAIN_HANDLED -DMETALANGLE -I$framework/Headers -F$(dirname "$framework")" \ + CPPFLAGS="-I$framework/Headers -DMETALANGLE" \ + LDFLAGS="-target $arch-apple-ios15.0-simulator -isysroot $sdk -F$(dirname "$framework") -framework MetalANGLE" \ + ac_cv_header_libunwind_h=no \ + "$sdl_source/configure" --host="$sdl_host" --disable-shared --prefix="$output/sdl-install" \ + --enable-video-offscreen --disable-video-x11 --disable-video-wayland \ + --disable-render-metal --disable-video-vulkan --disable-video-kmsdrm \ + --disable-hidapi --disable-joystick --disable-sensor --disable-power --disable-haptic || exit $? + make -j"$(sysctl -n hw.ncpu)" || exit $? + make install || exit $? +) > "$output/evidence/sdl-build.log" 2>&1; then + tail -100 "$output/evidence/sdl-build.log" >&2 + exit 1 +fi +cp "$output/sdl-build/include/SDL_config.h" "$output/evidence/SDL_config.h" +grep '^#define SDL_VIDEO_OPENGL_EGL 1' "$output/evidence/SDL_config.h" +grep '^#define SDL_VIDEO_DRIVER_OFFSCREEN 1' "$output/evidence/SDL_config.h" +if grep '^#define SDL_VIDEO_DRIVER_UIKIT 1' "$output/evidence/SDL_config.h"; then + echo 'Embedded SDL must not compile its process-global UIKit classes.' >&2 + exit 1 +fi +xcrun nm -a "$output/sdl-install/lib/libSDL2.a" > "$output/evidence/sdl-symbols.txt" +python3 - "$output/evidence/sdl-symbols.txt" <<'PY' +import pathlib, re, sys +symbols = pathlib.Path(sys.argv[1]).read_text() +collisions = re.findall(r'OBJC_CLASS_\$_SDL\w+\b', symbols) +if collisions: + raise SystemExit('RenPy SDL still defines host-conflicting Objective-C classes: ' + ', '.join(sorted(set(collisions)))) +if 'OBJC_CLASS_$_AetherRenpySDLInterruptionListener' not in symbols: + raise SystemExit('Expected the real retained CoreAudio implementation with its isolated Objective-C listener.') +print('Actual SDL archive excludes UIKit classes and preserves renamed CoreAudio listener.') +PY +xcrun lipo "$output/sdl-install/lib/libSDL2.a" -archs > "$output/evidence/sdl-architectures.txt" +app="$output/AetherRenPySDLProbe.app" +mkdir -p "$app/Frameworks" +cp -R "$framework" "$app/Frameworks/" +embedded="$app/Frameworks/MetalANGLE.framework" +if [[ "$(xcrun lipo "$embedded/MetalANGLE" -archs)" == *" "* ]]; then + xcrun lipo "$embedded/MetalANGLE" -thin "$arch" -output "$output/MetalANGLE.thin" + mv "$output/MetalANGLE.thin" "$embedded/MetalANGLE" +fi +codesign --force --sign - "$embedded" +cat > "$app/Info.plist" <<'PLIST' + + + +CFBundleIdentifierorg.aetherkiri.renpy-sdl-probe +CFBundleExecutableAetherRenPySDLProbe +CFBundleNameAether RenPy SDL Probe +CFBundlePackageTypeAPPL +CFBundleSupportedPlatformsiPhoneSimulator +CFBundleVersion1 +CFBundleShortVersionString1.0 +MinimumOSVersion15.0 +UIDeviceFamily12 +UILaunchScreen +UISupportedInterfaceOrientationsUIInterfaceOrientationPortrait + +PLIST +sdk="$(xcrun --sdk iphonesimulator --show-sdk-path)" +xcrun --sdk iphonesimulator clang -target "$arch-apple-ios15.0-simulator" -isysroot "$sdk" \ + -Wall -Wextra -Werror -I"$framework/Headers" -I"$output/sdl-install/include/SDL2" \ + -c "$repo_root/tools/renpy_ios_egl_probe/sdl_probe.c" -o "$output/egl_probe.o" +xcrun --sdk iphonesimulator clang -target "$arch-apple-ios15.0-simulator" -isysroot "$sdk" \ + -Wall -Wextra -Werror -Wno-deprecated-declarations -fobjc-arc \ + -I"$framework/Headers" -F"$(dirname "$framework")" \ + "$repo_root/tools/renpy_ios_egl_probe/main.m" "$output/egl_probe.o" "$output/sdl-install/lib/libSDL2.a" \ + -framework MetalANGLE -framework UIKit -framework Foundation -framework QuartzCore \ + -framework CoreFoundation -framework CoreAudio -framework AudioToolbox -framework AVFoundation \ + -framework CoreGraphics -framework CoreMotion -framework GameController -framework Metal -framework OpenGLES -liconv \ + -Wl,-rpath,@executable_path/Frameworks -o "$app/AetherRenPySDLProbe" +codesign --force --sign - "$app" +xcrun otool -L "$app/AetherRenPySDLProbe" > "$output/evidence/app-linked-libraries.txt" +xcrun simctl list runtimes --json > "$output/evidence/simulator-runtimes.json" +xcrun simctl list devicetypes --json > "$output/evidence/simulator-device-types.json" +read -r runtime device_type < <(python3 - "$output/evidence" <<'PY' +import json, pathlib, sys +p = pathlib.Path(sys.argv[1]) +available = [r for r in json.loads((p/'simulator-runtimes.json').read_text())['runtimes'] + if r.get('isAvailable') and '.iOS-' in r['identifier']] +if not available: + raise SystemExit('No available iOS Simulator runtime; install one with Xcode on this cloud runner.') +runtime = max(available, key=lambda r: tuple(int(i) for i in r['version'].split('.'))) +devices = json.loads((p/'simulator-device-types.json').read_text())['devicetypes'] +for name in ('iPhone 13', 'iPhone 14', 'iPhone 15', 'iPhone 16'): + device = next((d for d in devices if d['name'] == name), None) + if device: + print(runtime['identifier'], device['identifier']) + break +else: + raise SystemExit('No supported recent iPhone Simulator device type was found.') +PY +) +udid="$(xcrun simctl create "Aether RenPy SDL probe ${GITHUB_RUN_ID:-$$}" "$device_type" "$runtime")" +cleanup() { + xcrun simctl shutdown "$udid" >/dev/null 2>&1 || true + xcrun simctl delete "$udid" >/dev/null 2>&1 || true +} +trap cleanup EXIT +xcrun simctl boot "$udid" +xcrun simctl bootstatus "$udid" -b +xcrun simctl install "$udid" "$app" +xcrun simctl launch --terminate-running-process "$udid" org.aetherkiri.renpy-sdl-probe | tee "$output/evidence/simulator-launch.txt" +container="$(xcrun simctl get_app_container "$udid" org.aetherkiri.renpy-sdl-probe data)" +for ((attempt=0; attempt<90; ++attempt)); do + if [[ -f "$container/Documents/egl-probe.json" ]]; then break; fi + sleep 1 +done +if [[ ! -f "$container/Documents/egl-probe.json" ]]; then + xcrun simctl spawn "$udid" log show --last 3m --style compact > "$output/evidence/simulator-system.log" 2>&1 || true + echo 'Probe did not produce a report; inspect launch/system logs for dyld or Metal failures.' >&2 + exit 1 +fi +cp "$container/Documents/egl-probe."* "$output/evidence/" +xcrun simctl io "$udid" screenshot "$output/evidence/simulator-screen.png" +python3 - "$output/evidence/egl-probe.json" <<'PY' +import json, pathlib, sys +report_path = pathlib.Path(sys.argv[1]) +report = json.loads(report_path.read_text()) +report['probe'] = 'patched SDL2.0.20 offscreen MetalANGLE EGL pbuffer' +report['source_version'] = 'SDL2.0.20' +report['assertions'] = ['SDL_Init video driver=offscreen', 'SDL_CreateWindow EGL pbuffer', + 'SDL_GL_CreateContext', 'actual GLES pixels', 'host context preserved', + 'resume produces new pixels', 'SDL pointer down/up and UTF-8 text delivery', + 'private SDL window/context/video destruction preserves host GPU resources and fresh pixels', + 'no additional UIWindow'] +report_path.write_text(json.dumps(report, indent=2) + '\n') +print(json.dumps(report, indent=2)) +required = ('passed', 'pbuffer_created', 'readback_verified', 'host_context_restored', 'resume_verified', + 'sdl_terminal_cleanup_verified', 'no_extra_uiwindow') +if not all(report.get(k) for k in required): + raise SystemExit('Actual iOS EGL/GLES probe failed; this is not gameplay acceptance.') +print('Actual patched SDL iOS offscreen/GLES, pointer and UTF-8 event probe passed; Ren\'Py gameplay remains unverified.') +PY diff --git a/tools/renpy_ios_egl_probe/sdl_probe.c b/tools/renpy_ios_egl_probe/sdl_probe.c new file mode 100644 index 000000000..bf03531a3 --- /dev/null +++ b/tools/renpy_ios_egl_probe/sdl_probe.c @@ -0,0 +1,260 @@ +/* Actual SDL2 offscreen integration probe, not a replacement graphics API. + * Builds against the pinned and patched upstream SDL2.0.20 static library. */ +#define SDL_MAIN_HANDLED 1 +#include +#define aether_run_egl_probe aether_run_egl_baseline +#include "egl_probe.c" +#undef aether_run_egl_probe +#ifdef __APPLE__ +#include +#include +#include +#endif + +int aether_run_egl_probe(aether_egl_probe_result *out) { + if (aether_run_egl_baseline(out)) return -1; + out->passed = out->pbuffer_created = out->readback_verified = 0; + out->host_context_restored = out->resume_verified = 0; + const EGLDisplay previous_display = eglGetCurrentDisplay(); + const EGLContext previous_context = eglGetCurrentContext(); + const EGLSurface previous_draw = eglGetCurrentSurface(EGL_DRAW); + const EGLSurface previous_read = eglGetCurrentSurface(EGL_READ); + const EGLenum previous_api = eglQueryAPI(); + EGLDisplay display = EGL_NO_DISPLAY; + EGLSurface host_surface = EGL_NO_SURFACE; + EGLContext host_context = EGL_NO_CONTEXT; + SDL_Window *window = NULL; + SDL_GLContext context = NULL; + GLuint host_texture = 0, host_fbo = 0, host_program = 0; + GLuint host_vertex = 0, host_fragment = 0; +#define SDL_CHECK(condition, message) do { if (!(condition)) { \ + snprintf(out->error, sizeof(out->error), "%s: %s", message, SDL_GetError()); \ + out->egl_error = eglGetError(); out->gl_error = glGetError(); goto cleanup; \ +} } while (0) +#ifdef __APPLE__ + CFURLRef frameworks = CFBundleCopyPrivateFrameworksURL(CFBundleGetMainBundle()); + CFURLRef framework = frameworks ? CFURLCreateCopyAppendingPathComponent( + kCFAllocatorDefault, frameworks, CFSTR("MetalANGLE.framework"), 1) : NULL; + CFURLRef library = framework ? CFURLCreateCopyAppendingPathComponent( + kCFAllocatorDefault, framework, CFSTR("MetalANGLE"), 0) : NULL; + char framework_path[PATH_MAX]; + const int valid = library && CFURLGetFileSystemRepresentation( + library, 1, (UInt8 *)framework_path, sizeof(framework_path)); + if (library) CFRelease(library); + if (framework) CFRelease(framework); + if (frameworks) CFRelease(frameworks); + SDL_CHECK(valid, "resolve actually embedded MetalANGLE framework"); + SDL_CHECK(!setenv("SDL_VIDEO_GL_DRIVER", framework_path, 1) && + !setenv("SDL_VIDEO_EGL_DRIVER", framework_path, 1), "set actual MetalANGLE loader paths"); +#endif + SDL_CHECK(!SDL_setenv("SDL_VIDEODRIVER", "offscreen", 1) && + !SDL_setenv("AETHER_RENPY_EMBEDDED", "1", 1), "select embedded offscreen SDL backend"); + SDL_SetMainReady(); + SDL_CHECK(SDL_Init(SDL_INIT_VIDEO | SDL_INIT_EVENTS) == 0, "SDL_Init offscreen"); + SDL_CHECK(SDL_GetCurrentVideoDriver() && !strcmp(SDL_GetCurrentVideoDriver(), "offscreen"), + "SDL selected a driver other than offscreen"); + 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_RED_SIZE, 8); + SDL_GL_SetAttribute(SDL_GL_GREEN_SIZE, 8); + SDL_GL_SetAttribute(SDL_GL_BLUE_SIZE, 8); + SDL_GL_SetAttribute(SDL_GL_ALPHA_SIZE, 8); + SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 0); + window = SDL_CreateWindow("Aether RenPy offscreen", 0, 0, 16, 16, SDL_WINDOW_OPENGL | SDL_WINDOW_HIDDEN); + SDL_CHECK(window, "SDL_CreateWindow actual offscreen pbuffer"); + context = SDL_GL_CreateContext(window); + out->client_version = 3; + if (!context) { + SDL_ClearError(); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 2); + context = SDL_GL_CreateContext(window); + out->client_version = 2; + } + SDL_CHECK(context, "SDL_GL_CreateContext"); + display = eglGetCurrentDisplay(); + const EGLSurface game_surface = eglGetCurrentSurface(EGL_DRAW); + SDL_CHECK(display != EGL_NO_DISPLAY && game_surface != EGL_NO_SURFACE, + "SDL did not bind an actual EGL pbuffer"); + copy_string(out->gl_vendor, sizeof(out->gl_vendor), (const char *)glGetString(GL_VENDOR)); + copy_string(out->gl_renderer, sizeof(out->gl_renderer), (const char *)glGetString(GL_RENDERER)); + copy_string(out->gl_version, sizeof(out->gl_version), (const char *)glGetString(GL_VERSION)); + /* This actual framework reports ANGLE 850c87ba5b74. Its SurfaceMtl + getWidth/getHeight return zero until OffscreenSurfaceMtl creates the + color texture on the first framebuffer attachment operation. Draw to + the real default framebuffer before querying its allocated dimensions; + still require the requested size and every RGBA pixel below. */ + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glViewport(0, 0, 16, 16); + glDisable(GL_SCISSOR_TEST); + glClearColor(0, 0, 0, 1); + glClear(GL_COLOR_BUFFER_BIT); + glFinish(); + SDL_CHECK(glGetError() == GL_NO_ERROR, "initialize actual SDL pbuffer color attachment"); + EGLint width = 0, height = 0; + const EGLBoolean queried_width = eglQuerySurface(display, game_surface, EGL_WIDTH, &width); + const EGLint width_error = eglGetError(); + const EGLBoolean queried_height = eglQuerySurface(display, game_surface, EGL_HEIGHT, &height); + const EGLint height_error = eglGetError(); + if (!queried_width || !queried_height || width != 16 || height != 16) { + int window_width = 0, window_height = 0, drawable_width = 0, drawable_height = 0; + SDL_GetWindowSize(window, &window_width, &window_height); + SDL_GL_GetDrawableSize(window, &drawable_width, &drawable_height); + snprintf(out->error, sizeof(out->error), + "SDL pbuffer dimensions: EGL=%dx%d query=%u/%u errors=0x%x/0x%x; window=%dx%d drawable=%dx%d; requested=16x16", + width, height, queried_width, queried_height, width_error, height_error, + window_width, window_height, drawable_width, drawable_height); + out->egl_error = width_error != EGL_SUCCESS ? width_error : height_error; + out->gl_error = glGetError(); + goto cleanup; + } + out->pbuffer_created = 1; + const EGLint config_attributes[] = { + EGL_SURFACE_TYPE, EGL_PBUFFER_BIT, EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT, + EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8, EGL_BLUE_SIZE, 8, EGL_ALPHA_SIZE, 8, EGL_NONE + }; + EGLConfig config = NULL; + EGLint count = 0; + const EGLint surface_attributes[] = { EGL_WIDTH, 16, EGL_HEIGHT, 16, EGL_NONE }; + const EGLint context_attributes[] = { EGL_CONTEXT_CLIENT_VERSION, 2, EGL_NONE }; + SDL_CHECK(eglChooseConfig(display, config_attributes, &config, 1, &count) && count == 1, + "host pbuffer config"); + host_surface = eglCreatePbufferSurface(display, config, surface_attributes); + host_context = eglCreateContext(display, config, EGL_NO_CONTEXT, context_attributes); + SDL_CHECK(host_surface != EGL_NO_SURFACE && host_context != EGL_NO_CONTEXT, "host pbuffer/context"); + SDL_CHECK(eglMakeCurrent(display, host_surface, host_surface, host_context), "bind host context"); + glClearColor(0, 1, 1, 1); glClear(GL_COLOR_BUFFER_BIT); + /* Keep independent host GPU resources alive through the SDL teardown. + They belong to the same EGLDisplay but do not share a GL context. */ + glGenTextures(1, &host_texture); + glBindTexture(GL_TEXTURE_2D, host_texture); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 16, 16, 0, GL_RGBA, GL_UNSIGNED_BYTE, NULL); + glGenFramebuffers(1, &host_fbo); + glBindFramebuffer(GL_FRAMEBUFFER, host_fbo); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, host_texture, 0); + SDL_CHECK(glCheckFramebufferStatus(GL_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE, + "independent host sentinel framebuffer"); + glClearColor(0, 1, 1, 1); glClear(GL_COLOR_BUFFER_BIT); + glBindFramebuffer(GL_FRAMEBUFFER, 0); + const char *vertex_source = "attribute vec4 position; void main() { gl_Position = position; }"; + const char *fragment_source = "precision mediump float; void main() { gl_FragColor = vec4(1.0); }"; + host_vertex = glCreateShader(GL_VERTEX_SHADER); + host_fragment = glCreateShader(GL_FRAGMENT_SHADER); + glShaderSource(host_vertex, 1, &vertex_source, NULL); + glShaderSource(host_fragment, 1, &fragment_source, NULL); + glCompileShader(host_vertex); glCompileShader(host_fragment); + GLint compiled_vertex = 0, compiled_fragment = 0, linked = 0; + glGetShaderiv(host_vertex, GL_COMPILE_STATUS, &compiled_vertex); + glGetShaderiv(host_fragment, GL_COMPILE_STATUS, &compiled_fragment); + SDL_CHECK(compiled_vertex && compiled_fragment, "independent host sentinel shaders"); + host_program = glCreateProgram(); + glAttachShader(host_program, host_vertex); glAttachShader(host_program, host_fragment); + glLinkProgram(host_program); + glGetProgramiv(host_program, GL_LINK_STATUS, &linked); + SDL_CHECK(linked && glGetError() == GL_NO_ERROR, "independent host sentinel program"); + /* The host restores Ren'Py's raw EGL context, including after first bind. + SDL's own current-context cache must not hide this actual switch. */ + SDL_CHECK(eglMakeCurrent(display, game_surface, game_surface, (EGLContext)context), "bind SDL game context"); + glViewport(0, 0, 16, 16); glDisable(GL_DITHER); glEnable(GL_SCISSOR_TEST); + const float colors[4][4] = {{1,0,0,1}, {0,1,0,1}, {0,0,1,1}, {1,1,1,1}}; + for (int quadrant = 0; quadrant < 4; ++quadrant) { + glScissor((quadrant % 2) * 8, (quadrant / 2) * 8, 8, 8); + glClearColor(colors[quadrant][0], colors[quadrant][1], colors[quadrant][2], 1); + glClear(GL_COLOR_BUFFER_BIT); + } + glDisable(GL_SCISSOR_TEST); glPixelStorei(GL_PACK_ALIGNMENT, 1); glFinish(); + glReadPixels(0, 0, 16, 16, GL_RGBA, GL_UNSIGNED_BYTE, out->pixels); + SDL_CHECK(glGetError() == GL_NO_ERROR, "SDL pbuffer readback GL error"); + for (int y = 0; y < 16; ++y) for (int x = 0; x < 16; ++x) { + const int q = (y / 8) * 2 + x / 8; + SDL_CHECK(expect_pixel(out->pixels, x, y, (int)colors[q][0] * 255, + (int)colors[q][1] * 255, (int)colors[q][2] * 255, 255), "SDL pbuffer pixels mismatch"); + } + out->readback_verified = 1; + SDL_GL_SwapWindow(window); + SDL_CHECK(eglMakeCurrent(display, host_surface, host_surface, host_context), "restore host context"); + unsigned char pixel[4] = {0}; + glReadPixels(0, 0, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel); + SDL_CHECK(eglGetCurrentContext() == host_context && pixel[0] == 0 && pixel[1] == 255 && + pixel[2] == 255 && pixel[3] == 255, "SDL modified independent host rendering"); + out->host_context_restored = 1; + SDL_CHECK(eglMakeCurrent(display, game_surface, game_surface, (EGLContext)context), "resume SDL game context"); + glClearColor(1, 1, 0, 1); glClear(GL_COLOR_BUFFER_BIT); + glReadPixels(0, 0, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel); + SDL_CHECK(glGetError() == GL_NO_ERROR && pixel[0] == 255 && pixel[1] == 255 && + pixel[2] == 0 && pixel[3] == 255, "SDL resumed pixels mismatch"); + out->resume_verified = 1; + SDL_Event event; + SDL_zero(event); event.type = SDL_MOUSEBUTTONDOWN; + event.button.button = SDL_BUTTON_LEFT; event.button.state = SDL_PRESSED; + event.button.x = 7; event.button.y = 9; + SDL_CHECK(SDL_PushEvent(&event) == 1, "SDL actual pointer press enqueue"); + event.type = SDL_MOUSEBUTTONUP; event.button.state = SDL_RELEASED; + SDL_CHECK(SDL_PushEvent(&event) == 1, "SDL actual pointer release enqueue"); + SDL_zero(event); event.type = SDL_TEXTINPUT; + SDL_strlcpy(event.text.text, "\xe8\xa7\xa6\xe6\x8e\xa7", sizeof(event.text.text)); + SDL_CHECK(SDL_PushEvent(&event) == 1, "SDL actual UTF-8 text enqueue"); + int down = 0, up = 0, text = 0; + while (SDL_PollEvent(&event)) { + if (event.type == SDL_MOUSEBUTTONDOWN && event.button.x == 7 && event.button.y == 9 && + event.button.state == SDL_PRESSED) down = 1; + if (event.type == SDL_MOUSEBUTTONUP && event.button.state == SDL_RELEASED) up = 1; + if (event.type == SDL_TEXTINPUT && !strcmp(event.text.text, "\xe8\xa7\xa6\xe6\x8e\xa7")) text = 1; + } + SDL_CHECK(down && up && text, "SDL actual pointer/text event delivery mismatch"); + /* Production terminal cleanup must override a conflicting environment + hint in the private SDL instance without terminating the host display. */ + SDL_CHECK(!SDL_setenv("AETHER_RENPY_EMBEDDED", "0", 1), "set conflicting embedded hint"); + SDL_CHECK(SDL_SetHintWithPriority("AETHER_RENPY_EMBEDDED", "1", SDL_HINT_OVERRIDE) && + SDL_GetHintBoolean("AETHER_RENPY_EMBEDDED", SDL_FALSE), "override private SDL display ownership hint"); + SDL_GL_DeleteContext(context); context = NULL; + SDL_DestroyWindow(window); window = NULL; + SDL_QuitSubSystem(SDL_INIT_VIDEO); + SDL_CHECK(!(SDL_WasInit(SDL_INIT_VIDEO) & SDL_INIT_VIDEO), "private SDL video remains initialized"); + SDL_CHECK(eglBindAPI(EGL_OPENGL_ES_API) && + eglMakeCurrent(display, host_surface, host_surface, host_context), + "restore independent host after private SDL destruction"); + SDL_CHECK(glIsTexture(host_texture) && glIsFramebuffer(host_fbo) && glIsProgram(host_program), + "private SDL teardown destroyed host GPU resources"); + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glReadPixels(0, 0, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel); + SDL_CHECK(glGetError() == GL_NO_ERROR && pixel[0] == 0 && pixel[1] == 255 && + pixel[2] == 255 && pixel[3] == 255, "host pbuffer changed during SDL destruction"); + glBindFramebuffer(GL_FRAMEBUFFER, host_fbo); + glReadPixels(0, 0, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel); + SDL_CHECK(glGetError() == GL_NO_ERROR && pixel[0] == 0 && pixel[1] == 255 && + pixel[2] == 255 && pixel[3] == 255, "host framebuffer changed during SDL destruction"); + glClearColor(1, 0, 1, 1); glClear(GL_COLOR_BUFFER_BIT); glFinish(); + glReadPixels(0, 0, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel); + SDL_CHECK(glGetError() == GL_NO_ERROR && pixel[0] == 255 && pixel[1] == 0 && + pixel[2] == 255 && pixel[3] == 255, "host did not render fresh pixels after SDL destruction"); + out->sdl_terminal_cleanup_verified = 1; + out->passed = 1; +cleanup: + if (display != EGL_NO_DISPLAY && host_context != EGL_NO_CONTEXT && + eglMakeCurrent(display, host_surface, host_surface, host_context)) { + if (host_program) glDeleteProgram(host_program); + if (host_vertex) glDeleteShader(host_vertex); + if (host_fragment) glDeleteShader(host_fragment); + if (host_fbo) glDeleteFramebuffers(1, &host_fbo); + if (host_texture) glDeleteTextures(1, &host_texture); + } + if (display != EGL_NO_DISPLAY) eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT); + if (context) SDL_GL_DeleteContext(context); + if (window) SDL_DestroyWindow(window); + if (display != EGL_NO_DISPLAY) { + if (host_context != EGL_NO_CONTEXT) eglDestroyContext(display, host_context); + if (host_surface != EGL_NO_SURFACE) eglDestroySurface(display, host_surface); + } + SDL_Quit(); + eglBindAPI(previous_api); + if (previous_display != EGL_NO_DISPLAY && + !eglMakeCurrent(previous_display, previous_draw, previous_read, previous_context)) { + out->passed = 0; + copy_string(out->error, sizeof(out->error), "failed to restore pre-SDL EGL context"); + } + return out->passed ? 0 : -1; +#undef SDL_CHECK +} diff --git a/tools/renpy_mobile_startup_diagnostics_test.gd b/tools/renpy_mobile_startup_diagnostics_test.gd new file mode 100644 index 000000000..cefa1eda5 --- /dev/null +++ b/tools/renpy_mobile_startup_diagnostics_test.gd @@ -0,0 +1,87 @@ +extends Node + +# Run as the main scene in a disposable copy of apps/godot_app using a real +# Godot binary. This checks the production row helpers and request consumption; +# it does not initialize a native engine or manufacture mobile startup phases. +const Main = preload("res://scripts/main.gd") +const ProbeConfig = preload("res://scripts/probe_config.gd") +const RUN_ID := "0123456789abcdef0123456789abcdef" +const PHASES := [ + "main_ready_entered", "ui_build_entered", "ui_build_ready", + "player_create_entered", "player_create_ready", "player_create_failed", + "ready_frame_pending", "ready_frame_entered", + "engine_initialize_entered", "engine_initialize_ready", "engine_initialize_failed", + "observer_create_entered", "observer_create_ready", + "observer_start_entered", "observer_start_returned", +] + +var checks := 0 +var failures := 0 + +func _check(condition: bool, label: String) -> void: + checks += 1 + if not condition: + failures += 1 + push_error("startup diagnostic regression failed: " + label) + +func _ready() -> void: + var request := {"probe_script": "res://scripts/renpy_mobile_acceptance.gd", + "run_id": RUN_ID, "game_path": "user://request-fixture", "timeout_seconds": 600} + _check(Main._renpy_startup_request_run_id(request) == RUN_ID, "acceptance request identity") + for candidate in [null, 123, "", RUN_ID.to_upper(), RUN_ID + "0", "../private/path", "g".repeat(32)]: + var invalid := request.duplicate() + invalid["run_id"] = candidate + _check(Main._renpy_startup_request_run_id(invalid).is_empty(), "invalid identity is refused") + var unrelated := request.duplicate() + unrelated["probe_script"] = "res://scripts/smoke_test.gd" + _check(Main._renpy_startup_request_run_id(unrelated).is_empty(), "other probe identity is refused") + _check(Main.RENPY_STARTUP_PHASES == PHASES, "fixed phase whitelist") + + # These are protocol rows, not evidence that any runtime phase executed. + for index in range(PHASES.size()): + var row: Dictionary = Main._renpy_startup_phase_row(RUN_ID, index + 1, PHASES[index], 1000 + index) + _check(row.size() == 4 and row.run_id == RUN_ID and row.seq == index + 1 \ + and row.phase == PHASES[index] and row.ticks_msec == 1000 + index, "fixed row sequence") + var result_row: Dictionary = Main._renpy_startup_phase_row(RUN_ID, 1, PHASES[0], 0, -2147483648) + _check(result_row.size() == 5 and result_row.result == -2147483648, "signed result lower boundary") + _check(not Main._renpy_startup_phase_row(RUN_ID, 64, PHASES[0], Main.RENPY_STARTUP_TRACE_MAX_TICKS, 2147483647).is_empty(), "upper boundaries") + for value in [0, 65, true, 1.0, "1"]: + _check(Main._renpy_startup_phase_row(RUN_ID, value, PHASES[0], 0).is_empty(), "invalid sequence is refused") + for value in [-1, Main.RENPY_STARTUP_TRACE_MAX_TICKS + 1, true, 1.0, "1"]: + _check(Main._renpy_startup_phase_row(RUN_ID, 1, PHASES[0], value).is_empty(), "invalid clock is refused") + for value in [-2147483649, 2147483648, true, 1.0, "1"]: + _check(Main._renpy_startup_phase_row(RUN_ID, 1, PHASES[0], 0, value).is_empty(), "invalid result is refused") + _check(Main._renpy_startup_phase_row(RUN_ID, 1, "private arbitrary message", 0).is_empty(), "unknown phase is refused") + + var main := Main.new() + var trace_path := ProjectSettings.globalize_path(Main.RENPY_STARTUP_TRACE_PATH) + _check(not FileAccess.file_exists(trace_path), "disposable trace initially absent") + main._record_renpy_startup_phase(PHASES[0]) + _check(not FileAccess.file_exists(trace_path), "no request produces no trace") + var file := FileAccess.open(ProbeConfig.debug_request_path(), FileAccess.WRITE) + _check(file != null, "real debug request opened") + if file != null: + file.store_string(JSON.stringify(request)) + file.close() + _check(main._detect_cli_probe_script() == request.probe_script, "production Main detects acceptance") + _check(not FileAccess.file_exists(ProbeConfig.debug_request_path()), "request consumed once") + var parsed: Dictionary = JSON.parse_string(JSON.stringify(request)) + _check(ProbeConfig.load() == parsed and ProbeConfig.load() == parsed, "cached request survives repeated reads") + if not OS.is_debug_build() or OS.get_name() not in ["Android", "iOS"]: + _check(main.renpy_startup_trace_run_id.is_empty(), "actual nonmobile platform does not enable trace") + main._configure_renpy_startup_trace(request) + main._record_renpy_startup_phase(PHASES[0]) + _check(not FileAccess.file_exists(trace_path), "actual nonmobile writer is a no-op") + main.ui_motion.free() + main.free() + + var evidence := {"status": "passed" if failures == 0 else "failed", "checks": checks, + "failures": failures, "godot_version": Engine.get_version_info().string, + "platform": OS.get_name(), "protocol_rows_only": true, + "native_runtime_executed": false, "mobile_gameplay_executed": false} + print(JSON.stringify(evidence)) + var output := FileAccess.open("res://startup-diagnostics-test.json", FileAccess.WRITE) + if output != null: + output.store_string(JSON.stringify(evidence)) + output.close() + get_tree().quit(0 if failures == 0 else 1) diff --git a/tools/run_renpy_android_device_acceptance.py b/tools/run_renpy_android_device_acceptance.py new file mode 100644 index 000000000..9a6d6e917 --- /dev/null +++ b/tools/run_renpy_android_device_acceptance.py @@ -0,0 +1,988 @@ +#!/usr/bin/env python3 +"""Install and exercise an actual Ren'Py-enabled APK on a cloud Android device. + +No fake adb, scripted provider, skip-execution switch, or host-PC fallback is +provided. Missing APK/adb/device/debug staging yields not_run and exit status 2; +missing gameplay evidence after installation yields failed and exit status 1. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import math +import os +import re +import selectors +import shlex +import shutil +import struct +import subprocess +import tarfile +import tempfile +import time +import uuid +import zlib +from pathlib import Path + + +class Blocked(RuntimeError): + pass + + +class Failed(RuntimeError): + pass + + +def bounded_failure_command(command: list[str], deadline: float, *, byte_limit: int = 65536) -> tuple[dict, bytes]: + """Capture only bounded stdout of an owned read-only diagnostic child.""" + started = time.monotonic() + expires = min(deadline, started + 2) + if expires <= started: + return {"status": "not_available", "reason": "diagnostic_deadline"}, b"" + report, raw = {"status": "not_available"}, bytearray() + try: + process = subprocess.Popen(command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) + except OSError: + return {**report, "reason": "command_unavailable"}, b"" + try: + os.set_blocking(process.stdout.fileno(), False) + with selectors.DefaultSelector() as reader: + reader.register(process.stdout, selectors.EVENT_READ) + read_deadline = expires - min(0.2, (expires - started) / 2) + eof = False + while time.monotonic() < read_deadline: + if not eof: + events = reader.select(timeout=min(0.05, max(0, read_deadline - time.monotonic()))) + if events: + chunk = os.read(process.stdout.fileno(), min(4096, byte_limit + 1 - len(raw))) + if not chunk: + eof = True + reader.unregister(process.stdout) + else: + raw.extend(chunk) + if len(raw) > byte_limit: + report["reason"] = "stdout_limit" + break + elif process.poll() is None: + time.sleep(min(0.01, max(0, read_deadline - time.monotonic()))) + if eof and process.poll() is not None: + break + if not eof and "reason" not in report: + report["reason"] = "command_timeout" + if process.poll() is None and not report.get("cleanup_permission_denied"): + try: + process.kill() + except PermissionError: + report["cleanup_permission_denied"] = True + except ProcessLookupError: + pass + try: + code = process.wait(timeout=max(0, expires - time.monotonic())) + report["returncode"] = code + except subprocess.TimeoutExpired: + report["collector_not_reaped"] = True + if not report.get("reason") and report.get("returncode") == 0 and time.monotonic() < deadline: + report["status"] = "available" + elif not report.get("reason"): + report["reason"] = "command_failed_or_deadline" + except OSError: + report["reason"] = "command_io_error" + if process.poll() is None and not report.get("cleanup_permission_denied"): + try: + process.kill() + except PermissionError: + report["cleanup_permission_denied"] = True + except ProcessLookupError: + pass + try: + process.wait(timeout=max(0, expires - time.monotonic())) + except subprocess.TimeoutExpired: + report["collector_not_reaped"] = True + finally: + process.stdout.close() + report["elapsed_seconds"] = round(time.monotonic() - started, 3) + report["bytes_captured"] = min(len(raw), byte_limit) + # Incomplete/failed output is never used as PID or Activity state proof. + return report, bytes(raw) if report["status"] == "available" else b"" + + +def failure_activity_states(raw: bytes, component: str) -> list[dict]: + """Select state/drawn booleans only within the exact current Activity record.""" + if len(component) > 256 or not re.fullmatch(r"[A-Za-z0-9_.$]+/[A-Za-z0-9_.$]+", component): + return [] + states, current = [], None + wanted = re.compile(r"(? len(prefix): + prefix = prefix.rpartition(b"\n")[0] + for line in prefix.decode(errors="replace").splitlines()[:1000]: + if len(line) > 4096: + if "ActivityRecord{" in line: + current = None + continue + if "ActivityRecord{" in line: + current = None + if wanted.search(line) and len(states) < 4: + current = {} + states.append(current) + if current is None: + continue + match = re.search(r"\bstate=(INITIALIZING|STARTED|RESUMED|PAUSING|PAUSED|STOPPING|STOPPED|DESTROYING|DESTROYED|RESTARTING_PROCESS)\b", line) + if match: + current["state"] = match[1] + for name in ("mVisibleRequested", "mVisible", "allDrawn", "firstWindowDrawn"): + match = re.search(r"\b" + name + r"=(true|false)\b", line) + if match: + current[name] = match[1] == "true" + return [row for row in states if row] + + +def is_successful_os_return(row: object) -> bool: + """Require a delivered Return in the observer's explicit Godot namespace.""" + return (isinstance(row, dict) and row.get("kind") == "os_key" + and row.get("keycode_space") == "godot" and row.get("pressed") is True + and row.get("code") in ((1 << 22) | 5, (1 << 22) | 6) + and not isinstance(row.get("result"), bool) and row.get("result") == 0) + + +def png_pixels(path: Path, *, deadline: float | None = None) -> tuple[int, int, bytes, int]: + """Decode real Android/Godot 8-bit RGB(A) evidence without extra packages.""" + if deadline is not None and time.monotonic() >= deadline: + raise Failed("Passive capture deadline reached") + raw = path.read_bytes() + if deadline is not None and (len(raw) > 8 * 1024 * 1024 or time.monotonic() >= deadline): + raise Failed("Passive PNG exceeds its byte or time budget") + if raw[:8] != b"\x89PNG\r\n\x1a\n": + raise Failed(f"Evidence is not a PNG: {path.name}") + offset, compressed = 8, bytearray() + width = height = channels = 0 + while offset + 12 <= len(raw): + if deadline is not None and time.monotonic() >= deadline: + raise Failed("Passive capture deadline reached") + count = struct.unpack_from(">I", raw, offset)[0] + kind = raw[offset + 4:offset + 8] + data = raw[offset + 8:offset + 8 + count] + if kind == b"IHDR": + width, height, depth, color, compression, filtering, interlace = struct.unpack(">IIBBBBB", data) + if depth != 8 or color not in (2, 6) or compression or filtering or interlace: + raise Failed(f"Unsupported real PNG format in {path.name}") + channels = 4 if color == 6 else 3 + elif kind == b"IDAT": + compressed.extend(data) + elif kind == b"IEND": + break + offset += 12 + count + if width <= 0 or height <= 0 or width * height > 30_000_000 or not channels: + raise Failed(f"Invalid PNG dimensions: {path.name}") + stride = width * channels + if deadline is None: + stream = zlib.decompress(compressed) + else: + # The passive diagnostic has a finite CPU/memory budget. Keep the + # normal decoder unchanged; reject output beyond the legal PNG size. + if height * (stride + 1) > 8 * 1024 * 1024: + raise Failed("Passive PNG exceeds its decoded pixel budget") + decoder = zlib.decompressobj() + stream = decoder.decompress(compressed, height * (stride + 1) + 1) + if not decoder.eof or decoder.unconsumed_tail or time.monotonic() >= deadline: + raise Failed("Passive PNG decompression exceeds its size or time budget") + if len(stream) != height * (stride + 1): + raise Failed(f"Incomplete PNG pixels: {path.name}") + pixels = bytearray(height * stride) + previous = bytearray(stride) + for y in range(height): + if deadline is not None and time.monotonic() >= deadline: + raise Failed("Passive capture deadline reached") + start = y * (stride + 1) + filter_type = stream[start] + row = bytearray(stream[start + 1:start + 1 + stride]) + for x in range(stride): + if deadline is not None and x % 4096 == 0 and time.monotonic() >= deadline: + raise Failed("Passive capture deadline reached") + left = row[x - channels] if x >= channels else 0 + up = previous[x] + corner = previous[x - channels] if x >= channels else 0 + if filter_type == 1: + value = left + elif filter_type == 2: + value = up + elif filter_type == 3: + value = (left + up) // 2 + elif filter_type == 4: + p = left + up - corner + distance = (abs(p - left), abs(p - up), abs(p - corner)) + value = (left, up, corner)[distance.index(min(distance))] + elif filter_type == 0: + value = 0 + else: + raise Failed(f"Invalid PNG filter in {path.name}") + row[x] = (row[x] + value) & 255 + pixels[y * stride:(y + 1) * stride] = row + previous = row + result = width, height, bytes(pixels), channels + if deadline is not None and time.monotonic() >= deadline: + raise Failed("Passive capture deadline reached") + return result + + +def pixel(image: tuple[int, int, bytes, int], x: int, y: int) -> tuple[int, int, int]: + width, height, data, channels = image + if not 0 <= x < width or not 0 <= y < height: + raise Failed("Native frame's presentation geometry lies outside the device screenshot") + offset = (y * width + x) * channels + return tuple(data[offset:offset + 3]) + + +def image_stats(image: tuple[int, int, bytes, int]) -> dict: + width, height, _, _ = image + colors, luma = set(), [] + for y in range(0, height, max(1, height // 64)): + for x in range(0, width, max(1, width // 64)): + rgb = pixel(image, x, y) + colors.add(rgb) + luma.append(sum(rgb) / 3) + stats = {"width": width, "height": height, "sampled_colors": len(colors), + "luma_range": max(luma) - min(luma)} + if stats["sampled_colors"] < 32 or stats["luma_range"] < 40: + raise Failed("Real image is blank or insufficiently varied for the Ren'Py scene") + return stats + + +def is_script_checkpoint(row: object, stage: str, run_id: str) -> bool: + return (isinstance(row, dict) and row.get("run_id") == run_id + and row.get("source") == "renpy-script" and row.get("stage") == stage) + + +def valid_rgb(rgb: object) -> bool: + # Godot JSON round-trips integral channels as 40.0 rather than 40. Accept + # that representation, while rejecting bools, fractions and invalid RGB. + return (isinstance(rgb, list) and len(rgb) == 3 + and all(not isinstance(v, bool) and isinstance(v, (int, float)) + and 0 <= v <= 255 and int(v) == v for v in rgb)) + + +def checkpoint_landmarks(checkpoint: dict) -> dict: + """Read geometry and marker identity from the executed game's checkpoint.""" + if checkpoint.get("source") != "renpy-script": + raise Failed("Landmarks do not originate from an executed Ren'Py checkpoint") + expected_name = {"start_ready": "START", "post_text_ready": "WAIT", "quit_ready": "RESUMED"}.get(checkpoint.get("stage")) + details = checkpoint.get("details", {}) + if not isinstance(details, dict) or not expected_name or details.get("marker_name") != expected_name: + raise Failed("Checkpoint lacks its executed scene's stage marker") + canvas = details.get("logical_canvas", []) + if (not isinstance(canvas, list) or len(canvas) != 2 + or any(isinstance(v, bool) or not isinstance(v, (int, float)) or not math.isfinite(v) or v <= 0 for v in canvas)): + raise Failed("Checkpoint lacks a valid logical game canvas") + for name in ("button_bounds", "marker_bounds"): + bounds = details.get(name, []) + if (not isinstance(bounds, list) or len(bounds) != 4 + or any(isinstance(v, bool) or not isinstance(v, (int, float)) or not math.isfinite(v) for v in bounds) + or min(bounds[:2]) < 0 or min(bounds[2:]) <= 8 + or bounds[0] + bounds[2] > canvas[0] or bounds[1] + bounds[3] > canvas[1]): + raise Failed(f"Checkpoint has invalid {name} within its logical canvas") + rgb = details.get("marker_rgb", []) + if not valid_rgb(rgb): + raise Failed("Checkpoint lacks its actual marker's RGB color") + button_rgbs = details.get("button_rgbs", []) + if (not isinstance(button_rgbs, list) or not button_rgbs + or any(not valid_rgb(rgb) for rgb in button_rgbs)): + raise Failed("Checkpoint lacks its actual idle/hover button RGB colors") + return details + + +def landmark_samples(canvas: list, bounds: list): + # Exclude the outer four logical pixels where filtering/shadows can mix + # game and background. Sample densely throughout each remaining landmark. + x, y, width, height = bounds + columns = min(48, max(9, int((width - 8) / 5))) + rows = min(32, max(7, int((height - 8) / 5))) + for iy in range(rows): + for ix in range(columns): + yield ((x + 4 + (ix + 0.5) * (width - 8) / columns) / canvas[0], + (y + 4 + (iy + 0.5) * (height - 8) / rows) / canvas[1]) + + +def verify_landmark_pixels(provider: tuple, screen: tuple, frame: dict, checkpoint: dict) -> dict: + details = checkpoint_landmarks(checkpoint) + box, viewport = frame["drawn_box"], frame["viewport_size"] + if (len(box) != 4 or len(viewport) != 2 + or any(not isinstance(v, (int, float)) or not math.isfinite(v) for v in box + viewport) + or min(box[2:]) <= 0 or min(viewport) <= 0): + raise Failed("Landmark comparison lacks finite on-device presentation geometry") + metrics = {} + for name in ("button", "marker"): + matches = count = expected_color = 0 + expected_rgbs = details["button_rgbs"] if name == "button" else [details["marker_rgb"]] + for u, v in landmark_samples(details["logical_canvas"], details[name + "_bounds"]): + native = pixel(provider, int(u * provider[0]), int(v * provider[1])) + sx = int((box[0] + u * box[2]) * screen[0] / viewport[0]) + sy = int((box[1] + v * box[3]) * screen[1] / viewport[1]) + # The original sample must be within the OS image. Nearby actual + # pixels accommodate a subpixel linear-filter/antialiasing shift. + pixel(screen, sx, sy) + matches += any(max(abs(a - b) for a, b in zip(native, pixel(screen, nx, ny))) <= 24 + for ny in range(max(0, sy - 1), min(screen[1], sy + 2)) + for nx in range(max(0, sx - 1), min(screen[0], sx + 2))) + expected_color += any(max(abs(a - b) for a, b in zip(native, rgb)) <= 24 for rgb in expected_rgbs) + count += 1 + metrics[name] = {"logical_bounds": details[name + "_bounds"], "logical_inset": 4, + "matching_samples": matches, "samples": count, "fraction": matches / count, + "required_fraction": 0.90, "channel_tolerance": 24, "os_neighbor_radius": 1, + "native_required_color_fraction": 0.70} + if name == "marker": + metrics[name].update(name=details["marker_name"], expected_rgb=details["marker_rgb"], + native_expected_color_fraction=expected_color / count) + else: + metrics[name].update(expected_rgbs=details["button_rgbs"], + native_expected_color_fraction=expected_color / count) + # Return metrics even when an ROI fails so the caller can preserve evidence + # before rejecting. Solid button/marker fills leave room for actual labels. + return metrics + + +def require_landmark_pixels(metrics: dict) -> None: + for name in ("button", "marker"): + if metrics[name]["fraction"] < 0.90: + raise Failed(f"Actual OS screenshot does not present the native {name} ROI: {metrics[name]}") + if metrics["marker"]["native_expected_color_fraction"] < 0.70: + raise Failed("Native RGBA does not render the executed script's stage marker; a stale frame cannot pass") + if metrics["button"]["native_expected_color_fraction"] < 0.70: + raise Failed("Native RGBA lacks the executed script's visible idle/hover button; matching background cannot pass") + + +def verify_resumed_marker(before: tuple, before_checkpoint: dict, after: tuple, after_checkpoint: dict) -> dict: + earlier = checkpoint_landmarks(before_checkpoint) + later = checkpoint_landmarks(after_checkpoint) + if earlier["marker_name"] != "WAIT" or later["marker_name"] != "RESUMED": + raise Failed("Resume evidence must compare actual WAIT and RESUMED script-rendered markers") + changed = count = 0 + previous_samples = landmark_samples(earlier["logical_canvas"], earlier["marker_bounds"]) + current_samples = landmark_samples(later["logical_canvas"], later["marker_bounds"]) + if earlier["logical_canvas"] != later["logical_canvas"] or earlier["marker_bounds"] != later["marker_bounds"]: + raise Failed("The executed fixture's marker geometry changed unexpectedly during resume") + for (u0, v0), (u1, v1) in zip(previous_samples, current_samples): + previous = pixel(before, int(u0 * before[0]), int(v0 * before[1])) + current = pixel(after, int(u1 * after[0]), int(v1 * after[1])) + changed += max(abs(a - b) for a, b in zip(previous, current)) > 24 + count += 1 + return {"source": "actual-native-rgba-before-and-after-resumed-os-touch", "before": "WAIT", "after": "RESUMED", + "changed_samples": changed, "samples": count, "fraction": changed / count, + "required_fraction": 0.70, "channel_tolerance": 24} + + +def read_engine_log_file(path: Path) -> bytes: + try: + return path.read_bytes() + except OSError as exc: + raise Failed(f"Actual per-game engine log is missing or unreadable: {path}: {exc}") from exc + + +def require_engine_log(raw: bytes, game_root: str) -> dict: + """Reject sidecar errors after void native shutdown; do not infer its status.""" + if not isinstance(raw, bytes) or not raw.strip(): + raise Failed("Actual per-game engine log is missing, unreadable or empty") + try: + text = raw.decode("utf-8") + except UnicodeDecodeError as exc: + raise Failed("Actual per-game engine log is not readable UTF-8") from exc + path = game_root.rstrip("/") + "/aetherkiri-engine.log" + # Both harnesses remove and recreate this private test root before launch. + # The existing, flushed dispatcher attachment line independently ties the + # retained file to that root, rather than accepting another game's log. + attachment = "aetherkiri provider engine log attached: " + path + if not any(line.rstrip().endswith(attachment) for line in text.splitlines()): + raise Failed("Engine log lacks the existing dispatcher's attachment to this freshly staged game root") + records, errors = 0, [] + diagnostics = ("terminal cleanup failed", "private egl context could not be made current", + "failed to restore the host egl context", "lifecycle called from a different thread than init") + for number, line in enumerate(text.splitlines(), 1): + record = re.search(r"\b(trace|debug|info|warning|error)\s+\[renpy-mobile\]\s*(.*)", line, re.IGNORECASE) + if record is None: + continue + records += 1 + level, message = record.groups() + if (level.lower() == "error" or any(detail in message.lower() for detail in diagnostics) + or message.strip() == "cooperative_stop"): + errors.append({"line": number, "record": line}) + if errors: + raise Failed("Native Ren'Py cleanup/runtime errors were retained in aetherkiri-engine.log: " + + json.dumps(errors, ensure_ascii=False)) + return {"source": "actual-flushed-provider-game-log", "path": path, "bytes": len(raw), + "renpy_mobile_records": records, "renpy_mobile_error_records": 0, + "verification": "no-recorded-native-errors; void-shutdown-status-unavailable"} + + +class Acceptance: + def __init__(self, args: argparse.Namespace): + self.args = args + self.output = args.output_dir.resolve() + self.output.mkdir(parents=True, exist_ok=True) + self.run_id = uuid.uuid4().hex + self.device_prefix = [args.adb, "-s", args.serial] + self.game_root = "" + self.data_root = "" + self.pid = "" + self.installed = False + self.last_command_returncode = None + self.last_command_stderr = "" + self.execution_deadline = 0.0 + self.passive_deadline = None + self.waiting_marker = None + self.summary = {"status": "not_run", "run_id": self.run_id, + "source": "actual-apk-adb-cloud-device", "serial": args.serial, + "package": args.package, "checks": [], "blocker": "", + "apk_installed": False, "process_started": False, + "actual_status": {stage: {"status": "not_run", "source": "actual-adb-command", + "package": args.package, "serial": args.serial} + for stage in ("apk_install", "app_launch")}} + + def write_result(self) -> None: + pending = self.output / "result.pending.json" + pending.write_text(json.dumps(self.summary, indent=2) + "\n") + pending.replace(self.output / "result.json") + + def milestone(self, stage: str, **evidence) -> None: + report = self.summary["actual_status"][stage] + report.update(status="passed", **evidence) + self.summary["status"] = "running" + self.write_result() + # Publish only bounded command milestones, never raw device logs or + # any claim that the Ren'Py runtime has rendered or accepted input. + bounded = {**report, "package": report["package"][:256], "serial": report["serial"][:128]} + if "activity" in bounded: + bounded["activity"] = bounded["activity"][:512] + print(json.dumps({"event": "renpy_device_milestone", "run_id": self.run_id, + "apk_installed": self.summary["apk_installed"], + "process_started": self.summary["process_started"], + "stage": stage, "evidence": bounded}), flush=True) + + def command(self, *parts: str, check: bool = True, timeout: int = 20) -> bytes: + command = self.device_prefix + list(parts) + self.last_command_returncode = None + self.last_command_stderr = "" + if self.passive_deadline is not None: + png = parts[:3] == ("exec-out", "screencap", "-p") or ( + parts[:3] == ("exec-out", "run-as", self.args.package) and parts[-1].endswith(".png")) + report, raw = bounded_failure_command(command, min(self.passive_deadline, time.monotonic() + timeout), + byte_limit=8 * 1024 * 1024 if png else 65536) + self.last_command_returncode = report.get("returncode") + with (self.output / "commands.jsonl").open("a") as log: + log.write(json.dumps({"command": command, "returncode": self.last_command_returncode, + "passive_diagnostic_only": True, "time": time.time()}) + "\n") + if (report.get("reason") in ("command_timeout", "stdout_limit", "diagnostic_deadline") + or report.get("cleanup_permission_denied") or report.get("collector_not_reaped") + or time.monotonic() >= self.passive_deadline): + raise Failed("Passive device read exceeded its bounded budget") + if report.get("status") != "available": + if not check and report.get("returncode") is not None: + return b"" # Unavailable observer/checkpoint data is not readiness proof. + raise Failed("Passive device read is unavailable") + return raw + try: + result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout) + except (OSError, subprocess.TimeoutExpired) as exc: + raise Failed(f"Device command did not complete: {parts[0]}: {exc}") from exc + self.last_command_returncode = result.returncode + self.last_command_stderr = result.stderr.decode(errors="replace") + with (self.output / "commands.jsonl").open("a") as log: + log.write(json.dumps({"command": command, "returncode": result.returncode, + "stderr": result.stderr.decode(errors="replace"), + "time": time.time()}) + "\n") + if check and result.returncode: + raise Failed(f"adb {' '.join(parts[:3])} failed: {result.stderr.decode(errors='replace').strip()}") + return result.stdout + + def shell(self, *parts: str, check: bool = True, timeout: int = 20) -> bytes: + # adb shell joins arguments into a remote shell string. Quote at that + # boundary instead of relying on local subprocess's argument handling. + return self.command("shell", shlex.join(parts), check=check, timeout=timeout) + + def private(self, relative: str, required: bool = False) -> bytes: + return self.command("exec-out", "run-as", self.args.package, "cat", relative, check=required) + + def rows(self, relative: str) -> list[dict]: + result = [] + for line in self.private(relative).decode(errors="replace").splitlines(): + try: + row = json.loads(line) + except ValueError: + continue + if isinstance(row, dict) and row.get("run_id") == self.run_id: + result.append(row) + return result + + def game_rows(self) -> list[dict]: + return self.rows("files/renpy-device-demo/game/aether-device-checkpoints.jsonl") + + def observer_rows(self) -> list[dict]: + return self.rows("files/renpy-device-evidence/observer.jsonl") + + def wait(self, description: str, condition, timeout: int | None = None): + expires = time.monotonic() + (timeout or self.args.stage_timeout) + if self.execution_deadline: + expires = min(expires, self.execution_deadline) + while time.monotonic() < expires: + for row in self.observer_rows(): + if row.get("kind") == "failure": + raise Failed(f"Actual mobile observer failed: {row.get('message')}") + result = condition() + if self.passive_deadline is not None and time.monotonic() >= expires: + raise Failed("Passive observation deadline reached") + if result: + return result + time.sleep(min(0.4, max(0, expires - time.monotonic())) if self.passive_deadline is not None else 0.4) + raise Failed(f"Timed out waiting for {description}; execution is not accepted") + + def wait_game(self, stage: str) -> dict: + return self.wait(f"executed Ren'Py checkpoint {stage}", lambda: next( + (row for row in self.game_rows() if is_script_checkpoint(row, stage, self.run_id)), None)) + + def wait_observer(self, kind: str, **fields) -> dict: + return self.wait(f"native observer {kind} {fields}", lambda: next( + (row for row in self.observer_rows() if row.get("kind") == kind + and all(row.get(key) == value for key, value in fields.items())), None)) + + def screenshot(self, name: str) -> Path: + path = self.output / f"screen-{name}.png" + path.write_bytes(self.command("exec-out", "screencap", "-p")) + png_pixels(path, deadline=self.passive_deadline) + return path + + def foreground(self) -> None: + window = self.shell("dumpsys", "window", "windows").decode(errors="replace") + (self.output / "window-last.txt").write_text(window) + focused = [line for line in window.splitlines() if "mCurrentFocus=" in line or "mFocusedApp=" in line] + if not any(self.args.package in line for line in focused): + raise Failed("The installed application is not foreground on the cloud device") + + def capture(self, stage: str, *, screenshot_name: str | None = None) -> tuple[dict, tuple]: + row = self.wait_observer("provider_frame", stage=stage) + if row.get("source") != "native-provider-rgba": + raise Failed("Provider image lacks its native RGBA origin") + native = row.get("provider_debug", {}) + if (native.get("runtime") != "renpy" or native.get("initialized") is not True + or native.get("exited") is not False or int(row.get("frame_serial", 0)) <= 0): + raise Failed("Frame lacks live initialized Ren'Py native state and a rendered frame serial") + relative = "files/renpy-device-evidence/provider-" + stage + ".png" + provider_path = self.output / ("provider-" + stage + ".png") + provider_path.write_bytes(self.private(relative, required=True)) + provider = png_pixels(provider_path, deadline=self.passive_deadline) + self.foreground() + screen_path = self.screenshot(screenshot_name or stage) + screen = png_pixels(screen_path, deadline=self.passive_deadline) + stats = {"provider": image_stats(provider), "os_screenshot": image_stats(screen)} + box, viewport = row["drawn_box"], row["viewport_size"] + if min(viewport) <= 0 or min(box[2:]) <= 0: + raise Failed("Native frame lacks valid on-device presentation geometry") + matches, count = 0, 0 + # Compare actual game pixels with the OS image, including background + # regions throughout the scene. A colorful desktop cannot pass this. + for iy in range(1, 8): + for ix in range(1, 12): + u, v = ix / 12, iy / 8 + expected = pixel(provider, int(u * provider[0]), int(v * provider[1])) + sx = int((box[0] + u * box[2]) * screen[0] / viewport[0]) + sy = int((box[1] + v * box[3]) * screen[1] / viewport[1]) + actual = pixel(screen, sx, sy) + matches += max(abs(a - b) for a, b in zip(expected, actual)) <= 24 + count += 1 + stats["native_to_os_match"] = {"matching_samples": matches, "samples": count, + "fraction": matches / count, "channel_tolerance": 24} + game_stage = "post_text_ready" if stage == "resumed_ready" else stage + checkpoint = self.wait_game(game_stage) + observed = row.get("game_checkpoint", {}) + if (not is_script_checkpoint(observed, game_stage, self.run_id) + or observed.get("details") != checkpoint.get("details")): + raise Failed("Provider capture's landmarks do not match the independently read Ren'Py checkpoint") + stats["landmarks"] = verify_landmark_pixels(provider, screen, row, checkpoint) + if stage == "quit_ready": + if self.waiting_marker is None: + raise Failed("Missing independently captured native WAIT marker before OS pause") + stats["resumed_marker_change"] = verify_resumed_marker(*self.waiting_marker, provider, checkpoint) + (self.output / f"pixels-{screenshot_name or stage}.json").write_text(json.dumps(stats, indent=2) + "\n") + if matches / count < 0.70: + raise Failed(f"OS screenshot does not show the native Ren'Py frame at {stage}: {matches}/{count} samples") + require_landmark_pixels(stats["landmarks"]) + if stage == "quit_ready" and stats["resumed_marker_change"]["fraction"] < 0.70: + raise Failed("Native marker did not change after actual resumed OS touch; cached WAIT pixels cannot pass") + if stage == "post_text_ready": + self.waiting_marker = provider, checkpoint + if self.passive_deadline is not None and time.monotonic() >= self.passive_deadline: + raise Failed("Passive capture deadline reached") + self.summary["checks"].append({"stage": screenshot_name or stage, "native_provider_pixels_and_os_screen": stats}) + return row, screen + + def tap_button(self, frame: dict, screen: tuple) -> None: + box, viewport = frame["drawn_box"], frame["viewport_size"] + details = checkpoint_landmarks(frame["game_checkpoint"]) + bx, by, bw, bh = details["button_bounds"] + u = (bx + bw / 2) / details["logical_canvas"][0] + v = (by + bh / 2) / details["logical_canvas"][1] + x = round((box[0] + u * box[2]) * screen[0] / viewport[0]) + y = round((box[1] + v * box[3]) * screen[1] / viewport[1]) + self.shell("input", "tap", str(x), str(y)) + + def run(self) -> None: + if not self.args.cloud_device: + raise Blocked("--cloud-device is required: this task authorizes only cloud execution/devices") + missing = [] + if not self.args.apk.is_file(): + missing.append(f"Installable Ren'Py-enabled debug APK is missing: {self.args.apk}") + if not shutil.which(self.args.adb): + missing.append("adb is unavailable in the cloud task; provide Android platform-tools and a cloud device/emulator") + if missing: + raise Blocked("; ".join(missing)) + state = self.command("get-state", check=False).decode().strip() + if state != "device": + raise Blocked(f"Cloud Android device {self.args.serial} is unavailable or unauthorized (state={state!r})") + self.summary["apk_sha256"] = hashlib.sha256(self.args.apk.read_bytes()).hexdigest() + self.summary["device_fingerprint"] = self.shell("getprop", "ro.build.fingerprint").decode().strip() + self.summary["device_abis"] = self.shell("getprop", "ro.product.cpu.abilist").decode().strip() + install_status = self.summary["actual_status"]["apk_install"] + install_status["status"] = "failed" + try: + install = self.command("install", "-r", str(self.args.apk.resolve()), timeout=180).decode(errors="replace") + finally: + install_status["returncode"] = self.last_command_returncode + (self.output / "install.txt").write_text(install) + lines = [line.strip() for line in install.splitlines() if line.strip()] + if (not lines or lines[-1] != "Success" or lines.count("Success") != 1 + or any(line.startswith("Failure") for line in lines)): + raise Failed(f"Actual APK installation failed: {install.strip()}") + self.installed = True + self.summary["apk_installed"] = True + self.milestone("apk_install", returncode=0, stdout_marker="Success") + root = self.command("exec-out", "run-as", self.args.package, "pwd", check=False).decode().strip() + if not root.startswith("/data/") or "\n" in root or " " in root: + raise Blocked("Installed APK does not permit run-as debug staging; use a debuggable APK for this cloud harness") + self.data_root = root + self.game_root = root + "/files/renpy-device-demo" + self.shell("am", "force-stop", self.args.package) + self.stage() + resolved = self.shell("cmd", "package", "resolve-activity", "--brief", self.args.package).decode().splitlines() + component = next((line.strip() for line in reversed(resolved) if "/" in line and " " not in line), "") + if not component or not component.startswith(self.args.package + "/"): + raise Failed("Cannot resolve the installed APK's real launcher activity") + self.summary["activity"] = component + self.execution_deadline = time.monotonic() + self.args.overall_timeout + launch_status = self.summary["actual_status"]["app_launch"] + launch_status.update(status="failed", activity=component) + try: + launch = self.shell("am", "start", "-W", "-n", component, timeout=60).decode(errors="replace") + finally: + launch_status["returncode"] = self.last_command_returncode + (self.output / "launch.txt").write_text(launch) + if (not re.search(r"(?im)^\s*Status:\s*ok\s*$", launch) + or re.search(r"(?im)^\s*(?:Error\b|Exception\b|Failure\b)", launch + "\n" + self.last_command_stderr)): + statuses = re.findall(r"(?im)^\s*Status:\s*(\S+)\s*$", launch) + if (self.last_command_returncode == 0 and statuses == ["timeout"] + and not re.search(r"(?im)^\s*(?:Error\b|Exception\b|Failure\b)", launch + "\n" + self.last_command_stderr)): + self.observe_timed_out_start(component) + raise Failed("Actual am start did not report Status: ok without Android errors; inspect launch.txt and commands.jsonl") + self.pid = self.wait("installed app process", self.observed_pid) + pids = self.pid.split() + self.summary["process_started"] = True + self.milestone("app_launch", returncode=0, activity=component, + stdout_marker="Status: ok", pids=pids[:8], pid_count=len(pids)) + self.wait_game("start_ready") + frame, screen = self.capture("start_ready") + self.tap_button(frame, screen) + self.wait_game("touch_received") + self.wait_game("text_ready") + keyboard = self.wait_observer("soft_keyboard", active=True) + if keyboard.get("source") != "native-text-input-state" or not keyboard.get("feature_available"): + raise Failed("A real native text request and OS virtual keyboard are required") + keyboard_visible = self.wait_observer("soft_keyboard_visibility", visible=True) + if keyboard_visible.get("source") != "os-virtual-keyboard-height" or keyboard_visible.get("height", 0) <= 0: + raise Failed("Android did not report a real visible virtual keyboard") + ime = self.wait("visible Android system input method", lambda: self.visible_ime()) + (self.output / "input-method.txt").write_text(ime) + self.screenshot("text-ready-with-os-keyboard") + typed = "CloudRenPy" + self.run_id[:8] + self.shell("input", "text", typed) + self.shell("input", "keyevent", "66") + text = self.wait_game("text_received") + if text.get("details", {}).get("text") != typed: + raise Failed(f"Real Ren'Py text differs from OS input: {text.get('details')}") + self.wait_game("post_text_ready") + self.wait_observer("soft_keyboard", active=False) + self.wait_observer("soft_keyboard_visibility", visible=False) + self.wait("hidden Android system input method", lambda: not self.visible_ime()) + frame, screen = self.capture("post_text_ready") + self.shell("input", "keyevent", "3") # Android Home: real lifecycle pause. + pause = self.wait_observer("lifecycle", phase="paused") + if pause.get("result") != 0 or pause.get("provider_debug", {}).get("paused") is not True: + raise Failed("OS background did not produce an observed native pause") + self.screenshot("background-home") + time.sleep(2) + self.shell("am", "start", "-W", "-n", component, timeout=60) + resume = self.wait_observer("lifecycle", phase="resumed") + if resume.get("result") != 0 or resume.get("provider_debug", {}).get("paused") is not False: + raise Failed("OS foreground did not produce an observed native resume") + if any(row.get("stage") == "resumed_touch_received" for row in self.game_rows()): + raise Failed("Ren'Py advanced past its waiting interaction without resumed OS touch") + frame, screen = self.capture("resumed_ready", screenshot_name="resumed") + if int(frame.get("frame_serial", 0)) <= int(pause.get("provider_debug", {}).get("frame_serial", 0)): + raise Failed("OS resume did not produce a new native frame; cached pre-pause pixels cannot pass") + self.tap_button(frame, screen) + self.wait_game("resumed_touch_received") + self.wait_game("quit_ready") + frame, screen = self.capture("quit_ready") + self.tap_button(frame, screen) + self.wait_game("quit_requested") + exit_row = self.wait_observer("normal_exit") + if exit_row.get("provider_debug", {}).get("exited") is not True or not exit_row.get("engine_destroyed"): + raise Failed("Explicit Ren'Py quit did not exit native gameplay and destroy the real engine") + self.wait("normal app activity exit", self.activity_exited, timeout=20) + self.summary["host_exit"] = { + "activity_removed": True, + "remaining_process_pids": self.shell("pidof", self.args.package, check=False).decode().strip()} + self.screenshot("normal-exit-os") + events = self.observer_rows() + if not any(row.get("kind") == "os_touch" and row.get("action") == 1 and row.get("result") == 0 for row in events): + raise Failed("No real OS touch-down reached the provider") + if not any(row.get("kind") == "os_touch" and row.get("action") == 3 and row.get("result") == 0 for row in events): + raise Failed("No real OS touch-up reached the provider") + if not any(is_successful_os_return(row) for row in events): + raise Failed("No real OS Enter key reached the provider") + self.summary["checks"].extend([ + {"real_os_touch_and_keyboard_text": typed}, + {"real_os_pause_resume": {"pause": pause, "resume": resume}}, + {"explicit_renpy_quit_and_engine_destroy": exit_row}]) + raw = self.private("files/renpy-device-demo/aetherkiri-engine.log", required=True) + (self.output / "aetherkiri-engine.log").write_bytes(raw) + self.summary["checks"].append({"native_cleanup_error_log": require_engine_log(raw, self.game_root)}) + self.summary["status"] = "passed" + + def observe_timed_out_start(self, component: str) -> None: + """One finite read-only observation, never a launch/gameplay retry.""" + started = time.monotonic() + deadline = min(started + 30, self.execution_deadline) + report = {"source": "actual-adb-passive-startup-observation", "diagnostic_only": True, + "run_id": self.run_id, "package": self.args.package, "serial": self.args.serial, + "apk_sha256": self.summary.get("apk_sha256"), "activity": component, + "status": "diagnostic_notavailable", "window_seconds": max(0, deadline - started), + "start_ready": False, "native_rgba_and_os_pixels": False} + previous_deadline, previous_passive = self.execution_deadline, self.passive_deadline + previous_checks = self.summary["checks"] + install = self.summary["actual_status"]["apk_install"] + try: + if (not self.installed or self.summary.get("apk_installed") is not True + or install.get("status") != "passed" or install.get("source") != "actual-adb-command" + or install.get("package") != self.args.package or install.get("serial") != self.args.serial + or type(install.get("returncode")) is not int or install["returncode"] != 0 + or install.get("stdout_marker") != "Success" or deadline <= started + or len(component) > 256 or not re.fullmatch(r"[A-Za-z0-9_.$]+/[A-Za-z0-9_.$]+", component) + or not component.startswith(self.args.package + "/")): + return + self.execution_deadline = self.passive_deadline = deadline + self.summary["checks"] = [] + pids = self.observed_pid().split() + if not pids or len(pids) > 8 or len(set(pids)) != len(pids): + return + states = failure_activity_states(self.shell("dumpsys", "activity", "activities"), component) + if not any(row.get("state") == "RESUMED" for row in states): + return + report.update(diagnostic_pids=pids, activity_states=states) + self.wait_game("start_ready") + report["start_ready"] = True + if set(self.observed_pid().split()) != set(pids): + return + frame, _ = self.capture("start_ready", screenshot_name="passive-start-ready") + if set(self.observed_pid().split()) != set(pids) or time.monotonic() >= deadline: + return + report.update(status="diagnostic_available", native_rgba_and_os_pixels=True, + frame_serial=frame["frame_serial"], pixels=self.summary["checks"]) + except Exception: + # Optional diagnostics must never replace the original launch + # failure, including malformed observer data in shared capture. + report["observation_unavailable"] = True + finally: + self.summary["checks"] = previous_checks + self.execution_deadline, self.passive_deadline = previous_deadline, previous_passive + report.update(elapsed_seconds=round(time.monotonic() - started, 3), + deadline_reached=time.monotonic() >= deadline) + if report["deadline_reached"]: + report.update(status="diagnostic_notavailable", native_rgba_and_os_pixels=False) + self.summary["passive_startup_observation"] = report + + def observed_pid(self) -> str: + raw = self.shell("pidof", self.args.package, check=False).decode().strip() + pids = raw.split() + return raw if (self.last_command_returncode == 0 and pids + and all(re.fullmatch(r"[1-9][0-9]{0,9}", pid) + and int(pid) <= 2147483647 for pid in pids)) else "" + + def visible_ime(self) -> str: + text = self.shell("dumpsys", "input_method").decode(errors="replace") + return text if re.search(r"(?:mInputShown|isInputViewShown|inputShown|mIsInputViewShown)=true", text) else "" + + def activity_exited(self) -> bool: + # Android may cache an empty app process after the Activity finishes. + # Require the actual ActivityRecord to be removed, independently of + # native gameplay exit/engine teardown, and retain both kinds of evidence. + text = self.shell("dumpsys", "activity", "activities").decode(errors="replace") + (self.output / "activities-last.txt").write_text(text) + return not re.search(r"ActivityRecord\{[^}\n]*\b" + re.escape(self.args.package) + r"/", text) + + def stage(self) -> None: + fixture = Path(__file__).resolve().parents[1] / "demos/aetherkiri-renpy/game" + request = {"run_id": self.run_id} + config = {"probe_script": "res://scripts/renpy_mobile_acceptance.gd", "run_id": self.run_id, + "game_path": self.game_root, "timeout_seconds": self.args.overall_timeout} + # Staging is confined to two test directories plus the existing + # one-shot debug request. No app data, saves, or external paths are reset. + self.shell("run-as", self.args.package, "mkdir", "-p", "files") + self.shell("run-as", self.args.package, "rm", "-rf", "files/renpy-device-demo", "files/renpy-device-evidence") + with tempfile.TemporaryDirectory(prefix="renpy-cloud-device-") as temporary: + staging = Path(temporary) + project = staging / "renpy-device-demo/game" + project.mkdir(parents=True) + for source in fixture.iterdir(): + if source.is_file() and source.suffix in (".rpy", ".svg"): + shutil.copyfile(source, project / source.name) + (project / "aether-device-request.json").write_text(json.dumps(request)) + (staging / "aetherkiri-probe-request.json").write_text(json.dumps(config)) + archive = staging / "project.tar" + with tarfile.open(archive, "w") as tar: + tar.add(staging / "renpy-device-demo", arcname="renpy-device-demo") + tar.add(staging / "aetherkiri-probe-request.json", arcname="aetherkiri-probe-request.json") + remote = "/data/local/tmp/aether-renpy-device-" + self.run_id + ".tar" + self.command("push", str(archive), remote) + try: + self.shell("run-as", self.args.package, "tar", "-xf", remote, "-C", "files") + finally: + self.shell("rm", "-f", remote, check=False) + + def collect(self) -> None: + if not self.installed: + return + if self.summary.get("status") != "failed": + self.collect_complete_evidence() + return + started = time.monotonic() + deadline = started + 10 + snapshot = {"source": "actual-adb-failure-diagnostic", "diagnostic_only": True, + "run_id": self.run_id, "package": self.args.package, "serial": self.args.serial, + "apk_sha256": self.summary.get("apk_sha256"), "pid_sample": {"status": "not_available"}} + def capture(*parts, byte_limit=65536): + return bounded_failure_command(self.device_prefix + list(parts), deadline, byte_limit=byte_limit) + diagnostic_pid = "" + if self.summary.get("status") == "failed": + report, raw = capture("shell", shlex.join(("pidof", self.args.package)), byte_limit=2048) + pids = raw.decode(errors="replace").split() + if (report["status"] == "available" and pids + and all(re.fullmatch(r"[1-9][0-9]{0,9}", pid) and int(pid) <= 2147483647 for pid in pids) + and len(set(pids)) == len(pids)): + report.update(pids=pids[:8], pid_count=len(pids)) + diagnostic_pid = pids[0] + elif report["status"] == "available": + report.update(status="not_available", reason="no_valid_current_pid") + snapshot["pid_sample"] = report + if diagnostic_pid: + report, raw = capture("logcat", "-d", "--pid", diagnostic_pid, "-v", "threadtime", "-t", "100") + snapshot["process_logcat"] = report + if raw: + (self.output / "android-process-logcat.txt").write_bytes(b"\n".join(raw.splitlines()[:100]) + b"\n") + component = self.summary.get("activity", "") + if (len(component) <= 256 and re.fullmatch(r"[A-Za-z0-9_.$]+/[A-Za-z0-9_.$]+", component) + and component.startswith(self.args.package + "/")): + report, raw = capture("shell", shlex.join(("dumpsys", "activity", "activities"))) + report["exact_component"] = component + report["states"] = failure_activity_states(raw, component) if raw else [] + snapshot["activity_sample"] = report + report, data = capture("logcat", "-d", "-v", "threadtime", "-t", "100") + snapshot["system_logcat"] = report + if data: + (self.output / "android-system-logcat.txt").write_bytes(b"\n".join(data.splitlines()[:100]) + b"\n") + # These existing evidence reads share the same absolute budget. They + # do not retry a denied/failed read and cannot replace the primary error. + for relative, local in [ + ("files/renpy-device-demo/aetherkiri-engine.log", "aetherkiri-engine.log"), + ("files/renpy-device-evidence/observer.jsonl", "observer.jsonl"), + ("files/renpy-device-demo/game/aether-device-checkpoints.jsonl", "renpy-script-checkpoints.jsonl"), + ("files/renpy-device-demo/log.txt", "renpy-log.txt"), + ("files/renpy-device-demo/traceback.txt", "renpy-traceback.txt")]: + _, data = capture("exec-out", "run-as", self.args.package, "cat", relative) + if data: + (self.output / local).write_bytes(b"\n".join(data.splitlines()[:200]) + b"\n") + if self.pid and not diagnostic_pid: + _, data = capture("logcat", "-d", "--pid", self.pid.split()[0], "-v", "threadtime", "-t", "100") + if data: + (self.output / "android-process-logcat.txt").write_bytes(b"\n".join(data.splitlines()[:100]) + b"\n") + if self.summary.get("status") == "failed": + snapshot.update(elapsed_seconds=round(time.monotonic() - started, 3), + deadline_reached=time.monotonic() >= deadline) + self.summary["failure_diagnostics"] = snapshot + + def collect_complete_evidence(self) -> None: + """Preserve the original successful-run evidence without new truncation.""" + for relative, local in [ + ("files/renpy-device-demo/aetherkiri-engine.log", "aetherkiri-engine.log"), + ("files/renpy-device-evidence/observer.jsonl", "observer.jsonl"), + ("files/renpy-device-demo/game/aether-device-checkpoints.jsonl", "renpy-script-checkpoints.jsonl"), + ("files/renpy-device-demo/log.txt", "renpy-log.txt"), + ("files/renpy-device-demo/traceback.txt", "renpy-traceback.txt")]: + try: + data = self.private(relative) + if data: + (self.output / local).write_bytes(data) + except Failed: + pass + if self.pid: + try: + (self.output / "android-process-logcat.txt").write_bytes( + self.command("logcat", "-d", "--pid", self.pid.split()[0], "-v", "threadtime", check=False)) + except Failed: + pass + try: + (self.output / "android-system-logcat.txt").write_bytes( + self.command("logcat", "-d", "-v", "threadtime", "-t", "2000", check=False)) + except Failed: + pass + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--apk", type=Path, required=True) + parser.add_argument("--serial", required=True, help="Explicit cloud device/emulator adb serial") + parser.add_argument("--cloud-device", action="store_true", help="Confirm execution and selected device are cloud-hosted") + parser.add_argument("--package", default="org.github.krkr2.aetherkiri") + parser.add_argument("--adb", default="adb") + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--stage-timeout", type=int, default=60) + parser.add_argument("--overall-timeout", type=int, default=600, + help="Bound actual app execution to this many seconds (60..1200)") + args = parser.parse_args() + if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_.]+", args.package) or not 5 <= args.stage_timeout <= 180: + parser.error("Invalid package or stage timeout (5..180 seconds)") + if not 60 <= args.overall_timeout <= 1200: + parser.error("Invalid overall timeout (60..1200 seconds)") + acceptance = Acceptance(args) + code = 0 + try: + acceptance.run() + except Blocked as exc: + acceptance.summary.update(status="not_run", blocker=str(exc)) + code = 2 + except (Failed, KeyError, OSError, ValueError, struct.error, zlib.error) as exc: + acceptance.summary.update(status="failed", blocker=str(exc)) + code = 1 + finally: + try: + acceptance.collect() + except (Failed, OSError, ValueError): + # Diagnostics never mask the original execution classification. + acceptance.summary["failure_diagnostic_error"] = "not_available" + acceptance.write_result() + print(json.dumps(acceptance.summary, indent=2)) + return code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/run_renpy_android_emulator_session.py b/tools/run_renpy_android_emulator_session.py new file mode 100644 index 000000000..bebd2b8f2 --- /dev/null +++ b/tools/run_renpy_android_emulator_session.py @@ -0,0 +1,1246 @@ +#!/usr/bin/env python3 +"""Boot a real cloud Android emulator and optionally run the actual APK harness. + +Only existing KVM/HVF access and existing SDK license acceptance are used. +This never changes device permissions, accepts licenses or falls back to a +simulated provider. Session and original gameplay results remain separate. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import platform +import re +import shlex +import signal +import stat +import struct +import subprocess +import sys +import tempfile +import time +import zlib +from pathlib import Path + +from preflight_renpy_android_device import Preflight, Unavailable +from run_renpy_android_device_acceptance import Failed, png_pixels + + +IMAGE = "system-images;android-35;google_apis;x86_64" +SENSITIVE_DIAGNOSTIC = re.compile( + r"auth|token|credentials?|password|passwd|secret|jwt|grpc|api[_-]?key|" + r"://|www\.|eyJ", + re.IGNORECASE, +) +STARTUP_FILES = ("commands.jsonl", "install.txt", "launch.txt", "aetherkiri-engine.log", "renpy-traceback.txt", + "renpy-log.txt", "android-process-logcat.txt", "android-system-logcat.txt") +STARTUP_ERROR = re.compile(r"error|exception|traceback|fatal|failed|cannot|could not|not found|" + r"undefined symbol|no module|segmentation|renpy_mobile|cooperative_", re.IGNORECASE) +LOGCAT_ROW = re.compile(r"^\d\d-\d\d\s+\S+\s+(\d+)\s+\d+\s+([VDIWEF])\s+([^:]+):\s*(.*)$") +STARTUP_TOKENS = re.compile(r"[A-Za-z0-9_.]+") +# Exact public log strings from the installed Godot 4.7.2 debug AAR. These +# identify startup progress only; no message or dynamic path is echoed. +STARTUP_PROGRESS_MESSAGES = { + "GodotActivity": { + "Creating new Godot fragment instance.": "godot-fragment-create", + "Reusing existing Godot fragment instance.": "godot-fragment-reuse", + }, + "Godot": { + "Initializing Godot plugin registry": "godot-plugin-registry-initialize", + "Godot native layer initialization completed: true": "godot-native-initialization-result-true", + "Godot native layer initialization completed: false": "godot-native-initialization-result-false", + "Godot native layer setup completed": "godot-native-setup-completed", + "Engine already initialized": "godot-engine-already-initialized", + }, +} +STARTUP_BRIDGE_EVENTS = ("initialize_engine_begin", "initialize_engine_result", + "initialize_engine_already_initialized", "open_game_begin", "open_game_result") +AVD_RESOURCE_KEYS = { + "hw.cpu.ncore": 4096, "hw.ramSize": 2147483647, + "hw.lcd.width": 65536, "hw.lcd.height": 65536, "hw.lcd.density": 65536, +} +AVD_RESOURCE_ENUMS = {"hw.gpu.enabled": ("yes", "no"), "hw.keyboard": ("yes", "no"), + "hw.gpu.mode": ("auto", "host", "off", "swiftshader")} + + +def read_avd_resources(directory: Path | None, filename: str, started_ns: int | None, deadline: float) -> dict: + """Only read selected fields from this session's fresh, owned AVD files.""" + unknown = {key: "unknown" for key in (*AVD_RESOURCE_KEYS, *AVD_RESOURCE_ENUMS)} + result = {"status": "diagnostic_notavailable", "values": {}, "unknown": unknown} + if time.monotonic() >= deadline: + return {**result, "reason": "diagnostic_deadline"} + if directory is None or started_ns is None or filename not in ("config.ini", "hardware-qemu.ini"): + return {**result, "reason": "owned_avd_not_available"} + if directory.parent.is_symlink(): + return {**result, "reason": "owned_avd_not_available"} + try: + directory_fd = os.open(directory, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + descriptor = os.open(filename, os.O_RDONLY | os.O_NONBLOCK | os.O_NOFOLLOW, dir_fd=directory_fd) + try: + before = os.fstat(descriptor) + if not stat.S_ISREG(before.st_mode) or before.st_mtime_ns < started_ns: + return {**result, "reason": "not_fresh_regular_file"} + if before.st_size > 65536: + return {**result, "reason": "file_exceeds_read_limit"} + raw = os.read(descriptor, 65536) + after = os.fstat(descriptor) + if (after.st_size != len(raw) or after.st_size != before.st_size + or after.st_mtime_ns != before.st_mtime_ns): + return {**result, "reason": "file_changed_during_read"} + finally: + os.close(descriptor) + finally: + os.close(directory_fd) + except OSError: + return {**result, "reason": "missing_or_unreadable_file"} + if time.monotonic() >= deadline: + return {**result, "reason": "diagnostic_deadline"} + for raw_line in raw.decode(errors="replace").splitlines(): + if time.monotonic() >= deadline: + return {**result, "status": "diagnostic_notavailable", "values": {}, "reason": "diagnostic_deadline"} + key, separator, value = raw_line.partition("=") + key, value = key.strip(), value.strip() + if not separator or key not in (*AVD_RESOURCE_KEYS, *AVD_RESOURCE_ENUMS): + continue + # Config values are last-wins, including an invalid final value. Never + # publish free text or fall back to an earlier overwritten value. + result["values"].pop(key, None) + unknown[key] = "invalid_value" + if key in AVD_RESOURCE_KEYS and re.fullmatch(r"[0-9]{1,10}", value): + number = int(value) + if 1 <= number <= AVD_RESOURCE_KEYS[key]: + result["values"][key] = number + unknown.pop(key) + elif key in AVD_RESOURCE_ENUMS and value in AVD_RESOURCE_ENUMS[key]: + result["values"][key] = value + unknown.pop(key) + result["status"] = "available" if result["values"] else "diagnostic_notavailable" + return result + + +def cpu_resource_ranges(raw: bytes) -> dict: + """Parse bounded actual sysfs ranges without expanding an arbitrary CPU set.""" + value = raw.decode(errors="replace").strip() + if len(value) > 256 or not re.fullmatch(r"[0-9]{1,4}(?:-[0-9]{1,4})?(?:,[0-9]{1,4}(?:-[0-9]{1,4})?)*", value): + return {"status": "diagnostic_notavailable", "reason": "invalid_cpu_range"} + ranges, previous, count = [], -1, 0 + for item in value.split(","): + parts = [int(number) for number in item.split("-")] + start, end = parts[0], parts[-1] + if not previous < start <= end < 4096: + return {"status": "diagnostic_notavailable", "reason": "invalid_cpu_range"} + ranges.append([start, end]) + count += end - start + 1 + previous = end + return {"status": "available", "ranges": ranges, "count": count} + + +def memory_resource_values(raw: bytes) -> dict: + values, unknown = {}, {"MemTotal": "unknown", "MemAvailable": "unknown"} + for line in raw.decode(errors="replace").splitlines(): + key = line.partition(":")[0] + if key not in unknown and key not in values: + continue + # Reject duplicates rather than reporting an ambiguous memory sample. + if key in values or unknown[key] != "unknown": + values.pop(key, None) + unknown[key] = "duplicate_value" + continue + match = re.fullmatch(r"(?:MemTotal|MemAvailable):\s*([0-9]{1,12})\s+kB", line) + if match: + values[key] = int(match[1]) + unknown.pop(key) + else: + unknown[key] = "invalid_value" + return {"status": "available" if values else "diagnostic_notavailable", "values_kib": values, "unknown": unknown} + + +def startup_line(raw: str) -> str: + line = re.sub(r"\x1b\[[0-?]*[ -/]*[@-~]", "", raw) + return re.sub(r"[\x00-\x08\x0b-\x1f\x7f]", "", line) + + +def startup_text(lines: list[str]) -> dict: + # Only selected startup/error records reach this function, never raw EOF tails. + filtered = ["[redacted environment line]" if re.search( + r"(?:^|:\s*)(?:env(?:ironment)?\b|export\s|[A-Z_][A-Z0-9_]*\s*=)", line, re.IGNORECASE) + else line for raw in lines for line in startup_line(raw).splitlines()] + return bounded_diagnostic("\n".join(filtered), max_chars=512, max_lines=6) + + +def startup_error_text(lines: list[str], deadline: float | None = None) -> dict: + # Preserve the first fatal/header and the final specific causes. Long E-tag + # stacks must not replace a Java loader or Python import error with frames. + deadline = time.monotonic() + 2 if deadline is None else deadline + first = fallback = last = None + causes = [] + timed_out, skipped = time.monotonic() >= deadline, False + for index, line in enumerate(lines[:5000]): + if time.monotonic() >= deadline: + timed_out = True + break + if len(line) > 4096: + skipped = True + continue + lower = line.lower() + is_primary = any(word in lower for word in ("fatal", "traceback", "dlopen failed", "undefined symbol", "no module named")) + is_cause = any(word in lower for word in ("caused by:", "dlopen failed", "undefined symbol", "no module named")) + # One token traversal avoids an unanchored greedy suffix regex which + # rescans a long identifier at every character when no Error is present. + for token in STARTUP_TOKENS.finditer(line): + if time.monotonic() >= deadline: + timed_out = True + break + if token.group().lower().endswith(("error", "exception")): + is_primary = True + is_cause |= line[token.end():token.end() + 1] == ":" + if is_primary and is_cause: + break + if timed_out: + break + if fallback is None: + fallback = index + last = index + if first is None and is_primary: + first = index + if is_cause: + causes = (causes + [index])[-2:] + chosen = list(dict.fromkeys(([first if first is not None else fallback] if fallback is not None else []) + causes)) + if len(chosen) == 1 and chosen[0] != last: + chosen.append(last) + selected = [] + for index in chosen: + safe = startup_text([lines[index]])["output"] + selected.append(safe if len(safe) <= 192 else safe[:64] + " ... " + safe[-123:]) + result = bounded_diagnostic("\n".join(selected), max_chars=768, max_lines=6) + result["truncated"] |= timed_out or skipped or len(chosen) < len(lines) or any(len(lines[index]) > 192 for index in chosen) + if timed_out: + result["diagnostic_deadline_reached"] = True + return result + + +def startup_progress_text(records: dict[str, list[str]], pids: list[str], deadline: float) -> dict: + """Select fixed phases from this failure's real pidof-bound, fresh logs.""" + result = {"source": "actual-current-pid-startup-diagnostic", "diagnostic_only": True, + "status": "diagnostic_notavailable", "output": "", "redacted_lines": 0, "truncated": False} + if not pids: + return result + selected, seen = [], 0 + for name in ("android-process-logcat.txt", "aetherkiri-engine.log"): + for line in records.get(name, []): + if time.monotonic() >= deadline: + result["diagnostic_deadline_reached"] = True + break + if len(line) > 4096 or SENSITIVE_DIAGNOSTIC.search(line): + result["redacted_lines"] += 1 + continue + stage = "" + if name == "android-process-logcat.txt": + row = LOGCAT_ROW.match(line) + if not row: + continue + pid, priority, tag, message = row.groups() + tag = tag.strip() + if pid not in pids or priority not in ("V", "D", "I"): + continue + stage = STARTUP_PROGRESS_MESSAGES.get(tag, {}).get(message.strip(), "") + if tag == "AetherKiriBridge": + # These events are compiled in aether_runtime_player.cpp. + # Writable/cache/game paths and result/error text stay private. + event = re.match(r"event=([a-z_]+)(?:\s|$)", message) + if event and event[1] in STARTUP_BRIDGE_EVENTS: + stage = "bridge-" + event[1].replace("_", "-") + rendered = f"pid={pid} tag={tag} stage={stage}" if stage else "" + else: + # Existing dispatcher's per-game attachment message. Record a + # phase only, not the game path or a claim of rendered pixels. + if re.search(r"(?:^|\]\s*)aetherkiri provider engine log attached:\s", line): + stage = "provider-engine-log-attached" + rendered = "file=aetherkiri-engine.log stage=" + stage if stage else "" + if rendered: + seen += 1 + selected = (selected + [rendered])[-6:] + if result.get("diagnostic_deadline_reached"): + break + bounded = bounded_diagnostic("\n".join(selected), max_chars=768, max_lines=6) + result.update(output=bounded["output"], truncated=bounded["truncated"] or seen > 6) + if selected: + result["status"] = "diagnostic_available" + return result + + +def read_startup_lines(path: Path, started_ns: int, deadline: float, *, read_limit: int = 262144) -> tuple[dict, list[str]]: + """Read a bounded prefix of a fresh regular file produced by this harness.""" + if time.monotonic() >= deadline: + return {"status": "diagnostic_notavailable", "reason": "diagnostic_deadline"}, [] + try: + descriptor = os.open(path, os.O_RDONLY | os.O_NONBLOCK | os.O_NOFOLLOW) + try: + details = os.fstat(descriptor) + if not stat.S_ISREG(details.st_mode) or details.st_mtime_ns < started_ns: + return {"status": "diagnostic_notavailable", "reason": "not_fresh_regular_file"}, [] + raw = os.read(descriptor, read_limit) + finally: + os.close(descriptor) + except OSError: + return {"status": "diagnostic_notavailable", "reason": "missing_or_unreadable_file"}, [] + bytes_read = len(raw) + truncated = details.st_size > bytes_read + if truncated: + raw = raw.rpartition(b"\n")[0] # Never expose a cut line with hidden sensitive text. + lines = raw.decode(errors="replace").splitlines() + bounded = [startup_line(line) for line in lines[:5000] if len(line) <= 4096] + return {"status": "available", "bytes_read": bytes_read, "prefix_bytes_processed": len(raw), + "truncated": truncated or len(lines) > 5000 or len(bounded) != len(lines)}, bounded + + +def read_current_harness_result(path: Path, started_ns: int | None) -> dict: + """Never accept an old or redirected result, even with an identical APK tuple.""" + if started_ns is None or path.parent.is_symlink(): + raise Unavailable("Current APK harness result ownership could not be verified") + try: + descriptor = os.open(path, os.O_RDONLY | os.O_NONBLOCK | os.O_NOFOLLOW) + try: + before = os.fstat(descriptor) + if not stat.S_ISREG(before.st_mode) or before.st_mtime_ns < started_ns: + raise Unavailable("Actual APK harness result is not a fresh current regular file") + if before.st_size > 262144: + raise Unavailable("Actual APK harness result exceeds its 256KiB read limit") + raw = os.read(descriptor, 262144) + after = os.fstat(descriptor) + if after.st_mtime_ns < started_ns or after.st_size != len(raw) \ + or after.st_mtime_ns != before.st_mtime_ns or after.st_size != before.st_size: + raise Unavailable("Actual APK harness result changed during its bounded read") + finally: + os.close(descriptor) + except OSError as exc: + raise Unavailable("Actual APK harness did not provide a readable current result without symlinks") from exc + try: + summary = json.loads(raw.decode("utf-8")) + except (ValueError, RecursionError) as exc: + raise Unavailable("Actual APK harness current result is not valid bounded JSON") from exc + if not isinstance(summary, dict): + raise Unavailable("Actual APK harness current result is not a JSON object") + return summary + + +def os_screen_stats(image: tuple[int, int, bytes, int]) -> dict: + """Require visible OS content, without applying the Ren'Py scene palette gate.""" + width, height, data, channels = image + if width <= 0 or height <= 0 or width * height > 30_000_000 or channels not in (3, 4) \ + or len(data) != width * height * channels: + raise Failed("Invalid decoded Android OS screenshot dimensions or pixels") + colors, luma = set(), [] + for y in range(0, height, max(1, height // 64)): + for x in range(0, width, max(1, width // 64)): + offset = (y * width + x) * channels + rgb = tuple(data[offset:offset + 3]) + colors.add(rgb) + luma.append(sum(rgb) / 3) + stats = {"width": width, "height": height, "sampled_colors": len(colors), + "luma_range": max(luma) - min(luma)} + # A monochrome status bar can be valid OS content. A uniform frame or + # low-contrast noise cannot demonstrate that the display is ready. + stats["ready"] = stats["sampled_colors"] >= 2 and stats["luma_range"] >= 40 + return stats + + +def bounded_diagnostic(raw: bytes | str, *, max_chars: int = 4096, max_lines: int = 32) -> dict: + """Keep a small sanitized tail of only this session's actual output.""" + text = raw.decode(errors="replace") if isinstance(raw, bytes) else raw + text = re.sub(r"\x1b\[[0-?]*[ -/]*[@-~]", "", text) + lines = text.splitlines() + selected = lines[-max_lines:] + output = [] + redacted = 0 + for line in selected: + line = re.sub(r"[\x00-\x08\x0b-\x1f\x7f]", "", line) + if SENSITIVE_DIAGNOSTIC.search(line): + output.append("[redacted sensitive diagnostic line]") + redacted += 1 + else: + output.append(line[:512]) + joined = "\n".join(output) + return {"output": joined[-max_chars:], "redacted_lines": redacted, + "truncated": len(lines) > max_lines or any(len(line) > 512 for line in selected) + or len(joined) > max_chars} + + +def diagnostic_file_tail(path: Path, *, read_limit: int = 65536) -> dict: + # Never read a previous artifact or an unbounded emulator/kernel log. + try: + with path.open("rb") as stream: + size = stream.seek(0, os.SEEK_END) + stream.seek(max(0, size - read_limit)) + raw = stream.read(read_limit) + if size > read_limit: + # A cut line may have lost its sensitive header. Drop it whole. + raw = raw.partition(b"\n")[2] + result = bounded_diagnostic(raw) + result["truncated"] |= size > read_limit + return result + except OSError as exc: + return {"unavailable": True, **bounded_diagnostic(str(exc), max_chars=256, max_lines=1)} + + +def acceleration_usable(output: str, system: str) -> bool: + lines = [line.strip() for line in output.splitlines() if line.strip()] + try: + start = lines.index("accel:") + except ValueError: + return False + if len(lines) < start + 3 or lines[start + 1] != "0": + return False + description = lines[start + 2] + lower = description.lower() + if re.search(r"unavailable|unsupported|unusable|not\s+(?:available|installed|usable)|" + r"could\s+not|failed|disabled|error", lower): + return False + if system == "Darwin": + documented = re.fullmatch(r"Hypervisor\.Framework OS X Version \d+(?:\.\d+)*", description) + explicit = re.search(r"\b(?:hvf|hypervisor\.framework)\b", lower) and re.search( + r"\b(?:installed and usable|available and usable|is usable)\b", lower) + return bool(documented or explicit) + return system == "Linux" and bool(re.search(r"\bkvm\b", lower) + and re.search(r"\b(?:installed and usable|is usable)\b", lower)) + + +def owned_group_state(process: subprocess.Popen) -> str: + """The caller must have created this Popen with start_new_session=True.""" + if getattr(process, "_renpy_group_permission_denied", False): + return "permission_denied" + try: + os.killpg(process.pid, 0) + except ProcessLookupError: + return "absent" + except PermissionError: + # Unknown is never absent. Do not retry a denied probe or signal. + process._renpy_group_permission_denied = True + return "permission_denied" + return "present" + + +def owned_group_exists(process: subprocess.Popen) -> bool: + return owned_group_state(process) != "absent" + + +def wait_owned_group_exit(process: subprocess.Popen, grace: float) -> bool: + deadline = time.monotonic() + grace + while True: + state = owned_group_state(process) + if state == "absent": + return True + if state == "permission_denied": + return False + if time.monotonic() >= deadline: + return False + time.sleep(min(0.05, max(0, deadline - time.monotonic()))) + + +def terminate_owned_process(process: subprocess.Popen, grace: float = 10) -> int: + """Signal all members of our group and reap our direct child. + + The group can outlive its leader. Orphaned descendants are reaped by their + OS parent, so leader.wait() alone is insufficient to terminate that group. + """ + if owned_group_state(process) == "present": + try: + os.killpg(process.pid, signal.SIGTERM) + except ProcessLookupError: + pass + except PermissionError: + process._renpy_group_permission_denied = True + try: + process.wait(timeout=grace) + except subprocess.TimeoutExpired: + pass + if owned_group_state(process) == "permission_denied" and process.poll() is None: + raise Unavailable("Owned process-group probe/signal permission was denied; no permission retry was made") + if not wait_owned_group_exit(process, grace) and owned_group_state(process) != "permission_denied": + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + except PermissionError: + process._renpy_group_permission_denied = True + return process.wait(timeout=grace) + + +def gameplay_result_matches(summary: dict, code: int, package: str, serial: str, apk_hash: str) -> bool: + return isinstance(summary, dict) and code == 0 and summary.get("status") == "passed" \ + and summary.get("source") == "actual-apk-adb-cloud-device" and summary.get("serial") == serial \ + and summary.get("package") == package and summary.get("apk_sha256") == apk_hash + + +class Session(Preflight): + def __init__(self, args: argparse.Namespace): + super().__init__(args) + self.harness_process = None + self.harness_log = None + self.harness_started_ns = None + self.harness_apk_sha256 = None + self.avd_directory = None + self.avd_started_ns = None + self.boot_started = None + self.next_boot_progress = 0 + self.result.update(scope="actual-cloud-android-emulator-session", + gameplay_verification="not_run", gameplay_attempted=False, + gameplay_verified=False, harness_started=False, + process_started=False, + requested_gameplay=args.apk is not None) + + def boot_progress(self, phase: str, *, response: subprocess.CompletedProcess | None = None) -> None: + now = time.monotonic() + self.result["boot_elapsed_seconds"] = round(now - self.boot_started, 1) + self.result["boot_phase"] = phase + if response is not None: + self.result["last_boot_response"] = {"returncode": response.returncode, + **bounded_diagnostic(response.stdout, max_chars=256, max_lines=2)} + if now >= self.next_boot_progress: + print(json.dumps({"checkpoint": "actual-android-boot-progress", "phase": phase, + "elapsed_seconds": self.result["boot_elapsed_seconds"], + "serial": self.result["serial"], + "emulator_returncode": self.emulator_process.poll(), + "last_boot_response": self.result.get("last_boot_response"), + "actual_os_screen": self.result.get("actual_os_screen"), + "gameplay_verification": "not_run"}), flush=True) + self.next_boot_progress = now + 30 + + def diagnostic_command(self, *parts: str, timeout: float = 3, max_chars: int = 1024, + deadline: float | None = None) -> dict: + started = time.monotonic() + if deadline is not None: + timeout = min(timeout, deadline - started) + if timeout <= 0: + return {"unavailable": True, "reason": "diagnostic_deadline", "elapsed_seconds": 0} + try: + completed = subprocess.run([str(self.adb), "-s", self.result["serial"], *parts], + stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + env=self.environment, timeout=timeout) + result = {"returncode": completed.returncode, + **bounded_diagnostic(completed.stdout, max_chars=max_chars, max_lines=20)} + except subprocess.TimeoutExpired as exc: + result = {"timed_out": True, **bounded_diagnostic(exc.stdout or b"", max_chars=max_chars, max_lines=20)} + except OSError as exc: + result = {"unavailable": True, **bounded_diagnostic(str(exc), max_chars=256, max_lines=1)} + result["elapsed_seconds"] = round(time.monotonic() - started, 1) + return result + + def resource_command(self, command: list[str], deadline: float) -> tuple[dict, bytes]: + remaining = deadline - time.monotonic() + if remaining <= 0: + return {"status": "diagnostic_notavailable", "reason": "diagnostic_deadline"}, b"" + try: + completed = subprocess.run(command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, env=self.environment, timeout=min(2, remaining)) + except subprocess.TimeoutExpired: + return {"status": "diagnostic_notavailable", "reason": "command_timeout"}, b"" + except OSError: + return {"status": "diagnostic_notavailable", "reason": "command_unavailable"}, b"" + report = {"returncode": completed.returncode, "status": "diagnostic_notavailable"} + if time.monotonic() >= deadline: + return {**report, "reason": "diagnostic_deadline"}, b"" + if completed.returncode != 0: + return {**report, "reason": "command_failed"}, b"" + if len(completed.stdout) > 4096: + return {**report, "reason": "command_output_exceeds_limit"}, b"" + return {**report, "status": "captured"}, completed.stdout + + def resource_diagnostics(self, deadline: float) -> dict: + """Only fixed current-session numeric/enum samples; never raw command text.""" + sample = {"avd": {filename: read_avd_resources(self.avd_directory, filename, self.avd_started_ns, deadline) + for filename in ("config.ini", "hardware-qemu.ini")}} + if platform.system() == "Darwin": + keys = ("hw.logicalcpu", "hw.physicalcpu", "hw.memsize") + report, raw = self.resource_command(["/usr/sbin/sysctl", "-n", *keys], deadline) + if report["status"] == "captured": + values, unknown = {}, {key: "unknown" for key in keys} + lines = raw.decode(errors="replace").splitlines() + if len(lines) == 3: + for key, value in zip(keys, lines): + limit = 9007199254740991 if key == "hw.memsize" else 4096 + if re.fullmatch(r"[0-9]{1,16}", value) and 1 <= int(value) <= limit: + values[key] = int(value) + unknown.pop(key) + else: + unknown[key] = "invalid_value" + report.update(status="available" if values else "diagnostic_notavailable", + values=values, unknown=unknown) + sample["host"] = report + else: + sample["host"] = {"status": "diagnostic_notavailable", "reason": "host_not_darwin"} + sample["guest"] = {} + prefix = [str(self.adb), "-s", self.result["serial"], "shell", "cat"] + for name in ("online", "present"): + report, raw = self.resource_command([*prefix, "/sys/devices/system/cpu/" + name], deadline) + if report["status"] == "captured": + report.update(cpu_resource_ranges(raw)) + sample["guest"][name] = report + report, raw = self.resource_command([*prefix, "/proc/meminfo"], deadline) + if report["status"] == "captured": + report.update(memory_resource_values(raw)) + sample["guest"]["memory"] = report + return sample + + def boot_failure_diagnostics(self) -> None: + """Read the currently owned AVD before stop; never dump its environment.""" + started = time.monotonic() + deadline = started + 20 + diagnostics = {"checkpoint": "actual-android-boot-failure-diagnostics", + "boot_elapsed_seconds": round(started - self.boot_started, 1), + "phase": self.result.get("boot_phase"), "serial": self.result["serial"], + "emulator_returncode": self.emulator_process.poll(), + "last_boot_response": self.result.get("last_boot_response"), + "gameplay_verification": "not_run", + "emulator_log_tail": diagnostic_file_tail(self.output / "emulator.log")} + diagnostics["adb_state"] = self.diagnostic_command("get-state", deadline=deadline) + diagnostics["resources"] = self.resource_diagnostics(deadline) + diagnostics["boot_properties"] = { + name: self.diagnostic_command("shell", "getprop", name, max_chars=256, deadline=deadline) + for name in ("sys.boot_completed", "dev.bootcomplete", "init.svc.bootanim", "ro.build.version.sdk") + } + # Read at most 80 current warning/error entries, without clearing logcat. + diagnostics["logcat_warning_tail"] = self.diagnostic_command( + "logcat", "-d", "-t", "80", "-v", "brief", "*:W", timeout=5, max_chars=3072, deadline=deadline) + diagnostics["diagnostic_elapsed_seconds"] = round(time.monotonic() - started, 1) + diagnostics["diagnostic_deadline_reached"] = time.monotonic() >= deadline + # Resource samples are fixed fields. Bound the entire existing boot + # console record, including escaped diagnostic text, to 8KiB. + serialized = json.dumps(diagnostics, separators=(",", ":")) + text_reports = [diagnostics["emulator_log_tail"], diagnostics["adb_state"], + diagnostics["logcat_warning_tail"], *diagnostics["boot_properties"].values()] + if diagnostics["last_boot_response"]: + text_reports.append(diagnostics["last_boot_response"]) + while len(serialized.encode()) > 8191: + candidates = [report for report in text_reports if report.get("output")] + if not candidates: + diagnostics["resources"] = {"status": "diagnostic_notavailable", "reason": "console_size_limit"} + serialized = json.dumps(diagnostics, separators=(",", ":")) + break + largest = max(candidates, key=lambda report: len(report["output"])) + largest["output"] = largest["output"][-(len(largest["output"]) // 2):] if len(largest["output"]) > 1 else "" + largest["truncated"] = True + serialized = json.dumps(diagnostics, separators=(",", ":")) + self.result["boot_failure_diagnostics"] = diagnostics + print(serialized, flush=True) + + def startup_failure_diagnostics(self) -> None: + """Print selected fresh local evidence only; this performs no device/network calls.""" + payload = {"checkpoint": "actual-apk-startup-failure-diagnostics", "status": "diagnostic_notavailable", + "gameplay_verified": False, "files": {}, "commands": []} + output = self.output / "gameplay" + if self.harness_started_ns is None or output.is_symlink(): + payload["reason"] = "no_owned_harness_output" + else: + deadline = time.monotonic() + 2 + records = {} + for name in STARTUP_FILES: + metadata, lines = read_startup_lines(output / name, self.harness_started_ns, deadline, + read_limit=4096 if name == "launch.txt" else 262144) + payload["files"][name] = metadata + records[name] = lines + own_pids = set() + snapshot = self.current_failure_snapshot() + payload["failure_snapshot"] = snapshot + own_pids.update(snapshot.get("diagnostic_pids", [])) + payload["startup_progress"] = startup_progress_text(records, snapshot.get("diagnostic_pids", []), deadline) + prefix = [str(self.adb), "-s", self.result["serial"]] + for line in records.get("commands.jsonl", []): + if time.monotonic() >= deadline: + payload["diagnostic_deadline_reached"] = True + break + try: + row = json.loads(line) + except ValueError: + continue + if not isinstance(row, dict) or not isinstance(row.get("command"), list) \ + or row["command"][:3] != prefix or not isinstance(row.get("time"), (int, float)) \ + or row["time"] < self.harness_started_ns / 1_000_000_000: + continue + parts = row["command"][3:] + if not all(isinstance(part, str) for part in parts): + continue + if parts[:1] == ["logcat"] and "--pid" in parts: + index = parts.index("--pid") + 1 + if index < len(parts) and re.fullmatch(r"[1-9]\d{0,9}", parts[index]) \ + and int(parts[index]) <= 2147483647: + own_pids.add(parts[index]) + phase = "" + if parts[:2] == ["install", "-r"] and len(parts) == 3 \ + and self.args.apk is not None and parts[2] == str(self.args.apk.resolve()): + phase = "apk_install" + elif parts[:1] == ["shell"] and len(parts) == 2: + try: + remote = shlex.split(parts[1]) + except ValueError: + continue + if remote == ["pidof", self.args.package]: + phase = "app_pid_observation" + elif remote[:4] == ["am", "start", "-W", "-n"] and len(remote) == 5 \ + and remote[4].startswith(self.args.package + "/") \ + and re.fullmatch(r"[A-Za-z0-9_.$/]+", remote[4]): + phase = "app_start" + code = row.get("returncode") + if phase and isinstance(code, int) and not isinstance(code, bool) and -255 <= code <= 255 \ + and len(payload["commands"]) < 8: + payload["commands"].append({"phase": phase, "returncode": code, + "stdout": "not_recorded_by_command_log", + "stderr": startup_text([str(row.get("stderr", ""))])}) + owned_launch = self.result.get("actual_status", {}).get("app_launch", {}) + if owned_launch.get("status") == "passed": + own_pids.update(owned_launch["pids"]) + # A crash can precede pidof. Only an AndroidRuntime Process header + # with both the exact package and matching header/body PID binds + # subsequent causes. This is diagnostic attribution, not launch proof. + for line in records.get("android-system-logcat.txt", []): + if time.monotonic() >= deadline: + break + row = LOGCAT_ROW.match(line) + if not row: + continue + pid, _, tag, message = row.groups() + match = re.fullmatch(r"Process:\s*" + re.escape(self.args.package) + r",\s*PID:\s*([1-9][0-9]{0,9})\s*", message) + if (tag.strip() == "AndroidRuntime" and match and pid == match[1] + and int(pid) <= 2147483647 and len(own_pids) < 8): + own_pids.add(pid) + payload["diagnostic_bound_process_pids"] = sorted(own_pids)[:8] + payload["process_pid_semantics"] = "diagnostic-attribution-only" + install_lines = records.get("install.txt", []) + payload["installation_success_marker"] = any(line.strip() == "Success" for line in install_lines) + for name in STARTUP_FILES[2:]: + selected = [] + reported_statuses = [] + for line in records[name]: + if time.monotonic() >= deadline: + payload["diagnostic_deadline_reached"] = True + break + if name == "launch.txt": + match = re.fullmatch(r"\s*Status:\s*(\S+)\s*", line, re.IGNORECASE) + if match: + value = match[1].lower() + value = value if value in ("ok", "timeout", "error", "failed") else "unrecognized" + if value not in reported_statuses: + reported_statuses.append(value) + if not STARTUP_ERROR.search(line): + continue + elif "logcat" in name: + parsed = LOGCAT_ROW.match(line) + if not parsed: + continue + pid, priority, _, message = parsed.groups() + if pid not in own_pids and not re.search( + r"(? 6143: + candidates = [row for row in payload["files"].values() if row.get("output")] + candidates += [row["stderr"] for row in payload["commands"] if row["stderr"].get("output")] + if payload.get("startup_progress", {}).get("output"): + candidates.append(payload["startup_progress"]) + if not candidates: + # A schema regression must not create an infinite trim loop. + payload = {"checkpoint": "actual-apk-startup-failure-diagnostics", + "status": "diagnostic_notavailable", "gameplay_verified": False, + "files": {}, "commands": [], "reason": "console_size_limit"} + serialized = json.dumps(payload, separators=(",", ":")) + break + else: + largest = max(candidates, key=lambda row: len(row["output"])) + largest["output"] = largest["output"][:len(largest["output"]) // 2] + largest["truncated"] = True + serialized = json.dumps(payload, separators=(",", ":")) + self.result["startup_diagnostics"] = {"status": payload["status"], "console_bytes": len(serialized.encode()) + 1, + "files_available": sum(row["status"] == "available" + for row in payload["files"].values()), + "gameplay_verified": False} + print(serialized, flush=True) + + def current_failure_snapshot(self) -> dict: + """Select matched fresh-result diagnostic fields without upgrading gameplay.""" + unavailable = {"status": "diagnostic_notavailable", "diagnostic_only": True} + summary = self.result.get("gameplay_result", {}) + report = summary.get("failure_diagnostics", {}) if isinstance(summary, dict) else {} + installed = self.result.get("actual_status", {}).get("apk_install", {}) + if (not isinstance(report, dict) or report.get("source") != "actual-adb-failure-diagnostic" + or summary.get("status") != "failed" or summary.get("apk_installed") is not True + or self.result.get("apk_installed") is not True or installed.get("status") != "passed" + or installed.get("source") != "actual-adb-command" or type(installed.get("returncode")) is not int + or installed["returncode"] != 0 or installed.get("stdout_marker") != "Success" + or installed.get("package") != self.args.package or installed.get("serial") != self.result["serial"] + or report.get("diagnostic_only") is not True or summary.get("source") != "actual-apk-adb-cloud-device" + or not isinstance(summary.get("run_id"), str) or not re.fullmatch(r"[a-f0-9]{32}", summary["run_id"]) + or report.get("run_id") != summary["run_id"]): + return unavailable + for key, expected in (("package", self.args.package), ("serial", self.result["serial"]), + ("apk_sha256", self.harness_apk_sha256)): + if expected is None or summary.get(key) != expected or report.get(key) != expected: + return unavailable + if not re.fullmatch(r"[0-9a-f]{64}", self.harness_apk_sha256): + return unavailable + selected = {"status": "diagnostic_available", "source": report["source"], "diagnostic_only": True} + pid = report.get("pid_sample", {}) + pids = pid.get("pids", []) if isinstance(pid, dict) else [] + if isinstance(pid, dict): + if pid.get("status") in ("available", "not_available"): + selected["pid_status"] = pid["status"] + if type(pid.get("returncode")) is int and -255 <= pid["returncode"] <= 255: + selected["pid_returncode"] = pid["returncode"] + for flag in ("cleanup_permission_denied", "collector_not_reaped"): + if type(pid.get(flag)) is bool: + selected[flag] = pid[flag] + if (isinstance(pid, dict) and pid.get("status") == "available" and type(pid.get("returncode")) is int + and pid["returncode"] == 0 and isinstance(pids, list) and 0 < len(pids) <= 8 + and type(pid.get("pid_count")) is int and len(pids) <= pid["pid_count"] <= 4096 + and all(isinstance(value, str) and re.fullmatch(r"[1-9][0-9]{0,9}", value) + and int(value) <= 2147483647 for value in pids) and len(set(pids)) == len(pids)): + selected["diagnostic_pids"] = pids + activity = report.get("activity_sample", {}) + if isinstance(activity, dict) and activity.get("status") in ("available", "not_available"): + selected["activity_status"] = activity["status"] + if (isinstance(activity, dict) and activity.get("status") == "available" + and type(activity.get("returncode")) is int and activity["returncode"] == 0 + and activity.get("exact_component") == summary.get("activity") + and isinstance(summary.get("activity"), str) and summary["activity"].startswith(self.args.package + "/") + and len(summary["activity"]) <= 256 + and re.fullmatch(r"[A-Za-z0-9_.$]+/[A-Za-z0-9_.$]+", summary["activity"])): + states = [] + for row in activity.get("states", [])[:4] if isinstance(activity.get("states"), list) else []: + if not isinstance(row, dict): + continue + state = {} + if row.get("state") in ("INITIALIZING", "STARTED", "RESUMED", "PAUSING", "PAUSED", "STOPPING", "STOPPED", "DESTROYING", "DESTROYED", "RESTARTING_PROCESS"): + state["state"] = row["state"] + for name in ("mVisibleRequested", "mVisible", "allDrawn", "firstWindowDrawn"): + if type(row.get(name)) is bool: + state[name] = row[name] + if state: + states.append(state) + selected["activity_states"] = states + while len(json.dumps(selected).encode()) > 768 and selected.get("activity_states"): + selected["activity_states"].pop() + if len(json.dumps(selected).encode()) > 768: + return unavailable + return selected + + def retain_partial_milestones(self, summary: dict, apk_hash: str) -> None: + if summary.get("source") != "actual-apk-adb-cloud-device" or summary.get("package") != self.args.package \ + or summary.get("serial") != self.result["serial"] or summary.get("apk_sha256") != apk_hash: + return + stages = summary.get("actual_status") + if not isinstance(stages, dict): + return + mirrored = {} + for stage, flag, marker in (("apk_install", "apk_installed", "Success"), + ("app_launch", "process_started", "Status: ok")): + proof = stages.get(stage) + if not isinstance(proof, dict) or summary.get(flag) is not True or proof.get("status") != "passed" \ + or proof.get("source") != "actual-adb-command" or proof.get("package") != self.args.package \ + or proof.get("serial") != self.result["serial"] or not isinstance(proof.get("returncode"), int) \ + or isinstance(proof.get("returncode"), bool) \ + or proof.get("returncode") != 0 or proof.get("stdout_marker") != marker: + continue + row = {key: proof[key] for key in ("status", "source", "package", "serial", "returncode", "stdout_marker")} + if stage == "app_launch": + activity, pids, count = proof.get("activity"), proof.get("pids"), proof.get("pid_count") + if not self.result["apk_installed"] or not isinstance(activity, str) or len(activity) > 256 \ + or not activity.startswith(self.args.package + "/") \ + or not re.fullmatch(r"[A-Za-z0-9_.$/]+", activity) or not isinstance(pids, list) \ + or not 1 <= len(pids) <= 8 or not all(isinstance(pid, str) and re.fullmatch(r"[1-9]\d{0,9}", pid) + and int(pid) <= 2147483647 + for pid in pids) \ + or isinstance(count, bool) or not isinstance(count, int) or not len(pids) <= count <= 1024: + continue + row.update(activity=activity, pids=pids, pid_count=count) + self.result[flag] = True + mirrored[stage] = row + if mirrored: + self.result["actual_status"] = mirrored + + def gate_host(self, sdk: Path) -> Path: + system, machine = platform.system(), platform.machine() + if machine != "x86_64" or system not in ("Linux", "Darwin"): + raise Unavailable("An actual Linux x86_64/KVM or Intel macOS/HVF cloud runner is required") + if system == "Linux": + kvm = Path("/dev/kvm") + self.result["kvm"] = {"exists": kvm.exists(), "readable": os.access(kvm, os.R_OK), + "writable": os.access(kvm, os.W_OK)} + if kvm.exists(): + self.result["kvm"]["mode"] = oct(kvm.stat().st_mode & 0o777) + if not all(self.result["kvm"][key] for key in ("exists", "readable", "writable")): + raise Unavailable("Existing /dev/kvm read/write access is unavailable; no permissions were changed") + self.result["hypervisor"] = "KVM" + else: + support = self.command("host-hvf-support", ["/usr/sbin/sysctl", "-n", "kern.hv_support"]) + self.result["kern_hv_support"] = support.stdout.decode(errors="replace").strip() + if self.result["kern_hv_support"] != "1": + raise Unavailable("The actual Intel macOS runner does not expose kern.hv_support=1") + self.result["hypervisor"] = "HVF" + emulator = sdk / "emulator/emulator" + if not emulator.is_file() or not os.access(emulator, os.X_OK): + raise Unavailable(f"The existing official emulator is unavailable: {emulator}; no emulator was downloaded") + self.command("emulator-version", [str(emulator), "-version"], timeout=45) + check = self.command("acceleration", [str(emulator), "-accel-check"], check=False, timeout=45) + output = check.stdout.decode(errors="replace") + self.result["acceleration"] = {"returncode": check.returncode, "output": output[:1600], + "output_truncated": len(output) > 1600} + if check.returncode or len(output) > 1600 or not acceleration_usable(output, system): + raise Unavailable(f"Google's actual acceleration report does not confirm usable {self.result['hypervisor']}") + self.result["checks"].append("official-emulator-" + self.result["hypervisor"].lower() + "-usable") + print(json.dumps({"checkpoint": "actual-acceleration-usable", "hypervisor": self.result["hypervisor"], + "gameplay_verification": "not_run"}), flush=True) + return emulator + + def prepare_sdk(self, sdk: Path) -> Path: + manager, avdmanager = (sdk / "cmdline-tools/latest/bin" / name for name in ("sdkmanager", "avdmanager")) + for tool in (manager, avdmanager): + if not tool.is_file() or not os.access(tool, os.X_OK): + raise Unavailable(f"An existing official SDK tool is missing: {tool}") + # Inherited command() uses DEVNULL, not 'yes' or sdkmanager --licenses. + installed = self.command("official-sdk-install", [str(manager), "--install", "platform-tools", IMAGE], + timeout=600, check=False) + properties = sdk / "system-images/android-35/google_apis/x86_64/source.properties" + self.adb = sdk / "platform-tools/adb" + if installed.returncode or not properties.is_file() or not self.adb.is_file(): + detail = installed.stdout.decode(errors="replace")[-1800:] + if re.search(r"licen[cs]es?.*(?:not accepted|not been accepted)|" + r"(?:accept|review).*licen[cs]e", detail, re.IGNORECASE): + raise Unavailable("Official SDK installation is blocked by missing existing license acceptance; " + "no new license was accepted. See official-sdk-install.log: " + detail) + raise Unavailable(f"Official SDK installation did not provide the requested image/tools " + f"(exit {installed.returncode}); see official-sdk-install.log: {detail}") + (self.output / "system-image-source.properties").write_bytes(properties.read_bytes()) + self.command("adb-version", [str(self.adb), "version"]) + self.result.update(sdk_root=str(sdk), system_image=IMAGE) + self.environment["PATH"] = os.pathsep.join((str(sdk / "platform-tools"), str(sdk / "emulator"), + self.environment.get("PATH", ""))) + return avdmanager + + def boot(self, emulator: Path, avdmanager: Path, avd_root: Path) -> None: + avd_root.mkdir() + self.environment["ANDROID_AVD_HOME"] = str(avd_root) + name = "aether-renpy-cloud-session" + self.avd_started_ns = time.time_ns() + self.avd_directory = avd_root / f"{name}.avd" + self.command("create-official-avd", [str(avdmanager), "create", "avd", "--name", name, "--force", + "--device", "pixel_2", "--package", IMAGE], input_data=b"no\n", timeout=120) + config = avd_root / f"{name}.avd/config.ini" + if not config.is_file(): + raise Unavailable("Official avdmanager did not create the requested AVD") + with config.open("a") as stream: + stream.write("\nhw.keyboard=no\n") + (self.output / "avd-config.ini").write_bytes(config.read_bytes()) + command = [str(emulator), "-avd", name, "-port", str(self.args.port), "-accel", "on", + "-gpu", "swiftshader", "-no-window", "-no-audio", "-no-boot-anim", "-no-snapshot", + "-memory", "3072", "-camera-front", "none", "-camera-back", "none"] + # Official boot diagnostics only; acceleration, graphics and timeout stay unchanged. + # https://developer.android.com/studio/run/emulator-commandline + command.append("-show-kernel") + self.result["emulator_command"] = command + self.emulator_log = (self.output / "emulator.log").open("wb") + self.emulator_process = subprocess.Popen(command, stdout=self.emulator_log, stderr=subprocess.STDOUT, + stdin=subprocess.DEVNULL, env=self.environment, start_new_session=True) + self.execution_started = True + self.result["execution_started"] = True + self.boot_started = time.monotonic() + deadline = self.boot_started + self.args.boot_timeout + adb_deadline = self.boot_started + min(180, self.args.boot_timeout) + while time.monotonic() < adb_deadline: + self.boot_progress("waiting-for-adb") + try: + self.device("adb-wait-for-device", "wait-for-device", + timeout=min(30, max(0.1, adb_deadline - time.monotonic()))) + break + except Unavailable as exc: + if not isinstance(exc.__cause__, subprocess.TimeoutExpired): + raise + else: + raise Unavailable("Actual ADB did not connect within its existing boot wait deadline; see emulator.log") + while time.monotonic() < deadline: + self.boot_progress("waiting-for-sys.boot_completed") + if self.emulator_process.poll() is not None: + raise Unavailable(f"Actual emulator exited before boot: {self.emulator_process.returncode}; see emulator.log") + remaining = deadline - time.monotonic() + completed = self.device("boot-completed", "shell", "getprop", "sys.boot_completed", + timeout=min(20, max(0.1, remaining)), check=False) + self.boot_progress("waiting-for-sys.boot_completed", response=completed) + if completed.returncode: + raise Unavailable(f"Actual ADB boot property read failed (exit {completed.returncode}); see boot-completed.log") + if completed.stdout.decode().strip() == "1": + self.result["boot_completed"] = True + break + time.sleep(min(2, max(0, deadline - time.monotonic()))) + else: + raise Unavailable(f"Actual emulator did not boot within {self.args.boot_timeout}s; see emulator.log") + self.validate_booted_device(deadline) + + def boot_device(self, label: str, *parts: str, deadline: float, timeout: int = 30): + """Every readiness command uses the original emulator boot deadline.""" + remaining = deadline - time.monotonic() + if remaining <= 0: + raise Unavailable("Actual Android OS display was not verified before the original boot deadline") + return self.device(label, *parts, timeout=min(timeout, remaining)) + + def wait_os_screen(self, deadline: float) -> None: + screenshot = self.output / "actual-android-screen.png" + self.result["os_screen_attempts"] = 0 + self.result["os_screen_observations"] = [] + while True: + captured = self.boot_device("screen-capture", "exec-out", "screencap", "-p", deadline=deadline) + screenshot.write_bytes(captured.stdout) + # Failed adb commands, permission errors and malformed PNGs are + # fatal. Only a real, decoded but not-yet-visible frame is retried. + try: + stats = os_screen_stats(png_pixels(screenshot)) + except (struct.error, zlib.error) as exc: + raise Failed("Actual Android OS screenshot has malformed PNG data") from exc + self.result["os_screen_attempts"] += 1 + observation = {"attempt": self.result["os_screen_attempts"], + "elapsed_seconds": round(time.monotonic() - self.boot_started, 1), **stats} + self.result["actual_os_screen"] = observation + self.result["os_screen_observations"] = (self.result["os_screen_observations"] + [observation])[-32:] + self.boot_progress("waiting-for-os-screen") + if observation["attempt"] == 1: + print(json.dumps({"checkpoint": "actual-android-first-os-screen", **observation, + "gameplay_verification": "not_run"}), flush=True) + remaining = deadline - time.monotonic() + if remaining <= 0: + raise Unavailable("Actual Android OS display was not verified before the original boot deadline") + if stats["ready"]: + self.result["boot_phase"] = "validated-booted-device" + return + time.sleep(min(2, remaining)) + + def validate_booted_device(self, deadline: float) -> None: + self.result["boot_phase"] = "validating-booted-device" + # Ordinary input wakes the screen; it does not change lock/security settings. + self.boot_device("wakeup", "shell", "input", "keyevent", "224", deadline=deadline) + self.boot_device("unlock", "shell", "input", "keyevent", "82", deadline=deadline) + values = {} + for name in ("ro.build.fingerprint", "ro.product.cpu.abilist", "ro.build.version.sdk", "ro.opengles.version"): + values[name] = self.boot_device("property-" + name, "shell", "getprop", name, + deadline=deadline).stdout.decode().strip() + self.result["device_properties"] = values + if not values["ro.build.fingerprint"] or "x86_64" not in values["ro.product.cpu.abilist"].split(",") \ + or values["ro.build.version.sdk"] != "35": + raise Unavailable("The booted Android device is not the actual requested Android 35 x86_64 image") + if int(values["ro.opengles.version"] or "0") < 0x30000: + raise Unavailable("The actual Android device does not advertise required GLES 3.0") + surface = self.boot_device("surfaceflinger", "shell", "dumpsys", "SurfaceFlinger", + deadline=deadline).stdout.decode(errors="replace") + self.result["surfaceflinger_gles"] = [line.strip() for line in surface.splitlines() if "GLES:" in line] + if not self.result["surfaceflinger_gles"]: + raise Unavailable("Actual SurfaceFlinger did not report a GLES renderer; see surfaceflinger.log") + self.wait_os_screen(deadline) + self.result["checks"].extend(("actual-android-boot-completed", "adb-real-device-properties", + "actual-surfaceflinger-gles", "actual-os-screen")) + print(json.dumps({"checkpoint": "actual-android-booted", "device_properties": values, + "surfaceflinger_gles": self.result["surfaceflinger_gles"], + "actual_os_screen": self.result["actual_os_screen"], + "gameplay_verification": "not_run"}), flush=True) + + def gameplay(self) -> None: + output = self.output / "gameplay" + self.result["harness_result_path"] = "gameplay/result.json" + command = [sys.executable, str(Path(__file__).with_name("run_renpy_android_device_acceptance.py")), + "--apk", str(self.args.apk.resolve()), "--package", self.args.package, + "--serial", self.result["serial"], "--adb", str(self.adb), "--cloud-device", + "--stage-timeout", str(self.args.stage_timeout), "--overall-timeout", str(self.args.gameplay_timeout), + "--output-dir", str(output)] + self.result["harness_command"] = command + self.harness_log = (self.output / "gameplay-harness.log").open("wb") + self.harness_started_ns = time.time_ns() + self.harness_process = subprocess.Popen(command, stdout=self.harness_log, stderr=subprocess.STDOUT, + stdin=subprocess.DEVNULL, env=self.environment, start_new_session=True) + self.result.update(harness_started=True, gameplay_attempted=True, gameplay_executed=None) + print(json.dumps({"checkpoint": "actual-apk-harness-started", "deadline_seconds": self.args.gameplay_timeout, + "gameplay_verified": False}), flush=True) + try: + code = self.harness_process.wait(timeout=self.args.gameplay_timeout) + self.result["harness_returncode"] = code + except subprocess.TimeoutExpired as exc: + self.result.update(harness_timed_out=True, gameplay_verification="failed") + self.result["harness_returncode"] = terminate_owned_process(self.harness_process) + raise Unavailable(f"Actual APK harness exceeded its whole-process {self.args.gameplay_timeout}s deadline") from exc + finally: + try: + if self.harness_process.poll() is None or not wait_owned_group_exit(self.harness_process, 1): + self.result["harness_forced_cleanup"] = True + terminate_owned_process(self.harness_process) + else: + self.harness_process.wait(timeout=10) + finally: + group_state = owned_group_state(self.harness_process) + self.result["harness_group_state"] = group_state + self.result["harness_group_still_present"] = group_state != "absent" + if group_state == "permission_denied": + self.result.update(harness_cleanup_permission_denied=True, gameplay_verification="failed") + self.harness_log.close() + self.harness_log = None + if self.harness_process.poll() is not None: + self.harness_process = None + if self.result.get("harness_forced_cleanup") or self.result.get("harness_group_state") != "absent" \ + or self.result.get("harness_cleanup_permission_denied"): + self.result["gameplay_verification"] = "failed" + raise Unavailable("The actual harness process group is not confirmed absent; cleanup could not be verified") + summary_path = output / "result.json" + summary = read_current_harness_result(summary_path, self.harness_started_ns) + self.result["gameplay_result"] = summary + self.result["gameplay_verification"] = summary.get("status", "failed") + if summary.get("status") == "not_run": + self.result["gameplay_executed"] = False + apk_hash = hashlib.sha256(self.args.apk.read_bytes()).hexdigest() + self.harness_apk_sha256 = apk_hash + self.retain_partial_milestones(summary, apk_hash) + if not gameplay_result_matches(summary, code, self.args.package, self.result["serial"], apk_hash): + self.result["gameplay_verification"] = "not_run" if summary.get("status") == "not_run" else "failed" + raise Unavailable(f"Actual APK gameplay did not pass (harness exit {code}, result {summary.get('status')}); " + "see gameplay/result.json and gameplay-harness.log") + self.result.update(gameplay_executed=True, gameplay_verified=True, apk_installed=True, apk_sha256=apk_hash) + self.result["checks"].append("actual-apk-renpy-gameplay-harness-passed") + + def stop(self, *, require_clean: bool) -> None: + process = self.emulator_process + if process is None: + if self.emulator_log: + self.emulator_log.close() + self.emulator_log = None + return + clean = False + try: + if process.poll() is None: + try: + killed = self.device("stop-emulator", "emu", "kill", check=False, timeout=20) + clean = killed.returncode == 0 and process.wait(timeout=25) == 0 + except (Unavailable, subprocess.TimeoutExpired): + clean = False + if process.poll() is None or not wait_owned_group_exit(process, 1): + self.result["emulator_forced_cleanup"] = True + clean = False + terminate_owned_process(process) + else: + process.wait(timeout=10) + finally: + group_state = owned_group_state(process) + self.result.update(emulator_exit_code=process.returncode, + emulator_stopped_cleanly=clean and group_state == "absent") + self.result["emulator_group_state"] = group_state + self.result["emulator_group_still_present"] = group_state != "absent" + if group_state == "permission_denied": + self.result["emulator_cleanup_permission_denied"] = True + if self.emulator_log: + self.emulator_log.close() + self.emulator_log = None + if process.poll() is not None: + self.emulator_process = None + if require_clean and not self.result["emulator_stopped_cleanly"]: + raise Unavailable("The actual emulator did not stop cleanly; see stop-emulator.log/emulator.log") + + def run(self) -> None: + if not self.args.cloud_emulator: + raise Unavailable("--cloud-emulator is required; only a selected cloud runner is authorized") + if self.args.apk is not None and not self.args.apk.is_file(): + raise Unavailable(f"The actual installable APK is missing: {self.args.apk}") + sdk = Path(self.args.sdk_root).resolve() if self.args.sdk_root else None + if sdk is None or not sdk.is_dir(): + raise Unavailable("Existing official ANDROID_HOME/ANDROID_SDK_ROOT is unavailable") + emulator = self.gate_host(sdk) + manager = self.prepare_sdk(sdk) + with tempfile.TemporaryDirectory(prefix="renpy-cloud-emulator-session-") as temporary: + primary_failure = None + try: + try: + self.boot(emulator, manager, Path(temporary) / "avd") + except (Unavailable, Failed, OSError, ValueError, subprocess.TimeoutExpired) as boot_error: + self.result["boot_failure"] = bounded_diagnostic(str(boot_error), max_chars=1024, max_lines=4) + if self.execution_started: + self.result["boot_failed"] = True + try: + self.boot_failure_diagnostics() + except (OSError, ValueError) as diagnostic_error: + self.result["boot_failure_diagnostic_error"] = bounded_diagnostic(str(diagnostic_error)) + raise + if self.args.apk is not None: + try: + self.gameplay() + except (Unavailable, Failed, OSError, ValueError, subprocess.TimeoutExpired): + try: + self.startup_failure_diagnostics() + except (OSError, ValueError) as diagnostic_error: + self.result["startup_diagnostic_error"] = bounded_diagnostic( + str(diagnostic_error), max_chars=256, max_lines=1) + raise + self.stop(require_clean=True) + self.result["checks"].append("actual-emulator-stopped") + self.result["status"] = "passed" + except (Unavailable, Failed, OSError, ValueError, KeyboardInterrupt, subprocess.TimeoutExpired) as exc: + primary_failure = exc + raise + finally: + try: + self.stop(require_clean=False) + except (Unavailable, OSError, subprocess.TimeoutExpired) as cleanup_error: + self.result.update(status="failed", cleanup_error=bounded_diagnostic( + str(cleanup_error), max_chars=1024, max_lines=4)) + if primary_failure is None: + raise + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--cloud-emulator", action="store_true") + parser.add_argument("--apk", type=Path, help="Omit only for actual Android boot preflight without gameplay") + parser.add_argument("--package", default="org.aetherkiri.renpy.debug") + parser.add_argument("--sdk-root", default=os.environ.get("ANDROID_HOME") or os.environ.get("ANDROID_SDK_ROOT", "")) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--boot-timeout", type=int, default=480) + parser.add_argument("--gameplay-timeout", type=int, default=900) + parser.add_argument("--stage-timeout", type=int, default=90) + parser.add_argument("--port", type=int, default=5554) + args = parser.parse_args() + if not 60 <= args.boot_timeout <= 900 or not 60 <= args.gameplay_timeout <= 1200 \ + or not 5 <= args.stage_timeout <= 180 or not 5554 <= args.port <= 5682 or args.port % 2 \ + or not re.fullmatch(r"[A-Za-z][A-Za-z0-9_.]+", args.package): + parser.error("Invalid timeout, package, or even emulator port") + def interrupted(signum, _frame): + raise KeyboardInterrupt(f"Cloud emulator session interrupted by signal {signum}") + signal.signal(signal.SIGTERM, interrupted) + session = Session(args) + code = 0 + try: + session.run() + except (Unavailable, Failed, OSError, ValueError, KeyboardInterrupt, subprocess.TimeoutExpired) as exc: + session.result.update(status="failed" if session.execution_started else "not_run", blocker=str(exc)) + code = 1 if session.execution_started else 2 + finally: + try: + session.stop(require_clean=False) + except (Unavailable, OSError, subprocess.TimeoutExpired) as exc: + session.result["status"] = "failed" + session.result.setdefault("cleanup_error", bounded_diagnostic(str(exc), max_chars=1024, max_lines=4)) + code = 1 + (session.output / "result.json").write_text(json.dumps(session.result, indent=2) + "\n") + print(json.dumps(session.result, indent=2)) + return code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/run_renpy_godot_e2e.sh b/tools/run_renpy_godot_e2e.sh new file mode 100644 index 000000000..a09b0d533 --- /dev/null +++ b/tools/run_renpy_godot_e2e.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +godot_bin="${GODOT_BIN:-}" +renpy_sdk="${RENPY_SDK:-${AETHERKIRI_RENPY_SDK_ROOT:-}}" +fixture_root="${AETHERKIRI_RENPY_FIXTURE:-$repo_root/tests/fixtures/renpy_smoke}" +timeout_seconds="${RENPY_GODOT_E2E_TIMEOUT:-90}" + +if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + echo "Run the built Godot host against the official Ren'Py SDK fixture." + exit 0 +fi +[[ -x "$godot_bin" ]] || { echo "GODOT_BIN must point to a built Godot executable." >&2; exit 1; } +[[ -d "$renpy_sdk" ]] || { echo "RENPY_SDK must point to an extracted SDK." >&2; exit 1; } +[[ -d "$fixture_root/game" ]] || { echo "Ren'Py fixture missing game/." >&2; exit 1; } + +tmp_root="$(mktemp -d "${TMPDIR:-/tmp}/aetherkiri-renpy-godot-e2e.XXXXXX")" +trap 'rm -rf "$tmp_root"' EXIT +fixture="$tmp_root/fixture" +cp -a "$fixture_root/." "$fixture" +rm -rf "$fixture/game/cache" "$fixture/game/saves" "$fixture/cache" "$fixture/saves" +find "$fixture/game" -type f \( -name '*.rpyc' -o -name '*.rpymc' \) -delete +rm -f "$fixture/game/aetherkiri-choice" +config="$tmp_root/config.json" +cat > "$config" <"$log" 2>&1 +rc=$? +set -e +cat "$log" +[[ "$rc" -eq 0 ]] || { echo "Godot Ren'Py E2E exited with status $rc" >&2; exit "$rc"; } +if grep -E 'Traceback \(most recent call last\)|SCRIPT ERROR|uncaught exception|An exception has occurred' "$log"; then + echo "Godot Ren'Py E2E logged a runtime exception." >&2 + exit 1 +fi +grep -F 'step probe fps=' "$log" >/dev/null +grep -F 'AKRF1 RGBA bridge' "$log" >/dev/null +grep -F 'step 02 label=choose-continue' "$log" >/dev/null +marker="$fixture/game/aetherkiri-choice" +[[ -f "$marker" ]] || { echo "Ren'Py fixture did not record a menu choice." >&2; exit 1; } +choice="$(<"$marker")" +[[ "$choice" == "continue" ]] || { echo "Unexpected Ren'Py menu choice: $choice" >&2; exit 1; } +echo "Ren'Py Godot E2E ok: frame=640x360, display=1600x900, renderer=AKRF1 RGBA bridge, choice=$choice" diff --git a/tools/run_renpy_ios_device_acceptance.py b/tools/run_renpy_ios_device_acceptance.py new file mode 100755 index 000000000..4bffcc9ea --- /dev/null +++ b/tools/run_renpy_ios_device_acceptance.py @@ -0,0 +1,1015 @@ +#!/usr/bin/env python3 +"""Install a real Ren'Py Godot app and exercise gameplay on a cloud iOS Simulator. + +XCUITest supplies real touches, keyboard text, Home and foreground transitions. +Passing requires native frames visible in OS screenshots, real Ren'Py script +checkpoints, native pause/resume and normal game exit to the real library. Missing Apple +tools/app/runtime reports not_run (2); incomplete execution reports failed (1). +""" + +from __future__ import annotations + +import argparse +import copy +import errno +import hashlib +import json +import math +import os +import platform +import plistlib +import re +import shutil +import stat +import subprocess +import time +import uuid +from pathlib import Path + +from run_renpy_android_device_acceptance import ( + Blocked, Failed, checkpoint_landmarks, image_stats, is_script_checkpoint, is_successful_os_return, pixel, png_pixels, + read_engine_log_file, require_engine_log, require_landmark_pixels, verify_landmark_pixels, verify_resumed_marker, +) + + +DIAGNOSTIC_MAX_BYTES = 24 * 1024 +DIAGNOSTIC_MAX_RECORDS = 64 +DIAGNOSTIC_MAX_JSON_DEPTH = 16 +DIAGNOSTIC_MAX_TICKS = (1 << 53) - 1 +STARTUP_PHASES = ( + "main_ready_entered", "ui_build_entered", "ui_build_ready", + "player_create_entered", "player_create_ready", "player_create_failed", + "ready_frame_pending", "ready_frame_entered", "engine_initialize_entered", + "engine_initialize_ready", "engine_initialize_failed", "observer_create_entered", + "observer_create_ready", "observer_start_entered", "observer_start_returned", +) +CHECKPOINT_STAGES = ( + "start_ready", "touch_received", "text_ready", "text_received", + "post_text_ready", "resumed_touch_received", "quit_ready", "quit_requested", +) +OBSERVER_KINDS = ( + "opening", "startup", "lifecycle", "os_touch", "os_mouse", "os_key", + "soft_keyboard", "soft_keyboard_visibility", "checkpoint_seen", + "provider_frame", "normal_exit", "library_return", "failure", +) +DIAGNOSTIC_DOCUMENT_FILES = { + "startup_trace": "renpy-device-evidence/startup.jsonl", + "observer": "renpy-device-evidence/observer.jsonl", + "script_checkpoints": "renpy-device-demo/game/aether-device-checkpoints.jsonl", + "probe_request": "aetherkiri-probe-request.json", + "device_request": "renpy-device-demo/game/aether-device-request.json", + "engine_log": "renpy-device-demo/aetherkiri-engine.log", + "renpy_log": "renpy-device-demo/log.txt", + "renpy_traceback": "renpy-device-demo/traceback.txt", +} + + +def is_actual_xcui_ready(value: object, run_id: str, bundle_id: str) -> bool: + return (isinstance(value, dict) and value.get("run_id") == run_id + and value.get("bundle_id") == bundle_id and value.get("ready") is True) + + +def documents_identity(documents: Path) -> tuple[Path, int, int]: + """Identify the actual selected directory; publish no paths or inode data.""" + if not documents.is_absolute(): + raise ValueError("Actual app Documents identity requires an absolute container path") + parent_fd = directory_fd = None + try: + parent = documents.parent.resolve(strict=True) + flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW + parent_fd = os.open(parent, flags) + directory_fd = os.open(documents.name, flags, dir_fd=parent_fd) + info = os.fstat(directory_fd) + return parent / documents.name, info.st_dev, info.st_ino + finally: + if directory_fd is not None: + os.close(directory_fd) + if parent_fd is not None: + os.close(parent_fd) + + +def diagnostic_file(root: Path | None, relative: str, *, read: bool = False) -> tuple[dict, bytes | None]: + """Read fixed caller-selected files without following child symlinks. + + Metadata contains no paths, exceptions or contents. Reads have a 24 KiB + ceiling; ordinary logs are statted only, never decoded or printed here. + """ + metadata = {"status": "not_run", "exists": None, "bytes": None} + if root is None: + return metadata, None + parent_fd = directory_fd = file_fd = None + try: + root_stat = root.lstat() + if stat.S_ISLNK(root_stat.st_mode) or not stat.S_ISDIR(root_stat.st_mode): + metadata["status"] = "root_refused" + return metadata, None + parts = Path(relative).parts + if not parts or Path(relative).is_absolute() or any(part in (".", "..") for part in parts): + metadata["status"] = "path_refused" + return metadata, None + # Canonicalize normal host aliases above the selected Documents root. + # Hold directory descriptors for every component below that boundary. + directory_flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW + # Resolve aliases above Documents only. Opening the selected root's + # basename through a held parent descriptor atomically refuses a root + # swapped to a symlink after the preliminary lstat. + parent_fd = os.open(root.parent.resolve(strict=True), directory_flags) + directory_fd = os.open(root.name, directory_flags, dir_fd=parent_fd) + os.close(parent_fd) + parent_fd = None + for part in parts[:-1]: + child_fd = os.open(part, directory_flags, dir_fd=directory_fd) + os.close(directory_fd) + directory_fd = child_fd + file_fd = os.open(parts[-1], os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, + dir_fd=directory_fd) + file_stat = os.fstat(file_fd) + metadata["exists"] = True + if not stat.S_ISREG(file_stat.st_mode): + metadata["status"] = "non_regular_refused" + return metadata, None + if not 0 <= file_stat.st_size <= (1 << 63) - 1: + metadata["status"] = "invalid_metadata" + return metadata, None + metadata.update(status="regular", bytes=file_stat.st_size) + if not read: + return metadata, None + if file_stat.st_size > DIAGNOSTIC_MAX_BYTES: + metadata["status"] = "byte_limit" + return metadata, None + data = bytearray() + while len(data) < DIAGNOSTIC_MAX_BYTES: + chunk = os.read(file_fd, DIAGNOSTIC_MAX_BYTES - len(data)) + if not chunk: + break + data.extend(chunk) + if os.fstat(file_fd).st_size > DIAGNOSTIC_MAX_BYTES: + metadata["status"] = "byte_limit" + return metadata, None + return metadata, bytes(data) + except FileNotFoundError: + metadata.update(status="missing", exists=False) + except OSError as exc: + metadata["status"] = ("link_or_directory_refused" if exc.errno in (errno.ELOOP, errno.ENOTDIR) + else "read_error") + except (ValueError, RuntimeError): + metadata["status"] = "read_error" + finally: + if file_fd is not None: + os.close(file_fd) + if directory_fd is not None: + os.close(directory_fd) + if parent_fd is not None: + os.close(parent_fd) + return metadata, None + + +def _diagnostic_object(pairs: list[tuple]) -> dict: + result = {} + for key, value in pairs: + if key in result: + raise ValueError("duplicate_key") + result[key] = value + return result + + +def _diagnostic_integer(value: str) -> int: + if len(value.lstrip("-")) > 16: + raise ValueError("integer_limit") + result = int(value) + if abs(result) > DIAGNOSTIC_MAX_TICKS: + raise ValueError("integer_limit") + return result + + +def _diagnostic_float(value: str) -> float: + result = float(value) + if not math.isfinite(result): + raise ValueError("non_finite_number") + return result + + +def _diagnostic_constant(value: str): + raise ValueError("non_json_constant") + + +def _diagnostic_depth(data: bytes) -> None: + # Bound decoder nesting independently of Python's implementation/version. + # Ignore structural characters in JSON strings, including escaped quotes. + depth, quoted, escaped = 0, False, False + for byte in data: + if quoted: + if escaped: + escaped = False + elif byte == 92: + escaped = True + elif byte == 34: + quoted = False + elif byte == 34: + quoted = True + elif byte in (91, 123): + depth += 1 + if depth > DIAGNOSTIC_MAX_JSON_DEPTH: + raise ValueError("nesting_limit") + elif byte in (93, 125): + depth -= 1 + + +def diagnostic_rows(data: bytes | None, run_id: str, *, startup: bool = False) -> dict: + """Expose strict startup fields; other journal rows stay private for counts.""" + report = {"status": "unavailable", "rows": [], "violations": { + "invalid_json": 0, "foreign_run": 0, "invalid_schema": 0, + "invalid_order": 0, "record_limit": 0, "byte_limit": 0}} + if data is None: + return report + if len(data) > DIAGNOSTIC_MAX_BYTES: + report["status"] = "byte_limit" + report["violations"]["byte_limit"] = 1 + return report + lines = [line for line in data.splitlines() if line.strip()] + if len(lines) > DIAGNOSTIC_MAX_RECORDS: + report["status"] = "record_limit" + report["violations"]["record_limit"] = 1 + return report + if type(run_id) is not str or not re.fullmatch(r"[0-9a-f]{32}", run_id): + report["status"] = "invalid_identity" + report["violations"]["invalid_schema"] = 1 + return report + last_seq, last_ticks = 0, 0 + for line in lines: + try: + _diagnostic_depth(line) + row = json.loads(line.decode("utf-8"), object_pairs_hook=_diagnostic_object, + parse_int=_diagnostic_integer, parse_float=_diagnostic_float, + parse_constant=_diagnostic_constant) + except (ValueError, UnicodeError, RecursionError, OverflowError): + report["violations"]["invalid_json"] += 1 + continue + if not isinstance(row, dict): + report["violations"]["invalid_schema"] += 1 + continue + if row.get("run_id") != run_id: + report["violations"]["foreign_run"] += 1 + continue + if startup: + required = {"run_id", "seq", "ticks_msec", "phase"} + seq, ticks, result = row.get("seq"), row.get("ticks_msec"), row.get("result") + if (not required.issubset(row) or not set(row).issubset(required | {"result"}) + or row.get("phase") not in STARTUP_PHASES + or type(seq) is not int or not 1 <= seq <= DIAGNOSTIC_MAX_RECORDS + or type(ticks) is not int or not 0 <= ticks <= DIAGNOSTIC_MAX_TICKS + or ("result" in row and (type(result) is not int or not -(1 << 31) <= result < (1 << 31)))): + report["violations"]["invalid_schema"] += 1 + continue + if seq <= last_seq or ticks < last_ticks: + report["violations"]["invalid_order"] += 1 + continue + last_seq, last_ticks = seq, ticks + report["rows"].append(row) + report["status"] = "partial" if any(report["violations"].values()) else "valid" + return report + + +def configure_destination_artifacts(configuration: dict, test_root: Path, runner_app: Path, + bundle_id: str, run_id: str, overall_timeout: int) -> tuple[dict, str]: + """Bind a genuine built UI runner for the documented preinstalled mode. + + xcodebuild.xctestrun(5), UseDestinationArtifacts: exclude host artifact + paths and supply destination bundle identities plus the installed test + bundle path. __TESTHOST__/__TESTBUNDLE__ must remain device placeholders. + This configuration validation does not prove Apple test execution. + """ + if (not isinstance(configuration, dict) + or not isinstance(bundle_id, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,254}", bundle_id) + or not isinstance(run_id, str) or not re.fullmatch(r"[0-9a-f]{32}", run_id) + or type(overall_timeout) is not int or not 60 <= overall_timeout <= 1200): + raise Failed("Invalid actual UI destination configuration identity") + metadata = configuration.get("__xctestrun_metadata__") + if metadata is not None and (not isinstance(metadata, dict) + or type(metadata.get("FormatVersion")) is not int + or metadata["FormatVersion"] not in (1, 2)): + raise Failed("Unsupported actual UI test configuration format") + if runner_app.is_symlink() or not runner_app.is_dir() or runner_app.suffix != ".app": + raise Failed("The built UI runner must be a real application directory") + runner = runner_app.resolve(strict=True) + info_path = runner / "Info.plist" + if info_path.is_symlink() or not info_path.is_file(): + raise Failed("The built UI runner lacks its actual bundle metadata") + runner_info = plistlib.loads(info_path.read_bytes()) + runner_id = runner_info.get("CFBundleIdentifier") + runner_platforms = runner_info.get("CFBundleSupportedPlatforms") + if (not isinstance(runner_id, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,254}", runner_id) + or runner_id == bundle_id or not isinstance(runner_platforms, list) + or "iPhoneSimulator" not in runner_platforms): + raise Failed("The built UI runner lacks a real Simulator bundle identity") + result = copy.deepcopy(configuration) + targets = [] + + def find_targets(value): + if isinstance(value, dict): + if "TestBundlePath" in value or "TestBundleDestinationRelativePath" in value: + targets.append(value) + else: + for child in value.values(): + find_targets(child) + elif isinstance(value, list): + for child in value: + find_targets(child) + + find_targets(result) + if len(targets) != 1 or targets[0].get("IsUITestBundle") is not True: + raise Failed("The actual configuration must contain exactly one UI test target") + target = targets[0] + environment = target.get("TestingEnvironmentVariables") + if (not isinstance(environment, dict) + or any(not isinstance(key, str) or not isinstance(value, str) for key, value in environment.items())): + raise Failed("The actual UI target lacks its required testing environment") + + def artifact_path(value, *, allow_test_host=False): + if not isinstance(value, str) or not value or "\0" in value: + raise Failed("The actual UI target lacks a valid build artifact path") + expanded = value.replace("__TESTROOT__", str(test_root.resolve(strict=True))) + if allow_test_host: + expanded = expanded.replace("__TESTHOST__", str(runner)) + path = Path(expanded) + if (re.search(r"__[A-Z0-9_]+__", expanded) or not path.is_absolute() + or ".." in path.parts): + raise Failed("The actual UI target has an unsupported build artifact path") + return path + + host = artifact_path(target.get("TestHostPath")).resolve(strict=True) + if host != runner or ("TestHostBundleIdentifier" in target + and target["TestHostBundleIdentifier"] != runner_id): + raise Failed("The actual UI test host does not match the selected built runner") + original_bundle = artifact_path(target.get("TestBundlePath"), allow_test_host=True) + test_bundle = original_bundle.resolve(strict=True) + try: + relative = test_bundle.relative_to(runner) + except ValueError as exc: + raise Failed("The actual test bundle is outside its installed UI runner") from exc + if (len(relative.parts) < 2 or relative.parts[0] != "PlugIns" + or test_bundle.suffix != ".xctest" or not test_bundle.is_dir()): + raise Failed("The actual UI test bundle must be embedded in its runner PlugIns") + # Reject links within the installed bundle. Host filesystem aliases above + # the application directory were canonicalized separately. + original_host = next((parent for parent in original_bundle.parents + if parent.resolve(strict=True) == runner), None) + if original_host is None or original_host.is_symlink(): + raise Failed("The actual test bundle is not a direct descendant of its built runner") + component = original_host + for part in original_bundle.relative_to(original_host).parts: + component = component / part + if component.is_symlink(): + raise Failed("The actual installed test bundle cannot traverse a symlink") + bundle_info = test_bundle / "Info.plist" + if bundle_info.is_symlink() or not bundle_info.is_file(): + raise Failed("The actual embedded UI test bundle lacks its bundle metadata") + test_bundle_id = plistlib.loads(bundle_info.read_bytes()).get("CFBundleIdentifier") + if not isinstance(test_bundle_id, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,254}", test_bundle_id): + raise Failed("The actual embedded UI test bundle lacks its bundle identity") + target.update(UseDestinationArtifacts=True, TestHostBundleIdentifier=runner_id, + TestBundleDestinationRelativePath="__TESTHOST__/" + relative.as_posix(), + UITargetAppBundleIdentifier=bundle_id) + launch_environment = target.setdefault("EnvironmentVariables", {}) + if (not isinstance(launch_environment, dict) + or any(not isinstance(key, str) or not isinstance(value, str) for key, value in launch_environment.items())): + raise Failed("The actual UI target has an invalid launch environment") + launch_environment.update({ + "AETHER_RENPY_RUN_ID": run_id, "AETHER_RENPY_APP_BUNDLE_ID": bundle_id, + "AETHER_RENPY_TEST_TIMEOUT": str(overall_timeout)}) + for key in ("TestBundlePath", "TestHostPath", "UITargetAppPath", "DependentProductPaths"): + target.pop(key, None) + + def relocate_test_root(value): + if isinstance(value, dict): + return {key: relocate_test_root(child) for key, child in value.items()} + if isinstance(value, list): + return [relocate_test_root(child) for child in value] + if isinstance(value, str): + return value.replace("__TESTROOT__", str(test_root.resolve(strict=True))) + return value + + return relocate_test_root(result), runner_id + + +class Acceptance: + def __init__(self, args: argparse.Namespace): + self.args = args + self.output = args.output_dir.resolve() + self.output.mkdir(parents=True, exist_ok=True) + self.repo = Path(__file__).resolve().parents[1] + self.run_id = uuid.uuid4().hex + self.udid = args.udid or "" + self.created = self.installed = False + self.bundle_id = "" + self.documents: Path | None = None + self.staged_documents_identity: tuple[Path, int, int] | None = None + self.container_identity_checked = False + self.container_identity_same: bool | None = None + self.ui_root: Path | None = None + self.sequence = 0 + self.process: subprocess.Popen | None = None + self.process_log = None + self.prepared_xctestrun: Path | None = None + self.ui_runner_id = "" + self.test_artifact_mode = "not_prepared" + self.runner_installed = self.destination_bindings_verified = False + self.deadline = 0.0 + self.waiting_marker = None + self.keyboard_preference: str | None = None + self.preference_changed = False + self.started_monotonic = time.monotonic() + self.request_staged = self.xcui_ready = False + self.install_elapsed_msec = self.xcui_ready_elapsed_msec = None + self.diagnostics_collected = False + self.summary = {"status": "not_run", "run_id": self.run_id, + "source": "actual-godot-app-simctl-xcuitest-cloud-simulator", + "checks": [], "blocker": ""} + self.summary["startup_diagnostics"] = self.startup_gate_diagnostics() + + def elapsed_msec(self) -> int: + return max(0, min(DIAGNOSTIC_MAX_TICKS, + int((time.monotonic() - self.started_monotonic) * 1000))) + + def startup_gate_diagnostics(self) -> dict: + return {"schema_version": 1, "diagnostic_status": "not_collected", + "install_passed": self.installed, "install_elapsed_msec": self.install_elapsed_msec, + "xcui_ready_passed": self.xcui_ready, "xcui_ready_elapsed_msec": self.xcui_ready_elapsed_msec, + "request_staged": self.request_staged, + "test_artifact_mode": self.test_artifact_mode, + "runner_install_passed": self.runner_installed, + "destination_bindings_verified": self.destination_bindings_verified, + "container_identity_checked": self.container_identity_checked, + "container_identity_same": self.container_identity_same} + + def snapshot_startup_diagnostics(self) -> None: + """Collect fixed same-run facts before cleanup; never change acceptance. + + Only strict startup rows are exposed. Other journals supply counts, + ordinary logs supply metadata, and missing request files prove absence + only (not which component removed them). Diagnostic failures stay local. + """ + if self.diagnostics_collected: + return + self.diagnostics_collected = True + report = self.startup_gate_diagnostics() + report.update(diagnostic_status="collected", read_errors=0, + test_process_before_cleanup={"state": "not_started", "returncode": None}) + self.summary["startup_diagnostics"] = report + try: + if self.process is not None: + try: + returncode = self.process.poll() + if returncode is None: + report["test_process_before_cleanup"]["state"] = "running" + elif type(returncode) is int and -(1 << 31) <= returncode < (1 << 31): + report["test_process_before_cleanup"].update(state="exited", returncode=returncode) + else: + report["test_process_before_cleanup"]["state"] = "unavailable" + report["read_errors"] += 1 + except Exception: + report["test_process_before_cleanup"]["state"] = "unavailable" + report["read_errors"] += 1 + files, journals = {}, {} + for label, relative in DIAGNOSTIC_DOCUMENT_FILES.items(): + read = label in ("startup_trace", "observer", "script_checkpoints") + try: + metadata, data = diagnostic_file(self.documents, relative, read=read) + except Exception: + metadata, data = {"status": "read_error", "exists": None, "bytes": None}, None + if metadata["status"] == "read_error": + report["read_errors"] += 1 + files[label] = metadata + if read: + parsed = diagnostic_rows(data, self.run_id, startup=label == "startup_trace") + if metadata["status"] == "byte_limit": + parsed["status"] = "byte_limit" + parsed["violations"]["byte_limit"] = 1 + journals[label] = parsed + report["files"] = files + trace = journals["startup_trace"] + report["startup_trace"] = {"status": trace["status"], "rows": trace["rows"], + "records": len(trace["rows"]), "violations": trace["violations"]} + observer = journals["observer"] + report["observer"] = { + "status": observer["status"], "records": len(observer["rows"]), + "violations": observer["violations"], + "kind_counts": {kind: sum(row.get("kind") == kind for row in observer["rows"]) + for kind in OBSERVER_KINDS}, + "unknown_kind_records": sum(row.get("kind") not in OBSERVER_KINDS + for row in observer["rows"])} + checkpoints = journals["script_checkpoints"] + report["script_checkpoints"] = { + "status": checkpoints["status"], "records": len(checkpoints["rows"]), + "violations": checkpoints["violations"], + "stage_counts": {stage: sum(is_script_checkpoint(row, stage, self.run_id) + for row in checkpoints["rows"]) + for stage in CHECKPOINT_STAGES}, + "unrecognized_records": sum(not any(is_script_checkpoint(row, stage, self.run_id) + for stage in CHECKPOINT_STAGES) + for row in checkpoints["rows"])} + report["probe_request_exists"] = files["probe_request"]["exists"] + report["device_request_exists"] = files["device_request"]["exists"] + report["probe_request_absent_after_staging"] = ( + self.request_staged and files["probe_request"]["status"] == "missing") + if self.documents is None: + report["diagnostic_status"] = "unavailable" + elif (report["read_errors"] or any(journal["status"] != "valid" for journal in journals.values()) + or any(metadata["status"] not in ("regular", "missing") for metadata in files.values())): + report["diagnostic_status"] = "partial" + except Exception: + # In particular, never mask the original gameplay failure with a + # diagnostic read/parser exception or publish its arbitrary text. + report["diagnostic_status"] = "internal_error" + report["read_errors"] += 1 + + def command(self, *parts: str, check: bool = True, timeout: int = 60) -> bytes: + try: + result = subprocess.run(parts, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout) + except (OSError, subprocess.TimeoutExpired) as exc: + raise Failed(f"Cloud command did not complete: {parts[0]}: {exc}") from exc + with (self.output / "commands.jsonl").open("a") as stream: + stream.write(json.dumps({"command": parts, "returncode": result.returncode, + "stderr": result.stderr.decode(errors="replace"), "time": time.time()}) + "\n") + if parts[0] == "xcodebuild" and "build-for-testing" in parts: + (self.output / "xcuitest-build.log").write_bytes(result.stdout + result.stderr) + if check and result.returncode: + raise Failed(f"{' '.join(parts[:4])} failed: {result.stderr.decode(errors='replace').strip()}") + return result.stdout + + def simctl(self, *parts: str, **kwargs) -> bytes: + return self.command("xcrun", "simctl", *parts, **kwargs) + + def rows(self, relative: str) -> list[dict]: + if self.documents is None: + return [] + path = self.documents / relative + if not path.is_file(): + return [] + rows = [] + for line in path.read_text(errors="replace").splitlines(): + try: + value = json.loads(line) + if isinstance(value, dict) and value.get("run_id") == self.run_id: + rows.append(value) + except ValueError: + pass + return rows + + def observer_rows(self) -> list[dict]: + return self.rows("renpy-device-evidence/observer.jsonl") + + def game_rows(self) -> list[dict]: + return self.rows("renpy-device-demo/game/aether-device-checkpoints.jsonl") + + def wait(self, description: str, condition, timeout: int | None = None): + expires = time.monotonic() + (timeout or self.args.stage_timeout) + if self.deadline: + expires = min(expires, self.deadline) + while time.monotonic() < expires: + for row in self.observer_rows(): + if row.get("kind") == "failure": + raise Failed(f"Actual iOS provider observer failed: {row.get('message')}") + result = condition() + if result: + return result + if self.process is not None and self.process.poll() is not None: + raise Failed(f"Actual XCUITest finished before {description} (exit={self.process.returncode}); inspect xcuitest.log/xcresult") + time.sleep(0.25) + raise Failed(f"Timed out waiting for {description}; no gameplay evidence was synthesized") + + def wait_game(self, stage: str) -> dict: + return self.wait(f"executed Ren'Py {stage}", lambda: next( + (r for r in self.game_rows() if is_script_checkpoint(r, stage, self.run_id)), None)) + + def wait_observer(self, kind: str, **fields) -> dict: + return self.wait(f"native observer {kind} {fields}", lambda: next((r for r in self.observer_rows() + if r.get("kind") == kind and all(r.get(k) == v for k, v in fields.items())), None)) + + def ui(self, operation: str, **fields) -> dict: + if self.ui_root is None: + raise Failed("The actual UI test runner has not started") + self.sequence += 1 + request = {"run_id": self.run_id, "seq": self.sequence, "op": operation, **fields} + temporary = self.ui_root / "command.pending" + temporary.write_text(json.dumps(request)) + temporary.replace(self.ui_root / "command.json") + + def acknowledgment(): + try: + value = json.loads((self.ui_root / "ack.json").read_text()) + return value if value.get("run_id") == self.run_id and value.get("seq") == self.sequence else None + except (OSError, ValueError): + return None + + result = self.wait(f"actual XCUITest {operation}", acknowledgment) + with (self.output / "ui-actions.jsonl").open("a") as stream: + stream.write(json.dumps({"request": request, "acknowledgment": result}) + "\n") + if result.get("success") is not True: + raise Failed(f"Actual UI action {operation} failed: {result}") + screenshot = self.ui_root / f"{self.sequence}.png" + if screenshot.is_file(): + shutil.copyfile(screenshot, self.output / f"xcuitest-{self.sequence:02}-{operation}.png") + return result + + def screenshot(self, stage: str) -> Path: + path = self.output / f"os-{stage}.png" + self.simctl("io", self.udid, "screenshot", str(path)) + png_pixels(path) + return path + + def capture(self, stage: str) -> dict: + row = self.wait_observer("provider_frame", stage=stage) + native = row.get("provider_debug", {}) + if (row.get("source") != "native-provider-rgba" or native.get("runtime") != "renpy" + or native.get("initialized") is not True or native.get("exited") is not False + or int(row.get("frame_serial", 0)) <= 0): + raise Failed("Frame lacks live initialized native Ren'Py state and pixels") + provider_path = self.output / f"provider-{stage}.png" + shutil.copyfile(self.documents / f"renpy-device-evidence/provider-{stage}.png", provider_path) + provider = png_pixels(provider_path) + self.ui("foreground") + screen = png_pixels(self.screenshot(stage)) + stats = {"provider": image_stats(provider), "os_screenshot": image_stats(screen)} + box, viewport = row["drawn_box"], row["viewport_size"] + if min(viewport) <= 0 or min(box[2:]) <= 0: + raise Failed("Frame lacks real Simulator presentation geometry") + matches = count = 0 + for iy in range(1, 8): + for ix in range(1, 12): + u, v = ix / 12, iy / 8 + expected = pixel(provider, int(u * provider[0]), int(v * provider[1])) + actual = pixel(screen, int((box[0] + u * box[2]) * screen[0] / viewport[0]), + int((box[1] + v * box[3]) * screen[1] / viewport[1])) + matches += max(abs(a - b) for a, b in zip(expected, actual)) <= 24 + count += 1 + stats["native_to_os_match"] = {"matching_samples": matches, "samples": count, + "fraction": matches / count, "channel_tolerance": 24} + game_stage = "post_text_ready" if stage == "resumed_ready" else stage + checkpoint = self.wait_game(game_stage) + captured_checkpoint = row.get("game_checkpoint", {}) + if (not is_script_checkpoint(captured_checkpoint, game_stage, self.run_id) + or captured_checkpoint.get("details") != checkpoint.get("details")): + raise Failed("Native frame is not tied to the independently read executed Ren'Py scene checkpoint") + stats["landmarks"] = verify_landmark_pixels(provider, screen, row, checkpoint) + if stage == "quit_ready": + if self.waiting_marker is None: + raise Failed("No actual pre-pause native WAIT marker was captured") + stats["resumed_marker_change"] = verify_resumed_marker(*self.waiting_marker, provider, checkpoint) + (self.output / f"pixels-{stage}.json").write_text(json.dumps(stats, indent=2) + "\n") + if matches / count < 0.70: + raise Failed(f"Actual OS screenshot does not present Ren'Py native pixels at {stage}: {matches}/{count}") + require_landmark_pixels(stats["landmarks"]) + if stage == "quit_ready" and stats["resumed_marker_change"]["fraction"] < 0.70: + raise Failed("Native marker did not visibly change after the actual resumed OS touch") + if stage == "post_text_ready": + self.waiting_marker = (provider, checkpoint) + self.summary["checks"].append({"stage": stage, "native_provider_pixels_and_os_screen": stats}) + return row + + def tap_button(self, frame: dict) -> None: + box, viewport = frame["drawn_box"], frame["viewport_size"] + details = checkpoint_landmarks(frame["game_checkpoint"]) + bx, by, bw, bh = details["button_bounds"] + u = (bx + bw / 2) / details["logical_canvas"][0] + v = (by + bh / 2) / details["logical_canvas"][1] + self.ui("tap", x=(box[0] + box[2] * u) / viewport[0], y=(box[1] + box[3] * v) / viewport[1]) + + def create_simulator(self) -> None: + runtimes = json.loads(self.simctl("list", "runtimes", "--json")) + types = json.loads(self.simctl("list", "devicetypes", "--json")) + (self.output / "simulator-runtimes.json").write_text(json.dumps(runtimes, indent=2)) + available = [r for r in runtimes["runtimes"] if r.get("isAvailable") and ".iOS-" in r["identifier"]] + if not available: + raise Blocked("Xcode has no installed available iOS Simulator runtime; provision one on the cloud runner") + runtime = max(available, key=lambda r: tuple(int(v) for v in r["version"].split("."))) + device = None + # simctl performs the authoritative compatibility check. Prefer a + # supported iPhone from the runtime's own supportedDeviceTypes list. + supported = runtime.get("supportedDeviceTypes", []) + if supported: + device = next((d for d in reversed(supported) if d.get("productFamily") == "iPhone" or d.get("name", "").startswith("iPhone")), None) + if device is None: + device = next((d for d in reversed(types["devicetypes"]) if d["name"].startswith("iPhone")), None) + if device is None: + raise Blocked("No compatible iPhone Simulator device type is installed on the cloud host") + self.udid = self.simctl("create", "Aether RenPy gameplay " + self.run_id[:8], device["identifier"], runtime["identifier"]).decode().strip() + self.created = True + self.summary["runtime"] = runtime + + def stage(self) -> None: + self.documents.mkdir(parents=True, exist_ok=True) + for name in ("renpy-device-demo", "renpy-device-evidence"): + shutil.rmtree(self.documents / name, ignore_errors=True) + game = self.documents / "renpy-device-demo/game" + game.mkdir(parents=True) + for source in (self.repo / "demos/aetherkiri-renpy/game").iterdir(): + if source.is_file() and source.suffix in (".rpy", ".svg"): + shutil.copyfile(source, game / source.name) + (game / "aether-device-request.json").write_text(json.dumps({"run_id": self.run_id})) + (self.documents / "aetherkiri-probe-request.json").write_text(json.dumps({ + "probe_script": "res://scripts/renpy_mobile_acceptance.gd", "run_id": self.run_id, + "game_path": str(game.parent), "timeout_seconds": self.args.overall_timeout})) + self.staged_documents_identity = documents_identity(self.documents) + self.request_staged = True + self.summary["startup_diagnostics"]["request_staged"] = True + + def prepare_xcuitest(self) -> None: + project = self.repo / "tools/renpy_ios_acceptance/RenPyAcceptance.xcodeproj" + derived = self.output / "xcode-derived" + destination = "platform=iOS Simulator,id=" + self.udid + self.command("xcodebuild", "build-for-testing", "-project", str(project), "-scheme", "RenPyAcceptance", + "-configuration", "Debug", "-destination", destination, + "-derivedDataPath", str(derived), "CODE_SIGN_IDENTITY=-", timeout=240) + paths = list((derived / "Build/Products").glob("*.xctestrun")) + if len(paths) != 1: + raise Failed("Xcode did not produce one executable UI test configuration") + runners = list((derived / "Build/Products").glob("**/RenPyAcceptance-Runner.app/Info.plist")) + if len(runners) != 1: + raise Failed("Xcode did not build the real Simulator UI test runner app") + configuration, runner_id = configure_destination_artifacts( + plistlib.loads(paths[0].read_bytes()), paths[0].parent, runners[0].parent, + self.bundle_id, self.run_id, self.args.overall_timeout) + xctestrun = self.output / "RenPyGameplay.xctestrun" + xctestrun.write_bytes(plistlib.dumps(configuration)) + self.destination_bindings_verified = True + self.test_artifact_mode = "destination" + self.summary["startup_diagnostics"].update( + test_artifact_mode="destination", destination_bindings_verified=True) + # Both genuine apps are installed before querying/staging the target + # data container. The ensuing test action cannot reinstall artifacts. + self.simctl("install", self.udid, str(runners[0].parent.resolve()), timeout=180) + self.runner_installed = True + self.summary["startup_diagnostics"]["runner_install_passed"] = True + self.summary["ui_test_runner"] = runner_id + self.ui_runner_id = runner_id + self.prepared_xctestrun = xctestrun + + def start_xcuitest(self) -> None: + if (self.prepared_xctestrun is None or not self.runner_installed + or not self.destination_bindings_verified): + raise Failed("Actual destination UI test artifacts were not prepared and installed") + destination = "platform=iOS Simulator,id=" + self.udid + runner_id = self.ui_runner_id + self.process_log = (self.output / "xcuitest.log").open("wb") + self.process = subprocess.Popen(["xcodebuild", "test-without-building", "-xctestrun", str(self.prepared_xctestrun), + "-destination", destination, "-resultBundlePath", str(self.output / "gameplay.xcresult"), + "-parallel-testing-enabled", "NO"], stdout=self.process_log, stderr=subprocess.STDOUT) + + def ready(): + data = self.simctl("get_app_container", self.udid, runner_id, "data", check=False).decode().strip() + if not data: + return None + root = Path(data) / "Documents" / ("aether-renpy-ui-" + self.run_id) + try: + report = json.loads((root / "ready.json").read_text()) + if is_actual_xcui_ready(report, self.run_id, self.bundle_id): + self.ui_root = root + return True + except (OSError, ValueError): + return None + + self.wait("installed XCUITest runner and actual app launch", ready, timeout=180) + self.xcui_ready = True + self.xcui_ready_elapsed_msec = self.elapsed_msec() + self.summary["startup_diagnostics"].update( + xcui_ready_passed=True, xcui_ready_elapsed_msec=self.xcui_ready_elapsed_msec) + # The real test process has launched the bound app. Requery rather + # than assuming the prelaunch container survived the test action. + current = self.simctl("get_app_container", self.udid, self.bundle_id, "data").decode().strip() + self.verify_staged_documents(Path(current) / "Documents") + + def verify_staged_documents(self, current_documents: Path) -> None: + self.container_identity_checked = False + self.container_identity_same = None + self.summary["startup_diagnostics"].update(container_identity_checked=False, container_identity_same=None) + if self.staged_documents_identity is None: + raise Failed("The actual app Documents container was not bound during staging") + try: + current_identity = documents_identity(current_documents) + except (OSError, ValueError, RuntimeError) as exc: + raise Failed("The actual app Documents container cannot be verified after UI launch") from exc + self.container_identity_checked = True + self.container_identity_same = current_identity == self.staged_documents_identity + self.summary["startup_diagnostics"].update( + container_identity_checked=True, container_identity_same=self.container_identity_same) + if not self.container_identity_same: + raise Failed("The actual app data container changed after staging; refusing stale gameplay evidence") + + def run(self) -> None: + if not self.args.cloud_simulator: + raise Blocked("--cloud-simulator is required: only cloud-hosted devices are authorized") + if platform.system() != "Darwin" or not shutil.which("xcrun") or not shutil.which("xcodebuild"): + raise Blocked("Actual iOS Simulator gameplay requires a cloud macOS host with Xcode and its licensed SDK/runtime") + if not (self.args.app / "Info.plist").is_file(): + raise Blocked(f"Installable Ren'Py-enabled Simulator .app is missing: {self.args.app}") + info = plistlib.loads((self.args.app / "Info.plist").read_bytes()) + self.bundle_id = info["CFBundleIdentifier"] + if "iPhoneSimulator" not in info.get("CFBundleSupportedPlatforms", []): + raise Blocked("The provided .app targets iPhoneOS; this harness needs an actual Simulator build") + self.summary["bundle_id"] = self.bundle_id + self.summary["app_executable_sha256"] = hashlib.sha256((self.args.app / info["CFBundleExecutable"]).read_bytes()).hexdigest() + self.summary["xcode_version"] = self.command("xcodebuild", "-version").decode().strip() + if not self.udid: + self.create_simulator() + self.summary["udid"] = self.udid + devices = json.loads(self.simctl("list", "devices", "--json")) + selected = next((d for group in devices["devices"].values() for d in group if d["udid"] == self.udid and d.get("isAvailable")), None) + if selected is None: + raise Blocked(f"Selected cloud iOS Simulator {self.udid} is unavailable") + if selected["state"] != "Booted": + self.simctl("boot", self.udid) + self.simctl("bootstatus", self.udid, "-b", timeout=180) + self.keyboard_preference = self.command("defaults", "read", "com.apple.iphonesimulator", "ConnectHardwareKeyboard", check=False).decode().strip() or None + self.command("defaults", "write", "com.apple.iphonesimulator", "ConnectHardwareKeyboard", "-bool", "false") + self.preference_changed = True + self.simctl("install", self.udid, str(self.args.app.resolve()), timeout=180) + self.installed = True + self.install_elapsed_msec = self.elapsed_msec() + self.summary["startup_diagnostics"].update( + install_passed=True, install_elapsed_msec=self.install_elapsed_msec) + self.simctl("terminate", self.udid, self.bundle_id, check=False) + # Preserve the existing overall budget: it still includes the real + # build-for-testing work, now before data-container staging. + self.deadline = time.monotonic() + self.args.overall_timeout + self.prepare_xcuitest() + root = self.simctl("get_app_container", self.udid, self.bundle_id, "data").decode().strip() + self.documents = Path(root) / "Documents" + self.stage() + self.start_xcuitest() + self.wait_game("start_ready") + self.tap_button(self.capture("start_ready")) + self.wait_game("touch_received") + self.wait_game("text_ready") + keyboard = self.wait_observer("soft_keyboard", active=True) + visible = self.wait_observer("soft_keyboard_visibility", visible=True) + if keyboard.get("source") != "native-text-input-state" or not keyboard.get("feature_available") or visible.get("height", 0) <= 0: + raise Failed("Native text input did not show a real iOS virtual keyboard") + keyboard_ui = self.ui("keyboard") + if keyboard_ui.get("keyboard_visible") is not True: + raise Failed("XCUITest could not see the actual iOS software keyboard") + self.screenshot("text-ready-keyboard") + typed = "CloudRenPy" + self.run_id[:8] + self.ui("type", text=typed + "\n") + text = self.wait_game("text_received") + if text.get("details", {}).get("text") != typed: + raise Failed(f"Ren'Py input differs from actual system keyboard text: {text.get('details')}") + self.wait_game("post_text_ready") + self.wait_observer("soft_keyboard_visibility", visible=False) + self.capture("post_text_ready") + self.ui("home") + pause = self.wait_observer("lifecycle", phase="paused") + if pause.get("result") != 0 or pause.get("provider_debug", {}).get("paused") is not True: + raise Failed("Home did not pause the actual native runtime") + self.screenshot("background-home") + self.ui("activate") + resume = self.wait_observer("lifecycle", phase="resumed") + if resume.get("result") != 0 or resume.get("provider_debug", {}).get("paused") is not False: + raise Failed("Foreground did not resume the actual native runtime") + frame = self.capture("resumed_ready") + if int(frame.get("frame_serial", 0)) <= int(pause.get("provider_debug", {}).get("frame_serial", 0)): + raise Failed("Resume presented a cached pre-pause frame") + if any(r.get("stage") == "resumed_touch_received" for r in self.game_rows()): + raise Failed("Ren'Py advanced before the real resumed UI touch") + self.tap_button(frame) + self.wait_game("resumed_touch_received") + self.wait_game("quit_ready") + self.tap_button(self.capture("quit_ready")) + self.wait_game("quit_requested") + normal_exit = self.wait_observer("normal_exit") + if not normal_exit.get("engine_destroyed") or normal_exit.get("provider_debug", {}).get("exited") is not True: + raise Failed("Explicit Quit did not stop native Ren'Py and destroy its engine") + library = self.wait_observer("library_return") + self.verify_library_return(library) + events = self.observer_rows() + for action in (1, 3): + if not any(r.get("kind") == "os_touch" and r.get("action") == action and r.get("result") == 0 for r in events): + raise Failed(f"Actual iOS touch action {action} never reached the provider") + if not any(is_successful_os_return(r) for r in events): + raise Failed("Actual iOS Enter key never reached the provider") + self.ui("finish") + try: + returncode = self.process.wait(timeout=30) + except subprocess.TimeoutExpired as exc: + raise Failed("XCUITest did not finish after complete gameplay") from exc + if returncode: + raise Failed(f"Actual XCUITest failed (exit={returncode}); inspect its retained xcresult") + self.summary["checks"].extend([{"real_ios_touch_and_keyboard_text": typed}, + {"real_ios_pause_resume": {"pause": pause, "resume": resume}}, + {"explicit_renpy_exit_and_actual_library_return": {"native": normal_exit, "library": library}}]) + game_root = self.documents / "renpy-device-demo" + raw = read_engine_log_file(game_root / "aetherkiri-engine.log") + (self.output / "aetherkiri-engine.log").write_bytes(raw) + self.summary["checks"].append({"native_cleanup_error_log": require_engine_log(raw, str(game_root))}) + self.summary["status"] = "passed" + + def verify_library_return(self, report: dict) -> None: + ui = report.get("ui", {}) + if (report.get("source") != "actual-production-main-scene" or not report.get("engine_destroyed") + or ui.get("scene_path") != "res://scenes/main.tscn" + or ui.get("script_path") != "res://scripts/main.gd" + or not ui.get("shell_visible") or not ui.get("library_visible") + or ui.get("game_view_visible") is not False or ui.get("route") != "library" + or ui.get("mode") != "game" or not ui.get("title") or not ui.get("title_visible") + or not ui.get("search_visible") or not ui.get("native_exited") or ui.get("image_result") != 0 + or not ui.get("library_path", "").endswith("/LibraryView") + or not ui.get("search_path", "").endswith("/LibrarySearch")): + raise Failed("Normal game exit did not restore the actual main scene's game library") + self.ui("foreground") + actual_path = self.output / "production-library.png" + shutil.copyfile(self.documents / "renpy-device-evidence/production-library.png", actual_path) + library = png_pixels(actual_path) + screen = png_pixels(self.screenshot("actual-library-after-game-exit")) + stats = {"library_viewport": image_stats(library), "os_screenshot": image_stats(screen)} + matches = count = 0 + # Prove the restored production library is actually visible in the + # OS compositor. Only its viewport, never a test-painted label, is + # used for this final UI comparison; gameplay used native RGBA above. + for iy in range(1, 10): + for ix in range(1, 10): + u, v = ix / 10, iy / 10 + expected = pixel(library, int(u * library[0]), int(v * library[1])) + actual = pixel(screen, int(u * screen[0]), int(v * screen[1])) + matches += max(abs(a - b) for a, b in zip(expected, actual)) <= 24 + count += 1 + stats["library_to_os_match"] = {"matching_samples": matches, "samples": count, + "fraction": matches / count, "channel_tolerance": 24} + (self.output / "pixels-actual-library.json").write_text(json.dumps(stats, indent=2) + "\n") + if matches / count < 0.70: + raise Failed("The actual restored LibraryView is absent from the system screenshot") + self.summary["checks"].append({"actual_library_return_pixels_and_os_screen": stats}) + + def collect(self) -> None: + self.snapshot_startup_diagnostics() + if self.documents is not None: + for relative, filename in [ + ("renpy-device-demo/aetherkiri-engine.log", "aetherkiri-engine.log"), + ("renpy-device-evidence/observer.jsonl", "observer.jsonl"), + ("renpy-device-demo/game/aether-device-checkpoints.jsonl", "renpy-script-checkpoints.jsonl"), + ("renpy-device-demo/log.txt", "renpy-log.txt"), + ("renpy-device-demo/traceback.txt", "renpy-traceback.txt")]: + try: + source = self.documents / relative + if source.is_file(): + shutil.copyfile(source, self.output / filename) + except OSError as exc: + with (self.output / "collection-errors.jsonl").open("a") as stream: + stream.write(json.dumps({"path": relative, "error": str(exc)}) + "\n") + if self.process is not None and self.process.poll() is None: + self.process.terminate() + try: + self.process.wait(timeout=10) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.wait(timeout=10) + if self.process_log is not None: + self.process_log.close() + if self.installed: + try: + logs = self.simctl("spawn", self.udid, "log", "show", "--last", "10m", "--style", "compact", check=False, timeout=60) + (self.output / "simulator-system.log").write_bytes(logs) + except Failed: + pass + if self.preference_changed: + if self.keyboard_preference is None: + self.command("defaults", "delete", "com.apple.iphonesimulator", "ConnectHardwareKeyboard", check=False) + else: + self.command("defaults", "write", "com.apple.iphonesimulator", "ConnectHardwareKeyboard", "-bool", self.keyboard_preference, check=False) + if self.created: + self.simctl("shutdown", self.udid, check=False) + self.simctl("delete", self.udid, check=False) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--app", type=Path, required=True) + parser.add_argument("--cloud-simulator", action="store_true") + parser.add_argument("--udid", help="Optional existing cloud Simulator; otherwise create and delete a disposable one") + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--stage-timeout", type=int, default=60) + parser.add_argument("--overall-timeout", type=int, default=600) + args = parser.parse_args() + if not 1 <= args.stage_timeout <= 180 or not 60 <= args.overall_timeout <= 1200: + parser.error("stage timeout must be 1..180 and overall timeout 60..1200 seconds") + acceptance = Acceptance(args) + result = 1 + try: + acceptance.run() + result = 0 + except Blocked as exc: + acceptance.summary.update(status="not_run", blocker=str(exc)) + result = 2 + except (Failed, OSError, ValueError, KeyError) as exc: + acceptance.summary.update(status="failed", blocker=str(exc)) + finally: + try: + acceptance.collect() + except (Failed, OSError) as exc: + acceptance.summary["collection_error"] = str(exc) + (acceptance.output / "result.json").write_text(json.dumps(acceptance.summary, indent=2) + "\n") + print(json.dumps(acceptance.summary, indent=2)) + return result + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/run_renpy_ios_egl_probe.sh b/tools/run_renpy_ios_egl_probe.sh new file mode 100755 index 000000000..0e5733717 --- /dev/null +++ b/tools/run_renpy_ios_egl_probe.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# Builds and runs a genuine MetalANGLE/GLES probe in an isolated iOS Simulator. +# Requires a cloud macOS runner with Xcode and a downloaded simulator runtime. +set -euo pipefail +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +renpy_build="" +output="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/aether-renpy-ios-egl-probe" +framework="" +while (($#)); do + case "$1" in + --renpy-build) renpy_build="$2"; shift 2 ;; + --framework) framework="$2"; shift 2 ;; + --output) output="$2"; shift 2 ;; + *) echo "Usage: $0 --renpy-build PATH [--framework PATH] [--output PATH]" >&2; exit 2 ;; + esac +done +[[ "$(uname -s)" == Darwin ]] || { echo 'This real iOS Simulator probe requires macOS/Xcode; no simulation fallback is provided.' >&2; exit 2; } +mkdir -p "$output/input" "$output/evidence" +if [[ -z "$framework" ]]; then + archive="$renpy_build/source/MetalANGLE.framework.ios.simulator.zip" + [[ -f "$archive" ]] || { echo "Missing pinned Ren'Py simulator framework: $archive" >&2; exit 1; } + unzip -q -o "$archive" -d "$output/input" + framework="$output/input/MetalANGLE.framework" + shasum -a 256 "$archive" > "$output/evidence/framework-archive.sha256" +fi +[[ -f "$framework/MetalANGLE" ]] || { echo "Invalid MetalANGLE framework: $framework" >&2; exit 1; } +arch="$(uname -m)" +xcrun lipo "$framework/MetalANGLE" -verify_arch "$arch" +xcrun lipo "$framework/MetalANGLE" -archs > "$output/evidence/framework-architectures.txt" +xcodebuild -version > "$output/evidence/xcode-version.txt" +xcrun --sdk iphonesimulator --show-sdk-path > "$output/evidence/sdk-path.txt" +app="$output/AetherRenPyEGLProbe.app" +mkdir -p "$app/Frameworks" +cp -R "$framework" "$app/Frameworks/" +embedded="$app/Frameworks/MetalANGLE.framework" +if [[ "$(xcrun lipo "$embedded/MetalANGLE" -archs)" == *" "* ]]; then + xcrun lipo "$embedded/MetalANGLE" -thin "$arch" -output "$output/MetalANGLE.thin" + mv "$output/MetalANGLE.thin" "$embedded/MetalANGLE" +fi +codesign --force --sign - "$embedded" +cat > "$app/Info.plist" <<'PLIST' + + + +CFBundleIdentifierorg.aetherkiri.renpy-egl-probe +CFBundleExecutableAetherRenPyEGLProbe +CFBundleNameAether RenPy EGL Probe +CFBundlePackageTypeAPPL +CFBundleSupportedPlatformsiPhoneSimulator +CFBundleVersion1 +CFBundleShortVersionString1.0 +MinimumOSVersion15.0 +UIDeviceFamily12 +UILaunchScreen +UISupportedInterfaceOrientationsUIInterfaceOrientationPortrait + +PLIST +sdk="$(xcrun --sdk iphonesimulator --show-sdk-path)" +xcrun --sdk iphonesimulator clang -target "$arch-apple-ios15.0-simulator" -isysroot "$sdk" \ + -Wall -Wextra -Werror -I"$framework/Headers" \ + -c "$repo_root/tools/renpy_ios_egl_probe/egl_probe.c" -o "$output/egl_probe.o" +xcrun --sdk iphonesimulator clang -target "$arch-apple-ios15.0-simulator" -isysroot "$sdk" \ + -Wall -Wextra -Werror -Wno-deprecated-declarations -fobjc-arc \ + -I"$framework/Headers" -F"$(dirname "$framework")" \ + "$repo_root/tools/renpy_ios_egl_probe/main.m" "$output/egl_probe.o" \ + -framework MetalANGLE -framework UIKit -framework Foundation -framework QuartzCore -framework CoreGraphics \ + -Wl,-rpath,@executable_path/Frameworks -o "$app/AetherRenPyEGLProbe" +codesign --force --sign - "$app" +xcrun otool -L "$app/AetherRenPyEGLProbe" > "$output/evidence/app-linked-libraries.txt" +xcrun simctl list runtimes --json > "$output/evidence/simulator-runtimes.json" +xcrun simctl list devicetypes --json > "$output/evidence/simulator-device-types.json" +read -r runtime device_type < <(python3 - "$output/evidence" <<'PY' +import json, pathlib, sys +p = pathlib.Path(sys.argv[1]) +available = [r for r in json.loads((p/'simulator-runtimes.json').read_text())['runtimes'] + if r.get('isAvailable') and '.iOS-' in r['identifier']] +if not available: + raise SystemExit('No available iOS Simulator runtime; install one with Xcode on this cloud runner.') +runtime = max(available, key=lambda r: tuple(int(i) for i in r['version'].split('.'))) +devices = json.loads((p/'simulator-device-types.json').read_text())['devicetypes'] +for name in ('iPhone 13', 'iPhone 14', 'iPhone 15', 'iPhone 16'): + device = next((d for d in devices if d['name'] == name), None) + if device: + print(runtime['identifier'], device['identifier']) + break +else: + raise SystemExit('No supported recent iPhone Simulator device type was found.') +PY +) +udid="$(xcrun simctl create "Aether RenPy EGL probe ${GITHUB_RUN_ID:-$$}" "$device_type" "$runtime")" +cleanup() { + xcrun simctl shutdown "$udid" >/dev/null 2>&1 || true + xcrun simctl delete "$udid" >/dev/null 2>&1 || true +} +trap cleanup EXIT +xcrun simctl boot "$udid" +xcrun simctl bootstatus "$udid" -b +xcrun simctl install "$udid" "$app" +xcrun simctl launch --terminate-running-process "$udid" org.aetherkiri.renpy-egl-probe | tee "$output/evidence/simulator-launch.txt" +container="$(xcrun simctl get_app_container "$udid" org.aetherkiri.renpy-egl-probe data)" +for ((attempt=0; attempt<90; ++attempt)); do + if [[ -f "$container/Documents/egl-probe.json" ]]; then break; fi + sleep 1 +done +if [[ ! -f "$container/Documents/egl-probe.json" ]]; then + xcrun simctl spawn "$udid" log show --last 3m --style compact > "$output/evidence/simulator-system.log" 2>&1 || true + echo 'Probe did not produce a report; inspect launch/system logs for dyld or Metal failures.' >&2 + exit 1 +fi +cp "$container/Documents/egl-probe."* "$output/evidence/" +xcrun simctl io "$udid" screenshot "$output/evidence/simulator-screen.png" +python3 - "$output/evidence/egl-probe.json" <<'PY' +import json, pathlib, sys +report = json.loads(pathlib.Path(sys.argv[1]).read_text()) +print(json.dumps(report, indent=2)) +required = ('passed', 'pbuffer_created', 'readback_verified', 'host_context_restored', 'resume_verified', 'no_extra_uiwindow') +if not all(report.get(k) for k in required): + raise SystemExit('Actual iOS EGL/GLES probe failed; this is not gameplay acceptance.') +print('Actual iOS EGL/GLES pbuffer probe passed; Ren\'Py gameplay remains unverified.') +PY diff --git a/tools/run_renpy_mobile_source_build.sh b/tools/run_renpy_mobile_source_build.sh new file mode 100755 index 000000000..99be15fa6 --- /dev/null +++ b/tools/run_renpy_mobile_source_build.sh @@ -0,0 +1,212 @@ +#!/usr/bin/env bash +# Build a coherent Ren'Py 8.5.3 mobile fork, never an unpatched RAPT launcher. +set -euo pipefail +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +launcher="$repo_root/bridge/renpy_runtime/mobile_launcher/build.sh" +patch_root="$repo_root/bridge/renpy_runtime/mobile_launcher/patches" +mode="${AETHERKIRI_RENPY_SOURCE_BUILD_MODE:-auto}" +renpy_build="${AETHERKIRI_RENPY_BUILD_ROOT:-$repo_root/.aetherkiri-cache/renpy-build}" +platform="${AETHERKIRI_RENPY_SOURCE_BUILD_PLATFORM:-android}" +output_dir="${AETHERKIRI_RENPY_SOURCE_OUTPUT:-$repo_root/out/renpy-mobile-source}" +source_url="${AETHERKIRI_RENPY_BUILD_URL:-https://github.com/renpy/renpy-build.git}" +build_sha=7bfab40c1174f622f644b24669afd5fb167fbb79 +renpy_sha=39895c1e017f0b36ffea2447d97eccd69d76ee1c +greenlet_sha=65f8da82b13a1273e55a6bfcbd1f9da09fc4eb7a +fetch=false +usage() { + cat <<'USAGE' +Usage: tools/run_renpy_mobile_source_build.sh [options] + --mode MODE auto, skip, prepare, check, or build + --renpy-build PATH coherent official Ren'Py 8.5.3 build checkout + --fetch fetch pinned official GitHub sources when absent + --platform PLATFORM android (default), ios, or all + --output-dir PATH real native payload and provenance destination + --url URL renpy-build source mirror (revision remains pinned) + --ref REF compatibility option; must be the pinned build SHA +Prepare clones the pinned source and greenlet dependency and applies all Python +and native patches. It does not install host packages or licensed SDKs. +Build runs the actual cross compilation and gates every lifecycle export. +Auto builds when a checkout exists; it never substitutes check for compilation. +Neither a successful build nor export checks assert device gameplay. +USAGE +} +while (($#)); do + case "$1" in + --mode|--renpy-build|--platform|--output-dir|--url|--ref) + (($# >= 2)) || { echo "$1 requires a value" >&2; exit 2; } + case "$1" in + --mode) mode="$2" ;; + --renpy-build) renpy_build="$2" ;; + --platform) platform="$2" ;; + --output-dir) output_dir="$2" ;; + --url) source_url="$2" ;; + --ref) [[ "$2" == "$build_sha" ]] || { echo "unsupported build revision: $2; expected $build_sha" >&2; exit 2; } ;; + esac + shift 2 ;; + --fetch) fetch=true; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;; + esac +done +case "$mode" in auto|skip|prepare|check|build) ;; *) echo "invalid mode: $mode" >&2; exit 2 ;; esac +case "$platform" in android|ios|all) ;; *) echo "invalid platform: $platform" >&2; exit 2 ;; esac +if [[ "$mode" == skip ]]; then + echo "Ren'Py mobile source build skipped by request (no payload built)" + exit 0 +fi +clone_pin() { + local url="$1" destination="$2" revision="$3" + if [[ ! -d "$destination" ]]; then + [[ "$fetch" == true ]] || { echo "missing official source: $destination (use --fetch)" >&2; return 1; } + mkdir -p "$(dirname "$destination")" + git clone --filter=blob:none --no-checkout "$url" "$destination" + git -C "$destination" fetch --depth 1 origin "$revision" + git -C "$destination" checkout --detach "$revision" + fi + local actual + actual="$(git -C "$destination" rev-parse HEAD)" + [[ "$actual" == "$revision" ]] || { + echo "unsupported source revision in $destination: $actual; expected $revision" >&2 + return 1 + } +} +if [[ ! -d "$renpy_build" && "$fetch" != true && "$mode" == auto && + "${AETHERKIRI_RENPY_SOURCE_BUILD_REQUIRED:-0}" != 1 ]]; then + echo "Ren'Py mobile source build skipped: official renpy-build checkout is absent" + exit 0 +fi +if [[ ! -d "$renpy_build" && "$fetch" != true ]]; then + echo "Ren'Py mobile source build requires an official renpy-build checkout: $renpy_build" >&2 + exit 1 +fi +clone_pin "$source_url" "$renpy_build" "$build_sha" +if [[ "$(uname -s)" == Darwin && "$mode" != prepare ]]; then + [[ "$platform" == ios ]] || { echo "native macOS builds currently support --platform ios only" >&2; exit 1; } + command -v brew >/dev/null || { echo "Homebrew is required for the native macOS source build" >&2; exit 1; } + host_deps=() + for dependency in openssl@3 xz bzip2 libffi; do + host_deps+=("$(brew --prefix "$dependency")") + done + AETHERKIRI_RENPY_HOST_DEPS="$(IFS=:; echo "${host_deps[*]}")" + export AETHERKIRI_RENPY_HOST_DEPS + export LIBTOOLIZE=glibtoolize + config_sub_candidates=("$(brew --prefix automake)"/share/automake-*/config.sub) + [[ -f "${config_sub_candidates[0]}" ]] || { echo "Homebrew automake config.sub is missing" >&2; exit 1; } + export AETHERKIRI_RENPY_CONFIG_SUB="${config_sub_candidates[0]}" +fi + +if [[ "$mode" == check ]]; then + bash "$launcher" --check-renpy-build --renpy-build "$renpy_build" --platform "$platform" + echo "Ren'Py source preflight passed; no compilation requested" + exit 0 +fi +clone_pin https://github.com/renpy/renpy.git "$renpy_build/renpy" "$renpy_sha" +clone_pin https://github.com/python-greenlet/greenlet.git "$renpy_build/aether-greenlet" "$greenlet_sha" +python3 "$repo_root/tools/apply_renpy_mobile_patch_series.py" \ + --checkout "$renpy_build/renpy" --pin "$renpy_sha" \ + "$patch_root/python/0001-cooperative-loop-skeleton.patch" +python3 "$repo_root/tools/apply_renpy_mobile_patch_series.py" \ + --checkout "$renpy_build" --pin "$build_sha" \ + "$patch_root/native"/[0-9][0-9][0-9][0-9]-*.patch +cp "$patch_root/native/renpy_mobile_lifecycle.c" "$renpy_build/runtime/renpy_mobile_lifecycle.c" +cp "$repo_root/bridge/renpy_runtime/mobile_launcher/include/renpy_mobile_launcher.h" "$renpy_build/runtime/renpy_mobile_launcher.h" +mkdir -p "$renpy_build/runtime/greenlet" +cp -a "$renpy_build/aether-greenlet/src/greenlet/." "$renpy_build/runtime/greenlet/" +cp "$renpy_build/aether-greenlet/LICENSE" "$renpy_build/runtime/greenlet/LICENSE" +cp "$renpy_build/aether-greenlet/LICENSE.PSF" "$renpy_build/runtime/greenlet/LICENSE.PSF" +# Detached release sources otherwise fall back to development 8.6 metadata. +cat > "$renpy_build/renpy/renpy/vc_version.py" <<'VERSION' +version = "8.5.3.26051504" +version_name = "AetherKiri mobile fork" +branch = "fix" +official = False +nightly = False +VERSION +python3 -m py_compile "$renpy_build/renpy/renpy/aether_mobile.py" \ + "$renpy_build/renpy/renpy/bootstrap.py" "$renpy_build/renpy/renpy/execution.py" \ + "$renpy_build/renpy/renpy/display/core.py" "$renpy_build/tasks/renpython.py" +if [[ "$mode" == prepare ]]; then + echo "Pinned Ren'Py mobile sources prepared; no native payload was compiled" + exit 0 +fi +bash "$launcher" --check-renpy-build --renpy-build "$renpy_build" --platform "$platform" +# All prerequisite tasks must run: selecting only renpython would link stale +# librenpy and would omit the new Surface method and target standard library. +if [[ "$platform" == android || "$platform" == all ]]; then + (cd "$renpy_build"; ./build.sh --platform android --python 3 rebuild librenpy pythonlib renpython rapt sdl2) +fi +if [[ "$platform" == ios || "$platform" == all ]]; then + ios_arch_args=() + if [[ "$(uname -s)" == Darwin ]]; then + ios_arch_args=(--arch arm64,sim-arm64) + fi + (cd "$renpy_build"; ./build.sh --platform ios --python 3 ${ios_arch_args[@]+"${ios_arch_args[@]}"} rebuild librenpy pythonlib renpython renios) +fi +symbols=(renpy_mobile_bootstrap renpy_mobile_bind_window renpy_mobile_init renpy_mobile_tick + renpy_mobile_frame renpy_mobile_input renpy_mobile_pause renpy_mobile_resume renpy_mobile_shutdown + renpy_mobile_text_input_state renpy_mobile_set_surface_size) +check_exports() { + local artifact="$1" kind="$2" output symbol + [[ -f "$artifact" ]] || { echo "missing compiled artifact: $artifact" >&2; exit 1; } + if [[ "$kind" == android ]]; then + output="$(nm -D --defined-only "$artifact")" + elif [[ "$(uname -s)" == Darwin ]]; then + output="$(xcrun nm -gU "$artifact")" + else + output="$(llvm-nm-18 --defined-only --extern-only "$artifact")" + fi + for symbol in "${symbols[@]}"; do + awk -v symbol="$symbol" '{ name=$NF; sub(/^_/, "", name); if (name==symbol) found=1 } END { exit !found }' <<<"$output" || { + echo "compiled artifact lacks $symbol: $artifact" >&2; exit 1; + } + done +} +artifact_root="$renpy_build/renpy" +if [[ "$platform" == android || "$platform" == all ]]; then + for abi in arm64-v8a armeabi-v7a x86_64; do + check_exports "$artifact_root/rapt3/prototype/renpyandroid/src/main/jniLibs/$abi/librenpython.so" android + done +fi +if [[ "$platform" == ios || "$platform" == all ]]; then + for kind in release debug; do + check_exports "$artifact_root/renios3/prototype/prebuilt/$kind/librenpython.a" ios + done +fi +# Only create the distributable payload after actual builds/export checks pass. +mkdir -p "$output_dir/payload" +if [[ "$platform" == android || "$platform" == all ]]; then + mkdir -p "$output_dir/payload/rapt" + cp -a "$artifact_root/rapt3/." "$output_dir/payload/rapt/" +fi +if [[ "$platform" == ios || "$platform" == all ]]; then + mkdir -p "$output_dir/payload/renios" + cp -a "$artifact_root/renios3/." "$output_dir/payload/renios/" +fi +python3 - "$artifact_root" "$output_dir" "$platform" "$build_sha" "$renpy_sha" "$greenlet_sha" "$patch_root" <<'PY' +import hashlib, json, pathlib, shutil, sys +source, output = map(pathlib.Path, sys.argv[1:3]); platform = sys.argv[3] +for target in (["rapt"] if platform == "android" else ["renios"] if platform == "ios" else ["rapt", "renios"]): + private = output / "payload" / target / ("runtime/private" if target == "rapt" else "prototype/base") + private.mkdir(parents=True, exist_ok=True) + shutil.copy2(source / "renpy.py", private / ("main.py" if target == "rapt" else "renpy.py")) + shutil.copytree(source / "renpy", private / "renpy", dirs_exist_ok=True, + ignore=shutil.ignore_patterns("__pycache__", "*.pyx", "*.pxd", "*.pxi")) + shutil.copytree(source / "lib/python3.12", private / "lib/python3.12", dirs_exist_ok=True) + (private / "game").mkdir(exist_ok=True) + licenses = private / "licenses" / "greenlet" + licenses.mkdir(parents=True, exist_ok=True) + for name in ("LICENSE", "LICENSE.PSF"): + shutil.copy2(source.parent / "aether-greenlet" / name, licenses / name) +manifest = {"schema_version": 1, "renpy_version": "8.5.3.26051504+unofficial", "distribution": "8.5.3-rebuilt", "abi_version": 2, + "build_source_sha": sys.argv[4], "renpy_source_sha": sys.argv[5], + "greenlet_source_sha": sys.argv[6], "platform": platform, + "native_compilation": "passed", "device_gameplay": "unverified", + "patches": {str(p.relative_to(sys.argv[7])): hashlib.sha256(p.read_bytes()).hexdigest() + for p in pathlib.Path(sys.argv[7]).rglob("*.patch")}} +(output / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n") +for target in (output / "payload").iterdir(): + if target.name in ("rapt", "renios"): + (target / "aether-runtime.json").write_text(json.dumps(manifest, indent=2) + "\n") +PY +echo "Native mobile fork compiled and export-gated: $output_dir/payload" +echo "Device/simulator rendering, input and lifecycle gameplay remain unverified" diff --git a/tools/run_renpy_overlay_probe.sh b/tools/run_renpy_overlay_probe.sh new file mode 100755 index 000000000..0ea0c7dba --- /dev/null +++ b/tools/run_renpy_overlay_probe.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +renpy_sdk="${RENPY_SDK:-}" +renpy_bin="${RENPY_BIN:-}" +renderer="${RENPY_RENDERER:-gl2}" + +usage() { + cat <<'USAGE' +Usage: tools/run_renpy_overlay_probe.sh + +Run a real Ren'Py SDK frame/input bridge probe. Set RENPY_SDK to an extracted +official SDK directory, or RENPY_BIN to its launcher executable. The probe +uses SDL's dummy video/audio drivers, so it does not need a desktop display. +USAGE +} + +if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + usage + exit 0 +fi +if [[ $# -ne 0 ]]; then + printf 'unexpected argument: %s\n' "$1" >&2 + usage >&2 + exit 2 +fi + +if [[ -z "$renpy_bin" && -n "$renpy_sdk" ]]; then + if [[ -x "$renpy_sdk/renpy.sh" ]]; then + renpy_bin="$renpy_sdk/renpy.sh" + elif [[ -x "$renpy_sdk/renpy" ]]; then + renpy_bin="$renpy_sdk/renpy" + else + printf "Ren'Py launcher not found under RENPY_SDK: %s\n" "$renpy_sdk" >&2 + exit 1 + fi +fi +if [[ -z "$renpy_bin" ]]; then + if command -v renpy.sh >/dev/null 2>&1; then + renpy_bin="$(command -v renpy.sh)" + elif command -v renpy >/dev/null 2>&1; then + renpy_bin="$(command -v renpy)" + else + printf "Set RENPY_SDK to an extracted official Ren'Py SDK or RENPY_BIN to its launcher.\n" >&2 + exit 1 + fi +fi +if [[ ! -x "$renpy_bin" ]]; then + printf "Ren'Py launcher is not executable: %s\n" "$renpy_bin" >&2 + exit 1 +fi + +tmp_root="$(mktemp -d "${TMPDIR:-/tmp}/aetherkiri-renpy-overlay.XXXXXX")" +cleanup() { rm -rf "$tmp_root"; } +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +project="$tmp_root/project" +mkdir -p "$project/game" "$tmp_root/home" "$tmp_root/config" +cp "$repo_root/bridge/renpy_runtime/python/aether_renpy_overlay.py" "$project/game/" +cat > "$project/game/options.rpy" <<'R' +define config.name = "AetherKiri bridge probe" +define config.version = "1.0" +define config.window_title = "AetherKiri bridge probe" +define config.screen_width = 640 +define config.screen_height = 360 +define config.developer = True +define config.has_sound = False +define config.has_music = False +define config.has_voice = False +define config.gl_enable = False +define config.performance_test = False + +init python: + import aether_renpy_overlay + aether_renpy_overlay.install() +R +cat > "$project/game/script.rpy" <<'R' +label main_menu: + jump start + +label start: + scene Solid("#194a72") + with None + menu: + "Input bridge continue": + $ open(renpy.config.gamedir + "/input-ok", "w").write("continue\n") + $ renpy.quit() + "Input bridge finish": + $ open(renpy.config.gamedir + "/input-ok", "w").write("finish\n") + $ renpy.quit() +R + +frame="$tmp_root/frame.bin" +input="$tmp_root/input.json" +errors="$tmp_root/bridge-errors.log" +: > "$input" +sdk_root="$(cd "$(dirname "$renpy_bin")" && pwd)" +renpy_bin="$sdk_root/$(basename "$renpy_bin")" + +( + cd "$sdk_root" + HOME="$tmp_root/home" XDG_CONFIG_HOME="$tmp_root/config" \ + RENPY_RENDERER="$renderer" AETHERKIRI_RENPY_OVERLAY=1 \ + AETHERKIRI_RENPY_FRAME="$frame" AETHERKIRI_RENPY_INPUT="$input" \ + AETHERKIRI_RENPY_ERROR="$errors" \ + "$renpy_bin" --compile "$project" quit >/dev/null +) + +set +e +( + cd "$sdk_root" + HOME="$tmp_root/home" XDG_CONFIG_HOME="$tmp_root/config" \ + SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy RENPY_RENDERER="$renderer" \ + AETHERKIRI_RENPY_OVERLAY=1 AETHERKIRI_RENPY_FRAME="$frame" \ + AETHERKIRI_RENPY_INPUT="$input" AETHERKIRI_RENPY_ERROR="$errors" \ + timeout -k 1s "${RENPY_PROBE_TIMEOUT:-20}s" "$renpy_bin" "$project" +) >"$tmp_root/game.out" 2>&1 & +pid=$! +ready=0 +for _ in $(seq 1 200); do + if [[ -s "$frame" ]]; then ready=1; break; fi + sleep 0.1 +done +if [[ "$ready" -ne 1 ]]; then + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + printf 'overlay probe did not publish a frame\n' >&2 + cat "$tmp_root/game.out" >&2 + exit 1 +fi + +# The overlay fills Ren'Py 8.5.x KEYDOWN defaults when constructing an Event. +printf '%s\n%s\n' \ + '{"type":768,"attributes":{"key":1073741905}}' \ + '{"type":768,"attributes":{"key":13}}' \ + >> "$input" +wait "$pid" +rc=$? +set -e +if [[ "$rc" -ne 0 ]]; then + printf "overlay probe Ren'Py process exited with status %s\n" "$rc" >&2 + cat "$tmp_root/game.out" >&2 + exit 1 +fi + +python3 - "$frame" "$project/game/input-ok" "$errors" <<'PY' +import pathlib +import struct +import sys + +frame = pathlib.Path(sys.argv[1]).read_bytes() +marker = pathlib.Path(sys.argv[2]).read_text() +errors = pathlib.Path(sys.argv[3]) +if frame[:8] != b"AKRF1\0\0\0": + raise SystemExit("unexpected frame magic") +width, height, serial, payload_len = struct.unpack("&2 + usage >&2 + exit 2 +fi + +if [[ ! -d "$fixture_root/game" ]]; then + printf "Ren'Py fixture is missing game/: %s\n" "$fixture_root" >&2 + exit 1 +fi +for source_file in options.rpy script.rpy; do + if [[ ! -f "$fixture_root/game/$source_file" ]]; then + printf "Ren'Py fixture is missing game/%s\n" "$source_file" >&2 + exit 1 + fi +done + +if [[ -z "$renpy_bin" && -n "$renpy_sdk" ]]; then + host_platform="$(uname -s)" + if [[ "$host_platform" == MINGW* || "$host_platform" == MSYS* || + "$host_platform" == CYGWIN* ]] && [[ -f "$renpy_sdk/renpy.exe" ]]; then + # The Windows SDK also contains renpy.sh and Linux executables. + # Git Bash must run the native Windows launcher rather than that shell. + renpy_bin="$renpy_sdk/renpy.exe" + elif [[ -x "$renpy_sdk/renpy.sh" ]]; then + renpy_bin="$renpy_sdk/renpy.sh" + elif [[ -x "$renpy_sdk/renpy" ]]; then + renpy_bin="$renpy_sdk/renpy" + elif [[ -f "$renpy_sdk/renpy.exe" ]]; then + # Windows zip archives do not always preserve the executable mode bit + # when extracted by Git Bash, but the .exe is still directly runnable. + renpy_bin="$renpy_sdk/renpy.exe" + else + printf "Ren'Py launcher not found under RENPY_SDK: %s\n" "$renpy_sdk" >&2 + exit 1 + fi +fi +if [[ -z "$renpy_bin" ]]; then + if command -v renpy.sh >/dev/null 2>&1; then + renpy_bin="$(command -v renpy.sh)" + elif command -v renpy >/dev/null 2>&1; then + renpy_bin="$(command -v renpy)" + else + printf "Set RENPY_SDK to an extracted official Ren'Py SDK or RENPY_BIN to its launcher.\n" >&2 + exit 1 + fi +fi +if [[ ! -x "$renpy_bin" && + ! ( "$renpy_bin" == *.exe && -f "$renpy_bin" ) ]]; then + printf "Ren'Py launcher is not executable: %s\n" "$renpy_bin" >&2 + exit 1 +fi + +sdk_root="$(cd "$(dirname "$renpy_bin")" && pwd)" +renpy_bin="$sdk_root/$(basename "$renpy_bin")" +tmp_root="$(mktemp -d "${TMPDIR:-/tmp}/aetherkiri-renpy-smoke.XXXXXX")" +cleanup() { + rm -rf "$tmp_root" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +sandbox="$tmp_root/fixture" +mkdir -p "$sandbox" +cp -a "$fixture_root/." "$sandbox/" +mkdir -p "$tmp_root/home" "$tmp_root/config" + +# --compile forces the SDK to parse the source even when stale bytecode exists; +# quit initializes the project without leaving a game window running. +( + cd "$sdk_root" + HOME="$tmp_root/home" XDG_CONFIG_HOME="$tmp_root/config" \ + "$renpy_bin" --compile --savedir "$tmp_root/saves" "$sandbox" quit +) + +printf "Ren'Py smoke ok: fixture=%s sdk=%s\n" "$fixture_root" "$sdk_root" diff --git a/tools/stage_renpy_android_support.sh b/tools/stage_renpy_android_support.sh new file mode 100644 index 000000000..9b470a991 --- /dev/null +++ b/tools/stage_renpy_android_support.sh @@ -0,0 +1,338 @@ +#!/usr/bin/env bash +# Stage a rebuilt Ren'Py RAPT runtime into a Godot Android export. +# +# RAPT is an SDLActivity application template. It must not be merged as the +# exported app's manifest or launched as a second Activity: Godot owns the +# process, window, and SDL lifecycle. This script therefore packages the +# official Java/resources as inspectable assets, stages the selected native +# payload in jniLibs, and installs the host-owned JNI bridge. Enabled app +# builds require a lifecycle library and complete cooperative Python payload. +# --allow-unsupported is an explicit archive inspection mode, never gameplay. +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: tools/stage_renpy_android_support.sh --mobile-root PATH --godot-build PATH + +Stage the pinned official RAPT Android support package into an existing Godot +Android build template. The RAPT PythonSDLActivity is stored under APK assets +and is never added to the manifest, so this command cannot create a second +Activity. An optional game-specific private/assets directory can be supplied +with --private-assets. + +Options: + --mobile-root PATH Root containing rapt/prototype/renpyandroid + --godot-build PATH Extracted Godot Android build template directory + --abis LIST Comma-separated arm64-v8a,x86_64 (default: arm64-v8a) + --private-assets PATH Unpacked runtime directory (main.py, renpy/, lib/) + --allow-unsupported Inspect an official archive without a lifecycle fork; + this mode is never used by enabled app builds + -h, --help Show this help +USAGE +} + +mobile_root="" +godot_build="" +private_assets="" +abis="arm64-v8a" +allow_unsupported=false +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +while (($#)); do + case "$1" in + --mobile-root) + (($# >= 2)) || { echo "--mobile-root requires a value" >&2; exit 2; } + mobile_root="$2" + shift 2 + ;; + --godot-build) + (($# >= 2)) || { echo "--godot-build requires a value" >&2; exit 2; } + godot_build="$2" + shift 2 + ;; + --private-assets) + (($# >= 2)) || { echo "--private-assets requires a value" >&2; exit 2; } + private_assets="$2" + shift 2 + ;; + --abis) + (($# >= 2)) || { echo "--abis requires a value" >&2; exit 2; } + abis="$2" + shift 2 + ;; + --allow-unsupported) + allow_unsupported=true + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ -z "$mobile_root" || -z "$godot_build" ]]; then + echo "--mobile-root and --godot-build are required" >&2 + usage >&2 + exit 2 +fi + +rapt_root="$mobile_root/rapt" +rapt_main="$rapt_root/prototype/renpyandroid/src/main" +IFS=',' read -r -a abi_list <<< "$abis" +[[ -n "$abis" && "$abis" != *, && "$abis" != ,* ]] || { + echo "--abis must contain arm64-v8a and/or x86_64" >&2 + exit 2 +} +for abi in "${abi_list[@]}"; do + case "$abi" in + arm64-v8a|x86_64) ;; + *) echo "Unsupported Ren'Py Android packaging ABI: $abi" >&2; exit 2 ;; + esac +done + +[[ -d "$rapt_main" ]] || { + echo "RAPT Android module is missing: $rapt_main" >&2 + exit 1 +} +[[ -d "$godot_build" ]] || { + echo "Godot Android build template is missing: $godot_build" >&2 + exit 1 +} +[[ -f "$godot_build/build.gradle" ]] || { + echo "Godot Android Gradle application template is missing: $godot_build/build.gradle" >&2 + exit 1 +} +if [[ -n "$private_assets" && ! -e "$private_assets" ]]; then + echo "private assets path is missing: $private_assets" >&2 + exit 1 +fi + +is_elf_file() { + local path="$1" + [[ -f "$path" ]] || return 1 + [[ "$(dd if="$path" bs=4 count=1 2>/dev/null | LC_ALL=C od -An -tx1 | tr -d ' \\n')" == "7f454c46" ]] +} + +if [[ "$allow_unsupported" == false ]]; then + if [[ -z "$private_assets" ]]; then + private_assets="$rapt_root/runtime/private" + fi + python3 "$repo_root/tools/validate_renpy_mobile_payload.py" \ + --platform android --private-root "$private_assets" +fi +for abi in "${abi_list[@]}"; do + rapt_native="$rapt_main/jniLibs/$abi/librenpython.so" + [[ -f "$rapt_native" ]] || { + echo "RAPT $abi native library is missing: $rapt_native" >&2 + exit 1 + } + is_elf_file "$rapt_native" || { + echo "RAPT $abi native library is not an ELF file: $rapt_native" >&2 + exit 1 + } + if [[ "$allow_unsupported" == false ]]; then + python3 "$repo_root/tools/validate_renpy_mobile_payload.py" \ + --platform android --abi "$abi" --library "$rapt_native" + fi +done + +main_src="$godot_build/src/main" +# Godot recursively clears src/main/assets before every Gradle export. Keep +# the native Python payload in a separate Android asset source directory; +# Gradle merges it into the APK as real AssetManager files, outside game PCKs. +asset_root="$godot_build/renpy_assets/renpy_mobile" +asset_rapt="$asset_root/rapt" +asset_private="$asset_root/private" +private_archive="$asset_rapt/private.mp3" +jni_root="$main_src/jniLibs" +java_root="$main_src/java/org/github/krkr2/aetherkiri" +java_sdl_root="$main_src/java/org/libsdl/app" +java_renpy_root="$main_src/java/org/renpy/android" + +mkdir -p "$asset_rapt" "$asset_private" "$jni_root" "$java_root" +cat > "$godot_build/aether-renpy.gradle" <<'GRADLE' +// Aether's persistent Ren'Py assets survive Godot's exported-game cleanup. +// Keep Godot's normal asset source set and merge these raw files into it. +android.sourceSets.main.assets.srcDir(file('renpy_assets')) +GRADLE +if ! grep -Fxq "apply from: 'aether-renpy.gradle'" "$godot_build/build.gradle"; then + printf "\napply from: 'aether-renpy.gradle'\n" >> "$godot_build/build.gradle" +fi + +# This source is part of AetherKiri, not an official RAPT Activity. It binds +# the existing Godot Activity to engine_api without creating a second owner. +cp -f "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/bridge/renpy_runtime/android/RenPyMobileBridge.java" \ + "$java_root/RenPyMobileBridge.java" + +# Compile only the host-owned callback signatures. The official RAPT Java +# sources remain assets and its PythonSDLActivity manifest is never merged; +# compiling the full Activity would create a second SDL singleton. Refuse to +# overwrite an unrelated class if a Godot template starts shipping one. +install_host_shim() { + local source="$1" destination="$2" marker="$3" + mkdir -p "$(dirname "$destination")" + if [[ -f "$destination" ]] && ! grep -Fq "$marker" "$destination"; then + echo "Refusing to overwrite unrelated Android Java class: $destination" >&2 + exit 1 + fi + cp -f "$source" "$destination" + # Vendored SDL helper classes may not contain our marker in the upstream + # source. Mark the staged copy so another build can safely update it. + printf '\n// %s: staged host-owned callback source.\n' "$marker" >> "$destination" +} +while IFS= read -r -d '' shim; do + relative="${shim#"$repo_root/bridge/renpy_runtime/android/"}" + case "$relative" in + org/libsdl/*) marker='renpy-sdl-host-shim-v1' ;; + org/renpy/*) marker='renpy-python-host-shim-v1' ;; + *) continue ;; + esac + install_host_shim "$shim" "$main_src/java/$relative" "$marker" +done < <(find "$repo_root/bridge/renpy_runtime/android/org" -type f -name '*.java' -print0) + +# The source tree is deliberately copied below assets rather than src/main/java +# or src/main/res. RAPT's manifest names PythonSDLActivity as its launcher; +# merging it into Godot would create a second Activity and two SDL owners. +rm -rf "$asset_rapt/java" "$asset_rapt/res" "$asset_rapt/templates" +mkdir -p "$asset_rapt/java" "$asset_rapt/res" +cp -a "$rapt_main/java/." "$asset_rapt/java/" +if [[ -d "$rapt_main/res" ]]; then + cp -a "$rapt_main/res/." "$asset_rapt/res/" +fi +# Keep Kotlin templates too if a future official RAPT package adds them. The +# current 8.5.3 package is Java-only, but the asset boundary is language +# neutral and does not risk compiling a second Activity. +if find "$rapt_main" -type f -name '*.kt' -print -quit | grep -q .; then + mkdir -p "$asset_rapt/kotlin" + while IFS= read -r -d '' kotlin_file; do + relative="${kotlin_file#"$rapt_main/"}" + mkdir -p "$asset_rapt/kotlin/$(dirname "$relative")" + cp -f "$kotlin_file" "$asset_rapt/kotlin/$relative" + done < <(find "$rapt_main" -type f -name '*.kt' -print0) +fi +if [[ -f "$rapt_main/AndroidManifest.xml" ]]; then + cp -f "$rapt_main/AndroidManifest.xml" "$asset_rapt/AndroidManifest.xml" +fi +if [[ -f "$rapt_root/prototype/renpyandroid/build.gradle" ]]; then + cp -f "$rapt_root/prototype/renpyandroid/build.gradle" "$asset_rapt/build.gradle" +fi +if [[ -d "$rapt_root/templates" ]]; then + cp -a "$rapt_root/templates" "$asset_rapt/templates" +fi + +# Keep any official RAPT app assets and any optional game-specific private +# payload visible to the eventual extractor. RAPT does not ship a game +# private.mp3 in its support archive, so emit a deterministic marker instead +# of pretending that the provider can launch a game without one. +if [[ -d "$rapt_root/prototype/app/src/main/assets" ]]; then + rm -rf "$asset_rapt/app-assets" + mkdir -p "$asset_rapt/app-assets" + cp -a "$rapt_root/prototype/app/src/main/assets/." "$asset_rapt/app-assets/" +fi +find "$asset_private" -mindepth 1 -maxdepth 1 -exec rm -rf {} + +rm -f "$private_archive" +if [[ -d "$rapt_main/private" ]]; then + cp -a "$rapt_main/private/." "$asset_private/" +fi +if [[ -d "$private_assets" ]]; then + cp -a "$private_assets/." "$asset_private/" +elif [[ -n "$private_assets" ]]; then + cp -f "$private_assets" "$asset_private/$(basename "$private_assets")" +fi +if [[ -f "$asset_private/private.mp3" ]]; then + # PythonSDLActivity's ResourceManager looks up private.mp3 at the asset + # root. Keep the auditable private/ copy too, but expose this canonical + # RAPT filename for the future host-owned extractor. + cp -f "$asset_private/private.mp3" "$private_archive" +fi +if [[ -z "$(find "$asset_private" -mindepth 1 -print -quit)" ]]; then + cat > "$asset_private/README.txt" <<'PRIVATE_EOF' +This directory is reserved for the Ren'Py game's RAPT private payload. +The official RAPT support archive does not contain game-specific private.mp3 +data. Supply --private-assets when staging a built Ren'Py game. AetherKiri +keeps runtime=renpy NOT_SUPPORTED until extraction, lifecycle, input, and +rendering are wired to the existing Godot Activity. +PRIVATE_EOF +fi + +# Clear only files registered by a previous Ren'Py staging run. This keeps +# other Godot template libraries intact when switching emulator/device ABIs. +previous_manifest="$asset_root/manifest.properties" +if [[ -f "$previous_manifest" ]]; then + previous_files="$(sed -n 's/^staged_native_files=//p' "$previous_manifest")" + if [[ -z "$previous_files" ]]; then + previous_files="$(sed -n 's/^native_library=lib\///p' "$previous_manifest")" + fi + IFS=',' read -r -a previous_file_list <<< "$previous_files" + for previous_file in "${previous_file_list[@]}"; do + [[ "$previous_file" != *..* ]] || continue + case "$previous_file" in + arm64-v8a/lib*.so|x86_64/lib*.so) + rm -f "$jni_root/$previous_file" + ;; + esac + done +fi +native_paths=() +staged_native_files=() +for abi in "${abi_list[@]}"; do + mkdir -p "$jni_root/$abi" + native_paths+=("lib/$abi/librenpython.so") + for native_library in "$rapt_main/jniLibs/$abi/"*.so; do + [[ -f "$native_library" ]] || continue + native_file="$abi/$(basename "$native_library")" + cp -f "$native_library" "$jni_root/$native_file" + chmod 755 "$jni_root/$native_file" + staged_native_files+=("$native_file") + done +done + +# Retain the compiled Ren'Py dependency bytes in the APK. AGP otherwise strips +# them again, obscuring which validated native input the app actually shipped. +# This does not change the host libraries' normal release stripping policy. +{ + printf '\nandroid.packagingOptions.jniLibs.keepDebugSymbols += [\n' + for native_file in "${staged_native_files[@]}"; do + printf " 'lib/%s',\n" "$native_file" + done + printf ']\n' +} >> "$godot_build/aether-renpy.gradle" + +# Record the exact staged paths and the archive metadata when available. This +# is consumed by package smoke tests and makes an APK inspection auditable. +rapt_checksum="" +if [[ -f "$rapt_root/.aetherkiri-sha256" ]]; then + rapt_checksum="$(awk 'NR == 1 { print $1; exit }' "$rapt_root/.aetherkiri-sha256")" +elif [[ -f "$rapt_root/hash.txt" ]]; then + rapt_checksum="$(tr -d '[:space:]' < "$rapt_root/hash.txt")" +fi +{ + printf 'renpy_version=8.5.3\n' + printf 'runtime_sdl=SDL2\n' + printf 'rapt_checksum=%s\n' "$rapt_checksum" + printf 'playable=unverified\n' + printf 'inspection_only=%s\n' "$allow_unsupported" + printf 'native_lifecycle_validated=%s\n' "$( [[ "$allow_unsupported" == false ]] && printf true || printf false )" + printf 'activity_template=assets/renpy_mobile/rapt/java/org/renpy/android/PythonSDLActivity.java\n' + printf 'gradle_template=assets/renpy_mobile/rapt/build.gradle\n' + printf 'native_abis=%s\n' "$abis" + printf 'native_library=%s\n' "$(IFS=','; printf '%s' "${native_paths[*]}")" + printf 'staged_native_files=%s\n' "$(IFS=','; printf '%s' "${staged_native_files[*]}")" + printf 'private_assets=assets/renpy_mobile/private\n' + printf 'gradle_asset_source=renpy_assets\n' + printf 'private_archive=%s\n' "$( [[ -f "$private_archive" ]] && printf 'assets/renpy_mobile/rapt/private.mp3' || true )" + printf 'manifest_merged=false\n' + printf 'java_host_shims=org/libsdl/app/SDLActivity.java,org/renpy/android/PythonSDLActivity.java\n' +} > "$asset_root/manifest.properties" + +echo "Ren'Py Android support staged into $godot_build" +echo " official Java/resources: $asset_rapt" +echo " private assets: $asset_private" +echo " native library ABIs: $abis" +echo " gameplay: unverified (requires device execution)" diff --git a/tools/test_ggml_overlay_source.py b/tools/test_ggml_overlay_source.py new file mode 100644 index 000000000..89950fe37 --- /dev/null +++ b/tools/test_ggml_overlay_source.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Compile the pinned ggml source, apply the real port patches, and consume its CMake API. + +Supply an existing official v0.11.1 source archive. This check performs no +downloads, modifies no vcpkg cache, and does not test Android or gameplay. +""" + +import argparse +import hashlib +import json +import os +import re +import subprocess +import tarfile +import tempfile +from pathlib import Path, PurePosixPath + + +def run(*command: str, cwd: Path | None = None) -> None: + environment = dict(os.environ, SOURCE_DATE_EPOCH='1') + result = subprocess.run(command, cwd=cwd, env=environment, text=True, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + if result.returncode: + raise RuntimeError(f'{command}: exit {result.returncode}\n{result.stdout}') + + +def validate_archive(archive: Path, recipe: str) -> str: + expected = re.search(r'\bSHA512\s+([a-f0-9]{128})\b', recipe) + if not expected: + raise ValueError('The production recipe must specify SHA512') + actual = hashlib.sha512(archive.read_bytes()).hexdigest() + if actual != expected.group(1): + raise ValueError(f'Source archive SHA512 differs: {actual}') + return actual + + +def check(archive: Path, cmake: str) -> None: + port = Path(__file__).resolve().parents[1] / 'vcpkg/ports/ggml' + manifest = json.loads((port / 'vcpkg.json').read_text()) + recipe = (port / 'portfile.cmake').read_text() + assert manifest['version'] == '0.11.1' + assert 'include("${VCPKG_ROOT_DIR}/ports/ggml/portfile.cmake")' not in recipe + checksum = validate_archive(archive, recipe) + patches = re.search(r'\bPATCHES\s+(.*?)\n\)', recipe, re.DOTALL) + if not patches: + raise ValueError('The production recipe must list its source patches') + names = patches.group(1).split() + with tempfile.TemporaryDirectory(prefix='aether-ggml-source-') as directory: + root = Path(directory) + with tarfile.open(archive) as source_archive: + members = source_archive.getmembers() + for member in members: + path = PurePosixPath(member.name) + if path.is_absolute() or '..' in path.parts or not (member.isfile() or member.isdir()): + raise ValueError(f'Unsafe source archive member: {member.name}') + if not path.parts or path.parts[0] != 'ggml-0.11.1': + raise ValueError(f'Unexpected source root: {member.name}') + source_archive.extractall(root, members=members, filter='data') + source = root / 'ggml-0.11.1' + for name in names: + patch = port / name + if patch.parent != port or not patch.is_file(): + raise ValueError(f'Invalid or missing port patch: {name}') + run('patch', '-p1', '--batch', '--forward', '-i', str(patch), cwd=source) + build = root / 'build' + prefix = root / 'installed' + run(cmake, '-S', str(source), '-B', str(build), '-DCMAKE_BUILD_TYPE=Release', + '-DCMAKE_INSTALL_PREFIX=' + str(prefix), '-DBUILD_SHARED_LIBS=OFF', + '-DGGML_STATIC=ON', '-DGGML_CCACHE=OFF', '-DGGML_BUILD_NUMBER=1', + '-DGGML_BUILD_TESTS=OFF', '-DGGML_BUILD_EXAMPLES=OFF', '-DGGML_HIP=OFF', + '-DGGML_SYCL=OFF', '-DGGML_BLAS=OFF', '-DGGML_CUDA=OFF', '-DGGML_METAL=OFF', + '-DGGML_OPENCL=OFF', '-DGGML_OPENMP=OFF', '-DGGML_VULKAN=OFF') + run(cmake, '--build', str(build), '--parallel', '2') + run(cmake, '--install', str(build)) + consumer = root / 'consumer' + consumer.mkdir() + (consumer / 'CMakeLists.txt').write_text('''cmake_minimum_required(VERSION 3.14) +project(ggml_consumer LANGUAGES CXX) +find_package(ggml CONFIG REQUIRED) +add_executable(ggml_smoke smoke.cpp) +target_link_libraries(ggml_smoke PRIVATE ggml::ggml) +''') + # The application/llama-cpp host is C++; its static ggml implementation + # includes C++ objects and needs the normal C++ executable link driver. + (consumer / 'smoke.cpp').write_text('''#include +int main(void) { + struct ggml_init_params params = {4 * 1024 * 1024, 0, false}; + struct ggml_context *context = ggml_init(params); + if (!context) return 1; + struct ggml_tensor *tensor = ggml_new_tensor_1d(context, GGML_TYPE_F32, 4); + int result = (!tensor || ggml_nelements(tensor) != 4 || ggml_nbytes(tensor) != 16); + ggml_free(context); + return result; +} +''') + consumer_build = root / 'consumer-build' + run(cmake, '-S', str(consumer), '-B', str(consumer_build), + '-DCMAKE_PREFIX_PATH=' + str(prefix)) + run(cmake, '--build', str(consumer_build), '--parallel', '2') + run(str(consumer_build / 'ggml_smoke')) + # A single changed source byte must be rejected before extraction or compilation. + changed = root / 'changed-source.tar.gz' + changed.write_bytes(archive.read_bytes() + b'corruption') + try: + validate_archive(changed, recipe) + except ValueError: + pass + else: + raise AssertionError('Changed source archive was accepted') + print(f'ggml 0.11.1 SHA512 verified: {checksum}') + print(f'{len(names)} official patches applied; real host build/install and ggml::ggml API link/run passed; changed-byte rejection passed.') + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--archive', type=Path, required=True) + parser.add_argument('--cmake', default='cmake') + arguments = parser.parse_args() + check(arguments.archive.resolve(), arguments.cmake) diff --git a/tools/test_godot_ios_device_template.py b/tools/test_godot_ios_device_template.py new file mode 100644 index 000000000..a8ef49eac --- /dev/null +++ b/tools/test_godot_ios_device_template.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Test real iPhoneOS archive identity and Godot's scoped device PCK profile. + +Godot source builds, signing and phone gameplay are not performed by this test. +""" +import argparse +import configparser +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import zipfile + +import build_godot_ios_device_template as device +import build_godot_ios_simulator_template as common + +REPO = Path(__file__).resolve().parents[1] + + +def rejected(action): + try: + action() + except ValueError: + return + raise AssertionError("An incompatible real device archive was accepted") + + +def config(path): + result = configparser.ConfigParser(interpolation=None) + result.read(path) + return {section: dict(result[section]) for section in result.sections()} + + +def run(arguments, environment, log): + result = subprocess.run(list(map(str, arguments)), env=environment, text=True, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + log.write_text(result.stdout) + common.require(result.returncode == 0, f"Actual command failed ({result.returncode}): {log}\n{result.stdout[-3000:]}") + return result.stdout + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--template", type=Path, required=True, help="Genuine official Godot4.7.2 ios.zip") + parser.add_argument("--godot", type=Path, required=True, help="Genuine Godot4.7.2 editor") + parser.add_argument("--clang", default=shutil.which("clang")) + parser.add_argument("--ar", default=shutil.which("llvm-ar")) + parser.add_argument("--nm", default=shutil.which("llvm-nm")) + parser.add_argument("--output-dir", type=Path) + args = parser.parse_args() + root = (args.output_dir or Path(tempfile.mkdtemp(prefix="godot-real-device-acceptance-"))).resolve() + root.mkdir(parents=True, exist_ok=True) + require = common.require + actual = device.verify_template(args.template) + require(all(item["slices"] == [{"architecture": "arm64", "objects": item["slices"][0]["objects"], "platform": 2}] + for item in actual.values()), "Original device archive identity mismatch") + with tempfile.TemporaryDirectory(prefix="godot-real-device-objects-") as temporary: + work = Path(temporary) + source = work / "object.c" + source.write_text("int archive_platform_acceptance(void) { return 2; }\n") + for label, target, sdk, expected in (("device", "arm64-apple-ios14.0", "iphoneos", 2), + ("simulator", "arm64-apple-ios14.0-simulator", "iphonesimulator", 7)): + obj, archive = work / (label + ".o"), work / (label + ".a") + if sys.platform == "darwin": + sdk_path = subprocess.check_output(["xcrun", "--sdk", sdk, "--show-sdk-path"], text=True).strip() + compiler, archiver = ["xcrun", "--sdk", sdk, "clang", "-isysroot", sdk_path], ["xcrun", "ar"] + else: + require(args.clang and args.ar, "Real LLVM clang/llvm-ar are required") + compiler, archiver = [args.clang], [args.ar, "--format=darwin"] + subprocess.run([*compiler, "-target", target, "-c", source, "-o", obj], check=True) + subprocess.run([*archiver, "rcs", archive, obj], check=True) + require(common.archive_identity(archive, expected_platform=expected)[0]["platform"] == expected, + "Real LLVM object platform mismatch") + rejected(lambda: common.archive_identity(archive, expected_platform=7 if expected == 2 else 2)) + common.reject_sdl_symbols(archive, nm=None if sys.platform == "darwin" else args.nm) + # The real official device engine's strong SDL3 symbols trigger the + # collision even though its platform and CPU metadata are valid. + require(sys.platform == "darwin" or args.nm, "Existing real LLVM nm is required on Linux") + rejected(lambda: device.verify_template(args.template, require_sdl_free=True, + nm=None if sys.platform == "darwin" else args.nm)) + with zipfile.ZipFile(args.template) as template: + camera = work / "original-device-camera.a" + camera.write_bytes(template.read(device.member("libgodot_camera"))) + copy = work / "roundtrip-device-ios.zip" + original_digest = common.digest(args.template) + allowed = {device.member(library) for library in common.LIBRARIES} + common.replace_template_archives(template, copy, {device.member("libgodot_camera"): camera}, allowed_members=allowed) + require(device.verify_template(copy) == actual, "Real device template roundtrip changed archive payloads") + require(common.digest(args.template) == original_digest, "Official input ZIP changed") + rejected(lambda: common.replace_template_archives(template, work / "invalid.zip", + {common.member("libgodot_camera"): camera}, allowed_members=allowed)) + project = root / "project" + project.mkdir(exist_ok=True) + original_project = REPO / "apps/godot_app/project.godot" + original_presets = REPO / "apps/godot_app/export_presets.cfg" + original_text = original_project.read_text() + rendering = original_text[original_text.index("[rendering]"):] + source_project = root / "original-project.godot" + source_project.write_text("config_version=5\n\n" + rendering) + shutil.copy2(REPO / "tools/godot_ios_device_template/profile_acceptance.gd", project / "profile_acceptance.gd") + device.patch_device_profile(original_presets, project / "export_presets.cfg", source_project, + project / "project.godot", args.template) + before, after = config(original_presets), config(project / "export_presets.cfg") + expected = {key: dict(value) for key, value in before.items()} + expected["preset.1"]["custom_features"] = json.dumps(device.FEATURE) + expected["preset.1.options"]["custom_template/debug"] = json.dumps(str(args.template.resolve())) + require(after == expected, "Device profile altered an unrelated preset or option") + new_rendering = (project / "project.godot").read_text() + require(new_rendering.replace('renderer/rendering_method.' + device.FEATURE + '="gl_compatibility"\n', '') + == source_project.read_text(), "Device profile altered the ordinary renderer configuration") + environment = dict(os.environ) + environment.pop("GODOT_EDITOR_CUSTOM_FEATURES", None) + for name in ("CACHE", "CONFIG", "DATA"): + environment["XDG_" + name + "_HOME"] = str(root / name.lower()) + version = run([args.godot, "--version"], environment, root / "version.log") + require("4.7.2.stable.official.ed1daf0bf" in version, "Actual editor does not match the pinned Godot source") + environment["EXPECT_RENPY_DEVICE_PROFILE"] = "0" + run([args.godot, "--headless", "--path", project, "--script", project / "profile_acceptance.gd"], + environment, root / "ordinary-settings.log") + pck = root / "device-profile.pck" + run([args.godot, "--headless", "--path", project, "--export-pack", "iOS Debug", pck], environment, root / "pack.log") + environment["EXPECT_RENPY_DEVICE_PROFILE"] = "1" + run([args.godot, "--headless", "--path", project, "--main-pack", pck, "--script", "res://profile_acceptance.gd"], + environment, root / "packed-settings.log") + report = {"status": "passed", "official_template_sha256": common.digest(args.template), "official_archives": actual, + "cross_platform_objects_rejected": True, "real_template_roundtrip": "all_untouched_member_hashes_identical", + "real_godot_pck_profile": "passed", "ordinary_renderer_unchanged": True, + "official_sdl3_collision_rejected": True, + "godot_device_source_build": "not_run", "device_gameplay": "not_run", "signing": "not_run"} + (root / "evidence.json").write_text(json.dumps(report, indent=2) + "\n") + print(json.dumps(report, indent=2)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/test_godot_ios_simulator_template.py b/tools/test_godot_ios_simulator_template.py new file mode 100644 index 000000000..ee62ec26b --- /dev/null +++ b/tools/test_godot_ios_simulator_template.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""Check real official template archives and LLVM-produced Apple objects. + +This is archive/preset acceptance, not a Godot engine build or gameplay test. +On Darwin the universal archive checks use real Xcode lipo and SDK compilers. +""" +import argparse +import configparser +import copy as copy_module +import json +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import zipfile + +import build_godot_ios_simulator_template as helper + + +def rejected(action): + try: + action() + except ValueError: + return + raise AssertionError("An incompatible real archive/template was accepted") + + +def config(path): + result = configparser.ConfigParser(interpolation=None) + result.read(path) + return {section: dict(result[section]) for section in result.sections()} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--template", type=Path, required=True, + help="The genuine official Godot4.7.2 ios.zip") + parser.add_argument("--clang", default=shutil.which("clang")) + parser.add_argument("--ar", default=shutil.which("llvm-ar")) + parser.add_argument("--nm", default=shutil.which("llvm-nm")) + args = parser.parse_args() + require = helper.require + require(args.template.is_file(), "A real official template is required") + actual = helper.verify_template(args.template, "x86_64") + require(all(item["slices"][0]["architecture"] == "x86_64" and len(item["slices"]) == 1 + for item in actual.values()), "This acceptance starts with the original x64-only release template") + rejected(lambda: helper.verify_template(args.template, "arm64")) + # Pure key checks use actual official ZIP/LLVM inputs. They do not claim a + # Darwin toolchain identity or manufacture successful source-build evidence. + native_identity = {"source_sha": helper.SOURCE_SHA, "build_flags": list(helper.BUILD_FLAGS), + "input_template_sha256": helper.digest(args.template)} + if args.clang: + native_identity["compiler_sha256"] = helper.digest(args.clang) + key = helper.cache_key(native_identity) + require(key == helper.cache_key(dict(reversed(list(native_identity.items())))), "Cache key depends on dictionary order") + for name in native_identity: + changed = dict(native_identity) + changed[name] = "different input" + require(key != helper.cache_key(changed), "Cache key did not change for " + name) + for incomplete in ({}, {"status": "building"}, {"status": "failed"}, {"status": "built_and_verified"}): + rejected(lambda: helper.verify_cache_metadata(incomplete, native_identity, helper.digest(args.template))) + if sys.platform != "darwin": + rejected(lambda: helper.cache_identity(args.template)) + rejected(lambda: helper.verify_cached_libraries(args.template, args.template)) + with tempfile.TemporaryDirectory(prefix="godot-real-archive-acceptance-") as temporary: + root = Path(temporary) + source = root / "object.c" + source.write_text("int archive_platform_acceptance(void) { return 7; }\n") + archives = {} + for name, target, sdk in (("sim-arm64", "arm64-apple-ios14.0-simulator", "iphonesimulator"), + ("device-arm64", "arm64-apple-ios14.0", "iphoneos"), + ("sim-x86_64", "x86_64-apple-ios14.0-simulator", "iphonesimulator")): + obj, archive = root / (name + ".o"), root / (name + ".a") + if sys.platform == "darwin": + sdk_path = subprocess.check_output(["xcrun", "--sdk", sdk, "--show-sdk-path"], text=True).strip() + compiler = ["xcrun", "--sdk", sdk, "clang", "-isysroot", sdk_path] + archiver = ["xcrun", "ar"] + else: + require(args.clang and args.ar, "Real LLVM clang and llvm-ar are required") + compiler, archiver = [args.clang], [args.ar, "--format=darwin"] + subprocess.run([*compiler, "-target", target, "-c", source, "-o", obj], check=True) + subprocess.run([*archiver, "rcs", archive, obj], check=True) + archives[name] = archive + require(helper.archive_identity(archives["sim-arm64"])[0] == + {"architecture": "arm64", "objects": 1, "platform": 7}, "Actual arm64 Simulator object rejected") + require(helper.archive_identity(archives["sim-x86_64"])[0] == + {"architecture": "x86_64", "objects": 1, "platform": 7}, "Actual x64 Simulator object rejected") + rejected(lambda: helper.archive_identity(archives["device-arm64"])) + llvm_nm = args.nm or (str(Path(args.ar).with_name("llvm-nm")) if args.ar else None) + if sys.platform != "darwin": + require(llvm_nm and Path(llvm_nm).is_file(), "A genuine LLVM nm is required for the SDL collision gate") + helper.reject_sdl_symbols(archives["sim-arm64"], nm=llvm_nm if sys.platform != "darwin" else None) + sdl_source, sdl_object, sdl_archive = [root / name for name in ("sdl.c", "sdl.o", "sdl.a")] + sdl_source.write_text("int SDL_Init(void) { return 0; }\n") + subprocess.run([*compiler, "-target", "arm64-apple-ios14.0-simulator", "-c", sdl_source, "-o", sdl_object], check=True) + subprocess.run([*archiver, "rcs", sdl_archive, sdl_object], check=True) + helper.archive_identity(sdl_archive) + rejected(lambda: helper.reject_sdl_symbols(sdl_archive, nm=llvm_nm if sys.platform != "darwin" else None)) + with zipfile.ZipFile(args.template) as template: + # The actual device archive must never pass the Simulator gate, + # despite matching CPU and truthful device plist metadata. + path = root / "official-camera-device.a" + path.write_bytes(template.read("libgodot_camera.ios.debug.xcframework/ios-arm64/libgodot_camera.a")) + rejected(lambda: helper.archive_identity(path)) + # Exercise the real ZIP writer on the genuine full release template; + # retain every untouched archive/metadata byte and input ZIP itself. + camera = root / "official-camera-simulator.a" + camera.write_bytes(template.read(helper.member("libgodot_camera"))) + copy = root / "roundtrip-ios.zip" + original_digest = helper.digest(args.template) + helper.replace_template_archives(template, copy, {helper.member("libgodot_camera"): camera}) + require(helper.verify_template(copy, "x86_64") == actual, "Actual ZIP roundtrip changed real libraries") + require(helper.digest(args.template) == original_digest, "Official input ZIP was modified") + unchanged = helper.unchanged_member_hashes(args.template, copy) + require(len(unchanged) == len(template.namelist()) - len(helper.LIBRARIES), "Incomplete untouched-member proof") + mutated = root / "changed-official-member.zip" + selected = "libgodot.ios.debug.xcframework/Info.plist" + with zipfile.ZipFile(mutated, "w") as destination: + destination.comment = template.comment + for info in template.infolist(): + with template.open(info) as src, destination.open(copy_module.copy(info), "w") as dest: + shutil.copyfileobj(src, dest) + if info.filename == selected: + dest.write(b"\n") + rejected(lambda: helper.unchanged_member_hashes(args.template, mutated)) + duplicate = root / "duplicate-member.zip" + with zipfile.ZipFile(duplicate, "w") as destination: + destination.writestr(selected, template.read(selected)) + destination.writestr(selected, template.read(selected)) + rejected(lambda: helper.unchanged_member_hashes(args.template, duplicate)) + universal = "not_run (Linux has no Apple lipo)" + if sys.platform == "darwin": + fat = root / "real-universal.a" + subprocess.run(["xcrun", "lipo", "-create", archives["sim-arm64"], archives["sim-x86_64"], + "-output", fat], check=True) + require({item["architecture"] for item in helper.archive_identity(fat)} == {"arm64", "x86_64"}, + "Real lipo archive identities rejected") + wrong = root / "device-in-universal.a" + subprocess.run(["xcrun", "lipo", "-create", archives["device-arm64"], archives["sim-x86_64"], + "-output", wrong], check=True) + rejected(lambda: helper.archive_identity(wrong)) + rejected(lambda: helper.verify_cached_libraries(args.template, args.template)) + universal = "passed (real Xcode lipo/SDK objects)" + presets = Path(__file__).resolve().parents[1] / "apps/godot_app/export_presets.cfg" + before = config(presets) + output = root / "scoped-export.cfg" + for name, section in (("iOS Simulator Debug", "preset.11.options"), + ("iOS Simulator x64 Debug", "preset.12.options"), + ("iOS Debug", "preset.1.options")): + helper.patch_preset(presets, output, name, args.template, "debug") + after = config(output) + expected = {key: dict(value) for key, value in before.items()} + expected[section]["custom_template/debug"] = json.dumps(str(args.template.resolve())) + require(after == expected, "Custom template changed another preset or option") + rejected(lambda: helper.patch_preset(presets, output, "Android Debug", args.template, "debug")) + print(json.dumps({"status": "passed", "official_template_sha256": helper.digest(args.template), + "official_archives": actual, "device_slice_rejected": True, + "real_template_roundtrip": "all_untouched_member_hashes_identical", + "cache_key_input_changes": "passed (real inputs; no fabricated Darwin identity)", + "incomplete_cache_evidence_rejected": True, "changed_or_duplicate_cached_member_rejected": True, + "real_arm64_sdl_collision_rejected": True, + "cached_source_build_acceptance": "not_run (no newly built genuine cached engine supplied)", + "real_universal_archive": universal, "godot_engine_build": "not_run", "gameplay": "not_run"}, indent=2)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/test_install_renpy_mobile_support.sh b/tools/test_install_renpy_mobile_support.sh new file mode 100644 index 000000000..03435afa8 --- /dev/null +++ b/tools/test_install_renpy_mobile_support.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +installer="$repo_root/tools/install_renpy_mobile_support.sh" +[[ -f "$installer" ]] || { echo "mobile installer is missing: $installer" >&2; exit 1; } +bash -n "$installer" + +config="$(bash "$installer" --platform both --print-config)" +grep -Fx 'RENPY_VERSION=8.5.3' <<<"$config" +grep -Fx 'RENPY_RAPT_ARCHIVE=renpy-8.5.3-rapt.zip' <<<"$config" +grep -Fx 'RENPY_RAPT_SHA256=8a12be34a2f5238d125ff6dd76a56772fd8e44838af864f81bca82c1059b00e6' <<<"$config" +grep -Fx 'RENPY_RENIOS_ARCHIVE=renpy-8.5.3-renios.zip' <<<"$config" +grep -Fx 'RENPY_RENIOS_SHA256=c4fae153e8276ed0faed5e84ea3e0b7c4bf337f0e3208e9130c6a41748a83b2b' <<<"$config" + +if bash "$installer" --platform windows --print-config >/dev/null 2>&1; then + echo "installer accepted unsupported Windows platform" >&2 + exit 1 +fi + +# A real archive check is opt-in for CI/local runs that have downloaded the +# official packages. It avoids making a metadata-only unit test depend on a +# 200+ MiB network transfer. +cache_dir="${RENPY_MOBILE_TEST_CACHE_DIR:-}" +if [[ -n "$cache_dir" ]]; then + destination="$(mktemp -d "${TMPDIR:-/tmp}/aetherkiri-renpy-mobile-test.XXXXXX")" + trap 'rm -rf "$destination"' EXIT + bash "$installer" --platform both --cache-dir "$cache_dir" --destination "$destination" >/dev/null + [[ -s "$destination/rapt/.aetherkiri-sha256" ]] + [[ -s "$destination/renios/.aetherkiri-sha256" ]] + [[ -f "$destination/rapt/prototype/renpyandroid/src/main/jniLibs/arm64-v8a/librenpython.so" ]] + [[ -f "$destination/renios/prototype/prebuilt/release/librenpython.a" ]] + grep -F '8a12be34a2f5238d125ff6dd76a56772fd8e44838af864f81bca82c1059b00e6' \ + "$destination/rapt/.aetherkiri-sha256" + grep -F 'c4fae153e8276ed0faed5e84ea3e0b7c4bf337f0e3208e9130c6a41748a83b2b' \ + "$destination/renios/.aetherkiri-sha256" + echo "Ren'Py mobile archive staging validation ok" +else + echo "Ren'Py mobile installer metadata validation ok (archive staging not requested)" +fi diff --git a/tools/test_install_renpy_sdk.sh b/tools/test_install_renpy_sdk.sh new file mode 100755 index 000000000..334b403d3 --- /dev/null +++ b/tools/test_install_renpy_sdk.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +installer="$repo_root/tools/install_renpy_sdk.sh" +[[ -x "$installer" ]] || { echo "installer is not executable: $installer" >&2; exit 1; } +bash -n "$installer" + +linux_config="$($installer --platform linux --arch x86_64 --print-config)" +grep -Fx 'RENPY_VERSION=8.5.3' <<<"$linux_config" +grep -Fx 'RENPY_ARCHIVE=renpy-8.5.3-sdk.tar.bz2' <<<"$linux_config" +grep -Fx 'RENPY_SHA256=eb0a9be7f0fb13632fe25ceade9a8bed5a1b4d6b6e83bd19eeeb29e1a1bb4a45' <<<"$linux_config" +grep -Fx 'RENPY_URL=https://www.renpy.org/dl/8.5.3/renpy-8.5.3-sdk.tar.bz2' <<<"$linux_config" + +mac_config="$($installer --platform darwin --arch arm64 --print-config)" +grep -Fx 'RENPY_ARCHIVE=renpy-8.5.3-sdkarm.tar.bz2' <<<"$mac_config" +grep -Fx 'RENPY_SHA256=0579782517f203ba3535dcc2dab54e34bfc318f2f2a7510a5130b6f809f901f6' <<<"$mac_config" +mac_x86_config="$($installer --platform darwin --arch x86_64 --print-config)" +grep -Fx 'RENPY_ARCHIVE=renpy-8.5.3-sdk.tar.bz2' <<<"$mac_x86_config" +grep -Fx 'RENPY_SHA256=eb0a9be7f0fb13632fe25ceade9a8bed5a1b4d6b6e83bd19eeeb29e1a1bb4a45' <<<"$mac_x86_config" + +windows_config="$($installer --platform windows --arch x86_64 --print-config)" +grep -Fx 'RENPY_ARCHIVE=renpy-8.5.3-sdk.zip' <<<"$windows_config" +grep -Fx 'RENPY_SHA256=ff57648f9c04f27e381c48af6d8e3ee3cdec296bed4d3831f47f09b0a71b505e' <<<"$windows_config" +grep -Fx 'RENPY_URL=https://www.renpy.org/dl/8.5.3/renpy-8.5.3-sdk.zip' <<<"$windows_config" + +if "$installer" --platform windows --arch arm64 --print-config >/dev/null 2>&1; then + echo "installer accepted unsupported Windows architecture" >&2 + exit 1 +fi +if "$installer" --platform linux --arch arm64 --print-config >/dev/null 2>&1; then + echo "installer accepted unsupported Linux architecture" >&2 + exit 1 +fi +printf 'Ren\x27Py SDK installer validation ok\n' diff --git a/tools/test_ios_glib_pipe2.py b/tools/test_ios_glib_pipe2.py new file mode 100644 index 000000000..82dabf310 --- /dev/null +++ b/tools/test_ios_glib_pipe2.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +"""Check GLib's genuine pipe2 probe and source function with real host headers. + +This uses the pinned public GLib/Meson sources and a real Linux compiler/libc. +POSIX feature mode reproduces a linkable pipe2 without a header declaration; +GNU mode verifies that the corrected probe still enables a declared pipe2. +The original GLib function body then exercises actual pipe/fcntl operations. +No SDK or compiler is mocked. Full GLib/Apple/application/gameplay are NOT_RUN. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys + + +GLIB_COMMIT = "2371bee17d85318480b3ddeeab4f5107b4889ad7" +MESON_COMMIT = "75ef08140346d9a058546493036a31431774eb7a" +REPOSITORY = Path(__file__).resolve().parents[1] +PORT = REPOSITORY / "vcpkg/ports/glib" + + +def sha(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--glib-source", type=Path, required=True) + parser.add_argument("--meson-source", type=Path, required=True) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--cc", default="cc") + parser.add_argument("--cmake", default="cmake") + args = parser.parse_args() + if sys.platform != "linux": + parser.error("This real POSIX/GNU header regression requires a Linux host") + source = args.glib_source.resolve(strict=True) + meson = args.meson_source.resolve(strict=True) + output = args.output_dir.resolve() + if any(output == p or output.is_relative_to(p) for p in (source, meson, REPOSITORY)): + parser.error("Use an isolated output directory outside both checkouts and the repository") + cc, cmake, patch = (shutil.which(x) for x in (args.cc, args.cmake, "patch")) + if not all((cc, cmake, patch)): + raise RuntimeError("A real host compiler, CMake and patch are required") + for directory, expected in ((source, GLIB_COMMIT), (meson, MESON_COMMIT)): + actual = subprocess.check_output( + ["git", "-C", str(directory), "rev-parse", "HEAD"], text=True).strip() + if actual != expected: + raise RuntimeError(f"Expected genuine pinned source {expected}, got {actual}") + subprocess.run(["git", "-C", str(directory), "diff", "--exit-code", "HEAD", "--"], + check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + output.mkdir(parents=True, exist_ok=False) + process_env = os.environ.copy() + process_env["CC"] = cc + + def run(argv: list[str], log: Path, *, cwd: Path | None = None) -> subprocess.CompletedProcess: + result = subprocess.run(argv, cwd=cwd, env=process_env, text=True, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + log.write_text(result.stdout) + return result + + # Execute the production CMake patch-selection block without replacing any + # vcpkg function or evaluating the download/build portions of the recipe. + recipe = (PORT / "portfile.cmake").read_text() + selection = "set(GLIB_PATCHES\n" + recipe.split("set(GLIB_PATCHES\n", 1)[1].split( + "vcpkg_extract_source_archive(SOURCE_PATH", 1)[0] + checks = "" + for ios in (True, False): + checks += "set(VCPKG_TARGET_IS_IOS " + ("TRUE" if ios else "FALSE") + ")\n" + checks += selection + expected = "use-libiconv-on-windows.patch;libintl.patch" + if ios: + expected += ";ios-pipe2-prototype.patch" + checks += f'if(NOT GLIB_PATCHES STREQUAL "{expected}")\n' + checks += 'message(FATAL_ERROR "Wrong production patch selection")\nendif()\n' + (output / "production-scope.cmake").write_text(checks) + result = run([cmake, "-P", str(output / "production-scope.cmake")], + output / "production-scope.log") + if result.returncode: + raise RuntimeError("Actual production iOS/non-iOS patch selection failed") + + snapshot = output / "patched-source" + shutil.copytree(source, snapshot, ignore=shutil.ignore_patterns(".git")) + for name in ("use-libiconv-on-windows.patch", "libintl.patch", "ios-pipe2-prototype.patch"): + result = run([patch, "--batch", "--fuzz=0", "-p1", "-i", str(PORT / name)], + output / (name + ".log"), cwd=snapshot) + if result.returncode: + raise RuntimeError(f"Genuine GLib source patch failed: {name}") + + def probe_block(path: Path) -> str: + text = path.read_text() + if text.count("foreach f : functions\n") != 1: + raise RuntimeError("The production GLib capability loop changed") + return "foreach f : functions\n" + text.split("foreach f : functions\n", 1)[1].split( + "endforeach", 1)[0] + "endforeach\n" + + original_block = probe_block(source / "meson.build") + patched_block = probe_block(snapshot / "meson.build") + header = source / "glib/glib-unixprivate.h" + if header.read_bytes() != (snapshot / "glib/glib-unixprivate.h").read_bytes(): + raise RuntimeError("The production pipe function must remain unchanged") + body = header.read_text().split("G_BEGIN_DECLS\n", 1)[1].split("G_END_DECLS", 1)[0] + # Only the upstream scalar declarations/macros accompany its unmodified + # function body. All OS declarations and operations use actual host libc. + types = re.findall(r"^typedef\s+(?:int\s+gint|gint\s+gboolean);", + (source / "glib/gtypes.h").read_text(), re.M) + macros = re.findall(r"^#define\s+(?:FALSE|TRUE)\s+[^\n]+", + (source / "glib/gmacros.h").read_text(), re.M) + if len(types) != 2 or len(macros) != 2 or body.count("g_unix_open_pipe_internal") != 1: + raise RuntimeError("The pinned GLib scalar declarations/function boundary changed") + driver = '''#include "config.h" +#include +#include +#include +#include +#include +''' + "\n".join(types + macros) + "\n" + body + ''' +int main(void) { + for (int clo = 0; clo < 2; clo++) for (int nb = 0; nb < 2; nb++) { + int fds[2] = {-1, -1}; + assert(g_unix_open_pipe_internal(fds, clo, nb)); + for (int i = 0; i < 2; i++) { + int fdflags = fcntl(fds[i], F_GETFD); + int flags = fcntl(fds[i], F_GETFL); + assert(fdflags >= 0 && flags >= 0); + assert(!!(fdflags & FD_CLOEXEC) == clo); + assert(!!(flags & O_NONBLOCK) == nb); + } + if (nb) { + char byte; + errno = 0; + assert(read(fds[0], &byte, 1) == -1); + assert(errno == EAGAIN || errno == EWOULDBLOCK); + } + assert(write(fds[1], "ok", 2) == 2); + char bytes[2]; + assert(read(fds[0], bytes, 2) == 2); + assert(bytes[0] == 'o' && bytes[1] == 'k'); + assert(close(fds[0]) == 0 && close(fds[1]) == 0); + } + puts("actual_upstream_pipe_function_4_flag_cases_PASS"); + return 0; +} +''' + cases = {} + for label, flags, block, available in ( + ("original-posix", "-D_POSIX_C_SOURCE=200809L", original_block, True), + ("patched-posix", "-D_POSIX_C_SOURCE=200809L", patched_block, False), + ("patched-gnu", "-D_GNU_SOURCE", patched_block, True), + ): + directory = output / label + directory.mkdir() + project = "project('genuine-glib-probe', 'c', default_options : ['c_std=c99'])\n" + project += "cc = meson.get_compiler('c')\nfunctions = ['pipe2']\n" + project += "glib_conf = configuration_data()\nglib_conf_prefix = ''\n" + block + project += "configure_file(output : 'config.h', configuration : glib_conf)\n" + (directory / "meson.build").write_text(project) + result = run([sys.executable, str(meson / "meson.py"), "setup", + str(directory / "build"), str(directory), "--backend=none", + "-Dc_args=" + flags], directory / "configure.log") + if result.returncode: + raise RuntimeError(f"Real Meson configure failed: {label}") + config = (directory / "build/config.h").read_text() + detected = "#define HAVE_PIPE2 1" in config + if detected != available: + raise RuntimeError(f"Unexpected genuine header probe in {label}: {config}") + (directory / "actual-function.c").write_text(driver) + result = run([cc, "-std=c99", "-Werror=implicit-function-declaration", flags, + "-I", str(directory / "build"), str(directory / "actual-function.c"), + "-o", str(directory / "actual-function")], directory / "compiler.log") + if label == "original-posix": + if not result.returncode or not re.search( + r"(?:implicit declaration|undeclared function).*['‘]pipe2", result.stdout): + raise RuntimeError("Original real source did not fail on undeclared pipe2") + runtime = "NOT_RUN_EXPECTED_COMPILER_FAILURE" + else: + if result.returncode: + raise RuntimeError(f"Actual GLib function compilation failed: {label}") + result = run([str(directory / "actual-function")], directory / "runtime.log") + if result.returncode or "4_flag_cases_PASS" not in result.stdout: + raise RuntimeError(f"Actual pipe/fcntl runtime failed: {label}") + runtime = "PASS" + cases[label] = {"have_pipe2": detected, "runtime": runtime} + + evidence = { + "result": "PASS", "scope": "genuine_Linux_host_Meson_probe_and_isolated_GLib_source_function", + "glib_commit": GLIB_COMMIT, "meson_commit": MESON_COMMIT, + "compiler": subprocess.check_output([cc, "--version"], text=True).splitlines()[0], + "glib_meson_sha256": sha(source / "meson.build"), + "meson_compiler_sha256": sha(meson / "mesonbuild/compilers/mixins/clike.py"), + "pipe_header_sha256": sha(header), "patch_sha256": sha(PORT / "ios-pipe2-prototype.patch"), + "recipe_sha256": sha(PORT / "portfile.cmake"), "cases": cases, + "not_run": ["exact_GNOME_release_archive_build", "complete_GLib_build", + "Apple_SDK_compilation", "iOS_application", "gameplay"], + } + (output / "evidence.json").write_text(json.dumps(evidence, indent=2) + "\n") + print(json.dumps(evidence)) + + +if __name__ == "__main__": + main() diff --git a/tools/test_ios_libiconv_relocation.py b/tools/test_ios_libiconv_relocation.py new file mode 100644 index 000000000..019553776 --- /dev/null +++ b/tools/test_ios_libiconv_relocation.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""Compile and execute the genuine gnulib install-reloc wrapper at -O0. + +The iOS libiconv 1.19 failure is a missing standalone-wrapper dependency. +This regression uses an explicitly pinned public gnulib checkout and its real +autotools-generated config/headers. It does not download a release, imitate an +iOS SDK, or claim that libiconv's release installation or mobile gameplay ran. +Supply normal Autoconf/Automake/m4 through PATH (or their standard env vars). +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import shlex +import shutil +import subprocess +import sys + + +GNULIB_COMMIT = "42dd0e518e06e55e4629710bff94790fe8b9aec9" +REPOSITORY = Path(__file__).resolve().parents[1] +PATCH = REPOSITORY / "vcpkg/ports/libiconv/ios-reloc-memeq.patch" +SOURCE_FILES = ( + "build-aux/install-reloc", "lib/progname.c", "lib/progreloc.c", + "lib/memeq.c", "lib/string.in.h", "modules/relocatable-prog-wrapper", +) + + +def digest(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def run(command: list[str], cwd: Path, env: dict[str, str], log: Path, + *, expected_failure: bool = False) -> subprocess.CompletedProcess[str]: + result = subprocess.run(command, cwd=cwd, env=env, text=True, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + log.write_text(result.stdout, encoding="utf-8") + if not expected_failure and result.returncode: + raise RuntimeError(f"{command[0]} failed ({result.returncode}); see {log}") + return result + + +def validate_checkout(source: Path) -> str: + commit = subprocess.check_output( + ["git", "-C", str(source), "rev-parse", "HEAD"], text=True).strip() + if commit != GNULIB_COMMIT: + raise RuntimeError(f"Expected genuine gnulib {GNULIB_COMMIT}, got {commit}") + for name in SOURCE_FILES: + original = subprocess.check_output( + ["git", "-C", str(source), "show", f"{commit}:{name}"]) + if (source / name).read_bytes() != original: + raise RuntimeError(f"Gnulib source differs from its pinned commit: {name}") + return commit + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--gnulib-source", type=Path, required=True) + parser.add_argument("--output-dir", type=Path, required=True, + help="A fresh scratch directory; existing files are never changed") + parser.add_argument("--jobs", type=int, default=3) + parser.add_argument("--cc", default=os.environ.get("CC", "cc")) + args = parser.parse_args() + if args.jobs < 1 or sys.platform not in ("linux", "darwin"): + parser.error("This genuine host regression requires Linux or macOS and jobs >= 1") + source = args.gnulib_source.resolve(strict=True) + commit = validate_checkout(source) + output = args.output_dir.resolve() + output.mkdir(parents=True, exist_ok=False) + env = os.environ.copy() + cc = shlex.split(args.cc) + if not cc or any(any(c.isspace() for c in part) for part in cc): + parser.error("CC must have ordinary compiler arguments without embedded whitespace") + if not shutil.which(cc[0]): + raise RuntimeError(f"Compiler not available: {cc[0]}") + env.update(CC=shlex.join(cc), CFLAGS="-O0 -g") + generated = output / "generated" + run([str(source / "gnulib-tool"), "--create-testdir", "--without-tests", + f"--dir={generated}", "relocatable-prog", "minmax", + "glibc-internal/scratch_buffer"], output, env, + output / "generate.log") + for name in ("progname.c", "progreloc.c", "memeq.c"): + if (generated / "gllib" / name).read_bytes() != (source / "lib" / name).read_bytes(): + raise RuntimeError(f"Generator changed the actual source {name}") + wrapper = generated / "build-aux/install-reloc" + if wrapper.read_bytes() != (source / "build-aux/install-reloc").read_bytes(): + raise RuntimeError("Generator changed the actual install-reloc source") + install_prefix = output / "installed-prefix" + run(["./configure", "--enable-relocatable", f"--prefix={install_prefix}"], + generated, env, output / "configure.log") + run(["make", f"-j{args.jobs}"], generated, env, output / "make.log") + if not (generated / "config.h").is_file() or not (generated / "gllib/string.h").is_file(): + raise RuntimeError("Actual Autoconf-generated config/string headers are missing") + + # A real shared-library payload makes moving the prefix exercise the actual + # GNU wrapper's relocation, runtime search-path setup, and normal exec path. + payload = output / "payload" + payload.mkdir() + (payload / "library.c").write_text("int relocation_marker(void) { return 314159; }\n") + (payload / "program.c").write_text( + '#include \nextern int relocation_marker(void);\n' + 'int main(void) { int n = relocation_marker(); printf("marker=%d\\n", n); ' + 'return n != 314159; }\n') + darwin = sys.platform == "darwin" + library_name = "libaether_relocation.dylib" if darwin else "libaether_relocation.so" + shared_flags = (["-dynamiclib", "-Wl,-install_name,@rpath/" + library_name] + if darwin else ["-shared", "-fPIC"]) + run(cc + shared_flags + [str(payload / "library.c"), "-o", str(payload / library_name)], + output, env, output / "payload-library.log") + binary = payload / "program" + run(cc + [str(payload / "program.c"), "-L" + str(payload), + "-laether_relocation", "-o", str(binary)], + output, env, output / "payload-program.log") + + def install(script: Path, name: str) -> tuple[Path, subprocess.CompletedProcess[str]]: + # Both cases use the real configured prefix. Reset only our own failed + # installation before re-running the genuine script with the patch. + prefix = install_prefix + if prefix.exists(): + shutil.rmtree(prefix) + (prefix / "bin").mkdir(parents=True) + (prefix / "lib").mkdir() + shutil.copy2(payload / library_name, prefix / "lib" / library_name) + install_env = env.copy() + install_env.update( + RELOC_LIBRARY_PATH_VAR="DYLD_LIBRARY_PATH" if darwin else "LD_LIBRARY_PATH", + RELOC_LIBRARY_PATH_VALUE=str(prefix / "lib"), RELOC_PREFIX=str(prefix), + RELOC_DESTDIR="", RELOC_COMPILE_COMMAND=shlex.join(cc + ["-O0", "-g"]), + RELOC_SRCDIR=str(generated / "gllib"), RELOC_BUILDDIR=str(generated / "gllib"), + RELOC_CONFIG_H_DIR=str(generated), RELOC_EXEEXT="", RELOC_STRIP_PROG=":", + RELOC_INSTALL_PROG="install -m 755") + result = run(["sh", str(script), str(binary), str(prefix / "bin/program")], + output, install_env, output / (name + "-install.log"), + expected_failure=True) + return prefix, result + + _, original = install(wrapper, "original") + if original.returncode == 0 or not re.search(r"undefined|unresolved", original.stdout, re.I) \ + or not re.search(r"\b_?memeq\b", original.stdout): + raise RuntimeError("Original genuine -O0 wrapper did not fail with undefined memeq") + + run(["patch", "--batch", "--fuzz=0", "-p1", "-i", str(PATCH)], generated, + env, output / "patch.log") + prefix, patched = install(wrapper, "patched") + if patched.returncode: + raise RuntimeError("Patched genuine wrapper failed; see patched-install.log") + installed = prefix / "bin/program" + if not installed.is_file() or digest(installed) == digest(binary) \ + or digest(prefix / "bin/program.bin") != digest(binary): + raise RuntimeError("The real relocating wrapper/payload installation is missing") + runtime_env = env.copy() + for name in ("LD_LIBRARY_PATH", "DYLD_LIBRARY_PATH", "DYLD_FALLBACK_LIBRARY_PATH"): + runtime_env.pop(name, None) + result = run([str(installed)], output, runtime_env, output / "installed.log") + if result.stdout != "marker=314159\n": + raise RuntimeError("The actual installed wrapper did not execute its shared-library payload") + moved = output / "relocated-prefix" + shutil.move(str(prefix), moved) + result = run([str(moved / "bin/program")], output, runtime_env, output / "relocated.log") + if result.stdout != "marker=314159\n": + raise RuntimeError("The actual GNU wrapper did not resolve its relocated library") + evidence = { + "status": "PASS", "scope": "genuine-canonical-gnulib-host-wrapper", + "gnulib_commit": commit, "platform": sys.platform, + "compiler": subprocess.check_output(cc + ["--version"], text=True).splitlines()[0], + "source_sha256": {name: digest(source / name) for name in SOURCE_FILES}, + "patch_sha256": digest(PATCH), "generated_config_sha256": digest(generated / "config.h"), + "generated_string_header_sha256": digest(generated / "gllib/string.h"), + "original_undefined_memeq": "PASS", "patched_standalone_link": "PASS", + "installed_wrapper_execution": "PASS", "relocated_library_execution": "PASS", + "libiconv_1_19_release_install": "NOT_RUN", "apple_ios_sdk_link": "NOT_RUN", + "ios_application_gameplay": "NOT_RUN", + } + (output / "evidence.json").write_text(json.dumps(evidence, indent=2) + "\n") + print(json.dumps(evidence, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/tools/test_ios_minori_rust_target.py b/tools/test_ios_minori_rust_target.py new file mode 100644 index 000000000..100a008e8 --- /dev/null +++ b/tools/test_ios_minori_rust_target.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python3 +"""Configure the pinned production Minori CMake and inspect real Cargo rules. + +Apple SDK names/platform variables are configuration inputs, not SDK/compiler +substitutes. Real CMake/Ninja/Cargo are required. No generated Cargo command, +Apple target compilation, application, or gameplay is executed by this check. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path +import re +import shlex +import shutil +import subprocess + + +PIN = "7c56b2e7fbe5316d45aa30aedbd02fb5d9ba08c9" +ROOT_BASELINE = "8be1181db6c0305361e74ea13478355f26faaef6" + + +def run(argv: list[str], cwd: Path) -> subprocess.CompletedProcess: + return subprocess.run(argv, cwd=cwd, text=True, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, timeout=60) + + +def checked(argv: list[str], cwd: Path) -> str: + result = run(argv, cwd) + if result.returncode: + raise RuntimeError(f"{shlex.join(argv)} failed ({result.returncode}):\n{result.stdout}") + return result.stdout + + +def integration(source: str) -> str: + pattern = r"if\(AETHERKIRI_ENABLE_MINORI AND BUILD_GODOT_EXTENSION AND\s*\(LINUX OR APPLE OR ANDROID OR EMSCRIPTEN\)\)\n.*?endif\(\)" + matches = re.findall(pattern, source, flags=re.S) + if len(matches) != 1: + raise RuntimeError("production Minori integration boundary changed") + return matches[0] + + +def inspect(binary: Path, source: Path, ninja: str, expected: str, + override: str | None, sdk: str, profile: str) -> dict: + commands = checked([ninja, "-C", str(binary), "-t", "commands", + "aether_minori_runtime_build"], binary) + cargo_commands = [] + for line in commands.splitlines(): + argv = shlex.split(line) + if any(Path(arg).name == "cargo" and index + 1 < len(argv) + and argv[index + 1] == "build" for index, arg in enumerate(argv)): + cargo_commands.append(argv) + if len(cargo_commands) != 1: + raise AssertionError("expected exactly one genuine production Cargo build rule") + argv = cargo_commands[0] + targets = [argv[i + 1] for i, arg in enumerate(argv) if arg == "--target"] + if targets != ([expected] if expected else []): + raise AssertionError((targets, expected)) + if argv[argv.index("--manifest-path") + 1] != str(source / "packages/AetherMinori/Cargo.toml"): + raise AssertionError("Cargo rule did not use the genuine pinned package manifest") + if "--lib" not in argv or ("--release" in argv) != (profile == "Release"): + raise AssertionError("production Cargo library/profile rule changed") + archive, cache_value, outer_value, actual_sdk = (binary / "minori-state.txt").read_text().splitlines() + selected_profile = "release" if profile == "Release" else "debug" + subdir = expected + "/" if expected else "" + wanted_archive = str(binary / "packages/AetherMinori/cargo-target" / + (subdir + selected_profile + "/libaether_minori_runtime.a")) + if archive != wanted_archive or cache_value != (override or "") or outer_value != (override or ""): + raise AssertionError((archive, cache_value, outer_value, wanted_archive, override)) + if actual_sdk != sdk: + raise AssertionError("integration changed the actual SDK input") + if (binary / "parent-target-file.txt").read_text() != archive: + raise AssertionError("the root host cannot resolve the actual imported Minori target") + target_dir = str(binary / "packages/AetherMinori/cargo-target") + if f"CARGO_TARGET_DIR={target_dir}" not in argv: + raise AssertionError("the actual Cargo output environment changed") + return {"generated_cargo_target": expected or "host_default", "imported_archive": archive, + "cache_override": cache_value, "automatic_default_leaked": False, + "sdk_unchanged": True, "root_imported_target_reference": "passed", "cargo_build": "not_run"} + + +def configure(root: Path, label: str, repository: Path, block: str, cmake: str, + ninja: str, cargo: str, *, system="iOS", apple=True, sdk="iphonesimulator", + arch="arm64", processor="arm64", android=False, abi="", emscripten=False, + override=None, expected="aarch64-apple-ios-sim", profile="Debug", + failure=None, reuse=False, override_untyped=False) -> dict: + source = root / label / "source" + binary = root / label / "build" + if not reuse: + (source / "packages").mkdir(parents=True) + (source / "packages/AetherMinori").symlink_to(repository / "packages/AetherMinori", + target_is_directory=True) + (source / "cmake").symlink_to(repository / "cmake", target_is_directory=True) + project = f"""cmake_minimum_required(VERSION 3.28) +project(MinoriTargetAcceptance LANGUAGES NONE) +# Input-only configuration fixture. No Apple compiler or SDK is probed. +set(CMAKE_SYSTEM_NAME "${{CASE_SYSTEM}}") +set(APPLE "${{CASE_APPLE}}") +set(LINUX FALSE) +if(CASE_SYSTEM STREQUAL "Linux") + set(LINUX TRUE) +endif() +set(ANDROID "${{CASE_ANDROID}}") +set(ANDROID_ABI "${{CASE_ANDROID_ABI}}") +set(EMSCRIPTEN "${{CASE_EMSCRIPTEN}}") +set(CMAKE_OSX_SYSROOT "${{CASE_SDK}}") +set(CMAKE_OSX_ARCHITECTURES "${{CASE_ARCH}}") +set(CMAKE_SYSTEM_PROCESSOR "${{CASE_PROCESSOR}}") +set(AETHERKIRI_ENABLE_MINORI TRUE) +set(BUILD_GODOT_EXTENSION TRUE) +set(AETHERKIRI_MINORI_DIR "${{CMAKE_CURRENT_SOURCE_DIR}}/packages/AetherMinori") +{block} +if(NOT TARGET aether_minori_runtime OR NOT TARGET aether_minori_runtime_build) + message(FATAL_ERROR "the root host cannot access the production Minori targets") +endif() +get_target_property(imported_global aether_minori_runtime IMPORTED_GLOBAL) +if(NOT imported_global) + message(FATAL_ERROR "the production imported target is no longer GLOBAL") +endif() +add_library(minori_host_reference INTERFACE) +target_link_libraries(minori_host_reference INTERFACE aether_minori_runtime) +file(GENERATE OUTPUT "${{CMAKE_BINARY_DIR}}/parent-target-file.txt" + CONTENT "$") +get_target_property(archive aether_minori_runtime IMPORTED_LOCATION) +get_property(cache_value CACHE AETHERKIRI_RUST_TARGET PROPERTY VALUE) +file(WRITE "${{CMAKE_BINARY_DIR}}/minori-state.txt" + "${{archive}}\n${{cache_value}}\n${{AETHERKIRI_RUST_TARGET}}\n${{CMAKE_OSX_SYSROOT}}\n") +""" + (source / "CMakeLists.txt").write_text(project) + argv = [cmake, "-S", str(source), "-B", str(binary), "-G", "Ninja", + f"-DCMAKE_MAKE_PROGRAM={ninja}", f"-DCARGO_EXECUTABLE:FILEPATH={cargo}", + f"-DCMAKE_BUILD_TYPE:STRING={profile}", f"-DCASE_SYSTEM:STRING={system}", + f"-DCASE_APPLE:BOOL={'ON' if apple else 'OFF'}", f"-DCASE_SDK:STRING={sdk}", + f"-DCASE_ARCH:STRING={arch}", f"-DCASE_PROCESSOR:STRING={processor}", + f"-DCASE_ANDROID:BOOL={'ON' if android else 'OFF'}", f"-DCASE_ANDROID_ABI:STRING={abi}", + f"-DCASE_EMSCRIPTEN:BOOL={'ON' if emscripten else 'OFF'}"] + if override is not None: + override_name = "AETHERKIRI_RUST_TARGET" if override_untyped else "AETHERKIRI_RUST_TARGET:STRING" + argv.append(f"-D{override_name}={override}") + result = run(argv, root) + (root / label / ("reconfigure.log" if reuse else "configure.log")).write_text(result.stdout) + if failure: + if result.returncode == 0 or failure not in result.stdout: + raise AssertionError((label, "expected genuine configure rejection", result.stdout)) + return {"name": label, "configuration_rejection": "passed", "expected_reason": failure} + if result.returncode: + raise RuntimeError(result.stdout) + record = inspect(binary, source, ninja, expected, override, sdk, profile) + record.update(name=label, sdk_input=sdk, arch=arch, explicit_override=override) + return record + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repository", type=Path, default=Path(__file__).resolve().parents[1]) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--cmake", default=shutil.which("cmake")) + parser.add_argument("--ninja", default=shutil.which("ninja")) + parser.add_argument("--cargo", default=shutil.which("cargo")) + parser.add_argument("--baseline-commit", default=ROOT_BASELINE, + help="Genuine pre-fix root integration for the negative control") + args = parser.parse_args() + repository = args.repository.resolve(strict=True) + root = args.output_dir.resolve() + root.mkdir(parents=True, exist_ok=False) + for tool in (args.cmake, args.ninja, args.cargo): + if not tool or not Path(tool).is_file(): + raise RuntimeError("genuine CMake, Ninja and Cargo are required") + package = repository / "packages/AetherMinori" + if checked(["git", "rev-parse", "HEAD"], package).strip() != PIN: + raise AssertionError("use the genuine pinned public Minori checkout") + package_cmake = (package / "CMakeLists.txt").read_bytes() + if package_cmake != subprocess.check_output(["git", "show", "HEAD:CMakeLists.txt"], cwd=package): + raise AssertionError("the public Minori CMake source was modified") + current = integration((repository / "CMakeLists.txt").read_text()) + baseline = integration(checked(["git", "show", f"{args.baseline_commit}:CMakeLists.txt"], repository)) + if "aetherkiri_add_minori_runtime" not in current or "add_subdirectory" not in baseline: + raise AssertionError("review the current/pinned pre-fix integration boundaries") + arguments = dict(repository=repository, block=current, cmake=args.cmake, + ninja=args.ninja, cargo=args.cargo) + cases = [] + for arch, expected in (("arm64", "aarch64-apple-ios-sim"), + ("aarch64", "aarch64-apple-ios-sim"), ("x86_64", "x86_64-apple-ios")): + for label, sdk in (("alias", "iphonesimulator"), ("mixed-alias", "iPhOnEsImUlAtOr"), + ("canonical", "/Applications/Xcode.app/SDKs/iPhoneSimulator26.5.sdk"), + ("spaces", "/Applications/Xcode Selected.app/SDKs/iPhOnEsImUlAtOr26.5.sdk"), + ("unversioned", "/Applications/Xcode.app/SDKs/iPhoneSimulator.sdk")): + cases.append(configure(root, f"sim-{arch}-{label}", **arguments, + sdk=sdk, arch=arch, expected=expected)) + for label, sdk in (("alias", "iphoneos"), ("mixed-alias", "iPhOnEoS"), + ("canonical", "/Applications/Xcode.app/SDKs/iPhoneOS26.5.sdk"), + ("misleading-parent", "/tmp/iphonesimulator/iPhoneSimulator.platform/SDKs/iPhoneOS26.5.sdk")): + cases.append(configure(root, "device-" + label, **arguments, sdk=sdk, + expected="aarch64-apple-ios")) + cases.extend([ + configure(root, "sim-processor-fallback", **arguments, arch="", processor="arm64"), + configure(root, "sim-explicit-empty-cache", **arguments, override=""), + configure(root, "sim-explicit-target", **arguments, override="x86_64-apple-ios", + expected="x86_64-apple-ios"), + configure(root, "sim-explicit-untyped-cache", **arguments, override="x86_64-apple-ios", + override_untyped=True, expected="x86_64-apple-ios"), + configure(root, "device-explicit-target", **arguments, sdk="iphoneos", + override="aarch64-apple-ios-sim", expected="aarch64-apple-ios-sim"), + configure(root, "sim-explicit-target-unsupported-arch", **arguments, arch="riscv64", + override="x86_64-apple-ios", expected="x86_64-apple-ios"), + configure(root, "sim-release", **arguments, profile="Release"), + configure(root, "sim-unsupported-arch", **arguments, arch="riscv64", + failure="Minori has no Rust target mapping for iOS Simulator architecture"), + configure(root, "sim-multiple-arch", **arguments, arch="arm64;x86_64", + failure="Minori has no Rust target mapping for iOS Simulator architecture"), + ]) + for arch, expected in (("arm64", "aarch64-apple-darwin"), ("x86_64", "x86_64-apple-darwin")): + cases.append(configure(root, "mac-" + arch, **arguments, system="Darwin", sdk="macosx", + arch=arch, expected=expected)) + for abi, expected in (("arm64-v8a", "aarch64-linux-android"), + ("armeabi-v7a", "armv7-linux-androideabi"), ("x86_64", "x86_64-linux-android")): + cases.append(configure(root, "android-" + abi, **arguments, system="Android", apple=False, + android=True, abi=abi, expected=expected)) + cases.extend([ + configure(root, "linux-host-default", **arguments, system="Linux", apple=False, expected=""), + configure(root, "linux-explicit-target", **arguments, system="Linux", apple=False, + override="x86_64-unknown-linux-gnu", expected="x86_64-unknown-linux-gnu"), + configure(root, "emscripten", **arguments, system="Emscripten", apple=False, + emscripten=True, expected="wasm32-unknown-emscripten"), + configure(root, "android-unsupported-abi", **arguments, system="Android", apple=False, + android=True, abi="mips", failure="Minori has no Rust target mapping for Android ABI"), + ]) + # Reconfigure the same genuine projects; automatic target values must not + # stick in the cache when switching SDK, whereas an explicit override does. + transitions = [configure(root, "sdk-transition", **arguments, sdk="iphoneos", + expected="aarch64-apple-ios")] + transitions.append(configure(root, "sdk-transition", **arguments, reuse=True)) + transitions.append(configure(root, "sdk-transition", **arguments, reuse=True, + arch="x86_64", expected="x86_64-apple-ios")) + transitions.append(configure(root, "sdk-transition", **arguments, reuse=True, + sdk="iphoneos", expected="aarch64-apple-ios")) + transitions.append(configure(root, "sdk-transition", **arguments, reuse=True, + system="Darwin", sdk="macosx", expected="aarch64-apple-darwin")) + transitions.append(configure(root, "sdk-transition", **arguments, reuse=True, + system="Linux", apple=False, expected="")) + transitions.append(configure(root, "explicit-transition", **arguments, + override="x86_64-apple-ios", expected="x86_64-apple-ios")) + transitions.append(configure(root, "explicit-transition", **arguments, reuse=True, + sdk="iphoneos", override="x86_64-apple-ios", expected="x86_64-apple-ios")) + transitions.append(configure(root, "explicit-transition", **arguments, reuse=True, + sdk="iphoneos", override="", expected="aarch64-apple-ios")) + transitions.append(configure(root, "explicit-transition", **arguments, reuse=True, + override="")) + # The genuine pre-fix root block and full pinned package still produce the + # actual erroneous device target for the canonical Simulator SDK path. + old_arguments = dict(arguments, block=baseline) + old = configure(root, "pre-fix-canonical-simulator", **old_arguments, + sdk="/Applications/Xcode.app/SDKs/iPhoneSimulator26.5.sdk", + expected="aarch64-apple-ios") + evidence = {"status": "passed", "package_head": PIN, + "package_cmake_sha256": hashlib.sha256(package_cmake).hexdigest(), + "source_sha256": {str(path.relative_to(repository)): hashlib.sha256(path.read_bytes()).hexdigest() + for path in (repository / "CMakeLists.txt", + repository / "cmake/AetherKiriMinori.cmake", Path(__file__).resolve())}, + "tools": {"cmake": checked([args.cmake, "--version"], root).splitlines()[0], + "ninja": checked([args.ninja, "--version"], root).strip(), + "cargo": checked([args.cargo, "--version"], root).strip()}, + "configuration_cases": cases, "cache_sdk_transitions": transitions, + "baseline_commit": args.baseline_commit, + "genuine_pre_fix_negative_control": old, "apple_target_compile": "not_run", + "cargo_build": "not_run", "application": "not_run", "gameplay": "not_run"} + (root / "evidence.json").write_text(json.dumps(evidence, indent=2, sort_keys=True) + "\n") + print(json.dumps({"status": "passed", "configuration_cases": len(cases), + "cache_sdk_transitions": len(transitions), "genuine_pre_fix_negative_control": "passed", + "apple_target_compile": "not_run", "application": "not_run", "gameplay": "not_run"})) + + +if __name__ == "__main__": + main() diff --git a/tools/test_ios_openssl_configure.py b/tools/test_ios_openssl_configure.py new file mode 100644 index 000000000..23c4d31da --- /dev/null +++ b/tools/test_ios_openssl_configure.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +"""Exercise the real legacy vcpkg command with OpenSSL 3.6.3 Configure. + +This needs existing public OpenSSL/vcpkg checkouts and actual CMake, Bash, Perl +and a host compiler. It generates a native HOST Makefile/configdata with the +unmodified production wrapper. No SDK, compiler, Configure, or return value is +mocked; target Apple compilation/application/gameplay are explicitly NOT_RUN. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import platform +import shutil +import subprocess +import sys + + +OPENSSL_COMMIT = "aae016bfd52fcad2bc9657c2c782cfdf73b1ed5f" +BASELINE = "9e593bb18ea69cc5095e012465dcd675a822ed0d" +REPOSITORY = Path(__file__).resolve().parents[1] +PORT = REPOSITORY / "vcpkg/ports/openssl" + + +def sha(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def literal(value: str | Path) -> str: + text = str(value) + if "]==]" in text: + raise ValueError("Unsupported CMake bracket terminator in input") + return "[==[" + text + "]==]" + + +def block(recipe: str, name: str) -> str: + begin = "# AETHER_OPENSSL_IOS_TRIPLET_" + name + "_BEGIN\n" + end = "# AETHER_OPENSSL_IOS_TRIPLET_" + name + "_END\n" + if recipe.count(begin) != 1 or recipe.count(end) != 1: + raise RuntimeError("The production iOS triplet block is missing or ambiguous") + return recipe.split(begin)[1].split(end)[0] + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--openssl-source", type=Path, required=True) + parser.add_argument("--vcpkg-source", type=Path, required=True) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--cmake", default="cmake") + args = parser.parse_args() + source = args.openssl_source.resolve(strict=True) + vcpkg = args.vcpkg_source.resolve(strict=True) + output = args.output_dir.resolve() + if any(any(c.isspace() for c in str(p)) for p in (source, vcpkg, output)): + parser.error("The unmodified upstream wrapper requires paths without whitespace") + if sys.platform not in ("linux", "darwin"): + parser.error("This real host Configure regression supports Linux or macOS") + commit = subprocess.check_output(["git", "-C", str(source), "rev-parse", "HEAD"], + text=True).strip() + if commit != OPENSSL_COMMIT: + raise RuntimeError(f"Expected genuine OpenSSL 3.6.3 {OPENSSL_COMMIT}, got {commit}") + subprocess.run(["git", "-C", str(source), "diff", "--exit-code", "HEAD", "--"], + check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + wrapper = PORT / "unix/configure" + original = subprocess.check_output( + ["git", "-C", str(vcpkg), "show", f"{BASELINE}:ports/openssl/unix/configure"]) + if wrapper.read_bytes() != original: + raise RuntimeError("The production wrapper differs from the pinned original") + cmake, perl, bash, cc = (shutil.which(x) for x in (args.cmake, "perl", "bash", "cc")) + if not all((cmake, perl, bash, cc)): + raise RuntimeError("Actual CMake, Perl, Bash and the host compiler are required") + output.mkdir(parents=True, exist_ok=False) + recipe = (PORT / "unix/portfile.cmake").read_text() + normalize, restore = block(recipe, "NORMALIZE"), block(recipe, "RESTORE") + ios_body = recipe.split("elseif(VCPKG_TARGET_IS_IOS)\n")[1].split( + "elseif(VCPKG_TARGET_IS_TVOS OR VCPKG_TARGET_IS_WATCHOS)")[0] + helper = vcpkg / "scripts/cmake/vcpkg_configure_make.cmake" + helper_text = helper.read_text() + commands = [line.strip() for line in helper_text.splitlines() + if line.strip().startswith('set(command "${base_cmd}" -c "${configure_env} ./')] + if len(commands) != 1: + raise RuntimeError("The real legacy vcpkg command construction changed") + quote_begin = ' foreach(var IN ITEMS arg_OPTIONS arg_OPTIONS_RELEASE arg_OPTIONS_DEBUG)\n' + quote_loop = helper_text.split(quote_begin)[1].split( + "\n foreach(current_buildtype IN LISTS all_buildtypes)")[0] + quote_loop = quote_begin + quote_loop + # These are actual public production functions, not local lookalikes. + includes = "".join("include(" + literal(path) + ")\n" for path in ( + vcpkg / "scripts/cmake/z_vcpkg_function_arguments.cmake", + vcpkg / "scripts/cmake/vcpkg_list.cmake", + vcpkg / "ports/vcpkg-make/vcpkg_scripts.cmake", + vcpkg / "ports/vcpkg-make/vcpkg_make.cmake", + )) + checks = includes + "\n" + for arch, sdk, expected in ( + ("arm64", "iphonesimulator", "iossimulator-arm64-xcrun"), + ("arm64", "iphoneos", "ios64-xcrun"), + ("arm", "iphoneos", "ios-xcrun"), + ("x64", "iphonesimulator", "iossimulator-xcrun"), + ("x86", "iphonesimulator", "iossimulator-xcrun"), + ): + checks += f'set(VCPKG_TARGET_ARCHITECTURE {arch})\nset(VCPKG_OSX_SYSROOT {sdk})\n' + checks += ios_body + checks += f'if(NOT OPENSSL_ARCH STREQUAL "{expected}")\nmessage(FATAL_ERROR "Wrong actual OpenSSL target: ${{OPENSSL_ARCH}}")\nendif()\n' + # Leave non-iOS/undefined/empty contexts exactly as the actual caller set + # them, and prove modern make still receives the original two arguments. + checks += 'include(' + literal(REPOSITORY / "vcpkg/triplets/arm64-ios-simulator.cmake") + ')\n' + checks += '''set(VCPKG_TARGET_IS_IOS TRUE) +set(original_triplet "${VCPKG_MAKE_BUILD_TRIPLET}") +''' + normalize + restore + ''' +if(NOT VCPKG_MAKE_BUILD_TRIPLET STREQUAL original_triplet) +message(FATAL_ERROR "The production recipe did not restore the original triplet list") +endif() +z_vcpkg_make_get_configure_triplets(modern_triplets) +if(NOT modern_triplets STREQUAL original_triplet) +message(FATAL_ERROR "The real modern make helper lost the original argv") +endif() +z_vcpkg_make_get_crosscompiling(is_cross ${modern_triplets}) +if(NOT is_cross) +message(FATAL_ERROR "The real modern make helper lost cross-compilation detection") +endif() +set(VCPKG_TARGET_IS_IOS FALSE) +''' + normalize + restore + ''' +if(NOT VCPKG_MAKE_BUILD_TRIPLET STREQUAL original_triplet) +message(FATAL_ERROR "Non-iOS triplet context was modified") +endif() +set(VCPKG_TARGET_IS_IOS TRUE) +unset(VCPKG_MAKE_BUILD_TRIPLET) +''' + normalize + restore + ''' +if(DEFINED VCPKG_MAKE_BUILD_TRIPLET) +message(FATAL_ERROR "An undefined caller variable became defined") +endif() +set(VCPKG_MAKE_BUILD_TRIPLET "") +''' + normalize + restore + ''' +if(NOT DEFINED VCPKG_MAKE_BUILD_TRIPLET OR NOT VCPKG_MAKE_BUILD_TRIPLET STREQUAL "") +message(FATAL_ERROR "The empty caller variable was not preserved") +endif() +''' + check_file = output / "production-scope.cmake" + check_file.write_text(checks) + env = os.environ.copy() + result = subprocess.run([cmake, "-P", str(check_file)], cwd=output, env=env, + text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + (output / "production-scope.log").write_text(result.stdout) + if result.returncode: + raise RuntimeError("Actual production scope/modern-helper checks failed; see production-scope.log") + + arm64 = platform.machine().lower() in ("aarch64", "arm64") + target = ("darwin64-arm64" if arm64 else "darwin64-x86_64") if sys.platform == "darwin" \ + else ("linux-aarch64" if arm64 else "linux-x86_64") + cases = {} + for label in ("original", "patched"): + directory = output / label + directory.mkdir() + script = includes + 'include(' + literal(REPOSITORY / "vcpkg/triplets/arm64-ios-simulator.cmake") + ')\n' + script += 'set(VCPKG_TARGET_IS_IOS TRUE)\nset(original_triplet "${VCPKG_MAKE_BUILD_TRIPLET}")\n' + if label == "patched": + script += normalize + script += 'set(arg_BUILD_TRIPLET ${VCPKG_MAKE_BUILD_TRIPLET})\n' + script += 'set(base_cmd ' + literal(bash) + ')\n' + script += 'set(configure_env ' + literal("CC='" + cc + "'") + ')\n' + relative = os.path.relpath(wrapper.parent, directory) + script += 'set(relative_build_path ' + literal(relative) + ')\n' + script += 'set(arg_OPTIONS ' + ' '.join(literal(x) for x in ( + perl, source / "Configure", target, "no-tests", "no-apps", "no-shared", "no-module")) + ')\n' + script += 'set(arg_OPTIONS_DEBUG --debug)\nset(current_buildtype DEBUG)\n' + quote_loop + '\n' + script += commands[0] + '\nlist(LENGTH command argv_count)\n' + script += '''execute_process(COMMAND ${command} +WORKING_DIRECTORY "${CMAKE_CURRENT_LIST_DIR}" INPUT_FILE /dev/null +RESULT_VARIABLE rc OUTPUT_FILE configure-out.log ERROR_FILE configure-err.log) +file(WRITE "${CMAKE_CURRENT_LIST_DIR}/result.txt" "${rc}\\n${argv_count}\\n") +''' + if label == "patched": + script += restore + '\n' + script += '''if(NOT VCPKG_MAKE_BUILD_TRIPLET STREQUAL original_triplet) +message(FATAL_ERROR "Legacy call altered the caller's modern helper triplet") +endif() +''' + cmake_file = directory / "configure.cmake" + cmake_file.write_text(script) + result = subprocess.run([cmake, "-P", str(cmake_file)], cwd=directory, + env=env, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + (directory / "cmake.log").write_text(result.stdout) + if result.returncode: + raise RuntimeError(f"Actual CMake command failed; see {directory}/cmake.log") + cases[label] = (directory / "result.txt").read_text().splitlines() + if cases["original"] != ["0", "4"] or (output / "original/Makefile").exists() \ + or (output / "original/configdata.pm").exists(): + raise RuntimeError("Original actual argv did not reproduce the zero-exit/missing-Makefile failure") + if cases["patched"] != ["0", "3"] or not (output / "patched/Makefile").is_file() \ + or not (output / "patched/configdata.pm").is_file(): + raise RuntimeError("Patched actual argv did not run the genuine OpenSSL generator") + dump = subprocess.run([perl, "configdata.pm", "--dump"], cwd=output / "patched", + text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=True) + (output / "patched/configdata-dump.log").write_text(dump.stdout) + if "3.6.3" not in dump.stdout or target not in dump.stdout: + raise RuntimeError("The real configdata does not identify the pinned OpenSSL host target") + evidence = { + "status": "PASS", "scope": "genuine-openssl-host-configuration", + "openssl_commit": commit, "openssl_version": "3.6.3", "host_target": target, + "cmake_version": subprocess.check_output([cmake, "--version"], text=True).splitlines()[0], + "original_zero_exit_missing_makefile": "PASS", "production_ios_join_restore": "PASS", + "modern_make_list_and_cross_detection": "PASS", "production_target_and_context_gates": "PASS", + "actual_host_makefile_configdata": "PASS", "configure_sha256": sha(source / "Configure"), + "production_wrapper_sha256": sha(wrapper), "legacy_helper_sha256": sha(helper), + "production_recipe_sha256": sha(PORT / "unix/portfile.cmake"), + "apple_ios_sdk_build_link": "NOT_RUN", "ios_app_gameplay": "NOT_RUN", + } + (output / "evidence.json").write_text(json.dumps(evidence, indent=2) + "\n") + print(json.dumps(evidence, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/tools/test_ios_public_vcpkg_cache.py b/tools/test_ios_public_vcpkg_cache.py new file mode 100644 index 000000000..b29bfcd83 --- /dev/null +++ b/tools/test_ios_public_vcpkg_cache.py @@ -0,0 +1,512 @@ +#!/usr/bin/env python3 +"""Genuine files-provider builds/restores plus hostile archive controls. + +Creates one public scratch C port, never a product port. Uses normal manifest +install for every operation; no installed-tree skip or mocked vcpkg execution. +Linux checks do not claim Apple SDK, App or gameplay execution. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import platform +import shutil +import stat +import struct +import subprocess +import sys +import warnings +import zipfile + +import ios_public_vcpkg_cache as cache + + +def check(condition: bool, description: str) -> None: + if not condition: + raise AssertionError(description) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--vcpkg-root", type=Path, required=True) + parser.add_argument("--cmake", type=Path, required=True) + parser.add_argument("--ninja", type=Path, required=True) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--glib-source", type=Path, + help="Genuine GLib 2.84.2 Git checkout for its libexec helper/provider control") + args = parser.parse_args() + root = args.output_dir.resolve() + check(not root.exists(), "Use a fresh output directory.") + root.mkdir(parents=True) + vcpkg_root = args.vcpkg_root.resolve() + production_ports = cache.public_ports(Path(__file__).resolve().parents[1], vcpkg_root) + check({"vcpkg-cmake", "glib", "openssl", "libgdiplus"} <= production_ports, + "The real production public registry/overlay allowlist rejected required public ports.") + cc = shutil.which("cc") + check(cc is not None, "A genuine native C compiler is required.") + arch = platform.machine() + check(arch in ("x86_64", "arm64", "aarch64"), "Unsupported native regression architecture.") + if platform.system() == "Darwin": + triplet = "x64-osx" if arch == "x86_64" else "arm64-osx" + else: + check(platform.system() == "Linux", "Use a native Linux or Mac host.") + triplet = "x64-linux" if arch == "x86_64" else "arm64-linux" + port = root / "ports/aether-cache-probe" + source = port / "source" + source.mkdir(parents=True) + (port / "vcpkg.json").write_text(json.dumps({ + "name": "aether-cache-probe", "version": "1.0.0", + "dependencies": [{"name": "vcpkg-cmake", "host": True}], + "features": {"extra": {"description": "Public scratch feature control paragraph"}}, + }) + "\n") + (port / "portfile.cmake").write_text('''vcpkg_cmake_configure(SOURCE_PATH "${CMAKE_CURRENT_LIST_DIR}/source") +vcpkg_cmake_install() +file(REMOVE_RECURSE "${CURRENT_PACKAGES_DIR}/debug/include") +file(CREATE_LINK "libcache_probe.a" "${CURRENT_PACKAGES_DIR}/lib/libcache_probe_link.a" SYMBOLIC) +file(CREATE_LINK "libcache_probe.a" "${CURRENT_PACKAGES_DIR}/debug/lib/libcache_probe_link.a" SYMBOLIC) +file(INSTALL "${CMAKE_CURRENT_LIST_DIR}/copyright" DESTINATION "${CURRENT_PACKAGES_DIR}/share/${PORT}") +''') + (port / "copyright").write_text("Public scratch test source, generated by this regression.\n") + (source / "CMakeLists.txt").write_text('''cmake_minimum_required(VERSION 3.20) +project(cache_probe C) +add_library(cache_probe STATIC cache_probe.c) +install(TARGETS cache_probe ARCHIVE DESTINATION lib) +install(FILES cache_probe.h DESTINATION include) +''') + (source / "cache_probe.h").write_text("int cache_probe(void);\n") + manifest = {"name": "public-cache-regression", "version": "1.0.0", + "dependencies": [{"name": "aether-cache-probe", "features": ["extra"]}]} + (root / "vcpkg.json").write_text(json.dumps(manifest) + "\n") + archives = root / "archives" + env = os.environ.copy() + env["PATH"] = os.pathsep.join((str(args.cmake.resolve().parent), + str(args.ninja.resolve().parent), env.get("PATH", ""))) + env["VCPKG_FORCE_SYSTEM_BINARIES"] = "1" + env["VCPKG_BINARY_SOURCES"] = f"clear;files,{archives},readwrite" + command = [str(vcpkg_root / "vcpkg"), "install", f"--triplet={triplet}", + f"--host-triplet={triplet}", f"--overlay-ports={root / 'ports'}", + f"--x-install-root={root / 'installed'}", f"--x-buildtrees-root={root / 'buildtrees'}", + f"--x-packages-root={root / 'packages'}", f"--downloads-root={root / 'downloads'}", + "--disable-metrics"] + ports = {"aether-cache-probe", "vcpkg-cmake"} + stages = [] + def install(stage: str, expect_success: bool = True) -> str: + for name in ("installed", "packages", "buildtrees"): + shutil.rmtree(root / name, ignore_errors=True) + with (root / f"{stage}.log").open("w") as f: + rc = subprocess.run(command, cwd=root, env=env, stdout=f, stderr=subprocess.STDOUT).returncode + check((rc == 0) == expect_success, f"Unexpected genuine vcpkg result in {stage}; inspect its scratch log.") + report = cache.validate_cache(archives, ports, {triplet}) + (root / f"{stage}-archives.json").write_text(json.dumps(report, indent=2) + "\n") + stages.append({"stage": stage, "exit_code": rc, "archive_count": report["archive_count"], + "content_sha256": report["content_sha256"]}) + return (root / f"{stage}.log").read_text() + def write_source(value: int) -> None: + (source / "cache_probe.c").write_text(f'#include "cache_probe.h"\nint cache_probe(void) {{ return {value}; }}\n') + def execute(value: int) -> str: + fixture = root / "check.c" + fixture.write_text(f'#include "cache_probe.h"\nint main(void) {{ return cache_probe() == {value} ? 0 : 1; }}\n') + library = root / f"installed/{triplet}/lib/libcache_probe.a" + subprocess.run([cc, str(fixture), "-I", str(root / f"installed/{triplet}/include"), + str(library), "-o", str(root / "check")], check=True) + subprocess.run([str(root / "check")], check=True) + return cache.digest(library) + write_source(7) + first = install("cold-build") + check("Building aether-cache-probe" in first and "Restored 0 package(s)" in first, "Cold stage did not actually compile.") + before = cache.validate_cache(archives, ports, {triplet}) + check(before["archive_count"] == 2, "Expected the genuine C port and host helper ZIPs.") + probe_record = next(a for a in before["archives"] if a["package"] == "aether-cache-probe") + probe_zip = archives / probe_record["abi"][:2] / (probe_record["abi"] + ".zip") + with zipfile.ZipFile(probe_zip) as z: + check("Feature: extra" in z.read("CONTROL").decode(), "Missing genuine feature paragraph.") + check(any(stat.S_IFMT(i.external_attr >> 16) == stat.S_IFLNK for i in z.infolist()), "The provider did not retain the actual relative package symlink.") + first_library = execute(7) + second = install("normal-manifest-restore") + check("Restored 2 package(s)" in second and "Building aether-cache-probe" not in second, + "Normal manifest install did not restore both real files-provider archives.") + check(execute(7) == first_library, "Restored native library bytes changed.") + write_source(8) + third = install("changed-port-abi") + check("Restored 1 package(s)" in third and "Building aether-cache-probe" in third, + "Changed port content did not genuinely invalidate its ABI.") + changed = cache.validate_cache(archives, ports, {triplet}) + check(changed["archive_count"] == 3, "ABI change did not preserve the older partial archives.") + check(execute(8) != first_library, "Changed source did not produce a different native library.") + fail = root / "ports/aether-cache-failure" + fail.mkdir() + (fail / "vcpkg.json").write_text(json.dumps({"name": "aether-cache-failure", "version": "1.0.0", + "dependencies": [{"name": "aether-cache-probe", "features": ["extra"]}]}) + "\n") + (fail / "portfile.cmake").write_text('message(FATAL_ERROR "Expected scratch failure after successful dependency build")\n') + manifest["dependencies"] = ["aether-cache-failure"] + (root / "vcpkg.json").write_text(json.dumps(manifest) + "\n") + write_source(9) + failed = install("later-package-failure", expect_success=False) + after_failure = cache.validate_cache(archives, ports, {triplet}) + check("Building aether-cache-probe" in failed and after_failure["archive_count"] == 4, + "Successful package archive was not flushed before normal vcpkg failure.") + execute(9) + + glib_evidence = {"status": "not_run"} + glib_record = None + glib_original = None + if args.glib_source: + # Compile the unmodified upstream helper, not a dummy with the same + # filename. This narrow package fixture tests its real install layout + # and standard files-provider roundtrip, not the full GLib library. + glib_source = args.glib_source.resolve() + glib_head = "2371bee17d85318480b3ddeeab4f5107b4889ad7" + check(cache.run(["git", "rev-parse", "HEAD"], glib_source) == glib_head, + "Use the genuine pinned GLib 2.84.2 source checkout.") + files = ("gio/gio-launch-desktop.c", "glib/gjournal-private.c", + "glib/gjournal-private.h", "glib/gmacros.h") + glib_root = root / "glib-helper-provider" + glib_port = glib_root / "ports/glib" + glib_package_source = glib_port / "source" + source_hashes = {} + for filename in files: + data = (glib_source / filename).read_bytes() + check(data == subprocess.check_output(["git", "show", f"HEAD:{filename}"], cwd=glib_source), + "The pinned GLib helper source was modified.") + destination = glib_package_source / filename + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(data) + source_hashes[filename] = hashlib.sha256(data).hexdigest() + production_manifest = json.loads((Path(__file__).resolve().parents[1] / + "vcpkg/ports/glib/vcpkg.json").read_text()) + check((production_manifest["version"], production_manifest["port-version"]) == ("2.84.2", 2), + "The production GLib pin changed; review the helper exception.") + (glib_port / "vcpkg.json").write_text(json.dumps({ + "name": "glib", "version": "2.84.2", "port-version": 2, + "description": "Pinned public GLib helper package fixture, not the full library", + "dependencies": [{"name": "vcpkg-cmake", "host": True}], + }) + "\n") + (glib_port / "portfile.cmake").write_text('''vcpkg_cmake_configure(SOURCE_PATH "${CMAKE_CURRENT_LIST_DIR}/source") +vcpkg_cmake_install() +file(INSTALL "${CMAKE_CURRENT_LIST_DIR}/copyright" DESTINATION "${CURRENT_PACKAGES_DIR}/share/${PORT}") +''') + shutil.copyfile(glib_source / "LICENSES/LGPL-2.1-or-later.txt", glib_port / "copyright") + (glib_package_source / "CMakeLists.txt").write_text('''cmake_minimum_required(VERSION 3.20) +project(glib_helper C) +add_executable(gio-launch-desktop gio/gio-launch-desktop.c) +target_include_directories(gio-launch-desktop PRIVATE glib) +if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + target_sources(gio-launch-desktop PRIVATE glib/gjournal-private.c) +endif() +install(TARGETS gio-launch-desktop RUNTIME DESTINATION libexec) +''') + (glib_root / "vcpkg.json").write_text(json.dumps({ + "name": "glib-helper-cache-regression", "version": "1.0.0", "dependencies": ["glib"], + }) + "\n") + glib_archives = glib_root / "archives" + glib_env = dict(env, VCPKG_BINARY_SOURCES=f"clear;files,{glib_archives},readwrite") + glib_command = [str(vcpkg_root / "vcpkg"), "install", f"--triplet={triplet}", + f"--host-triplet={triplet}", f"--overlay-ports={glib_root / 'ports'}", + f"--x-install-root={glib_root / 'installed'}", + f"--x-buildtrees-root={glib_root / 'buildtrees'}", + f"--x-packages-root={glib_root / 'packages'}", + f"--downloads-root={root / 'downloads'}", "--disable-metrics"] + helper_hash = None + for stage in ("cold-build", "normal-manifest-restore"): + for name in ("installed", "packages", "buildtrees"): + shutil.rmtree(glib_root / name, ignore_errors=True) + with (glib_root / f"{stage}.log").open("w") as f: + subprocess.run(glib_command, cwd=glib_root, env=glib_env, + stdout=f, stderr=subprocess.STDOUT, check=True) + text = (glib_root / f"{stage}.log").read_text() + if stage == "cold-build": + check("Building glib" in text and "Restored 0 package(s)" in text, + "The genuine GLib helper was not source compiled.") + else: + check("Restored 2 package(s)" in text and "Building glib" not in text, + "The genuine GLib helper package was not restored normally.") + report = cache.validate_cache(glib_archives, production_ports, {triplet}) + check(report["archive_count"] == 2, "Expected real GLib helper and vcpkg-cmake archives.") + (glib_root / f"{stage}-archives.json").write_text(json.dumps(report, indent=2) + "\n") + helper = glib_root / f"installed/{triplet}/libexec/gio-launch-desktop" + output = subprocess.check_output([str(helper), sys.executable, "-I", "-c", + "import os; print(os.getenv('GIO_LAUNCHED_DESKTOP_FILE_PID', ''))"], + text=True).strip() + check(output.isdigit() and int(output) > 0, "Actual GLib helper execution did not set the child PID.") + current_hash = cache.digest(helper) + check(helper_hash is None or helper_hash == current_hash, "Restored helper bytes changed.") + helper_hash = current_hash + glib_record = next(a for a in report["archives"] if a["package"] == "glib") + glib_zip = glib_archives / glib_record["abi"][:2] / (glib_record["abi"] + ".zip") + with zipfile.ZipFile(glib_zip) as z: + glib_original = [(i, z.read(i)) for i in z.infolist()] + check("libexec/gio-launch-desktop" in z.namelist(), "Real provider omitted the public helper.") + glib_evidence = {"status": "passed", "scope": "pinned_glib_helper_package_not_full_glib", + "source_head": glib_head, "source_sha256": source_hashes, + "archive": glib_record, "helper_sha256": helper_hash, + "actual_helper_execution": "passed", "files_provider_restore": "passed"} + + # Derive rejection controls from a real successfully produced archive. + controls = root / "rejection-controls" + controls.mkdir() + with zipfile.ZipFile(probe_zip) as z: + original = [(i, z.read(i)) for i in z.infolist()] + # Standard-library ZIP64 container compatibility, using genuine provider + # payload bytes. This is a format control, not a claimed large vcpkg build. + zip64_dir = root / "zip64-format-control" / probe_record["abi"][:2] + zip64_dir.mkdir(parents=True) + zip64_path = zip64_dir / probe_zip.name + with zipfile.ZipFile(zip64_path, "w", allowZip64=True) as z: + for i, data in original: + z.writestr(i, data) + for index in range(65536 - len(original)): + z.writestr(f"share/aether-cache-probe/zip64-format-{index}", b"") + check(b"PK\x06\x06" in zip64_path.read_bytes()[-200:], "The format control did not actually produce a ZIP64 end record.") + check(cache.validate_cache(zip64_dir.parent, ports, {triplet})["archive_count"] == 1, + "Standard ZIP64 container rejected.") + rejected = [] + def reject(label: str, edit, expected: str, allowed_triplets=None, *, glib=False) -> cache.InvalidCache: + record = glib_record if glib else probe_record + entries_source = glib_original if glib else original + d = controls / label / record["abi"][:2] + d.mkdir(parents=True) + path = d / (record["abi"] + ".zip") + entries = edit([(i, b) for i, b in entries_source]) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + with zipfile.ZipFile(path, "w") as z: + for info, data in entries: + z.writestr(info, data) + try: + cache.validate_cache(d.parent, production_ports if glib else ports, allowed_triplets or {triplet}) + except cache.InvalidCache as e: + check(str(e) == expected, f"Wrong rejection reason in {label}.") + failure = e + check(len(cache.rejection_message(e)) <= 640, "Rejection diagnostic is not bounded.") + if expected != "unexpected-package-root": + check(e.metadata is None, "Unverified package identity was printed in a diagnostic.") + else: + raise AssertionError(f"Unsafe control accepted: {label}.") + rejected.append(label) + return failure + def control_edit(old: bytes, new: bytes): + return lambda es: [(i, b.replace(old, new) if i.filename == "CONTROL" else b) for i, b in es] + reject("wrong-control-abi", control_edit(probe_record["abi"].encode(), b"0" * 64), "control-abi-mismatch") + reject("altered-abi-info", lambda es: [(i, b + b"changed\n" if i.filename.endswith("vcpkg_abi_info.txt") else b) for i, b in es], "abi-info-digest-mismatch") + reject("private-package", control_edit(b"Package: aether-cache-probe", b"Package: private-product"), "non-public-package") + reject("private-dependency", control_edit(b"Depends: vcpkg-cmake", b"Depends: private-product"), "non-public-dependency") + reject("foreign-triplet", lambda es: es, "unexpected-package-triplet", {"arm64-ios"}) + reject("path-traversal", lambda es: es + [(zipfile.ZipInfo("../outside"), b"x")], "unsafe-member") + reject("absolute-path", lambda es: es + [(zipfile.ZipInfo("/outside"), b"x")], "unsafe-member") + reject("duplicate-control", lambda es: es + [next(e for e in es if e[0].filename == "CONTROL")], "duplicate-member") + def link_edit(target: bytes): + def edit(es): + i = zipfile.ZipInfo("lib/unsafe-link") + i.create_system = 3 + i.external_attr = (stat.S_IFLNK | 0o777) << 16 + return es + [(i, target)] + return edit + reject("absolute-symlink", link_edit(b"/outside"), "unsafe-symlink") + reject("escaping-symlink", link_edit(b"../../outside"), "escaping-symlink") + reject("symlink-cycle", link_edit(b"unsafe-link"), "symlink-cycle") + reject("source-cache-root", lambda es: es + [(zipfile.ZipInfo("buildtrees/compiler.log"), b"x")], "unexpected-package-root") + reject("other-package-libexec", lambda es: es + [ + (zipfile.ZipInfo("libexec/gio-launch-desktop"), b"x")], "unexpected-package-root") + if glib_original: + reject("glib-libexec-extra-member", lambda es: es + [ + (zipfile.ZipInfo("libexec/unknown-helper"), b"x")], "unexpected-package-root", glib=True) + reject("glib-libexec-other-version", control_edit(b"Version: 2.84.2", b"Version: 2.84.3"), + "unexpected-package-root", glib=True) + reject("glib-libexec-other-port-version", control_edit(b"Port-Version: 2", b"Port-Version: 3"), + "unexpected-package-root", glib=True) + reject("glib-libexec-unverified-abi", control_edit(glib_record["abi"].encode(), b"0" * 64), + "control-abi-mismatch", glib=True) + reject("glib-libexec-missing-helper", lambda es: [ + (i, b) for i, b in es if i.filename != "libexec/gio-launch-desktop"], + "unexpected-package-root", glib=True) + def helper_directory(es): + result = [] + for info, data in es: + if info.filename == "libexec/gio-launch-desktop": + info = zipfile.ZipInfo(info.filename) + info.create_system = 3 + info.external_attr = (stat.S_IFDIR | 0o755) << 16 + result.append((info, data)) + return result + reject("glib-libexec-directory-mode", helper_directory, "unexpected-package-root", glib=True) + def helper_symlink(es): + result = [] + for info, data in es: + if info.filename == "libexec/gio-launch-desktop": + info = zipfile.ZipInfo(info.filename) + info.create_system = 3 + info.external_attr = (stat.S_IFLNK | 0o777) << 16 + data = b"../share/glib/copyright" + result.append((info, data)) + return result + reject("glib-libexec-symlink", helper_symlink, "unexpected-package-root", glib=True) + known_root = reject("diagnostic-known-root", lambda es: es + [ + (zipfile.ZipInfo("manual-tools/aether-cache-probe/tool"), b"x")], "unexpected-package-root") + check(known_root.metadata == {"abi": probe_record["abi"], "package": "aether-cache-probe", + "triplet": triplet, "root": "manual-tools"}, + "Known root diagnostic did not contain only verified public identity and the fixed label.") + private_root = "private-root-name-must-not-be-echoed" + unknown_root = reject("diagnostic-hashed-root", lambda es: es + [ + (zipfile.ZipInfo(private_root + "/private-member-must-not-be-echoed"), b"x")], "unexpected-package-root") + message = cache.rejection_message(unknown_root) + check(private_root not in message and "private-member-must-not-be-echoed" not in message, + "Arbitrary root/member text escaped into a diagnostic.") + check(unknown_root.metadata == {"abi": probe_record["abi"], "package": "aether-cache-probe", + "triplet": triplet, "root_sha256": hashlib.sha256(private_root.encode()).hexdigest()}, + "Unknown root diagnostic was not limited to verified identity and a digest.") + # Run the real CLI rejection path, without substituting an Apple identity. + # Linux rejects the host boundary; Mac rejects this private CONTROL after + # using its actual SDK identity. Neither may publish success outputs/report. + output = root / "rejected-github-output" + output.write_text("existing-test-marker=value\n") + rejection_report = root / "must-not-exist.json" + rejected_cli = subprocess.run([ + sys.executable, str(Path(cache.__file__).resolve()), "validate", + "--repository", str(Path(__file__).resolve().parents[1]), + "--vcpkg-root", str(vcpkg_root), "--sdk", "iphonesimulator", + "--triplet", "arm64-ios-simulator", "--internal", "OFF", + "--cache-dir", str(controls / "private-package"), + "--report", str(rejection_report), "--github-output", str(output), + ], env=env, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + expected_code = "non-public-package" if platform.system() == "Darwin" else "real-apple-host-required" + check(rejected_cli.returncode == 1 and expected_code in rejected_cli.stderr, + "Actual CLI did not reject the unsafe cache at the genuine platform/package boundary.") + check(not rejected_cli.stdout and output.read_text() == "existing-test-marker=value\n" + and not rejection_report.exists(), "Rejected CLI cache published success output or a report.") + # Actual provider bytes with an oversized declared central directory must + # be rejected before zipfile allocates/parses that directory. + cd_dir = controls / "central-directory-budget" / probe_record["abi"][:2] + cd_dir.mkdir(parents=True) + cd_path = cd_dir / probe_zip.name + payload = bytearray(probe_zip.read_bytes()) + end = payload.rfind(b"PK\x05\x06") + struct.pack_into(" str: + result = subprocess.run(argv, cwd=cwd, env=env, text=True, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + timeout=60, check=False) + if result.returncode: + raise RuntimeError(f"{shlex.join(argv)} failed ({result.returncode}):\n{result.stdout}") + return result.stdout + + +def quote(value: str | Path) -> str: + value = str(value) + if "]]" in value: + raise ValueError("unsupported CMake bracket delimiter in input") + return "[[" + value + "]]" + + +def fragment(source: str, start: str, end: str) -> str: + if source.count(start) != 1 or source.count(end) != 1: + raise RuntimeError("production CMake fragment boundaries changed") + return source[source.index(start):source.index(end)] + + +def cargo_target(commands: str, subcommand: str) -> str: + matches = [] + for line in commands.splitlines(): + args = shlex.split(line) + if any(Path(arg).name == "cargo" and i + 1 < len(args) + and args[i + 1] == subcommand for i, arg in enumerate(args)): + if args.count("--target") != 1: + raise RuntimeError("generated Cargo command lacks exactly one target") + matches.append(args[args.index("--target") + 1]) + if len(matches) != 1: + raise RuntimeError(f"expected one production Cargo {subcommand} command") + return matches[0] + + +def configure_case(root: Path, name: str, sdk: str, arch: str, + expected: str, rfvp_expected: str, override: str | None, + repository: Path, siglus_helper: Path, rfvp_source: str, + cmake: str, ninja: str, env: dict[str, str], ios: bool = True) -> dict: + source = root / name / "source" + binary = root / name / "build" + (source / "packages").mkdir(parents=True) + (source / "packages" / "AetherSiglus").symlink_to(repository / "packages/AetherSiglus", + target_is_directory=True) + selection = fragment(rfvp_source, "find_program(RFVP_CARGO cargo REQUIRED)", + "include(cmake/PrepareRfvpSources.cmake)") + build = fragment(rfvp_source, 'set(rfvp_features "', + "add_library(aether_rfvp_rust STATIC IMPORTED GLOBAL)") + (source / "rfvp-production-selection.cmake").write_text(selection) + (source / "rfvp-production-build.cmake").write_text(build) + production_siglus_dir = repository / "bridge/siglus_runtime" + project = f"""cmake_minimum_required(VERSION 3.28) +project(RustTargetAcceptance LANGUAGES NONE) +# Controlled configuration inputs, not an Apple compiler or SDK probe. +set(APPLE TRUE) +set(IOS {'TRUE' if ios else 'FALSE'}) +set(CMAKE_OSX_ARCHITECTURES {quote(arch)}) +set(CMAKE_OSX_SYSROOT {quote(sdk)}) +set(CMAKE_BUILD_TYPE Debug) +include({quote(siglus_helper)}) +aetherkiri_deduce_rust_target(selected_siglus) +function(generate_siglus) + set(CMAKE_CURRENT_SOURCE_DIR {quote(production_siglus_dir)}) + aetherkiri_add_siglus_rs(actual_siglus) + if(NOT actual_siglus_FOUND) + message(FATAL_ERROR "actual production Siglus target was skipped") + endif() + get_target_property(archive actual_siglus IMPORTED_LOCATION) + file(WRITE "${{CMAKE_BINARY_DIR}}/siglus-archive.txt" "${{archive}}") +endfunction() +generate_siglus() +set(AETHERKIRI_RFVP_PROJECT_ROOT {quote(repository)}) +set(AETHERKIRI_RFVP_DIR {quote(repository / 'packages/rfvp')}) +include("${{CMAKE_CURRENT_SOURCE_DIR}}/rfvp-production-selection.cmake") +set(rfvp_prepared "${{CMAKE_BINARY_DIR}}/prepared") +include("${{CMAKE_CURRENT_SOURCE_DIR}}/rfvp-production-build.cmake") +file(WRITE "${{CMAKE_BINARY_DIR}}/selected.txt" + "${{selected_siglus}}\n${{AETHERKIRI_RFVP_RUST_TARGET}}\n${{rfvp_library}}\n") +""" + (source / "CMakeLists.txt").write_text(project) + argv = [cmake, "-S", str(source), "-B", str(binary), "-G", "Ninja", + f"-DCMAKE_MAKE_PROGRAM={ninja}"] + if override is not None: + argv.append(f"-DAETHERKIRI_RFVP_RUST_TARGET:STRING={override}") + configure_log = run(argv, root, env) + (root / name / "configure.log").write_text(configure_log) + selected, rfvp_selected, rfvp_archive = (binary / "selected.txt").read_text().splitlines() + siglus_commands = run([ninja, "-C", str(binary), "-t", "commands", "siglus_rs_cargo_build"], root, env) + rfvp_commands = run([ninja, "-C", str(binary), "-t", "commands", "aether_rfvp_rust_build"], root, env) + actual = (selected, cargo_target(siglus_commands, "rustc"), + rfvp_selected, cargo_target(rfvp_commands, "build")) + wanted = (expected, expected, rfvp_expected, rfvp_expected) + if actual != wanted: + raise AssertionError((name, actual, wanted)) + siglus_archive = (binary / "siglus-archive.txt").read_text() + if f"/siglus-rs-target/{expected}/debug/libsiglus_scene_vm.a" not in siglus_archive: + raise AssertionError((name, siglus_archive)) + if f"/target/{rfvp_expected}/debug/librfvp.a" not in rfvp_archive: + raise AssertionError((name, rfvp_archive)) + # Reconfigure the genuine project to exercise the persistent cache too. + run(argv, root, env) + if (binary / "selected.txt").read_text().splitlines()[:2] != [expected, rfvp_expected]: + raise AssertionError("reconfigure changed the selected target") + return {"name": name, "sdk_fixture": sdk, "architecture": arch, + "siglus_target": selected, "rfvp_target": rfvp_selected, + "rfvp_explicit_override": override, "cargo_commands_generated": True, + "cache_reconfigure": "pass", "target_compile": "not_run"} + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repository", type=Path, default=Path(__file__).resolve().parents[1]) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--cmake", default=shutil.which("cmake")) + parser.add_argument("--ninja", default=shutil.which("ninja")) + parser.add_argument("--baseline-commit", help="optional genuine pre-fix source for negative controls") + args = parser.parse_args() + repository = args.repository.resolve(strict=True) + root = args.output_dir.resolve() + root.mkdir(parents=True, exist_ok=False) + env = os.environ.copy() + for tool in (args.cmake, args.ninja, shutil.which("cargo"), shutil.which("rustc")): + if not tool or not Path(tool).is_file(): + raise RuntimeError("real CMake, Ninja, cargo and rustc must be installed") + siglus = repository / "bridge/siglus_runtime/cmake/BuildSiglusRs.cmake" + rfvp_path = repository / "bridge/rfvp_runtime/CMakeLists.txt" + rfvp = rfvp_path.read_text() + versions = {"cmake": run([args.cmake, "--version"], root, env).splitlines()[0], + "ninja": run([args.ninja, "--version"], root, env).strip(), + "rustc": run([shutil.which("rustc"), "-vV"], root, env), + "cargo": run([shutil.which("cargo"), "--version"], root, env).strip()} + cases = [] + for arch, expected in (("arm64", "aarch64-apple-ios-sim"), ("x86_64", "x86_64-apple-ios")): + for label, sdk in (("alias", "iphonesimulator"), ("mixed-alias", "iPhOnEsImUlAtOr"), + ("sdk", "/Applications/Xcode_26.6.app/SDKs/iPhoneSimulator26.5.sdk"), + ("spaces", "/Applications/Xcode Selected.app/SDKs/iPhOnEsImUlAtOr26.5.sdk"), + ("unversioned-sdk", "/Applications/Xcode.app/SDKs/iPhoneSimulator.sdk")): + cases.append((f"sim-{arch}-{label}", sdk, arch, expected, expected, None, True)) + for label, sdk in (("alias", "iphoneos"), ("mixed-alias", "iPhOnEoS"), + ("sdk", "/Applications/Xcode_26.6.app/SDKs/iPhoneOS26.5.sdk"), + ("spaces", "/Applications/Xcode Selected.app/SDKs/iPhOnEoS26.5.sdk"), + ("misleading-parent", "/tmp/iphonesimulator/iPhoneSimulator.platform/SDKs/iPhoneOS26.5.sdk")): + cases.append((f"device-{label}", sdk, "arm64", "aarch64-apple-ios", "aarch64-apple-ios", None, True)) + cases.extend([ + ("mac-arm64", "macosx", "arm64", "aarch64-apple-darwin", "aarch64-apple-darwin", None, False), + ("mac-x64", "macosx", "x86_64", "x86_64-apple-darwin", "x86_64-apple-darwin", None, False), + ("rfvp-explicit-sim", "/Applications/Xcode.app/SDKs/iPhoneSimulator26.5.sdk", "arm64", + "aarch64-apple-ios-sim", "x86_64-apple-ios", "x86_64-apple-ios", True), + ("rfvp-explicit-device", "iphoneos", "arm64", "aarch64-apple-ios", + "aarch64-apple-ios-sim", "aarch64-apple-ios-sim", True), + ]) + results = [configure_case(root, *case[:6], repository, siglus, rfvp, args.cmake, + args.ninja, env, ios=case[6]) for case in cases] + # Execute the unchanged non-Apple mapper branches with real CMake/rustc. + controls = root / "non-ios.cmake" + controls.write_text(f"""include({quote(siglus)}) +find_program(RUSTC_EXECUTABLE rustc REQUIRED) +function(check expected) + aetherkiri_deduce_rust_target(actual) + if(NOT actual STREQUAL expected) + message(FATAL_ERROR "${{actual}} != ${{expected}}") + endif() +endfunction() +set(WEB TRUE) +check(wasm32-unknown-unknown) +set(WEB FALSE) +set(EMSCRIPTEN TRUE) +check(wasm32-unknown-unknown) +set(EMSCRIPTEN FALSE) +set(ANDROID TRUE) +set(ANDROID_ABI arm64-v8a) +check(aarch64-linux-android) +set(ANDROID_ABI armeabi-v7a) +check(armv7-linux-androideabi) +set(ANDROID_ABI x86_64) +check(x86_64-linux-android) +set(ANDROID_ABI x86) +check(i686-linux-android) +set(ANDROID FALSE) +execute_process(COMMAND "${{RUSTC_EXECUTABLE}}" -vV OUTPUT_VARIABLE version COMMAND_ERROR_IS_FATAL ANY) +string(REGEX MATCH "host: ([^\n]+)" ignored "${{version}}") +check("${{CMAKE_MATCH_1}}") +""") + run([args.cmake, "-P", str(controls)], root, env) + baseline = {"negative_controls": "not_run"} + if args.baseline_commit: + old_siglus = root / "baseline-siglus.cmake" + old_siglus.write_text(run(["git", "show", f"{args.baseline_commit}:{siglus.relative_to(repository)}"], repository, env)) + old_rfvp = run(["git", "show", f"{args.baseline_commit}:{rfvp_path.relative_to(repository)}"], repository, env) + try: + configure_case(root, "baseline-canonical-sdk", "/Applications/Xcode.app/SDKs/iPhoneSimulator26.5.sdk", + "arm64", "aarch64-apple-ios-sim", "aarch64-apple-ios-sim", None, + repository, old_siglus, old_rfvp, args.cmake, args.ninja, env) + except AssertionError as error: + actual = error.args[0][1] + if actual != ("aarch64-apple-ios",) * 4: + raise + baseline = {"commit": args.baseline_commit, "negative_controls": "pass", + "actual_wrong_targets": list(actual)} + else: + raise AssertionError("genuine pre-fix source did not reproduce the wrong device target") + evidence = {"status": "pass", "tools": versions, + "regression_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), + "production_source_sha256": { + str(path.relative_to(repository)): hashlib.sha256(path.read_bytes()).hexdigest() + for path in (siglus, rfvp_path)}, + "configuration_and_command_cases": results, "non_ios_mapper_controls": 7, + "baseline": baseline, "apple_sdk_compile": "not_run", + "full_application": "not_run", "gameplay": "not_run"} + (root / "evidence.json").write_text(json.dumps(evidence, indent=2) + "\n") + print(json.dumps({"status": "pass", "configuration_and_command_cases": len(results), + "non_ios_mapper_controls": 7, "baseline": baseline, + "apple_sdk_compile": "not_run", "gameplay": "not_run"})) + + +if __name__ == "__main__": + main() diff --git a/tools/test_libgdiplus_cmake_relocation.py b/tools/test_libgdiplus_cmake_relocation.py new file mode 100644 index 000000000..9dd494f04 --- /dev/null +++ b/tools/test_libgdiplus_cmake_relocation.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""Exercise libgdiplus's real CMake export section with a compiled tiny fixture. + +This tests package relocation and dependency metadata, not the libgdiplus +implementation, an Android build, or mobile gameplay. Supply an ordinary +vcpkg checkout; this test does not download tools or alter its cache. +""" + +import argparse +import shutil +import subprocess +import tempfile +from pathlib import Path + + +def run(*command: str, succeeds: bool = True) -> str: + result = subprocess.run(command, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + if (result.returncode == 0) != succeeds: + raise RuntimeError(f'{command}: exit {result.returncode}\n{result.stdout}') + return result.stdout + + +def check(cmake: str, vcpkg: Path) -> None: + repo = Path(__file__).resolve().parents[1] + port = repo / 'vcpkg/ports/libgdiplus' + export = (port / 'CMakeLists.txt').read_text().split('# 合并所有依赖\n', 1)[1] + recipe = (port / 'portfile.cmake').read_text() + assert recipe.index('vcpkg_cmake_install()') < recipe.index('vcpkg_cmake_config_fixup()') < recipe.index('"${CURRENT_PACKAGES_DIR}/debug/share"') + assert 'VCPKG_POLICY_SKIP_ABSOLUTE_PATHS_CHECK' not in recipe + helper_paths = ['scripts/cmake/z_vcpkg_function_arguments.cmake', 'scripts/cmake/vcpkg_list.cmake', + 'ports/vcpkg-cmake-config/vcpkg_cmake_config_fixup.cmake'] + for path in helper_paths: + if not (vcpkg / path).is_file(): + raise FileNotFoundError(vcpkg / path) + with tempfile.TemporaryDirectory(prefix='aether-libgdiplus-relocation-') as directory: + root = Path(directory) + original = root / 'old-release-triplet' + packages = root / 'package' + source = root / 'source' + source.mkdir() + headers = original / 'include/dependency' + headers.mkdir(parents=True) + (headers / 'fixture-dependency.h').write_text('int dependency_fixture(void);\n') + (source / 'dependency.c').write_text('int dependency_fixture(void) { return 41; }\n') + (source / 'named.c').write_text('int named_fixture(void) { return CONFIG_VALUE; }\n') + (source / 'gdiplus.c').write_text('#include "fixture-dependency.h"\nint named_fixture(void);\nint gdiplus_fixture(void) { return dependency_fixture() + named_fixture() + 1; }\n') + (source / 'src').mkdir() + (source / 'src/gdiplus-fixture.h').write_text('int gdiplus_fixture(void);\n') + (source / 'config.h.in').write_text('/* CMake export fixture */\n') + shutil.copyfile(port / 'libgdiplus-config.cmake.in', source / 'libgdiplus-config.cmake.in') + (source / 'CMakeLists.txt').write_text('''cmake_minimum_required(VERSION 3.28) +project(libgdiplus VERSION 5.6.1 LANGUAGES C) +add_library(dependency STATIC dependency.c) +set_target_properties(dependency PROPERTIES OUTPUT_NAME dependency_fixture) +install(TARGETS dependency ARCHIVE DESTINATION lib) +add_library(named_dependency STATIC named.c) +set_target_properties(named_dependency PROPERTIES OUTPUT_NAME named_dependency_fixture DEBUG_POSTFIX d) +target_compile_definitions(named_dependency PRIVATE "CONFIG_VALUE=$,7,13>") +install(TARGETS named_dependency ARCHIVE DESTINATION lib) +add_library(libgdiplus STATIC gdiplus.c) +set(GLIB_INCLUDE_DIRS "${DEPENDENCY_PREFIX}/include/dependency") +set(GLIB_LIBRARY_DIRS "${DEPENDENCY_PREFIX}/${DEPENDENCY_LIBDIR}") +if(CMAKE_BUILD_TYPE STREQUAL "Debug") + set(GLIB_LIBRARIES named_dependency_fixtured) +else() + set(GLIB_LIBRARIES named_dependency_fixture) +endif() +set(GDIPLUS_LIBS "${DEPENDENCY_PREFIX}/${DEPENDENCY_LIBDIR}/libdependency_fixture.a") +''' + export) + # Build both real native archives and export exactly the production + # port's target/interface/install section, including absolute deps. + for configuration, suffix in [('Release', ''), ('Debug', 'debug')]: + build = root / ('producer-' + configuration) + run(cmake, '-S', str(source), '-B', str(build), '-DCMAKE_BUILD_TYPE=' + configuration, + '-DCMAKE_INSTALL_PREFIX=' + str(packages / suffix), '-DDEPENDENCY_PREFIX=' + str(original), + '-DDEPENDENCY_LIBDIR=' + ('debug/lib' if suffix else 'lib')) + run(cmake, '--build', str(build)) + run(cmake, '--install', str(build)) + destination = original / suffix / 'lib' + destination.mkdir(parents=True) + shutil.copyfile(packages / suffix / 'lib/libdependency_fixture.a', destination / 'libdependency_fixture.a') + named_archive = 'libnamed_dependency_fixture' + ('d' if suffix else '') + '.a' + shutil.copyfile(packages / suffix / 'lib' / named_archive, destination / named_archive) + shutil.copytree(original / 'include/dependency', packages / 'include/dependency') + # The config's TIFF dependency must remain resolvable after moving. + # TIFF is an interface fixture here; the linked dependency above is a + # real archive and exercises Debug/Release path merging. + tiff = packages / 'share/TIFF' + tiff.mkdir(parents=True) + (tiff / 'TIFFConfig.cmake').write_text('set(TIFF_FOUND TRUE)\nif(NOT TARGET TIFF::TIFF)\nadd_library(TIFF::TIFF INTERFACE IMPORTED)\nendif()\n') + consumer = root / 'consumer' + consumer.mkdir() + (consumer / 'CMakeLists.txt').write_text('''cmake_minimum_required(VERSION 3.28) +project(consumer LANGUAGES C) +set(CMAKE_FIND_PACKAGE_PREFER_CONFIG ON) +find_package(libgdiplus CONFIG REQUIRED) +add_executable(consumer main.c) +target_link_libraries(consumer PRIVATE libgdiplus::libgdiplus) +''') + (consumer / 'main.c').write_text('''#include +int main(void) { +#ifdef NDEBUG + return gdiplus_fixture() != 55; +#else + return gdiplus_fixture() != 49; +#endif +} +''') + shutil.rmtree(original) + broken = run(cmake, '-S', str(consumer), '-B', str(root / 'consumer-stale'), + '-DCMAKE_PREFIX_PATH=' + str(packages), succeeds=False) + assert 'includes non-existent path' in broken and str(original) in broken, broken + fixup = root / 'fixup.cmake' + fixup.write_text('cmake_minimum_required(VERSION 3.28)\n' + + '\n'.join(f'include("{vcpkg / path}")' for path in helper_paths) + + f'\nset(PORT libgdiplus)\nset(CURRENT_INSTALLED_DIR "{original}")\n' + f'set(CURRENT_PACKAGES_DIR "{packages}")\nvcpkg_cmake_config_fixup()\n') + run(cmake, '-P', str(fixup)) + for metadata in (packages / 'share/libgdiplus').glob('*.cmake'): + contents = metadata.read_text() + assert str(original) not in contents and str(packages) not in contents, metadata + relocated = root / 'new-debug-triplet' + shutil.move(packages, relocated) + for configuration in ['Release', 'Debug']: + build = root / ('consumer-' + configuration) + run(cmake, '-S', str(consumer), '-B', str(build), '-DCMAKE_BUILD_TYPE=' + configuration, + '-DCMAKE_PREFIX_PATH=' + str(relocated)) + run(cmake, '--build', str(build)) + run(str(build / 'consumer')) + shutil.rmtree(relocated / 'include/dependency') + missing = run(cmake, '-S', str(consumer), '-B', str(root / 'consumer-missing'), + '-DCMAKE_PREFIX_PATH=' + str(relocated), succeeds=False) + assert 'includes non-existent path' in missing and str(relocated / 'include/dependency') in missing, missing + print('Actual CMake export/install/relocation and Release+Debug consumer compile/link/run passed; stale and missing include prefixes rejected. Compiled package fixture only; mobile gameplay unverified.') + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--cmake', default='cmake') + parser.add_argument('--vcpkg-root', type=Path, required=True) + arguments = parser.parse_args() + check(arguments.cmake, arguments.vcpkg_root.resolve()) diff --git a/tools/test_prepare_renpy_android_test_export.py b/tools/test_prepare_renpy_android_test_export.py new file mode 100644 index 000000000..1f4e37eef --- /dev/null +++ b/tools/test_prepare_renpy_android_test_export.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Verify disposable test export configuration; no Android APK or SDK is run.""" + +import tempfile +import unittest +from pathlib import Path + +from prepare_renpy_android_test_export import prepare, read_android_preset + + +PRESETS = '''[preset.0] +name="Android Debug" +platform="Android" +[preset.0.options] +package/unique_name="org.github.krkr2.aetherkiri" +package/name="Aether" +version/code=4501 +version/name="1.0.7-alpha.3" +gradle_build/min_sdk=26 +architectures/arm64-v8a=true +[preset.1] +name="Android Release" +platform="Android" +[preset.1.options] +package/unique_name="org.github.krkr2.aetherkiri" +package/name="Aether" +version/code=4501 +version/name="1.0.7-alpha.3" +gradle_build/min_sdk=26 +''' + + +class TestExportTests(unittest.TestCase): + def test_source_and_release_are_preserved_raw_assets_and_native_are_copied(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source, destination = root / 'source', root / 'test-export' + source.mkdir() + (source / 'export_presets.cfg').write_text(PRESETS) + files = ['project.godot', '.godot/fixture-cache', + 'android/build/renpy_assets/renpy_mobile/private/main.py', + 'android/build/src/main/jniLibs/arm64-v8a/librenpython.so', + 'android/build/build/outputs/old.apk', 'android/build/.gradle/cache', + 'android/build/aether-renpy.gradle'] + for name in files: + path = source / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('Disposable configuration fixture; not a runtime') + result = prepare(source, destination, 'Android Debug', 'org.aetherkiri.renpy.debug') + self.assertEqual(result['product_package'], 'org.github.krkr2.aetherkiri') + self.assertEqual((source / 'export_presets.cfg').read_text(), PRESETS) + config, section, debug = read_android_preset(destination / 'export_presets.cfg', 'Android Debug') + self.assertEqual(debug['package'], 'org.aetherkiri.renpy.debug') + self.assertEqual(config[section]['package/name'], '"Aether RenPy Test"') + _, _, release = read_android_preset(destination / 'export_presets.cfg', 'Android Release') + self.assertEqual(release['package'], 'org.github.krkr2.aetherkiri') + for name in files: + expected = not name.startswith(('.godot/', 'android/build/build/', 'android/build/.gradle/')) + self.assertEqual((destination / name).exists(), expected, name) + # Copies are independent; modifications cannot write back via a hard link. + (destination / 'project.godot').write_text('changed') + self.assertNotEqual((source / 'project.godot').read_text(), 'changed') + + def test_release_product_package_and_nonempty_destination_reject(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source = root / 'source' + source.mkdir() + (source / 'export_presets.cfg').write_text(PRESETS) + for name, package in [('Android Release', 'org.aetherkiri.renpy.debug'), + ('Android Debug', 'org.github.krkr2.aetherkiri'), + ('Android Debug', 'org.bad-package.debug')]: + with self.assertRaises(ValueError): + prepare(source, root / 'test-export', name, package) + with self.assertRaisesRegex(ValueError, 'outside the source'): + prepare(source, source / 'inside', 'Android Debug', 'org.aetherkiri.renpy.debug') + destination = root / 'existing' + destination.mkdir() + (destination / 'keep').write_text('existing file') + with self.assertRaisesRegex(ValueError, 'new or empty'): + prepare(source, destination, 'Android Debug', 'org.aetherkiri.renpy.debug') + self.assertEqual((destination / 'keep').read_text(), 'existing file') + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/test_renios_ios_bundle.sh b/tools/test_renios_ios_bundle.sh new file mode 100644 index 000000000..5dc0d8fc3 --- /dev/null +++ b/tools/test_renios_ios_bundle.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Validate the iOS Renios link/bundle boundary without requiring Xcode. A +# downloaded/staged archive is optional; when provided this also checks the +# complete native closure and the dynamic framework input. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +build_script="$repo_root/scripts/build_ios.sh" +launcher_probe="$repo_root/tools/test_renios_ios_launcher.sh" +[[ -x "$build_script" ]] || { echo "iOS build script is not executable" >&2; exit 1; } +[[ -f "$launcher_probe" ]] || { echo "Renios launcher probe is missing" >&2; exit 1; } +bash -n "$build_script" +bash -n "$launcher_probe" +python3 "$repo_root/tools/test_renpy_ios_runtime_project.py" + +grep -Fq 'AETHERKIRI_RENPY_MOBILE_ROOT' "$build_script" +grep -Fq 'collect_renios_archives' "$build_script" +grep -Fq 'libaether_renpy_runtime.a' "$build_script" +grep -Fq 'stage_renios_ios_resources' "$build_script" +grep -Fq 'libSDL2main.a|libSDL2_test.a' "$build_script" +grep -Fq 'MetalANGLE.xcframework' "$build_script" +# The Godot iOS build must not grow a second UIKit/SDL application entrypoint. +if grep -Eq '^[[:space:]]*(SDL_UIKitRunApp|UIApplicationMain)[[:space:]]*\(' "$build_script"; then + echo "build_ios.sh introduced a second iOS application entrypoint" >&2 + exit 1 +fi + +mobile_root="${RENPY_MOBILE_TEST_ROOT:-}" +if [[ -z "$mobile_root" ]]; then + bash "$launcher_probe" + echo "Renios iOS bundle boundary validation ok (archive inspection not requested)" + exit 0 +fi + +bash "$launcher_probe" "$mobile_root" "${RENPY_MOBILE_TEST_CONFIG:-release}" + +prototype="$mobile_root/renios/prototype" +prebuilt="$prototype/prebuilt/${RENPY_MOBILE_TEST_CONFIG:-release}" +[[ -d "$prebuilt" ]] || { echo "missing Renios prebuilt directory: $prebuilt" >&2; exit 1; } +[[ -f "$prototype/Frameworks/MetalANGLE.xcframework/Info.plist" ]] || { + echo "missing official MetalANGLE framework metadata" >&2 + exit 1 +} +for archive in librenpython.a librenpy.a libpython3.12.a libSDL2.a libSDL2_image.a; do + [[ -f "$prebuilt/$archive" ]] || { echo "missing Renios runtime archive: $archive" >&2; exit 1; } +done +for archive in "$prebuilt"/lib*.a; do + [[ -f "$archive" ]] || continue + case "$(basename "$archive")" in + libSDL2main.a|libSDL2_test.a) continue ;; + esac +done +echo "Renios iOS native closure/framework validation ok" diff --git a/tools/test_renios_ios_launcher.sh b/tools/test_renios_ios_launcher.sh new file mode 100644 index 000000000..8141d0d92 --- /dev/null +++ b/tools/test_renios_ios_launcher.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# Inspect the official Renios launch contract without invoking Xcode or iOS. +# The probe deliberately reports whether the archive exposes only the +# blocking Python launcher; it never claims that this is a host-tick runtime. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +mobile_root="${1:-${RENPY_MOBILE_TEST_ROOT:-${AETHERKIRI_RENPY_MOBILE_ROOT:-}}}" +configuration="${2:-${RENPY_MOBILE_TEST_CONFIG:-release}}" + +if [[ -z "$mobile_root" ]]; then + echo "Renios iOS launcher probe metadata validation ok (archive inspection not requested)" + exit 0 +fi + +prototype="$mobile_root/renios/prototype" +prebuilt="$prototype/prebuilt/$configuration" +[[ -d "$prototype" ]] || { echo "missing Renios prototype: $prototype" >&2; exit 1; } +[[ -d "$prebuilt" ]] || { echo "missing Renios prebuilt directory: $prebuilt" >&2; exit 1; } + +for archive in librenpython.a librenpy.a libpython3.12.a libSDL2.a libSDL2main.a; do + [[ -f "$prebuilt/$archive" ]] || { + echo "missing Renios launcher archive: $prebuilt/$archive" >&2 + exit 1 + } +done + +has_symbol() { + local archive="$1" symbol="$2" + # Apple's strings only scans initialized data by default. In the Renios + # Mach-O archive that includes the archive symbol index (so + # _launcher_main is found) but can skip the undefined _Py_RunMain entry in + # the member object. Prefer the symbol table, and retain a full-file + # strings fallback for portable fixture archives and hosts without nm. + if command -v nm >/dev/null 2>&1; then + local symbols + if symbols="$(nm "$archive" 2>/dev/null)"; then + awk -v wanted="$symbol" '$NF == wanted { found = 1 } END { exit !found }' <<<"$symbols" + return $? + fi + fi + strings -a "$archive" | grep -Fqx "$symbol" +} + +# librenpython.a exports the launcher, but its relocation set calls +# Py_RunMain. That is the blocking boundary we must split before host ticks. +has_symbol "$prebuilt/librenpython.a" _launcher_main || { + echo "librenpython.a does not export _launcher_main" >&2 + exit 1 +} +has_symbol "$prebuilt/librenpython.a" _Py_RunMain || { + echo "librenpython.a does not expose the blocking _Py_RunMain dependency" >&2 + exit 1 +} +# SDL_UIKitRunApp is implemented by SDL2 itself. The separate SDL2main +# archive is only the prototype's dummy-main shim and is intentionally +# excluded from the Godot extension archive. Keep both facts explicit. +has_symbol "$prebuilt/libSDL2.a" _SDL_UIKitRunApp || { + echo "libSDL2.a does not contain the expected UIKit entrypoint" >&2 + exit 1 +} +has_symbol "$prebuilt/libSDL2.a" _UIApplicationMain || { + echo "libSDL2.a does not expose its UIKit UIApplicationMain dependency" >&2 + exit 1 +} +if has_symbol "$prebuilt/libSDL2main.a" _SDL_UIKitRunApp; then + echo "SDL2main unexpectedly owns the UIKit entrypoint" >&2 + exit 1 +fi +grep -Fq 'SDL_UIKitRunApp' "$prototype/main.c" || { + echo "Renios prototype main.c no longer documents its UIKit entrypoint" >&2 + exit 1 +} + +cat < bytes: + """Encode real PNG bytes for a two-color OS boundary fixture, not gameplay.""" + channels = 4 if rgba else 3 + def chunk(kind, data): + return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data)) + rows = b"".join(b"\0" + (bytes([first if y < 8 else second] * 3) + + (b"\xff" if rgba else b"")) * 64 for y in range(64)) + header = struct.pack(">IIBBBBB", 64, 64, 8, 6 if channels == 4 else 2, 0, 0, 0) + return b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header) + chunk(b"IDAT", zlib.compress(rows)) + chunk(b"IEND", b"") + + +class OsScreenReadinessTests(unittest.TestCase): + def session(self, temporary: str, *, apk: Path | None = None) -> Session: + args = type("Args", (), {"output_dir": Path(temporary), "port": 5554, "apk": apk, + "cloud_emulator": True, "sdk_root": temporary})() + session = Session(args) + session.boot_started, session.next_boot_progress = 0, 0 + session.emulator_process = type("BoundaryProcess", (), {"poll": lambda _: None})() + return session + + def test_real_two_color_png_is_os_ready_but_still_rejected_as_a_renpy_scene(self): + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / "two-color-os-fixture.png" + for rgba in (False, True): + path.write_bytes(encoded_os_fixture(rgba=rgba)) + image = png_pixels(path) + stats = os_screen_stats(image) + self.assertEqual((stats["width"], stats["height"], stats["sampled_colors"]), (64, 64, 2)) + self.assertEqual(stats["luma_range"], 255) + self.assertTrue(stats["ready"]) + with self.assertRaisesRegex(Failed, "Ren'Py scene"): + image_stats(image) + + def test_real_uniform_and_low_contrast_pngs_are_not_os_ready(self): + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / "unready-os-fixture.png" + for first, second in ((0, 0), (255, 255), (0, 39), (100, 139)): + path.write_bytes(encoded_os_fixture(first=first, second=second)) + self.assertFalse(os_screen_stats(png_pixels(path))["ready"], (first, second)) + path.write_bytes(b"invalid PNG fixture") + with self.assertRaisesRegex(Failed, "not a PNG"): + png_pixels(path) + with self.assertRaisesRegex(Failed, "dimensions or pixels"): + os_screen_stats((64, 64, b"incomplete", 3)) + + def test_normal_wakeup_precedes_menu_and_all_readiness_commands_share_the_original_deadline(self): + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + calls = [] + properties = {"ro.build.fingerprint": "boundary-input-not-a-device", + "ro.product.cpu.abilist": "x86_64", "ro.build.version.sdk": "35", + "ro.opengles.version": str(0x30000)} + def boundary_device(label, *parts, **kwargs): + calls.append((label, parts, kwargs["timeout"])) + if label.startswith("property-"): + output = properties[parts[-1]].encode() + elif label == "surfaceflinger": + output = b"GLES: controlled input boundary\n" + elif label == "screen-capture": + output = encoded_os_fixture() + else: + output = b"" + return subprocess.CompletedProcess([label], 0, output) + with patch.object(session, "device", side_effect=boundary_device), \ + patch("run_renpy_android_emulator_session.time.monotonic", return_value=595), \ + patch("sys.stdout", new_callable=io.StringIO): + session.validate_booted_device(deadline=600) + self.assertEqual(calls[0][:2], ("wakeup", ("shell", "input", "keyevent", "224"))) + self.assertEqual(calls[1][:2], ("unlock", ("shell", "input", "keyevent", "82"))) + self.assertTrue(all(timeout == 5 for _, _, timeout in calls)) + self.assertEqual(session.result["os_screen_attempts"], 1) + self.assertTrue(session.result["actual_os_screen"]["ready"]) + self.assertFalse(session.result["gameplay_attempted"]) + self.assertFalse(session.result["harness_started"]) + + def test_unready_frames_reach_original_deadline_without_more_commands_or_harness(self): + # Controlled clock/ADB responses test orchestration only; no emulator runs. + with tempfile.TemporaryDirectory() as temporary: + apk = Path(temporary) / "boundary-only-not-an-apk" + apk.write_bytes(b"boundary input") + session = self.session(temporary, apk=apk) + clock, calls = {"now": 595.0}, [] + def capture(label, *parts, **kwargs): + self.assertLess(clock["now"], 600) + calls.append(kwargs["timeout"]) + clock["now"] += 0.5 + return subprocess.CompletedProcess([label], 0, encoded_os_fixture(first=0, second=0)) + def boundary_boot(*_): + session.execution_started = True + session.result["boot_completed"] = True + session.wait_os_screen(deadline=600) + with patch.object(session, "gate_host", return_value=Path("boundary-emulator")), \ + patch.object(session, "prepare_sdk", return_value=Path("boundary-manager")), \ + patch.object(session, "boot", side_effect=boundary_boot), \ + patch.object(session, "boot_failure_diagnostics"), patch.object(session, "stop"), \ + patch.object(session, "gameplay") as harness, patch.object(session, "device", side_effect=capture), \ + patch("run_renpy_android_emulator_session.time.monotonic", side_effect=lambda: clock["now"]), \ + patch("run_renpy_android_emulator_session.time.sleep", side_effect=lambda seconds: clock.update(now=clock["now"] + seconds)), \ + patch("sys.stdout", new_callable=io.StringIO): + with self.assertRaisesRegex(Unavailable, "original boot deadline"): + session.run() + harness.assert_not_called() + self.assertEqual(clock["now"], 600) + self.assertEqual(calls, [5, 2.5]) + self.assertEqual(session.result["os_screen_attempts"], 2) + self.assertEqual(session.result["actual_os_screen"]["sampled_colors"], 1) + self.assertFalse(session.result["actual_os_screen"]["ready"]) + self.assertEqual(session.result["gameplay_verification"], "not_run") + self.assertFalse(session.result["gameplay_verified"]) + + def test_os_frame_and_numeric_observations_are_retained_until_visible(self): + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + clock = {"now": 590.0} + frames = iter((encoded_os_fixture(first=0, second=0), encoded_os_fixture())) + def capture(label, *parts, **kwargs): + return subprocess.CompletedProcess([label], 0, next(frames)) + with patch.object(session, "device", side_effect=capture), \ + patch("run_renpy_android_emulator_session.time.monotonic", side_effect=lambda: clock["now"]), \ + patch("run_renpy_android_emulator_session.time.sleep", side_effect=lambda seconds: clock.update(now=clock["now"] + seconds)), \ + patch("sys.stdout", new_callable=io.StringIO) as console: + session.wait_os_screen(deadline=600) + self.assertEqual([row["ready"] for row in session.result["os_screen_observations"]], [False, True]) + self.assertEqual([row["elapsed_seconds"] for row in session.result["os_screen_observations"]], [590, 592]) + self.assertEqual(session.result["os_screen_attempts"], 2) + self.assertEqual((session.output / "actual-android-screen.png").read_bytes(), encoded_os_fixture()) + first = [json.loads(line) for line in console.getvalue().splitlines() + if "actual-android-first-os-screen" in line][0] + self.assertFalse(first["ready"]) + self.assertEqual(first["sampled_colors"], 1) + + def test_ready_frame_cannot_pass_after_capture_exhausts_the_original_deadline(self): + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + clock = {"now": 599.0} + def capture(label, *parts, **kwargs): + self.assertEqual(kwargs["timeout"], 1) + clock["now"] = 600 + return subprocess.CompletedProcess([label], 0, encoded_os_fixture()) + with patch.object(session, "device", side_effect=capture) as command, \ + patch("run_renpy_android_emulator_session.time.monotonic", side_effect=lambda: clock["now"]), \ + patch("sys.stdout", new_callable=io.StringIO): + with self.assertRaisesRegex(Unavailable, "original boot deadline"): + session.wait_os_screen(deadline=600) + self.assertEqual(command.call_count, 1) + self.assertTrue(session.result["actual_os_screen"]["ready"]) + self.assertNotEqual(session.result["boot_phase"], "validated-booted-device") + + def test_readiness_permission_or_invalid_png_failure_is_not_retried(self): + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + broken_compression = bytearray(encoded_os_fixture()) + broken_compression[41] = 0 # Real PNG IHDR, invalid zlib stream in IDAT. + for failure, captured in ((Unavailable("screen-capture permission denied"), b""), + (None, b"not a PNG"), (None, bytes(broken_compression))): + with patch.object(session, "device", side_effect=failure, + return_value=subprocess.CompletedProcess(["fixture"], 0, captured)) as command, \ + patch("run_renpy_android_emulator_session.time.monotonic", return_value=590): + with self.assertRaises((Unavailable, Failed)): + session.wait_os_screen(deadline=600) + self.assertEqual(command.call_count, 1) + with patch.object(session, "device", side_effect=AssertionError("Expired readiness must not call adb")), \ + patch("run_renpy_android_emulator_session.time.monotonic", return_value=600): + with self.assertRaisesRegex(Unavailable, "original boot deadline"): + session.boot_device("expired", "get-state", deadline=600) + + +class AccelerationBoundaryTests(unittest.TestCase): + def test_google_macos_and_linux_reports_are_distinct(self): + mac = "accel:\n0\nHypervisor.Framework OS X Version 15.7\naccel\n" + linux = "accel:\n0\nKVM (version 12) is installed and usable.\naccel\n" + self.assertTrue(acceleration_usable(mac, "Darwin")) + self.assertTrue(acceleration_usable(linux, "Linux")) + self.assertFalse(acceleration_usable(mac, "Linux")) + self.assertFalse(acceleration_usable(linux, "Darwin")) + + def test_unavailable_and_malformed_hvf_reports_are_rejected(self): + for description in ("HVF unusable", "HVF is not usable", "HVF is unsupported", "HVF is unavailable", + "HVF is not installed and usable", "WHPX is installed and usable"): + self.assertFalse(acceleration_usable(f"accel:\n0\n{description}\naccel\n", "Darwin"), description) + self.assertFalse(acceleration_usable("accel:\n1\nHypervisor.Framework OS X Version 15.7\naccel\n", "Darwin")) + self.assertFalse(acceleration_usable("Hypervisor.Framework OS X Version 15.7\n", "Darwin")) + + def test_linux_kvm_permission_denial_stops_before_any_sdk_command(self): + # Unit input boundary only: no KVM access or command is performed. + session = object.__new__(Session) + session.result = {"checks": []} + with patch("run_renpy_android_emulator_session.platform.system", return_value="Linux"), \ + patch("run_renpy_android_emulator_session.platform.machine", return_value="x86_64"), \ + patch("run_renpy_android_emulator_session.os.access", return_value=False), \ + patch.object(session, "command", side_effect=AssertionError("SDK must not run")): + with self.assertRaisesRegex(Unavailable, "no permissions were changed"): + session.gate_host(Path("/not-a-sdk")) + + def test_arm_mac_stops_before_any_hypervisor_or_sdk_command(self): + session = object.__new__(Session) + session.result = {"checks": []} + with patch("run_renpy_android_emulator_session.platform.system", return_value="Darwin"), \ + patch("run_renpy_android_emulator_session.platform.machine", return_value="arm64"), \ + patch.object(session, "command", side_effect=AssertionError("SDK must not run")): + with self.assertRaisesRegex(Unavailable, "Intel macOS/HVF"): + session.gate_host(Path("/not-a-sdk")) + + +class HarnessResultBoundaryTests(unittest.TestCase): + def test_return_code_and_all_actual_result_identities_are_required(self): + summary = {"status": "passed", "source": "actual-apk-adb-cloud-device", "serial": "emulator-5554", + "package": "org.aetherkiri.renpy.debug", "apk_sha256": "test-only-hash"} + arguments = ("org.aetherkiri.renpy.debug", "emulator-5554", "test-only-hash") + self.assertTrue(gameplay_result_matches(summary, 0, *arguments)) + self.assertFalse(gameplay_result_matches(summary, 1, *arguments)) + self.assertFalse(gameplay_result_matches([], 0, *arguments)) + for name in summary: + wrong = copy.deepcopy(summary) + wrong[name] = "different-test-input" + self.assertFalse(gameplay_result_matches(wrong, 0, *arguments), name) + + +class CurrentStartupDiagnosticTests(unittest.TestCase): + def test_actual_caller_rejects_stale_matching_passed_and_failed_results(self): + # Genuine ordinary exit0/exit1 subprocesses validate this caller boundary. + # They never invoke Android, install an APK or execute a Ren'Py game. + original_popen = subprocess.Popen + for status, code in (("failed", 1), ("passed", 0)): + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + summary = self.milestone_summary(session) + summary.update(status=status, apk_sha256=hashlib.sha256(session.args.apk.read_bytes()).hexdigest()) + path = self.write(session, "result.json", json.dumps(summary)) + os.utime(path, ns=(0, 0)) + processes = [] + def ordinary_child(*_, **kwargs): + process = original_popen([sys.executable, "-c", f"raise SystemExit({code})"], + stdin=subprocess.DEVNULL, stdout=kwargs["stdout"], + stderr=subprocess.STDOUT, start_new_session=True) + processes.append(process) + return process + with patch("run_renpy_android_emulator_session.subprocess.Popen", side_effect=ordinary_child), \ + patch("sys.stdout", new_callable=io.StringIO): + with self.assertRaisesRegex(Unavailable, "not a fresh current regular file"): + session.gameplay() + self.assertEqual(processes[0].poll(), code) + self.assertEqual(owned_group_state(processes[0]), "absent") + self.assertFalse(session.result["apk_installed"], status) + self.assertFalse(session.result["process_started"], status) + self.assertFalse(session.result["gameplay_verified"], status) + self.assertNotIn("gameplay_result", session.result) + + def test_current_result_requires_fresh_regular_bounded_json_without_symlinks(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + started_ns = time.time_ns() + def write_current_fixture(path, data): + # Controlled semantic input: userspace clock sampling alone + # does not guarantee a subsequent inode mtime at that precision. + path.write_bytes(data) + current_ns = started_ns + 1_000_000 + os.utime(path, ns=(current_ns, current_ns)) + self.assertGreaterEqual(path.stat().st_mtime_ns, started_ns) + fresh = root / "fresh-result.json" + write_current_fixture(fresh, b'{"status":"failed"}') + self.assertEqual(read_current_harness_result(fresh, started_ns), {"status": "failed"}) + symlink = root / "symlink-result.json" + symlink.symlink_to(fresh) + with self.assertRaisesRegex(Unavailable, "without symlinks"): + read_current_harness_result(symlink, started_ns) + fifo = root / "fifo-result.json" + os.mkfifo(fifo) + with self.assertRaisesRegex(Unavailable, "regular file"): + read_current_harness_result(fifo, started_ns) + redirected = root / "redirected" + redirected.symlink_to(root, target_is_directory=True) + with self.assertRaisesRegex(Unavailable, "ownership"): + read_current_harness_result(redirected / fresh.name, started_ns) + oversized = root / "oversized-result.json" + write_current_fixture(oversized, b"x" * 262145) + with patch("run_renpy_android_emulator_session.os.read", side_effect=AssertionError("Oversized result must not be read")): + with self.assertRaisesRegex(Unavailable, "256KiB"): + read_current_harness_result(oversized, started_ns) + write_current_fixture(fresh, b"malformed JSON") + with self.assertRaisesRegex(Unavailable, "valid bounded JSON"): + read_current_harness_result(fresh, started_ns) + write_current_fixture(fresh, b"[]") + with self.assertRaisesRegex(Unavailable, "JSON object"): + read_current_harness_result(fresh, started_ns) + + def test_current_ordinary_child_result_can_retain_partial_proof_without_gameplay_pass(self): + # This is a local consumer fixture, never evidence of SDK/APK execution. + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + summary = self.milestone_summary(session) + summary["apk_sha256"] = hashlib.sha256(session.args.apk.read_bytes()).hexdigest() + path = session.output / "gameplay/result.json" + original_popen = subprocess.Popen + def ordinary_child(*_, **kwargs): + return original_popen([sys.executable, "-c", + "import sys; from pathlib import Path; Path(sys.argv[1]).write_text(sys.argv[2]); raise SystemExit(1)", + str(path), json.dumps(summary)], stdin=subprocess.DEVNULL, stdout=kwargs["stdout"], + stderr=subprocess.STDOUT, start_new_session=True) + with patch("run_renpy_android_emulator_session.subprocess.Popen", side_effect=ordinary_child), \ + patch("sys.stdout", new_callable=io.StringIO): + with self.assertRaisesRegex(Unavailable, "gameplay did not pass"): + session.gameplay() + self.assertTrue(session.result["apk_installed"]) + self.assertTrue(session.result["process_started"]) + self.assertFalse(session.result["gameplay_verified"]) + self.assertEqual(session.result["gameplay_verification"], "failed") + + def test_real_long_token_counterexample_finishes_inside_the_processing_budget(self): + # Real ordinary Python subprocess, no SDK/device execution. A previous + # unanchored suffix regex exceeded 3s on this <256KiB current-log prefix. + code = ("import json,sys,time; sys.path.insert(0,sys.argv[1]); " + "from run_renpy_android_emulator_session import startup_error_text; " + "rows=['10-09 04:05:00.000 1234 1234 E Tag: '+'A'*3900]*60; " + "start=time.monotonic(); result=startup_error_text(rows,start+2); " + "print(json.dumps({'elapsed':time.monotonic()-start, 'deadline_reached':" + "result.get('diagnostic_deadline_reached',False)}))") + completed = subprocess.run([sys.executable, "-c", code, str(Path(__file__).parent.resolve())], + stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=5, check=True) + result = json.loads(completed.stdout) + self.assertLess(result["elapsed"], 2) + self.assertFalse(result["deadline_reached"]) + with patch("run_renpy_android_emulator_session.time.monotonic", return_value=10): + expired = startup_error_text(["FATAL EXCEPTION: controlled-not-to-be-processed"], deadline=10) + self.assertEqual(expired["output"], "") + self.assertTrue(expired["diagnostic_deadline_reached"]) + # Conservative JWT/URL filtering also avoids a greedy variable-length + # scheme/token regex on the same long identifiers. + self.assertNotIn("eyJ", startup_text(["Caused by: Error: " + "eyJ" * 1300])["output"]) + self.assertNotIn("://", startup_text(["Caused by: Error: " + "a" * 3900 + "://private"])["output"]) + + def session(self, temporary): + apk = Path(temporary) / "controlled-boundary-not-an-apk" + apk.write_bytes(b"controlled data; no APK/device is executed") + args = type("Args", (), {"output_dir": Path(temporary), "port": 5554, "apk": apk, + "cloud_emulator": True, "sdk_root": temporary, + "package": "org.aetherkiri.renpy.debug", "stage_timeout": 90, + "gameplay_timeout": 900})() + session = Session(args) + session.adb = Path("/controlled-not-an-adb") + session.harness_started_ns = time.time_ns() + (session.output / "gameplay").mkdir() + return session + + def write(self, session, name, text): + path = session.output / "gameplay" / name + path.write_text(text) + return path + + def commands(self, session): + prefix = [str(session.adb), "-s", session.result["serial"]] + parts = [["install", "-r", str(session.args.apk.resolve())], + ["shell", "am start -W -n org.aetherkiri.renpy.debug/com.godot.game.GodotAppLauncher"], + ["shell", "pidof org.aetherkiri.renpy.debug"], ["logcat", "-d", "--pid", "1234"]] + rows = [{"command": prefix + part, "returncode": 0, "stderr": "", "time": time.time()} for part in parts] + self.write(session, "commands.jsonl", "\n".join(json.dumps(row) for row in rows) + "\n") + + def diagnose(self, session): + with patch("sys.stdout", new_callable=io.StringIO) as console, \ + patch("run_renpy_android_emulator_session.subprocess.run", side_effect=AssertionError("No device/network call")): + session.startup_failure_diagnostics() + return console.getvalue(), json.loads(console.getvalue()) + + def test_owned_java_first_fatal_and_last_loader_cause_survive_long_stacks(self): + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + self.commands(session) + self.write(session, "install.txt", "Performing Streamed Install\nSuccess\n") + prefix = "10-09 04:05:00.000 1234 1234 E AndroidRuntime: " + frames = [prefix + f"at fixture.Class.method(Class.java:{index})" for index in range(40)] + cause = prefix + "Caused by: java.lang.UnsatisfiedLinkError: dlopen failed: cannot locate symbol 'controlled-missing-symbol'" + self.write(session, "android-process-logcat.txt", "\n".join( + [prefix + "FATAL EXCEPTION: main", *frames, cause, *frames, + prefix + "Caused by: CredentialError: token=controlled-private-token"]) + "\n") + self.write(session, "android-system-logcat.txt", + "10-09 04:05:00.000 9999 9999 E Other: FATAL EXCEPTION: unrelated-private-app\n") + self.write(session, "renpy-traceback.txt", "Traceback (most recent call last):\n" + ' File "controlled-fixture.py", line 1\nModuleNotFoundError: No module named controlled_module\n') + text, payload = self.diagnose(session) + self.assertIn("FATAL EXCEPTION: main", text) + self.assertIn("controlled-missing-symbol", text) + self.assertIn("ModuleNotFoundError", text) + self.assertNotIn("controlled-private-token", text) + self.assertNotIn("unrelated-private-app", text) + self.assertEqual(payload["diagnostic_bound_process_pids"], ["1234"]) + self.assertEqual(payload["process_pid_semantics"], "diagnostic-attribution-only") + self.assertNotIn("observed_process_pids", payload) + self.assertTrue(payload["installation_success_marker"]) + self.assertEqual([row["phase"] for row in payload["commands"]], + ["apk_install", "app_start", "app_pid_observation"]) + self.assertFalse(session.result["apk_installed"], "Diagnostics cannot verify installation/gameplay") + self.assertFalse(session.result["gameplay_verified"]) + + def test_current_am_start_status_and_error_stdout_are_diagnostic_only(self): + # Real-format local stdout fixtures validate extraction, never app launch. + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + self.write(session, "launch.txt", "Starting: Intent { cmp=org.aetherkiri.renpy.debug/Launcher }\n" + "Status: timeout\nLaunchState: COLD\n" + "Error: controlled-activity-start-failure\n" + "Caused by: java.lang.RuntimeException: controlled-start-cause\n" + "Error: token=controlled-private-launch-token\n") + text, payload = self.diagnose(session) + report = payload["files"]["launch.txt"] + self.assertEqual(report["reported_statuses"], ["timeout"]) + self.assertIn("controlled-activity-start-failure", text) + self.assertIn("controlled-start-cause", text) + self.assertNotIn("controlled-private-launch-token", text) + self.assertFalse(session.result["process_started"]) + self.assertFalse(session.result["gameplay_verified"]) + self.write(session, "launch.txt", "Status: ok\nError: controlled-failure-despite-ok\n" + "Status: secret-token://unrecognized\n") + text, payload = self.diagnose(session) + self.assertEqual(payload["files"]["launch.txt"]["reported_statuses"], ["ok", "unrecognized"]) + self.assertIn("controlled-failure-despite-ok", text) + self.assertNotIn("secret-token", text) + self.assertFalse(session.result["process_started"], "Even Status: ok diagnostics cannot prove a process") + self.assertFalse(session.result["apk_installed"]) + self.assertFalse(session.result["gameplay_verified"]) + + def failure_summary(self, session): + summary = self.milestone_summary(session) + digest = hashlib.sha256(session.args.apk.read_bytes()).hexdigest() + summary.update(run_id="a" * 32, apk_sha256=digest, activity=session.args.package + "/Launcher", + process_started=False) + summary["actual_status"]["app_launch"]["status"] = "failed" + summary["failure_diagnostics"] = { + "source": "actual-adb-failure-diagnostic", "diagnostic_only": True, + "run_id": summary["run_id"], "package": session.args.package, "serial": session.result["serial"], + "apk_sha256": digest, "pid_sample": {"status": "available", "returncode": 0, "pids": ["1234"], "pid_count": 1}, + "activity_sample": {"status": "available", "returncode": 0, "exact_component": summary["activity"], + "states": [{"state": "INITIALIZING", "allDrawn": False, "private": "private-token"}]}} + session.harness_apk_sha256 = digest + session.result["gameplay_result"] = summary + session.retain_partial_milestones(summary, digest) + return summary + + def test_current_matched_failure_pid_can_select_causes_without_launch_or_gameplay_success(self): + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + self.failure_summary(session) + prefix = "10-09 07:00:00.000 1234 1234 E AndroidRuntime: " + path = self.write(session, "android-process-logcat.txt", prefix + "FATAL EXCEPTION: main\n" + prefix + + "Caused by: java.lang.UnsatisfiedLinkError: controlled-current-loader-cause\n") + os.utime(path, ns=(session.harness_started_ns + 1_000_000,) * 2) + text, payload = self.diagnose(session) + self.assertEqual(payload["failure_snapshot"]["diagnostic_pids"], ["1234"]) + self.assertEqual(payload["failure_snapshot"]["activity_states"], [{"state": "INITIALIZING", "allDrawn": False}]) + self.assertIn("controlled-current-loader-cause", text) + self.assertNotIn("private-token", text) + self.assertTrue(session.result["apk_installed"]) + self.assertFalse(session.result["process_started"]) + self.assertFalse(session.result["gameplay_verified"]) + self.assertLessEqual(len(text.encode()), 6144) + + def test_current_failure_pid_known_startup_phases_are_bounded_and_never_launch_proof(self): + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + self.failure_summary(session) + prefix = "10-09 08:12:00.000 1234 1234 " + process = self.write(session, "android-process-logcat.txt", + prefix + "V GodotActivity: Creating new Godot fragment instance.\n" + + prefix + "I Godot: Initializing Godot plugin registry\n" + + prefix + "D Godot: Godot native layer initialization completed: false\n" + + prefix + "I AetherKiriBridge: event=initialize_engine_begin writable=/data/private-path cache=/data/private-cache\n" + + prefix + "I AetherKiriBridge: event=open_game_begin path=/data/private-game async=1 runtime=renpy\n") + engine = self.write(session, "aetherkiri-engine.log", + "[2026-10-09] [info] aetherkiri provider engine log attached: /data/private-game/aetherkiri-engine.log\n") + for path in (process, engine): + os.utime(path, ns=(session.harness_started_ns + 1_000_000,) * 2) + text, payload = self.diagnose(session) + progress = payload["startup_progress"] + self.assertEqual(progress["status"], "diagnostic_available") + self.assertTrue(progress["diagnostic_only"]) + self.assertEqual(progress["source"], "actual-current-pid-startup-diagnostic") + self.assertIn("godot-fragment-create", progress["output"]) + self.assertIn("godot-native-initialization-result-false", progress["output"]) + self.assertIn("provider-engine-log-attached", progress["output"]) + self.assertNotIn("/data/private", text) + self.assertLessEqual(len(progress["output"]), 768) + self.assertLessEqual(len(progress["output"].splitlines()), 6) + self.assertLessEqual(len(text.encode()), 6144) + self.assertFalse(session.result["process_started"] or session.result["gameplay_verified"]) + + def test_startup_progress_rejects_foreign_secret_unmatched_and_expired_sources(self): + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + self.failure_summary(session) + prefix = "10-09 08:12:00.000 1234 1234 I " + path = self.write(session, "android-process-logcat.txt", + "10-09 08:12:00.000 9999 9999 I Godot: Godot native layer setup completed\n" + + prefix + "Other: Godot native layer setup completed\n" + + prefix + "Godot: arbitrary unrecognized public log\n" + + prefix + "AetherKiriBridge: event=open_game_begin token=controlled-private-token\n" + + prefix + "Godot: export CONTROLLED_PRIVATE_ENV=controlled-value\n") + os.utime(path, ns=(session.harness_started_ns + 1_000_000,) * 2) + text, payload = self.diagnose(session) + self.assertEqual(payload["startup_progress"]["status"], "diagnostic_notavailable") + self.assertNotIn("controlled-private-token", text) + self.assertNotIn("controlled-value", text) + path = self.write(session, "android-process-logcat.txt", + (prefix + "Godot: Godot native layer setup completed\n") * 20) + os.utime(path, ns=(session.harness_started_ns + 1_000_000,) * 2) + _, payload = self.diagnose(session) + self.assertTrue(payload["startup_progress"]["truncated"]) + self.assertEqual(len(payload["startup_progress"]["output"].splitlines()), 6) + self.assertFalse(session.result["process_started"] or session.result["gameplay_verified"]) + session.result["gameplay_result"]["failure_diagnostics"]["run_id"] = "b" * 32 + _, payload = self.diagnose(session) + self.assertEqual(payload["startup_progress"]["status"], "diagnostic_notavailable") + with patch("run_renpy_android_emulator_session.time.monotonic", return_value=10): + from run_renpy_android_emulator_session import startup_progress_text + expired = startup_progress_text({"android-process-logcat.txt": + [prefix + "Godot: Godot native layer setup completed"]}, ["1234"], deadline=10) + self.assertTrue(expired["diagnostic_deadline_reached"]) + self.assertEqual(expired["output"], "") + + def test_wrong_tuple_success_or_unproven_install_cannot_bind_diagnostic_pids(self): + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + baseline = self.failure_summary(session) + for key in ("run_id", "source", "package", "serial", "apk_sha256", "diagnostic_only"): + wrong = copy.deepcopy(baseline) + wrong["failure_diagnostics"][key] = False if key == "diagnostic_only" else "different-controlled-value" + session.result["gameplay_result"] = wrong + self.assertEqual(session.current_failure_snapshot()["status"], "diagnostic_notavailable", key) + for changes in ({"status": "passed"}, {"apk_installed": False}): + session.result["gameplay_result"] = {**baseline, **changes} + self.assertEqual(session.current_failure_snapshot()["status"], "diagnostic_notavailable") + session.result["gameplay_result"] = copy.deepcopy(baseline) + for changes in ({"returncode": False}, {"pids": ["9999999999"]}, {"pids": ["1234", "1234"]}, {"pid_count": True}): + session.result["gameplay_result"]["failure_diagnostics"]["pid_sample"] = { + **baseline["failure_diagnostics"]["pid_sample"], **changes} + self.assertNotIn("diagnostic_pids", session.current_failure_snapshot()) + session.result["actual_status"]["apk_install"]["stdout_marker"] = "NotSuccess" + self.assertEqual(session.current_failure_snapshot()["status"], "diagnostic_notavailable") + self.assertFalse(session.result["process_started"] or session.result["gameplay_verified"]) + + def test_crash_header_requires_exact_package_tag_and_equal_header_body_pid(self): + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + for package, body_pid, tag, bound in ((session.args.package, "1234", "AndroidRuntime", True), + (session.args.package, "4321", "AndroidRuntime", False), + (session.args.package + ".other", "1234", "AndroidRuntime", False), + (session.args.package, "1234", "OtherRuntime", False)): + prefix = "10-09 07:00:00.000 1234 1234 E " + tag + ": " + path = self.write(session, "android-system-logcat.txt", prefix + "FATAL EXCEPTION: main\n" + prefix + + f"Process: {package}, PID: {body_pid}\n" + prefix + + "Caused by: java.lang.UnsatisfiedLinkError: controlled-exact-crash-cause\n") + os.utime(path, ns=(session.harness_started_ns + 1_000_000,) * 2) + text, payload = self.diagnose(session) + self.assertEqual("controlled-exact-crash-cause" in text, bound) + self.assertEqual(payload["diagnostic_bound_process_pids"], ["1234"] if bound else []) + self.assertEqual(payload["process_pid_semantics"], "diagnostic-attribution-only") + self.assertFalse(session.result["process_started"] or session.result["gameplay_verified"]) + + def test_console_metadata_overflow_terminates_without_output_candidates(self): + # Controlled schema regression: no text candidates can be trimmed. + # The production fallback must terminate instead of looping forever. + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + metadata = {"status": "diagnostic_notavailable", "reason": "controlled-large-metadata" * 400} + with patch("run_renpy_android_emulator_session.read_startup_lines", return_value=(metadata, [])): + text, payload = self.diagnose(session) + self.assertEqual(payload["reason"], "console_size_limit") + self.assertEqual(payload["status"], "diagnostic_notavailable") + self.assertEqual(payload["commands"], []) + self.assertEqual(payload["files"], {}) + self.assertLessEqual(len(text.encode()), 6144) + self.assertFalse(session.result["process_started"] or session.result["gameplay_verified"]) + + def test_launch_stdout_is_fresh_regular_and_prefix_bounded_without_reading_private_eof(self): + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + path = self.write(session, "launch.txt", "Status: ok\n") + os.utime(path, ns=(0, session.harness_started_ns - 1)) + _, payload = self.diagnose(session) + self.assertEqual(payload["files"]["launch.txt"]["reason"], "not_fresh_regular_file") + self.assertEqual(payload["files"]["launch.txt"]["reported_statuses"], []) + path.unlink() + target = Path(temporary) / "other-unowned-launch-stdout" + target.write_text("Error: controlled-private-redirect\n") + path.symlink_to(target) + text, payload = self.diagnose(session) + self.assertEqual(payload["files"]["launch.txt"]["status"], "diagnostic_notavailable") + self.assertNotIn("controlled-private-redirect", text) + path.unlink() + os.mkfifo(path) + _, payload = self.diagnose(session) + self.assertEqual(payload["files"]["launch.txt"]["reason"], "not_fresh_regular_file") + path.unlink() + path.write_text("Status: timeout\nError: controlled-prefix-launch-cause\n" + "x" * 10000 + + "\nError: controlled-EOF-must-not-be-read\n") + text, payload = self.diagnose(session) + self.assertEqual(payload["files"]["launch.txt"]["bytes_read"], 4096) + self.assertTrue(payload["files"]["launch.txt"]["truncated"]) + self.assertEqual(payload["files"]["launch.txt"]["reported_statuses"], ["timeout"]) + self.assertIn("controlled-prefix-launch-cause", text) + self.assertNotIn("controlled-EOF", text) + self.assertLessEqual(len(text.encode()), 6144) + self.assertFalse(session.result["gameplay_verified"]) + + def test_environment_credentials_control_sequences_and_urls_do_not_escape(self): + raw = ["\x1b[31mError: PATH=/controlled-private-environment\x1b[0m\n" + "Environment: controlled-private-env\nAuthorization: Bearer controlled-auth\n" + "Error: https://example.invalid/controlled-private-url\n" + "Error: secret=controlled-private-secret\nError: safe-controlled-cause\x00"] + output = startup_text(raw)["output"] + for value in ("private-", "controlled-auth", "PATH=", "example.invalid", "\x1b", "\x00"): + self.assertNotIn(value, output) + self.assertIn("safe-controlled-cause", output) + + def test_stale_symlink_fifo_missing_and_unowned_files_are_not_read(self): + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + stale = self.write(session, "install.txt", "Success\n") + os.utime(stale, ns=(0, session.harness_started_ns - 1)) + private = Path(temporary) / "unowned-controlled-private-file" + private.write_text("Fatal: controlled-private-content\n") + (session.output / "gameplay/aetherkiri-engine.log").symlink_to(private) + os.mkfifo(session.output / "gameplay/renpy-log.txt") + self.write(session, "unknown-private-file", "Fatal: controlled-private-content\n") + text, payload = self.diagnose(session) + self.assertEqual(payload["status"], "diagnostic_notavailable") + self.assertNotIn("controlled-private-content", text) + self.assertFalse(payload["installation_success_marker"]) + self.assertEqual(payload["files"]["install.txt"]["reason"], "not_fresh_regular_file") + self.assertEqual(payload["files"]["renpy-log.txt"]["reason"], "not_fresh_regular_file") + self.assertNotIn("unknown-private-file", payload["files"]) + + def test_prefix_read_and_combined_console_metadata_are_bounded(self): + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + for name in ("aetherkiri-engine.log", "renpy-traceback.txt", "renpy-log.txt", + "android-process-logcat.txt", "android-system-logcat.txt"): + self.write(session, name, "Exception: " + "模" * 200 + "\n" + ("controlled padding\n" * 40000)) + text, payload = self.diagnose(session) + self.assertLessEqual(len(text.encode()), 6144) + self.assertLess(len(json.dumps(session.result["startup_diagnostics"]).encode()), 1024) + self.assertLess(len(text.encode()) + len(json.dumps(session.result["startup_diagnostics"]).encode()), 8192) + self.assertLess(sum(row.get("bytes_read", 0) for row in payload["files"].values()), 2 * 1024 * 1024) + self.assertTrue(payload["files"]["aetherkiri-engine.log"]["truncated"]) + path = self.write(session, "renpy-log.txt", "Exception: first-prefix-cause\n" + "x" * 300000 + + "\nException: controlled-EOF-must-not-be-read\n") + metadata, lines = read_startup_lines(path, session.harness_started_ns, time.monotonic() + 2) + self.assertLessEqual(metadata["bytes_read"], 262144) + self.assertNotIn("controlled-EOF", "\n".join(lines)) + + def test_diagnostic_deadline_and_no_harness_never_call_any_device(self): + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + self.write(session, "install.txt", "Success\n") + with patch("run_renpy_android_emulator_session.time.monotonic", return_value=10), \ + patch("run_renpy_android_emulator_session.os.open", side_effect=AssertionError("No read after deadline")): + metadata, lines = read_startup_lines(session.output / "gameplay/install.txt", session.harness_started_ns, 10) + self.assertEqual(metadata["reason"], "diagnostic_deadline") + self.assertEqual(lines, []) + session.harness_started_ns = None + _, payload = self.diagnose(session) + self.assertEqual(payload["status"], "diagnostic_notavailable") + + def milestone_summary(self, session): + common = {"status": "passed", "source": "actual-adb-command", "package": session.args.package, + "serial": session.result["serial"], "returncode": 0} + return {"status": "failed", "source": "actual-apk-adb-cloud-device", "package": session.args.package, + "serial": session.result["serial"], "apk_sha256": "controlled-hash", + "apk_installed": True, "process_started": True, + "actual_status": {"apk_install": {**common, "stdout_marker": "Success"}, + "app_launch": {**common, "stdout_marker": "Status: ok", "pids": ["1234"], + "pid_count": 1, "activity": session.args.package + "/Launcher"}}} + + def test_matched_partial_milestones_remain_separate_from_gameplay_acceptance(self): + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + summary = self.milestone_summary(session) + session.retain_partial_milestones(summary, "controlled-hash") + self.assertTrue(session.result["apk_installed"]) + self.assertTrue(session.result["process_started"]) + self.assertFalse(session.result["gameplay_verified"]) + self.assertEqual(session.result["gameplay_verification"], "not_run") + self.assertFalse(gameplay_result_matches(summary, 1, session.args.package, + session.result["serial"], "controlled-hash")) + + def test_wrong_identity_or_unproven_stages_cannot_mirror_milestones(self): + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + original = self.milestone_summary(session) + for field in ("source", "package", "serial", "apk_sha256"): + wrong = copy.deepcopy(original) + wrong[field] = "different-controlled-input" + session.retain_partial_milestones(wrong, "controlled-hash") + self.assertFalse(session.result["apk_installed"], field) + self.assertFalse(session.result["process_started"], field) + for key, value in (("source", "simulated"), ("returncode", True), ("returncode", 0.0), + ("stdout_marker", "Success-looking"), ("package", "other.package")): + wrong = copy.deepcopy(original) + wrong["actual_status"]["apk_install"][key] = value + session.retain_partial_milestones(wrong, "controlled-hash") + self.assertFalse(session.result["apk_installed"], key) + self.assertFalse(session.result["process_started"], key) + for pids in (["0"], ["-1"], ["9999999999"], ["2147483648"], [1234]): + wrong = copy.deepcopy(original) + wrong["actual_status"]["app_launch"]["pids"] = pids + session.retain_partial_milestones(wrong, "controlled-hash") + self.assertFalse(session.result["process_started"], pids) + + def test_diagnostics_precede_stop_and_cannot_mask_the_real_primary_gameplay_failure(self): + # Actual local evidence/control flow only; no SDK, APK or emulator is run. + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + events = [] + with patch.object(session, "gate_host", return_value=Path("controlled-emulator")), \ + patch.object(session, "prepare_sdk", return_value=Path("controlled-manager")), \ + patch.object(session, "boot"), \ + patch.object(session, "gameplay", side_effect=Unavailable("controlled original startup failure")), \ + patch.object(session, "startup_failure_diagnostics", side_effect=lambda: events.append("diagnostics")), \ + patch.object(session, "stop", side_effect=lambda **_: events.append("stop")): + with self.assertRaisesRegex(Unavailable, "controlled original startup failure"): + session.run() + self.assertEqual(events, ["diagnostics", "stop"]) + + +class CurrentResourceDiagnosticTests(unittest.TestCase): + def session(self, temporary): + args = type("Args", (), {"output_dir": Path(temporary), "port": 5554, "apk": None, + "cloud_emulator": True, "sdk_root": temporary})() + session = Session(args) + session.adb = Path("boundary-adb-not-a-device") + session.boot_started = 0 + session.emulator_process = type("BoundaryProcess", (), {"poll": lambda _: None})() + session.avd_started_ns = time.time_ns() + session.avd_directory = Path(temporary) / "owned.avd" + session.avd_directory.mkdir() + return session + + def test_fresh_owned_avd_only_reports_numeric_and_explicit_enum_fields(self): + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + config = session.avd_directory / "config.ini" + config.write_text("hw.cpu.ncore=2\nhw.ramSize=3072\nhw.lcd.width=1080\n" + "hw.lcd.height=1920\nhw.lcd.density=420\nhw.gpu.enabled=yes\n" + "hw.gpu.mode=swiftshader\nhw.keyboard=yes\nhw.keyboard=no\n" + "image.sysdir.1=/private/secret-token-sdk\nuuid=private-uuid\n") + report = read_avd_resources(session.avd_directory, "config.ini", session.avd_started_ns, time.monotonic() + 1) + self.assertEqual(report["status"], "available") + self.assertEqual(report["values"]["hw.cpu.ncore"], 2) + self.assertEqual(report["values"]["hw.keyboard"], "no") + self.assertFalse(report["unknown"]) + self.assertNotIn("private", json.dumps(report)) + self.assertNotIn("token", json.dumps(report)) + with config.open("a") as stream: + stream.write("hw.cpu.ncore=4097\nhw.gpu.mode=secret-token://arbitrary\n") + report = read_avd_resources(session.avd_directory, "config.ini", session.avd_started_ns, time.monotonic() + 1) + self.assertNotIn("hw.cpu.ncore", report["values"]) + self.assertNotIn("hw.gpu.mode", report["values"]) + self.assertEqual(report["unknown"]["hw.cpu.ncore"], "invalid_value") + self.assertNotIn("secret", json.dumps(report)) + + def test_stale_redirected_nonregular_and_oversize_avd_files_remain_unknown(self): + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + config = session.avd_directory / "config.ini" + config.write_text("hw.cpu.ncore=2\n") + os.utime(config, ns=(0, 0)) + self.assertEqual(read_avd_resources(session.avd_directory, "config.ini", session.avd_started_ns, + time.monotonic() + 1)["reason"], "not_fresh_regular_file") + config.unlink() + target = Path(temporary) / "other-current-file" + target.write_text("hw.cpu.ncore=4\n") + config.symlink_to(target) + self.assertEqual(read_avd_resources(session.avd_directory, "config.ini", session.avd_started_ns, + time.monotonic() + 1)["status"], "diagnostic_notavailable") + config.unlink() + os.mkfifo(config) + started = time.monotonic() + self.assertEqual(read_avd_resources(session.avd_directory, "config.ini", session.avd_started_ns, + started + 1)["reason"], "not_fresh_regular_file") + self.assertLess(time.monotonic() - started, 1) + config.unlink() + config.write_bytes(b"hw.cpu.ncore=4\n" + b"A" * 65536) + self.assertEqual(read_avd_resources(session.avd_directory, "config.ini", session.avd_started_ns, + time.monotonic() + 1)["reason"], "file_exceeds_read_limit") + redirected = Path(temporary) / "redirected.avd" + redirected.symlink_to(session.avd_directory, target_is_directory=True) + self.assertEqual(read_avd_resources(redirected, "config.ini", session.avd_started_ns, + time.monotonic() + 1)["status"], "diagnostic_notavailable") + with patch("run_renpy_android_emulator_session.os.open", side_effect=AssertionError("No read after deadline")): + self.assertEqual(read_avd_resources(session.avd_directory, "config.ini", session.avd_started_ns, + time.monotonic() - 1)["reason"], "diagnostic_deadline") + + def test_cpu_and_memory_parsers_reject_unbounded_ranges_and_keep_missing_values_unknown(self): + self.assertEqual(cpu_resource_ranges(b"0-3,6\n")["ranges"], [[0, 3], [6, 6]]) + self.assertEqual(cpu_resource_ranges(b"0-3,6\n")["count"], 5) + for value in (b"", b"0-9999", b"3-1", b"0-3,2", b"4,1", b"0-999999999999", b"secret-token", b"0," * 200): + self.assertEqual(cpu_resource_ranges(value)["status"], "diagnostic_notavailable") + report = memory_resource_values(b"MemTotal: 3145728 kB\nMemAvailable: 65536 kB\nPrivateEnv: secret-token\n") + self.assertEqual(report["values_kib"], {"MemTotal": 3145728, "MemAvailable": 65536}) + self.assertNotIn("secret", json.dumps(report)) + report = memory_resource_values(b"MemTotal: 3145728 kB\nMemTotal: 42 kB\nMemAvailable: token\n") + self.assertEqual(report["values_kib"], {}) + self.assertEqual(report["unknown"], {"MemTotal": "duplicate_value", "MemAvailable": "invalid_value"}) + self.assertEqual(memory_resource_values(b"")["unknown"], {"MemTotal": "unknown", "MemAvailable": "unknown"}) + + def test_actual_ordinary_resource_child_timeout_is_bounded_and_does_not_publish_partial_text(self): + # A real ordinary subprocess validates timeout handling, not Android/HVF. + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + started = time.monotonic() + report, raw = session.resource_command([sys.executable, "-c", + "import time; print('secret-token', flush=True); time.sleep(10)"], started + 0.2) + self.assertLess(time.monotonic() - started, 1) + self.assertEqual(report["reason"], "command_timeout") + self.assertEqual(raw, b"") + self.assertNotIn("secret", json.dumps(report)) + with patch("run_renpy_android_emulator_session.subprocess.run", side_effect=AssertionError("No command after deadline")): + self.assertEqual(session.resource_command(["not-executed"], time.monotonic() - 1)[0]["reason"], + "diagnostic_deadline") + + def test_old_and_new_boot_diagnostics_share_one_absolute_twenty_second_budget(self): + # Controlled clock and command responses test orchestration, not a guest. + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + clock, calls = {"now": 600.0}, [] + def bounded_response(command, **kwargs): + self.assertLess(clock["now"], 620) + self.assertLessEqual(kwargs["timeout"], 620 - clock["now"]) + calls.append((command, kwargs["timeout"])) + clock["now"] += kwargs["timeout"] + output = b"4\n2\n8589934592\n" if command[0] == "/usr/sbin/sysctl" else b"" + return subprocess.CompletedProcess(command, 0, output) + with patch("run_renpy_android_emulator_session.platform.system", return_value="Darwin"), \ + patch("run_renpy_android_emulator_session.time.monotonic", side_effect=lambda: clock["now"]), \ + patch("run_renpy_android_emulator_session.subprocess.run", side_effect=bounded_response), \ + patch("sys.stdout", new_callable=io.StringIO) as console: + session.boot_failure_diagnostics() + report = json.loads(console.getvalue()) + self.assertEqual(clock["now"], 620) + self.assertEqual(report["diagnostic_elapsed_seconds"], 20) + self.assertTrue(report["diagnostic_deadline_reached"]) + self.assertEqual(report["logcat_warning_tail"]["reason"], "diagnostic_deadline") + self.assertEqual(report["resources"]["host"]["values"]["hw.logicalcpu"], 4) + self.assertFalse(session.result["gameplay_verified"]) + self.assertFalse(session.result["harness_started"]) + self.assertEqual(session.boot_started, 0) + self.assertTrue(all(command[0] in ("/usr/sbin/sysctl", str(session.adb)) for command, _ in calls)) + + def test_resource_command_errors_and_missing_avd_never_become_measured_defaults(self): + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + with patch("run_renpy_android_emulator_session.platform.system", return_value="Darwin"), \ + patch("run_renpy_android_emulator_session.subprocess.run", + return_value=subprocess.CompletedProcess(["boundary"], 1, b"secret-token://irrelevant")): + report = session.resource_diagnostics(time.monotonic() + 1) + self.assertEqual(report["host"]["reason"], "command_failed") + self.assertTrue(all(row["reason"] == "command_failed" for row in report["guest"].values())) + self.assertTrue(all(row["status"] == "diagnostic_notavailable" for row in report["avd"].values())) + self.assertNotIn("secret", json.dumps(report)) + self.assertNotIn("values", report["host"]) + + def test_entire_current_boot_console_is_eight_kibibytes_and_resources_are_selected_only(self): + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + (session.output / "emulator.log").write_text(("Visible kernel state " + "\u2603" * 500 + "\n") * 40) + (session.avd_directory / "config.ini").write_text("hw.cpu.ncore=1\npath=secret-token://private\n") + def responses(command, **kwargs): + if command[0] == "/usr/sbin/sysctl": + output = b"4\n2\n8589934592\n" + elif command[-1] in ("/sys/devices/system/cpu/online", "/sys/devices/system/cpu/present"): + output = b"0\n" + elif command[-1] == "/proc/meminfo": + output = b"MemTotal: 3145728 kB\nMemAvailable: 123456 kB\nSecret: token\n" + else: + output = (("Visible record " + "\u2603" * 500 + "\n") * 40).encode() + return subprocess.CompletedProcess(command, 0, output) + with patch("run_renpy_android_emulator_session.platform.system", return_value="Darwin"), \ + patch("run_renpy_android_emulator_session.subprocess.run", side_effect=responses), \ + patch("sys.stdout", new_callable=io.StringIO) as console: + session.boot_failure_diagnostics() + self.assertLessEqual(len(console.getvalue().encode()), 8192) + report = json.loads(console.getvalue()) + self.assertEqual(report["resources"]["guest"]["online"]["count"], 1) + self.assertEqual(report["resources"]["guest"]["memory"]["values_kib"]["MemAvailable"], 123456) + self.assertNotIn("token", console.getvalue()) + self.assertNotIn("private", console.getvalue()) + self.assertFalse(session.result["gameplay_verified"]) + + +class CurrentDiagnosticBoundaryTests(unittest.TestCase): + def test_sensitive_lines_are_removed_and_current_kernel_failure_is_retained(self): + raw = ("\x1b[31mKernel panic: fixture boot evidence\x1b[0m\n" + "Authorization: Bearer private-auth-value\naccess_token=private-token-value\n" + "credential=private-credential-value\ngrpc server key=private-grpc-value\n" + "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJzZWNyZXQifQ.privateJwtSignature\n" + "Download https://example.invalid/private-url-value\n" + "SystemServer: fixture service failed\n") + result = bounded_diagnostic(raw) + self.assertEqual(result["redacted_lines"], 6) + self.assertIn("Kernel panic: fixture boot evidence", result["output"]) + self.assertIn("SystemServer: fixture service failed", result["output"]) + self.assertNotIn("\x1b", result["output"]) + for secret in ("private-", "eyJ", "example.invalid", "Bearer"): + self.assertNotIn(secret, result["output"]) + + def test_tail_limits_do_not_reveal_a_sensitive_line_with_its_header_cut_off(self): + with tempfile.TemporaryDirectory() as temporary: + log = Path(temporary) / "only-current-fixture.log" + log.write_bytes(b"Authorization: " + b"fixture-private-secret" * 5000 + b"\nKernel panic: fixture\n") + result = diagnostic_file_tail(log, read_limit=1024) + self.assertTrue(result["truncated"]) + self.assertEqual(result["output"], "Kernel panic: fixture") + limited = bounded_diagnostic("\n".join(str(i) + "x" * 800 for i in range(100)), + max_chars=300, max_lines=3) + self.assertLessEqual(len(limited["output"]), 300) + self.assertLessEqual(len(limited["output"].splitlines()), 3) + self.assertTrue(limited["truncated"]) + + def test_real_ordinary_diagnostic_child_is_bounded_and_its_partial_output_redacted(self): + # A real ordinary executable validates timeout/capture, not an Android SDK. + with tempfile.TemporaryDirectory() as temporary: + fixture = Path(temporary) / "controlled-diagnostic-process" + fixture.write_text(f"#!{sys.executable}\nimport time\n" + "print('token=fixture-private-value', flush=True)\n" + "print('fixture-child-ready', flush=True)\ntime.sleep(60)\n") + fixture.chmod(0o700) + session = object.__new__(Session) + session.adb = fixture + session.environment = dict(os.environ) + session.result = {"serial": "emulator-5554"} + started = time.monotonic() + result = session.diagnostic_command("get-state", timeout=1) + self.assertLess(time.monotonic() - started, 4) + self.assertTrue(result["timed_out"]) + self.assertIn("fixture-child-ready", result["output"]) + self.assertNotIn("fixture-private-value", result["output"]) + self.assertEqual(list(Path(temporary).iterdir()), [fixture], "No raw diagnostic log may be created") + + def test_progress_is_throttled_and_last_real_command_response_is_bounded(self): + # Input/timing boundary only; no emulator is run. + session = object.__new__(Session) + session.boot_started, session.next_boot_progress = 100, 0 + session.result = {"serial": "emulator-5554"} + process = subprocess.CompletedProcess(["fixture"], 0, b"fixture-response") + with patch.object(session, "emulator_process", create=True) as emulator, \ + patch("run_renpy_android_emulator_session.time.monotonic", + side_effect=[100, 101, 129, 130, 159, 160]), patch("sys.stdout", new_callable=io.StringIO) as console: + emulator.poll.return_value = None + for _ in range(6): + session.boot_progress("fixture-progress", response=process) + rows = [json.loads(line) for line in console.getvalue().splitlines()] + self.assertEqual([row["elapsed_seconds"] for row in rows], [0, 30, 60]) + self.assertEqual(session.result["last_boot_response"]["output"], "fixture-response") + self.assertTrue(all(row["gameplay_verification"] == "not_run" for row in rows)) + + def test_boot_failure_diagnostics_precede_stop_and_do_not_mask_original_failure(self): + # Control-flow boundary only; no SDK, AVD or device command is executed. + with tempfile.TemporaryDirectory() as temporary: + args = type("Args", (), {"output_dir": Path(temporary), "port": 5554, "apk": None, + "cloud_emulator": True, "sdk_root": temporary})() + session = Session(args) + events = [] + def fail_boot(*_): + session.execution_started = True + raise Unavailable("fixture original boot failure") + def fail_diagnostics(): + events.append("diagnostics-before-stop") + raise OSError("token=fixture-secret") + with patch.object(session, "gate_host", return_value=Path("fixture-emulator")), \ + patch.object(session, "prepare_sdk", return_value=Path("fixture-manager")), \ + patch.object(session, "boot", side_effect=fail_boot), \ + patch.object(session, "boot_failure_diagnostics", side_effect=fail_diagnostics), \ + patch.object(session, "stop", side_effect=lambda **_: events.append("stop")): + with self.assertRaisesRegex(Unavailable, "fixture original boot failure"): + session.run() + self.assertEqual(events, ["diagnostics-before-stop", "stop"]) + self.assertTrue(session.result["boot_failed"]) + self.assertEqual(session.result["gameplay_verification"], "not_run") + self.assertNotIn("fixture-secret", session.result["boot_failure_diagnostic_error"]["output"]) + + def test_boot_failure_and_cleanup_denial_are_preserved_separately(self): + with tempfile.TemporaryDirectory() as temporary: + args = type("Args", (), {"output_dir": Path(temporary), "port": 5554, "apk": None, + "cloud_emulator": True, "sdk_root": temporary})() + session = Session(args) + events = [] + def fail_boot(*_): + session.execution_started = True + raise Unavailable("fixture original boot failure") + def fail_cleanup(**_): + events.append("stop") + raise PermissionError("fixture cleanup permission denied") + with patch.object(session, "gate_host", return_value=Path("fixture-emulator")), \ + patch.object(session, "prepare_sdk", return_value=Path("fixture-manager")), \ + patch.object(session, "boot", side_effect=fail_boot), \ + patch.object(session, "boot_failure_diagnostics", side_effect=lambda: events.append("diagnostics")), \ + patch.object(session, "stop", side_effect=fail_cleanup): + with self.assertRaisesRegex(Unavailable, "fixture original boot failure"): + session.run() + self.assertEqual(events, ["diagnostics", "stop"]) + self.assertEqual(session.result["boot_failure"]["output"], "fixture original boot failure") + self.assertEqual(session.result["cleanup_error"]["output"], "fixture cleanup permission denied") + self.assertEqual(session.result["status"], "failed") + self.assertEqual(session.result["gameplay_verification"], "not_run") + + def test_cleanup_failure_without_primary_still_fails_the_session(self): + with tempfile.TemporaryDirectory() as temporary: + args = type("Args", (), {"output_dir": Path(temporary), "port": 5554, "apk": None, + "cloud_emulator": True, "sdk_root": temporary})() + session = Session(args) + with patch.object(session, "gate_host", return_value=Path("fixture-emulator")), \ + patch.object(session, "prepare_sdk", return_value=Path("fixture-manager")), \ + patch.object(session, "boot"), \ + patch.object(session, "stop", side_effect=[None, PermissionError("fixture cleanup permission denied")]): + with self.assertRaisesRegex(PermissionError, "fixture cleanup permission denied"): + session.run() + self.assertEqual(session.result["status"], "failed") + self.assertEqual(session.result["cleanup_error"]["output"], "fixture cleanup permission denied") + + +class RealProcessCleanupTests(unittest.TestCase): + def start(self, ignore_term=False): + code = "import signal,time; " + if ignore_term: + code += "signal.signal(signal.SIGTERM, signal.SIG_IGN); " + code += "print('ready', flush=True); time.sleep(120)" + process = subprocess.Popen([sys.executable, "-u", "-c", code], stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, start_new_session=True) + self.addCleanup(self.cleanup, process) + with selectors.DefaultSelector() as selector: + selector.register(process.stdout, selectors.EVENT_READ) + self.assertTrue(selector.select(timeout=5), "Real child did not become ready") + self.assertEqual(process.stdout.readline().strip(), b"ready") + return process + + @staticmethod + def cleanup(process): + if process.poll() is None or owned_group_exists(process): + terminate_owned_process(process, grace=0.1) + if process.stdout: + process.stdout.close() + + def assert_reaped(self, process): + self.assertIsNotNone(process.poll()) + with self.assertRaises(ProcessLookupError): + os.kill(process.pid, 0) + + def test_term_stops_and_reaps_an_actual_owned_child(self): + process = self.start() + self.assertEqual(terminate_owned_process(process, grace=2), -signal.SIGTERM) + self.assert_reaped(process) + + def test_term_ignoring_actual_child_is_killed_after_deadline_and_reaped(self): + process = self.start(ignore_term=True) + self.assertEqual(terminate_owned_process(process, grace=0.1), -signal.SIGKILL) + self.assert_reaped(process) + + def start_with_grandchild(self, exit_leader=False): + temporary = tempfile.TemporaryDirectory(prefix="owned-process-grandchild-") + self.addCleanup(temporary.cleanup) + pid_file = Path(temporary.name) / "child.pid" + child_code = ("import os,signal,sys,time; from pathlib import Path; " + "signal.signal(signal.SIGTERM, signal.SIG_IGN); " + "Path(sys.argv[1]).write_text(str(os.getpid())); time.sleep(120)") + leader_code = ("import subprocess,sys,time; from pathlib import Path; " + f"child=subprocess.Popen([sys.executable,'-c',{child_code!r},{str(pid_file)!r}]); " + "deadline=time.monotonic()+5\n" + f"while not Path({str(pid_file)!r}).exists() and time.monotonic()&2 + exit 1 +fi +[[ -f "$jni_header" ]] || { echo "JNI header missing: $jni_header" >&2; exit 1; } +mkdir -p "$work/include/android" +cp "$jni_header" "$work/include/jni.h" +if [[ -f "$(dirname "$jni_header")/jni_md.h" ]]; then + cp "$(dirname "$jni_header")/jni_md.h" "$work/include/" +elif [[ -f "$(dirname "$jni_header")/linux/jni_md.h" ]]; then + cp "$(dirname "$jni_header")/linux/jni_md.h" "$work/include/" +fi +# Only the host-executable fixture needs these three unused native-window +# declarations. True Android compilation must separately use the NDK sysroot. +cat > "$work/include/android/native_window.h" <<'HEADER' +#pragma once +#include +struct ANativeWindow; +extern "C" int32_t ANativeWindow_getWidth(ANativeWindow*); +extern "C" int32_t ANativeWindow_getHeight(ANativeWindow*); +extern "C" void ANativeWindow_release(ANativeWindow*); +HEADER +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror -D__ANDROID__ \ + -I"$work/include" -I"$repo_root/bridge/renpy_runtime/include" \ + -I"$repo_root/bridge/renpy_runtime/mobile_launcher/include" -I"$repo_root/abi/include" \ + "$repo_root/bridge/renpy_runtime/tests/android_host_binding.cpp" \ + "$repo_root/bridge/renpy_runtime/src/renpy_mobile_adapter.cpp" \ + "$repo_root/bridge/renpy_runtime/src/renpy_runtime_mobile_provider.cpp" \ + -ldl -pthread -o "$work/host-test" +"$work/host-test" +printf '%s\n' 'RenPy Android JNI/provider binding and project detection protocol passed (not device startup)' diff --git a/tools/test_renpy_demo_fixture.sh b/tools/test_renpy_demo_fixture.sh new file mode 100644 index 000000000..7ffe76f41 --- /dev/null +++ b/tools/test_renpy_demo_fixture.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Structural and optional SDK smoke test for the real Ren'Py demo project. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +fixture="${AETHERKIRI_RENPY_DEMO_FIXTURE:-$repo_root/demos/aetherkiri-renpy}" +[[ -d "$fixture/game" ]] || { echo "missing Ren'Py demo game directory: $fixture" >&2; exit 1; } +for file in options.rpy script.rpy scene.svg; do + [[ -s "$fixture/game/$file" ]] || { echo "missing Ren'Py demo asset: game/$file" >&2; exit 1; } +done +for marker in \ + 'renpy.input' \ + 'renpy.pause' \ + 'renpy.quit' \ + 'textbutton "Start"' \ + 'textbutton "Pause / resume"'; do + grep -Fq "$marker" "$fixture/game/script.rpy" || { + echo "Ren'Py demo is missing required interaction: $marker" >&2 + exit 1 + } +done +grep -Fq 'image bg fixture = "scene.svg"' "$fixture/game/script.rpy" +grep -Fq 'config.screen_width' "$fixture/game/options.rpy" + +if [[ "${RENPY_DEMO_RUN_SMOKE:-0}" != 1 ]]; then + echo "Ren'Py demo fixture metadata and interaction checks passed" + exit 0 +fi +AETHERKIRI_RENPY_FIXTURE="$fixture" bash "$repo_root/tools/run_renpy_smoke.sh" diff --git a/tools/test_renpy_device_evidence_metrics.py b/tools/test_renpy_device_evidence_metrics.py new file mode 100644 index 000000000..1b69514e6 --- /dev/null +++ b/tools/test_renpy_device_evidence_metrics.py @@ -0,0 +1,679 @@ +#!/usr/bin/env python3 +"""Synthetic fixtures and controlled subprocess regressions, not gameplay. + +These byte arrays are explicitly generated test data. No device, renderer, +provider, adb or XCUITest execution is claimed by these tests. The explicit +subprocess mocks below exercise only truthful partial-failure reporting; they +always fail or block the harness and cannot stand in for device acceptance. +""" + +from __future__ import annotations + +import copy +import contextlib +import functools +import io +import itertools +import json +import shlex +import subprocess +import struct +import sys +import tempfile +import time +import unittest +import zlib +from pathlib import Path +from unittest import mock + +import run_renpy_android_device_acceptance as android + +from run_renpy_android_device_acceptance import ( + Failed, checkpoint_landmarks, image_stats, is_script_checkpoint, pixel, + read_engine_log_file, require_engine_log, require_landmark_pixels, verify_landmark_pixels, verify_resumed_marker, +) + + +COLORS = {"start_ready": ("START", [40, 180, 210]), + "post_text_ready": ("WAIT", [65, 106, 221]), + "quit_ready": ("RESUMED", [235, 164, 64])} + + +def checkpoint(stage="start_ready"): + name, rgb = COLORS[stage] + return {"source": "renpy-script", "run_id": "synthetic-metric-fixture", "stage": stage, + "details": {"logical_canvas": [960, 540], "button_bounds": [360, 222, 240, 96], + "button_rgbs": [[23, 52, 76], [37, 106, 136]], + "marker_bounds": [360, 350, 240, 40], "marker_name": name, "marker_rgb": rgb}} + + +@functools.lru_cache(maxsize=None) +def synthetic_native(stage="start_ready", button=True, hover=False): + # Half-resolution RGB data exercises logical-to-native conversion as well + # as the separate native-to-OS letterbox transform. + width, height = 480, 270 + data = bytearray(width * height * 3) + marker = COLORS[stage][1] + for y in range(height): + for x in range(width): + lx, ly = 2 * x, 2 * y + rgb = [20 + x * 135 // width, 25 + y * 165 // height, + 45 + (x + y) * 105 // (width + height)] + if button and 360 <= lx < 600 and 222 <= ly < 318: + rgb = [37, 106, 136] if hover else [23, 52, 76] + # A small high-contrast synthetic label exercises 1px sampling + # tolerance without treating text as another flat background. + if 418 <= lx < 542 and 254 <= ly < 282 and (x // 3) % 2: + rgb = [255, 255, 255] + if 360 <= lx < 600 and 350 <= ly < 390: + rgb = marker + if 436 <= lx < 524 and 362 <= ly < 378 and (x // 3) % 2: + rgb = [255, 255, 255] + start = (y * width + x) * 3 + data[start:start + 3] = bytes(rgb) + return width, height, bytes(data), 3 + + +def synthetic_os(native, frame, size=(960, 540), shift=(0, 0), color_offset=0): + width, height = size + box, viewport = frame["drawn_box"], frame["viewport_size"] + data = bytearray(width * height * 3) + for y in range(height): + v = ((y + 0.5 - shift[1]) * viewport[1] / height - box[1]) / box[3] + if not 0 <= v < 1: + continue + ny = min(native[1] - 1, int(v * native[1])) + for x in range(width): + u = ((x + 0.5 - shift[0]) * viewport[0] / width - box[0]) / box[2] + if not 0 <= u < 1: + continue + nx = min(native[0] - 1, int(u * native[0])) + rgb = pixel(native, nx, ny) + start = (y * width + x) * 3 + data[start:start + 3] = bytes(min(255, max(0, c + color_offset)) for c in rgb) + return width, height, bytes(data), 3 + + +def legacy_global_fraction(native, screen, frame): + # Reproduce the retained pre-existing 77-point whole-scene proof limit. + box, viewport = frame["drawn_box"], frame["viewport_size"] + matches = count = 0 + for iy in range(1, 8): + for ix in range(1, 12): + u, v = ix / 12, iy / 8 + expected = pixel(native, int(u * native[0]), int(v * native[1])) + actual = pixel(screen, int((box[0] + u * box[2]) * screen[0] / viewport[0]), + int((box[1] + v * box[3]) * screen[1] / viewport[1])) + matches += max(abs(a - b) for a, b in zip(expected, actual)) <= 24 + count += 1 + return matches, count + + +class EvidenceMetrics(unittest.TestCase): + full_frame = {"drawn_box": [0, 0, 1280, 720], "viewport_size": [1280, 720]} + + def test_positive_dense_roi_with_label_and_color_tolerance(self): + native = synthetic_native() + screen = synthetic_os(native, self.full_frame, color_offset=12) + metrics = verify_landmark_pixels(native, screen, self.full_frame, checkpoint()) + require_landmark_pixels(metrics) + self.assertGreater(metrics["button"]["samples"], 700) + self.assertGreater(metrics["marker"]["samples"], 300) + self.assertEqual(metrics["button"]["fraction"], 1) + + def test_missing_entire_button_passes_global_but_fails_roi(self): + native = synthetic_native() + screen = synthetic_os(synthetic_native(button=False), self.full_frame) + image_stats(native) + image_stats(screen) + matches, count = legacy_global_fraction(native, screen, self.full_frame) + self.assertEqual((matches, count), (74, 77)) + self.assertGreater(matches / count, 0.70) + metrics = verify_landmark_pixels(native, screen, self.full_frame, checkpoint()) + self.assertLess(metrics["button"]["fraction"], 0.10) + with self.assertRaises(Failed): + require_landmark_pixels(metrics) + + def test_native_and_os_both_missing_button_cannot_pass(self): + native = synthetic_native(button=False) + screen = synthetic_os(native, self.full_frame) + metrics = verify_landmark_pixels(native, screen, self.full_frame, checkpoint()) + self.assertEqual(metrics["button"]["fraction"], 1) + self.assertLess(metrics["button"]["native_expected_color_fraction"], 0.70) + with self.assertRaises(Failed): + require_landmark_pixels(metrics) + + def test_hover_button_remains_valid(self): + native = synthetic_native(hover=True) + metrics = verify_landmark_pixels(native, synthetic_os(native, self.full_frame), self.full_frame, checkpoint()) + self.assertGreaterEqual(metrics["button"]["native_expected_color_fraction"], 0.70) + require_landmark_pixels(metrics) + + def test_stale_wait_marker_rejected_even_when_os_matches_native(self): + stale = synthetic_native("post_text_ready") + screen = synthetic_os(stale, self.full_frame) + metrics = verify_landmark_pixels(stale, screen, self.full_frame, checkpoint("quit_ready")) + self.assertEqual(metrics["marker"]["fraction"], 1) + self.assertLess(metrics["marker"]["native_expected_color_fraction"], 0.70) + with self.assertRaises(Failed): + require_landmark_pixels(metrics) + change = verify_resumed_marker(stale, checkpoint("post_text_ready"), stale, checkpoint("quit_ready")) + self.assertEqual(change["fraction"], 0) + + def test_os_retaining_wait_pixels_after_native_resume_is_rejected(self): + native = synthetic_native("quit_ready") + screen = synthetic_os(synthetic_native("post_text_ready"), self.full_frame) + metrics = verify_landmark_pixels(native, screen, self.full_frame, checkpoint("quit_ready")) + self.assertLess(metrics["marker"]["fraction"], 0.30) + with self.assertRaises(Failed): + require_landmark_pixels(metrics) + + def test_actual_native_marker_change_is_required(self): + waiting, resumed = synthetic_native("post_text_ready"), synthetic_native("quit_ready") + change = verify_resumed_marker(waiting, checkpoint("post_text_ready"), resumed, checkpoint("quit_ready")) + self.assertGreaterEqual(change["fraction"], change["required_fraction"]) + metrics = verify_landmark_pixels(resumed, synthetic_os(resumed, self.full_frame), + self.full_frame, checkpoint("quit_ready")) + require_landmark_pixels(metrics) + + def test_impostor_source_and_wrong_run_or_stage_are_not_checkpoints(self): + real = checkpoint() + self.assertTrue(is_script_checkpoint(real, "start_ready", "synthetic-metric-fixture")) + impostor = copy.deepcopy(real) + impostor["source"] = "host-observer" + self.assertFalse(is_script_checkpoint(impostor, "start_ready", "synthetic-metric-fixture")) + self.assertFalse(is_script_checkpoint(real, "quit_ready", "synthetic-metric-fixture")) + self.assertFalse(is_script_checkpoint(real, "start_ready", "another-run")) + with self.assertRaises(Failed): + checkpoint_landmarks(impostor) + + def test_letterbox_and_noninteger_scale_with_one_os_pixel_shift(self): + native = synthetic_native() + cases = [({"drawn_box": [0, 75, 800, 450], "viewport_size": [800, 600]}, (641, 481)), + ({"drawn_box": [45.25, 49.5, 1189.5, 669.09375], + "viewport_size": [1280, 720]}, (767, 431))] + for frame, size in cases: + with self.subTest(frame=frame, size=size): + screen = synthetic_os(native, frame, size, shift=(1, -1)) + require_landmark_pixels(verify_landmark_pixels(native, screen, frame, checkpoint())) + + def test_incorrect_letterbox_transform_cannot_pass(self): + native = synthetic_native() + frame = {"drawn_box": [0, 75, 800, 450], "viewport_size": [800, 600]} + screen = synthetic_os(native, frame, (641, 481)) + wrong = {"drawn_box": [0, 0, 800, 600], "viewport_size": [800, 600]} + with self.assertRaises(Failed): + require_landmark_pixels(verify_landmark_pixels(native, screen, wrong, checkpoint())) + + def test_invalid_landmark_bounds_rejected(self): + for field, value in (("logical_canvas", [0, 540]), ("logical_canvas", [float("nan"), 540]), + ("button_bounds", [900, 222, 240, 96]), + ("marker_bounds", [360, 350, 8, 40]), ("marker_rgb", [True, 180, 210]), + ("button_rgbs", []), ("button_rgbs", [[23, 52]]), + ("button_rgbs", [[23, 52, 76.5]]), ("button_rgbs", [[True, 52, 76]])): + bad = copy.deepcopy(checkpoint()) + bad["details"][field] = value + with self.subTest(field=field, value=value), self.assertRaises(Failed): + checkpoint_landmarks(bad) + + def test_godot_integral_float_json_roundtrip_remains_valid(self): + # Actual Godot JSON.parse_string -> JSON.stringify emits these numbers + # as integral floats; the observer's captured checkpoint uses this shape. + captured = copy.deepcopy(checkpoint()) + for key in ("logical_canvas", "button_bounds", "marker_bounds", "marker_rgb"): + captured["details"][key] = [float(v) for v in captured["details"][key]] + captured["details"]["button_rgbs"] = [[float(v) for v in rgb] for rgb in captured["details"]["button_rgbs"]] + self.assertEqual(captured["details"], checkpoint()["details"]) + checkpoint_landmarks(captured) + + def test_dense_roi_threshold_remains_ninety_percent(self): + native = synthetic_native() + screen = synthetic_os(native, self.full_frame) + altered = bytearray(screen[2]) + # Corrupt roughly 13% of the visible button's interior, enough to pass + # a 70% ROI gate while failing the required 90% gate with actual pixels. + for y in range(226, 314): + for x in range(566, 600): + offset = (y * screen[0] + x) * screen[3] + altered[offset:offset + 3] = b"\xff\x00\xff" + screen = (screen[0], screen[1], bytes(altered), screen[3]) + metrics = verify_landmark_pixels(native, screen, self.full_frame, checkpoint()) + self.assertGreater(metrics["button"]["fraction"], 0.70) + self.assertLess(metrics["button"]["fraction"], 0.90) + with self.assertRaises(Failed): + require_landmark_pixels(metrics) + + +class EngineLogEvidence(unittest.TestCase): + # Explicit synthetic diagnostic text, never an actual runtime pass. + root = "/cloud/private/renpy-device-demo" + attachment = ("[2026-10-08 12:00:00.000] [info] aetherkiri provider engine log attached: " + + root + "/aetherkiri-engine.log\n") + + def test_nonempty_fresh_game_log_without_native_errors(self): + raw = (self.attachment + "[info] info [renpy-mobile] renderer initialized\n" + "[info] info [renpy-mobile] cooperative_stop completed\n" + "[error] error [another-runtime] unrelated diagnostic\n").encode() + report = require_engine_log(raw, self.root) + self.assertEqual(report["renpy_mobile_error_records"], 0) + self.assertIn("void-shutdown-status-unavailable", report["verification"]) + + def test_terminal_egl_thread_and_python_cleanup_errors_reject(self): + diagnostics = ["error [renpy-mobile] Ren'Py terminal cleanup failed; retained runtime resources require a host application restart", + "error [renpy-mobile] Ren'Py private EGL context could not be made current (EGL error 0x300e); context-loss recovery is unavailable", + "error [renpy-mobile] Ren'Py failed to restore the host EGL context after a lifecycle call", + "error [renpy-mobile] Ren'Py lifecycle called from a different thread than init", + "error [renpy-mobile] cooperative_stop", + "error [renpy-mobile] Python cleanup exception with no special keywords", + "warning [renpy-mobile] Ren'Py terminal cleanup failed", + "warning [renpy-mobile] cooperative_stop"] + for line in diagnostics: + with self.subTest(line=line), self.assertRaises(Failed): + require_engine_log((self.attachment + "[2026-10-08] [error] " + line + "\n").encode(), self.root) + + def test_empty_unreadable_or_wrong_game_log_rejects(self): + for raw in (None, b"", b" \n", b"\xff", b"some other log", self.attachment.replace(self.root, "/cloud/older-game").encode()): + with self.subTest(raw=raw), self.assertRaises(Failed): + require_engine_log(raw, self.root) + + def test_real_file_read_requires_available_regular_log(self): + with tempfile.TemporaryDirectory(prefix="renpy-synthetic-log-gate-") as temporary: + directory = Path(temporary) + with self.assertRaises(Failed): + read_engine_log_file(directory / "missing.log") + with self.assertRaises(Failed): + read_engine_log_file(directory) + path = directory / "synthetic-aetherkiri-engine.log" + path.write_text(self.attachment) + require_engine_log(read_engine_log_file(path), self.root) + + +class AndroidFailureDiagnosticBoundaries(unittest.TestCase): + """Real ordinary subprocesses and controlled state fixtures, never Android.""" + + def test_activity_state_after_full_configuration_is_kept_until_next_record(self): + component = "org.example.controlled/com.godot.game.GodotAppLauncher" + # AOSP Android 15 ActivityRecord.dump: configuration/TaskDescription + # precede state/visible/drawn fields (source lines 1022..1223). + lines = (["display configuration field"] * 220 + + ["* Hist #0: ActivityRecord{x u0 " + component + " t1}"] + + [" CurrentConfiguration=controlled config"] * 45 + + [" state=STARTED delayedResume=false finishing=false", + " mVisibleRequested=true mVisible=false reportedDrawn=false", + " allDrawn=false firstWindowDrawn=false", + "* Hist #1: ActivityRecord{x u0 org.example.unrelated/.Other t2}", + " state=RESUMED mVisible=true allDrawn=true firstWindowDrawn=true", + "* Hist #2: ActivityRecord{x u0 " + component + "Other t3}", + " state=RESUMED allDrawn=true"]) + self.assertEqual(android.failure_activity_states("\n".join(lines).encode(), component), + [{"state": "STARTED", "mVisibleRequested": True, "mVisible": False, + "allDrawn": False, "firstWindowDrawn": False}]) + + def test_activity_scan_keeps_byte_line_and_oversize_record_boundaries(self): + component = "org.example.controlled/.Activity" + header = "ActivityRecord{x u0 " + component + " t1}\n" + self.assertEqual(android.failure_activity_states( + (header + "config\n" * 999 + "state=RESUMED\n").encode(), component), []) + self.assertEqual(android.failure_activity_states( + (header + "config-padding" * 6000 + "\nstate=RESUMED\n").encode(), component), []) + raw = (header + "state=STARTED\n" + "x" * 4097 + " ActivityRecord{x u0 unrelated/.Other t2}\n" + + "state=RESUMED allDrawn=true\n").encode() + self.assertEqual(android.failure_activity_states(raw, component), [{"state": "STARTED"}]) + + def test_real_success_nonzero_and_oversize_stdout_have_distinct_bounded_results(self): + for script, expected in (("print('4343')", "available"), + ("print('4343'); raise SystemExit(7)", "not_available"), + ("print('x'*100000)", "not_available")): + report, raw = android.bounded_failure_command([sys.executable, "-c", script], time.monotonic() + 2, + byte_limit=1024) + self.assertEqual(report["status"], expected) + self.assertLessEqual(report["bytes_captured"], 1024) + self.assertEqual(raw, b"4343\n" if expected == "available" else b"") + self.assertEqual(report["reason"], "stdout_limit") + + def test_real_non_eof_stdout_times_out_and_direct_child_is_reaped(self): + original = subprocess.Popen + processes = [] + def owned_child(*args, **kwargs): + child = original(*args, **kwargs) + processes.append(child) + return child + started = time.monotonic() + with mock.patch.object(android.subprocess, "Popen", side_effect=owned_child): + report, raw = android.bounded_failure_command([sys.executable, "-c", + "import time; print('4343',flush=True); time.sleep(10)"], started + 0.4) + self.assertEqual(report["reason"], "command_timeout") + self.assertEqual(raw, b"") + self.assertIsNotNone(processes[0].poll()) + self.assertLess(time.monotonic() - started, 1) + + def test_denied_kill_is_unknown_without_retry_or_partial_pid_use(self): + original = subprocess.Popen + processes, signals = [], [] + def denied_child(*args, **kwargs): + child = original(*args, **kwargs) + processes.append(child) + def denied(): + signals.append(True) + raise PermissionError("controlled denial, not an actual permission change") + child.kill = denied + return child + try: + with mock.patch.object(android.subprocess, "Popen", side_effect=denied_child): + report, raw = android.bounded_failure_command([sys.executable, "-c", + "import time; print('4343',flush=True); time.sleep(10)"], time.monotonic() + 0.4) + self.assertTrue(report["cleanup_permission_denied"]) + self.assertTrue(report["collector_not_reaped"]) + self.assertEqual(raw, b"") + self.assertEqual(signals, [True]) + finally: + for child in processes: + subprocess.Popen.kill(child) # Normal cleanup of our real test child, with the injected denial removed. + child.wait(timeout=2) + + def acceptance(self, temporary): + args = type("Args", (), {"output_dir": Path(temporary), "package": "org.example.controlled", + "serial": "controlled-only", "adb": "not-an-adb"})() + acceptance = android.Acceptance(args) + acceptance.installed = True + acceptance.summary.update(status="failed", blocker="original-controlled-launch-timeout", apk_installed=True, + apk_sha256="a" * 64, activity="org.example.controlled/.Activity") + return acceptance + + def test_collect_has_one_ten_second_deadline_and_never_upgrades_partial_milestones(self): + with tempfile.TemporaryDirectory() as temporary: + acceptance = self.acceptance(temporary) + before = copy.deepcopy(acceptance.summary["actual_status"]) + clock, calls = {"now": 0.0}, [] + def capture(command, deadline, **kwargs): + self.assertEqual(deadline, 10) + if clock["now"] >= deadline: + return {"status": "not_available", "reason": "diagnostic_deadline"}, b"" + calls.append(command) + clock["now"] += min(2, deadline - clock["now"]) + output = b"4343\n" if command[-1].startswith("pidof ") else b"" + if command[-1] == "dumpsys activity activities": + output = ("ActivityRecord{x u0 org.example.controlled/.Activity t1}\n" + " state=INITIALIZING firstWindowDrawn=false allDrawn=false\n" + "ActivityRecord{x u0 unrelated/.Activity t2}\n state=RESUMED allDrawn=true\n").encode() + return {"status": "available", "returncode": 0}, output + with mock.patch.object(android.time, "monotonic", side_effect=lambda: clock["now"]), \ + mock.patch.object(android, "bounded_failure_command", side_effect=capture): + acceptance.collect() + report = acceptance.summary["failure_diagnostics"] + self.assertEqual(clock["now"], 10) + self.assertEqual(len(calls), 5) + self.assertTrue(report["deadline_reached"]) + self.assertEqual(report["pid_sample"]["pids"], ["4343"]) + self.assertEqual(report["activity_sample"]["states"], + [{"state": "INITIALIZING", "allDrawn": False, "firstWindowDrawn": False}]) + self.assertEqual((report["run_id"], report["package"], report["serial"], report["apk_sha256"]), + (acceptance.run_id, acceptance.args.package, acceptance.args.serial, "a" * 64)) + self.assertEqual(acceptance.summary["actual_status"], before) + self.assertEqual(acceptance.summary["blocker"], "original-controlled-launch-timeout") + self.assertFalse(acceptance.summary["process_started"]) + + def test_successful_collection_keeps_original_full_evidence_and_no_failure_snapshot(self): + with tempfile.TemporaryDirectory() as temporary: + acceptance = self.acceptance(temporary) + acceptance.summary["status"] = "passed" + full = ("controlled complete evidence line " + "x" * 80 + "\n").encode() * 1000 + with mock.patch.object(acceptance, "private", return_value=full), \ + mock.patch.object(acceptance, "command", return_value=b"controlled logcat\n"), \ + mock.patch.object(android, "bounded_failure_command", side_effect=AssertionError("Success cannot enter new failure collector")): + acceptance.collect() + self.assertEqual((acceptance.output / "observer.jsonl").read_bytes(), full) + self.assertEqual((acceptance.output / "renpy-script-checkpoints.jsonl").read_bytes(), full) + self.assertNotIn("failure_diagnostics", acceptance.summary) + + +class AndroidPartialFailureReporting(unittest.TestCase): + """Controlled command results, never an actual installation or game pass.""" + + package = "org.example.controlled" + serial = "controlled-subprocess-fixture" + + def run_controlled_failure(self, *, install=(0, b"Performing streamed install\nSuccess\n", b""), + launch=(0, b"Status: ok\n", b""), pid=(0, b"4343\n", b""), + stage_failure=False, cloud=True, passive_ready=False, activity_state="RESUMED", + malformed_capture=False): + with tempfile.TemporaryDirectory(prefix="renpy-controlled-partial-report-") as temporary: + root = Path(temporary) + apk = root / "controlled-not-installable.apk" + apk.write_bytes(b"Explicit unit fixture, not an installable APK") + output = root / "evidence" + snapshots = [] + pid_responses = iter(pid) if isinstance(pid, list) else itertools.repeat(pid) + actual_capture = android.Acceptance.capture + + def command_result(argv, **kwargs): + parts = argv[3:] # Controlled adb executable, -s, serial. + result = (0, b"", b"") + if parts == ["get-state"]: + result = (0, b"device\n", b"") + elif parts[0] == "install": + result = install + elif parts[:3] == ["exec-out", "run-as", self.package] and parts[3:] == ["pwd"]: + result = (0, b"/data/user/0/org.example.controlled\n", b"") + elif parts[0] == "shell": + shell = shlex.split(parts[1]) + if shell[:3] == ["cmd", "package", "resolve-activity"]: + result = (0, (self.package + "/.ControlledActivity\n").encode(), b"") + elif shell[:2] == ["am", "start"]: + # The independently persisted install checkpoint must + # already exist before the next command can fail. + snapshots.append(json.loads((output / "result.json").read_text())) + result = launch + elif shell[:1] == ["pidof"]: + result = next(pid_responses, (0, b"", b"")) + elif shell == ["dumpsys", "activity", "activities"]: + result = (0, ("ActivityRecord{x u0 " + self.package + "/.ControlledActivity t1}\n" + " state=" + activity_state + " firstWindowDrawn=false\n").encode(), b"") + return subprocess.CompletedProcess(argv, result[0], result[1], result[2]) + + def runtime_failure(stage): + snapshots.append(json.loads((output / "result.json").read_text())) + if passive_ready: + return {"source": "renpy-script", "run_id": snapshots[-1]["run_id"], "stage": stage} + raise Failed("Controlled regression: Ren'Py startup rejected") + + def diagnostic_read(argv, deadline, **kwargs): + result = command_result(argv) + return {"status": "available" if result.returncode == 0 else "not_available", + "returncode": result.returncode}, result.stdout if result.returncode == 0 else b"" + + def controlled_capture(acceptance, stage, **kwargs): + self.assertTrue(passive_ready) + self.assertIsNotNone(acceptance.passive_deadline) + if malformed_capture: + with mock.patch.object(acceptance, "wait_observer", return_value={ + "source": "native-provider-rgba", "provider_debug": None, "frame_serial": 1}): + return actual_capture(acceptance, stage, **kwargs) + # This controlled boundary fixture does not execute native + # rendering; the enclosing run must retain its launch failure. + acceptance.summary["checks"].append({"controlled_unit_fixture": True}) + return {"frame_serial": 1}, () + + argv = ["controlled-harness", "--apk", str(apk), "--serial", self.serial, + "--package", self.package, "--adb", "controlled-only-adb", "--output-dir", str(output), + "--stage-timeout", "5", "--overall-timeout", "60"] + if cloud: + argv.append("--cloud-device") + stream = io.StringIO() + clock = itertools.count() + with (mock.patch.object(sys, "argv", argv), + mock.patch.object(android.shutil, "which", return_value="/controlled-only/adb"), + mock.patch.object(android.subprocess, "run", side_effect=command_result) as commands, + mock.patch.object(android.time, "monotonic", side_effect=lambda: float(next(clock)) / (10 if passive_ready else 1)), + mock.patch.object(android.time, "sleep"), + mock.patch.object(android, "bounded_failure_command", side_effect=diagnostic_read), + mock.patch.object(android.Acceptance, "stage", + side_effect=Failed("Controlled regression: staging rejected") if stage_failure else None) as staging, + mock.patch.object(android.Acceptance, "wait_game", side_effect=runtime_failure), + mock.patch.object(android.Acceptance, "capture", autospec=True, side_effect=controlled_capture), + contextlib.redirect_stdout(stream)): + code = android.main() + result = json.loads((output / "result.json").read_text()) + checkpoints = [json.loads(line) for line in stream.getvalue().splitlines() + if line.startswith('{"event": "renpy_device_milestone"')] + self.assertNotEqual(result["status"], "passed") + self.assertFalse((output / "result.pending.json").exists()) + return code, result, checkpoints, snapshots, commands.call_args_list, staging.call_count + + def test_no_device_authorization_records_no_attempts(self): + code, report, progress, _, commands, stages = self.run_controlled_failure(cloud=False) + self.assertEqual((code, report["status"]), (2, "not_run")) + self.assertFalse(report["apk_installed"] or report["process_started"]) + self.assertTrue(all(p["status"] == "not_run" for p in report["actual_status"].values())) + self.assertEqual((progress, commands, stages), ([], [], 0)) + + def test_install_counterexamples_cannot_claim_partial_success(self): + for response in [(1, b"Success\n", b"controlled failure"), + (0, b"NotSuccess\n", b""), + (0, b"Success\nFailure [CONTROLLED]\n", b""), + (0, b"Success\nSuccess\n", b"")]: + with self.subTest(response=response): + code, report, progress, _, _, stages = self.run_controlled_failure(install=response) + self.assertEqual((code, report["status"]), (1, "failed")) + self.assertFalse(report["apk_installed"] or report["process_started"]) + self.assertEqual(report["actual_status"]["apk_install"]["status"], "failed") + self.assertEqual(report["actual_status"]["app_launch"]["status"], "not_run") + self.assertEqual((progress, stages), ([], 0)) + + def test_staging_failure_preserves_install_without_claiming_launch(self): + code, report, progress, _, _, _ = self.run_controlled_failure(stage_failure=True) + self.assertEqual((code, report["status"]), (1, "failed")) + self.assertTrue(report["apk_installed"]) + self.assertFalse(report["process_started"]) + self.assertEqual(report["actual_status"]["app_launch"]["status"], "not_run") + self.assertEqual([p["stage"] for p in progress], ["apk_install"]) + + def test_am_start_error_or_missing_status_cannot_claim_process(self): + for response in [(1, b"Status: ok\n", b"controlled error"), + (0, b"Status: ok\nError type 3\n", b""), + (0, b"Status: ok\n", b"Error: controlled denial\n"), + (1, b"Status: timeout\n", b"controlled error"), + (0, b"Status: timeout\n", b"Error: controlled denial\n"), + (0, b"Status: timeout\nStatus: timeout\n", b""), + (0, b"Starting: Intent { controlled }\n", b"")]: + with self.subTest(response=response): + code, report, progress, _, _, _ = self.run_controlled_failure(launch=response) + self.assertEqual((code, report["status"]), (1, "failed")) + self.assertTrue(report["apk_installed"]) + self.assertFalse(report["process_started"]) + self.assertEqual(report["actual_status"]["app_launch"]["status"], "failed") + self.assertEqual([p["stage"] for p in progress], ["apk_install"]) + + def test_absent_invalid_or_failed_pid_observation_cannot_claim_process(self): + for response in [(0, b"", b""), (0, b"not running\n", b""), (0, b"0\n", b""), + (0, b"4343 error\n", b""), (1, b"4343\n", b"controlled pidof failure")]: + with self.subTest(response=response): + code, report, progress, _, _, _ = self.run_controlled_failure(pid=response) + self.assertEqual((code, report["status"]), (1, "failed")) + self.assertTrue(report["apk_installed"]) + self.assertFalse(report["process_started"]) + self.assertEqual([p["stage"] for p in progress], ["apk_install"]) + + def test_runtime_failure_retains_both_command_milestones(self): + code, report, progress, snapshots, _, _ = self.run_controlled_failure() + self.assertEqual((code, report["status"]), (1, "failed")) + self.assertTrue(report["apk_installed"] and report["process_started"]) + self.assertEqual(report["checks"], []) + self.assertEqual([p["stage"] for p in progress], ["apk_install", "app_launch"]) + self.assertEqual([s["process_started"] for s in snapshots], [False, True]) + self.assertTrue(all(s["status"] == "running" for s in snapshots)) + for stage in report["actual_status"].values(): + self.assertEqual((stage["status"], stage["source"], stage["returncode"]), + ("passed", "actual-adb-command", 0)) + self.assertEqual((stage["package"], stage["serial"]), (self.package, self.serial)) + launch = report["actual_status"]["app_launch"] + self.assertEqual(launch["activity"], self.package + "/.ControlledActivity") + self.assertEqual((launch["pids"], launch["pid_count"]), (["4343"], 1)) + + def test_passive_timeout_capture_cannot_promote_launch_or_gameplay(self): + code, report, progress, _, commands, _ = self.run_controlled_failure( + launch=(0, b"Status: timeout\n", b""), passive_ready=True) + self.assertEqual((code, report["status"]), (1, "failed")) + self.assertIn("Actual am start did not report Status: ok", report["blocker"]) + self.assertTrue(report["apk_installed"]) + self.assertFalse(report["process_started"]) + self.assertEqual(report["checks"], []) + self.assertEqual(report["actual_status"]["app_launch"]["status"], "failed") + observation = report["passive_startup_observation"] + self.assertTrue(observation["diagnostic_only"] and observation["native_rgba_and_os_pixels"]) + self.assertEqual(observation["diagnostic_pids"], ["4343"]) + self.assertEqual((observation["run_id"], observation["package"], observation["serial"], observation["apk_sha256"]), + (report["run_id"], self.package, self.serial, report["apk_sha256"])) + self.assertLessEqual(observation["elapsed_seconds"], 30) + self.assertEqual([p["stage"] for p in progress], ["apk_install"]) + shell_commands = [shlex.split(c.args[0][4]) for c in commands if len(c.args[0]) > 4 and c.args[0][3] == "shell"] + self.assertEqual(sum(c[:2] == ["am", "start"] for c in shell_commands), 1) + self.assertFalse(any(c[:1] == ["input"] for c in shell_commands)) + # Exercise the actual shared capture's AttributeError on legal JSON + # with provider_debug:null; it must not mask the original am failure. + code, report, _, _, _, _ = self.run_controlled_failure( + launch=(0, b"Status: timeout\n", b""), passive_ready=True, malformed_capture=True) + self.assertEqual((code, report["status"]), (1, "failed")) + self.assertIn("Actual am start did not report Status: ok", report["blocker"]) + self.assertFalse(report["process_started"]) + self.assertEqual(report["checks"], []) + self.assertEqual(report["actual_status"]["app_launch"]["status"], "failed") + self.assertTrue(report["passive_startup_observation"]["observation_unavailable"]) + self.assertFalse(report["passive_startup_observation"]["native_rgba_and_os_pixels"]) + + def test_passive_timeout_needs_current_valid_pid_and_exact_resumed_record(self): + for pid, state in [((0, b"", b""), "RESUMED"), ((1, b"4343\n", b""), "RESUMED"), + ((0, b"4343 4343\n", b""), "RESUMED"), ((0, b"9999999999\n", b""), "RESUMED"), + ((0, b"4343\n", b""), "STARTED"), + ([(0, b"4343\n", b""), (0, b"4545\n", b"")], "RESUMED"), + ([(0, b"4343\n", b""), (0, b"4343\n", b""), (0, b"4545\n", b"")], "RESUMED")]: + with self.subTest(pid=pid, state=state): + code, report, _, _, _, _ = self.run_controlled_failure( + launch=(0, b"Status: timeout\n", b""), pid=pid, activity_state=state, passive_ready=True) + self.assertEqual((code, report["status"]), (1, "failed")) + self.assertFalse(report["process_started"]) + self.assertFalse(report["passive_startup_observation"]["native_rgba_and_os_pixels"]) + + def test_passive_read_real_child_respects_short_remaining_deadline(self): + with tempfile.TemporaryDirectory() as temporary: + args = type("Args", (), {"output_dir": Path(temporary), "package": self.package, + "serial": self.serial, "adb": "not-an-adb"})() + acceptance = android.Acceptance(args) + acceptance.device_prefix = [sys.executable, "-c", "import time; print('4343',flush=True); time.sleep(10)"] + started = time.monotonic() + acceptance.passive_deadline = started + 0.3 + original, children = subprocess.Popen, [] + def owned_child(*parts, **kwargs): + child = original(*parts, **kwargs) + children.append(child) + return child + with mock.patch.object(android.subprocess, "Popen", side_effect=owned_child), self.assertRaises(Failed): + acceptance.command("shell", "pidof controlled-only", check=False) + self.assertLess(time.monotonic() - started, 1) + self.assertIsNotNone(children[0].poll()) + self.assertEqual(acceptance.pid, "") + self.assertFalse(acceptance.summary["process_started"]) + + def test_passive_png_deadline_is_optional_and_bounds_actual_decode(self): + def png(width, height): + def chunk(kind, data): + return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data)) + return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress((b"\0" + bytes([30, 90, 180]) * width) * height)) + chunk(b"IEND", b"")) + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / "controlled-generated.png" + path.write_bytes(png(4, 4)) + ordinary = android.png_pixels(path) + self.assertEqual(android.png_pixels(path, deadline=time.monotonic() + 1), ordinary) + path.write_bytes(png(1200, 800)) + started = time.monotonic() + with self.assertRaisesRegex(Failed, "deadline"): + android.png_pixels(path, deadline=started + 0.01) + self.assertLess(time.monotonic() - started, 1) + path.write_bytes(png(2000, 1600)) # Legal normal dimensions, above the passive-only decoded byte cap. + with self.assertRaisesRegex(Failed, "decoded pixel budget"): + android.png_pixels(path, deadline=time.monotonic() + 1) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tools/test_renpy_ios_destination_artifacts.py b/tools/test_renpy_ios_destination_artifacts.py new file mode 100644 index 000000000..44e003aa9 --- /dev/null +++ b/tools/test_renpy_ios_destination_artifacts.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 +"""Check production xctestrun transformation using real host filesystem fixtures. + +The fixture plist/bundle directories exercise configuration and containment; +they are not compiled Apple bundles. No SDK, Simulator installation, XCTest, +native runtime, graphics, input or gameplay success is simulated or claimed. +""" + +from __future__ import annotations + +import ast +import argparse +import copy +import plistlib +import tempfile +import unittest +from pathlib import Path + +import run_renpy_ios_device_acceptance as production + + +RUN_ID = "0123456789abcdef0123456789abcdef" +RUNNER_ID = "org.aetherkiri.renpy-ios-acceptance.xctrunner" +APP_ID = "org.aetherkiri.renpy-simulator.debug" + + +class DestinationArtifacts(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix="aether-ios-destination-artifacts-") + self.root = Path(self.temporary.name) + self.products = self.root / "Products with spaces" + self.products.mkdir() + self.runner = self.products / "Debug-iphonesimulator/RenPyAcceptance-Runner.app" + self.test_bundle = self.runner / "PlugIns/RenPyAcceptance.xctest" + self.test_bundle.mkdir(parents=True) + (self.runner / "Info.plist").write_bytes(plistlib.dumps({ + "CFBundleIdentifier": RUNNER_ID, "CFBundleSupportedPlatforms": ["iPhoneSimulator"]})) + (self.test_bundle / "Info.plist").write_bytes(plistlib.dumps({ + "CFBundleIdentifier": "org.aetherkiri.renpy-ios-acceptance"})) + self.target = { + "BlueprintName": "RenPyAcceptance", "IsUITestBundle": True, + "TestHostPath": "__TESTROOT__/Debug-iphonesimulator/RenPyAcceptance-Runner.app", + "TestBundlePath": "__TESTHOST__/PlugIns/RenPyAcceptance.xctest", + "TestHostBundleIdentifier": RUNNER_ID, + "UITargetAppPath": "__TESTROOT__/Aether.app", + "DependentProductPaths": ["__TESTROOT__/Aether.app", "__TESTHOST__/PlugIns/RenPyAcceptance.xctest"], + "TestingEnvironmentVariables": { + "DYLD_FRAMEWORK_PATH": "__TESTROOT__/Debug-iphonesimulator:__PLATFORMS__/Developer/Library/Frameworks", + "XCInjectBundleInto": "__TESTHOST__/RenPyAcceptance-Runner", + "TEST_BUNDLE": "__TESTBUNDLE__", + "SHARED_FRAMEWORKS": "__SHAREDFRAMEWORKS__"}, + "EnvironmentVariables": {"EXISTING_PARAMETER": "preserved"}, + } + + def tearDown(self): + self.temporary.cleanup() + + def transform(self, configuration=None, runner=None, **kwargs): + return production.configure_destination_artifacts( + configuration if configuration is not None else {"RenPyAcceptance": self.target}, + self.products, runner or self.runner, + kwargs.get("bundle_id", APP_ID), kwargs.get("run_id", RUN_ID), kwargs.get("overall_timeout", 600)) + + def assert_destination(self, target): + self.assertIs(target["UseDestinationArtifacts"], True) + self.assertEqual(target["TestHostBundleIdentifier"], RUNNER_ID) + self.assertEqual(target["UITargetAppBundleIdentifier"], APP_ID) + self.assertEqual(target["TestBundleDestinationRelativePath"], + "__TESTHOST__/PlugIns/RenPyAcceptance.xctest") + for key in ("TestHostPath", "TestBundlePath", "UITargetAppPath", "DependentProductPaths"): + self.assertNotIn(key, target) + self.assertEqual(target["EnvironmentVariables"], { + "EXISTING_PARAMETER": "preserved", "AETHER_RENPY_RUN_ID": RUN_ID, + "AETHER_RENPY_APP_BUNDLE_ID": APP_ID, "AETHER_RENPY_TEST_TIMEOUT": "600"}) + environment = target["TestingEnvironmentVariables"] + self.assertEqual(environment["XCInjectBundleInto"], "__TESTHOST__/RenPyAcceptance-Runner") + self.assertEqual(environment["TEST_BUNDLE"], "__TESTBUNDLE__") + self.assertEqual(environment["SHARED_FRAMEWORKS"], "__SHAREDFRAMEWORKS__") + self.assertEqual(environment["DYLD_FRAMEWORK_PATH"], str(self.products.resolve()) + + "/Debug-iphonesimulator:__PLATFORMS__/Developer/Library/Frameworks") + + def test_legacy_original_configuration_becomes_destination_only(self): + original = {"RenPyAcceptance": copy.deepcopy(self.target)} + # The previous normal configuration still supplies installation paths; + # it does not meet the documented destination-only contract. + self.assertNotIn("UseDestinationArtifacts", original["RenPyAcceptance"]) + self.assertIn("UITargetAppPath", original["RenPyAcceptance"]) + result, runner_id = self.transform(original) + self.assertEqual(runner_id, RUNNER_ID) + self.assert_destination(result["RenPyAcceptance"]) + self.assertEqual(original["RenPyAcceptance"], self.target) + # Exercise the actual plist serialization that production hands Xcode. + self.assertEqual(plistlib.loads(plistlib.dumps(result)), result) + + def test_version_two_and_testroot_bundle_path(self): + target = copy.deepcopy(self.target) + target["TestBundlePath"] = "__TESTROOT__/Debug-iphonesimulator/RenPyAcceptance-Runner.app/PlugIns/RenPyAcceptance.xctest" + configuration = { + "TestPlan": {"Name": "actual-format-fixture", "IsDefault": True}, + "TestConfigurations": [{"Name": "Debug", "IsEnabled": True, "TestTargets": [target]}], + "__xctestrun_metadata__": {"FormatVersion": 2}} + result, _ = self.transform(configuration) + self.assert_destination(result["TestConfigurations"][0]["TestTargets"][0]) + self.assertEqual(result["TestPlan"], configuration["TestPlan"]) + self.assertEqual(result["__xctestrun_metadata__"], {"FormatVersion": 2}) + for version in (1, 2): + with self.subTest(version=version): + self.assert_destination(self.transform({"RenPyAcceptance": copy.deepcopy(self.target), + "__xctestrun_metadata__": {"FormatVersion": version}})[0]["RenPyAcceptance"]) + + def test_absolute_paths_and_normal_parent_alias(self): + alias = self.root / "build-alias" + alias.symlink_to(self.products, target_is_directory=True) + target = copy.deepcopy(self.target) + target["TestHostPath"] = str(alias / self.runner.relative_to(self.products)) + target["TestBundlePath"] = str(alias / self.test_bundle.relative_to(self.products)) + self.assert_destination(self.transform({"RenPyAcceptance": target})[0]["RenPyAcceptance"]) + + def test_mandatory_source_paths_testing_environment_and_format(self): + for key in ("TestHostPath", "TestBundlePath", "TestingEnvironmentVariables"): + with self.subTest(key=key): + target = copy.deepcopy(self.target) + del target[key] + with self.assertRaises(production.Failed): + self.transform({"RenPyAcceptance": target}) + for key in ("TestingEnvironmentVariables", "EnvironmentVariables"): + for value in (None, [], {"value": 1}, {"value": True}): + with self.subTest(key=key, value=value): + target = copy.deepcopy(self.target) + target[key] = value + with self.assertRaises(production.Failed): + self.transform({"RenPyAcceptance": target}) + for metadata in ([], {}, {"FormatVersion": 3}, {"FormatVersion": True}, {"FormatVersion": "2"}): + with self.subTest(metadata=metadata), self.assertRaises(production.Failed): + self.transform({"RenPyAcceptance": copy.deepcopy(self.target), "__xctestrun_metadata__": metadata}) + + def test_duplicate_ui_or_additional_non_ui_targets_are_rejected(self): + for is_ui in (True, False): + with self.subTest(is_ui=is_ui), self.assertRaises(production.Failed): + extra = {**copy.deepcopy(self.target), "IsUITestBundle": is_ui} + self.transform({"RenPyAcceptance": copy.deepcopy(self.target), "Another": extra}) + configuration = {"TestConfigurations": [{"TestTargets": [copy.deepcopy(self.target), copy.deepcopy(self.target)]}], + "__xctestrun_metadata__": {"FormatVersion": 2}} + with self.assertRaises(production.Failed): + self.transform(configuration) + for is_ui in (False, 1, "true", None): + with self.subTest(is_ui=is_ui), self.assertRaises(production.Failed): + self.transform({"RenPyAcceptance": {**copy.deepcopy(self.target), "IsUITestBundle": is_ui}}) + + def test_host_path_and_identity_must_match_selected_actual_runner(self): + other = self.products / "another.app" + other.mkdir() + cases = [str(other), "__TESTHOST__", "__PLATFORMS__/runner.app", "relative.app", + "__TESTROOT__/Debug-iphonesimulator/../Debug-iphonesimulator/RenPyAcceptance-Runner.app"] + for path in cases: + with self.subTest(path=path), self.assertRaises(production.Failed): + self.transform({"RenPyAcceptance": {**copy.deepcopy(self.target), "TestHostPath": path}}) + with self.assertRaises(production.Failed): + self.transform({"RenPyAcceptance": {**copy.deepcopy(self.target), "TestHostBundleIdentifier": "other.runner"}}) + alias = self.products / "runner.app" + alias.symlink_to(self.runner, target_is_directory=True) + with self.assertRaises(production.Failed): + self.transform(runner=alias) + + def test_missing_wrong_platform_and_invalid_bundle_metadata(self): + runner_info = self.runner / "Info.plist" + for info in ({}, {"CFBundleIdentifier": RUNNER_ID}, + {"CFBundleIdentifier": APP_ID, "CFBundleSupportedPlatforms": ["iPhoneSimulator"]}, + {"CFBundleIdentifier": RUNNER_ID, "CFBundleSupportedPlatforms": ["iPhoneOS"]}, + {"CFBundleIdentifier": RUNNER_ID, "CFBundleSupportedPlatforms": "iPhoneSimulator"}): + with self.subTest(info=info): + runner_info.write_bytes(plistlib.dumps(info)) + with self.assertRaises(production.Failed): + self.transform() + runner_info.write_bytes(plistlib.dumps({"CFBundleIdentifier": RUNNER_ID, + "CFBundleSupportedPlatforms": ["iPhoneSimulator"]})) + bundle_info = self.test_bundle / "Info.plist" + for info in ({}, {"CFBundleIdentifier": ""}, {"CFBundleIdentifier": "bad/bundle"}): + with self.subTest(info=info): + bundle_info.write_bytes(plistlib.dumps(info)) + with self.assertRaises(production.Failed): + self.transform() + bundle_info.unlink() + with self.assertRaises(production.Failed): + self.transform() + + def test_test_bundle_escape_wrong_embedding_and_links_are_rejected(self): + outside = self.root / "outside.xctest" + outside.mkdir() + for path in (str(outside), str(self.runner), str(self.runner / "Info.plist")): + with self.subTest(path=path), self.assertRaises(production.Failed): + self.transform({"RenPyAcceptance": {**copy.deepcopy(self.target), "TestBundlePath": path}}) + inside_link = self.runner / "PlugIns/linked.xctest" + inside_link.symlink_to(self.test_bundle, target_is_directory=True) + outside_link = self.runner / "PlugIns/escaped.xctest" + outside_link.symlink_to(outside, target_is_directory=True) + for link in (inside_link, outside_link): + with self.subTest(link=link), self.assertRaises(production.Failed): + self.transform({"RenPyAcceptance": {**copy.deepcopy(self.target), "TestBundlePath": str(link)}}) + # Even a symlink to a valid actual metadata file is refused. + info = self.test_bundle / "Info.plist" + moved = self.root / "test-info.plist" + info.rename(moved) + info.symlink_to(moved) + with self.assertRaises(production.Failed): + self.transform() + + def test_configuration_identity_and_existing_destination_input_are_rejected(self): + for key, values in (("run_id", ["", "g" * 32, RUN_ID.upper(), 1]), + ("bundle_id", ["", "bad/app", 1]), + ("overall_timeout", [59, 1201, True, "600"])): + for value in values: + with self.subTest(key=key, value=value), self.assertRaises(production.Failed): + self.transform(**{key: value}) + result, _ = self.transform() + with self.assertRaises(production.Failed): + self.transform(result) + + def test_actual_directory_identity_alias_replacement_and_missing_guard(self): + documents = self.root / "Documents" + documents.mkdir() + acceptance = production.Acceptance(argparse.Namespace(output_dir=self.root / "output", udid=None)) + acceptance.summary.update(status="failed", blocker="original gameplay failure") + acceptance.staged_documents_identity = production.documents_identity(documents) + alias = self.root / "host-alias" + alias.symlink_to(self.root, target_is_directory=True) + acceptance.verify_staged_documents(alias / "Documents") + self.assertTrue(acceptance.summary["startup_diagnostics"]["container_identity_checked"]) + self.assertTrue(acceptance.summary["startup_diagnostics"]["container_identity_same"]) + # Real directory replacement must fail even if the lexical path is + # unchanged. The original inode remains alive at its renamed path. + documents.rename(self.root / "old-documents") + documents.mkdir() + with self.assertRaises(production.Failed): + acceptance.verify_staged_documents(documents) + self.assertFalse(acceptance.summary["startup_diagnostics"]["container_identity_same"]) + self.assertEqual(acceptance.summary["status"], "failed") + self.assertEqual(acceptance.summary["checks"], []) + self.assertEqual(acceptance.summary["blocker"], "original gameplay failure") + with self.assertRaises(production.Failed): + acceptance.verify_staged_documents(self.root / "missing") + self.assertFalse(acceptance.summary["startup_diagnostics"]["container_identity_checked"]) + self.assertIsNone(acceptance.summary["startup_diagnostics"]["container_identity_same"]) + with self.assertRaises(production.Failed): + acceptance.verify_staged_documents(Path("relative/Documents")) + link = self.root / "linked-documents" + link.symlink_to(documents, target_is_directory=True) + with self.assertRaises(production.Failed): + acceptance.verify_staged_documents(link) + self.assertNotIn(str(self.root), str(acceptance.summary["startup_diagnostics"])) + acceptance.staged_documents_identity = None + with self.assertRaises(production.Failed): + acceptance.verify_staged_documents(documents) + + def test_production_order_keeps_all_installs_before_stage_and_budget_before_build(self): + # Static ordering supplements the real transform/filesystem tests; it + # does not execute or mock an Apple command or its successful result. + source = Path(production.__file__).read_text() + tree = ast.parse(source) + acceptance = next(node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == "Acceptance") + methods = {node.name: node for node in acceptance.body if isinstance(node, ast.FunctionDef)} + run = methods["run"] + calls = [(node.lineno, node.func.attr, [arg.value if isinstance(arg, ast.Constant) else None for arg in node.args]) + for node in ast.walk(run) if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) + and isinstance(node.func.value, ast.Name) and node.func.value.id == "self"] + line = lambda name: next(number for number, operation, _ in calls if operation == name) + install = next(number for number, operation, args in calls if operation == "simctl" and args[0] == "install") + container = next(number for number, operation, args in calls if operation == "simctl" and args[0] == "get_app_container") + deadline = next(node.lineno for node in ast.walk(run) if isinstance(node, ast.Assign) + and any(isinstance(target, ast.Attribute) and target.attr == "deadline" for target in node.targets)) + self.assertLess(install, deadline) + self.assertLess(deadline, line("prepare_xcuitest")) + self.assertLess(line("prepare_xcuitest"), container) + self.assertLess(container, line("stage")) + self.assertLess(line("stage"), line("start_xcuitest")) + self.assertLess(line("start_xcuitest"), line("wait_game")) + prepare_calls = [node for node in ast.walk(methods["prepare_xcuitest"]) if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) and node.func.attr == "simctl"] + self.assertEqual(len(prepare_calls), 1) + self.assertEqual(prepare_calls[0].args[0].value, "install") + self.assertFalse(any(key.arg == "check" and isinstance(key.value, ast.Constant) + and key.value.value is False for key in prepare_calls[0].keywords)) + self.assertNotIn("build-for-testing", ast.get_source_segment(source, methods["start_xcuitest"])) + self.assertIn("test-without-building", ast.get_source_segment(source, methods["start_xcuitest"])) + startup = methods["start_xcuitest"] + verification = next(node.lineno for node in ast.walk(startup) if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) and node.func.attr == "verify_staged_documents") + ready_wait = next(node.lineno for node in ast.walk(startup) if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) and node.func.attr == "wait") + self.assertLess(ready_wait, verification) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tools/test_renpy_ios_force_load.py b/tools/test_renpy_ios_force_load.py new file mode 100644 index 000000000..e0e0f7e42 --- /dev/null +++ b/tools/test_renpy_ios_force_load.py @@ -0,0 +1,235 @@ +#!/usr/bin/env python3 +"""Link real Mach-O archives using the production Renios force-load arguments. + +Darwin uses the selected Xcode Simulator SDK and Apple linker. Linux can use +real LLVM tools to check archive loading semantics, without an Apple SDK. +Neither path builds an application or executes an iOS runtime. +""" +import argparse +import hashlib +import json +from pathlib import Path +import shutil +import struct +import subprocess +import sys +import tempfile + +import build_godot_ios_simulator_template as archive_tools + +REPO = Path(__file__).resolve().parents[1] +ROOTS = ("librenpython.a", "librenpy.a", "libpython3.12.a", "libSDL2.a", "libSDL2_image.a") +EXCLUDED = ("libSDL2main.a", "libSDL2_test.a", "libSDL3main.a", "libSDL3_test.a", "libmockrt.a") + + +def run(command): + result = subprocess.run(list(map(str, command)), stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + if result.returncode: + raise RuntimeError(f"Actual command failed ({result.returncode}): {command}\n{result.stderr.decode()}") + return result.stdout + + +def production_arguments(prebuilt): + source = (REPO / "scripts/build_ios.sh").read_text() + functions = [] + for name in ("renios_archive_is_excluded", "collect_renios_archives"): + start = source.index(name + "() {\n") + end = source.index("\n}\n", start) + 3 + functions.append(source[start:end]) + framework = source[source.index("build_renios_runtime_framework() {\n"):] + start = framework.index(" local archive_link_args=() archive\n") + end = framework.index(" local minimum_flag=", start) + actual_builder = framework[start:end] + link_start = framework.index(" xcrun --sdk \"$IOS_SDK\" clang++") + link_end = framework.index('\n -o "$RENPY_FRAMEWORK_OUTPUT/$RENPY_FRAMEWORK_NAME"', link_start) + actual_link = framework[link_start:link_end] + assert actual_link.count('"${archive_link_args[@]}"') == 1, "Production link must use the checked argv array" + assert "-all_load" not in actual_link and "-noall_load" not in actual_link + shell = "set -euo pipefail\n" + "\n".join(functions) + "\n" + shell += 'arguments() {\n local prebuilt="$1"\n' + actual_builder + shell += ' printf "%s\\0" "${archive_link_args[@]}"\n}\narguments "$1"\n' + output = run(["bash", "-c", shell, "force-load-regression", prebuilt]) + return [part.decode() for part in output.rstrip(b"\0").split(b"\0")] + + +def linked_identity(data): + assert struct.unpack_from("= 8 and position + size <= end, "Invalid actual Mach-O load command" + if command == 0x32: # LC_BUILD_VERSION + platforms.append(struct.unpack_from("&2; exit 1; } +mkdir -p "$work/project/.godot" "$work/xdg/data" "$work/xdg/config" "$work/xdg/cache" +export XDG_DATA_HOME="$work/xdg/data" XDG_CONFIG_HOME="$work/xdg/config" XDG_CACHE_HOME="$work/xdg/cache" +cat > "$work/project/project.godot" <<'PROJECT' +config_version=5 +[application] +config/name="RenPy Java boolean boundary" +[rendering] +renderer/rendering_method="gl_compatibility" +PROJECT +"$godot_bin" --headless --path "$work/project" --dump-gdextension-interface --dump-extension-api +python3 - "$work/project/extension_api.json" "$work/sizes.h" <<'PY' +import json, struct, sys +api = json.load(open(sys.argv[1])) +precision = 'double' if api['header']['precision'] == 'double' else 'float' +configuration = f'{precision}_{struct.calcsize("P") * 8}' +sizes = next(x['sizes'] for x in api['builtin_class_sizes'] if x['build_configuration'] == configuration) +sizes = {x['name']: x['size'] for x in sizes} +with open(sys.argv[2], 'w') as f: + f.write(f'#define AETHER_TEST_VARIANT_SIZE {sizes["Variant"]}\n') + f.write(f'#define AETHER_TEST_STRING_SIZE {sizes["String"]}\n') +PY +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror -fPIC -shared \ + -include "$work/sizes.h" -I"$work/project" -I"$repo_root/bridge/renpy_runtime/include" \ + "$repo_root/bridge/renpy_runtime/tests/java_boolean_godot.cpp" -o "$work/project/variant_test.so" +cat > "$work/project/variant_test.gdextension" <<'EXTENSION' +[configuration] +entry_symbol="aether_java_boolean_test_init" +compatibility_minimum="4.1" +[libraries] +linux="res://variant_test.so" +EXTENSION +printf '%s\n' 'res://variant_test.gdextension' > "$work/project/.godot/extension_list.cfg" +cat > "$work/project/quit.gd" <<'SCRIPT' +extends SceneTree +func _initialize() -> void: + quit(0) +SCRIPT +export AETHER_VARIANT_RESULT="$work/result.json" +"$godot_bin" --headless --path "$work/project" --script "$work/project/quit.gd" +python3 - "$work/result.json" "$work/project/extension_api.json" <<'PY' +import json, sys +result = json.load(open(sys.argv[1])) +api = json.load(open(sys.argv[2]))['header'] +assert result['passed'] and result['checks'] == 12, result +assert result['godot'] == '.'.join(str(api[k]) for k in ('version_major', 'version_minor', 'version_patch')), result +assert result['android_java_executed'] is False, result +print(json.dumps(result, sort_keys=True)) +PY diff --git a/tools/test_renpy_mobile_assimp_copy.py b/tools/test_renpy_mobile_assimp_copy.py new file mode 100644 index 000000000..b42c567d9 --- /dev/null +++ b/tools/test_renpy_mobile_assimp_copy.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""Build the actual pinned Assimp recipe and execute host ownership checks. + +This runs the real download/build tasks, compiler, library and C++ objects. +It does not compile an iOS target or execute Ren'Py/mobile gameplay. +""" +import argparse +import importlib.util +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tempfile +from types import SimpleNamespace + +REPO = Path(__file__).resolve().parents[1] +BUILD_PIN = "7bfab40c1174f622f644b24669afd5fb167fbb79" +ASSIMP_PIN = "c35200e38ea8f058812b83de2ef32c6093b0ece2" + + +def command(arguments, *, cwd): + result = subprocess.run(list(map(str, arguments)), cwd=cwd, + text=True, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, timeout=1200) + if result.returncode: + raise RuntimeError(f"Host command failed ({result.returncode}):\n{result.stdout[-8000:]}") + return result.stdout + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("build_source", type=Path) + parser.add_argument("--compiler", default=shutil.which("clang++")) + args = parser.parse_args() + if not args.compiler: + raise SystemExit("A genuine Clang 21 or later host C++ compiler is required.") + # Preserve clang++'s argv[0] so its C++ driver also links the standard library. + compiler = Path(args.compiler).absolute() + assert compiler.is_file() + version = command([compiler, "--version"], cwd=REPO) + detected = re.search(r"clang version (\d+)\.", version) + if not detected or int(detected[1]) < 21: + raise SystemExit("The nontrivial object-copy regression requires genuine Clang 21 or later.") + c_compiler = compiler.with_name("clang") + assert c_compiler.is_file() + assert command(["git", "rev-parse", "HEAD"], cwd=args.build_source).strip() == BUILD_PIN + patches = sorted((REPO / "bridge/renpy_runtime/mobile_launcher/patches/native").glob( + "[0-9][0-9][0-9][0-9]-*.patch")) + assert any(path.name.startswith("0011-") for path in patches) + with tempfile.TemporaryDirectory(prefix="renpy-real-assimp-copy-") as directory: + root = Path(directory) / "renpy-build" + command(["git", "clone", "--no-local", "--depth=1", "--single-branch", "--quiet", "--no-checkout", + args.build_source.resolve(), root], cwd=REPO) + command(["git", "checkout", "--quiet", "--detach", BUILD_PIN], cwd=root) + command([sys.executable, REPO / "tools/apply_renpy_mobile_patch_series.py", + "--checkout", root, "--pin", BUILD_PIN, *patches], cwd=REPO) + sys.path.insert(0, str(root)) + from renpybuild.context import Context + from renpybuild.task import annotators, tasks + # Load the unchanged Assimp task without importing unrelated SDK tasks. + specification = importlib.util.spec_from_file_location("assimp", root / "tasks/assimp.py") + module = importlib.util.module_from_spec(specification) + specification.loader.exec_module(module) + + def host_compiler(context): + context.env("CC", str(c_compiler)) + context.env("CXX", str(compiler)) + context.env("CXXFLAGS", "-Werror=nontrivial-memcall {{ CXXFLAGS }}") + context.env("CMAKE_BUILD_PARALLEL_LEVEL", str(min(os.cpu_count() or 1, 4))) + context.var("cmake_args", "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON") + + annotators.append(host_compiler) + selected = {task.task: task for task in tasks if task.name == "assimp"} + context = Context("host", "host", "3", root, SimpleNamespace()) + selected["download"].run(context) + source = context.tmp / "source/assimp" + assert command(["git", "rev-parse", "HEAD"], cwd=source).strip() == ASSIMP_PIN + selected["build"].run(context) + recipes = json.loads((context.build / "compile_commands.json").read_text()) + scene = [entry for entry in recipes if entry["file"].endswith("/Common/SceneCombiner.cpp")] + assert len(scene) == 1 and "-Werror=nontrivial-memcall" in scene[0]["command"] + assert "-Wno-nontrivial-memcall" not in scene[0]["command"] + + project = Path(directory) / "drivers" + project.mkdir() + (project / "CMakeLists.txt").write_text("""cmake_minimum_required(VERSION 3.20) +project(RealAssimpOwnership LANGUAGES CXX) +set(CMAKE_CXX_STANDARD 17) +find_package(assimp CONFIG REQUIRED) +add_executable(ownership "${DRIVER_ROOT}/ownership.cpp") +target_link_libraries(ownership PRIVATE assimp::assimp) +add_executable(constructors "${DRIVER_ROOT}/constructors.cpp") +target_include_directories(constructors PRIVATE "${ASSIMP_SOURCE}/contrib/Open3DGC") +foreach(target ownership constructors) + target_compile_options(${target} PRIVATE -Wall -Wextra -Werror -Werror=nontrivial-memcall -UNDEBUG) +endforeach() +""") + driver_build = project / "build" + command(["cmake", "-S", project, "-B", driver_build, + "-DCMAKE_CXX_COMPILER=" + str(compiler), + "-DCMAKE_PREFIX_PATH=" + str(context.install), + "-DDRIVER_ROOT=" + str(REPO / "tools/renpy_assimp_acceptance"), + "-DASSIMP_SOURCE=" + str(source)], cwd=root) + command(["cmake", "--build", driver_build, "--parallel", "2"], cwd=root) + ownership = command([driver_build / "ownership"], cwd=root).strip() + constructors = command([driver_build / "constructors"], cwd=root).strip() + print(json.dumps({"status": "passed", "build_source_pin": BUILD_PIN, + "assimp_source_pin": ASSIMP_PIN, + "compiler": version.splitlines()[0], "host": sys.platform, + "production_download_and_build": "passed", + "actual_scene_combiner_copy": ownership, + "actual_open3dgc_constructors": constructors, + "target_compilation": "not_run", "gameplay": "unverified"}, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/tools/test_renpy_mobile_build_parallelism.py b/tools/test_renpy_mobile_build_parallelism.py new file mode 100644 index 000000000..78dc3abba --- /dev/null +++ b/tools/test_renpy_mobile_build_parallelism.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""Exercise patched upstream RunGroup using real host subprocesses. + +These are compiler scheduling/error-boundary tests, not a native runtime build +or gameplay. No SDK or compilation prerequisite is mocked as successful. +""" +import ast +from concurrent.futures import ThreadPoolExecutor +import contextlib +import io +import json +import os +from pathlib import Path +import shlex +import subprocess +import sys +import tempfile +import time +from types import SimpleNamespace +from unittest.mock import patch + +REPO = Path(__file__).resolve().parents[1] +PIN = "7bfab40c1174f622f644b24669afd5fb167fbb79" + + +def main(): + checkout = Path(sys.argv[1]) + assert subprocess.check_output(["git", "-C", str(checkout), "rev-parse", "HEAD"], text=True).strip() == PIN + with tempfile.TemporaryDirectory(prefix="renpy-build-parallelism-") as temporary: + root = Path(temporary) + run_file = root / "renpybuild/run.py" + run_file.parent.mkdir() + run_file.write_bytes(subprocess.check_output(["git", "-C", str(checkout), "show", "HEAD:renpybuild/run.py"])) + build_patch = REPO / "bridge/renpy_runtime/mobile_launcher/patches/native/0009-bounded-build-parallelism.patch" + subprocess.run(["git", "apply", "--check", str(build_patch)], cwd=root, check=True) + subprocess.run(["git", "apply", str(build_patch)], cwd=root, check=True) + tree = ast.parse(run_file.read_text()) + classes = [node for node in tree.body if isinstance(node, ast.ClassDef) and node.name in ("RunCommand", "RunGroup")] + namespace = dict(os=os, shlex=shlex, subprocess=subprocess, sys=sys, ThreadPoolExecutor=ThreadPoolExecutor) + exec(compile(ast.Module(body=classes, type_ignores=[]), str(run_file), "exec"), namespace) + RunGroup = namespace["RunGroup"] + + def context(jobs=None): + environment = dict(os.environ) + environment.pop("RENPY_BUILD_JOBS", None) + if jobs is not None: + environment["RENPY_BUILD_JOBS"] = jobs + return SimpleNamespace(cwd=root, environ=environment, expand=lambda command: command) + + def command(code): + return shlex.join([sys.executable, "-c", code]) + + state = root / "state.json" + state.write_text(json.dumps(dict(running=0, peak=0, finished=0))) + child = """ +import fcntl,json,pathlib,time +state=pathlib.Path('state.json') +def update(delta): + with state.open('r+') as stream: + fcntl.flock(stream,fcntl.LOCK_EX) + data=json.load(stream) + data['running']+=delta + data['peak']=max(data['peak'],data['running']) + data['finished']+=int(delta<0) + stream.seek(0) + json.dump(data,stream) + stream.truncate() +update(1) +time.sleep(0.08) +update(-1) +""" + output = io.StringIO() + with contextlib.redirect_stdout(output), RunGroup(context("2")) as group: + for _ in range(8): + group.run(command(child)) + data = json.loads(state.read_text()) + assert data == dict(running=0, peak=2, finished=8), data + assert all(task.future.done() for task in group.tasks) + with patch.object(os, "cpu_count", lambda: 3): + with RunGroup(context()) as default_group: + assert default_group.executor._max_workers == 3 + for invalid in ("0", "-1", "invalid", "1.5", ""): + try: + RunGroup(context(invalid)) + except ValueError as error: + assert "positive integer" in str(error) + else: + raise AssertionError("invalid job limit accepted: " + repr(invalid)) + + output = io.StringIO() + try: + with contextlib.redirect_stdout(output), RunGroup(context("2")) as failed: + failed.run(command("import sys; print('actual child failure'); sys.exit(7)")) + failed.run(command("print('actual child success')")) + except SystemExit as error: + assert error.code == 1 + else: + raise AssertionError("failed compiler process was reported as success") + assert "Process failed with 7" in output.getvalue(), output.getvalue() + assert sorted(task.code for task in failed.tasks) == [0, 7] + assert all(task.future.done() for task in failed.tasks) + + try: + with RunGroup(context("1")) as missing: + missing.run(shlex.quote(str(root / "nonexistent-compiler"))) + except FileNotFoundError: + pass + else: + raise AssertionError("compiler spawn error was swallowed") + assert missing.executor._shutdown + + started = root / "started" + finished = root / "finished" + unexpected = root / "unexpected" + try: + with RunGroup(context("1")) as interrupted: + interrupted.run(command("import pathlib,time; pathlib.Path('started').touch(); time.sleep(0.2); pathlib.Path('finished').touch()")) + deadline = time.monotonic() + 5 + while not started.exists() and time.monotonic() < deadline: + time.sleep(0.005) + assert started.exists(), "real child did not start" + interrupted.run(command("import pathlib; pathlib.Path('unexpected').touch()")) + raise RuntimeError("actual build-body failure") + except RuntimeError as error: + assert str(error) == "actual build-body failure" + assert finished.exists(), "running child was left behind after the build-body exception" + assert not unexpected.exists(), "queued child executed after cancellation" + assert all(task.future.done() for task in interrupted.tasks) + assert interrupted.tasks[1].future.cancelled() + print("Real subprocess concurrency, failures and cleanup passed; no native runtime build or gameplay performed") + + +if __name__ == "__main__": + main() diff --git a/tools/test_renpy_mobile_cooperative.py b/tools/test_renpy_mobile_cooperative.py new file mode 100755 index 000000000..97e25b9fe --- /dev/null +++ b/tools/test_renpy_mobile_cooperative.py @@ -0,0 +1,405 @@ +#!/usr/bin/env python3 +"""Exercise real greenlet stack switching with a synthetic Ren'Py boundary. + +This verifies cooperative ownership/input/frame contracts. It does not execute +Ren'Py's engine, GL, SDL or a game and is not gameplay acceptance. +""" +import argparse +import ast +import importlib.util +from pathlib import Path +import sys +import tempfile +import textwrap +import threading +import types + +parser = argparse.ArgumentParser() +parser.add_argument("--renpy-src", required=True, type=Path) +args = parser.parse_args() +try: + import greenlet +except ImportError: + raise SystemExit("Install target-matching greenlet 3.2.4 to run cooperative behavior checks") +assert greenlet.__version__ == "3.2.4", greenlet.__version__ +sys.modules["_aether_greenlet"] = greenlet +renpy = types.ModuleType("renpy") +renpy.__path__ = [] +sys.modules["renpy"] = renpy +spec = importlib.util.spec_from_file_location("renpy.aether_mobile", args.renpy_src / "renpy/aether_mobile.py") +mobile = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = mobile +spec.loader.exec_module(mobile) +renpy.aether_mobile = mobile + +class QuitException(Exception): + pass + +renpy.game = types.ModuleType("renpy.game") +renpy.game.QuitException = QuitException +sys.modules["renpy.game"] = renpy.game +log = [] +events = [] +binds = [] +teardown = [] +window = types.SimpleNamespace(get_size=lambda: (800, 600), + get_sdl_window_pointer=lambda: types.SimpleNamespace(value=17)) +pygame = types.ModuleType("renpy.pygame") +for name, number in {"KEYDOWN": 1, "KEYUP": 2, "TEXTINPUT": 3, "MOUSEBUTTONDOWN": 4, + "MOUSEBUTTONUP": 5, "MOUSEMOTION": 6, "MOUSEWHEEL": 7, + "K_ESCAPE": 27}.items(): + setattr(pygame, name, number) +pygame.event = types.SimpleNamespace(post=events.append, Event=lambda event_type, **values: dict(type=event_type, **values), + get_mousewheel_buttons=lambda: True) +pygame.display = types.SimpleNamespace(get_window=lambda: window) +pygame.display.prepare_mobile_shutdown = lambda: teardown.append("protect_host_display") +pygame.display.get_init = lambda: True +pygame.display.quit = lambda: teardown.append("delete_owned_window") +pygame.time = types.SimpleNamespace(set_timer=lambda event_type, interval: teardown.append((event_type, interval))) +pygame.event.clear = lambda event_types: teardown.append(("clear_timers", event_types)) +core = types.ModuleType("renpy.display.core") +core.PERIODIC, core.REDRAW, core.TIMEEVENT = 50, 51, 52 +core.PERIODIC_INTERVAL = 50 +sys.modules[core.__name__] = core +shader = types.ModuleType("renpy.gl2.gl2shader") +shader.retire_mobile_programs = lambda: teardown.append("retire_programs") +sys.modules[shader.__name__] = shader +pygame.key = types.SimpleNamespace(text_input=True) +renpy.pygame = pygame +sys.modules["renpy.pygame"] = pygame +sys.modules["_aether_host"] = types.SimpleNamespace(bind_window=lambda pointer: binds.append(pointer) or 0) +surface = types.SimpleNamespace(get_bytesize=lambda: 4, get_size=lambda: (2, 1), + aether_copy_pixels=lambda: b"\x01\x02\x03\xff\x04\x05\x06\xff") +interface = types.SimpleNamespace(started=True, mobile_save=lambda: log.append("save"), force_redraw=False, + pushed_event=None, kill_textures=lambda: teardown.append("clear_render_caches")) +renpy.config = types.SimpleNamespace(screen_width=800, screen_height=600) +renpy.display = types.SimpleNamespace(interface=interface, draw=types.SimpleNamespace( + screenshot=lambda tree: surface, untranslate_point=lambda x, y: (round(x), round(y)))) +renpy.display.core = core +renpy.audio = types.SimpleNamespace(audio=types.SimpleNamespace(pause_all=lambda: log.append("pause"), + unpause_all=lambda: log.append("resume"))) + +with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + entry = root / "renpy.py" + entry.write_text("def main():\n pass\n") + def nested_game(game_root, argv0): + local_state = {"counter": 41} + try: + log.append("entered") + mobile.cooperative_checkpoint(force=True) + local_state["counter"] += 1 + log.append(local_state["counter"]) + mobile.cooperative_capture("real-render-tree-boundary") + mobile.cooperative_checkpoint(force=True) + except QuitException: + pass + finally: + log.append("cleanup") + mobile._run = nested_game + mobile.cooperative_start(str(root), str(entry)) + assert log == [], "start ran game before tick deadline was established" + mobile.cooperative_set_surface_size(800, 600) + assert mobile.cooperative_surface_size() == (800, 600) + assert mobile.cooperative_tick(1) == "yield" + assert log == ["entered"], "yield unwound active game stack" + for event in [dict(type=2, value=1, code=1, x=0.25, y=0.5), + dict(type=2, value=2, code=1, x=0.5, y=0.75), + dict(type=2, value=3, code=1, x=0.5, y=0.75), + dict(type=1, value=5, code=97, modifiers=2, repeat=True), + dict(type=1, value=6, code=97), dict(type=3, value=0, text="你好、日本語 🐾"), + dict(type=2, value=4, code=0, x=0.5, y=0.75, delta_y=1)]: + mobile.cooperative_input(event) + assert mobile.cooperative_tick(1) == "yield" + assert log == ["entered", 42], "stack local values were lost or cleanup ran early" + assert [event["type"] for event in events] == [4, 6, 5, 1, 2, 3, 4, 5] + assert events[0]["pos"] == (200, 300) + assert events[1]["buttons"] == (True, False, False) + assert events[5]["text"] == "你好、日本語 🐾" + assert events[-2]["button"] == events[-1]["button"] == 4 + assert mobile.cooperative_mouse_pos() == (400, 450) + assert mobile.cooperative_mouse_pressed() == (False, False, False) + assert not mobile.cooperative_key_pressed(97) + renpy.config.screen_width = 600 + renpy.display.draw.untranslate_point = lambda x, y: (round(x + 100), round(y)) + mobile.cooperative_input(dict(type=2, value=1, code=1, x=0, y=0)) + mobile._dispatch_input() + assert events[-1]["pos"] == (100, 0), "input ignored the game viewport letterbox" + assert mobile.cooperative_frame() == (surface.aether_copy_pixels(), 2, 1, 1) + assert binds == [17] + mobile.cooperative_pause() + assert mobile.cooperative_tick(1) == "yield" and "cleanup" not in log + assert teardown == [(50, 0), (51, 0), (52, 0), ("clear_timers", (50, 51, 52))] + before_pause = (list(log), list(teardown)) + mobile.cooperative_pause() + assert (log, teardown) == before_pause, "repeated pause saved or stopped timers twice" + mobile.cooperative_resume() + assert log[-3:] == ["save", "pause", "resume"] and interface.force_redraw + assert teardown[-2:] == [("clear_timers", (50, 51, 52)), (50, 50)] + assert mobile.cooperative_timer_generation() == 1 and events[-1]["type"] == core.PERIODIC + before_resume = (list(log), list(teardown), list(events)) + mobile.cooperative_resume() + assert (log, teardown, events) == before_resume, "repeated resume restarted timers or posted extra events" + assert mobile.cooperative_text_input_state() + errors = [] + def other_thread(): + try: + mobile.cooperative_tick(1) + except RuntimeError as error: + errors.append(str(error)) + thread = threading.Thread(target=other_thread) + thread.start(); thread.join() + assert errors and "original host thread" in errors[0] + teardown.clear() + mobile.cooperative_stop() + assert log.count("cleanup") == 1 and not mobile.cooperative_active() + assert mobile.cooperative_tick(1) == "not_started" + assert teardown == ["retire_programs", "protect_host_display", "clear_render_caches", + (50, 0), (51, 0), (52, 0), ("clear_timers", (50, 51, 52)), "delete_owned_window"] + before = list(teardown) + mobile.cooperative_stop() + assert teardown == before, "repeated stop ran GPU/window teardown twice" + + # Validate official-entry dispatch and SystemExit handling independently. + entry.write_text("def main():\n import sys\n import renpy\n renpy.entry_argv = list(sys.argv)\n raise SystemExit(0)\n") + del sys.modules["renpy.aether_mobile"] + spec.loader.exec_module(mobile) + sys.modules["renpy.aether_mobile"] = mobile + mobile.cooperative_start(str(root), str(entry)) + assert mobile.cooperative_tick(10) == "finished" + assert renpy.entry_argv == [str(entry), str(root)] + mobile.cooperative_stop() + + def fresh_mobile(): + spec.loader.exec_module(mobile) + renpy.display.draw = types.SimpleNamespace( + shader_cache=types.SimpleNamespace(clear=lambda: teardown.append("clear_shader_cache")), + texture_loader=types.SimpleNamespace(retire_mobile=lambda: teardown.append("retire_loader")), + window=window) + teardown.clear() + return mobile + + # A real newborn greenlet is false and must not receive QuitException. + fresh_mobile() + mobile.cooperative_start(str(root), str(entry)) + assert not mobile._game and not mobile._game.dead + mobile.cooperative_stop() + assert not mobile.cooperative_active() + assert teardown.index("retire_programs") < teardown.index("retire_loader") < teardown.index("clear_shader_cache") + assert teardown[-1] == "delete_owned_window" + + # Paused destruction must still unwind the active game exactly once. + fresh_mobile() + def paused_game(*_): + try: + mobile.cooperative_checkpoint(force=True) + except QuitException: + pass + finally: + log.append("paused_finally") + mobile._run = paused_game + mobile.cooperative_start(str(root), str(entry)) + mobile.cooperative_tick(1) + mobile.cooperative_pause() + mobile.cooperative_stop() + assert log.count("paused_finally") == 1 and teardown[-1] == "delete_owned_window" + + # Startup errors return through _run; stop must release any partial display. + fresh_mobile() + entry.write_text("def main():\n raise RuntimeError('bootstrap failed')\n") + mobile.cooperative_start(str(root), str(entry)) + try: + mobile.cooperative_tick(1) + raise AssertionError("startup failure disappeared") + except RuntimeError as error: + assert "bootstrap failed" in str(error) + mobile.cooperative_stop() + assert teardown[-1] == "delete_owned_window" and not mobile.cooperative_active() + + # A failing teardown must remain observable and retryable, not clear owner. + fresh_mobile() + mobile.cooperative_start(str(root), str(entry)) + saved_quit = pygame.display.quit + def failed_quit(): + raise RuntimeError("owned display teardown failed") + pygame.display.quit = failed_quit + try: + mobile.cooperative_stop() + raise AssertionError("cleanup failure disappeared") + except RuntimeError as error: + assert "owned display teardown failed" in str(error) + assert mobile.cooperative_active() and not mobile._terminal_cleanup_done + pygame.display.quit = saved_quit + mobile.cooperative_stop() + assert not mobile.cooperative_active() + + # No renderer/display imports are added when startup never loaded them. + fresh_mobile() + mobile.cooperative_start(str(root), str(entry)) + del sys.modules["renpy.pygame"] + del sys.modules["renpy.gl2.gl2shader"] + mobile.cooperative_stop() + assert "renpy.pygame" not in sys.modules and "renpy.gl2.gl2shader" not in sys.modules + assert teardown == [] + sys.modules["renpy.pygame"] = pygame + sys.modules[shader.__name__] = shader + + # Run the patched Interface.event_wait and timer scheduling source with a + # controlled SDL timer queue. The greenlet is real; the clock/SDL events + # remain fixtures, so this is not renderer or gameplay acceptance. + source = (args.renpy_src / "renpy/display/core.py").read_text() + interface_ast = next(node for node in ast.parse(source).body + if isinstance(node, ast.ClassDef) and node.name == "Interface") + event_wait_ast = next(node for node in interface_ast.body + if isinstance(node, ast.FunctionDef) and node.name == "event_wait") + wait_globals = {"aether_mobile": mobile, "pygame": pygame, + "renpy": types.SimpleNamespace(emscripten=False)} + exec(compile(ast.Module(body=[event_wait_ast], type_ignores=[]), str(args.renpy_src / "renpy/display/core.py"), "exec"), + wait_globals) + checkpoint_start = source.index(" aether_mobile.cooperative_checkpoint()") + checkpoint_end = source.index(' renpy.plog(1, "start of interact while loop")', checkpoint_start) + checkpoint_source = compile(textwrap.dedent(source[checkpoint_start:checkpoint_end]), "interact_core resume boundary", "exec") + schedule_start = source.index(" # Compute the redraw time and set the redraw timer.") + schedule_end = source.index(" # Process the invoke queue.", schedule_start) + schedule_source = compile(textwrap.dedent(source[schedule_start:schedule_end]), "interact_core SDL timer scheduling", "exec") + + class TimerQueue: + def __init__(self): + self.now = 0 + self.timers = {} + self.queue = [] + self.calls = [] + + def set_timer(self, event_type, interval, once=False): + self.calls.append((event_type, interval, once)) + self.timers.pop(event_type, None) + if interval: + self.timers[event_type] = (interval, self.now + interval, once) + + def advance(self, milliseconds): + self.now += milliseconds + for event_type, (interval, due, once) in list(self.timers.items()): + while due <= self.now: + self.post(dict(type=event_type)) + if once: + self.timers.pop(event_type) + break + due += interval + else: + self.timers[event_type] = (interval, due, once) + + def clear(self, event_types): + self.queue[:] = [event for event in self.queue if event["type"] not in event_types] + + def post(self, event): + self.queue.append(event) + + def poll(self): + return types.SimpleNamespace(**self.queue.pop(0)) if self.queue else types.SimpleNamespace(type=pygame.NOEVENT) + + timers = TimerQueue() + pygame.NOEVENT = 0 + pygame.time = types.SimpleNamespace(set_timer=timers.set_timer) + pygame.event = types.SimpleNamespace(Event=lambda event_type, **values: dict(type=event_type, **values), + post=timers.post, poll=timers.poll, clear=timers.clear) + fresh_mobile() + wait_interface = types.SimpleNamespace(pushed_event=None, last_event=None, + check_background_screenshot=lambda: None, timeout_time=2000.0, + post_time_event=lambda: timers.post(dict(type=core.TIMEEVENT)), + redraw_event=dict(type=core.REDRAW)) + interface.pushed_event = None + boundary = {"aether_mobile": mobile, "pygame": pygame, "self": wait_interface, + "PERIODIC": core.PERIODIC, "REDRAW": core.REDRAW, "TIMEEVENT": core.TIMEEVENT, + "get_time": lambda: timers.now / 1000.0, "redraw_time": 1000.0, + "old_redraw_time": None, "old_timeout_time": None, + "aether_timer_generation": mobile.cooperative_timer_generation()} + resumed_waits = [] + def timer_wait_game(*_): + stack_token = object() + try: + exec(schedule_source, boundary) + for _ in range(2): + event = wait_globals["event_wait"](wait_interface) + exec(checkpoint_source, boundary) + exec(schedule_source, boundary) + resumed_waits.append((stack_token, event.type)) + mobile.cooperative_checkpoint(force=True) + except QuitException: + pass + + mobile._run = timer_wait_game + mobile.cooperative_start(str(root), str(entry)) + timers.set_timer(core.PERIODIC, core.PERIODIC_INTERVAL) + assert mobile.cooperative_tick(1000) == "yield" and resumed_waits == [] + assert boundary["old_redraw_time"] == 1000.0 and boundary["old_timeout_time"] == 2000.0 + timers.advance(500) + assert len(timers.queue) == 10, "fixture did not reproduce periodic backlog" + timers.post(dict(type=pygame.TEXTINPUT, text="retained input")) + interface.pushed_event = types.SimpleNamespace(type=core.PERIODIC) + mobile.cooperative_pause() + assert timers.timers == {} and interface.pushed_event is None + assert timers.queue == [dict(type=pygame.TEXTINPUT, text="retained input")] + timers.advance(50000) + assert len(timers.queue) == 1 and mobile.cooperative_tick(1000) == "yield" + assert resumed_waits == [], "paused Tick resumed the saved event_wait" + # Model an SDL callback already in flight at removal; resume clears it. + timers.post(dict(type=core.TIMEEVENT)) + mobile.cooperative_resume() + assert [event["type"] for event in timers.queue] == [pygame.TEXTINPUT, core.PERIODIC] + assert mobile.cooperative_tick(1000) == "yield" + assert resumed_waits[0][1] == pygame.TEXTINPUT + assert boundary["old_timeout_time"] == wait_interface.timeout_time == 2000.0 + assert timers.timers[core.REDRAW][0] == 1000 + assert timers.timers[core.TIMEEVENT][0] == int((2000.0 - timers.now / 1000.0) * 1000 + 1) + assert not any(event["type"] == core.TIMEEVENT for event in timers.queue), "resume fired a future wait early" + # A second pause passes the existing deadline. Actual scheduler source + # posts it once on resume instead of retaining a canceled timer forever. + mobile.cooperative_pause() + timers.advance(2000000) + assert timers.queue == [] and timers.timers == {} + mobile.cooperative_resume() + assert mobile.cooperative_tick(1000) == "yield" + assert resumed_waits[1][0] is resumed_waits[0][0], "resume rebuilt the interaction stack" + assert resumed_waits[1][1] == core.PERIODIC + assert wait_interface.timeout_time is None + assert sum(event["type"] == core.TIMEEVENT for event in timers.queue) == 1 + mobile.cooperative_stop() + assert timers.timers == {} and timers.queue == [] + + # A pause before display startup must neither import it nor arm timers. + fresh_mobile() + interface.started = False + mobile.cooperative_start(str(root), str(entry)) + before = len(timers.calls) + saved_pygame = sys.modules.pop("renpy.pygame") + saved_core = sys.modules.pop("renpy.display.core") + mobile.cooperative_pause() + mobile.cooperative_resume() + assert len(timers.calls) == before and mobile.cooperative_timer_generation() == 0 + assert "renpy.pygame" not in sys.modules and "renpy.display.core" not in sys.modules + sys.modules["renpy.pygame"] = saved_pygame + sys.modules["renpy.display.core"] = saved_core + mobile.cooperative_stop() + interface.started = True + +renderer_source = (args.renpy_src / "renpy/gl2/gl2draw.pyx").read_text() +start = renderer_source.index(" gles = self.gles") +end = renderer_source.index(" # Select the GL attributes and hints.", start) +size_selection = textwrap.dedent(renderer_source[start:end]) +for platform in ("android", "ios"): + for enabled in (False, True): + context = { + "self": types.SimpleNamespace(gles=False), "pwidth": 1600, "pheight": 900, + "renpy": types.SimpleNamespace(android=platform == "android", ios=platform == "ios", + aether_mobile=types.SimpleNamespace(cooperative_active=lambda: enabled), + config=types.SimpleNamespace(gl2_modify_window_flags=None)), + "pygame": types.SimpleNamespace(OPENGL=1, DOUBLEBUF=2, WINDOW_ALLOW_HIGHDPI=4, RESIZABLE=8), + } + exec(size_selection, context) + assert (context["pwidth"], context["pheight"]) == ((1600, 900) if enabled else (0, 0)) + assert context["gles"] + if platform == "ios": + assert context["window_flags"] & 12 == 12 + +print("Cooperative checks passed: real greenlet ownership, input/frame contracts, paused timer queue and resumed waits, natural/paused/newborn/failed startup shutdown, terminal cleanup order, retry errors and idempotence") +print("Synthetic engine boundaries only; Ren'Py/SDL/GL/device gameplay was not run") diff --git a/tools/test_renpy_mobile_darwin_build.py b/tools/test_renpy_mobile_darwin_build.py new file mode 100644 index 000000000..9cc1d2c2d --- /dev/null +++ b/tools/test_renpy_mobile_darwin_build.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""Check real patched task configuration for Darwin iOS and Linux Android. + +Xcode paths are fixtures for configuration tests only. No preflight, compiler, +SDK contents or gameplay is simulated as successful. +""" +import ast +from pathlib import Path +import os +import shutil +import subprocess +import sys +import tarfile +import tempfile +from types import SimpleNamespace +from unittest.mock import patch + +REPO = Path(__file__).resolve().parents[1] +PIN = "7bfab40c1174f622f644b24669afd5fb167fbb79" + + +def task_function(path, name, namespace, *, archs=None): + tree = ast.parse(path.read_text()) + selected = next(node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == name and + (archs is None or any(isinstance(decorator, ast.Call) and + any(keyword.arg == "archs" and keyword.value.value == archs for keyword in decorator.keywords) + for decorator in node.decorator_list))) + selected.decorator_list = [] + exec(compile(ast.Module(body=[selected], type_ignores=[]), str(path), "exec"), namespace) + return namespace[name] + + +def autoconf_compiler_boundary(root, Context, run): + """Run the real NASM configure with real host CC through the patched formatter.""" + compiler = shutil.which("cc") + assert compiler is not None + sdk = subprocess.check_output(["xcrun", "--sdk", "macosx", "--show-sdk-path"], text=True).strip() \ + if sys.platform == "darwin" else "/" + + def selected_tool(arguments, **_kwargs): + if arguments[1] == "--find": + return compiler if arguments[2] in ("clang", "clang++") else shutil.which(arguments[2]) + assert arguments == ["xcrun", "--sdk", "macosx", "--show-sdk-path"] + return sdk + + with patch.object(run.sys, "platform", "darwin"), \ + patch.object(run.subprocess, "check_output", selected_tool): + context = Context("host", "host", "3", root, SimpleNamespace()) + run.llvm(context) + # Use the real compiler directly: the boundary under test is Autoconf's + # expansion of CC, not ccache installation or any emulated Xcode SDK. + current = context.environ["CC"].removeprefix("ccache ") + old = f'"{compiler}" -isysroot "{sdk}" -std=gnu17' + for name, command, expect_success in (("quoted-cc", old, False), ("formatted-cc", current, True)): + destination = root / name + destination.mkdir() + with tarfile.open(root / "source/nasm-2.14.02.tar.gz") as source: + source.extractall(destination, filter="data") + source_root = destination / "nasm-2.14.02" + environment = {**os.environ, "CC": command, "CFLAGS": "", "CPPFLAGS": "", "LDFLAGS": ""} + result = subprocess.run(["./configure", "--prefix=" + str(destination / "install")], + cwd=source_root, env=environment, capture_output=True, text=True) + if expect_success: + assert result.returncode == 0, result.stdout + result.stderr + else: + assert result.returncode == 77, result.stdout + result.stderr + assert '"' + compiler + '"' in (source_root / "config.log").read_text() + print("Actual host NASM configure reproduced quoted-CC failure and passed the patched command; no iOS compilation performed") + + +def main(): + checkout = Path(sys.argv[1]) + assert subprocess.check_output(["git", "-C", str(checkout), "rev-parse", "HEAD"], text=True).strip() == PIN + with tempfile.TemporaryDirectory(prefix="renpy-darwin-build-") as temporary: + root = Path(temporary) + archive = subprocess.Popen([ + "git", "-C", str(checkout), "archive", "HEAD", "renpybuild", "tasks", + "source/Python-3.12.8-Setup.stdlib", "tools/cmake_build_variables.cmake", + "source/nasm-2.14.02.tar.gz", + ], stdout=subprocess.PIPE) + assert archive.stdout is not None + with tarfile.open(fileobj=archive.stdout, mode="r|*") as source: + source.extractall(root, filter="data") + assert archive.wait() == 0 + build_patch = REPO / "bridge/renpy_runtime/mobile_launcher/patches/native/0007-darwin-ios-build.patch" + subprocess.run(["git", "apply", "--include=tasks/metalangle.py", str(REPO / "bridge/renpy_runtime/mobile_launcher/patches/native/0006-ios-offscreen-renderer.patch")], cwd=root, check=True) + subprocess.run(["git", "apply", "--check", str(build_patch)], cwd=root, check=True) + subprocess.run(["git", "apply", str(build_patch)], cwd=root, check=True) + sys.path.insert(0, str(root)) + from renpybuild.context import Context + import renpybuild.run as run + + def xcrun(arguments, **_kwargs): + assert arguments[0] == "xcrun", arguments + if arguments[1] == "--find": + return "/fixture/Xcode/usr/bin/" + arguments[2] + assert arguments[1] == "--sdk" and arguments[3] == "--show-sdk-path", arguments + sdk = root / "local-sdk" / arguments[2] + sdk.mkdir(parents=True, exist_ok=True) + return str(sdk) + + with patch.object(run.sys, "platform", "darwin"), patch.object(run.subprocess, "check_output", xcrun), \ + patch.dict(run.os.environ, {"AETHERKIRI_RENPY_HOST_DEPS": "/fixture/openssl:/fixture/libffi"}), \ + patch.object(run.sysconfig, "get_config_var", lambda _name: "aarch64-apple-darwin24"): + for architecture, target in (("arm64", "arm64-apple-ios13.0"), ("sim-arm64", "arm64-apple-ios13.0-simulator")): + context = Context("ios", architecture, "3", root, SimpleNamespace()) + context.set_names("python", "build", "renpython") + assert target in context.environ["CC"] + assert "-isysroot" in context.environ["CC"] + assert '"' not in context.environ["CC"] + assert "-fuse-ld=lld" not in context.environ["CC"] + assert "-stdlib=libc++" in context.environ["CXX"] + assert "-lmockrt" not in context.environ["LDFLAGS"] + assert "/fixture/openssl" not in context.environ["LDFLAGS"], "host libraries leaked into target linking" + assert context.variables["lipo"] == "xcrun lipo" + assert "-DCMAKE_POLICY_VERSION_MINIMUM=3.5" in context.variables["cmake_args"] + host_generation = Context("ios", architecture, "3", root, SimpleNamespace()) + host_generation.set_names("host-python", "gen_static3", "librenpy") + assert host_generation.install == root / "tmp" / ("install.ios-" + architecture) + assert str(root / "local-sdk/macosx") in host_generation.environ["CC"] + assert "/fixture/libffi/include" in host_generation.environ["CFLAGS"] + host = Context("host", "host", "3", root, SimpleNamespace()) + host.set_names("host", "build_host", "hostpython3") + assert "/fixture/openssl/lib" in host.environ["LDFLAGS"] + rendered = host.expand((root / "source/Python-3.12.8-Setup.stdlib").read_text()) + assert "_scproxy -DPy_BUILD_CORE_BUILTIN _scproxy.c -framework SystemConfiguration" in rendered + namespace = {"Context": Context, "sys": sys, "subprocess": run.subprocess} + toolchain = task_function(root / "tasks/toolchain.py", "build", namespace, + archs="sim-arm64,sim-x86_64") + cross = Context("ios", "sim-arm64", "3", root, SimpleNamespace()) + cross.set_names("cross", "build", "toolchain") + cross.run = lambda _command: None + toolchain(cross) + assert cross.path("{{ cross }}/sdk").is_symlink() + assert cross.path("{{ cross }}/sdk").samefile(root / "local-sdk/iphonesimulator") + shim_task = task_function(root / "tasks/toolchain.py", "mockrt", namespace) + shim_context = Context("ios", "arm64", "3", root, SimpleNamespace()) + shim_context.set_names("arch", "mockrt", "toolchain") + obsolete_shim = shim_context.path("{{ install }}/lib/libmockrt.a") + obsolete_shim.parent.mkdir(parents=True, exist_ok=True) + obsolete_shim.touch() + def reject_shim_compile(command): + raise AssertionError("native Xcode build attempted the Linux runtime shim: " + command) + shim_context.run = reject_shim_compile + shim_task(shim_context) + assert not obsolete_shim.exists() + with patch.object(run.sys, "platform", "linux"), \ + patch.object(run.sysconfig, "get_config_var", lambda _name: "x86_64-pc-linux-gnu"): + context = Context("android", "arm64_v8a", "3", root, SimpleNamespace()) + context.set_names("python", "build", "renpython") + assert "aarch64-linux-android21-clang" in context.environ["CC"] + assert "linux-x86_64" in context.environ["CC"] + assert not context.native_darwin + assert "CMAKE_POLICY_VERSION_MINIMUM" not in context.variables["cmake_args"] + autoconf_compiler_boundary(root, Context, run) + print("Darwin iOS and Linux Android task configuration passed; no native build or gameplay performed") + + +if __name__ == "__main__": + main() diff --git a/tools/test_renpy_mobile_freetype_host_tools.py b/tools/test_renpy_mobile_freetype_host_tools.py new file mode 100644 index 000000000..e33d2430e --- /dev/null +++ b/tools/test_renpy_mobile_freetype_host_tools.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +"""Run the pinned FreeType task, its real host exporter and target archive. + +Darwin checks native macOS plus real device/Simulator SDK builds. Linux checks +the native library and, with --ndk, a genuine Android AArch64 cross archive. +The genuine native apinames executable is run; target libraries are not run. +""" +import argparse +from contextlib import contextmanager +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import platform +import shlex +import shutil +import struct +import subprocess +import sys +import tempfile +from types import SimpleNamespace + +REPO = Path(__file__).resolve().parents[1] +PIN = "7bfab40c1174f622f644b24669afd5fb167fbb79" +SOURCE_SHA256 = "5c3a8e78f7b24c20b25b54ee575d6daa40007a5f4eea2845861c3409b3021747" +TARGET_VARIABLES = ("CC", "CXX", "CPP", "AR", "RANLIB", "NM", "CFLAGS", "CXXFLAGS", + "CPPFLAGS", "LDFLAGS", "SDKROOT", "IPHONEOS_DEPLOYMENT_TARGET") + + +def command(arguments, *, cwd, env=None): + result = subprocess.run(list(map(str, arguments)), cwd=cwd, env=env, text=True, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=600) + if result.returncode: + raise RuntimeError(f"Command failed ({result.returncode}): {shlex.join(map(str, arguments))}\n" + + result.stdout[-8000:]) + return result.stdout + + +@contextmanager +def build_log(path): + sys.stdout.flush() + sys.stderr.flush() + previous = [os.dup(1), os.dup(2)] + try: + with path.open("w") as output: + os.dup2(output.fileno(), 1) + os.dup2(output.fileno(), 2) + yield + finally: + sys.stdout.flush() + sys.stderr.flush() + for descriptor, saved in zip((1, 2), previous): + os.dup2(saved, descriptor) + os.close(saved) + + +def object_identity(data): + if data[:4] == b"\x7fELF": + endian = "<" if data[5] == 1 else ">" + return {"format": "ELF", "machine": struct.unpack_from(endian + "H", data, 18)[0]} + assert data[:4] == bytes.fromhex("cffaedfe"), "A genuine ELF or Mach-O64 object is required" + machine, commands = struct.unpack_from("= 8 + if kind == 0x32: # LC_BUILD_VERSION + platforms.append(struct.unpack_from(" 0 + assert command(["git", "rev-parse", "HEAD"], cwd=args.build_source).strip() == PIN + evidence = (args.output_dir or Path(tempfile.mkdtemp(prefix="renpy-freetype-evidence-"))).resolve() + evidence.mkdir(parents=True, exist_ok=True) + print("FreeType evidence:", evidence, flush=True) + patches = sorted((REPO / "bridge/renpy_runtime/mobile_launcher/patches/native").glob( + "[0-9][0-9][0-9][0-9]-*.patch")) + assert any(path.name.startswith("0012-") for path in patches) + + with tempfile.TemporaryDirectory(prefix="renpy-real-freetype-tools-") as temporary: + root = Path(temporary) / "renpy-build" + command(["git", "clone", "--no-local", "--depth=1", "--single-branch", "--quiet", "--no-checkout", + args.build_source.resolve(), root], cwd=REPO) + command(["git", "checkout", "--quiet", "--detach", PIN], cwd=root) + command([sys.executable, REPO / "tools/apply_renpy_mobile_patch_series.py", + "--checkout", root, "--pin", PIN, *patches], cwd=REPO) + assert hashlib.sha256((root / "source/freetype-2.13.3.tar.gz").read_bytes()).hexdigest() == SOURCE_SHA256 + sys.path.insert(0, str(root)) + from renpybuild.context import Context + from renpybuild.task import annotators, tasks + specification = importlib.util.spec_from_file_location("freetype", root / "tasks/freetype.py") + module = importlib.util.module_from_spec(specification) + specification.loader.exec_module(module) + selected = {task.task: task for task in tasks if task.name == "freetype"} + + class TrackingContext(Context): + def run(self, template, verbose=False, quiet=False, **kwargs): + arguments = shlex.split(self.expand(template, **kwargs)) + self.calls.append(arguments) + if arguments[0] == "./configure": + self.target_before = {name: self.environ.get(name) for name in TARGET_VARIABLES} + if arguments[:2] == ["make", "install"]: + # Exercise install's real exporter rebuild, not just an + # up-to-date install that never uses its CCexe assignment. + (self.cwd / "objs/apinames").unlink() + (self.cwd / "objs/ftexport.sym").unlink() + return super().run(template, verbose=verbose, quiet=quiet, **kwargs) + + native_machine = {"x86_64": 62, "aarch64": 183, "arm64": 183}[platform.machine()] + if sys.platform == "darwin": + assert platform.machine() == "arm64", "This iOS acceptance uses a genuine arm64 Mac runner" + cases = [("mac-host", "mac", "arm64", "macosx", 1), + ("ios-device", "ios", "arm64", "iphoneos", 2), + ("ios-simulator", "ios", "sim-arm64", "iphonesimulator", 7)] + else: + assert sys.platform == "linux" + cases = [("linux-host", "linux", platform.machine(), None, None)] + if args.ndk: + ndk = args.ndk.resolve(strict=True) + assert 'Pkg.Revision = 29.' in (ndk / "source.properties").read_text() + toolchain = ndk / "toolchains/llvm/prebuilt/linux-x86_64/bin" + assert (toolchain / "clang").is_file() + cases.append(("android-arm64", "android", "arm64_v8a", None, None)) + + for label, target_platform, architecture, sdk_name, expected_platform in cases: + case = evidence / label + case.mkdir(exist_ok=True) + sdk = None + if sdk_name: + sdk = Path(command(["xcrun", "--sdk", sdk_name, "--show-sdk-path"], cwd=REPO).strip()).resolve(strict=True) + cross_sdk = root / f"tmp/cross.{target_platform}-{architecture}/sdk" + cross_sdk.parent.mkdir(parents=True, exist_ok=True) + cross_sdk.symlink_to(sdk) + + def real_compilers(context): + context.var("make", "nice make -j " + str(args.jobs)) + # Preserve the production target flags, replacing only missing + # cache/tool executable locations with genuine local compilers. + if target_platform == "android": + cc = [str(toolchain / "clang"), "--target=aarch64-linux-android21", "-std=gnu17"] + cpp = cc[:-1] + ["-E"] + ar, ranlib, nm = [str(toolchain / name) for name in ("llvm-ar", "llvm-ranlib", "llvm-nm")] + elif sdk: + cc = shlex.split(context.environ["CC"]) + assert cc.pop(0) == "ccache" + cpp = shlex.split(context.environ["CPP"]) + assert cpp.pop(0) == "ccache" + ar, ranlib, nm = [context.environ[name] for name in ("AR", "RANLIB", "NM")] + context.env("SDKROOT", str(sdk)) + else: + cc = [shutil.which("cc")] + cpp = cc + ["-E"] + ar, ranlib, nm = [shutil.which(name) for name in ("ar", "ranlib", "nm")] + context.var("cross_config", "--build=" + command([cc[0], "-dumpmachine"], cwd=REPO).strip() + + " --host=" + command([cc[0], "-dumpmachine"], cwd=REPO).strip()) + assert all(cc) and ar and ranlib and nm + context.env("CC", shlex.join(cc)) + context.env("CPP", shlex.join(cpp)) + for variable, value in (("AR", ar), ("RANLIB", ranlib), ("NM", nm)): + context.env(variable, value) + + annotators.append(real_compilers) + context = TrackingContext(target_platform, architecture, "3", root, SimpleNamespace()) + context.calls = [] + print("Actual FreeType production task:", label, flush=True) + try: + with build_log(case / "production-build.log"): + selected["unpack"].run(context) + selected["build"].run(context) + except BaseException: + print("New FreeType build errors:", (case / "production-build.log").read_text(errors="replace")[-8000:], flush=True) + raise + finally: + annotators.remove(real_compilers) + assert context.target_before == {name: context.environ.get(name) for name in TARGET_VARIABLES} + (case / "production-commands.json").write_text(json.dumps(context.calls, indent=2) + "\n") + (case / "target-toolchain.json").write_text(json.dumps(context.target_before, indent=2) + "\n") + source = context.cwd + apinames = source / "objs/apinames" + shutil.copy2(apinames, case / "apinames") + shutil.copy2(context.build / "aether-freetype-host-cc", case / "aether-freetype-host-cc") + exports = (source / "objs/ftexport.sym").read_text() + assert "FT_Init_FreeType" in exports and "FT_OpenType_Validate" in exports + assert "TT_New_Context" in exports and "TT_RunIns" in exports + shutil.copy2(source / "objs/ftexport.sym", case / "ftexport.sym") + host_identity = object_identity(apinames.read_bytes()) + if sys.platform == "darwin": + assert host_identity == {"format": "Mach-O", "machine": 0x100000C, "platform": 1} + else: + assert host_identity == {"format": "ELF", "machine": native_machine} + archive = context.install / "lib/libfreetype.a" + assert archive.is_file() + shutil.copy2(archive, case / "libfreetype.a") + members = command([context.environ["AR"], "t", archive], cwd=root).splitlines() + objects = [member for member in members if member.endswith(".o")] + assert objects + identities = [] + for member in objects: + data = subprocess.check_output([context.environ["AR"], "p", str(archive), member], cwd=root) + identity = object_identity(data) + expected = {"format": "Mach-O", "machine": 0x100000C, "platform": expected_platform} if sdk \ + else {"format": "ELF", "machine": 183 if target_platform == "android" else native_machine} + assert identity == expected, (member, identity, expected) + identities.append(identity) + nm_flags = ["-g", "-U"] if sys.platform == "darwin" else ["--defined-only", "--extern-only"] + defined = command([context.environ["NM"], *nm_flags, archive], cwd=root) + names = {line.split()[-1].lstrip("_") for line in defined.splitlines() if line.split()} + assert {"FT_Init_FreeType", "FT_OpenType_Validate"} <= names + (case / "defined-symbols.txt").write_text(defined) + result = {"status": "passed", "case": label, "source_sha256": SOURCE_SHA256, + "production_unpack_build_install": "passed", "install_exporter_rebuild": "passed", + "apinames": host_identity, "exported_api_names": len(exports.splitlines()), + "apinames_sha256": hashlib.sha256(apinames.read_bytes()).hexdigest(), + "target_archive_objects": len(objects), "target_archive_identity": identities[0], + "target_archive_sha256": hashlib.sha256(archive.read_bytes()).hexdigest(), + "target_execution": "not_run", "gameplay": "unverified"} + (case / "result.json").write_text(json.dumps(result, indent=2) + "\n") + print(json.dumps(result, sort_keys=True), flush=True) + + +if __name__ == "__main__": + main() diff --git a/tools/test_renpy_mobile_ios_host_python.py b/tools/test_renpy_mobile_ios_host_python.py new file mode 100644 index 000000000..7562855f8 --- /dev/null +++ b/tools/test_renpy_mobile_ios_host_python.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""Execute the iOS build's host Python tools and pure-package installation. + +This uses real host CPython, pip, published packages and the patched task body. +It does not compile an iOS object, launch a target interpreter or run gameplay. +""" +import argparse +import ast +import importlib.metadata +import json +from pathlib import Path +import subprocess +import sys +import tempfile +from types import SimpleNamespace + +REPO = Path(__file__).resolve().parents[1] +PIN = "7bfab40c1174f622f644b24669afd5fb167fbb79" +RENPY_PIN = "39895c1e017f0b36ffea2447d97eccd69d76ee1c" + + +def capture(command, *, cwd, env=None): + result = subprocess.run(list(map(str, command)), cwd=cwd, env=env, + text=True, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, timeout=600) + if result.returncode: + raise RuntimeError(f"Host command failed ({result.returncode}):\n{result.stdout[-6000:]}") + return result.stdout + + +def task_functions(path, context_type): + functions = [node for node in ast.parse(path.read_text()).body + if isinstance(node, ast.FunctionDef) and node.name in ("annotate", "pip")] + assert len(functions) == 2 + for node in functions: + node.decorator_list = [] + namespace = {"Context": context_type} + exec(compile(ast.Module(body=functions, type_ignores=[]), str(path), "exec"), namespace) + return namespace + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("build_source", type=Path) + parser.add_argument("--renpy-src", required=True, type=Path) + args = parser.parse_args() + if sys.version_info[:2] != (3, 12): + raise SystemExit("The pinned build's host-tool regression requires genuine CPython 3.12.") + assert capture(["git", "rev-parse", "HEAD"], cwd=args.build_source).strip() == PIN + assert capture(["git", "rev-parse", "HEAD"], cwd=args.renpy_src).strip() == RENPY_PIN + patches = sorted((REPO / "bridge/renpy_runtime/mobile_launcher/patches/native").glob( + "[0-9][0-9][0-9][0-9]-*.patch")) + assert any(path.name.startswith("0010-") for path in patches) + with tempfile.TemporaryDirectory(prefix="renpy-real-host-python-") as directory: + root = Path(directory) / "renpy-build" + # Limit the copy to the materialized pinned HEAD. Local shared clones + # can traverse unrelated missing objects in a promisor repository. + capture(["git", "clone", "--no-local", "--depth=1", "--single-branch", "--quiet", "--no-checkout", + args.build_source.resolve(), root], cwd=REPO) + capture(["git", "checkout", "--quiet", "--detach", PIN], cwd=root) + capture([sys.executable, REPO / "tools/apply_renpy_mobile_patch_series.py", + "--checkout", root, "--pin", PIN, *patches], cwd=REPO) + # Run the real task through a filesystem alias as on macOS /var -> + # /private/var, so lexical path containment cannot mask a wrong prefix. + alias = Path(directory) / "renpy-build-alias" + alias.symlink_to(root, target_is_directory=True) + root = alias + sys.path.insert(0, str(root)) + from renpybuild.context import Context + from renpybuild.ios_host_python import prepare_ios_host_python + + tasks = task_functions(root / "tasks/python3.py", Context) + # These are genuine production Context paths/commands. No target SDK + # or compiler is invoked by this host-tool/package-only regression. + context = Context("ios", "arm64", "3", root, SimpleNamespace()) + context.set_names("python", "pip", "python3") + context.var("host", Path(sys.base_prefix)) + tasks["annotate"](context) + poison = root / "foreign-stdlib" + poison.mkdir() + (poison / "subprocess.py").write_text("raise RuntimeError('foreign target stdlib loaded')\n") + context.env("PYTHONHOME", str(poison)) + context.env("PYTHONPATH", str(poison)) + prepare_ios_host_python(context) + + # Exercise a versioned-only distribution layout with the actual host + # interpreter. The symlink resolves to real CPython and its real stdlib; + # this does not substitute an interpreter or simulate a target SDK. + versioned_host = root / "versioned-only-host" + (versioned_host / "bin").mkdir(parents=True) + canonical = Path(sys._base_executable).resolve(strict=True) + (versioned_host / "bin/python3.12").symlink_to(canonical) + assert not (versioned_host / "bin/python3").exists() + context.var("host", versioned_host) + prepare_ios_host_python(context) + assert (context.install / "aether-host-tools/bin/python3").resolve(strict=True) == canonical + tasks["pip"](context) + + wrapper = context.path("{{ install }}/bin/hostpython3") + assert wrapper.read_bytes().startswith(b"#!/bin/sh\n"), "Host Mach-O/ELF was copied instead of a build-only wrapper" + probe = root / "host-generation-probe.py" + probe.write_text("""import json, pathlib, subprocess, sys +sys.path.insert(0, sys.argv[1]) +import setuplib, setuptools, _posixsubprocess +assert sys.flags.isolated +subprocess.run([sys.executable, '-I', '-c', 'import subprocess, _posixsubprocess'], check=True) +print(json.dumps({'isolated': bool(sys.flags.isolated), 'stdlib': subprocess.__file__, + 'setuptools': setuptools.__version__, 'prefix': sys.prefix})) +""") + tools = json.loads(capture([wrapper, probe, args.renpy_src.resolve() / "scripts"], + cwd=root, env=context.environ).strip().splitlines()[-1]) + assert tools["setuptools"] == "74.1.2" + install = context.install.resolve(strict=True) + stdlib = Path(tools["stdlib"]).resolve(strict=True) + prefix = Path(tools["prefix"]).resolve(strict=True) + expected_prefix = (context.install / "aether-host-tools").resolve(strict=True) + assert not stdlib.is_relative_to(install), (stdlib, install) + assert prefix == expected_prefix, (prefix, expected_prefix) + site = context.path("{{ install }}/lib/{{ pythonver }}/site-packages") + versions = {dist.metadata["Name"].lower().replace("_", "-"): dist.version + for dist in importlib.metadata.distributions(path=[str(site)])} + expected = {"future": "1.0.0", "six": "1.16.0", "rsa": "4.9", "pyasn1": "0.6.1", + "ecdsa": "0.19.0", "urllib3": "2.7.0", "charset-normalizer": "3.3.2", + "chardet": "5.2.0", "idna": "3.8", "requests": "2.33.0", + "pefile": "2022.5.30", "websockets": "12.0", + "setuptools": "74.1.2", "pysocks": "1.7.1"} + assert all(versions.get(name) == version for name, version in expected.items()), versions + assert "certifi" in versions and (site / "certifi/cacert.pem").is_file() + assert (site / "websockets/__init__.py").is_file() + assert (site / "charset_normalizer/__init__.py").is_file() + assert (site / "pefile.py").is_file() + assert not any(path.is_file() and path.suffix.lower() in (".so", ".dylib", ".pyd") + for path in site.rglob("*")), "A native extension entered target site-packages" + runtime = root / "runtime-compilation-probe.py" + runtime.write_text("value = 'real CPython bytecode'\n") + context.compile(runtime) + assert runtime.with_suffix(".pyc").is_file() + result = {"status": "passed", "host_python": sys.version.split()[0], + "installed_and_versioned_only_host_layouts": "passed", + "canonical_host_venv_and_stdlib_paths": "passed", + "isolated_stdlib_and_subprocess": "passed", "pinned_setuplib_import": "passed", + "actual_context_compileall": "passed", "target_package_versions": versions, + "target_native_extensions": "absent", "certifi_certificate": "present", + "target_compilation": "not_run", "gameplay": "unverified"} + print(json.dumps(result, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/tools/test_renpy_mobile_ios_libffi.py b/tools/test_renpy_mobile_ios_libffi.py new file mode 100755 index 000000000..76c79560f --- /dev/null +++ b/tools/test_renpy_mobile_ios_libffi.py @@ -0,0 +1,460 @@ +#!/usr/bin/env python3 +"""Exercise pinned CPython patch tasks and genuine external libffi builds. + +Linux results are host compilation/execution only. On Darwin, additional +device and Simulator cases configure CPython, compile its genuine POSIX +object and link using the selected real Xcode SDKs. Target executables are +not installed or run as gameplay. +""" +import argparse +import ast +import hashlib +import json +import os +from pathlib import Path +import re +import shlex +import shutil +import subprocess +import sys +import tarfile +import tempfile +from types import SimpleNamespace + +REPO = Path(__file__).resolve().parents[1] +PIN = "7bfab40c1174f622f644b24669afd5fb167fbb79" +PATCHES = REPO / "bridge/renpy_runtime/mobile_launcher/patches/native" +PYTHON_VERSION = "3.12.8" +FFI_VERSION = "3.4.5" +API_SOURCE = r""" +#include +#include +static int add_three(int value) { return value + 3; } +static void callback(ffi_cif *cif, void *result, void **arguments, void *data) { + (void)cif; + *(int *)result = *(int *)arguments[0] + *(int *)data; +} +int main(void) { + ffi_cif cif, variadic; + ffi_type *types[] = { &ffi_type_sint }; + int value = 7, increment = 5; + void *arguments[] = { &value }, *code = NULL; + ffi_arg result = 0; + if (ffi_prep_cif(&cif, FFI_DEFAULT_ABI, 1, &ffi_type_sint, types) != FFI_OK) return 1; + if (ffi_prep_cif_var(&variadic, FFI_DEFAULT_ABI, 1, 1, &ffi_type_sint, types) != FFI_OK) return 2; + ffi_call(&cif, FFI_FN(add_three), &result, arguments); + if ((int)result != 10) return 3; + ffi_closure *closure = ffi_closure_alloc(sizeof(*closure), &code); + if (!closure || !code) return 4; + if (ffi_prep_closure_loc(closure, &cif, callback, &increment, code) != FFI_OK) return 5; + int actual = ((int (*)(int))code)(value); + ffi_closure_free(closure); + if (actual != 12) return 6; + puts("Genuine libffi call and executable closure returned 10 and 12"); + return 0; +} +""" + + +class BeforeMake(Exception): + """Only capture task generation; genuine builds run separately below.""" + + +def run(command, *, cwd, env=None, log): + result = subprocess.run(command, cwd=cwd, env=env, text=True, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=600) + log.parent.mkdir(parents=True, exist_ok=True) + log.write_text("Command: " + shlex.join(map(str, command)) + "\n" + result.stdout) + if result.returncode: + # Only expose this new public-source command's tool errors. Do not + # publish config.log's environment/cache dump or old build logs. + for config_log in sorted(Path(cwd).rglob("config.log"))[:6]: + errors = [line for line in config_log.read_text(errors="replace").splitlines() + if re.match(r"^(?:xcrun: error:|make(?:\[\d+\])?: |[^='\"\s]+:\d+: \*\*\*)", line)] + if errors: + print(json.dumps({"new_config_tool_errors": errors[-15:]}), flush=True) + if sys.platform == "darwin": + def tool_probe(command, environment, record): + try: + probe = subprocess.run(command, cwd=cwd, env=environment, + text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=30) + record.update(status=probe.returncode, result=probe.stdout.strip()) + except (OSError, subprocess.TimeoutExpired) as error: + record.update(diagnostic_error=type(error).__name__) + print(json.dumps(record), flush=True) + tool_probe(["xcrun", "--find", "make"], env, {"make_probe": "xcrun-selected"}) + sdk_setting = (env or os.environ).get("SDKROOT") + for label, sdk_value in (("original", sdk_setting), + ("resolved", str(Path(sdk_setting).resolve()) if sdk_setting else None), + ("unset", None)): + diagnostic_environment = dict(env or os.environ) + diagnostic_environment.pop("SDKROOT", None) + if sdk_value: + diagnostic_environment["SDKROOT"] = sdk_value + tool_probe(["make", "--version"], diagnostic_environment, + {"make_probe": label, "sdkroot": sdk_value}) + raise RuntimeError(f"Command failed ({result.returncode}); {log}\n{result.stdout[-7000:]}") + return result.stdout + + +def extract(path, destination): + with tarfile.open(path) as source: + source.extractall(destination, filter="data") + + +def task_namespace(path, Context): + selected = [node for node in ast.parse(path.read_text()).body + if isinstance(node, ast.FunctionDef) and node.name in + ("common", "common_post", "patch_posix", "patch_ios", "build_ios")] + assert len(selected) == 5 + for node in selected: + node.decorator_list = [] + namespace = {"Context": Context, "version": PYTHON_VERSION} + exec(compile(ast.Module(body=selected, type_ignores=[]), str(path), "exec"), namespace) + return namespace + + +def make_variables(path): + values = {} + for line in path.read_text().splitlines(): + match = re.match(r"([A-Za-z0-9_]+)\s*=\s*(.*)", line) + if match: + values[match.group(1)] = match.group(2) + def expand(value): + for _ in range(20): + # GNU make expands an unset override such as CFLAGS to empty. + expanded = re.sub(r"\$\((\w+)\)", lambda match: values.get(match.group(1), ""), value) + if expanded == value: + assert "$" not in value, f"Unexpanded compiler flags: {value}" + return value + value = expanded + raise AssertionError("Recursive Makefile flags") + return values, expand + + +def capture_ios_task(namespace, Context, root, shared, architecture, source, sdk): + context = Context("ios", architecture, "3", root, SimpleNamespace()) + context.set_names("python", "build_ios", "python3") + if sdk: + cross = context.path("{{ cross }}/sdk") + cross.parent.mkdir(parents=True, exist_ok=True) + cross.symlink_to(sdk, target_is_directory=True) + assert cross.resolve() == sdk.resolve(), "Only a genuine selected Xcode SDK may be used" + context.var("source", shared / "source") + context.cwd = source.parent + captured = {} + base_sdkroot = context.environ.get("SDKROOT") + def capture(command, **kwargs): + expanded = context.expand(command) + if expanded.strip().startswith("./configure"): + assert not captured, "Unexpected second configure" + captured.update(command=shlex.split(expanded), environment=dict(context.environ), + base_context_sdkroot=base_sdkroot) + # Execute configure below before running any post-configure task. + raise BeforeMake() + raise AssertionError("Unexpected pre-configure command: " + expanded) + context.run = capture + try: + namespace["build_ios"](context) + except BeforeMake: + pass + assert captured and context.cwd == source + environment = captured["environment"] + assert environment["SDKROOT"] == str(context.path("{{ cross }}/sdk").resolve()) + assert environment["LIBFFI_CFLAGS"] == "-I" + str(context.install / "include") + assert environment["LIBFFI_LIBS"] == "-L" + str(context.install / "lib") + " -lffi" + assert "ac_cv_func_ffi_" not in (source / "config.site").read_text() + return context, captured + + +def ffi_and_python_case(label, *, root, shared, namespace, Context, compiler, + architecture, sdk, target, cython, jobs, evidence): + case = root / label + case.mkdir() + extract(shared / f"source/Python-{PYTHON_VERSION}.tar.xz", case) + python_source = case / f"Python-{PYTHON_VERSION}" + patch_context = Context("ios", architecture, "3", case, SimpleNamespace()) + patch_context.set_names("python", "patch_posix", "python3") + patch_context.cwd = case + patch_context.var("patches", shared / "patches") + patch_context.environ["PATH"] = str(cython.parent) + os.pathsep + patch_context.environ["PATH"] + # These are the complete real task bodies, with real Context.patch/run. + namespace["patch_posix"](patch_context) + patch_context.cwd = case + namespace["patch_ios"](patch_context) + generated = (python_source / "Modules/_scproxy.c").read_text() + assert "Generated by Cython 3.2.4" in generated + assert "HAVE_SYSTEM" in (python_source / "Modules/posixmodule.c").read_text() + + context, captured = capture_ios_task(namespace, Context, case, shared, architecture, python_source, sdk) + cross = context.path("{{ cross }}/sdk") + prefix = context.install + host_python = context.path("{{ host }}/bin/python3") + host_python.parent.mkdir(parents=True, exist_ok=True) + host_python.symlink_to(Path(sys.executable).resolve()) + + extract(shared / f"source/libffi-{FFI_VERSION}.tar.gz", case) + ffi_source = case / f"libffi-{FFI_VERSION}" + patch_context.cwd = ffi_source + patch_context.patchdir("libffi") + initial_ffi_configs = {path: path.read_bytes() for path in ffi_source.rglob("fficonfig.h")} + if target: + # Preserve the production Context's compiler, preprocessor, target, + # SDK alias, tools and complete compile/link flags, including ccache. + environment = dict(captured["environment"]) + words = shlex.split(environment["CC"]) + assert target in words and str(cross) in words, words + assert Path(environment["SDKROOT"]).resolve() == sdk.resolve() + else: + words = [str(compiler)] + if sdk: + words += ["-isysroot", str(sdk)] + environment = dict(os.environ, CC=shlex.join(words), CFLAGS="", CPPFLAGS="", LDFLAGS="", LIBS="") + if target: + # libffi runs as an arch task before Python sets its own SDKROOT, + # CONFIG_SITE, PYTHON_FOR_BUILD and external-libffi variables. + ffi_context = Context("ios", architecture, "3", case, SimpleNamespace()) + ffi_context.set_names("arch", "build", "libffi") + assert ffi_context.install == prefix + ffi_environment = dict(ffi_context.environ) + for key in ("CC", "CPP", "CXX", "AR", "RANLIB", "NM", "CFLAGS", "CPPFLAGS", "LDFLAGS"): + assert ffi_environment[key] == environment[key], key + else: + ffi_environment = dict(environment) + print(json.dumps({"case": label, "ffi_environment_source": "libffi task Context" if target else "host isolation", + "base_context_sdkroot": captured["base_context_sdkroot"], + "ffi_sdkroot": ffi_environment.get("SDKROOT")}), flush=True) + ffi_command = ["./configure", "--disable-shared", "--enable-portable-binary", "--prefix=" + str(prefix)] + if target: + # Use the actual production libffi tuple; do not silently fix it in a test. + ffi_command += shlex.split(context.expand("{{ ffi_cross_config }}")) + run(ffi_command, cwd=ffi_source, env=ffi_environment, log=evidence / f"{label}-ffi-configure.log") + run(["make", "-j", str(jobs)], cwd=ffi_source, env=ffi_environment, log=evidence / f"{label}-ffi-build.log") + run(["make", "install"], cwd=ffi_source, env=ffi_environment, log=evidence / f"{label}-ffi-install.log") + # The official archive also ships a pre-generated MSVC ARM64 header. Only + # a header created/changed by this genuine configure belongs to this case. + ffi_configs = [path for path in ffi_source.rglob("fficonfig.h") + if initial_ffi_configs.get(path) != path.read_bytes()] + assert len(ffi_configs) == 1, f"Expected one genuinely generated libffi configuration: {ffi_configs}" + trampoline_table = bool(re.search(r"^#define FFI_EXEC_TRAMPOLINE_TABLE 1$", ffi_configs[0].read_text(), re.M)) + if target: + assert trampoline_table, "iOS ARM64 external libffi must use Apple's static executable trampoline table" + static_ffi = prefix / "lib/libffi.a" + assert static_ffi.is_file() and not (prefix / "lib/libffi.dylib").exists() + + environment.update(LIBFFI_CFLAGS=captured["environment"]["LIBFFI_CFLAGS"], + LIBFFI_LIBS=captured["environment"]["LIBFFI_LIBS"], + CONFIG_SITE=str(python_source / "config.site")) + if not target: + environment.update(CPPFLAGS="-I" + str(prefix / "include"), + LDFLAGS="-L" + str(prefix / "lib"), + SDKROOT=str(sdk) if sdk else "") + # No function cache is supplied. CPython's three genuine ffi probes are + # compile checks; the API executable and link map below establish linking. + assert not any(key.startswith("ac_cv_func_ffi_") for key in environment) + configure = captured["command"] if target else ["./configure", "--prefix=" + str(prefix), "--with-ensurepip=no"] + run(configure, cwd=python_source, env=environment, log=evidence / f"{label}-python-configure.log") + # Production runs configure first, then replaces BOTH Setup files. Repeat + # the genuine common_post generation in that order, stopping before make. + # Its static extensions use global compiler flags plus this Setup line, + # rather than assuming MODULE__CTYPES_CFLAGS controls the static object. + static_commands = [] + def execute_post_configure(command, **kwargs): + expanded = context.expand(command) + if expanded.strip().startswith("nice make"): + raise BeforeMake() + static_commands.append(shlex.split(expanded)) + # Use the genuine Context implementation for the production task's + # makesetup and config.c move, rather than adding a test-only rule. + return Context.run(context, command, **kwargs) + context.run = execute_post_configure + try: + namespace["common_post"](context) + except BeforeMake: + pass + assert len(static_commands) == 2 and "Modules/makesetup" in static_commands[0] + assert (python_source / "Modules/Setup").read_bytes() == (python_source / "Modules/Setup.stdlib").read_bytes() + registry = (python_source / "Modules/config.c").read_text() + assert re.search(r'\{\s*"_ctypes"\s*,\s*PyInit__ctypes\s*\}', registry), \ + "The real generated static builtin registry is missing _ctypes" + for name in ("Setup", "Setup.stdlib"): + line = next(line for line in (python_source / "Modules" / name).read_text().splitlines() + if line.startswith("_ctypes ")) + assert "_ctypes/malloc_closure.c" in line and "-lffi" in line + assert "USING_APPLE_OS_LIBFFI" not in line + # Execute the genuine makesetup rule after the production templates have + # replaced configure's Setup.stdlib, without building the interpreter. + run(["make", "Makefile"], cwd=python_source, env=environment, + log=evidence / f"{label}-static-makefile.log") + variables, expand = make_variables(python_source / "Makefile") + assert variables["MODULE__CTYPES_STATE"] == "yes", variables["MODULE__CTYPES_STATE"] + cflags = shlex.split(expand(variables["MODULE__CTYPES_CFLAGS"])) + ldflags = shlex.split(expand(variables["MODULE__CTYPES_LDFLAGS"])) + assert "-I" + str(prefix / "include") in cflags, cflags + assert "-L" + str(prefix / "lib") in ldflags and "-lffi" in ldflags, ldflags + assert not any("USING_APPLE_OS_LIBFFI" in flag for flag in cflags), cflags + config = (python_source / "pyconfig.h").read_text() + for feature in ("FFI_PREP_CIF_VAR", "FFI_PREP_CLOSURE_LOC", "FFI_CLOSURE_ALLOC"): + assert re.search(r"^#define HAVE_" + feature + r" 1$", config, re.M), feature + # Compile the exact object that previously failed on the genuine + # Simulator SDK. Successful ffi checks do not establish this boundary. + posix_dry_run = run(["make", "-n", "Modules/posixmodule.o"], cwd=python_source, + env=environment, log=evidence / f"{label}-posix-make-command.log") + posix_commands = [shlex.split(line) for line in posix_dry_run.splitlines() + if "posixmodule.c" in line and " -c " in line] + assert len(posix_commands) == 1, posix_dry_run + run(["make", "Modules/posixmodule.o"], cwd=python_source, env=environment, + log=evidence / f"{label}-posix-object-compile.log") + posix_object = python_source / "Modules/posixmodule.o" + assert posix_object.is_file() + posix_functions = { + name: bool(re.search(r"^#define HAVE_" + name.upper() + r" 1$", config, re.M)) + for name in ("dup3", "pipe2") + } + api = case / "ffi-api.c" + api.write_text(API_SOURCE) + executable = case / "ffi-api" + link_map = evidence / f"{label}-ffi-link.map" + map_flag = "-Wl,-map," if sys.platform == "darwin" else "-Wl,-Map," + compile_flags = shlex.split(environment.get("CFLAGS", "")) + shlex.split(environment.get("CPPFLAGS", "")) + link_flags = shlex.split(environment.get("LDFLAGS", "")) + shlex.split(environment.get("LIBS", "")) + run([*words, *compile_flags, "-Wall", "-Wextra", "-Werror", *cflags, str(api), *link_flags, *ldflags, + map_flag + str(link_map), "-o", str(executable)], cwd=case, + env=environment, log=evidence / f"{label}-ffi-api-link.log") + assert str(static_ffi) in link_map.read_text(), "API link did not consume the freshly built external libffi.a" + closure_object = case / "malloc_closure.o" + # Linux configure normally omits malloc_closure.c. This particular object + # uses the actual iOS static Setup's definitions, including the symbol + # wrappers that prevent aliases from recursively calling themselves. + static_line = next(line for line in (python_source / "Modules/Setup").read_text().splitlines() + if line.startswith("_ctypes ")) + static_definitions = [flag for flag in shlex.split(static_line) if flag.startswith("-D")] + assert "_ctypes/malloc_closure.c" in static_line and "-lffi" in static_line + assert not any("USING_APPLE_OS_LIBFFI" in flag for flag in static_definitions) + global_cflags = shlex.split(expand(variables["PY_BUILTIN_MODULE_CFLAGS"])) + assert "-I" + str(prefix / "include") in global_cflags, global_cflags + dry_run = run(["make", "-n", "Modules/_ctypes/malloc_closure.o"], cwd=python_source, + env=environment, log=evidence / f"{label}-ctypes-make-command.log") + commands = [shlex.split(line) for line in dry_run.splitlines() + if "malloc_closure.c" in line and " -c " in line] + assert len(commands) == 1, dry_run + closure_command = commands[0] + assert all(flag in closure_command for flag in static_definitions), closure_command + assert "-I" + str(prefix / "include") in closure_command, closure_command + assert "USING_APPLE_OS_LIBFFI" not in " ".join(closure_command) + closure_command[closure_command.index("-o") + 1] = str(closure_object) + # Compile the upstream object with its real production warning policy. + # A blanket extra -Werror rejects CPython's intentionally unreachable + # legacy fallback when external ffi_closure_alloc is available. + headers = run([*closure_command, "-H"], + cwd=python_source, env=environment, log=evidence / f"{label}-ctypes-closure-compile.log") + assert str(prefix / "include/ffi.h") in headers, "Actual static closure object did not include the external libffi header" + wrapped_api = case / "ctypes-ffi-api.c" + wrapped_api.write_text("#include \n" + "extern void *Py_ffi_closure_alloc(size_t, void **);\n" + "extern void Py_ffi_closure_free(void *);\n" + API_SOURCE.replace( + "ffi_closure_alloc(sizeof(*closure), &code)", "Py_ffi_closure_alloc(sizeof(*closure), &code)").replace( + "ffi_closure_free(closure)", "Py_ffi_closure_free(closure)")) + wrapped_executable = case / "ctypes-ffi-api" + wrapped_map = evidence / f"{label}-ctypes-ffi-link.map" + run([*words, *compile_flags, "-Wall", "-Wextra", "-Werror", *cflags, str(wrapped_api), str(closure_object), *link_flags, *ldflags, + map_flag + str(wrapped_map), "-o", str(wrapped_executable)], cwd=case, + env=environment, log=evidence / f"{label}-ctypes-ffi-api-link.log") + assert str(static_ffi) in wrapped_map.read_text(), "CPython closure wrapper did not link to the external libffi.a" + if target: + assert executable.read_bytes()[:4] == bytes.fromhex("cffaedfe") + assert closure_object.read_bytes()[:4] == bytes.fromhex("cffaedfe") + assert posix_object.read_bytes()[:4] == bytes.fromhex("cffaedfe") + assert wrapped_executable.read_bytes()[:4] == bytes.fromhex("cffaedfe") + output = run(["xcrun", "nm", "-u", str(executable)], cwd=case, + log=evidence / f"{label}-ffi-unresolved.txt") + assert not re.search(r"\b_ffi_\w+", output), output + else: + run([str(executable)], cwd=case, env=environment, log=evidence / f"{label}-ffi-api-run.log") + run([str(wrapped_executable)], cwd=case, env=environment, log=evidence / f"{label}-ctypes-ffi-api-run.log") + compiler_version = subprocess.check_output([str(compiler), "--version"], text=True).splitlines()[0] + record = {"case": label, "compiler": words, "compiler_version": compiler_version, + "build_source_pin": PIN, "python_version": PYTHON_VERSION, "libffi_version": FFI_VERSION, + "sdk": str(sdk) if sdk else None, + "python_sdkroot": environment.get("SDKROOT"), + "ffi_sdkroot": ffi_environment.get("SDKROOT"), + "target_context_toolchain": "production" if target else "host isolation", + "static_setup_commands": static_commands, + "ctypes_static_registry": "passed", + "configure_ffi_headers": "passed", "ffi_api_link": "passed", + "closure_object": "passed", "ctypes_closure_link": "passed", + "posix_object": "passed", "posix_functions": posix_functions, + "actual_posix_compile_command": posix_commands[0], + "ffi_api_execution": "not_run" if target else "passed", + "ctypes_closure_execution": "not_run" if target else "passed", + "ffi_trampoline_table": trampoline_table, + "libffi_sha256": hashlib.sha256(static_ffi.read_bytes()).hexdigest(), + "ctypes_compile_flags": cflags, "ctypes_link_flags": ldflags, + "static_global_compile_flags": global_cflags, "static_definitions": static_definitions, + "actual_static_closure_command": closure_command, + "actual_static_closure_header": str(prefix / "include/ffi.h"), + "gameplay": "unverified"} + (evidence / f"{label}-result.json").write_text(json.dumps(record, indent=2) + "\n") + print(json.dumps(record, sort_keys=True), flush=True) + print(f"{label}: genuine patch/autoreconf/Cython, libffi build, CPython configure, POSIX/closure objects and API link PASS; " + + ("target API execution not run" if target else "host ffi_call/closure execution PASS"), flush=True) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("build_source", type=Path) + parser.add_argument("--cython", type=Path) + parser.add_argument("--output-dir", type=Path) + parser.add_argument("--jobs", type=int, default=2) + args = parser.parse_args() + assert args.jobs > 0 + assert sys.version_info[:2] == (3, 12), "The genuine CPython 3.12 configure requires a matching build Python" + assert subprocess.check_output(["git", "-C", str(args.build_source), "rev-parse", "HEAD"], text=True).strip() == PIN + cython = args.cython or Path(shutil.which("cython") or "") + assert cython.is_file() and os.access(cython, os.X_OK), "Genuine Cython 3.2.4 is required" + cython_version = subprocess.check_output([str(cython), "--version"], text=True, stderr=subprocess.STDOUT) + assert "Cython version 3.2.4" in cython_version, cython_version + evidence = (args.output_dir or Path(tempfile.mkdtemp(prefix="renpy-ios-libffi-evidence-"))).resolve() + evidence.mkdir(parents=True, exist_ok=True) + print("Evidence:", evidence, flush=True) + with tempfile.TemporaryDirectory(prefix="renpy-ios-libffi-") as temporary: + root = Path(temporary) + shared = root / "build-source" + shared.mkdir() + archive = subprocess.Popen(["git", "-C", str(args.build_source), "archive", "HEAD", "renpybuild", "tasks", + "patches", "source/Python-3.12.8-Setup.stdlib", "source/Python-3.12.8.tar.xz", + "source/libffi-3.4.5.tar.gz", "tools/cmake_build_variables.cmake"], stdout=subprocess.PIPE) + assert archive.stdout is not None + with tarfile.open(fileobj=archive.stdout, mode="r|*") as source: + source.extractall(shared, filter="data") + assert archive.wait() == 0 + run(["git", "apply", "--include=tasks/metalangle.py", str(PATCHES / "0006-ios-offscreen-renderer.patch")], + cwd=shared, log=evidence / "0006-prerequisite.log") + run(["git", "apply", str(PATCHES / "0007-darwin-ios-build.patch")], + cwd=shared, log=evidence / "0007-production-patch.log") + sys.path.insert(0, str(shared)) + from renpybuild.context import Context + namespace = task_namespace(shared / "tasks/python3.py", Context) + compiler = Path(subprocess.check_output(["xcrun", "--find", "clang"], text=True).strip()) if sys.platform == "darwin" \ + else Path(shutil.which("cc") or "") + assert compiler.is_file(), "A genuine host C compiler is required" + host_sdk = Path(subprocess.check_output(["xcrun", "--sdk", "macosx", "--show-sdk-path"], text=True).strip()) \ + if sys.platform == "darwin" else None + ffi_and_python_case("host", root=root, shared=shared, namespace=namespace, Context=Context, + compiler=compiler, architecture="arm64", sdk=host_sdk, target=None, + cython=cython, jobs=args.jobs, evidence=evidence) + if sys.platform == "darwin": + for architecture, sdk_name, target in (("arm64", "iphoneos", "arm64-apple-ios13.0"), + ("sim-arm64", "iphonesimulator", "arm64-apple-ios13.0-simulator")): + sdk = Path(subprocess.check_output(["xcrun", "--sdk", sdk_name, "--show-sdk-path"], text=True).strip()) + assert sdk.is_dir(), sdk + ffi_and_python_case(sdk_name, root=root, shared=shared, namespace=namespace, Context=Context, + compiler=compiler, architecture=architecture, sdk=sdk, target=target, + cython=cython, jobs=args.jobs, evidence=evidence) + else: + print("Linux host checks passed; iOS device/Simulator SDK compilation and linking NOT RUN", flush=True) + print("No iOS executable was installed or launched; no Ren'Py/GL/device gameplay was tested", flush=True) + + +if __name__ == "__main__": + main() diff --git a/tools/test_renpy_mobile_ios_nis.py b/tools/test_renpy_mobile_ios_nis.py new file mode 100644 index 000000000..89df74969 --- /dev/null +++ b/tools/test_renpy_mobile_ios_nis.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Run genuine CPython configure with the actual iOS task's module policy. + +This checks generated optional-module rules with a real host compiler. On Mac, +it also probes the actual device/Simulator SDK headers. It does not compile or +link a target Python runtime, emulate an SDK, or verify gameplay. +""" +import argparse +import ast +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tarfile +import tempfile + +REPO = Path(__file__).resolve().parents[1] +PIN = "7bfab40c1174f622f644b24669afd5fb167fbb79" +PATCHES = REPO / "bridge/renpy_runtime/mobile_launcher/patches/native" + + +def unchanged_other_targets(original, patched): + def functions(text): + return {node.name: ast.dump(node, include_attributes=False) + for node in ast.parse(text).body if isinstance(node, ast.FunctionDef)} + before, after = functions(original), functions(patched) + for name in ("common", "build_posix", "build_android", "build_windows", "build_web"): + assert before[name] == after[name], f"Unrelated target policy changed: {name}" + + +def site_from_task(task_text, source, root, Context): + tree = ast.parse(task_text) + selected = [node for node in tree.body if isinstance(node, ast.FunctionDef) + and node.name in ("common", "build_ios")] + assert len(selected) == 2 + for node in selected: + node.decorator_list = [] + namespace = {"Context": Context, "version": "3.12.8", "common_post": lambda context: None} + exec(compile(ast.Module(body=selected, type_ignores=[]), "actual-python3-task", "exec"), namespace) + context = Context("ios", "arm64", "3", root, None) + context.set_names("python", "build_ios", "python3") + context.cwd = source.parent + # Capture only the site's genuine task policy. Configure runs below with + # the real host compiler; no SDK or build is represented by this callback. + context.run = lambda command: None + namespace["build_ios"](context) + assert context.cwd == source + return source / "config.site" + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("build_source", type=Path) + args = parser.parse_args() + assert subprocess.check_output(["git", "-C", str(args.build_source), "rev-parse", "HEAD"], text=True).strip() == PIN + with tempfile.TemporaryDirectory(prefix="renpy-ios-nis-") as temporary: + root = Path(temporary) + archive = subprocess.Popen([ + "git", "-C", str(args.build_source), "archive", "HEAD", "renpybuild", "tasks", + "source/Python-3.12.8-Setup.stdlib", "source/Python-3.12.8.tar.xz", + "tools/cmake_build_variables.cmake", + ], stdout=subprocess.PIPE) + assert archive.stdout is not None + with tarfile.open(fileobj=archive.stdout, mode="r|*") as source: + source.extractall(root, filter="data") + assert archive.wait() == 0 + task = root / "tasks/python3.py" + original = task.read_text() + subprocess.run(["git", "apply", "--include=tasks/metalangle.py", str(PATCHES / "0006-ios-offscreen-renderer.patch")], cwd=root, check=True) + subprocess.run(["git", "apply", str(PATCHES / "0007-darwin-ios-build.patch")], cwd=root, check=True) + patched = task.read_text() + unchanged_other_targets(original, patched) + sys.path.insert(0, str(root)) + from renpybuild.context import Context + + if sys.platform == "darwin": + compiler = subprocess.check_output(["xcrun", "--find", "clang"], text=True).strip() + host_sdk = subprocess.check_output(["xcrun", "--sdk", "macosx", "--show-sdk-path"], text=True).strip() + compiler_words = [compiler, "-isysroot", host_sdk] + assert all(not any(c.isspace() for c in word) for word in compiler_words) + for sdk, target in (("iphoneos", "arm64-apple-ios13.0"), ("iphonesimulator", "arm64-apple-ios13.0-simulator")): + sdk_root = subprocess.check_output(["xcrun", "--sdk", sdk, "--show-sdk-path"], text=True).strip() + assert Path(sdk_root).is_dir(), sdk_root + result = subprocess.run([compiler, "-target", target, "-isysroot", sdk_root, + "-x", "c", "-fsyntax-only", "-"], + input="#include \n", text=True, capture_output=True) + assert result.returncode != 0 and "rpcsvc/yp_prot.h" in result.stderr and "file not found" in result.stderr, result.stderr + print(f"Actual {sdk} SDK reproduces the unavailable NIS header: {sdk_root}", flush=True) + else: + compiler = shutil.which("cc") + assert compiler + compiler_words = [compiler] + + for label, text in (("original", original), ("patched", patched)): + destination = root / label + destination.mkdir() + with tarfile.open(root / "source/Python-3.12.8.tar.xz") as source: + source.extractall(destination, filter="data") + python_source = destination / "Python-3.12.8" + site = site_from_task(text, python_source, root, Context) + environment = {**os.environ, "CONFIG_SITE": str(site), "CC": " ".join(compiler_words), + "CFLAGS": "", "CPPFLAGS": "", "LDFLAGS": ""} + result = subprocess.run(["./configure", "--with-ensurepip=no", "--prefix=" + str(destination / "install")], + cwd=python_source, env=environment, capture_output=True, text=True) + assert result.returncode == 0, result.stdout[-5000:] + result.stderr[-5000:] + makefile = (python_source / "Makefile").read_text() + state = next(line.partition("=")[2] for line in makefile.splitlines() if line.startswith("MODULE_NIS_STATE=")) + if label == "patched": + assert state == "n/a", state + assert not any(line.startswith("Modules/nismodule.o:") for line in makefile.splitlines()) + assert not any(line.startswith("nis ") for line in (python_source / "Modules/Setup.stdlib").read_text().splitlines()) + assert "ac_cv_func_clock_settime=no\npy_cv_module_nis=n/a\n" in site.read_text() + print(f"Actual CPython 3.12.8 {label} host configure: MODULE_NIS_STATE={state}", flush=True) + if label == "original" and state != "yes": + print("Original NIS auto-enable is not reproduced by this host's dependencies; the genuine iOS compiler failure is recorded separately", flush=True) + print("Actual configure generated no NIS extension object rule under the iOS policy; target runtime compilation and gameplay remain untested here") + + +if __name__ == "__main__": + main() diff --git a/tools/test_renpy_mobile_launcher_loader.sh b/tools/test_renpy_mobile_launcher_loader.sh new file mode 100644 index 000000000..bbe664fa9 --- /dev/null +++ b/tools/test_renpy_mobile_launcher_loader.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +work="$(mktemp -d "${TMPDIR:-/tmp}/aetherkiri-renpy-loader.XXXXXX")" +trap 'rm -rf "$work"' EXIT + +cat > "$work/fake_launcher.cpp" <<'CPP' +#include "renpy_mobile_launcher.h" +#include +#include +#include +static unsigned char pixel[4] = {0x12, 0x34, 0x56, 0xff}; +static void verify_runtime_environment() { + assert(std::getenv("SDL_VIDEODRIVER") && !std::strcmp(std::getenv("SDL_VIDEODRIVER"), "offscreen")); + assert(std::getenv("SDL_VIDEO_GL_DRIVER") && !std::strcmp(std::getenv("SDL_VIDEO_GL_DRIVER"), "/runtime/MetalANGLE")); + assert(std::getenv("SDL_VIDEO_EGL_DRIVER") && !std::strcmp(std::getenv("SDL_VIDEO_EGL_DRIVER"), "/runtime/MetalANGLE")); +} +extern "C" int renpy_mobile_init(const renpy_mobile_config_t* config, const renpy_mobile_host_t*) { + verify_runtime_environment(); + return config->game_root_utf8 && !std::strcmp(config->game_root_utf8, "init_fail") ? RENPY_MOBILE_ERROR : 0; +} +extern "C" int renpy_mobile_bootstrap(const renpy_mobile_config_t* config, const renpy_mobile_host_t*) { + setenv("SDL_VIDEODRIVER", "offscreen", 1); + setenv("SDL_VIDEO_GL_DRIVER", "/runtime/MetalANGLE", 1); + setenv("SDL_VIDEO_EGL_DRIVER", "/runtime/MetalANGLE", 1); + return config->game_root_utf8 && !std::strcmp(config->game_root_utf8, "bootstrap_fail") ? RENPY_MOBILE_ERROR : 0; +} +extern "C" int renpy_mobile_tick(unsigned int budget) { + verify_runtime_environment(); + return budget == 13 ? RENPY_MOBILE_ERROR : 0; +} +extern "C" int renpy_mobile_frame(renpy_mobile_frame_t* frame) { + verify_runtime_environment(); + if (!frame) return -1; + frame->rgba = pixel; frame->width = 1; frame->height = 1; frame->stride = 4; frame->serial = 7; + return 0; +} +extern "C" int renpy_mobile_input(const renpy_mobile_input_t*) { verify_runtime_environment(); return 0; } +extern "C" int renpy_mobile_pause(void) { verify_runtime_environment(); return 0; } +extern "C" int renpy_mobile_resume(void) { verify_runtime_environment(); return 0; } +extern "C" int renpy_mobile_bind_window(void* window) { return window ? 0 : -1; } +extern "C" int renpy_mobile_text_input_state(uint32_t* active) { verify_runtime_environment(); if (!active) return -1; *active = 1; return 0; } +extern "C" int renpy_mobile_set_surface_size(uint32_t width, uint32_t height) { verify_runtime_environment(); return width && height ? 0 : -1; } +extern "C" void renpy_mobile_shutdown(void) { + verify_runtime_environment(); + // A callback can mutate process state; the loader must still restore host values. + unsetenv("SDL_VIDEODRIVER"); + setenv("SDL_VIDEO_GL_DRIVER", "shutdown changed this", 1); +} +CPP +cat > "$work/test_loader.cpp" <<'CPP' +#include "renpy_mobile_loader.h" +#include +#include +#include +using namespace aetherkiri::renpy::mobile; +static void expect_host_environment(const char* driver = "host-uikit") { + assert(std::getenv("SDL_VIDEODRIVER") && !std::strcmp(std::getenv("SDL_VIDEODRIVER"), driver)); + assert(!std::getenv("SDL_VIDEO_GL_DRIVER")); + assert(std::getenv("SDL_VIDEO_EGL_DRIVER") && !std::strcmp(std::getenv("SDL_VIDEO_EGL_DRIVER"), "")); +} +int main() { + setenv("SDL_VIDEODRIVER", "host-uikit", 1); + unsetenv("SDL_VIDEO_GL_DRIVER"); + setenv("SDL_VIDEO_EGL_DRIVER", "", 1); + Launcher launcher; + assert(launcher.available()); + renpy_mobile_config_t config{}; config.struct_size = sizeof(config); config.abi_version = RENPY_MOBILE_LAUNCHER_ABI_VERSION; + renpy_mobile_host_t host{}; host.struct_size = sizeof(host); host.abi_version = RENPY_MOBILE_LAUNCHER_ABI_VERSION; + assert(launcher.Init(config, host) == RENPY_MOBILE_OK); + expect_host_environment(); + assert(launcher.Tick(16) == RENPY_MOBILE_OK); + expect_host_environment(); + assert(launcher.Tick(13) == RENPY_MOBILE_ERROR); + expect_host_environment(); + renpy_mobile_frame_t frame{}; frame.struct_size = sizeof(frame); + assert(launcher.Frame(&frame) == RENPY_MOBILE_OK); + assert(frame.width == 1 && frame.height == 1 && frame.stride == 4 && frame.serial == 7); + expect_host_environment(); + renpy_mobile_input_t input{}; input.struct_size = sizeof(input); input.type = RENPY_MOBILE_INPUT_KEY; + assert(launcher.Input(input) == RENPY_MOBILE_OK); + expect_host_environment(); + assert(launcher.Pause() == RENPY_MOBILE_OK); + expect_host_environment(); + // Snapshot every call instead of restoring a stale value from Init. + setenv("SDL_VIDEODRIVER", "host-driver-changed-while-paused", 1); + assert(launcher.Resume() == RENPY_MOBILE_OK); + expect_host_environment("host-driver-changed-while-paused"); + uint32_t active = 0; + assert(launcher.TextInputState(&active) == RENPY_MOBILE_OK && active == 1); + expect_host_environment("host-driver-changed-while-paused"); + assert(launcher.SetSurfaceSize(1280, 720) == RENPY_MOBILE_OK); + expect_host_environment("host-driver-changed-while-paused"); + launcher.Shutdown(); + assert(!launcher.initialized()); + expect_host_environment("host-driver-changed-while-paused"); + for (const char* failure : {"bootstrap_fail", "init_fail"}) { + Launcher failing; + config.game_root_utf8 = failure; + assert(failing.Init(config, host) == RENPY_MOBILE_ERROR); + expect_host_environment("host-driver-changed-while-paused"); + } + for (const char* name : {"SDL_VIDEODRIVER", "SDL_VIDEO_GL_DRIVER", "SDL_VIDEO_EGL_DRIVER"}) unsetenv(name); + { + Launcher initially_absent; + config.game_root_utf8 = "game"; + assert(initially_absent.Init(config, host) == RENPY_MOBILE_OK); + assert(!std::getenv("SDL_VIDEODRIVER") && !std::getenv("SDL_VIDEO_GL_DRIVER") && !std::getenv("SDL_VIDEO_EGL_DRIVER")); + assert(initially_absent.Tick(16) == RENPY_MOBILE_OK); + assert(!std::getenv("SDL_VIDEODRIVER") && !std::getenv("SDL_VIDEO_GL_DRIVER") && !std::getenv("SDL_VIDEO_EGL_DRIVER")); + } + // Destructor shutdown also restores the three originally absent values. + assert(!std::getenv("SDL_VIDEODRIVER") && !std::getenv("SDL_VIDEO_GL_DRIVER") && !std::getenv("SDL_VIDEO_EGL_DRIVER")); + return 0; +} +CPP + +cxx="${CXX:-c++}" +"$cxx" -std=c++17 -fPIC -shared -D__ANDROID__ \ + -I"$repo_root/bridge/renpy_runtime/mobile_launcher/include" \ + "$work/fake_launcher.cpp" -o "$work/librenpython.so" +"$cxx" -std=c++17 -D__ANDROID__ \ + -I"$repo_root/bridge/renpy_runtime/mobile_launcher/include" \ + "$work/test_loader.cpp" \ + "$repo_root/bridge/renpy_runtime/mobile_launcher/src/renpy_mobile_loader.cpp" \ + -ldl -o "$work/test_loader" +LD_LIBRARY_PATH="$work${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" "$work/test_loader" +printf '%s\n' 'Ren''Py mobile lifecycle loader test passed' diff --git a/tools/test_renpy_mobile_launcher_scaffold.sh b/tools/test_renpy_mobile_launcher_scaffold.sh new file mode 100644 index 000000000..6423b65ac --- /dev/null +++ b/tools/test_renpy_mobile_launcher_scaffold.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# Metadata/source-only test for the host-owned Ren'Py launcher scaffold. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +scaffold="$repo_root/bridge/renpy_runtime/mobile_launcher" +script="$scaffold/build.sh" +header="$scaffold/include/renpy_mobile_launcher.h" +loader="$scaffold/src/renpy_mobile_loader.cpp" +renpy_build="${RENPY_BUILD_TEST_ROOT:-/tmp/renpy-build-src}" +renpy_source="${RENPY_SOURCE_TEST_ROOT:-/tmp/renpy-src}" + +[[ -f "$script" ]] || { echo "missing launcher scaffold script" >&2; exit 1; } +[[ -f "$header" ]] || { echo "missing launcher lifecycle header" >&2; exit 1; } +[[ -f "$loader" ]] || { echo "missing host lifecycle loader" >&2; exit 1; } +bash -n "$script" + +grep -Fq 'librenpython.so' "$loader" +grep -Fq 'dlsym' "$loader" +grep -Fq 'RTLD_DEFAULT' "$loader" +for symbol in \ + renpy_mobile_init renpy_mobile_tick renpy_mobile_frame renpy_mobile_input \ + renpy_mobile_pause renpy_mobile_resume renpy_mobile_shutdown \ + renpy_mobile_bootstrap renpy_mobile_bind_window renpy_mobile_text_input_state renpy_mobile_set_surface_size; do + grep -Fq "$symbol" "$loader" +done + +for symbol in \ + renpy_mobile_init renpy_mobile_tick renpy_mobile_frame renpy_mobile_input \ + renpy_mobile_pause renpy_mobile_resume renpy_mobile_shutdown \ + renpy_mobile_bootstrap renpy_mobile_bind_window renpy_mobile_text_input_state renpy_mobile_set_surface_size; do + grep -Fq "$symbol" "$header" +done + +[[ -s "$scaffold/patches/python/0001-cooperative-loop-skeleton.patch" ]] +if [[ -d "$renpy_source" ]]; then + patch_output="$(bash "$script" --check-python-patch --renpy-src "$renpy_source")" + grep -Fq 'applies cleanly' <<<"$patch_output" +fi +for template in \ + "$scaffold/patches/android/librenpython_android_host.c.template" \ + "$scaffold/patches/ios/librenpython_ios_host.c.template"; do + [[ -f "$template" ]] || { echo "missing native template: $template" >&2; exit 1; } + for symbol in \ + renpy_mobile_init renpy_mobile_tick renpy_mobile_frame renpy_mobile_input \ + renpy_mobile_pause renpy_mobile_resume renpy_mobile_shutdown; do + grep -Fq "$symbol" "$template" + done + # Templates must not accidentally reintroduce process entrypoints. + ! grep -Eq '(^|[^[:alnum:]_])(Py_RunMain|SDL_main|SDL_RunApp|SDL_UIKitRunApp|UIApplicationMain)[[:space:]]*\(' "$template" + grep -Fq 'AETHERKIRI_RENPY_LIFECYCLE_STUB' "$template" +done + +if [[ ! -d "$renpy_build" ]]; then + echo "Ren'Py build checkout not present; metadata-only scaffold test ok" + exit 0 +fi + +output="$(bash "$script" --check --renpy-build "$renpy_build")" +grep -Fq 'source check ok' <<<"$output" +grep -Fq 'no artifact was built or installed' <<<"$output" + +contract_dir="$(mktemp -d "${TMPDIR:-/tmp}/renpy-mobile-contract.XXXXXX")" +trap 'rm -rf "$contract_dir"' EXIT +bash "$script" --compile-contract --renpy-build "$renpy_build" --output-dir "$contract_dir" >/dev/null +for object in "$contract_dir"/*.o; do + for symbol in \ + renpy_mobile_init renpy_mobile_tick renpy_mobile_frame renpy_mobile_input \ + renpy_mobile_pause renpy_mobile_resume renpy_mobile_shutdown; do + nm -g --defined-only "$object" | awk '{print $3}' | grep -Fxq "$symbol" + done +done +if bash "$script" --install --renpy-build "$renpy_build" --stage "$contract_dir/stage" \ + --android-so-arm64 "$contract_dir/librenpython_android_host.o" \ + >"$contract_dir/install.stdout" 2>"$contract_dir/install.stderr"; then + echo "contract-only lifecycle object was accepted as a runtime artifact" >&2 + exit 1 +fi +grep -Fq 'contract-only lifecycle stub' "$contract_dir/install.stderr" + +if bash "$script" --check --renpy-build "$renpy_build" | grep -Fq 'SDL_main'; then + : +else + echo "source check did not report the blocking Android launcher" >&2 + exit 1 +fi + +echo "Ren'Py mobile launcher scaffold/source smoke ok" +bash "$repo_root/tools/test_renpy_mobile_launcher_loader.sh" diff --git a/tools/test_renpy_mobile_libffi_cfi.py b/tools/test_renpy_mobile_libffi_cfi.py new file mode 100644 index 000000000..8515f64a7 --- /dev/null +++ b/tools/test_renpy_mobile_libffi_cfi.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""Compile the pinned libffi unwind assembly with the genuine LLVM assembler. + +On macOS this uses the selected Xcode SDKs and the complete assembly. On Linux +the Apple closure trampoline table is excluded from this focused CFI check, +because its Mach SDK header is unavailable. Neither case links a full runtime +or executes gameplay. +""" +import argparse +import ast +from pathlib import Path +import os +import shutil +import subprocess +import sys +import tarfile +import tempfile +from types import SimpleNamespace + +REPO = Path(__file__).resolve().parents[1] +PIN = "7bfab40c1174f622f644b24669afd5fb167fbb79" + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("build_source", type=Path) + parser.add_argument("--clang", type=Path) + args = parser.parse_args() + assert subprocess.check_output(["git", "-C", str(args.build_source), "rev-parse", "HEAD"], text=True).strip() == PIN + if sys.platform == "darwin": + compiler = str(args.clang) if args.clang else subprocess.check_output(["xcrun", "--find", "clang"], text=True).strip() + else: + compiler = str(args.clang) if args.clang else shutil.which("clang-18") or shutil.which("clang") + assert compiler, "A genuine LLVM clang is required for the Mach-O assembly regression" + print(subprocess.check_output([compiler, "--version"], text=True).splitlines()[0]) + host_cc = shutil.which("cc") + assert host_cc + with tempfile.TemporaryDirectory(prefix="renpy-libffi-cfi-") as temporary: + root = Path(temporary) + archive = subprocess.Popen([ + "git", "-C", str(args.build_source), "archive", "HEAD", "renpybuild", "tasks", + "source/Python-3.12.8-Setup.stdlib", "tools/cmake_build_variables.cmake", + "source/libffi-3.4.5.tar.gz", + ], stdout=subprocess.PIPE) + assert archive.stdout is not None + with tarfile.open(fileobj=archive.stdout, mode="r|*") as source: + source.extractall(root, filter="data") + assert archive.wait() == 0 + subprocess.run(["git", "apply", "--include=tasks/metalangle.py", str(REPO / "bridge/renpy_runtime/mobile_launcher/patches/native/0006-ios-offscreen-renderer.patch")], cwd=root, check=True) + subprocess.run(["git", "apply", str(REPO / "bridge/renpy_runtime/mobile_launcher/patches/native/0007-darwin-ios-build.patch")], cwd=root, check=True) + with tarfile.open(root / "source/libffi-3.4.5.tar.gz") as source: + source.extractall(root, filter="data") + source_root = root / "libffi-3.4.5" + environment = {**os.environ, "CC": host_cc, "CFLAGS": "", "CPPFLAGS": "", "LDFLAGS": ""} + build = subprocess.check_output([host_cc, "-dumpmachine"], text=True).strip() + configured = subprocess.run(["./configure", "--host=aarch64-apple-darwin", "--build=" + build, + "--disable-shared", "--enable-portable-binary"], + cwd=source_root, env=environment, capture_output=True, text=True) + assert configured.returncode == 0, configured.stdout + configured.stderr + configuration = next(source_root.glob("*/fficonfig.h")) + assert "#define HAVE_AS_CFI_PSEUDO_OP 1" in configuration.read_text(), "Configured assembler CFI support is required" + if sys.platform != "darwin": + # This test isolates CFI, not the Apple-specific closure page table. + # Production configuration and source compilation retain that table. + text = configuration.read_text() + assert "#define FFI_EXEC_TRAMPOLINE_TABLE 1" in text + configuration.write_text(text.replace("#define FFI_EXEC_TRAMPOLINE_TABLE 1", "#define FFI_EXEC_TRAMPOLINE_TABLE 0")) + includes = ["-DHAVE_CONFIG_H", "-I" + str(configuration.parent), + "-I" + str(configuration.parent / "include"), "-Iinclude", "-Isrc"] + targets = (("arm64-apple-ios13.0", "iphoneos"), + ("arm64-apple-ios13.0-simulator", "iphonesimulator")) + rejected = 0 + for target, sdk in targets: + sdk_args = [] + if sys.platform == "darwin": + sdk_args = ["-isysroot", subprocess.check_output(["xcrun", "--sdk", sdk, "--show-sdk-path"], text=True).strip()] + command = [compiler, "-target", target, *sdk_args, *includes, "-c", "src/aarch64/sysv.S", "-o", target + ".o"] + result = subprocess.run(command, cwd=source_root, capture_output=True, text=True) + if result.returncode != 0: + assert "invalid CFI advance_loc expression" in result.stderr, result.stderr + rejected += 1 + else: + assert sys.platform != "darwin", "Selected Xcode did not reproduce the observed original CFI error" + # Execute the genuine unpack function through its real Context, so a + # regression in the task's patch wiring also fails this check. + sys.path.insert(0, str(root)) + from renpybuild.context import Context + tree = ast.parse((root / "tasks/libffi.py").read_text()) + unpack = next(node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == "unpack") + unpack.decorator_list = [] + namespace = {"Context": Context, "version": "3.4.5"} + exec(compile(ast.Module(body=[unpack], type_ignores=[]), str(root / "tasks/libffi.py"), "exec"), namespace) + context = Context("ios", "arm64", "3", root, SimpleNamespace()) + context.set_names("arch", "unpack", "libffi") + namespace["unpack"](context) + source_root = context.cwd + for target, sdk in targets: + sdk_args = [] + if sys.platform == "darwin": + sdk_args = ["-isysroot", subprocess.check_output(["xcrun", "--sdk", sdk, "--show-sdk-path"], text=True).strip()] + command = [compiler, "-target", target, *sdk_args, *includes, "-c", "src/aarch64/sysv.S", "-o", target + ".o"] + result = subprocess.run(command, cwd=source_root, capture_output=True, text=True) + assert result.returncode == 0, result.stderr + output = (source_root / (target + ".o")).read_bytes() + assert output[:4] == bytes.fromhex("cffaedfe") + assert b"__eh_frame" in output and b"__compact_unwind" in output, "Patched assembly lost its unwind metadata" + if rejected == len(targets): + print("Original assembly reproduced the observed CFI rejection for both targets") + else: + print(f"Original assembly was accepted for {len(targets) - rejected}/{len(targets)} targets; negative-control rejection was not reproduced there") + print("Patched arm64 device/Simulator Mach-O assembly objects compiled with real unwind metadata") + if sys.platform != "darwin": + print("Linux check excluded the Apple closure page table; full Xcode runtime compilation and gameplay are not covered") + + +if __name__ == "__main__": + main() diff --git a/tools/test_renpy_mobile_metalangle_flags.py b/tools/test_renpy_mobile_metalangle_flags.py new file mode 100644 index 000000000..8b1543c69 --- /dev/null +++ b/tools/test_renpy_mobile_metalangle_flags.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Exercise the genuine MetalANGLE annotator and LLVM compiler/linker flag split. + +The compiled probe uses no SDK headers or framework functions. It checks the +actual clang driver with -Werror, not full framework linking or gameplay. +""" +import argparse +import ast +from pathlib import Path +import shlex +import shutil +import subprocess +import sys +import tarfile +import tempfile +from types import SimpleNamespace + +REPO = Path(__file__).resolve().parents[1] +PIN = "7bfab40c1174f622f644b24669afd5fb167fbb79" +PATCHES = REPO / "bridge/renpy_runtime/mobile_launcher/patches/native" + + +def annotator(path, Context): + tree = ast.parse(path.read_text()) + selected = next(n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name == "annotate") + selected.decorator_list = [] + namespace = {"Context": Context} + exec(compile(ast.Module(body=[selected], type_ignores=[]), str(path), "exec"), namespace) + return namespace["annotate"] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("build_source", type=Path) + parser.add_argument("--clang", type=Path) + args = parser.parse_args() + assert subprocess.check_output(["git", "-C", str(args.build_source), "rev-parse", "HEAD"], text=True).strip() == PIN + if args.clang: + compiler = str(args.clang) + elif sys.platform == "darwin": + compiler = subprocess.check_output(["xcrun", "--find", "clang"], text=True).strip() + else: + compiler = shutil.which("clang-18") or shutil.which("clang") + assert compiler, "A genuine LLVM clang is required" + print(subprocess.check_output([compiler, "--version"], text=True).splitlines()[0], flush=True) + with tempfile.TemporaryDirectory(prefix="renpy-metalangle-flags-") as temporary: + root = Path(temporary) + archive = subprocess.Popen([ + "git", "-C", str(args.build_source), "archive", "HEAD", "renpybuild", "tasks", + "source/Python-3.12.8-Setup.stdlib", "tools/cmake_build_variables.cmake", + ], stdout=subprocess.PIPE) + assert archive.stdout is not None + with tarfile.open(fileobj=archive.stdout, mode="r|*") as source: + source.extractall(root, filter="data") + assert archive.wait() == 0 + subprocess.run(["git", "apply", "--include=tasks/metalangle.py", str(PATCHES / "0006-ios-offscreen-renderer.patch")], cwd=root, check=True) + original = root / "original-metalangle.py" + original.write_bytes((root / "tasks/metalangle.py").read_bytes()) + subprocess.run(["git", "apply", str(PATCHES / "0007-darwin-ios-build.patch")], cwd=root, check=True) + sys.path.insert(0, str(root)) + from renpybuild.context import Context + source = root / "probe.c" + source.write_text("#ifndef METALANGLE\n#error MetalANGLE compile definition was lost\n#endif\nint renpy_compile_probe(void) { return 1; }\n") + for architecture, target in (("arm64", "arm64-apple-ios13.0"), ("sim-arm64", "arm64-apple-ios13.0-simulator")): + sdk_flags = [] + if sys.platform == "darwin": + sdk = "iphoneos" if architecture == "arm64" else "iphonesimulator" + sdk_root = subprocess.check_output(["xcrun", "--sdk", sdk, "--show-sdk-path"], text=True).strip() + assert Path(sdk_root).is_dir(), sdk_root + sdk_flags = ["-isysroot", sdk_root] + print(f"Actual {target} SDK: {sdk_root}", flush=True) + for version, expect_success in ((original, False), (root / "tasks/metalangle.py", True)): + context = Context("ios", architecture, "3", root, SimpleNamespace()) + context.set_names("arch", "build", "libavif") + annotator(version, Context)(context) + assert "MetalANGLE.framework/Headers" in context.environ["CPPFLAGS"] + for language, variable in (("c", "CFLAGS"), ("c++", "CXXFLAGS")): + flags = shlex.split(context.environ[variable]) + if expect_success: + assert "-framework" not in flags + assert "-F" in flags and "-DMETALANGLE" in flags + assert "MetalANGLE.framework/Headers" in context.environ[variable] + assert "-framework MetalANGLE" in context.environ["LDFLAGS"] + output = root / (architecture + language + ".o") + result = subprocess.run([compiler, "-target", target, *sdk_flags, "-x", language, "-Werror", *flags, + "-c", str(source), "-o", str(output)], capture_output=True, text=True) + if expect_success: + assert result.returncode == 0, result.stderr + assert output.read_bytes()[:4] == bytes.fromhex("cffaedfe") + else: + assert result.returncode != 0 and "linker" in result.stderr and "unused" in result.stderr, result.stderr + print("Actual LLVM -Werror reproduced framework flags in compile-only failures and compiled patched C/C++ device/Simulator objects") + print("MetalANGLE header/compile definitions and framework linker flags retained; full target linking and gameplay not covered") + + +if __name__ == "__main__": + main() diff --git a/tools/test_renpy_mobile_native_fork.sh b/tools/test_renpy_mobile_native_fork.sh new file mode 100644 index 000000000..cdfa4e162 --- /dev/null +++ b/tools/test_renpy_mobile_native_fork.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Validate the real native lifecycle fork source and the Ren'Py demo gate. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +native="$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native/renpy_mobile_lifecycle.c" +[[ -s "$native" ]] || { echo "missing native lifecycle fork: $native" >&2; exit 1; } +for symbol in \ + renpy_mobile_init renpy_mobile_tick renpy_mobile_frame renpy_mobile_input \ + renpy_mobile_pause renpy_mobile_resume renpy_mobile_shutdown \ + renpy_mobile_bind_window renpy_mobile_text_input_state renpy_mobile_set_surface_size; do + grep -Fq "$symbol" "$native" || { echo "native fork missing $symbol" >&2; exit 1; } +done +for forbidden in \ + 'SDL_main(' 'launcher_main(' 'Py_RunMain(' 'SDL_RunApp(' \ + 'SDL_UIKitRunApp(' 'UIApplicationMain(' 'System.exit(' 'exit('; do + if grep -Fq "$forbidden" "$native"; then + echo "native lifecycle fork contains forbidden process entrypoint: $forbidden" >&2 + exit 1 + fi +done +for patch in "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native"/[0-9][0-9][0-9][0-9]-*.patch; do + [[ -s "$patch" ]] || { echo "missing native build patch: $patch" >&2; exit 1; } + if [[ "$patch" == *host-bootstrap.patch ]]; then + grep -Fq 'renpy_mobile_bootstrap' "$patch" || { + echo "native build patch does not expose renpy_mobile_bootstrap: $patch" >&2 + exit 1 + } + fi +done + +if [[ -n "${RENPY_BUILD_TEST_ROOT:-}" && -d "${RENPY_BUILD_TEST_ROOT}/.git" ]]; then + for patch in "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native"/[0-9][0-9][0-9][0-9]-*.patch; do + git -C "$RENPY_BUILD_TEST_ROOT" apply --check "$patch" 2>/dev/null || \ + git -C "$RENPY_BUILD_TEST_ROOT" apply --reverse --check "$patch" + done + python3 "$repo_root/tools/test_renpy_mobile_optional_sdk.py" "$RENPY_BUILD_TEST_ROOT" + python3 "$repo_root/tools/test_renpy_mobile_build_parallelism.py" "$RENPY_BUILD_TEST_ROOT" +fi +bash "$repo_root/tools/test_renpy_demo_fixture.sh" +printf '%s\n' 'RenPy native source/demo fixture checks passed; no gameplay performed' diff --git a/tools/test_renpy_mobile_optional_sdk.py b/tools/test_renpy_mobile_optional_sdk.py new file mode 100644 index 000000000..e0ae12df7 --- /dev/null +++ b/tools/test_renpy_mobile_optional_sdk.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""Exercise optional-SDK task branches from patched pinned upstream sources. + +This verifies task selection and payload rules; it does not compile or run Ren'Py. +""" +import ast +import contextlib +from pathlib import Path +import re +import subprocess +import sys +import tarfile +import tempfile + + +REPO = Path(__file__).resolve().parents[1] +PATCH = REPO / "bridge/renpy_runtime/mobile_launcher/patches/native/0005-optional-cubism.patch" +PIN = "7bfab40c1174f622f644b24669afd5fb167fbb79" + + +def function(path, name, namespace): + tree = ast.parse(path.read_text()) + selected = next(node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == name) + selected.decorator_list = [] + exec(compile(ast.Module(body=[selected], type_ignores=[]), str(path), "exec"), namespace) + return namespace[name] + + +class Context: + python = "3" + platform = "android" + + def __init__(self, root): + self.root = root + self.renpy = root / "renpy" + self.environ = {"CUBISM": "/unrelated/host/sdk"} + self.variables = {} + self.includes = [] + self.commands = [] + self.modules = [] + + def path(self, text): + values = { + "install": self.root / "install", + "pytmp": self.root / "pytmp", + "source": self.root / "source", + "distlib": self.root / "distlib", + "pythonver": "python3.12", + "pycver": "312", + "runtime": self.root / "runtime", + } + for key, value in values.items(): + text = text.replace("{{ " + key + " }}", str(value)) + return Path(text) + + def expand(self, text): + return str(self.path(text)) + + def include(self, text): + self.includes.append(self.path(text)) + + def env(self, name, value): + self.environ[name] = self.expand(value) + + def var(self, name, value): + self.variables[name] = value + + def run(self, command): + for name, value in self.variables.items(): + command = command.replace("{{ " + name + " }}", str(value)) + self.commands.append(command) + if command.startswith("mkdir -p "): + self.path(command[len("mkdir -p "):]).mkdir(parents=True, exist_ok=True) + + def run_group(self): + return contextlib.nullcontext(self) + + def generate(self, _source, _destination, *, modules): + self.modules = list(modules) + + def unlink(self, _path): + pass + + def copy(self, _source, _destination): + pass + + def rmtree(self, _path): + pass + + def clean(self, path): + self.path(path).mkdir(parents=True, exist_ok=True) + + def compile(self, path): + # Emulate bytecode filenames so the actual packaging rule loop runs. + for source in Path(path).glob("**/*.py"): + destination = source.parent / "__pycache__" / (source.stem + ".cpython-312.pyc") + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(b"rule-test") + + +def write(root, name, text): + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + return path + + +def main(): + checkout = Path(sys.argv[1]) + actual = subprocess.check_output(["git", "-C", str(checkout), "rev-parse", "HEAD"], text=True).strip() + assert actual == PIN, (actual, PIN) + with tempfile.TemporaryDirectory(prefix="renpy-optional-sdk-") as temporary: + root = Path(temporary) + archive = subprocess.Popen([ + "git", "-C", str(checkout), "archive", "HEAD", + "tasks/live2d.py", "tasks/librenpy.py", "tasks/pythonlib.py", + ], stdout=subprocess.PIPE) + assert archive.stdout is not None + with tarfile.open(fileobj=archive.stdout, mode="r|*") as source: + source.extractall(root, filter="data") + assert archive.wait() == 0 + subprocess.run(["git", "apply", "--check", str(PATCH)], cwd=root, check=True) + subprocess.run(["git", "apply", str(PATCH)], cwd=root, check=True) + namespace = {"Context": Context} + annotate = function(root / "tasks/live2d.py", "annotate", namespace) + build = function(root / "tasks/librenpy.py", "build", namespace) + package = function(root / "tasks/pythonlib.py", "python3", { + "Context": Context, "PY3_MODULES": "", "re": re, + "shutil": __import__("shutil"), + }) + write(root, "renpy/src/Setup", "base base.c\nrenpy.gl2.live2dmodel live2dmodel.c live2dcsm.c\n") + write(root, "extensions/Setup", "extension extension.c\n") + write(root, "pytmp/pyjnius/Setup", "jnius jnius.c\n") + write(root, "source/brotli/Setup", "_brotli _brotli.c\n") + context = Context(root) + annotate(context) + assert "CUBISM" not in context.environ and not context.includes + build(context) + assert context.modules == ["base", "extension", "jnius", "_brotli"], context.modules + assert not any("live2dmodel" in command or "live2dcsm" in command for command in context.commands) + header = write(root, "install/cubism/Core/include/Live2DCubismCore.h", "/* test SDK */\n") + context = Context(root) + annotate(context) + assert context.environ["CUBISM"] == str(header.parents[2]) + assert context.includes == [header.parent] + build(context) + assert "renpy.gl2.live2dmodel" in context.modules + assert any("live2dmodel" in command for command in context.commands) + assert any("live2dcsm" in command for command in context.commands) + for present in (False, True): + case = root / ("steam-present" if present else "steam-absent") + write(case, "distlib/python3.12/certifi/empty", "") + write(case, "runtime/site3.py", "# test sitecustomize\n") + if present: + write(case, "pytmp/steam/steamapi.py", "# generated by SDK task\n") + package(Context(case)) + assert (case / "distlib/python3.12/steamapi.pyc").exists() == present + print("Pinned optional Cubism/Steam task branches passed; no native build or gameplay performed") + + +if __name__ == "__main__": + main() diff --git a/tools/test_renpy_mobile_packaging.sh b/tools/test_renpy_mobile_packaging.sh new file mode 100755 index 000000000..8ffd8ba38 --- /dev/null +++ b/tools/test_renpy_mobile_packaging.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Exercise input rejection and resource validation; fixtures are never apps. +set -euo pipefail +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +validator="$repo_root/tools/validate_renpy_mobile_payload.py" +work="$(mktemp -d "${TMPDIR:-/tmp}/aetherkiri-renpy-packaging.XXXXXX")" +trap 'rm -rf "$work"' EXIT +bash -n "$repo_root/scripts/build_android.sh" "$repo_root/scripts/build_ios.sh" \ + "$repo_root/tools/stage_renpy_android_support.sh" + +mkdir -p "$work/private/renpy/display" "$work/private/lib/python3.12/encodings" +for file in main.py renpy/__init__.py renpy/main.py renpy/execution.py \ + renpy/display/core.py lib/python3.12/site.py lib/python3.12/encodings/__init__.py; do + printf '# packaging contract fixture\n' > "$work/private/$file" +done +cat > "$work/private/renpy/aether_mobile.py" <<'PY' +def cooperative_start(): pass +def cooperative_tick(): pass +def cooperative_stop(): pass +PY +python3 "$validator" --platform android --private-root "$work/private" +# The distribution carries sourceless Python 3.12 modules, so verify the +# actual bytecode-reading path as well as source resources. +python3 - "$work/private" <<'PY' +from pathlib import Path +import py_compile +import sys +for source in Path(sys.argv[1]).rglob('*.py'): + if source.name == 'main.py' and source.parent == Path(sys.argv[1]): + continue + py_compile.compile(str(source), cfile=str(source.with_suffix('.pyc')), doraise=True) + source.unlink() +PY +python3 "$validator" --platform android --private-root "$work/private" +rm "$work/private/renpy/aether_mobile.pyc" +if python3 "$validator" --platform android --private-root "$work/private" \ + > "$work/stdout" 2> "$work/stderr"; then + echo 'packaging accepted a Python runtime without the cooperative module' >&2 + exit 1 +fi +grep -Fq 'missing Python module' "$work/stderr" +ln -s /tmp "$work/private/escape" +if python3 "$validator" --platform android --private-root "$work/private" \ + > "$work/stdout" 2> "$work/stderr"; then + echo 'packaging accepted an asset symlink' >&2 + exit 1 +fi +grep -Fq 'must not contain symlinks' "$work/stderr" + +# A host shared library must not be mislabeled as a mobile deliverable even +# when its function names resemble the lifecycle ABI. +printf 'int renpy_mobile_init(void) { return 0; }\n' > "$work/host.c" +"${CC:-cc}" -shared -fPIC "$work/host.c" -o "$work/host.so" +if python3 "$validator" --platform android --library "$work/host.so" \ + > "$work/stdout" 2> "$work/stderr"; then + echo 'packaging accepted a host shared library as Android arm64' >&2 + exit 1 +fi +grep -Eq 'must target arm64-v8a|must be a little-endian ELF64' "$work/stderr" + +echo "Ren'Py mobile packaging rejection/resource checks passed; no mobile app executed" diff --git a/tools/test_renpy_mobile_patch_series.py b/tools/test_renpy_mobile_patch_series.py new file mode 100644 index 000000000..5997c33de --- /dev/null +++ b/tools/test_renpy_mobile_patch_series.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Verify complete-series idempotency and drift refusal on genuine pins. + +Optional Ren'Py/greenlet checkouts also exercise prepare -> prepare -> the real +build preflight. That preflight must reject the absent NDK, not pretend a build. +""" +import argparse +from pathlib import Path +import subprocess +import sys +import tempfile + +REPO = Path(__file__).resolve().parents[1] +HELPER = REPO / "tools/apply_renpy_mobile_patch_series.py" +PIN = "7bfab40c1174f622f644b24669afd5fb167fbb79" + + +def clone(source, destination, pin): + subprocess.run(["git", "clone", "--shared", "--quiet", "--no-checkout", str(source), str(destination)], check=True) + subprocess.run(["git", "-C", str(destination), "checkout", "--quiet", "--detach", pin], check=True) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("build_source", type=Path) + parser.add_argument("--renpy-src", type=Path) + parser.add_argument("--greenlet-src", type=Path) + args = parser.parse_args() + patches = sorted((REPO / "bridge/renpy_runtime/mobile_launcher/patches/native").glob("[0-9][0-9][0-9][0-9]-*.patch")) + with tempfile.TemporaryDirectory(prefix="renpy-patch-series-") as temporary: + root = Path(temporary) / "renpy-build" + clone(args.build_source, root, PIN) + command = [sys.executable, str(HELPER), "--checkout", str(root), "--pin", PIN, *map(str, patches)] + subprocess.run(command, check=True, stdout=subprocess.DEVNULL) + repeated = subprocess.run(command, check=True, capture_output=True, text=True) + assert "already applied and verified" in repeated.stdout + for relative in ("runtime/librenpython3.c", "build.sh"): + path = root / relative + before = path.read_bytes() + changed = before + b"\n# actual source drift\n" + path.write_bytes(changed) + failure = subprocess.run(command, capture_output=True, text=True) + assert failure.returncode != 0 and "drift" in failure.stderr.lower(), failure.stderr + assert path.read_bytes() == changed, "drift was overwritten instead of rejected" + path.write_bytes(before) + subprocess.run(command, check=True, stdout=subprocess.DEVNULL) + partial = Path(temporary) / "partial" + clone(args.build_source, partial, PIN) + subprocess.run(["git", "apply", str(patches[0])], cwd=partial, check=True) + failure = subprocess.run([sys.executable, str(HELPER), "--checkout", str(partial), "--pin", PIN, *map(str, patches)], capture_output=True, text=True) + assert failure.returncode != 0 and "partial/stale patch series" in failure.stderr, failure.stderr + if args.renpy_src or args.greenlet_src: + assert args.renpy_src and args.greenlet_src + clone(args.renpy_src, root / "renpy", "39895c1e017f0b36ffea2447d97eccd69d76ee1c") + clone(args.greenlet_src, root / "aether-greenlet", "65f8da82b13a1273e55a6bfcbd1f9da09fc4eb7a") + runner = ["bash", str(REPO / "tools/run_renpy_mobile_source_build.sh"), "--platform", "android", "--renpy-build", str(root)] + for _ in range(2): + subprocess.run([*runner, "--mode", "prepare"], check=True, stdout=subprocess.DEVNULL) + preflight = subprocess.run([*runner, "--mode", "build"], capture_output=True, text=True) + assert preflight.returncode != 0 and "source-build preflight failed" in preflight.stdout + preflight.stderr, preflight.stdout + preflight.stderr + assert "patch does not apply" not in preflight.stderr + assert "android-ndk-r29-linux.zip" in preflight.stdout + preflight.stderr + print("Real pinned prepare -> prepare -> build preflight passed; missing NDK correctly refused compilation") + print("Complete pinned patch-series idempotency and source-drift checks passed; no native build or gameplay performed") + + +if __name__ == "__main__": + main() diff --git a/tools/test_renpy_mobile_runtime_protocol.sh b/tools/test_renpy_mobile_runtime_protocol.sh new file mode 100755 index 000000000..7f9fd7029 --- /dev/null +++ b/tools/test_renpy_mobile_runtime_protocol.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Native/provider boundary tests with controlled Python/launcher modules. +# These tests do not build an APK/IPA or prove Ren'Py gameplay. +set -euo pipefail +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +work="$(mktemp -d "${TMPDIR:-/tmp}/aether-renpy-protocol.XXXXXX")" +trap 'rm -rf "$work"' EXIT +cc="${CC:-cc}" +cxx="${CXX:-c++}" +python_config="${PYTHON_CONFIG:-python3-config}" +command -v "$python_config" >/dev/null || { echo 'CPython development headers/python3-config are required' >&2; exit 1; } +read -r -a python_includes <<<"$("$python_config" --includes)" +read -r -a python_libs <<<"$("$python_config" --embed --ldflags)" +includes=(-I"$repo_root/bridge/renpy_runtime/include" -I"$repo_root/bridge/renpy_runtime/mobile_launcher/include" -I"$repo_root/abi/include") +"$cc" -std=c11 -Wall -Wextra -Werror "${python_includes[@]}" "${includes[@]}" \ + -c "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native/renpy_mobile_lifecycle.c" -o "$work/lifecycle.o" +"$cxx" -std=c++17 -Wall -Wextra -Werror "${python_includes[@]}" "${includes[@]}" \ + "$repo_root/bridge/renpy_runtime/tests/mobile_lifecycle.cpp" "$work/lifecycle.o" "${python_libs[@]}" \ + -o "$work/lifecycle-test" +python_lib_dir="$("$python_config" --prefix)/lib" +LD_LIBRARY_PATH="$python_lib_dir${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" "$work/lifecycle-test" +"$cxx" -std=c++17 -Wall -Wextra -Werror -DAETHERKIRI_RENPY_PROTOCOL_TEST -Wl,-export-dynamic "${includes[@]}" \ + "$repo_root/bridge/renpy_runtime/tests/mobile_provider_protocol.cpp" \ + "$repo_root/bridge/renpy_runtime/src/renpy_runtime_mobile_provider.cpp" \ + "$repo_root/bridge/renpy_runtime/mobile_launcher/src/renpy_mobile_loader.cpp" \ + -ldl -pthread -o "$work/provider-test" +"$work/provider-test" +printf '%s\n' 'RenPy native/provider protocol tests passed (not gameplay acceptance)' diff --git a/tools/test_renpy_mobile_source_build_plan.sh b/tools/test_renpy_mobile_source_build_plan.sh new file mode 100755 index 000000000..8cd9bd8e0 --- /dev/null +++ b/tools/test_renpy_mobile_source_build_plan.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Check source-build gating, pins and platform-aware prerequisites. No gameplay. +set -euo pipefail +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +launcher="$repo_root/bridge/renpy_runtime/mobile_launcher/build.sh" +runner="$repo_root/tools/run_renpy_mobile_source_build.sh" +runner_tmp="$(mktemp -d "${TMPDIR:-/tmp}/renpy-mobile-source-runner.XXXXXX")" +trap 'rm -rf "$runner_tmp"' EXIT +bash -n "$runner" +bash -n "$launcher" +skip_output="$(AETHERKIRI_RENPY_BUILD_ROOT="$runner_tmp/missing" bash "$runner" --mode auto)" +grep -Fq 'source build skipped' <<<"$skip_output" +if AETHERKIRI_RENPY_SOURCE_BUILD_REQUIRED=1 AETHERKIRI_RENPY_BUILD_ROOT="$runner_tmp/missing" \ + bash "$runner" --mode auto >"$runner_tmp/required.stdout" 2>"$runner_tmp/required.stderr"; then + echo "required source build unexpectedly skipped" >&2; exit 1 +fi +grep -Fq 'requires an official renpy-build checkout' "$runner_tmp/required.stderr" +if bash "$runner" --ref master --mode prepare >"$runner_tmp/ref.stdout" 2>"$runner_tmp/ref.stderr"; then + echo "unpinned build revision unexpectedly accepted" >&2; exit 1 +fi +grep -Fq 'unsupported build revision' "$runner_tmp/ref.stderr" +bash "$launcher" --source-build-plan --renpy-build "$repo_root" --platform android >"$runner_tmp/plan" +for expected in '7bfab40c1174f622f644b24669afd5fb167fbb79' '39895c1e017f0b36ffea2447d97eccd69d76ee1c' \ + '65f8da82b13a1273e55a6bfcbd1f9da09fc4eb7a' '--python 3 rebuild librenpy pythonlib renpython rapt sdl2' \ + 'librenpython.so' 'librenpython.a' 'does not assert mobile playability'; do + grep -Fq -- "$expected" "$runner_tmp/plan" +done +if bash "$launcher" --check-renpy-build --renpy-build "$repo_root" --platform android >"$runner_tmp/check" 2>&1; then + echo "strict preflight unexpectedly accepted the Aether checkout" >&2; exit 1 +fi +grep -Fq 'Ren\x27Py source-build preflight failed' "$runner_tmp/check" || grep -Fq "Ren'Py source-build preflight failed" "$runner_tmp/check" +if grep -Fq 'missing toolchain archive:' "$runner_tmp/check"; then + grep -Fq 'android-ndk-r29-linux.zip' "$runner_tmp/check" + if grep -Eq '^missing toolchain archive:.*iPhone' "$runner_tmp/check"; then + echo "Android-only preflight incorrectly requires a licensed iOS SDK" >&2; exit 1 + fi +fi +python3 - "$runner" <<'PY' +from pathlib import Path +import sys +source = Path(sys.argv[1]).read_text() +assert source.index('--checkout "$renpy_build/renpy" --pin "$renpy_sha"') < source.index('./build.sh --platform android') +assert source.index('"$patch_root/native"/[0-9][0-9][0-9][0-9]-*.patch') < source.index('./build.sh --platform android') +assert '"$mode" == "check" || "$mode" == "auto"' not in source, 'auto must not mean check-only' +for symbol in ('renpy_mobile_text_input_state', 'renpy_mobile_set_surface_size'): + assert symbol in source +assert 'rapt-sdl2' not in source, 'pinned release has a sdl2 task, no rapt-sdl2 task' +PY +python3 - "$runner" "$runner_tmp" <<'PY' +from pathlib import Path +import subprocess +import sys +source = Path(sys.argv[1]).read_text() +root = Path(sys.argv[2]) +symbols = source[source.index('symbols=('):source.index('\ncheck_exports()')] +function = source[source.index('check_exports()'):source.index('\nartifact_root=')] +catalog = root / 'large-symbol-catalog' +required = [name for name in symbols.replace('(', ' ').replace(')', ' ').split() if name.startswith('renpy_mobile_')] +catalog.write_text(''.join('000000 T ' + name + '\n' for name in required) + + ''.join(f'000000 T runtime_function_{i}\n' for i in range(20000))) +artifact = root / 'export-gate-library' +artifact.touch() +script = root / 'export-gate-check.sh' +script.write_text('set -euo pipefail\n' + symbols + '\n' + function + '\n' + + 'nm() { cat "$RENPY_GATE_CATALOG"; }\n' + + 'check_exports "$RENPY_GATE_ARTIFACT" android\n') +import os +environment = dict(os.environ, RENPY_GATE_CATALOG=str(catalog), RENPY_GATE_ARTIFACT=str(artifact)) +subprocess.run(['bash', str(script)], env=environment, check=True) +catalog.write_text(catalog.read_text().replace('000000 T renpy_mobile_tick\n', '')) +failure = subprocess.run(['bash', str(script)], env=environment, capture_output=True, text=True) +assert failure.returncode != 0 and 'lacks renpy_mobile_tick' in failure.stderr +PY +echo "Ren'Py mobile source-build gating checks passed; no native build/gameplay performed" diff --git a/tools/test_renpy_mobile_static_api.py b/tools/test_renpy_mobile_static_api.py new file mode 100644 index 000000000..d9e2a609c --- /dev/null +++ b/tools/test_renpy_mobile_static_api.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Generate a genuine Cython API and compile the C objects that consume it. + +This checks clean-source host compilation, not target linkage or gameplay. +""" +import argparse +import ast +from pathlib import Path +import os +import shlex +import subprocess +import sys +import sysconfig +import tarfile +import tempfile + + +REPO = Path(__file__).resolve().parents[1] +BUILD_PIN = "7bfab40c1174f622f644b24669afd5fb167fbb79" +RENPY_PIN = "39895c1e017f0b36ffea2447d97eccd69d76ee1c" +PATCHES = REPO / "bridge/renpy_runtime/mobile_launcher/patches" + + +def archive(checkout, destination, pin, *paths): + actual = subprocess.check_output(["git", "-C", str(checkout), "rev-parse", "HEAD"], text=True).strip() + assert actual == pin, (actual, pin) + command = subprocess.Popen(["git", "-C", str(checkout), "archive", "HEAD", *paths], stdout=subprocess.PIPE) + assert command.stdout is not None + with tarfile.open(fileobj=command.stdout, mode="r|*") as contents: + contents.extractall(destination, filter="data") + assert command.wait() == 0 + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("renpy_build", type=Path) + parser.add_argument("renpy_source", type=Path) + parser.add_argument("--sdl-headers", type=Path, help="SDL2 include directory (otherwise use system headers)") + args = parser.parse_args() + sys.path.insert(0, str(args.renpy_build.resolve())) + from renpybuild.context import Context + + with tempfile.TemporaryDirectory(prefix="renpy-static-api-") as temporary: + root = Path(temporary) + source = root / "renpy" + source.mkdir() + archive(args.renpy_build, root, BUILD_PIN, "tasks/librenpy.py", "tasks/renpython.py") + archive(args.renpy_source, source, RENPY_PIN) + task_file = root / "tasks/librenpy.py" + original_task = task_file.read_text() + subprocess.run(["git", "apply", str(PATCHES / "native/0001-renpy-build-link.patch")], cwd=root, check=True) + subprocess.run(["git", "apply", str(PATCHES / "python/0001-cooperative-loop-skeleton.patch")], cwd=source, check=True) + generated = source / "tmp/gen3-static" + generated.mkdir(parents=True) + subprocess.run([ + sys.executable, "-m", "cython", "-Isrc", "-Isrc/pygame/include", "-Itmp/gen3-static", "-I.", + "-X", "profile=False", "-X", "embedsignature=True", "-X", "embedsignature.format=python", + "renpy/pygame/surface.pyx", "-o", str(generated / "renpy.pygame.surface.c"), + ], cwd=source, check=True) + assert (generated / "renpy.pygame.surface_api.h").is_file() + assert not (source / "tmp/gen3").exists(), "test must not use preexisting dynamic API headers" + + base = ["-fPIC", "-I" + sysconfig.get_path("include")] + if args.sdl_headers: + include = root / "include" + include.mkdir() + (include / "SDL2").symlink_to(args.sdl_headers.resolve(), target_is_directory=True) + base.append("-I" + str(include)) + + def flags(task_text, name): + context = Context("android", "x86_64", "3", root, None) + context.env(name, shlex.join(base)) + tree = ast.parse(task_text) + build = next(node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == "build") + assignments = [node for node in build.body if isinstance(node, ast.Expr) + and isinstance(node.value, ast.Call) and isinstance(node.value.func, ast.Attribute) + and node.value.func.attr == "env" and node.value.args[0].value == name] + assert len(assignments) == 1 + exec(compile(ast.Module(body=assignments, type_ignores=[]), str(task_file), "exec"), {"c": context}) + return shlex.split(context.environ[name]) + + compiler = shlex.split(os.environ.get("CC", "cc")) + # The unpatched task must reproduce the missing-header error. + control = subprocess.run(compiler + flags(original_task, "CFLAGS") + ["-c", str(source / "src/core.c"), + "-o", str(root / "control.o")], capture_output=True, text=True) + assert control.returncode != 0 and "renpy.pygame.surface_api.h" in control.stderr, control.stderr + task_text = task_file.read_text() + for name in ("core", "renpysound_core"): + subprocess.run(compiler + flags(task_text, "CFLAGS") + ["-c", str(source / f"src/{name}.c"), + "-o", str(root / f"{name}.o")], check=True) + probe = root / "api.cc" + probe.write_text('#include \n#include \n#include \n') + subprocess.run(shlex.split(os.environ.get("CXX", "c++")) + flags(task_text, "CXXFLAGS") + + ["-c", str(probe), "-o", str(root / "api.o")], check=True) + print("Actual static Cython API generation and C/C++ compilation passed; no target linkage or gameplay performed") + + +if __name__ == "__main__": + main() diff --git a/tools/test_renpy_mobile_symbol_gate.sh b/tools/test_renpy_mobile_symbol_gate.sh new file mode 100644 index 000000000..e0df9e142 --- /dev/null +++ b/tools/test_renpy_mobile_symbol_gate.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# Validate the host lifecycle export surface without claiming mobile playability. +# +# With no artifact paths this runs a deterministic shared-library fixture and +# a negative missing-symbol check. CI can pass RENPY_MOBILE_ANDROID_SO and/or +# RENPY_MOBILE_IOS_A to gate a rebuilt native payload once one is available. +set -euo pipefail + +required_symbols=( + renpy_mobile_bootstrap + renpy_mobile_bind_window + renpy_mobile_init + renpy_mobile_tick + renpy_mobile_frame + renpy_mobile_input + renpy_mobile_pause + renpy_mobile_resume + renpy_mobile_shutdown + renpy_mobile_text_input_state + renpy_mobile_set_surface_size +) + +nm_listing() { + local artifact="$1" kind="$2" + [[ -f "$artifact" ]] || { + echo "lifecycle artifact does not exist: $artifact" >&2 + return 1 + } + local listing + listing="$(mktemp)" + case "$kind" in + shared) + if ! nm -D --defined-only "$artifact" >"$listing" 2>/dev/null; then + nm -gU "$artifact" >"$listing" + fi + ;; + static) + if ! nm -gU "$artifact" >"$listing" 2>/dev/null; then + nm -g "$artifact" >"$listing" + fi + ;; + *) echo "unknown artifact kind: $kind" >&2; rm -f "$listing"; return 2 ;; + esac + printf '%s\n' "$listing" +} + +check_symbols() { + local artifact="$1" kind="$2" listing + listing="$(nm_listing "$artifact" "$kind")" + local missing=0 symbol + for symbol in "${required_symbols[@]}"; do + if ! awk -v symbol="$symbol" '{ name=$NF; sub(/^_/, "", name); if (name==symbol) found=1 } END { exit !found }' "$listing"; then + echo "missing lifecycle export: ${symbol} (${artifact})" >&2 + missing=1 + fi + done + rm -f "$listing" + ((missing == 0)) +} + +check_optional_artifact() { + local variable_name="$1" kind="$2" artifact="" + if [[ -n "${!variable_name+x}" ]]; then + artifact="${!variable_name}" + fi + if [[ -n "$artifact" ]]; then + echo "Checking lifecycle exports from ${variable_name}: ${artifact}" + check_symbols "$artifact" "$kind" + fi +} + +check_optional_artifact RENPY_MOBILE_ANDROID_SO shared +check_optional_artifact RENPY_MOBILE_IOS_A static + +work="$(mktemp -d "${TMPDIR:-/tmp}/aetherkiri-renpy-symbol-gate.XXXXXX")" +trap 'rm -rf "$work"' EXIT + +cat >"$work/fixture.c" <<'C' +#define EXPORT __attribute__((visibility("default"))) +EXPORT int renpy_mobile_bootstrap(void) { return 0; } +EXPORT int renpy_mobile_bind_window(void) { return 0; } +EXPORT int renpy_mobile_init(void) { return 0; } +EXPORT int renpy_mobile_tick(void) { return 0; } +EXPORT int renpy_mobile_frame(void) { return 0; } +EXPORT int renpy_mobile_input(void) { return 0; } +EXPORT int renpy_mobile_pause(void) { return 0; } +EXPORT int renpy_mobile_resume(void) { return 0; } +EXPORT void renpy_mobile_shutdown(void) {} +EXPORT int renpy_mobile_text_input_state(void) { return 0; } +EXPORT int renpy_mobile_set_surface_size(void) { return 0; } +C +"${CC:-cc}" -shared -fPIC "$work/fixture.c" -o "$work/librenpython.so" +check_symbols "$work/librenpython.so" shared + +cat >"$work/missing.c" <<'C' +int renpy_mobile_init(void) { return 0; } +C +"${CC:-cc}" -shared -fPIC "$work/missing.c" -o "$work/librenpython-missing.so" +if check_symbols "$work/librenpython-missing.so" shared; then + echo "missing lifecycle export fixture unexpectedly passed" >&2 + exit 1 +fi + +echo "Ren'Py mobile lifecycle export gate passed (fixture + negative check)" diff --git a/tools/test_stage_renpy_android_support.sh b/tools/test_stage_renpy_android_support.sh new file mode 100644 index 000000000..d797bf938 --- /dev/null +++ b/tools/test_stage_renpy_android_support.sh @@ -0,0 +1,181 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +stage="$repo_root/tools/stage_renpy_android_support.sh" +[[ -f "$stage" ]] || { echo "stager is missing: $stage" >&2; exit 1; } +bash -n "$stage" + +# This library is deliberately marked as a contract-only fixture. It checks +# ABI selection and staging ownership; it is never used as a gameplay proof. +abi_work="$(mktemp -d "${TMPDIR:-/tmp}/aetherkiri-renpy-abi-stage.XXXXXX")" +trap 'rm -rf "$abi_work"' EXIT +abi_main="$abi_work/mobile/rapt/prototype/renpyandroid/src/main" +mkdir -p "$abi_main/jniLibs/x86_64" "$abi_main/java/org/renpy/android" \ + "$abi_work/android-build/src/main/jniLibs/x86_64" +printf '// Godot Gradle staging fixture\n' > "$abi_work/android-build/build.gradle" +cat > "$abi_work/fixture.c" <<'C' +const char aether_contract_fixture[] = "AETHERKIRI_RENPY_LIFECYCLE_STUB"; +int renpy_mobile_init(void) { return -3; } +C +"${CC:-cc}" -shared -fPIC "$abi_work/fixture.c" \ + -o "$abi_main/jniLibs/x86_64/librenpython.so" +cp "$abi_main/jniLibs/x86_64/librenpython.so" \ + "$abi_main/jniLibs/x86_64/liblegacy.so" +printf 'class PythonSDLActivity {}\n' \ + > "$abi_main/java/org/renpy/android/PythonSDLActivity.java" +printf 'unrelated host library\n' \ + > "$abi_work/android-build/src/main/jniLibs/x86_64/libhost.so" +bash "$stage" --allow-unsupported --abis x86_64 \ + --mobile-root "$abi_work/mobile" --godot-build "$abi_work/android-build" >/dev/null +abi_manifest="$abi_work/android-build/renpy_assets/renpy_mobile/manifest.properties" +grep -Fx 'native_abis=x86_64' "$abi_manifest" +grep -Fx 'native_library=lib/x86_64/librenpython.so' "$abi_manifest" +grep -Fx 'gradle_asset_source=renpy_assets' "$abi_manifest" +grep -Fx "android.sourceSets.main.assets.srcDir(file('renpy_assets'))" \ + "$abi_work/android-build/aether-renpy.gradle" +grep -Fx " 'lib/x86_64/librenpython.so'," "$abi_work/android-build/aether-renpy.gradle" +[[ "$(grep -Fc " 'lib/x86_64/" "$abi_work/android-build/aether-renpy.gradle")" == 2 ]] +! grep -Fq 'libhost.so' "$abi_work/android-build/aether-renpy.gradle" +mkdir -p "$abi_work/android-build/src/main/assets" +printf 'Godot export cleanup sentinel\n' > "$abi_work/android-build/src/main/assets/sentinel" +rm -rf "$abi_work/android-build/src/main/assets" +[[ -s "$abi_manifest" ]] +[[ -s "$abi_work/android-build/renpy_assets/renpy_mobile/rapt/java/org/renpy/android/PythonSDLActivity.java" ]] +cmp "$abi_main/jniLibs/x86_64/librenpython.so" \ + "$abi_work/android-build/src/main/jniLibs/x86_64/librenpython.so" +[[ ! -e "$abi_work/android-build/src/main/jniLibs/arm64-v8a/librenpython.so" ]] + +# A repeated staging run updates its own Java helpers and removes obsolete +# Ren'Py native dependencies while preserving files owned by the host. +rm "$abi_main/jniLibs/x86_64/liblegacy.so" +bash "$stage" --allow-unsupported --abis x86_64 \ + --mobile-root "$abi_work/mobile" --godot-build "$abi_work/android-build" >/dev/null +[[ ! -e "$abi_work/android-build/src/main/jniLibs/x86_64/liblegacy.so" ]] +[[ "$(grep -Fxc "apply from: 'aether-renpy.gradle'" "$abi_work/android-build/build.gradle")" == 1 ]] +! grep -Fq 'liblegacy.so' "$abi_work/android-build/aether-renpy.gradle" +grep -Fx 'unrelated host library' \ + "$abi_work/android-build/src/main/jniLibs/x86_64/libhost.so" +if bash "$stage" --allow-unsupported --abis mips \ + --mobile-root "$abi_work/mobile" --godot-build "$abi_work/android-build" \ + > "$abi_work/stdout" 2> "$abi_work/stderr"; then + echo 'unsupported packaging ABI unexpectedly accepted' >&2 + exit 1 +fi +grep -Fq 'Unsupported Ren' "$abi_work/stderr" +if bash "$stage" --allow-unsupported \ + --mobile-root "$abi_work/mobile" --godot-build "$abi_work/android-build" \ + > "$abi_work/stdout" 2> "$abi_work/stderr"; then + echo 'default arm64 build silently used an x86_64 payload' >&2 + exit 1 +fi +grep -Fq 'RAPT arm64-v8a native library is missing' "$abi_work/stderr" +rm -rf "$abi_work" +trap - EXIT +echo "Ren'Py Android x86_64 staging/ownership checks passed (contract fixture, no mobile app executed)" + +mobile_root="${RENPY_MOBILE_STAGE_TEST_ROOT:-/workspace/shared/renpy-mobile-staged}" +if [[ ! -f "$mobile_root/rapt/prototype/renpyandroid/src/main/jniLibs/arm64-v8a/librenpython.so" ]]; then + echo "Ren'Py mobile stage not present; metadata-only staging test skipped" + exit 0 +fi + +tmp_root="$(mktemp -d "${TMPDIR:-/tmp}/aetherkiri-renpy-android-stage.XXXXXX")" +conflict_root="$(mktemp -d "${TMPDIR:-/tmp}/aetherkiri-renpy-android-conflict.XXXXXX")" +trap 'rm -rf "$tmp_root" "$conflict_root"' EXIT +mkdir -p "$tmp_root/android-build/src/main" "$tmp_root/private" +printf '// Godot Gradle staging fixture\n' > "$tmp_root/android-build/build.gradle" +printf 'private fixture\n' > "$tmp_root/private/private.mp3" + +bash "$stage" \ + --allow-unsupported \ + --mobile-root "$mobile_root" \ + --godot-build "$tmp_root/android-build" \ + --private-assets "$tmp_root/private" >/dev/null + +main="$tmp_root/android-build/src/main" +[[ -s "$main/jniLibs/arm64-v8a/librenpython.so" ]] +[[ "$(dd if="$main/jniLibs/arm64-v8a/librenpython.so" bs=4 count=1 2>/dev/null | od -An -tx1 | tr -d ' \\n')" == "7f454c46" ]] +[[ -s "$tmp_root/android-build/renpy_assets/renpy_mobile/rapt/java/org/renpy/android/PythonSDLActivity.java" ]] +[[ -s "$tmp_root/android-build/renpy_assets/renpy_mobile/rapt/templates/app-AndroidManifest.xml" ]] +[[ -s "$tmp_root/android-build/renpy_assets/renpy_mobile/rapt/build.gradle" ]] +[[ -s "$tmp_root/android-build/renpy_assets/renpy_mobile/private/private.mp3" ]] +[[ -s "$tmp_root/android-build/renpy_assets/renpy_mobile/rapt/private.mp3" ]] +# Keep the loader preflight tied to the official payload's real ABI. GNU nm +# understands the staged arm64 ELF even when no Android runtime is available. +if command -v nm >/dev/null 2>&1; then + for symbol in \ + SDL_main \ + JNI_OnLoad \ + SDL_AndroidGetJNIEnv \ + SDL_AndroidGetActivity \ + Java_org_libsdl_app_SDLActivity_nativeSetupJNI \ + Java_org_libsdl_app_SDLActivity_nativeRunMain \ + Java_org_libsdl_app_SDLActivity_onNativeSurfaceCreated \ + Java_org_libsdl_app_SDLActivity_onNativeSurfaceChanged \ + Java_org_libsdl_app_SDLActivity_onNativeSurfaceDestroyed \ + Java_org_libsdl_app_SDLActivity_nativeSetScreenResolution \ + Java_org_libsdl_app_SDLActivity_onNativeResize \ + Java_org_libsdl_app_SDLActivity_onNativeKeyDown \ + Java_org_libsdl_app_SDLActivity_onNativeKeyUp \ + Java_org_libsdl_app_SDLActivity_onNativeTouch \ + Java_org_libsdl_app_SDLActivity_nativePause \ + Java_org_libsdl_app_SDLActivity_nativeResume \ + Java_org_libsdl_app_SDLActivity_nativeQuit \ + Java_org_renpy_android_PythonSDLActivity_nativeSetEnv; do + nm -D --defined-only "$main/jniLibs/arm64-v8a/librenpython.so" \ + | awk '{ print $3 }' | grep -Fx "$symbol" >/dev/null + done +fi +grep -Fq 'Java_org_libsdl_app_SDLActivity_*' "$repo_root/cmake/engine_api_elf.map" +[[ -s "$main/java/org/github/krkr2/aetherkiri/RenPyMobileBridge.java" ]] +[[ -s "$main/java/org/libsdl/app/SDLActivity.java" ]] +[[ -s "$main/java/org/renpy/android/PythonSDLActivity.java" ]] +grep -Fq 'renpy-sdl-host-shim-v1' "$main/java/org/libsdl/app/SDLActivity.java" +grep -Fq 'renpy-python-host-shim-v1' "$main/java/org/renpy/android/PythonSDLActivity.java" +for callback in \ + nativeSetupJNI nativeRunMain nativeSetScreenResolution onNativeResize \ + onNativeKeyDown onNativeKeyUp onNativeTouch nativePause nativeResume \ + nativeQuit getNativeSurface getContext; do + grep -Fq "$callback" "$main/java/org/libsdl/app/SDLActivity.java" +done +grep -Fq 'nativeSetEnv' "$main/java/org/renpy/android/PythonSDLActivity.java" +grep -Fx 'playable=unverified' "$tmp_root/android-build/renpy_assets/renpy_mobile/manifest.properties" +grep -Fx 'inspection_only=true' "$tmp_root/android-build/renpy_assets/renpy_mobile/manifest.properties" +grep -Fx 'native_lifecycle_validated=false' "$tmp_root/android-build/renpy_assets/renpy_mobile/manifest.properties" +grep -Fx 'manifest_merged=false' "$tmp_root/android-build/renpy_assets/renpy_mobile/manifest.properties" +grep -Fx 'java_host_shims=org/libsdl/app/SDLActivity.java,org/renpy/android/PythonSDLActivity.java' \ + "$tmp_root/android-build/renpy_assets/renpy_mobile/manifest.properties" +# The RAPT manifest is an asset only; no second Activity is added to the host +# source tree. +[[ ! -f "$main/AndroidManifest.xml" ]] +# The official PythonSDLActivity remains assets-only; the compiled source is +# the explicit host shim above. +grep -Fq 'AETHERKIRI_HOST_SHIM' "$main/java/org/renpy/android/PythonSDLActivity.java" +grep -Fq 'PythonSDLActivity' "$tmp_root/android-build/renpy_assets/renpy_mobile/rapt/java/org/renpy/android/PythonSDLActivity.java" + +# A Godot template that already owns either SDL class must fail closed rather +# than silently replacing its singleton with the RAPT shim. +mkdir -p "$conflict_root/android-build/src/main/java/org/libsdl/app" +printf '// Godot Gradle staging fixture\n' > "$conflict_root/android-build/build.gradle" +printf 'package org.libsdl.app; public final class SDLActivity {}\n' \ + > "$conflict_root/android-build/src/main/java/org/libsdl/app/SDLActivity.java" +if bash "$stage" --allow-unsupported --mobile-root "$mobile_root" \ + --godot-build "$conflict_root/android-build" \ + >"$conflict_root/stdout" 2>"$conflict_root/stderr"; then + echo "stager overwrote an unrelated SDLActivity shim" >&2 + exit 1 +fi +grep -Fq 'Refusing to overwrite unrelated Android Java class' "$conflict_root/stderr" + +# The same official RAPT archive and dummy private.mp3 must be rejected by the +# real app staging path, rather than producing a silently unsupported build. +if bash "$stage" --mobile-root "$mobile_root" \ + --godot-build "$tmp_root/android-build" --private-assets "$tmp_root/private" \ + >"$tmp_root/strict-stdout" 2>"$tmp_root/strict-stderr"; then + echo "enabled app stager accepted an official blocking archive and incomplete Python payload" >&2 + exit 1 +fi +grep -Fq "Ren'Py mobile packaging input rejected" "$tmp_root/strict-stderr" + +echo "Ren'Py Android archive staging and unsupported-runtime rejection ok (gameplay untested)" diff --git a/tools/validate_renpy_mobile_payload.py b/tools/validate_renpy_mobile_payload.py new file mode 100755 index 000000000..eb7413013 --- /dev/null +++ b/tools/validate_renpy_mobile_payload.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +"""Reject incomplete mobile packaging inputs; this does not test gameplay.""" + +import argparse +import ast +import importlib.util +import marshal +import os +from pathlib import Path +import re +import shlex +import shutil +import struct +import subprocess +import sys +import tempfile +import types +import zipfile + + +LIFECYCLE_SYMBOLS = ( + "renpy_mobile_bootstrap", "renpy_mobile_bind_window", "renpy_mobile_init", + "renpy_mobile_tick", "renpy_mobile_frame", "renpy_mobile_input", + "renpy_mobile_pause", "renpy_mobile_resume", "renpy_mobile_shutdown", + "renpy_mobile_text_input_state", "renpy_mobile_set_surface_size", +) + +ANDROID_ARCHITECTURES = { + "arm64": ("arm64-v8a", 183), + "arm64-v8a": ("arm64-v8a", 183), + "x86_64": ("x86_64", 62), +} + + +def fail(message): + raise ValueError(message) + + +def module_functions(root, relative): + source = root / (relative + ".py") + bytecode = root / (relative + ".pyc") + if source.is_file(): + return {node.name for node in ast.parse(source.read_bytes()).body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))} + if bytecode.is_file(): + data = bytecode.read_bytes() + if data[:4] != importlib.util.MAGIC_NUMBER: + fail(f"{bytecode}: validate with the matching Python 3.12 interpreter") + code = marshal.loads(data[16:]) + if not isinstance(code, types.CodeType): + fail(f"invalid Python bytecode: {bytecode}") + return {value.co_name for value in code.co_consts + if isinstance(value, types.CodeType)} + fail(f"missing Python module: {root / relative}.py or .pyc") + + +def validate_private(root, platform): + if not root.is_dir(): + fail(f"Ren'Py private payload must be an unpacked directory: {root}") + for entry in root.rglob("*"): + if entry.is_symlink(): + fail(f"APK/bundle private assets must not contain symlinks: {entry}") + entrypoints = ("main.py",) if platform == "android" else ("renpy.py", "main.py") + if not any((root / entry).is_file() for entry in entrypoints): + fail(f"Ren'Py private payload lacks entrypoint {entrypoints}: {root}") + for module in ("lib/python3.12/site", "lib/python3.12/encodings/__init__", + "renpy/__init__", "renpy/execution", "renpy/display/core"): + module_functions(root, module) + module_functions(root, "renpy/main") + functions = module_functions(root, "renpy/aether_mobile") + missing = {"cooperative_start", "cooperative_tick", "cooperative_stop"} - functions + if missing: + fail(f"unpatched Ren'Py Python payload: missing {', '.join(sorted(missing))} in {root}/renpy/aether_mobile") + + +def command_output(command): + result = subprocess.run(command, capture_output=True, text=True, check=False) + if result.returncode: + fail(f"cannot inspect mobile native payload ({' '.join(command)}): {result.stderr.strip()}") + return result.stdout + + +def validate_library(library, platform, arch, sdk, executable=False, packaged=False): + if not library.is_file(): + fail(f"Ren'Py lifecycle library is missing: {library}") + data = library.read_bytes() + if b"AETHERKIRI_RENPY_LIFECYCLE_STUB" in data: + fail(f"contract-only stub cannot be packaged as a Ren'Py runtime: {library}") + if platform == "android": + if arch not in ANDROID_ARCHITECTURES: + fail(f"unsupported Android runtime ABI: {arch}") + abi, expected_machine = ANDROID_ARCHITECTURES[arch] + if len(data) < 20 or data[:4] != b"\x7fELF" or data[4:6] != b"\x02\x01": + fail(f"Android Ren'Py payload must be a little-endian ELF64 shared library: {library}") + elf_type, machine = struct.unpack_from("> 12; ++ const u16vec4 s = u16vec4( ++ data_a[a_offset + ib].scales[0], ++ data_a[a_offset + ib].scales[1], ++ data_a[a_offset + ib].scales[2], ++ data_a[a_offset + ib].scales[3] ++ ) >> 12; + const float d = float(unpackHalf2x16(s.x | (s.y << 4) | (s.z << 8) | (s.w << 12)).x); + return vec2(d, 0); + } diff --git a/vcpkg/ports/ggml/fix-vk-32bit.diff b/vcpkg/ports/ggml/fix-vk-32bit.diff new file mode 100644 index 000000000..eeea52dad --- /dev/null +++ b/vcpkg/ports/ggml/fix-vk-32bit.diff @@ -0,0 +1,71 @@ +diff --git a/src/ggml-vulkan/ggml-vulkan.cpp b/src/ggml-vulkan/ggml-vulkan.cpp +index 0a793100..a754ce42 100644 +--- a/src/ggml-vulkan/ggml-vulkan.cpp ++++ b/src/ggml-vulkan/ggml-vulkan.cpp +@@ -2036,7 +2036,7 @@ void vk_memory_logger::log_allocation(vk_buffer_ref buf_ref, size_t size) { + allocations[buf->buffer] = size; + total_device += device ? size : 0; + total_host += device ? 0 : size; +- VK_LOG_MEMORY(buf->device->name << ": +" << format_size(size) << " " << type << " at " << buf->buffer << ". Total device: " << format_size(total_device) << ", total host: " << format_size(total_host)); ++ VK_LOG_MEMORY(buf->device->name << ": +" << format_size(size) << " " << type << " at " << static_cast(buf->buffer) << ". Total device: " << format_size(total_device) << ", total host: " << format_size(total_host)); + } + + void vk_memory_logger::log_deallocation(vk_buffer_ref buf_ref) { +@@ -2052,10 +2052,10 @@ void vk_memory_logger::log_deallocation(vk_buffer_ref buf_ref) { + total_device -= device ? it->second : 0; + total_host -= device ? 0 : it->second; + if (it != allocations.end()) { +- VK_LOG_MEMORY(buf->device->name << ": -" << format_size(it->second) << " " << type << " at " << buf->buffer << ". Total device: " << format_size(total_device) << ", total host: " << format_size(total_host)); ++ VK_LOG_MEMORY(buf->device->name << ": -" << format_size(it->second) << " " << type << " at " << static_cast(buf->buffer) << ". Total device: " << format_size(total_device) << ", total host: " << format_size(total_host)); + allocations.erase(it); + } else { +- VK_LOG_MEMORY("ERROR " << buf->device->name << ": Attempted to deallocate unknown " << type << " memory at " << buf->buffer); ++ VK_LOG_MEMORY("ERROR " << buf->device->name << ": Attempted to deallocate unknown " << type << " memory at " << static_cast(buf->buffer)); + } + } + +@@ -6951,7 +6951,7 @@ static bool ggml_vk_buffer_write_2d_async(vk_context subctx, vk_buffer& dst, siz + } + + ggml_vk_sync_buffers(nullptr, subctx); +- subctx->s->buffer->buf.copyBuffer((VkBuffer)staging_buffer->buffer, (VkBuffer)dst->buffer, slices); ++ subctx->s->buffer->buf.copyBuffer(staging_buffer->buffer, dst->buffer, slices); + + if (width == spitch) { + deferred_memcpy((uint8_t *)staging_buffer->ptr, src, staging_size, &subctx->in_memcpys); +@@ -7086,7 +7086,7 @@ static bool ggml_vk_buffer_read_async(vk_context subctx, vk_buffer& src, size_t + } + + static void ggml_vk_buffer_read_2d(vk_buffer& src, size_t offset, void * dst, size_t spitch, size_t dpitch, size_t width, size_t height) { +- VK_LOG_DEBUG("ggml_vk_buffer_read_2d(" << src->buffer << ", " << offset << ", " << width << ", " << height << ")"); ++ VK_LOG_DEBUG("ggml_vk_buffer_read_2d(" << static_cast(src->buffer) << ", " << offset << ", " << width << ", " << height << ")"); + + // If the device is not an UMA device the memory is host-accessible through rebar. While writing + // through PCIe is sufficient fast reading back data from PCIe is slower than going through +@@ -7118,7 +7118,7 @@ static void ggml_vk_buffer_read_2d(vk_buffer& src, size_t offset, void * dst, si + } + + static void ggml_vk_buffer_read(vk_buffer& src, size_t offset, void * dst, size_t size) { +- VK_LOG_DEBUG("ggml_vk_buffer_read(" << src->buffer << ", " << offset << ", " << size << ")"); ++ VK_LOG_DEBUG("ggml_vk_buffer_read(" << static_cast(src->buffer) << ", " << offset << ", " << size << ")"); + ggml_vk_buffer_read_2d(src, offset, dst, size, size, size, 1); + } + +@@ -7286,7 +7286,7 @@ static void ggml_vk_matmul( + uint32_t batch_stride_a, uint32_t batch_stride_b, uint32_t batch_stride_d, + uint32_t split_k, uint32_t batch, uint32_t ne02, uint32_t ne12, uint32_t broadcast2, uint32_t broadcast3, + uint32_t padded_n) { +- VK_LOG_DEBUG("ggml_vk_matmul(a: (" << a.buffer->buffer << ", " << a.offset << ", " << a.size << "), b: (" << b.buffer->buffer << ", " << b.offset << ", " << b.size << "), d: (" << d.buffer->buffer << ", " << d.offset << ", " << d.size << "), split_k: (" << (split_k_buffer.buffer != nullptr ? split_k_buffer.buffer->buffer : VK_NULL_HANDLE) << ", " << split_k_buffer.offset << ", " << split_k_buffer.size << "), m: " << m << ", n: " << n << ", k: " << k << ", stride_a: " << stride_a << ", stride_b: " << stride_b << ", stride_d: " << stride_d << ", batch_stride_a: " << batch_stride_a << ", batch_stride_b: " << batch_stride_b << ", batch_stride_d: " << batch_stride_d << ", split_k: " << split_k << ", batch: " << batch << ", ne02: " << ne02 << ", ne12: " << ne12 << ", broadcast2: " << broadcast2 << ", broadcast3: " << broadcast3 << ", padded_n: " << padded_n << ")"); ++ VK_LOG_DEBUG("ggml_vk_matmul(a: (" << static_cast(a.buffer->buffer) << ", " << a.offset << ", " << a.size << "), b: (" << static_cast(b.buffer->buffer) << ", " << b.offset << ", " << b.size << "), d: (" << static_cast(d.buffer->buffer) << ", " << d.offset << ", " << d.size << "), split_k: (" << (split_k_buffer.buffer != nullptr ? static_cast(split_k_buffer.buffer->buffer) : VkBuffer{}) << ", " << split_k_buffer.offset << ", " << split_k_buffer.size << "), m: " << m << ", n: " << n << ", k: " << k << ", stride_a: " << stride_a << ", stride_b: " << stride_b << ", stride_d: " << stride_d << ", batch_stride_a: " << batch_stride_a << ", batch_stride_b: " << batch_stride_b << ", batch_stride_d: " << batch_stride_d << ", split_k: " << split_k << ", batch: " << batch << ", ne02: " << ne02 << ", ne12: " << ne12 << ", broadcast2: " << broadcast2 << ", broadcast3: " << broadcast3 << ", padded_n: " << padded_n << ")"); + if (split_k == 1) { + ggml_pipeline_request_descriptor_sets(ctx, pipeline, CEIL_DIV(batch, ctx->device->properties.limits.maxComputeWorkGroupCount[2])); + +@@ -7366,7 +7366,7 @@ static void ggml_vk_matmul_id( + uint32_t batch_stride_a, uint32_t batch_stride_b, uint32_t batch_stride_d, + uint32_t n_as, uint32_t nei0, uint32_t nei1, uint32_t nbi1, uint32_t ne11, + uint32_t padded_n) { +- VK_LOG_DEBUG("ggml_vk_matmul_id(a: (" << a.buffer->buffer << ", " << a.offset << ", " << a.size << "), b: (" << b.buffer->buffer << ", " << b.offset << ", " << b.size << "), d: (" << d.buffer->buffer << ", " << d.offset << ", " << d.size << "), ids: (" << ids.buffer->buffer << ", " << ids.offset << ", " << ids.size << "), expert_count: (" << expert_count_buf.buffer->buffer << ", " << expert_count_buf.offset << ", " << expert_count_buf.size << "), " << ++ VK_LOG_DEBUG("ggml_vk_matmul_id(a: (" << static_cast(a.buffer->buffer) << ", " << a.offset << ", " << a.size << "), b: (" << static_cast(b.buffer->buffer) << ", " << b.offset << ", " << b.size << "), d: (" << static_cast(d.buffer->buffer) << ", " << d.offset << ", " << d.size << "), ids: (" << static_cast(ids.buffer->buffer) << ", " << ids.offset << ", " << ids.size << "), expert_count: (" << static_cast(expert_count_buf.buffer->buffer) << ", " << expert_count_buf.offset << ", " << expert_count_buf.size << "), " << + "m: " << m << ", n: " << n << ", k: " << k << ", stride_a: " << stride_a << ", stride_b: " << stride_b << ", stride_d: " << stride_d << ", " << + "batch_stride_a: " << batch_stride_a << ", batch_stride_b: " << batch_stride_b << ", batch_stride_d: " << batch_stride_d << ", " << + "n_as: " << n_as << ", nei0: " << nei0 << ", nei1: " << nei1 << ", nbi1: " << nbi1 << ", ne11: " << ne11 << ")"); diff --git a/vcpkg/ports/ggml/pkgconfig.diff b/vcpkg/ports/ggml/pkgconfig.diff new file mode 100644 index 000000000..8c9bd5df8 --- /dev/null +++ b/vcpkg/ports/ggml/pkgconfig.diff @@ -0,0 +1,216 @@ +diff --git a/CMakeLists.txt b/CMakeLists.txt +index 672b37df..c6f16c7c 100644 +--- a/CMakeLists.txt ++++ b/CMakeLists.txt +@@ -352,7 +352,7 @@ if (GGML_STANDALONE) + @ONLY) + + install(FILES ${CMAKE_CURRENT_BINARY_DIR}/ggml.pc +- DESTINATION share/pkgconfig) ++ DESTINATION ${CMAKE_INSTALL_LIBDIR}/pkgconfig) + endif() + + # +@@ -373,6 +373,7 @@ set(variable_set_statements + set(GGML_SHARED_LIB ${BUILD_SHARED_LIBS}) + + get_cmake_property(all_variables VARIABLES) ++list(FILTER all_variables EXCLUDE REGEX "^GGML_PKGCONFIG") + foreach(variable_name IN LISTS all_variables) + if(variable_name MATCHES "^GGML_") + string(REPLACE ";" "\\;" +diff --git a/ggml.pc.in b/ggml.pc.in +index 3e0291e0..a7627339 100644 +--- a/ggml.pc.in ++++ b/ggml.pc.in +@@ -6,5 +6,7 @@ libdir=${prefix}/@CMAKE_INSTALL_LIBDIR@ + Name: ggml + Description: The GGML Tensor Library for Machine Learning + Version: @GGML_VERSION@ +-Cflags: -I${includedir} +-Libs: -L${libdir} -lggml ++Cflags: -I${includedir} @GGML_PKGCONFIG_CFLAGS@ ++Libs: -L${libdir} -lggml @GGML_PKGCONFIG_LIBS_BACKEND@ -lggml-base ++Libs.private: @GGML_PKGCONFIG_LIBS_PRIVATE@ ++Requires.private: @GGML_PKGCONFIG_REQUIRES_PRIVATE@ +diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt +index 3e48860b..1ec27e83 100644 +--- a/src/CMakeLists.txt ++++ b/src/CMakeLists.txt +@@ -185,6 +185,10 @@ endif() + + # ggml + ++set(GGML_PKGCONFIG_CFLAGS "") ++set(GGML_PKGCONFIG_LIBS_BACKEND "") ++set(GGML_PKGCONFIG_LIBS_PRIVATE "") ++ + if (GGML_BACKEND_DL AND NOT BUILD_SHARED_LIBS) + message(FATAL_ERROR "GGML_BACKEND_DL requires BUILD_SHARED_LIBS") + endif() +@@ -243,6 +247,7 @@ target_link_libraries(ggml PUBLIC ggml-base) + + if (CMAKE_SYSTEM_NAME MATCHES "Linux") + target_link_libraries(ggml PRIVATE dl) ++ string(APPEND GGML_PKGCONFIG_LIBS_PRIVATE " -ldl") + endif() + + function(ggml_add_backend_library backend) +@@ -296,12 +301,20 @@ function(ggml_add_backend backend) + string(TOUPPER "GGML_${backend}" backend_id) + if (${backend_id}) + string(TOLOWER "ggml-${backend}" backend_target) ++ if (NOT GGML_BACKEND_DL) ++ # Mirrors ggml_add_backend_library but avoids cmake scoping ++ set(GGML_PKGCONFIG_LIBS_BACKEND "${GGML_PKGCONFIG_LIBS_BACKEND} -l${backend_target}") ++ endif() + add_subdirectory(${backend_target}) + message(STATUS "Including ${backend} backend") + if (NOT GGML_BACKEND_DL) + string(TOUPPER "GGML_USE_${backend}" backend_use) + target_compile_definitions(ggml PUBLIC ${backend_use}) ++ set(GGML_PKGCONFIG_CFLAGS "${GGML_PKGCONFIG_CFLAGS} -D${backend_use}" PARENT_SCOPE) + endif() ++ set(GGML_PKGCONFIG_LIBS_BACKEND "${GGML_PKGCONFIG_LIBS_BACKEND}" PARENT_SCOPE) ++ set(GGML_PKGCONFIG_LIBS_PRIVATE "${GGML_PKGCONFIG_LIBS_PRIVATE}" PARENT_SCOPE) ++ set(GGML_PKGCONFIG_REQUIRES_PRIVATE "${GGML_PKGCONFIG_REQUIRES_PRIVATE}" PARENT_SCOPE) + endif() + endfunction() + +@@ -474,10 +487,14 @@ if (DEFINED MATH_LIBRARY) + target_link_libraries(ggml-base PRIVATE ${MATH_LIBRARY}) + elseif (NOT WIN32 AND NOT DEFINED ENV{ONEAPI_ROOT}) + target_link_libraries(ggml-base PRIVATE m) ++ string(APPEND GGML_PKGCONFIG_LIBS_PRIVATE " -lm") + endif() + + if (CMAKE_SYSTEM_NAME MATCHES "Android") + target_link_libraries(ggml-base PRIVATE dl) ++ if(NOT GGML_PKGCONFIG_LIBS_PRIVATE MATCHES " -ldl") ++ string(APPEND GGML_PKGCONFIG_LIBS_PRIVATE " -ldl") ++ endif() + endif() + + if(CMAKE_SYSTEM_NAME MATCHES "visionOS") +@@ -489,5 +506,11 @@ if (BUILD_SHARED_LIBS) + set_target_properties(${target} PROPERTIES POSITION_INDEPENDENT_CODE ON) + target_compile_definitions(${target} PRIVATE GGML_BUILD) + target_compile_definitions(${target} PUBLIC GGML_SHARED) ++ string(APPEND GGML_PKGCONFIG_CFLAGS " -DGGML_SHARED -DGGML_BACKEND_SHARED") + endforeach() + endif() ++ ++set(GGML_PKGCONFIG_CFLAGS "${GGML_PKGCONFIG_CFLAGS}" PARENT_SCOPE) ++set(GGML_PKGCONFIG_LIBS_BACKEND "${GGML_PKGCONFIG_LIBS_BACKEND}" PARENT_SCOPE) ++set(GGML_PKGCONFIG_LIBS_PRIVATE "${GGML_PKGCONFIG_LIBS_PRIVATE}" PARENT_SCOPE) ++set(GGML_PKGCONFIG_REQUIRES_PRIVATE "${GGML_PKGCONFIG_REQUIRES_PRIVATE}" PARENT_SCOPE) +diff --git a/src/ggml-blas/CMakeLists.txt b/src/ggml-blas/CMakeLists.txt +index c27dc174..ce1ab21d 100644 +--- a/src/ggml-blas/CMakeLists.txt ++++ b/src/ggml-blas/CMakeLists.txt +@@ -93,6 +93,7 @@ if (BLAS_FOUND) + endif() + + target_link_libraries (ggml-blas PRIVATE ${BLAS_LIBRARIES}) ++ set(GGML_PKGCONFIG_REQUIRES_PRIVATE "${GGML_PKGCONFIG_REQUIRES_PRIVATE} cblas" PARENT_SCOPE) + target_include_directories(ggml-blas SYSTEM PRIVATE ${BLAS_INCLUDE_DIRS}) + else() + message(FATAL_ERROR "BLAS not found, please refer to " +diff --git a/src/ggml-cpu/CMakeLists.txt b/src/ggml-cpu/CMakeLists.txt +index 869c7b23..53a90f73 100644 +--- a/src/ggml-cpu/CMakeLists.txt ++++ b/src/ggml-cpu/CMakeLists.txt +@@ -57,6 +57,9 @@ function(ggml_add_cpu_backend_variant_impl tag_name) + target_compile_features(${GGML_CPU_NAME} PRIVATE c_std_11 cxx_std_17) + target_include_directories(${GGML_CPU_NAME} PRIVATE . ggml-cpu) + ++ set(libs_private "") ++ set(pkgconfig_cflags "") ++ + if (APPLE AND GGML_ACCELERATE) + find_library(ACCELERATE_FRAMEWORK Accelerate) + if (ACCELERATE_FRAMEWORK) +@@ -67,6 +70,7 @@ function(ggml_add_cpu_backend_variant_impl tag_name) + target_compile_definitions(${GGML_CPU_NAME} PRIVATE ACCELERATE_LAPACK_ILP64) + + target_link_libraries(${GGML_CPU_NAME} PRIVATE ${ACCELERATE_FRAMEWORK}) ++ string(APPEND libs_private " -framework Accelerate") + else() + message(WARNING "Accelerate framework not found") + endif() +@@ -79,6 +83,18 @@ function(ggml_add_cpu_backend_variant_impl tag_name) + target_compile_definitions(${GGML_CPU_NAME} PRIVATE GGML_USE_OPENMP) + + target_link_libraries(${GGML_CPU_NAME} PRIVATE OpenMP::OpenMP_C OpenMP::OpenMP_CXX) ++ set(items "") ++ foreach(lib IN LISTS OpenMP_CXX_LIB_NAMES OpenMP_C_LIB_NAMES) ++ list(REMOVE_ITEM items " -l${lib}") ++ list(APPEND items " -l${lib}") ++ endforeach() ++ string(APPEND libs_private ${items}) ++ set(items "") ++ foreach(flag IN LISTS OpenMP_CXX_FLAGS OpenMP_C_FLAGS) ++ list(REMOVE_ITEM items " ${flag}") ++ list(APPEND items " ${flag}") ++ endforeach() ++ string(APPEND pkgconfig_cflags ${items}) + else() + set(GGML_OPENMP_ENABLED "OFF" CACHE INTERNAL "") + message(WARNING "OpenMP not found") +@@ -101,8 +117,12 @@ function(ggml_add_cpu_backend_variant_impl tag_name) + target_compile_definitions(${GGML_CPU_NAME} PRIVATE GGML_USE_CPU_HBM) + + target_link_libraries(${GGML_CPU_NAME} PUBLIC memkind) ++ string(APPEND libs_private " -lmemkind") + endif() + ++ set(GGML_PKGCONFIG_CFLAGS "${GGML_PKGCONFIG_CFLAGS}${pkgconfig_cflags}" PARENT_SCOPE) ++ set(GGML_PKGCONFIG_LIBS_PRIVATE "${GGML_PKGCONFIG_LIBS_PRIVATE}${libs_private}" PARENT_SCOPE) ++ + if (GGML_SYSTEM_ARCH STREQUAL "ARM") + message(STATUS "ARM detected") + list(APPEND GGML_CPU_SOURCES +diff --git a/src/ggml-metal/CMakeLists.txt b/src/ggml-metal/CMakeLists.txt +index 42054d84..d780361e 100644 +--- a/src/ggml-metal/CMakeLists.txt ++++ b/src/ggml-metal/CMakeLists.txt +@@ -19,6 +19,11 @@ target_link_libraries(ggml-metal PRIVATE + ${METALKIT_FRAMEWORK} + ) + ++set(GGML_PKGCONFIG_LIBS_PRIVATE ++ "${GGML_PKGCONFIG_LIBS_PRIVATE} -framework Foundation -framework Metal -framework MetalKit" ++ PARENT_SCOPE ++) ++ + if (GGML_METAL_NDEBUG) + add_compile_definitions(GGML_METAL_NDEBUG) + endif() +diff --git a/src/ggml-opencl/CMakeLists.txt b/src/ggml-opencl/CMakeLists.txt +index ffde6a4f..9a7f9526 100644 +--- a/src/ggml-opencl/CMakeLists.txt ++++ b/src/ggml-opencl/CMakeLists.txt +@@ -7,6 +7,7 @@ ggml_add_backend_library(${TARGET_NAME} + ggml-opencl.cpp + ../../include/ggml-opencl.h) + target_link_libraries(${TARGET_NAME} PRIVATE ${OpenCL_LIBRARIES}) ++set(GGML_PKGCONFIG_REQUIRES_PRIVATE "${GGML_PKGCONFIG_REQUIRES_PRIVATE} OpenCL" PARENT_SCOPE) + target_include_directories(${TARGET_NAME} PRIVATE ${OpenCL_INCLUDE_DIRS}) + + if (GGML_OPENCL_PROFILING) +diff --git a/src/ggml-vulkan/CMakeLists.txt b/src/ggml-vulkan/CMakeLists.txt +index 715a263a..04c142c2 100644 +--- a/src/ggml-vulkan/CMakeLists.txt ++++ b/src/ggml-vulkan/CMakeLists.txt +@@ -87,6 +87,11 @@ if (Vulkan_FOUND) + ) + + target_link_libraries(ggml-vulkan PRIVATE Vulkan::Vulkan) ++ if(ANDROID) ++ set(GGML_PKGCONFIG_LIBS_PRIVATE "${GGML_PKGCONFIG_LIBS_PRIVATE} -lvulkan" PARENT_SCOPE) ++ else() ++ set(GGML_PKGCONFIG_REQUIRES_PRIVATE "${GGML_PKGCONFIG_REQUIRES_PRIVATE} vulkan" PARENT_SCOPE) ++ endif() + target_include_directories(ggml-vulkan PRIVATE ${CMAKE_CURRENT_BINARY_DIR}) + + # Workaround to the "can't dereference invalidated vector iterator" bug in clang-cl debug build diff --git a/vcpkg/ports/ggml/portfile.cmake b/vcpkg/ports/ggml/portfile.cmake index 3bdacdc0d..cb200a737 100644 --- a/vcpkg/ports/ggml/portfile.cmake +++ b/vcpkg/ports/ggml/portfile.cmake @@ -1,3 +1,9 @@ +# Official ggml 0.11.1 recipe and patches are pinned together to microsoft/vcpkg +# commit 9e593bb18ea69cc5095e012465dcd675a822ed0d (this repository's registry baseline): +# https://github.com/microsoft/vcpkg/tree/9e593bb18ea69cc5095e012465dcd675a822ed0d/ports/ggml +# Including VCPKG_ROOT/ports/ggml would mix our fixed VERSION with a newer +# upstream recipe and its different source hash/patches. Keep SHA512 validation. + # ggml enables -march=native unless it detects a reproducible/cross build. # When producing x86_64 on an Apple Silicon host, clang expands that flag to # apple-m1 and rejects it for the x86 target. Scope the reproducible-build hint @@ -9,10 +15,106 @@ if(DEFINED ENV{SOURCE_DATE_EPOCH}) endif() set(ENV{SOURCE_DATE_EPOCH} "1") -set(_aether_overlay_port_dir "${CURRENT_PORT_DIR}") -set(CURRENT_PORT_DIR "${VCPKG_ROOT_DIR}/ports/ggml") -include("${VCPKG_ROOT_DIR}/ports/ggml/portfile.cmake") -set(CURRENT_PORT_DIR "${_aether_overlay_port_dir}") +vcpkg_from_github( + OUT_SOURCE_PATH SOURCE_PATH + REPO ggml-org/ggml + REF v${VERSION} + SHA512 9559545fea9606a5d9cd87fdd7ef1352ade4695305dfe27327abdc6cf598a8f159875f05992864cfd55405589b58ea3598181b64b3600bd5aa287a320c14d0b3 + HEAD_REF master + PATCHES + cmake-config.diff + pkgconfig.diff + relax-link-options.diff + vulkan-shaders-gen.diff + fix-dequant_funcs.diff + fix-vk-32bit.diff +) + +vcpkg_check_features(OUT_FEATURE_OPTIONS FEATURE_OPTIONS + FEATURES + blas GGML_BLAS + cuda GGML_CUDA + metal GGML_METAL + opencl GGML_OPENCL + openmp GGML_OPENMP + vulkan GGML_VULKAN +) + +if("blas" IN_LIST FEATURES) + vcpkg_find_acquire_program(PKGCONFIG) + list(APPEND FEATURE_OPTIONS + "-DCMAKE_REQUIRE_FIND_PACKAGE_BLAS=ON" # workaround message(ERROR ...) + "-DPKG_CONFIG_EXECUTABLE=${PKGCONFIG}" + ) +endif() + +if("cuda" IN_LIST FEATURES) + vcpkg_find_cuda(OUT_CUDA_TOOLKIT_ROOT cuda_toolkit_root) + list(APPEND FEATURE_OPTIONS + "-DCMAKE_CUDA_COMPILER=${NVCC}" + "-DCUDAToolkit_ROOT=${cuda_toolkit_root}" + ) +endif() + +if("opencl" IN_LIST FEATURES) + vcpkg_find_acquire_program(PYTHON3) + list(APPEND FEATURE_OPTIONS + "-DPython3_EXECUTABLE=${PYTHON3}" + ) +endif() + +if(VCPKG_TARGET_IS_WINDOWS AND NOT VCPKG_TARGET_IS_MINGW AND VCPKG_TARGET_ARCHITECTURE STREQUAL "arm64") + message(STATUS "The CPU backend is not supported for arm64 with MSVC.") + list(APPEND FEATURE_OPTIONS + "-DGGML_CPU=OFF" + ) + if(FEATURES STREQUAL "core") + message(WARNING "No backend enabled!") + endif() +endif() + +if("vulkan" IN_LIST FEATURES AND VCPKG_CROSSCOMPILING) + list(APPEND FEATURE_OPTIONS + "-DVulkan_GLSLC_EXECUTABLE=${CURRENT_HOST_INSTALLED_DIR}/tools/shaderc/glslc${VCPKG_HOST_EXECUTABLE_SUFFIX}" + "-DVULKAN_SHADERS_GEN_EXECUTABLE=${CURRENT_HOST_INSTALLED_DIR}/tools/${PORT}/vulkan-shaders-gen${VCPKG_HOST_EXECUTABLE_SUFFIX}" + ) +endif() + +string(COMPARE EQUAL "${VCPKG_LIBRARY_LINKAGE}" "static" GGML_STATIC) + +vcpkg_cmake_configure( + SOURCE_PATH "${SOURCE_PATH}" + OPTIONS + -DGGML_STATIC=${GGML_STATIC} + -DGGML_CCACHE=OFF + -DGGML_BUILD_NUMBER=1 + -DGGML_BUILD_TESTS=OFF + -DGGML_BUILD_EXAMPLES=OFF + -DGGML_HIP=OFF + -DGGML_SYCL=OFF + ${FEATURE_OPTIONS} + MAYBE_UNUSED_VARIABLES + PKG_CONFIG_EXECUTABLE +) + +vcpkg_cmake_install() +vcpkg_copy_pdbs() +vcpkg_cmake_config_fixup(PACKAGE_NAME ggml CONFIG_PATH "lib/cmake/ggml") +vcpkg_fixup_pkgconfig() + +if(VCPKG_LIBRARY_LINKAGE STREQUAL "dynamic") + vcpkg_replace_string("${CURRENT_PACKAGES_DIR}/include/ggml.h" "#ifdef GGML_SHARED" "#if 1") + vcpkg_replace_string("${CURRENT_PACKAGES_DIR}/include/ggml-backend.h" "#ifdef GGML_BACKEND_SHARED" "#if 1") +endif() + +if("vulkan" IN_LIST FEATURES AND NOT VCPKG_CROSSCOMPILING) + vcpkg_copy_tools(TOOL_NAMES vulkan-shaders-gen AUTO_CLEAN) +endif() + +file(REMOVE_RECURSE "${CURRENT_PACKAGES_DIR}/debug/include") +file(REMOVE_RECURSE "${CURRENT_PACKAGES_DIR}/debug/share") + +vcpkg_install_copyright(FILE_LIST "${SOURCE_PATH}/LICENSE") if(_aether_had_source_date_epoch) set(ENV{SOURCE_DATE_EPOCH} "${_aether_source_date_epoch}") diff --git a/vcpkg/ports/ggml/relax-link-options.diff b/vcpkg/ports/ggml/relax-link-options.diff new file mode 100644 index 000000000..39970685d --- /dev/null +++ b/vcpkg/ports/ggml/relax-link-options.diff @@ -0,0 +1,14 @@ +diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt +index 1ec27e83..3cef2687 100644 +--- a/src/CMakeLists.txt ++++ b/src/CMakeLists.txt +@@ -117,9 +117,7 @@ if (NOT MSVC) + if (UNIX AND NOT APPLE) + set(CMAKE_FIND_LIBRARY_SUFFIXES ".a;.so") + endif() +- add_link_options(-static) + if (MINGW) +- add_link_options(-static-libgcc -static-libstdc++) + endif() + endif() + if (GGML_GPROF) diff --git a/vcpkg/ports/ggml/vcpkg.json b/vcpkg/ports/ggml/vcpkg.json index bdd220a69..bd0f0fdee 100644 --- a/vcpkg/ports/ggml/vcpkg.json +++ b/vcpkg/ports/ggml/vcpkg.json @@ -1,6 +1,7 @@ { "name": "ggml", "version": "0.11.1", + "port-version": 1, "description": "Tensor library for machine learning", "homepage": "https://github.com/ggml-org/ggml", "license": "MIT", diff --git a/vcpkg/ports/ggml/vulkan-shaders-gen.diff b/vcpkg/ports/ggml/vulkan-shaders-gen.diff new file mode 100644 index 000000000..6a8fb247f --- /dev/null +++ b/vcpkg/ports/ggml/vulkan-shaders-gen.diff @@ -0,0 +1,26 @@ +diff --git a/src/ggml-vulkan/CMakeLists.txt b/src/ggml-vulkan/CMakeLists.txt +index 04c142c2..40a2791f 100644 +--- a/src/ggml-vulkan/CMakeLists.txt ++++ b/src/ggml-vulkan/CMakeLists.txt +@@ -125,6 +125,12 @@ if (Vulkan_FOUND) + add_compile_definitions(GGML_VULKAN_RUN_TESTS) + endif() + ++ if(DEFINED VULKAN_SHADERS_GEN_EXECUTABLE) ++ add_executable(vulkan-shaders-gen IMPORTED) ++ set_target_properties(vulkan-shaders-gen PROPERTIES IMPORTED_LOCATION "${VULKAN_SHADERS_GEN_EXECUTABLE}") ++ elseif(1) ++ add_subdirectory(vulkan-shaders) ++ elseif(0) + # Set up toolchain for host compilation whether cross-compiling or not + if (CMAKE_CROSSCOMPILING) + if (GGML_VULKAN_SHADERS_GEN_TOOLCHAIN) +@@ -174,6 +180,8 @@ if (Vulkan_FOUND) + set (_ggml_vk_host_suffix $,.exe,>) + set (_ggml_vk_genshaders_dir "${CMAKE_BINARY_DIR}/$") + set (_ggml_vk_genshaders_cmd "${_ggml_vk_genshaders_dir}/vulkan-shaders-gen${_ggml_vk_host_suffix}") ++ endif() ++ set (_ggml_vk_genshaders_cmd "vulkan-shaders-gen") + set (_ggml_vk_header "${CMAKE_CURRENT_BINARY_DIR}/ggml-vulkan-shaders.hpp") + set (_ggml_vk_input_dir "${CMAKE_CURRENT_SOURCE_DIR}/vulkan-shaders") + set (_ggml_vk_output_dir "${CMAKE_CURRENT_BINARY_DIR}/vulkan-shaders.spv") diff --git a/vcpkg/ports/glib/ios-pipe2-prototype.patch b/vcpkg/ports/glib/ios-pipe2-prototype.patch new file mode 100644 index 000000000..c072cc1e9 --- /dev/null +++ b/vcpkg/ports/glib/ios-pipe2-prototype.patch @@ -0,0 +1,14 @@ +--- a/meson.build ++++ b/meson.build +@@ -816,7 +816,10 @@ + foreach f : functions +- if cc.has_function(f) ++ # iOS SDKs may export functions without declaring a supported public API. ++ # Require pipe2's real prototype before enabling its optional fast path. ++ function_prefix = f == 'pipe2' ? '#include ' : '' ++ if cc.has_function(f, prefix : function_prefix) + define = 'HAVE_' + f.underscorify().to_upper() + glib_conf.set(define, 1) + glib_conf_prefix = glib_conf_prefix + '#define @0@ 1\n'.format(define) + set_variable('have_func_' + f, true) + else diff --git a/vcpkg/ports/glib/portfile.cmake b/vcpkg/ports/glib/portfile.cmake index da614ace6..31444cd67 100644 --- a/vcpkg/ports/glib/portfile.cmake +++ b/vcpkg/ports/glib/portfile.cmake @@ -6,11 +6,17 @@ vcpkg_download_distfile(GLIB_ARCHIVE SHA512 430928d7d7a442fc3927ca943f2569035fe8768768a0ebc6720ae1ef152b56fc5f8d4215d21b4828cc2f39a8632c907ed2c52a0c8566da1c533a2e049a1a121f ) +set(GLIB_PATCHES + use-libiconv-on-windows.patch + libintl.patch +) +if(VCPKG_TARGET_IS_IOS) + list(APPEND GLIB_PATCHES ios-pipe2-prototype.patch) +endif() + vcpkg_extract_source_archive(SOURCE_PATH ARCHIVE "${GLIB_ARCHIVE}" - PATCHES - use-libiconv-on-windows.patch - libintl.patch + PATCHES ${GLIB_PATCHES} ) if(VCPKG_HOST_IS_WINDOWS) diff --git a/vcpkg/ports/libgdiplus/CMakeLists.txt b/vcpkg/ports/libgdiplus/CMakeLists.txt index ebcb58895..c51bea6cb 100644 --- a/vcpkg/ports/libgdiplus/CMakeLists.txt +++ b/vcpkg/ports/libgdiplus/CMakeLists.txt @@ -213,10 +213,12 @@ target_link_libraries(${PROJECT_NAME} PUBLIC ) target_link_directories(${PROJECT_NAME} PUBLIC - ${GLIB_LIBRARY_DIRS} - ${CAIRO_LIBRARY_DIRS} - ${FONTCONFIG_LIBRARY_DIRS} - ${FREETYPE_LIBRARY_DIRS} + "$" + # pkg-config exports bare library names, including Debug postfixes. The + # installed target must search the consumer's Debug directory first; + # vcpkg's config fixup merges link libraries, but not link directories. + "$:$/debug/lib>>" + "$/lib>" ) target_include_directories(${PROJECT_NAME} diff --git a/vcpkg/ports/libgdiplus/portfile.cmake b/vcpkg/ports/libgdiplus/portfile.cmake index 117540a84..27de13e9d 100644 --- a/vcpkg/ports/libgdiplus/portfile.cmake +++ b/vcpkg/ports/libgdiplus/portfile.cmake @@ -1,5 +1,3 @@ -set(VCPKG_POLICY_SKIP_ABSOLUTE_PATHS_CHECK enabled) - vcpkg_from_github( OUT_SOURCE_PATH SOURCE_PATH REPO mono/libgdiplus @@ -29,6 +27,10 @@ endif() vcpkg_cmake_configure(SOURCE_PATH "${SOURCE_PATH}" OPTIONS ${OPTIONS}) vcpkg_cmake_install() +# Public dependency include/link directories originate in the build triplet. +# Rewrite them relative to the installed package and merge Debug/Release +# targets before caching; a package must survive a new consumer build path. +vcpkg_cmake_config_fixup() vcpkg_fixup_pkgconfig() vcpkg_copy_pdbs() diff --git a/vcpkg/ports/libgdiplus/vcpkg.json b/vcpkg/ports/libgdiplus/vcpkg.json index 7f55fde34..08f863b30 100644 --- a/vcpkg/ports/libgdiplus/vcpkg.json +++ b/vcpkg/ports/libgdiplus/vcpkg.json @@ -1,7 +1,7 @@ { "name": "libgdiplus", "version-string": "5.6.1", - "port-version": 10, + "port-version": 12, "description": "Libgdiplus is the Mono library that provides a GDI+-compatible API on non-Windows operating systems. Our implementation uses Cairo to do most of the heavy lifting.", "homepage": "https://www.mono-project.com/docs/gui/libgdiplus/", "license": null, diff --git a/vcpkg/ports/libiconv/0002-Config-for-MSVC.patch b/vcpkg/ports/libiconv/0002-Config-for-MSVC.patch new file mode 100644 index 000000000..0b40e366b --- /dev/null +++ b/vcpkg/ports/libiconv/0002-Config-for-MSVC.patch @@ -0,0 +1,14 @@ +diff --git a/srclib/fcntl.c b/srclib/fcntl.c +index f9753c4..02fc8be 100644 +--- a/srclib/fcntl.c ++++ b/srclib/fcntl.c +@@ -37,6 +37,9 @@ + /* Get declarations of the native Windows API functions. */ + # define WIN32_LEAN_AND_MEAN + # include ++#if defined(WINAPI_FAMILY) && (WINAPI_FAMILY == WINAPI_FAMILY_APP) ++#define HANDLE_FLAG_INHERIT 0 ++#endif + + /* Get _get_osfhandle. */ + # if GNULIB_MSVC_NOTHROW diff --git a/vcpkg/ports/libiconv/0003-Add-export.patch b/vcpkg/ports/libiconv/0003-Add-export.patch new file mode 100644 index 000000000..c8d131d2e --- /dev/null +++ b/vcpkg/ports/libiconv/0003-Add-export.patch @@ -0,0 +1,12 @@ +diff -uNr libiconv-1.16/libcharset/include/libcharset.h.in libiconv-1.16-work/libcharset/include/libcharset.h.in +--- libiconv-1.16/libcharset/include/libcharset.h.in 2018-09-17 19:07:42.000000000 +0300 ++++ libiconv-1.16-work/libcharset/include/libcharset.h.in 2019-12-04 17:15:50.000000000 +0300 +@@ -33,7 +33,7 @@ + by the corresponding pathname with the current prefix instead. Both + prefixes should be directory names without trailing slash (i.e. use "" + instead of "/"). */ +-extern void libcharset_set_relocation_prefix (const char *orig_prefix, ++extern void LIBCHARSET_DLL_EXPORTED libcharset_set_relocation_prefix (const char *orig_prefix, + const char *curr_prefix); + + diff --git a/vcpkg/ports/libiconv/0004-ModuleFileName.patch b/vcpkg/ports/libiconv/0004-ModuleFileName.patch new file mode 100644 index 000000000..df409c88e --- /dev/null +++ b/vcpkg/ports/libiconv/0004-ModuleFileName.patch @@ -0,0 +1,13 @@ +diff --git a/srclib/relocatable.c b/srclib/relocatable.c +index 365c085..4e08fdc 100644 +--- a/srclib/relocatable.c ++++ b/srclib/relocatable.c +@@ -338,7 +338,7 @@ DllMain (HINSTANCE module_handle, DWORD event, LPVOID reserved) + /* The DLL is being loaded into an application's address range. */ + static char location[MAX_PATH]; + +- if (!GetModuleFileName (module_handle, location, sizeof (location))) ++ if (!GetModuleFileNameA (module_handle, location, sizeof (location))) + /* Shouldn't happen. */ + return FALSE; + diff --git a/vcpkg/ports/libiconv/ios-reloc-memeq.patch b/vcpkg/ports/libiconv/ios-reloc-memeq.patch new file mode 100644 index 000000000..d6e65e320 --- /dev/null +++ b/vcpkg/ports/libiconv/ios-reloc-memeq.patch @@ -0,0 +1,18 @@ +--- a/build-aux/install-reloc ++++ b/build-aux/install-reloc +@@ -282,6 +282,7 @@ + "$srcdir"/relocwrapper.c \ + "$srcdir"/progname.c \ + "$srcdir"/progreloc.c \ ++ "$srcdir"/memeq.c \ + "$srcdir"/areadlink.c \ + "$srcdir"/careadlinkat.c \ + "$srcdir"/allocator.c \ +@@ -308,6 +309,7 @@ + rm -f relocwrapper.o \ + progname.o \ + progreloc.o \ ++ memeq.o \ + areadlink.o \ + careadlinkat.o \ + allocator.o \ diff --git a/vcpkg/ports/libiconv/portfile.cmake b/vcpkg/ports/libiconv/portfile.cmake new file mode 100644 index 000000000..ccf30dc3b --- /dev/null +++ b/vcpkg/ports/libiconv/portfile.cmake @@ -0,0 +1,79 @@ +if(NOT DEFINED X_VCPKG_BUILD_GNU_LIBICONV) + set(X_VCPKG_BUILD_GNU_LIBICONV 0) + if(VCPKG_TARGET_IS_WINDOWS OR VCPKG_TARGET_IS_IOS OR VCPKG_TARGET_IS_BSD) + set(X_VCPKG_BUILD_GNU_LIBICONV 1) + elseif(VCPKG_TARGET_IS_ANDROID) + vcpkg_cmake_get_vars(cmake_vars_file) + include("${cmake_vars_file}") + if(VCPKG_DETECTED_CMAKE_SYSTEM_VERSION VERSION_LESS "28") + set(X_VCPKG_BUILD_GNU_LIBICONV 1) + endif() + endif() +endif() + +if(NOT X_VCPKG_BUILD_GNU_LIBICONV) + message(STATUS "Not building GNU libiconv.") + set(VCPKG_POLICY_EMPTY_PACKAGE enabled) + file(MAKE_DIRECTORY "${CURRENT_PACKAGES_DIR}/share/iconv") + file(COPY "${CMAKE_CURRENT_LIST_DIR}/vcpkg-cmake-wrapper.cmake" DESTINATION "${CURRENT_PACKAGES_DIR}/share/iconv") + return() +endif() + +vcpkg_download_distfile(ARCHIVE + URLS "https://ftpmirror.gnu.org/gnu/libiconv/libiconv-${VERSION}.tar.gz" + "https://ftp.gnu.org/gnu/libiconv/libiconv-${VERSION}.tar.gz" + "https://www.mirrorservice.org/sites/ftp.gnu.org/gnu/libiconv/libiconv-${VERSION}.tar.gz" + FILENAME "libiconv-${VERSION}.tar.gz" + SHA512 1e8150f9bca907579330cd9c44ebbee46a260271fbe8f50d5ee24a39ef29c8d254505e85c3409324f7440596da711a8bd49e89f848a6be0cb3238a58c24aaecd +) +# Keep the builtin 1.19 recipe, with only the iOS standalone wrapper fix. +vcpkg_list(SET LIBICONV_PATCHES + 0002-Config-for-MSVC.patch + 0003-Add-export.patch + 0004-ModuleFileName.patch +) +if(VCPKG_TARGET_IS_IOS) + # install-reloc links its own sources instead of srclib/libicrt.a. + vcpkg_list(APPEND LIBICONV_PATCHES ios-reloc-memeq.patch) +endif() + +vcpkg_extract_source_archive(SOURCE_PATH + ARCHIVE "${ARCHIVE}" + SOURCE_BASE "v${VERSION}" + PATCHES ${LIBICONV_PATCHES} +) + +vcpkg_list(SET OPTIONS) +if (NOT VCPKG_TARGET_IS_ANDROID) + vcpkg_list(APPEND OPTIONS --enable-relocatable) +endif() + +vcpkg_make_configure( + SOURCE_PATH "${SOURCE_PATH}" + OPTIONS + --enable-extra-encodings + --without-libiconv-prefix + --without-libintl-prefix + ${OPTIONS} +) +vcpkg_make_install() + +vcpkg_copy_pdbs() +vcpkg_copy_tool_dependencies("${CURRENT_PACKAGES_DIR}/tools/${PORT}/bin") +vcpkg_copy_tool_dependencies("${CURRENT_PACKAGES_DIR}/tools/${PORT}/debug/bin") + +file(COPY "${CMAKE_CURRENT_LIST_DIR}/vcpkg-cmake-wrapper.cmake" DESTINATION "${CURRENT_PACKAGES_DIR}/share/iconv") + +set(VCPKG_POLICY_ALLOW_RESTRICTED_HEADERS enabled) +file(REMOVE_RECURSE "${CURRENT_PACKAGES_DIR}/debug/share") +file(REMOVE_RECURSE "${CURRENT_PACKAGES_DIR}/share/${PORT}") # share contains unneeded doc files + +# Please keep, the default usage is broken +file(INSTALL "${CMAKE_CURRENT_LIST_DIR}/usage" DESTINATION "${CURRENT_PACKAGES_DIR}/share/${PORT}") + +# Handle copyright +vcpkg_install_copyright(FILE_LIST "${SOURCE_PATH}/COPYING.LIB" "${SOURCE_PATH}/COPYING" COMMENT " +The libiconv and libcharset libraries and their header files are under LGPL, +see COPYING.LIB below. + +The iconv program and the documentation are under GPL, see COPYING below.") diff --git a/vcpkg/ports/libiconv/usage b/vcpkg/ports/libiconv/usage new file mode 100644 index 000000000..16d80a2d7 --- /dev/null +++ b/vcpkg/ports/libiconv/usage @@ -0,0 +1,4 @@ +libiconv provides CMake targets: + + find_package(Iconv REQUIRED) + target_link_libraries(main PRIVATE Iconv::Iconv) diff --git a/vcpkg/ports/libiconv/vcpkg-cmake-wrapper.cmake b/vcpkg/ports/libiconv/vcpkg-cmake-wrapper.cmake new file mode 100644 index 000000000..af29f1264 --- /dev/null +++ b/vcpkg/ports/libiconv/vcpkg-cmake-wrapper.cmake @@ -0,0 +1,33 @@ +include(SelectLibraryConfigurations) + +set(Z_VCPKG_ICONV_USES_DARWIN_SDK FALSE) +if(APPLE AND CMAKE_OSX_SYSROOT AND EXISTS "${CMAKE_OSX_SYSROOT}/usr/include/iconv.h") + set(Z_VCPKG_ICONV_USES_DARWIN_SDK TRUE) + set(Iconv_IS_BUILT_IN FALSE CACHE BOOL "iconv requires a separate library") + if(NOT Iconv_INCLUDE_DIR) + set(Iconv_INCLUDE_DIR "${CMAKE_OSX_SYSROOT}/usr/include" CACHE PATH "iconv include directory") + endif() + if(NOT Iconv_LIBRARY) + set(Iconv_LIBRARY iconv CACHE STRING "iconv library") + endif() +endif() + +_find_package(${ARGS}) +if(Iconv_FOUND AND NOT Iconv_IS_BUILT_IN AND NOT Z_VCPKG_ICONV_USES_DARWIN_SDK) + find_path(CHARSET_INCLUDE_DIR NAMES "libcharset.h" HINTS "${_VCPKG_INSTALLED_DIR}/${VCPKG_TARGET_TRIPLET}/include") + find_library(CHARSET_LIBRARY_DEBUG NAMES charsetd libcharsetd charset libcharset NAMES_PER_DIR PATH_SUFFIXES lib HINTS "${_VCPKG_INSTALLED_DIR}/${VCPKG_TARGET_TRIPLET}/debug") + find_library(CHARSET_LIBRARY_RELEASE NAMES charset libcharset NAMES_PER_DIR PATH_SUFFIXES lib HINTS "${_VCPKG_INSTALLED_DIR}/${VCPKG_TARGET_TRIPLET}") + select_library_configurations(CHARSET) + if(NOT TARGET Iconv::Charset) + add_library(Iconv::Charset INTERFACE IMPORTED) + set_target_properties(Iconv::Charset PROPERTIES + INTERFACE_LINK_LIBRARIES "\$<\$>:${CHARSET_LIBRARY_RELEASE}>;\$<\$:${CHARSET_LIBRARY_DEBUG}>" + INTERFACE_INCLUDE_DIRECTORIES "${CHARSET_INCLUDE_DIRS}") + endif() + if(CHARSET_LIBRARIES) + list(APPEND Iconv_LIBRARIES ${CHARSET_LIBRARIES}) + if(TARGET Iconv::Iconv) + set_property(TARGET Iconv::Iconv APPEND PROPERTY INTERFACE_LINK_LIBRARIES Iconv::Charset) + endif() + endif() +endif() diff --git a/vcpkg/ports/libiconv/vcpkg.json b/vcpkg/ports/libiconv/vcpkg.json new file mode 100644 index 000000000..700d3cda0 --- /dev/null +++ b/vcpkg/ports/libiconv/vcpkg.json @@ -0,0 +1,21 @@ +{ + "name": "libiconv", + "version": "1.19", + "description": [ + "iconv() text conversion.", + "This port installs GNU libiconv if the system C runtime doesn't provide a suitable iconv() implementation." + ], + "homepage": "https://www.gnu.org/software/libiconv/", + "license": null, + "dependencies": [ + { + "name": "vcpkg-cmake-get-vars", + "host": true, + "platform": "android" + }, + { + "name": "vcpkg-make", + "host": true + } + ] +} diff --git a/vcpkg/ports/openssl/cmake-config.patch b/vcpkg/ports/openssl/cmake-config.patch new file mode 100644 index 000000000..74251abef --- /dev/null +++ b/vcpkg/ports/openssl/cmake-config.patch @@ -0,0 +1,62 @@ +diff --git a/Configurations/unix-Makefile.tmpl b/Configurations/unix-Makefile.tmpl +index 09303c4..487ff68 100644 +--- a/Configurations/unix-Makefile.tmpl ++++ b/Configurations/unix-Makefile.tmpl +@@ -338,7 +338,7 @@ bindir={- file_name_is_absolute($bindir) + ? $bindir : '$(INSTALLTOP)/$(BINDIR)' -} + + PKGCONFIGDIR=$(libdir)/pkgconfig +-CMAKECONFIGDIR=$(libdir)/cmake/OpenSSL ++CMAKECONFIGDIR=$(INSTALLTOP)/share/openssl + + MANDIR=$(INSTALLTOP)/share/man + DOCDIR=$(INSTALLTOP)/share/doc/$(BASENAME) +diff --git a/Configurations/windows-makefile.tmpl b/Configurations/windows-makefile.tmpl +index 894834c..d6d3c41 100644 +--- a/Configurations/windows-makefile.tmpl ++++ b/Configurations/windows-makefile.tmpl +@@ -254,7 +254,7 @@ MODULESDIR=$(MODULESDIR_dev)$(MODULESDIR_dir) + libdir={- file_name_is_absolute($libdir) + ? $libdir : '$(INSTALLTOP)\$(LIBDIR)' -} + +-CMAKECONFIGDIR=$(libdir)\cmake\OpenSSL ++CMAKECONFIGDIR=$(INSTALLTOP)\share\openssl + + ##### User defined commands and flags ################################ + +diff --git a/exporters/cmake/OpenSSLConfig.cmake.in b/exporters/cmake/OpenSSLConfig.cmake.in +index 766aebe..026680a 100644 +--- a/exporters/cmake/OpenSSLConfig.cmake.in ++++ b/exporters/cmake/OpenSSLConfig.cmake.in +@@ -91,8 +91,7 @@ get_filename_component(_ossl_prefix "${CMAKE_CURRENT_LIST_FILE}" PATH) + {- + # For each component in $OpenSSL::safe::installdata::CMAKECONFIGDIR[0] relative to + # $OpenSSL::safe::installdata::PREFIX[0], have CMake figure out the parent directory. +- my $d = join('/', unixify(catdir($OpenSSL::safe::installdata::LIBDIR_REL_PREFIX[0], +- $OpenSSL::safe::installdata::CMAKECONFIGDIR_REL_LIBDIR[0]), 1)); ++ my $d = 'share/openssl'; + $OUT = ''; + if ($d ne '.') { + $OUT .= 'get_filename_component(_ossl_prefix "${_ossl_prefix}" PATH)' . "\n" +@@ -141,6 +140,14 @@ set(OPENSSL_APPLINK_SOURCE "${_ossl_prefix}/{- unixify($OpenSSL::safe::installda + {- output_on() if $disabled{uplink}; "" -} + set(OPENSSL_PROGRAM "${OPENSSL_RUNTIME_DIR}/{- platform->bin('openssl') -}") + ++if(NOT Z_VCPKG_OPENSSL_USE_SINGLE_CONFIG) ++ # Prevent loop ++ set(Z_VCPKG_OPENSSL_USE_SINGLE_CONFIG "prevent-loop") ++ # Chainload vcpkg's module-based multi-config target setup ++ find_package(OpenSSL MODULE) ++ set(Z_VCPKG_OPENSSL_USE_SINGLE_CONFIG 0) ++else() ++ # Use official single-config target setup + # Set up the imported targets + if(_ossl_use_static_libs) + {- output_off() unless $no_static; "" -} +@@ -240,5 +247,6 @@ set_property(TARGET OpenSSL::applink PROPERTY + INTERFACE_SOURCES "${OPENSSL_APPLINK_SOURCE}") + {- output_on() if $disabled{uplink}; "" -} + ++endif() + unset(_ossl_prefix) + unset(_ossl_use_static_libs) diff --git a/vcpkg/ports/openssl/command-line-length.patch b/vcpkg/ports/openssl/command-line-length.patch new file mode 100644 index 000000000..f379d2da2 --- /dev/null +++ b/vcpkg/ports/openssl/command-line-length.patch @@ -0,0 +1,13 @@ +diff --git a/Configurations/unix-Makefile.tmpl b/Configurations/unix-Makefile.tmpl +index 8ddb128..52b9ad6 100644 +--- a/Configurations/unix-Makefile.tmpl ++++ b/Configurations/unix-Makefile.tmpl +@@ -1961,7 +1961,7 @@ EOF + my @objs = map { platform->obj($_) } @{$args{objs}}; + my $deps = join(" \\\n" . ' ' x (length($lib) + 2), + fill_lines(' ', $COLUMNS - length($lib) - 2, @objs)); +- my $max_per_call = 500; ++ my $max_per_call = ($^O eq 'msys' || $^O eq 'cygwin') ? 80 : 500; + my @objs_grouped; + push @objs_grouped, join(" ", splice @objs, 0, $max_per_call) while @objs; + my $fill_lib = diff --git a/vcpkg/ports/openssl/install-pc-files.cmake b/vcpkg/ports/openssl/install-pc-files.cmake new file mode 100644 index 000000000..04897faed --- /dev/null +++ b/vcpkg/ports/openssl/install-pc-files.cmake @@ -0,0 +1,32 @@ +function(install_pc_file name pc_data) + if(NOT DEFINED VCPKG_BUILD_TYPE OR VCPKG_BUILD_TYPE STREQUAL "release") + configure_file("${CMAKE_CURRENT_LIST_DIR}/openssl.pc.in" "${CURRENT_PACKAGES_DIR}/lib/pkgconfig/${name}.pc" @ONLY) + endif() + if(NOT DEFINED VCPKG_BUILD_TYPE OR VCPKG_BUILD_TYPE STREQUAL "debug") + configure_file("${CMAKE_CURRENT_LIST_DIR}/openssl.pc.in" "${CURRENT_PACKAGES_DIR}/debug/lib/pkgconfig/${name}.pc" @ONLY) + endif() +endfunction() + +install_pc_file(openssl [[ +Name: OpenSSL +Description: Secure Sockets Layer and cryptography libraries and tools +Requires: libssl libcrypto +]]) + +install_pc_file(libssl [[ +Name: OpenSSL-libssl +Description: Secure Sockets Layer and cryptography libraries +Libs: -L"${libdir}" -llibssl +Requires: libcrypto +Cflags: -I"${includedir}" +]]) + +install_pc_file(libcrypto [[ +Name: OpenSSL-libcrypto +Description: OpenSSL cryptography library +Libs: -L"${libdir}" -llibcrypto +Libs.private: -lcrypt32 -lws2_32 -ladvapi32 -luser32 +Cflags: -I"${includedir}" +]]) + +vcpkg_fixup_pkgconfig() diff --git a/vcpkg/ports/openssl/openssl.pc.in b/vcpkg/ports/openssl/openssl.pc.in new file mode 100644 index 000000000..f7fa9d185 --- /dev/null +++ b/vcpkg/ports/openssl/openssl.pc.in @@ -0,0 +1,6 @@ +prefix=${pcfiledir}/../.. +exec_prefix=${prefix} +libdir=${exec_prefix}/lib +includedir=${prefix}/include +Version: @VERSION@ +@pc_data@ diff --git a/vcpkg/ports/openssl/portfile.cmake b/vcpkg/ports/openssl/portfile.cmake new file mode 100644 index 000000000..536048e2a --- /dev/null +++ b/vcpkg/ports/openssl/portfile.cmake @@ -0,0 +1,93 @@ +if(EXISTS "${CURRENT_INSTALLED_DIR}/share/libressl/copyright" + OR EXISTS "${CURRENT_INSTALLED_DIR}/share/boringssl/copyright") + message(FATAL_ERROR "Can't build openssl if libressl/boringssl is installed. Please remove libressl/boringssl, and try install openssl again if you need it.") +endif() + +if(VCPKG_TARGET_IS_EMSCRIPTEN) + vcpkg_check_linkage(ONLY_STATIC_LIBRARY) +endif() + +vcpkg_from_github( + OUT_SOURCE_PATH SOURCE_PATH + REPO openssl/openssl + REF "openssl-${VERSION}" + SHA512 a89c08101fa1d7e3c09b14f4a90d450bcf336a4f6a3e6e4ea990e4deddcd9ce250472f9114438fd134ff4b47fe93dd47232308567088b2b1c0b2eb50e3b56bdf + PATCHES + cmake-config.patch + command-line-length.patch + script-prefix.patch + windows/install-layout.patch + windows/install-pdbs.patch + windows/install-programs.diff # https://github.com/openssl/openssl/issues/28744 + unix/android-cc.patch + unix/move-openssldir.patch + unix/no-empty-dirs.patch + unix/no-static-libs-for-shared.patch +) + +vcpkg_list(SET CONFIGURE_OPTIONS + enable-static-engine + enable-capieng + no-tests + no-docs +) + +# https://github.com/openssl/openssl/blob/master/INSTALL.md#enable-ec_nistp_64_gcc_128 +vcpkg_cmake_get_vars(cmake_vars_file) +include("${cmake_vars_file}") +if(VCPKG_DETECTED_CMAKE_C_COMPILER_ID MATCHES "^(GNU|Clang|AppleClang)$" + AND VCPKG_TARGET_ARCHITECTURE MATCHES "^(x64|arm64|riscv64|ppc64le)$") + vcpkg_list(APPEND CONFIGURE_OPTIONS enable-ec_nistp_64_gcc_128) +endif() + +set(INSTALL_FIPS "") +if("fips" IN_LIST FEATURES) + vcpkg_list(APPEND INSTALL_FIPS install_fips) + vcpkg_list(APPEND CONFIGURE_OPTIONS enable-fips) +endif() + +if(VCPKG_LIBRARY_LINKAGE STREQUAL "dynamic") + vcpkg_list(APPEND CONFIGURE_OPTIONS shared) +else() + vcpkg_list(APPEND CONFIGURE_OPTIONS no-shared no-module) +endif() + +if(NOT "tools" IN_LIST FEATURES) + vcpkg_list(APPEND CONFIGURE_OPTIONS no-apps) +endif() + +if("weak-ssl-ciphers" IN_LIST FEATURES) + vcpkg_list(APPEND CONFIGURE_OPTIONS enable-weak-ssl-ciphers) +endif() + +if("ssl3" IN_LIST FEATURES) + vcpkg_list(APPEND CONFIGURE_OPTIONS enable-ssl3) + vcpkg_list(APPEND CONFIGURE_OPTIONS enable-ssl3-method) +endif() + +if(DEFINED OPENSSL_USE_NOPINSHARED) + vcpkg_list(APPEND CONFIGURE_OPTIONS no-pinshared) +endif() + +if(OPENSSL_NO_AUTOLOAD_CONFIG) + vcpkg_list(APPEND CONFIGURE_OPTIONS no-autoload-config) +endif() + +if(VCPKG_TARGET_IS_WINDOWS AND NOT VCPKG_TARGET_IS_MINGW) + include("${CMAKE_CURRENT_LIST_DIR}/windows/portfile.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/install-pc-files.cmake") +else() + include("${CMAKE_CURRENT_LIST_DIR}/unix/portfile.cmake") +endif() + +file(INSTALL "${CMAKE_CURRENT_LIST_DIR}/usage" DESTINATION "${CURRENT_PACKAGES_DIR}/share/${PORT}") + +if (NOT "${VERSION}" MATCHES [[^([0-9]+)\.([0-9]+)\.([0-9]+)$]]) + message(FATAL_ERROR "Version regex did not match.") +endif() +set(OPENSSL_VERSION_MAJOR "${CMAKE_MATCH_1}") +set(OPENSSL_VERSION_MINOR "${CMAKE_MATCH_2}") +set(OPENSSL_VERSION_FIX "${CMAKE_MATCH_3}") +configure_file("${CMAKE_CURRENT_LIST_DIR}/vcpkg-cmake-wrapper.cmake.in" "${CURRENT_PACKAGES_DIR}/share/${PORT}/vcpkg-cmake-wrapper.cmake" @ONLY) + +vcpkg_install_copyright(FILE_LIST "${SOURCE_PATH}/LICENSE.txt") diff --git a/vcpkg/ports/openssl/script-prefix.patch b/vcpkg/ports/openssl/script-prefix.patch new file mode 100644 index 000000000..fd3316b85 --- /dev/null +++ b/vcpkg/ports/openssl/script-prefix.patch @@ -0,0 +1,22 @@ +diff --git a/tools/c_rehash.in b/tools/c_rehash.in +index 343cdc1..e48038e 100644 +--- a/tools/c_rehash.in ++++ b/tools/c_rehash.in +@@ -12,7 +12,7 @@ + # and add symbolic links to their hash values. + + my $dir = {- quotify1($config{openssldir}) -}; +-my $prefix = {- quotify1($config{prefix}) -}; ++use FindBin; + + my $errorcount = 0; + my $openssl = $ENV{OPENSSL} || "openssl"; +@@ -61,7 +61,7 @@ if (defined(&Cwd::getcwd)) { + + # DOS/Win32 or Unix delimiter? Prefix our installdir, then search. + my $path_delim = ($pwd =~ /^[a-z]\:/i) ? ';' : ':'; +-$ENV{PATH} = "$prefix/bin" . ($ENV{PATH} ? $path_delim . $ENV{PATH} : ""); ++$ENV{PATH} = "$FindBin::Bin" . ($ENV{PATH} ? $path_delim . $ENV{PATH} : ""); + + if (!(-f $openssl && -x $openssl)) { + my $found = 0; diff --git a/vcpkg/ports/openssl/unix/android-cc.patch b/vcpkg/ports/openssl/unix/android-cc.patch new file mode 100644 index 000000000..f4c3d6834 --- /dev/null +++ b/vcpkg/ports/openssl/unix/android-cc.patch @@ -0,0 +1,20 @@ +diff --git a/Configurations/15-android.conf b/Configurations/15-android.conf +index 41ad922..d15e34c 100644 +--- a/Configurations/15-android.conf ++++ b/Configurations/15-android.conf +@@ -102,6 +102,7 @@ + my $cflags; + my $cppflags; + ++if (0) { + # see if there is NDK clang on $PATH, "universal" or "standalone" + if (which("clang") =~ m|^$ndk/.*/prebuilt/([^/]+)/|) { + my $host=$1; +@@ -158,6 +159,7 @@ + $sysroot =~ s|^$ndk/||; + $sysroot = " --sysroot=\$($ndk_var)/$sysroot"; + } ++} + $android_ndk = { + cflags => $cflags . $sysroot, + cppflags => $cppflags, diff --git a/vcpkg/ports/openssl/unix/configure b/vcpkg/ports/openssl/unix/configure new file mode 100755 index 000000000..2d49b3d16 --- /dev/null +++ b/vcpkg/ports/openssl/unix/configure @@ -0,0 +1,31 @@ +#!/usr/bin/env bash + +set -e + +declare -a OUT_OPTIONS + +INTERPRETER=perl + +LAST_SEEN= +COPY_OPTIONS=no +for OPTION; do + case "${OPTION},${COPY_OPTIONS}" in + */Configure,no) + OUT_OPTIONS+=("${OPTION}") + INTERPRETER="${LAST_SEEN}" + COPY_OPTIONS=yes + ;; + --prefix=*|--openssldir=*|--libdir=*|--cross-compile-prefix=*|--debug,*) + OUT_OPTIONS+=("${OPTION}") + ;; + -*|*=*) + ;; + *,yes) + OUT_OPTIONS+=("${OPTION}") + ;; + esac + LAST_SEEN="${OPTION}" +done + +set -x +"${INTERPRETER}" ${OUT_OPTIONS[@]} diff --git a/vcpkg/ports/openssl/unix/move-openssldir.patch b/vcpkg/ports/openssl/unix/move-openssldir.patch new file mode 100644 index 000000000..2a39bdbe7 --- /dev/null +++ b/vcpkg/ports/openssl/unix/move-openssldir.patch @@ -0,0 +1,16 @@ +diff --git a/Configurations/unix-Makefile.tmpl b/Configurations/unix-Makefile.tmpl +index c82c086..6c5402d 100644 +--- a/Configurations/unix-Makefile.tmpl ++++ b/Configurations/unix-Makefile.tmpl +@@ -690,6 +690,11 @@ install_ssldirs: + chmod 644 "$(DESTDIR)$(OPENSSLDIR)/ct_log_list.cnf"; \ + fi + ++ $(PERL) $(SRCDIR)/util/mkdir-p.pl $(DESTDIR)$(INSTALLTOP)$(OPENSSLDIR) ++ for I in $(DESTDIR)$(OPENSSLDIR)/*; do \ ++ mv $$I $(DESTDIR)$(INSTALLTOP)$(OPENSSLDIR)/; \ ++ done ++ + install_dev: install_runtime_libs + @[ -n "$(INSTALLTOP)" ] || (echo INSTALLTOP should not be empty; exit 1) + @$(ECHO) "*** Installing development files" diff --git a/vcpkg/ports/openssl/unix/no-empty-dirs.patch b/vcpkg/ports/openssl/unix/no-empty-dirs.patch new file mode 100644 index 000000000..7cf0cf05f --- /dev/null +++ b/vcpkg/ports/openssl/unix/no-empty-dirs.patch @@ -0,0 +1,22 @@ +diff --git a/Configurations/unix-Makefile.tmpl b/Configurations/unix-Makefile.tmpl +index 6c5402d..fc982df 100644 +--- a/Configurations/unix-Makefile.tmpl ++++ b/Configurations/unix-Makefile.tmpl +@@ -823,7 +823,7 @@ _install_modules_deps: install_runtime_libs build_modules + + install_engines: _install_modules_deps + @[ -n "$(INSTALLTOP)" ] || (echo INSTALLTOP should not be empty; exit 1) +- @$(PERL) $(SRCDIR)/util/mkdir-p.pl "$(DESTDIR)$(ENGINESDIR)/" ++ @[ -z "$(INSTALL_ENGINES)" ] || $(PERL) $(SRCDIR)/util/mkdir-p.pl $(DESTDIR)$(ENGINESDIR)/ + @$(ECHO) "*** Installing engines" + @set -e; for e in dummy $(INSTALL_ENGINES); do \ + if [ "$$e" = "dummy" ]; then continue; fi; \ +@@ -847,7 +847,7 @@ uninstall_engines: + + install_modules: _install_modules_deps + @[ -n "$(INSTALLTOP)" ] || (echo INSTALLTOP should not be empty; exit 1) +- @$(PERL) $(SRCDIR)/util/mkdir-p.pl "$(DESTDIR)$(MODULESDIR)/" ++ @[ -z "$(INSTALL_MODULES)" ] || $(PERL) $(SRCDIR)/util/mkdir-p.pl $(DESTDIR)$(MODULESDIR)/ + @$(ECHO) "*** Installing modules" + @set -e; for e in dummy $(INSTALL_MODULES); do \ + if [ "$$e" = "dummy" ]; then continue; fi; \ diff --git a/vcpkg/ports/openssl/unix/no-static-libs-for-shared.patch b/vcpkg/ports/openssl/unix/no-static-libs-for-shared.patch new file mode 100644 index 000000000..d6aa34cfe --- /dev/null +++ b/vcpkg/ports/openssl/unix/no-static-libs-for-shared.patch @@ -0,0 +1,12 @@ +diff --git a/Configurations/unix-Makefile.tmpl b/Configurations/unix-Makefile.tmpl +index fc982df..10f1c54 100644 +--- a/Configurations/unix-Makefile.tmpl ++++ b/Configurations/unix-Makefile.tmpl +@@ -713,6 +713,7 @@ install_dev: install_runtime_libs + done + @$(PERL) $(SRCDIR)/util/mkdir-p.pl "$(DESTDIR)$(libdir)" + @set -e; for l in $(INSTALL_LIBS); do \ ++ if [ -n "$(INSTALL_SHLIBS)" ] ; then continue ; fi ; \ + fn=`basename $$l`; \ + $(ECHO) "install $$l -> $(DESTDIR)$(libdir)/$$fn"; \ + cp $$l "$(DESTDIR)$(libdir)/$$fn.new"; \ diff --git a/vcpkg/ports/openssl/unix/portfile.cmake b/vcpkg/ports/openssl/unix/portfile.cmake new file mode 100644 index 000000000..3e2226d6d --- /dev/null +++ b/vcpkg/ports/openssl/unix/portfile.cmake @@ -0,0 +1,195 @@ +if (VCPKG_TARGET_IS_LINUX) + message(NOTICE [[ +openssl requires Linux kernel headers from the system package manager. + They can be installed on Alpine systems via `apk add linux-headers`. + They can be installed on Ubuntu systems via `apt install linux-libc-dev`. +]]) +endif() + +if(VCPKG_HOST_IS_WINDOWS) + vcpkg_acquire_msys(MSYS_ROOT PACKAGES make perl) + set(MAKE "${MSYS_ROOT}/usr/bin/make.exe") + set(PERL "${MSYS_ROOT}/usr/bin/perl.exe") +else() + find_program(MAKE make) + if(NOT MAKE) + message(FATAL_ERROR "Could not find make. Please install it through your package manager.") + endif() + vcpkg_find_acquire_program(PERL) +endif() +set(INTERPRETER "${PERL}") + +execute_process( + COMMAND "${PERL}" -e "use IPC::Cmd;" + RESULT_VARIABLE perl_ipc_cmd_result +) +if(NOT perl_ipc_cmd_result STREQUAL "0") + message(FATAL_ERROR "\nPerl cannot find IPC::Cmd. Please install it through your system package manager.\n") +endif() + +# Ideally, OpenSSL should use `CC` from vcpkg as is (absolute path). +# But in reality, OpenSSL expects to locate the compiler via `PATH`, +# and it makes its own choices e.g. for Android. +vcpkg_cmake_get_vars(cmake_vars_file) +include("${cmake_vars_file}") +cmake_path(GET VCPKG_DETECTED_CMAKE_C_COMPILER PARENT_PATH compiler_path) +cmake_path(GET VCPKG_DETECTED_CMAKE_C_COMPILER FILENAME compiler_name) +find_program(compiler_in_path NAMES "${compiler_name}" PATHS ENV PATH NO_DEFAULT_PATH) +if(NOT compiler_in_path) + vcpkg_host_path_list(APPEND ENV{PATH} "${compiler_path}") +elseif(NOT compiler_in_path STREQUAL VCPKG_DETECTED_CMAKE_C_COMPILER) + vcpkg_host_path_list(PREPEND ENV{PATH} "${compiler_path}") +endif() + +vcpkg_list(SET MAKEFILE_OPTIONS) +if(VCPKG_TARGET_IS_ANDROID) + set(ENV{ANDROID_NDK_ROOT} "${VCPKG_DETECTED_CMAKE_ANDROID_NDK}") + set(OPENSSL_ARCH "android-${VCPKG_DETECTED_CMAKE_ANDROID_ARCH}") + if(VCPKG_DETECTED_CMAKE_ANDROID_ARCH STREQUAL "arm" AND NOT VCPKG_DETECTED_CMAKE_ANDROID_ARM_NEON) + vcpkg_list(APPEND CONFIGURE_OPTIONS no-asm) + endif() +elseif(VCPKG_TARGET_IS_LINUX) + if(VCPKG_TARGET_ARCHITECTURE MATCHES "arm64") + set(OPENSSL_ARCH linux-aarch64) + elseif(VCPKG_TARGET_ARCHITECTURE MATCHES "arm") + set(OPENSSL_ARCH linux-armv4) + elseif(VCPKG_TARGET_ARCHITECTURE MATCHES "x64") + set(OPENSSL_ARCH linux-x86_64) + elseif(VCPKG_TARGET_ARCHITECTURE MATCHES "x86") + set(OPENSSL_ARCH linux-x86) + else() + set(OPENSSL_ARCH linux-generic32) + endif() +elseif(VCPKG_TARGET_IS_IOS) + if(VCPKG_TARGET_ARCHITECTURE MATCHES "arm64") + if(VCPKG_OSX_SYSROOT STREQUAL "iphonesimulator") + set(OPENSSL_ARCH iossimulator-arm64-xcrun) + else() + set(OPENSSL_ARCH ios64-xcrun) + endif() + elseif(VCPKG_TARGET_ARCHITECTURE MATCHES "arm") + set(OPENSSL_ARCH ios-xcrun) + elseif(VCPKG_TARGET_ARCHITECTURE MATCHES "x86" OR VCPKG_TARGET_ARCHITECTURE MATCHES "x64") + set(OPENSSL_ARCH iossimulator-xcrun) + else() + message(FATAL_ERROR "Unknown iOS target architecture: ${VCPKG_TARGET_ARCHITECTURE}") + endif() + # disable that makes linkage error (e.g. require stderr usage) + list(APPEND CONFIGURE_OPTIONS no-ui no-asm) +elseif(VCPKG_TARGET_IS_TVOS OR VCPKG_TARGET_IS_WATCHOS) + set(OPENSSL_ARCH iphoneos-cross) + # disable that makes linkage error (e.g. require stderr usage) + list(APPEND CONFIGURE_OPTIONS no-ui no-asm) +elseif(VCPKG_TARGET_IS_OSX) + # Universal builds don't support ASM + if(VCPKG_OSX_ARCHITECTURES MATCHES "arm64" AND VCPKG_OSX_ARCHITECTURES MATCHES "x86_64") + list(APPEND CONFIGURE_OPTIONS no-asm) + endif() + if(VCPKG_TARGET_ARCHITECTURE MATCHES "arm64") + set(OPENSSL_ARCH darwin64-arm64) + else() + set(OPENSSL_ARCH darwin64-x86_64) + endif() +elseif(VCPKG_TARGET_IS_BSD) + set(OPENSSL_ARCH BSD-nodef-generic64) +elseif(VCPKG_TARGET_IS_SOLARIS) + if(VCPKG_TARGET_ARCHITECTURE STREQUAL "x64") + set(OPENSSL_ARCH solaris64-x86_64-gcc) + else() + set(OPENSSL_ARCH solaris-x86-gcc) + endif() +elseif(VCPKG_TARGET_IS_MINGW) + if(VCPKG_TARGET_ARCHITECTURE STREQUAL "x64") + set(OPENSSL_ARCH mingw64) + elseif(VCPKG_TARGET_ARCHITECTURE STREQUAL "arm64") + set(OPENSSL_ARCH mingwarm64) + else() + set(OPENSSL_ARCH mingw) + endif() +elseif(VCPKG_TARGET_IS_EMSCRIPTEN) + set(OPENSSL_ARCH linux-x32) + vcpkg_list(APPEND CONFIGURE_OPTIONS + no-engine + no-asm + no-sse2 + no-srtp + --cross-compile-prefix= + ) + # Cf. https://emscripten.org/docs/porting/pthreads.html: + # For Pthreads support, not just openssl but everything + # must be compiled and linked with `-pthread`. + # This makes it a triplet/toolchain-wide setting. + if(NOT " ${VCPKG_DETECTED_CMAKE_C_FLAGS} " MATCHES " -pthread ") + vcpkg_list(APPEND CONFIGURE_OPTIONS no-threads) + endif() +else() + message(FATAL_ERROR "Unknown platform") +endif() + +file(MAKE_DIRECTORY "${SOURCE_PATH}/vcpkg") +file(COPY "${CMAKE_CURRENT_LIST_DIR}/configure" DESTINATION "${SOURCE_PATH}/vcpkg") +# AETHER_OPENSSL_IOS_TRIPLET_NORMALIZE_BEGIN +# The legacy helper builds a single bash -c argument. A CMake-list separator +# splits that argument before Perl/Configure; the modern make helper needs the +# original list, so join only for this call and restore it immediately below. +if(VCPKG_TARGET_IS_IOS AND DEFINED VCPKG_MAKE_BUILD_TRIPLET) + set(AETHER_OPENSSL_ORIGINAL_MAKE_BUILD_TRIPLET "${VCPKG_MAKE_BUILD_TRIPLET}") + list(JOIN VCPKG_MAKE_BUILD_TRIPLET " " VCPKG_MAKE_BUILD_TRIPLET) +endif() +# AETHER_OPENSSL_IOS_TRIPLET_NORMALIZE_END +vcpkg_configure_make( + SOURCE_PATH "${SOURCE_PATH}" + PROJECT_SUBPATH "vcpkg" + NO_ADDITIONAL_PATHS + OPTIONS + "${INTERPRETER}" + "${SOURCE_PATH}/Configure" + ${OPENSSL_ARCH} + ${CONFIGURE_OPTIONS} + "--openssldir=/etc/ssl" + "--libdir=lib" + OPTIONS_DEBUG + --debug +) +# AETHER_OPENSSL_IOS_TRIPLET_RESTORE_BEGIN +if(DEFINED AETHER_OPENSSL_ORIGINAL_MAKE_BUILD_TRIPLET) + set(VCPKG_MAKE_BUILD_TRIPLET "${AETHER_OPENSSL_ORIGINAL_MAKE_BUILD_TRIPLET}") + unset(AETHER_OPENSSL_ORIGINAL_MAKE_BUILD_TRIPLET) +endif() +# AETHER_OPENSSL_IOS_TRIPLET_RESTORE_END +vcpkg_install_make( + ${MAKEFILE_OPTIONS} + BUILD_TARGET build_inst_sw +) +vcpkg_fixup_pkgconfig() + +if("tools" IN_LIST FEATURES) + file(MAKE_DIRECTORY "${CURRENT_PACKAGES_DIR}/tools/${PORT}") + file(RENAME "${CURRENT_PACKAGES_DIR}/bin/c_rehash" "${CURRENT_PACKAGES_DIR}/tools/${PORT}/c_rehash") + file(REMOVE "${CURRENT_PACKAGES_DIR}/debug/bin/c_rehash") + vcpkg_copy_tools(TOOL_NAMES openssl AUTO_CLEAN) +elseif(VCPKG_LIBRARY_LINKAGE STREQUAL "static" OR NOT VCPKG_TARGET_IS_WINDOWS) + file(REMOVE_RECURSE "${CURRENT_PACKAGES_DIR}/bin" "${CURRENT_PACKAGES_DIR}/debug/bin") + file(REMOVE_RECURSE "${CURRENT_PACKAGES_DIR}/etc/ssl/misc") +endif() + +file(TOUCH "${CURRENT_PACKAGES_DIR}/etc/ssl/certs/.keep") +file(TOUCH "${CURRENT_PACKAGES_DIR}/etc/ssl/private/.keep") + +file(REMOVE_RECURSE + "${CURRENT_PACKAGES_DIR}/debug/etc" + "${CURRENT_PACKAGES_DIR}/debug/include" + "${CURRENT_PACKAGES_DIR}/debug/share" +) + +# For consistency of mingw build with nmake build +file(GLOB engines "${CURRENT_PACKAGES_DIR}/lib/ossl-modules/*.dll") +if(NOT engines STREQUAL "") + file(COPY ${engines} DESTINATION "${CURRENT_PACKAGES_DIR}/bin") + file(REMOVE_RECURSE "${CURRENT_PACKAGES_DIR}/lib/ossl-modules") +endif() +file(GLOB engines "${CURRENT_PACKAGES_DIR}/debug/lib/ossl-modules/*.dll") +if(NOT engines STREQUAL "") + file(COPY ${engines} DESTINATION "${CURRENT_PACKAGES_DIR}/debug/bin") + file(REMOVE_RECURSE "${CURRENT_PACKAGES_DIR}/debug/lib/ossl-modules") +endif() diff --git a/vcpkg/ports/openssl/unix/remove-deps.cmake b/vcpkg/ports/openssl/unix/remove-deps.cmake new file mode 100644 index 000000000..53ad6ef29 --- /dev/null +++ b/vcpkg/ports/openssl/unix/remove-deps.cmake @@ -0,0 +1,7 @@ +file(GLOB_RECURSE MAKEFILES ${DIR}/*/Makefile) +foreach(MAKEFILE ${MAKEFILES}) + message("removing deps from ${MAKEFILE}") + file(READ "${MAKEFILE}" _contents) + string(REGEX REPLACE "\n# DO NOT DELETE THIS LINE.*" "" _contents "${_contents}") + file(WRITE "${MAKEFILE}" "${_contents}") +endforeach() diff --git a/vcpkg/ports/openssl/usage b/vcpkg/ports/openssl/usage new file mode 100644 index 000000000..5fdc6360a --- /dev/null +++ b/vcpkg/ports/openssl/usage @@ -0,0 +1,5 @@ +openssl is compatible with built-in CMake targets: + + find_package(OpenSSL REQUIRED) + target_link_libraries(main PRIVATE OpenSSL::SSL) + target_link_libraries(main PRIVATE OpenSSL::Crypto) diff --git a/vcpkg/ports/openssl/vcpkg-cmake-wrapper.cmake.in b/vcpkg/ports/openssl/vcpkg-cmake-wrapper.cmake.in new file mode 100644 index 000000000..5008112c3 --- /dev/null +++ b/vcpkg/ports/openssl/vcpkg-cmake-wrapper.cmake.in @@ -0,0 +1,82 @@ +cmake_policy(PUSH) +cmake_policy(SET CMP0012 NEW) +cmake_policy(SET CMP0054 NEW) +cmake_policy(SET CMP0057 NEW) + +set(OPENSSL_VERSION_MAJOR @OPENSSL_VERSION_MAJOR@) +set(OPENSSL_VERSION_MINOR @OPENSSL_VERSION_MINOR@) +set(OPENSSL_VERSION_FIX @OPENSSL_VERSION_FIX@) + +if(OPENSSL_USE_STATIC_LIBS) + if("@VCPKG_LIBRARY_LINKAGE@" STREQUAL "dynamic") + message(WARNING "OPENSSL_USE_STATIC_LIBS is set, but vcpkg port openssl was built with dynamic linkage") + endif() + set(OPENSSL_USE_STATIC_LIBS_BAK "${OPENSSL_USE_STATIC_LIBS}") + set(OPENSSL_USE_STATIC_LIBS FALSE) +endif() + +if(DEFINED OPENSSL_ROOT_DIR) + set(OPENSSL_ROOT_DIR_BAK "${OPENSSL_ROOT_DIR}") +endif() +get_filename_component(OPENSSL_ROOT_DIR "${CMAKE_CURRENT_LIST_DIR}" DIRECTORY) +get_filename_component(OPENSSL_ROOT_DIR "${OPENSSL_ROOT_DIR}" DIRECTORY) +find_path(OPENSSL_INCLUDE_DIR NAMES openssl/ssl.h PATH "${OPENSSL_ROOT_DIR}/include" NO_DEFAULT_PATH) +if(MSVC) + find_library(LIB_EAY_DEBUG NAMES libcrypto PATHS "${OPENSSL_ROOT_DIR}/debug/lib" NO_DEFAULT_PATH) + find_library(LIB_EAY_RELEASE NAMES libcrypto PATHS "${OPENSSL_ROOT_DIR}/lib" NO_DEFAULT_PATH) + find_library(SSL_EAY_DEBUG NAMES libssl PATHS "${OPENSSL_ROOT_DIR}/debug/lib" NO_DEFAULT_PATH) + find_library(SSL_EAY_RELEASE NAMES libssl PATHS "${OPENSSL_ROOT_DIR}/lib" NO_DEFAULT_PATH) +elseif(WIN32) + find_library(LIB_EAY NAMES libcrypto crypto NAMES_PER_DIR) + find_library(SSL_EAY NAMES libssl ssl NAMES_PER_DIR) +else() + find_library(OPENSSL_CRYPTO_LIBRARY NAMES crypto) + find_library(OPENSSL_SSL_LIBRARY NAMES ssl) +endif() + +_find_package(${ARGS}) + +unset(OPENSSL_ROOT_DIR) +if(DEFINED OPENSSL_ROOT_DIR_BAK) + set(OPENSSL_ROOT_DIR "${OPENSSL_ROOT_DIR_BAK}") + unset(OPENSSL_ROOT_DIR_BAK) +endif() + +if(DEFINED OPENSSL_USE_STATIC_LIBS_BAK) + set(OPENSSL_USE_STATIC_LIBS "${OPENSSL_USE_STATIC_LIBS_BAK}") + unset(OPENSSL_USE_STATIC_LIBS_BAK) +endif() + +if(OPENSSL_FOUND AND "@VCPKG_LIBRARY_LINKAGE@" STREQUAL "static") + if(WIN32) + list(APPEND OPENSSL_LIBRARIES crypt32 ws2_32) + if(TARGET OpenSSL::Crypto) + set_property(TARGET OpenSSL::Crypto APPEND PROPERTY INTERFACE_LINK_LIBRARIES "crypt32;ws2_32") + endif() + if(TARGET OpenSSL::SSL) + set_property(TARGET OpenSSL::SSL APPEND PROPERTY INTERFACE_LINK_LIBRARIES "crypt32;ws2_32") + endif() + else() + find_library(OPENSSL_DL_LIBRARY NAMES dl) + if(OPENSSL_DL_LIBRARY) + list(APPEND OPENSSL_LIBRARIES "dl") + if(TARGET OpenSSL::Crypto) + set_property(TARGET OpenSSL::Crypto APPEND PROPERTY INTERFACE_LINK_LIBRARIES "dl") + endif() + endif() + + if("REQUIRED" IN_LIST ARGS) + find_package(Threads REQUIRED) + else() + find_package(Threads) + endif() + list(APPEND OPENSSL_LIBRARIES ${CMAKE_THREAD_LIBS_INIT}) + if(TARGET OpenSSL::Crypto) + set_property(TARGET OpenSSL::Crypto APPEND PROPERTY INTERFACE_LINK_LIBRARIES "Threads::Threads") + endif() + if(TARGET OpenSSL::SSL) + set_property(TARGET OpenSSL::SSL APPEND PROPERTY INTERFACE_LINK_LIBRARIES "Threads::Threads") + endif() + endif() +endif() +cmake_policy(POP) diff --git a/vcpkg/ports/openssl/vcpkg.json b/vcpkg/ports/openssl/vcpkg.json new file mode 100644 index 000000000..82e2a0232 --- /dev/null +++ b/vcpkg/ports/openssl/vcpkg.json @@ -0,0 +1,37 @@ +{ + "name": "openssl", + "version": "3.6.3", + "description": "OpenSSL is an open source project that provides a robust, commercial-grade, and full-featured toolkit for the Transport Layer Security (TLS) and Secure Sockets Layer (SSL) protocols. It is also a general-purpose cryptography library.", + "homepage": "https://www.openssl.org", + "license": "Apache-2.0", + "dependencies": [ + { + "name": "vcpkg-cmake", + "host": true + }, + { + "name": "vcpkg-cmake-config", + "host": true + }, + { + "name": "vcpkg-cmake-get-vars", + "host": true + } + ], + "features": { + "fips": { + "description": "Enable fips", + "supports": "!static" + }, + "ssl3": { + "description": "Enable SSL3" + }, + "tools": { + "description": "Install openssl executable and scripts", + "supports": "!uwp" + }, + "weak-ssl-ciphers": { + "description": "Enable weak-ssl-ciphers" + } + } +} diff --git a/vcpkg/ports/openssl/windows/install-layout.patch b/vcpkg/ports/openssl/windows/install-layout.patch new file mode 100644 index 000000000..6706f2404 --- /dev/null +++ b/vcpkg/ports/openssl/windows/install-layout.patch @@ -0,0 +1,13 @@ +diff --git a/Configurations/windows-makefile.tmpl b/Configurations/windows-makefile.tmpl +index f71f3bf..116954f 100644 +--- a/Configurations/windows-makefile.tmpl ++++ b/Configurations/windows-makefile.tmpl +@@ -223,7 +223,7 @@ MODULESDIR_dev={- use File::Spec::Functions qw(:DEFAULT splitpath catpath); + splitpath($modulesprefix, 1); + our $modulesdir_dev = $modulesprefix_dev; + our $modulesdir_dir = +- catdir($modulesprefix_dir, "ossl-modules"); ++ catdir($modulesprefix_dir, "../bin"); # change lib/ossl-modules to bin + our $modulesdir = catpath($modulesdir_dev, $modulesdir_dir); + our $enginesdir_dev = $modulesprefix_dev; + our $enginesdir_dir = diff --git a/vcpkg/ports/openssl/windows/install-pdbs.patch b/vcpkg/ports/openssl/windows/install-pdbs.patch new file mode 100644 index 000000000..552545407 --- /dev/null +++ b/vcpkg/ports/openssl/windows/install-pdbs.patch @@ -0,0 +1,46 @@ +diff --git a/Configurations/windows-makefile.tmpl b/Configurations/windows-makefile.tmpl +index 5946c89..f71f3bf 100644 +--- a/Configurations/windows-makefile.tmpl ++++ b/Configurations/windows-makefile.tmpl +@@ -564,8 +564,9 @@ + "$(INSTALLTOP)\include\openssl" + @"$(PERL)" "$(SRCDIR)\util\mkdir-p.pl" "$(libdir)" + @"$(PERL)" "$(SRCDIR)\util\copy.pl" $(INSTALL_LIBS) "$(libdir)" + @if "$(SHLIBS)"=="" \ ++ @if "$(INSTALL_PDBS)"=="ON" \ + "$(PERL)" "$(SRCDIR)\util\copy.pl" ossl_static.pdb "$(libdir)" + @"$(PERL)" "$(SRCDIR)\util\mkdir-p.pl" "$(CMAKECONFIGDIR)" + @"$(PERL)" "$(SRCDIR)\util\copy.pl" $(INSTALL_EXPORTERS_CMAKE) "$(CMAKECONFIGDIR)" + +@@ -569,6 +570,7 @@ install_engines: _install_modules_deps + @if not "$(INSTALL_ENGINES)"=="" \ + "$(PERL)" "$(SRCDIR)\util\copy.pl" $(INSTALL_ENGINES) "$(ENGINESDIR)" + @if not "$(INSTALL_ENGINES)"=="" \ ++ @if "$(INSTALL_PDBS)"=="ON" \ + "$(PERL)" "$(SRCDIR)\util\copy.pl" $(INSTALL_ENGINEPDBS) "$(ENGINESDIR)" + + uninstall_engines: +@@ -580,6 +582,7 @@ install_modules: _install_modules_deps + @if not "$(INSTALL_MODULES)"=="" \ + "$(PERL)" "$(SRCDIR)\util\copy.pl" $(INSTALL_MODULES) "$(MODULESDIR)" + @if not "$(INSTALL_MODULES)"=="" \ ++ @if "$(INSTALL_PDBS)"=="ON" \ + "$(PERL)" "$(SRCDIR)\util\copy.pl" $(INSTALL_MODULEPDBS) "$(MODULESDIR)" + + uninstall_modules: +@@ -593,6 +596,7 @@ install_runtime_libs: build_libs + @if not "$(SHLIBS)"=="" \ + "$(PERL)" "$(SRCDIR)\util\copy.pl" $(INSTALL_SHLIBS) "$(INSTALLTOP)\bin" + @if not "$(SHLIBS)"=="" \ ++ @if "$(INSTALL_PDBS)"=="ON" \ + "$(PERL)" "$(SRCDIR)\util\copy.pl" $(INSTALL_SHLIBPDBS) \ + "$(INSTALLTOP)\bin" + +@@ -605,6 +609,7 @@ install_programs: install_runtime_libs build_programs + "$(PERL)" "$(SRCDIR)\util\copy.pl" $(INSTALL_PROGRAMS) \ + "$(INSTALLTOP)\bin" + @if not "$(INSTALL_PROGRAMS)"=="" \ ++ @if "$(INSTALL_PDBS)"=="ON" \ + "$(PERL)" "$(SRCDIR)\util\copy.pl" $(INSTALL_PROGRAMPDBS) \ + "$(INSTALLTOP)\bin" + @if not "$(INSTALL_PROGRAMS)"=="" \ diff --git a/vcpkg/ports/openssl/windows/install-programs.diff b/vcpkg/ports/openssl/windows/install-programs.diff new file mode 100644 index 000000000..257ad7d3c --- /dev/null +++ b/vcpkg/ports/openssl/windows/install-programs.diff @@ -0,0 +1,13 @@ +diff --git a/Configurations/windows-makefile.tmpl b/Configurations/windows-makefile.tmpl +index b587212..c031cfb 100644 +--- a/Configurations/windows-makefile.tmpl ++++ b/Configurations/windows-makefile.tmpl +@@ -134,7 +134,7 @@ INSTALL_FIPSMODULE={- + -} + INSTALL_FIPSMODULECONF=providers\fipsmodule.cnf + INSTALL_PROGRAMS={- +- join(" ", map { quotify1(platform->bin($_)) } ++ join(" ", map { platform->bin($_) } + grep { !$unified_info{attributes}->{programs}->{$_}->{noinst} } + @{$unified_info{programs}}) + -} diff --git a/vcpkg/ports/openssl/windows/portfile.cmake b/vcpkg/ports/openssl/windows/portfile.cmake new file mode 100644 index 000000000..d2cc1f5d3 --- /dev/null +++ b/vcpkg/ports/openssl/windows/portfile.cmake @@ -0,0 +1,187 @@ +# Need cmd to pass quoted CC from nmake to mkbuildinf.pl, GH-37134 +find_program(CMD_EXECUTABLE cmd HINTS ENV PATH NO_DEFAULT_PATH REQUIRED) +cmake_path(NATIVE_PATH CMD_EXECUTABLE cmd) +set(ENV{COMSPEC} "${cmd}") + +vcpkg_find_acquire_program(PERL) +get_filename_component(PERL_EXE_PATH "${PERL}" DIRECTORY) +vcpkg_add_to_path("${PERL_EXE_PATH}") + +vcpkg_cmake_get_vars(cmake_vars_file) +include("${cmake_vars_file}") + +if(VCPKG_TARGET_ARCHITECTURE STREQUAL "x86") + set(OPENSSL_ARCH VC-WIN32) +elseif(VCPKG_TARGET_ARCHITECTURE STREQUAL "x64") + set(OPENSSL_ARCH VC-WIN64A) +elseif(VCPKG_TARGET_ARCHITECTURE STREQUAL "arm") + set(OPENSSL_ARCH VC-WIN32-ARM) +elseif(VCPKG_TARGET_ARCHITECTURE STREQUAL "arm64") + if(VCPKG_TARGET_IS_UWP) + set(OPENSSL_ARCH VC-WIN64-ARM) + elseif(VCPKG_DETECTED_CMAKE_C_COMPILER_ID MATCHES "Clang") + set(OPENSSL_ARCH VC-CLANG-WIN64-CLANGASM-ARM) + else() + set(OPENSSL_ARCH VC-WIN64-CLANGASM-ARM) + endif() +else() + message(FATAL_ERROR "Unsupported target architecture: ${VCPKG_TARGET_ARCHITECTURE}") +endif() + +if(VCPKG_TARGET_IS_UWP) + vcpkg_list(APPEND CONFIGURE_OPTIONS + no-unit-test + no-asm + no-uplink + ) + string(APPEND OPENSSL_ARCH "-UWP") +endif() + +if(VCPKG_CONCURRENCY GREATER "1") + vcpkg_list(APPEND CONFIGURE_OPTIONS no-makedepend) +endif() + +cmake_path(NATIVE_PATH CURRENT_PACKAGES_DIR NORMALIZE current_packages_dir_native) + +# Clang always uses /Z7; Patching /Zi /Fd out of openssl requires more work. +set(OPENSSL_BUILD_MAKES_PDBS ON) +if (VCPKG_DETECTED_CMAKE_C_COMPILER_ID MATCHES "Clang" OR VCPKG_LIBRARY_LINKAGE STREQUAL "static") + set(OPENSSL_BUILD_MAKES_PDBS OFF) +endif() + +cmake_path(NATIVE_PATH VCPKG_DETECTED_CMAKE_C_COMPILER NORMALIZE cc) +if(OPENSSL_ARCH MATCHES "CLANG") + vcpkg_find_acquire_program(CLANG) + cmake_path(GET CLANG PARENT_PATH clang_path) + vcpkg_add_to_path("${clang_path}") + if(VCPKG_DETECTED_CMAKE_C_COMPILER_ID MATCHES "Clang") + string(APPEND VCPKG_COMBINED_C_FLAGS_DEBUG " --target=aarch64-win32-msvc") + string(APPEND VCPKG_COMBINED_C_FLAGS_RELEASE " --target=aarch64-win32-msvc") + endif() +endif() +if(OPENSSL_ARCH MATCHES "CLANGASM") + vcpkg_list(APPEND CONFIGURE_OPTIONS "ASFLAGS=--target=aarch64-win32-msvc") +else() + vcpkg_find_acquire_program(NASM) + cmake_path(NATIVE_PATH NASM NORMALIZE as) + cmake_path(GET NASM PARENT_PATH nasm_path) + vcpkg_add_to_path("${nasm_path}") # Needed by Configure +endif() + +cmake_path(NATIVE_PATH VCPKG_DETECTED_CMAKE_AR NORMALIZE ar) +cmake_path(NATIVE_PATH VCPKG_DETECTED_CMAKE_LINKER NORMALIZE ld) + +# We can't set openssldir because that would leak build machine information into the built binaries, +# and introduce vulnerabilities where OpenSSL would search those locations at runtime, potentially +# unexpectedly loading code from there. For example CVE-2019-12572 +# +# Put the built bits in subdirectories with DESTDIR then move them where they go after the fact +# instead. +vcpkg_build_nmake( + SOURCE_PATH "${SOURCE_PATH}" + PREFER_JOM + CL_LANGUAGE NONE + PRERUN_SHELL_RELEASE "${PERL}" Configure + ${CONFIGURE_OPTIONS} + ${OPENSSL_ARCH} + "AS=${as}" + "CC=${cc}" + "CFLAGS=${VCPKG_COMBINED_C_FLAGS_RELEASE}" + "AR=${ar}" + "ARFLAGS=${VCPKG_COMBINED_STATIC_LINKER_FLAGS_RELEASE}" + "LD=${ld}" + "LDFLAGS=${VCPKG_COMBINED_SHARED_LINKER_FLAGS_RELEASE}" + PRERUN_SHELL_DEBUG "${PERL}" Configure + ${CONFIGURE_OPTIONS} + ${OPENSSL_ARCH} + --debug + "AS=${as}" + "CC=${cc}" + "CFLAGS=${VCPKG_COMBINED_C_FLAGS_DEBUG}" + "AR=${ar}" + "ARFLAGS=${VCPKG_COMBINED_STATIC_LINKER_FLAGS_DEBUG}" + "LD=${ld}" + "LDFLAGS=${VCPKG_COMBINED_SHARED_LINKER_FLAGS_DEBUG}" + PROJECT_NAME "makefile" + TARGET install_dev install_modules ${INSTALL_FIPS} + LOGFILE_ROOT install + OPTIONS + "INSTALL_PDBS=${OPENSSL_BUILD_MAKES_PDBS}" # install-pdbs.patch + OPTIONS_RELEASE + "DESTDIR=${current_packages_dir_native}" + install_runtime install_ssldirs # extra targets + OPTIONS_DEBUG + "DESTDIR=${current_packages_dir_native}/debug" +) + +function(z_rearrange_openssl_dirs) + cmake_parse_arguments(PARSE_ARGV 0 arg "" "OUT_PROGRAM_FILES_DIR;FLAVOR_PREFIX" "") + + if(DEFINED arg_UNPARSED_ARGUMENTS) + message(FATAL_ERROR "z_rearrange_openssl_dirs was passed extra arguments: ${arg_UNPARSED_ARGUMENTS}") + endif() + + # The resulting directory will contain something like "Program Files" or "Program Files (x86)"; + # globbing here to be architecture agnostic + set(prefix_packages_dir "${CURRENT_PACKAGES_DIR}${arg_FLAVOR_PREFIX}") + file(GLOB flavor_programfiles_dir LIST_DIRECTORIES true "${prefix_packages_dir}/Program*") + if(NOT flavor_programfiles_dir) + message(FATAL_ERROR "${flavor_programfiles_dir}: error: couldn't find program files dir") + endif() + + if(DEFINED arg_OUT_PROGRAM_FILES_DIR) + set("${arg_OUT_PROGRAM_FILES_DIR}" "${flavor_programfiles_dir}" PARENT_SCOPE) + endif() + + set(flavor_openssl_dir "${flavor_programfiles_dir}/OpenSSL") + if(NOT EXISTS "${flavor_openssl_dir}") + message(FATAL_ERROR "${flavor_openssl_dir}: should exist and be OpenSSLDir") + endif() + + # ideally we would use RENAME rather than COPY and REMOVE_RECURSE but CMake doesn't have an out + # of the box way to do that correctly merging directories + file(GLOB flavor_openssl_dirs LIST_DIRECTORIES true "${flavor_openssl_dir}/*") + file(COPY ${flavor_openssl_dirs} DESTINATION "${prefix_packages_dir}") + file(REMOVE_RECURSE "${flavor_openssl_dir}") +endfunction() + +z_rearrange_openssl_dirs(FLAVOR_PREFIX "" OUT_PROGRAM_FILES_DIR release_programfiles) +if(NOT VCPKG_BUILD_TYPE) + z_rearrange_openssl_dirs(FLAVOR_PREFIX "/debug" OUT_PROGRAM_FILES_DIR debug_programfiles) + file(REMOVE_RECURSE "${CURRENT_PACKAGES_DIR}/debug/include") + file(REMOVE_RECURSE "${debug_programfiles}") + file(REMOVE "${CURRENT_PACKAGES_DIR}/debug/bin/c_rehash.pl") +endif() + +set(scripts "bin/c_rehash.pl" "misc/CA.pl" "misc/tsget.pl") +if("tools" IN_LIST FEATURES) + file(MAKE_DIRECTORY "${CURRENT_PACKAGES_DIR}/tools/${PORT}") + file(COPY_FILE "${release_programfiles}/Common Files/SSL/openssl.cnf" "${CURRENT_PACKAGES_DIR}/tools/${PORT}/openssl.cnf") + if("fips" IN_LIST FEATURES) + file(COPY_FILE "${release_programfiles}/Common Files/SSL/fipsmodule.cnf" "${CURRENT_PACKAGES_DIR}/tools/${PORT}/fipsmodule.cnf") + endif() + + file(RENAME "${CURRENT_PACKAGES_DIR}/bin/c_rehash.pl" "${CURRENT_PACKAGES_DIR}/tools/${PORT}/c_rehash.pl") + file(RENAME "${release_programfiles}/Common Files/SSL/misc/CA.pl" "${CURRENT_PACKAGES_DIR}/tools/${PORT}/CA.pl") + file(RENAME "${release_programfiles}/Common Files/SSL/misc/tsget.pl" "${CURRENT_PACKAGES_DIR}/tools/${PORT}/tsget.pl") + vcpkg_copy_tools(TOOL_NAMES openssl AUTO_CLEAN) +else() + file(REMOVE + "${CURRENT_PACKAGES_DIR}/bin/c_rehash.pl" + "${release_programfiles}/Common Files/SSL/misc/CA.pl" + "${release_programfiles}/Common Files/SSL/misc/tsget.pl" + ) + + if(VCPKG_LIBRARY_LINKAGE STREQUAL "static") + file(REMOVE_RECURSE "${CURRENT_PACKAGES_DIR}/bin" "${CURRENT_PACKAGES_DIR}/debug/bin") + endif() +endif() + +vcpkg_copy_pdbs() +vcpkg_cmake_config_fixup() + +file(REMOVE_RECURSE # to pass empty directories check + "${release_programfiles}/Common Files/SSL/certs" + "${release_programfiles}/Common Files/SSL/misc" + "${release_programfiles}/Common Files/SSL/private" +) diff --git a/vcpkg/ports/sdl2/aetherkiri-ios-opengles-isolation.patch b/vcpkg/ports/sdl2/aetherkiri-ios-opengles-isolation.patch new file mode 100644 index 000000000..9843851f6 --- /dev/null +++ b/vcpkg/ports/sdl2/aetherkiri-ios-opengles-isolation.patch @@ -0,0 +1,116 @@ +diff --git a/src/video/uikit/SDL_uikitopengles.h b/src/video/uikit/SDL_uikitopengles.h +index 0533f69..c00ee77 100644 +--- a/src/video/uikit/SDL_uikitopengles.h ++++ b/src/video/uikit/SDL_uikitopengles.h +@@ -34,6 +34,9 @@ extern SDL_GLContext UIKit_GL_CreateContext(_THIS, SDL_Window * window); + extern void UIKit_GL_DeleteContext(_THIS, SDL_GLContext context); + extern void *UIKit_GL_GetProcAddress(_THIS, const char *proc); + extern int UIKit_GL_LoadLibrary(_THIS, const char *path); ++#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST ++extern void UIKit_GL_UnloadLibrary(_THIS); ++#endif + + extern void UIKit_GL_RestoreCurrentContext(void); + +diff --git a/src/video/uikit/SDL_uikitopengles.m b/src/video/uikit/SDL_uikitopengles.m +index 988966d..01f3eb0 100644 +--- a/src/video/uikit/SDL_uikitopengles.m ++++ b/src/video/uikit/SDL_uikitopengles.m +@@ -54,10 +54,17 @@ + + void *UIKit_GL_GetProcAddress(_THIS, const char *proc) + { +- /* Look through all SO's for the proc symbol. Here's why: +- * -Looking for the path to the OpenGL Library seems not to work in the iOS Simulator. +- * -We don't know that the path won't change in the future. */ ++#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST ++ /* The host can also load an isolated MetalANGLE runtime. Resolve only ++ * Apple's framework, whose EAGLContext owns this backend's contexts. */ ++ if (!_this->gl_config.dll_handle) { ++ SDL_SetError("Apple OpenGLES library is not loaded"); ++ return NULL; ++ } ++ return SDL_LoadFunction(_this->gl_config.dll_handle, proc); ++#else + return dlsym(RTLD_DEFAULT, proc); ++#endif + } + + /* +@@ -106,9 +113,35 @@ int UIKit_GL_LoadLibrary(_THIS, const char *path) + if (path != NULL) { + return SDL_SetError("iOS GL Load Library just here for compatibility"); + } ++#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST ++ if (_this->gl_config.dll_handle) { ++ return 0; ++ } ++ @autoreleasepool { ++ /* NSBundle locates the loaded Apple framework in the actual device ++ * or Simulator runtime instead of assuming a system filesystem path. */ ++ NSBundle *framework = [NSBundle bundleForClass:[EAGLContext class]]; ++ NSString *executable = [framework executablePath]; ++ if (!executable) { ++ return SDL_SetError("Could not locate Apple's loaded OpenGLES framework"); ++ } ++ _this->gl_config.dll_handle = SDL_LoadObject([executable fileSystemRepresentation]); ++ if (!_this->gl_config.dll_handle) { ++ return -1; ++ } ++ } ++#endif + return 0; + } + ++#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST ++void UIKit_GL_UnloadLibrary(_THIS) ++{ ++ SDL_UnloadObject(_this->gl_config.dll_handle); ++ _this->gl_config.dll_handle = NULL; ++} ++#endif ++ + int UIKit_GL_SwapWindow(_THIS, SDL_Window * window) + { + @autoreleasepool { +diff --git a/src/video/uikit/SDL_uikitvideo.m b/src/video/uikit/SDL_uikitvideo.m +index f7aa3fd..9120779 100644 +--- a/src/video/uikit/SDL_uikitvideo.m ++++ b/src/video/uikit/SDL_uikitvideo.m +@@ -121,6 +121,9 @@ static SDL_VideoDevice *UIKit_CreateDevice(void) + device->GL_DeleteContext = UIKit_GL_DeleteContext; + device->GL_GetProcAddress = UIKit_GL_GetProcAddress; + device->GL_LoadLibrary = UIKit_GL_LoadLibrary; ++#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST ++ device->GL_UnloadLibrary = UIKit_GL_UnloadLibrary; ++#endif + #endif + device->free = UIKit_DeleteDevice; + +@@ -155,6 +158,13 @@ VideoBootStrap UIKIT_bootstrap = { + + int UIKit_VideoInit(_THIS) + { ++#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST && (defined(SDL_VIDEO_OPENGL_ES) || defined(SDL_VIDEO_OPENGL_ES2)) ++ /* UIKit preloads its GL driver, so acquire the concrete framework handle ++ * before generic SDL_GL_LoadLibrary increments that initial reference. */ ++ if (UIKit_GL_LoadLibrary(_this, NULL) < 0) { ++ return -1; ++ } ++#endif + _this->gl_config.driver_loaded = 1; + + if (UIKit_InitModes(_this) < 0) { +@@ -173,6 +183,12 @@ void UIKit_VideoQuit(_THIS) + SDL_QuitGCMouse(); + + UIKit_QuitModes(_this); ++#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST && (defined(SDL_VIDEO_OPENGL_ES) || defined(SDL_VIDEO_OPENGL_ES2)) ++ /* SDL_VideoQuit frees the device even when no SDL window was created. ++ * Release its preloaded reference and any remaining explicit load refs. */ ++ UIKit_GL_UnloadLibrary(_this); ++ _this->gl_config.driver_loaded = 0; ++#endif + } + + void UIKit_SuspendScreenSaver(_THIS) diff --git a/vcpkg/ports/sdl2/portfile.cmake b/vcpkg/ports/sdl2/portfile.cmake index 593bb4877..4ddafa165 100644 --- a/vcpkg/ports/sdl2/portfile.cmake +++ b/vcpkg/ports/sdl2/portfile.cmake @@ -9,6 +9,7 @@ vcpkg_from_github( alsa-dep-fix.patch cxx-linkage-pkgconfig.diff aetherkiri-embedded-android-jni.patch + aetherkiri-ios-opengles-isolation.patch ) string(COMPARE EQUAL "${VCPKG_LIBRARY_LINKAGE}" "static" SDL_STATIC)