diff --git a/.github/workflows/android-compat.yml b/.github/workflows/android-compat.yml new file mode 100644 index 000000000..70f652df2 --- /dev/null +++ b/.github/workflows/android-compat.yml @@ -0,0 +1,503 @@ +name: Android compatibility + +# One APK, every Android version we claim to support. +# +# `native-smoke` asks whether the templates can produce a launching app at all, +# and it asks it on ONE platform version — API 34. That leaves the failure this +# exists for completely uncovered: a host that runs on the version CI happens to +# boot and dies on the ones users have. A packaged game is built ONCE here and +# installed on one emulator per Android release, so a version-specific break is +# attributed to the version rather than to the build. +# +# The frames are collected to be LOOKED AT. There is deliberately no pixel +# judgement: a scene that is legitimately dark and a renderer that died produce +# the same dark PNG, and no threshold separates them. What this gates is what can +# be decided without a human — did it install, did the host reach `ready`, did the +# boot record name an error. Everything else is measured, tabulated, and left for +# a reviewer. +# +# The version list is DISCOVERED from the runner's own system images rather than +# written down, so a new Android release is picked up by the next run instead of +# by whoever remembers to edit this file. Preview releases are named by codename +# and are skipped on purpose — a matrix that reds on an unreleased platform gets +# ignored within a week. +# +# What a hosted runner cannot see: real GPU drivers, compressed texture support, +# thermal throttling and memory pressure. Dawn runs on the emulator's SwiftShader, +# so the frame times are a CPU rasteriser's. Comparing versions against each other +# is valid; reading any number here as device performance is not. The report says +# so on every run rather than trusting anyone to remember it. + +on: + pull_request: + paths: + - 'native/**' + - 'build-tools/**' + - 'sdk/**' + - 'toolchain.manifest.json' + - '.github/workflows/android-compat.yml' + - 'tools/verify-native-boot.mjs' + - 'tools/android-compat-report.mjs' + workflow_dispatch: + inputs: + examples: + description: 'Comma-separated example names to package and run on every version.' + required: false + default: 'space-shooter' + type: string + api-floor: + description: 'Oldest API level to test (29 = Android 10).' + required: false + default: '29' + type: string + template-source: + description: 'Which runtime template to wrap: the latest release, this branch (~35 min), or decide from what the PR touches.' + required: false + default: auto + type: choice + options: [auto, release, head] + +permissions: + contents: read + +# One matrix per PR at a time. `paths` on a pull_request matches the PR's whole +# diff, not the latest push, so every push re-runs all eight emulators — three +# pushes queued twenty-four of them before this was here. +concurrency: + group: android-compat-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + # Android 10. The manifest claims 26, which is a wider promise than anything + # here checks — raising this floor is a decision about what we support, so it is + # stated once, in one place, rather than implied by a matrix. + ANDROID_API_FLOOR: ${{ inputs.api-floor || '29' }} + ANDROID_PROFILE: pixel_6 + CANARY_EXAMPLES: ${{ inputs.examples || 'space-shooter' }} + +jobs: + # --------------------------------------------------------------------------- + versions: + name: Which Android versions can this runner boot + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + matrix: ${{ steps.find.outputs.matrix }} + count: ${{ steps.find.outputs.count }} + steps: + - name: List the system images the SDK actually offers + id: find + run: | + set -euo pipefail + SDKMANAGER="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" + # Digits only, so preview platforms (android-Baklava and the like) are + # left out rather than reddening the matrix on an unreleased release. + AVAILABLE=$("$SDKMANAGER" --list 2>/dev/null \ + | grep -oE 'system-images;android-[0-9]+;google_apis;x86_64' \ + | grep -oE 'android-[0-9]+' | grep -oE '[0-9]+' | sort -un || true) + + if [ -z "$AVAILABLE" ]; then + echo "::error::sdkmanager listed no google_apis;x86_64 system images at all." + exit 1 + fi + + LEVELS=$(echo "$AVAILABLE" | awk -v floor="$ANDROID_API_FLOOR" '$1 >= floor') + if [ -z "$LEVELS" ]; then + echo "::error::no system image at or above API $ANDROID_API_FLOOR. This runner has: $(echo $AVAILABLE | tr '\n' ' ')" + exit 1 + fi + + # The floor itself must exist. If the runner image stops carrying the + # oldest version we claim to support, that is a silent loss of coverage + # unless it fails here and says so. + if ! echo "$LEVELS" | grep -qx "$ANDROID_API_FLOOR"; then + echo "::error::API $ANDROID_API_FLOOR (the declared floor) has no system image on this runner. Available: $(echo $LEVELS | tr '\n' ' ')" + exit 1 + fi + + MATRIX=$(echo "$LEVELS" | jq -Rsc 'split("\n") | map(select(length > 0) | tonumber)') + echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT" + echo "count=$(echo "$LEVELS" | wc -l | tr -d ' ')" >> "$GITHUB_OUTPUT" + echo "Testing API levels: $(echo $LEVELS | tr '\n' ' ')" + + # --------------------------------------------------------------------------- + apk: + name: Package the canary example(s) + runs-on: ubuntu-latest + timeout-minutes: 120 + permissions: + contents: read + pull-requests: read # to see which files the PR touches + outputs: + # Reported alongside the numbers: "the shipped binary" and "a build of this + # branch" are different claims, and a table that does not say which it made + # is a table nobody can act on. + template-from: ${{ steps.src.outputs.from }} + steps: + # Submodules only matter to a from-source build; the released template + # carries its libraries already. Fetched unconditionally because a + # conditional checkout is a second way for the two paths to diverge. + - uses: actions/checkout@v7 + with: + submodules: recursive + + - uses: ./.github/actions/setup + + # The SDK bundle is compiled INTO the host binary, so a template built here + # needs it — and the export bundles the game against it either way. + - name: Build SDK + run: pnpm --filter ./sdk build + + # Building the host takes ~35 minutes (Dawn, once per ABI) and produces + # something byte-for-byte irrelevant unless the host source actually changed. + # A released template was already built, on a machine that did nothing else, + # and it has the stronger claim besides: it is the binary users installed, so + # a crash reproduced against it is THE crash rather than a rebuild of it. + # + # So: from source only when this PR touches the host, and from the release + # otherwise. Getting this backwards is silent — a native fix "verified" + # against a template that predates it — hence the decision is logged. + - name: Decide where the template comes from + id: src + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + WANT='${{ inputs.template-source }}' + if [ -z "$WANT" ] || [ "$WANT" = 'auto' ]; then + if [ "$GITHUB_EVENT_NAME" = 'pull_request' ]; then + CHANGED=$(gh pr diff '${{ github.event.pull_request.number }}' --name-only) + # build-tools/ counts: the API level the host compiles against is a + # DEFAULT in build-tools, not a value in native/. The change that fixed + # the Android 10 crash touched nothing under native/, so a rule that + # watched only native/ would have tested the old released binary and + # reported the fix as not working. + if echo "$CHANGED" | grep -qE '^(native/|build-tools/|toolchain\.manifest\.json)'; then + WANT=head + echo "this PR touches the native host — the template must be built from it" + else + WANT=release + echo "this PR touches no native host source — using the released template" + fi + else + WANT=release + fi + fi + echo "from=$WANT" >> "$GITHUB_OUTPUT" + + - name: Take the template from the latest release + if: steps.src.outputs.from == 'release' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + TAG=$(gh release view --repo "$GITHUB_REPOSITORY" --json tagName -q .tagName) + echo "template from $TAG" + mkdir -p artifacts + gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \ + -p 'estella-native-android-*.zip' -D artifacts + + - name: Read the native dependency pins + if: steps.src.outputs.from == 'head' + id: pins + run: | + node -e "const p=require('./toolchain.manifest.json').native;\ + console.log('dawn='+p.dawn.commit);console.log('quickjs='+p.quickjs.commit)" >> "$GITHUB_OUTPUT" + + # restore-keys without the pins, because a Dawn build for a DIFFERENT pin is + # still most of this one's object files. Note the release pipeline's own cache + # is written from a tag ref and is therefore unreachable from any branch — + # see the PR discussion; warming this from master is separate work. + - name: Cache the pinned checkouts + Dawn builds + if: steps.src.outputs.from == 'head' + uses: actions/cache@v4 + with: + path: build/native-deps + key: native-deps-android-${{ steps.pins.outputs.dawn }}-${{ steps.pins.outputs.quickjs }} + restore-keys: native-deps-android- + + - name: Set up the Android NDK + if: steps.src.outputs.from == 'head' + uses: nttld/setup-ndk@v1 + id: ndk + with: + ndk-version: r28 + + - name: Fetch Dawn + QuickJS at their pinned commits + if: steps.src.outputs.from == 'head' + run: node build-tools/cli.js native --fetch-deps + + # BOTH ABIs, even though every emulator here is x86_64: ANDROID_ABIS names + # arm64-v8a and x86_64 as what a template must carry, and the emitter refuses + # an incomplete one. An x86_64-only build fails at the emit step, not at the + # compile — which is exactly how this was found. + - name: Build the Android runtime template from this branch + if: steps.src.outputs.from == 'head' + env: + ANDROID_NDK_HOME: ${{ steps.ndk.outputs.ndk-path }} + run: | + node build-tools/cli.js native --abi arm64-v8a + node build-tools/cli.js native --abi x86_64 --template-out artifacts + + - name: Unpack the template + run: | + node build-tools/cli.js verify-template artifacts/*.zip + mkdir -p template + unzip -q artifacts/*.zip -d template + + # Packaged the way the editor's Package dialog does it, once: every version + # in the matrix installs the identical file, so a difference between two rows + # cannot be a difference between two builds. + - name: Package the canary example(s) + run: | + set -euo pipefail + mkdir -p apks + IFS=',' read -ra NAMES <<< "$CANARY_EXAMPLES" + for name in "${NAMES[@]}"; do + name=$(echo "$name" | xargs) + [ -z "$name" ] && continue + echo "::group::packaging $name" + node desktop/scripts/export-project.mjs "examples/$name" \ + --platform android --template template \ + --out "build/pkg/$name" --json "build/pkg/$name.json" + APK=$(node -e "process.stdout.write(require('./build/pkg/$name.json').apkFile || '')") + if [ -z "$APK" ]; then + echo "::error::the export of $name wrote no APK" + exit 1 + fi + cp "$APK" "apks/$name.apk" + echo "::endgroup::" + done + ls -la apks + + - uses: actions/upload-artifact@v7 + with: + name: compat-apks + path: apks/*.apk + retention-days: 3 + + # --------------------------------------------------------------------------- + compat: + name: Android API ${{ matrix.api }} + needs: [versions, apk] + runs-on: ubuntu-latest + # A version that works finishes in about three minutes and one that crashes in + # seven. API 29 once failed to boot at all and sat here for the full 45, so the + # cap is set near what a real run costs: a stuck emulator should be reported as + # "no data" quickly, not held open in case it recovers. + timeout-minutes: 20 + strategy: + # A compatibility matrix whose whole output is "which versions work" must + # run every version even after one fails. fail-fast here would report the + # oldest break and hide everything above it. + fail-fast: false + matrix: + api: ${{ fromJSON(needs.versions.outputs.matrix) }} + steps: + - uses: actions/checkout@v7 + + - uses: ./.github/actions/setup + + - uses: actions/download-artifact@v8 + with: + name: compat-apks + path: apks + + # Without KVM the emulator falls back to software CPU emulation and the boot + # alone outlasts the job. + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + # Attempted twice, because "the app crashed" and "the emulator never came up" + # both fail this step and only one of them is an answer. Older API levels + # download their system image and then sometimes never reach + # sys.boot_completed at all — API 29 and API 30 traded places across three + # runs, costing a version each time, which a matrix built to say "every + # version works" cannot afford to do at random. + # + # `continue-on-error` on the first go, and the retry is conditional on + # NOTHING having been filed: a metrics file means the script ran and reached + # a verdict, even a crash, and re-running that would only waste an emulator. + - name: Install, launch and measure on API ${{ matrix.api }} + id: attempt1 + continue-on-error: true + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api }} + target: google_apis + arch: x86_64 + profile: ${{ env.ANDROID_PROFILE }} + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -camera-front none -memory 4096 -cores 2 + # ONE line, because this action runs `script` as a separate `sh -c` per + # line: a loop written here arrives split and dies on its own `do`, and + # nothing set on one line is visible on the next. The work lives in a + # file that can also be run by hand. + script: bash tools/android-compat-run.sh ${{ matrix.api }} + + - name: Did anything get measured + id: measured + run: | + if ls build/compat/*.json >/dev/null 2>&1; then + echo "any=true" >> "$GITHUB_OUTPUT" + else + echo "any=false" >> "$GITHUB_OUTPUT" + echo "nothing was filed — the emulator never came up, so this is not yet a result" + fi + + - name: Try API ${{ matrix.api }} once more + if: steps.measured.outputs.any == 'false' + uses: reactivecircus/android-emulator-runner@v2 + continue-on-error: true + with: + api-level: ${{ matrix.api }} + target: google_apis + arch: x86_64 + profile: ${{ env.ANDROID_PROFILE }} + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -camera-front none -memory 4096 -cores 2 + script: bash tools/android-compat-run.sh ${{ matrix.api }} + + - name: Keep the frame, the record and the numbers + if: always() + uses: actions/upload-artifact@v7 + with: + name: compat-api${{ matrix.api }} + path: build/compat + if-no-files-found: warn + + # This job MEASURES; the report JUDGES. So it fails only when it measured + # nothing at all — a crashed version is a successful measurement of a broken + # platform, and letting that redden the job here would put the verdict in two + # places that can disagree. + - name: Fail only if this version was never measured + if: always() + run: | + if ls build/compat/*.json >/dev/null 2>&1; then + echo "measured API ${{ matrix.api }}" + else + echo "::error::API ${{ matrix.api }} produced no data after two attempts" + exit 1 + fi + + # --------------------------------------------------------------------------- + report: + name: Report into the PR + needs: [versions, apk, compat] + # always(): the versions that FAILED are the rows this report exists to show. + # A summariser that only runs on success reports exactly the case nobody needs. + if: always() && needs.versions.result == 'success' + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: write # publishes the frames to a branch the PR can render + pull-requests: write # posts the table + steps: + - uses: actions/checkout@v7 + + # The report itself is node builtins only, but the toolchain version it runs + # under is this action's to decide, not the runner image's. + - uses: ./.github/actions/setup + + - uses: actions/download-artifact@v8 + with: + pattern: compat-api* + path: build/compat + + # A fork's token is read-only, so neither the push nor the comment can work. + # Say that in the log and still build the report, rather than failing with a + # permissions error that looks like a broken workflow. + - name: Can this run write to the repo + id: writable + run: | + if [ "${{ github.event_name }}" = 'pull_request' ] \ + && [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.repository }}" ]; then + echo "same-repo=false" >> "$GITHUB_OUTPUT" + echo "PR is from a fork — the token cannot push frames or comment." + else + echo "same-repo=true" >> "$GITHUB_OUTPUT" + fi + + # GitHub renders an image in a comment only from a URL. Artifacts have none, + # so the frames go to an orphan branch and are referenced from there. The + # github.com/raw form (not raw.githubusercontent) is used because it resolves + # for a signed-in reviewer even when the repository is private. + - name: Publish the frames where the PR can show them + id: shots + if: steps.writable.outputs.same-repo == 'true' + env: + BRANCH: ci-android-shots + DEST: pr-${{ github.event.pull_request.number || 'dispatch' }}/${{ github.run_id }} + run: | + set -euo pipefail + shopt -s nullglob globstar + FRAMES=(build/compat/**/*.png) + if [ ${#FRAMES[@]} -eq 0 ]; then + echo "no frames were captured — nothing to publish" + exit 0 + fi + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then + git fetch --depth=1 origin "$BRANCH:$BRANCH" + git worktree add shots "$BRANCH" + else + git worktree add --detach shots + git -C shots checkout --orphan "$BRANCH" + git -C shots rm -rf --cached . >/dev/null 2>&1 || true + rm -rf shots/* 2>/dev/null || true + fi + mkdir -p "shots/$DEST" + cp "${FRAMES[@]}" "shots/$DEST/" + git -C shots add -A + git -C shots commit -q -m "ci: android compat frames for ${DEST}" + git -C shots push -q origin "$BRANCH" + echo "base=https://github.com/${{ github.repository }}/raw/$BRANCH/$DEST" >> "$GITHUB_OUTPUT" + + # continue-on-error because a non-zero exit here IS the verdict — it means + # some version is broken or untested — and the comment still has to be + # posted. The gate at the end of this job reads the outcome. + - name: Build the table + id: table + continue-on-error: true + run: | + node tools/android-compat-report.mjs \ + --dir build/compat \ + --template-source '${{ needs.apk.outputs.template-from }}' \ + --expect '${{ needs.versions.outputs.matrix }}' \ + ${{ steps.shots.outputs.base && format('--shots-base {0}', steps.shots.outputs.base) || '' }} \ + --out compat-comment.md + cat compat-comment.md >> "$GITHUB_STEP_SUMMARY" + + # Edited in place on re-runs: a PR that pushes six times should carry one + # current table, not six stale ones. + - name: Post it on the PR + if: github.event_name == 'pull_request' && steps.writable.outputs.same-repo == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + EXISTING=$(gh api "repos/${{ github.repository }}/issues/$PR/comments" --paginate \ + --jq '.[] | select(.body | startswith("")) | .id' | head -1) + if [ -n "$EXISTING" ]; then + gh api -X PATCH "repos/${{ github.repository }}/issues/comments/$EXISTING" \ + -F body=@compat-comment.md >/dev/null + echo "updated comment $EXISTING" + else + gh api -X POST "repos/${{ github.repository }}/issues/$PR/comments" \ + -F body=@compat-comment.md >/dev/null + echo "posted a new comment" + fi + + # The gate, after the report rather than before it: a red check with no table + # tells a reviewer that something broke but not what. It reads the DATA, not + # the matrix jobs' conclusions — those cannot tell a crashed app from an + # emulator that never booted, and this distinction is the whole point. + - name: Fail if any version is broken or untested + if: steps.table.outcome == 'failure' + run: | + echo "::error::some Android version is broken or was never measured — see the table in the PR comment" + exit 1 diff --git a/.github/workflows/native-deps-cache.yml b/.github/workflows/native-deps-cache.yml new file mode 100644 index 000000000..a16bd40f0 --- /dev/null +++ b/.github/workflows/native-deps-cache.yml @@ -0,0 +1,125 @@ +name: Warm the native dependency cache + +# Dawn is ~16 minutes per ABI and the pins move a few times a year, so it should +# be built about as often as the pins move. It was in fact being built on every +# single release, and on every branch that needed it, because of where the cache +# was being written from. +# +# A GitHub cache is readable from the ref that created it, that ref's descendants, +# and the default branch. The release pipeline runs on a TAG — so the 937 MB +# android and 401 MB ios caches it saved (v0.36.0's and v0.37.0's, both still +# there) are readable by nothing at all. Not by a branch, not by a PR, and not by +# the next release either, since every new tag is a new scope. Each release paid +# for a cold Dawn build and then wrote its result somewhere it could never be read +# from again. +# +# Writing it from the default branch instead is the whole fix: a default-branch +# cache is in scope for every run in the repository. +# +# This is restore-first and builds only on a miss, which is what makes the weekly +# trigger cheap. GitHub evicts a cache untouched for 7 days, and a restore counts +# as touching it — so on the normal week this job downloads the cache, finds it +# complete, and stops. It rebuilds only when the pins actually moved. + +on: + push: + branches: [master] + paths: + # The only thing that invalidates the cache is the pins themselves. + - 'toolchain.manifest.json' + # And this file, so that merging it warms the cache once instead of leaving + # it cold until the pins next move or the weekly trigger comes round. A + # workflow_dispatch cannot stand in for that: dispatch only resolves against + # the default branch, so there is no way to run this before it is merged. + - '.github/workflows/native-deps-cache.yml' + schedule: + # Weekly, purely to keep the 7-day eviction clock from running out. + - cron: '0 3 * * 1' + workflow_dispatch: + inputs: + force-rebuild: + description: 'Build even if the cache already has this pin (for a corrupt entry).' + required: false + default: false + type: boolean + +permissions: + contents: read + +jobs: + warm: + name: Dawn for ${{ matrix.target }} + runs-on: ${{ matrix.os }} + timeout-minutes: 150 + strategy: + fail-fast: false + matrix: + include: + - target: android + os: ubuntu-latest + - target: ios + os: macos-latest + steps: + # No submodules: Dawn and QuickJS are fetched into build/native-deps by + # --fetch-deps, and third_party is the HOST's dependency, which this never + # builds. + - uses: actions/checkout@v7 + + - uses: ./.github/actions/setup + + - name: Read the native dependency pins + id: pins + run: | + node -e "const p=require('./toolchain.manifest.json').native;\ + console.log('dawn='+p.dawn.commit);console.log('quickjs='+p.quickjs.commit)" >> "$GITHUB_OUTPUT" + + # restore, not the combined action: a hit must end this job rather than + # trigger a save of what it just downloaded. + - name: Is this pin already built + id: restore + if: ${{ !inputs.force-rebuild }} + uses: actions/cache/restore@v4 + with: + path: build/native-deps + key: native-deps-${{ matrix.target }}-${{ steps.pins.outputs.dawn }}-${{ steps.pins.outputs.quickjs }} + + - name: Report what the restore found + run: | + if [ '${{ steps.restore.outputs.cache-hit }}' = 'true' ]; then + echo "cache is warm for these pins — the restore refreshed its eviction clock, nothing to build" + else + echo "no cache for these pins — building Dawn" + fi + + - name: Set up the Android NDK + if: matrix.target == 'android' && steps.restore.outputs.cache-hit != 'true' + uses: nttld/setup-ndk@v1 + id: ndk + with: + ndk-version: r28 + + # Dawn builds with Ninja and a macOS runner has neither it nor CMake; the + # Android SDK carries its own pair. + - name: Set up CMake + Ninja + if: matrix.target == 'ios' && steps.restore.outputs.cache-hit != 'true' + run: brew install cmake ninja + + - name: Fetch Dawn + QuickJS at their pinned commits + if: steps.restore.outputs.cache-hit != 'true' + run: node build-tools/cli.js native --fetch-deps + + # Every ABI (android) and every sysroot slice (ios) the consumers ask for. + # Warming one and leaving the other cold would look like a hit and behave + # like a miss. + - name: Build Dawn + if: steps.restore.outputs.cache-hit != 'true' + env: + ANDROID_NDK_HOME: ${{ steps.ndk.outputs.ndk-path }} + run: node build-tools/cli.js native --target ${{ matrix.target }} --build-deps + + - name: Save it for every ref in the repository + if: steps.restore.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: build/native-deps + key: native-deps-${{ matrix.target }}-${{ steps.pins.outputs.dawn }}-${{ steps.pins.outputs.quickjs }} diff --git a/build-tools/cli.js b/build-tools/cli.js index 9808f41ae..9332042a1 100644 --- a/build-tools/cli.js +++ b/build-tools/cli.js @@ -162,11 +162,12 @@ program .description('Build the native (embedded-Dawn) host for Android or iOS arm64') .option('--target ', 'android or ios', 'android') .option('--fetch-deps', 'Check out Dawn + QuickJS-ng at the pinned commits, then stop', false) + .option('--build-deps', 'Build Dawn for every ABI/slice the target needs, then stop (for warming a cache)', false) .option('--dawn ', 'Dawn source dir (default: the pinned checkout; or ESTELLA_DAWN_DIR)') .option('--dawn-build ', 'Dawn build dir for this target (default: /out-, built if absent)') .option('--quickjs ', 'QuickJS-ng source dir (default: the pinned checkout; or ESTELLA_QUICKJS_DIR)') .option('--abi ', 'Android ABI', 'arm64-v8a') - .option('--platform ', 'Android platform', 'android-33') + .option('--platform ', 'Android platform', 'android-29') .option('--ios-min ', 'iOS deployment target', '17.0') .option('--simulator', 'iOS: build the simulator slice (needs a simulator Dawn)', false) .option('--package', 'Assemble the app around --content from the installed runtime template: Android a signed APK, iOS an Xcode project', false) diff --git a/build-tools/tasks/native.js b/build-tools/tasks/native.js index 6409d3495..0831f24d8 100644 --- a/build-tools/tasks/native.js +++ b/build-tools/tasks/native.js @@ -18,7 +18,7 @@ import { requireSdk, requireNdk, sdkCmake } from '../utils/android.js'; import { emitNativeTemplate, writeTemplateIndex, readEngineVersion } from './nativeTemplateEmit.js'; import { fetchNativeDeps, pinnedDep, ensureDawnBuild, dawnLibrary, DAWN_TARGETS } from './nativeDeps.js'; import { - BYTECODE_FILE, findTemplate, iosTemplateSources, templateStoreDir, + ANDROID_ABIS, BYTECODE_FILE, findTemplate, iosTemplateSources, templateStoreDir, } from '../utils/nativeTemplate.js'; import { readAppConfig, fillTemplate, iosInterfaceOrientations } from '../utils/nativeApp.js'; import { emitIosXcodeProject } from '../utils/iosProject.js'; @@ -312,7 +312,14 @@ function quickjsDir(options) { const fwd = (p) => p.replace(/\\/g, '/'); async function buildAndroidHost(options) { - const { abi = 'arm64-v8a', platform = 'android-33' } = options; + // The platform MUST equal the manifest's minSdkVersion. The NDK emits a weak + // reference for an API newer than the target and a strong one otherwise, and + // `__builtin_available` is compiled out in the second case — so building above + // the declared floor turns every guard into dead code and every guarded symbol + // into a load-time requirement. At android-33 that shipped a host which could + // not dlopen below API 31: `cannot locate symbol APerformanceHint_getManager`, + // on Android 10 and 11, before a line of our code ran. + const { abi = 'arm64-v8a', platform = 'android-29' } = options; const rootDir = config.paths.root; const sdk = requireSdk(); @@ -644,8 +651,43 @@ async function emitFromExistingBuild(target, options) { }); } +/** + * Build the pinned dependencies for a target and stop. + * + * For a job that exists only to populate the dependency cache. Dawn is the whole + * cost — the host beside it is minutes — so building the host too would only add + * ways for a cache warm-up to fail for reasons that have nothing to do with what + * it is caching. + * + * EVERY build tree the consumers look for, not just the default one: Dawn's is + * per ABI on Android and per sysroot on iOS, so a warm-up that did one would + * leave the release cold in exactly the half it did not do. + */ +async function buildNativeDeps(options) { + const target = (options.target || 'android').toLowerCase(); + if (target === 'android') { + const sdk = requireSdk(); + const ndk = requireNdk(sdk); + const { cmake, ninja } = sdkCmake(sdk); + for (const abi of ANDROID_ABIS) { + logger.step(`Dawn for android/${abi}...`); + await dawnPaths({ ...options, abi }, 'android', { ndk, cmake, ninja }); + } + logger.success(`Dawn built for ${ANDROID_ABIS.join(', ')}`); + return; + } + const developerDir = await iosDeveloperDir(); + const env = developerDir ? { DEVELOPER_DIR: developerDir } : undefined; + for (const slice of ['ios', 'ios-sim']) { + logger.step(`Dawn for ${slice}...`); + await dawnPaths(options, slice, { env }); + } + logger.success('Dawn built for ios, ios-sim'); +} + export async function buildNative(options = {}) { if (options.fetchDeps) return fetchNativeDeps(options); + if (options.buildDeps) return buildNativeDeps(options); if (options.templateIndex) { return writeTemplateIndex(path.isAbsolute(options.templateIndex) ? options.templateIndex : path.join(config.paths.root, options.templateIndex)); diff --git a/build-tools/tasks/nativeDeps.js b/build-tools/tasks/nativeDeps.js index bf197606a..027ec349c 100644 --- a/build-tools/tasks/nativeDeps.js +++ b/build-tools/tasks/nativeDeps.js @@ -145,7 +145,7 @@ export async function ensureDawnBuild(options) { ? [ `-DCMAKE_TOOLCHAIN_FILE=${path.join(options.ndk, 'build', 'cmake', 'android.toolchain.cmake')}`, `-DANDROID_ABI=${options.abi || 'arm64-v8a'}`, - `-DANDROID_PLATFORM=${options.androidPlatform || 'android-33'}`, + `-DANDROID_PLATFORM=${options.androidPlatform || 'android-29'}`, '-DANDROID_STL=c++_shared', '-DDAWN_ENABLE_VULKAN=ON', '-DDAWN_ENABLE_METAL=OFF', // Shared on Android (the APK ships the .so); static on iOS (an app diff --git a/build-tools/tasks/nativeTemplateEmit.js b/build-tools/tasks/nativeTemplateEmit.js index 0dd9fbdad..1ce6293ab 100644 --- a/build-tools/tasks/nativeTemplateEmit.js +++ b/build-tools/tasks/nativeTemplateEmit.js @@ -200,7 +200,7 @@ export async function emitNativeTemplate(options) { spineVersion: options.spineVersion || '4.2', ...(platform === 'ios' ? { deploymentTarget: options.deploymentTarget || '17.0' } : {}), ...(platform === 'android' - ? { androidPlatform: options.androidPlatform || 'android-33', abis: templateAbis(dir) } + ? { androidPlatform: options.androidPlatform || 'android-29', abis: templateAbis(dir) } : {}), }); diff --git a/build-tools/utils/apk.js b/build-tools/utils/apk.js index 29447266a..25f9937de 100644 --- a/build-tools/utils/apk.js +++ b/build-tools/utils/apk.js @@ -28,7 +28,7 @@ import { fillTemplate, androidScreenOrientation } from './nativeApp.js'; * smaller page size also divides. */ const PAGE_ALIGNMENT = 16384; -/** APK Signature Scheme v2. minSdk 26 is well past the API 24 that introduced it, +/** APK Signature Scheme v2. minSdk 29 is well past the API 24 that introduced it, * so v1 (JAR signing) would be dead weight — and its PKCS#7 is the only part of * APK signing that is genuinely hard to write. */ const V2_BLOCK_ID = 0x7109871a; diff --git a/build-tools/utils/gradleProject.js b/build-tools/utils/gradleProject.js index 6c0a84135..830537fd2 100644 --- a/build-tools/utils/gradleProject.js +++ b/build-tools/utils/gradleProject.js @@ -54,7 +54,7 @@ export function gradleManifest(templateXml, app) { HAS_CODE: 'true', }); - const minSdk = Number(/android:minSdkVersion="(\d+)"/.exec(filled)?.[1] ?? 26); + const minSdk = Number(/android:minSdkVersion="(\d+)"/.exec(filled)?.[1] ?? 29); const targetSdk = Number(/android:targetSdkVersion="(\d+)"/.exec(filled)?.[1] ?? 33); const xml = filled diff --git a/desktop/tests/android-project.test.ts b/desktop/tests/android-project.test.ts index 9a591a5e7..01c9e130d 100644 --- a/desktop/tests/android-project.test.ts +++ b/desktop/tests/android-project.test.ts @@ -110,7 +110,7 @@ describe('the Android Studio project an export writes', () => { expect(gradle).toContain('versionCode = 7'); expect(gradle).toContain('versionName = "1.2"'); // Taken from the manifest template rather than restated, so the two agree. - expect(gradle).toContain('minSdk = 26'); + expect(gradle).toContain('minSdk = 29'); expect(gradle).toContain('targetSdk = 33'); }); @@ -163,7 +163,7 @@ describe('the Android Studio project an export writes', () => { describe('gradleManifest', () => { it('reads the SDK levels it strips, so the build script can state them', () => { const { minSdk, targetSdk } = gradleManifest(readFileSync(MANIFEST_TEMPLATE, 'utf8'), APP); - expect(minSdk).toBe(26); + expect(minSdk).toBe(29); expect(targetSdk).toBe(33); }); }); diff --git a/native/android/host/AndroidManifest.xml.in b/native/android/host/AndroidManifest.xml.in index b173606c8..787912588 100644 --- a/native/android/host/AndroidManifest.xml.in +++ b/native/android/host/AndroidManifest.xml.in @@ -8,12 +8,22 @@ hard requirement, and the config changes the host handles rather than being restarted for. `hasCode` follows whether the Java shim compiled — the game is native, and the only Java in the package is the IME's side of a text field. + + minSdkVersion 29, and the NDK MUST build against the same number (`--platform`, + build-tools/tasks/native.js). The host calls AFontMatcher_create unguarded and + that is API 29, so nothing below it could ever have run; 26 promised three + releases the code never supported. The equality is what makes the availability + guards work: the NDK emits a weak reference only for an API newer than the + build target, so building above the declared floor compiles every + `__builtin_available` out and turns its symbol into a load-time requirement. + Built at android-33, the released host could not dlopen below API 31 — + "cannot locate symbol APerformanceHint_getManager" on Android 10 and 11. --> - + diff --git a/native/host/platform/android.cpp b/native/host/platform/android.cpp index c11a2f831..10c525439 100644 --- a/native/host/platform/android.cpp +++ b/native/host/platform/android.cpp @@ -14,6 +14,7 @@ * Licensed under the Apache License, Version 2.0. */ #include +#include #include #include #include @@ -22,7 +23,6 @@ #include #include #include -#include #include #include @@ -499,21 +499,59 @@ void onAppCmd(android_app* app, int32_t cmd) { // The loop then reports what each frame actually cost, and the system ramps on // that instead of on a guess. Busy-waiting to fake demand is the alternative the // platform documentation explicitly warns against. +// +// Resolved by hand, through dlsym, rather than called directly. +// +// ADPF is API 33 and this host builds against the manifest's floor, so the NDK +// marks those four symbols `unavailable`: a hard compile error, and no +// `__builtin_available` guard changes that — the annotation is not "call me +// under a check", it is "this build cannot see me". Raising the build target is +// what makes them callable, and that is precisely the mistake being fixed here: +// at android-33 every availability guard in this file became dead code and every +// guarded symbol became a load-time requirement, so the released host could not +// dlopen on Android 10 or 11 at all. +// +// The NDK's own answer is ANDROID_WEAK_API_DEFS, which turns such symbols into +// weak references. Looking them up here instead keeps the decision in the code +// that depends on it, where it is visible, rather than in a toolchain flag whose +// absence would silently restore the same class of failure. struct PerformanceHints { - APerformanceHintSession* session = nullptr; + void* session = nullptr; // One display refresh. The Choreographer is what calls the frame, so the // deadline is the panel's cadence, whatever it happens to be. static constexpr int64_t kFrameBudgetNanos = 16'666'667; + // Opaque on purpose: the header's typedefs come with the same availability + // annotations, and nothing here needs to know what a session is. + using GetManagerFn = void* (*)(); + using CreateSessionFn = void* (*)(void*, const int32_t*, size_t, int64_t); + using ReportFn = int (*)(void*, int64_t); + using CloseFn = void (*)(void*); + + ReportFn reportFn = nullptr; + CloseFn closeFn = nullptr; + void open() { - if (__builtin_available(android 31, *)) { - APerformanceHintManager* manager = APerformanceHint_getManager(); - if (!manager) return; // no session on a device (or emulator) without one - // The frame loop's own thread, and it lives as long as the app does — - // the session wants long-lived tids, not ones that come and go. - const int32_t tid = static_cast(gettid()); - session = APerformanceHint_createSession(manager, &tid, 1, kFrameBudgetNanos); + // RTLD_DEFAULT: libandroid is already loaded — this asks whether THIS + // platform version exports the symbols, which is the actual question. + const auto getManager = reinterpret_cast( + dlsym(RTLD_DEFAULT, "APerformanceHint_getManager")); + const auto createSession = reinterpret_cast( + dlsym(RTLD_DEFAULT, "APerformanceHint_createSession")); + reportFn = reinterpret_cast( + dlsym(RTLD_DEFAULT, "APerformanceHint_reportActualWorkDuration")); + closeFn = reinterpret_cast( + dlsym(RTLD_DEFAULT, "APerformanceHint_closeSession")); + + if (getManager && createSession && reportFn && closeFn) { + if (void* manager = getManager()) { + // The frame loop's own thread, and it lives as long as the app + // does — the session wants long-lived tids, not ones that come + // and go. + const int32_t tid = static_cast(gettid()); + session = createSession(manager, &tid, 1, kFrameBudgetNanos); + } } __android_log_print(session ? ANDROID_LOG_INFO : ANDROID_LOG_WARN, LOG_TAG, "perf hints: %s", session ? "session up (ADPF)" : "unavailable"); @@ -523,14 +561,11 @@ struct PerformanceHints { if (!session) return; const auto nanos = std::chrono::duration_cast(frame).count(); if (nanos <= 0) return; // the API rejects a non-positive duration - if (__builtin_available(android 31, *)) { - APerformanceHint_reportActualWorkDuration(session, nanos); - } + reportFn(session, nanos); } ~PerformanceHints() { - if (!session) return; - if (__builtin_available(android 31, *)) APerformanceHint_closeSession(session); + if (session) closeFn(session); } }; diff --git a/tools/android-compat-report.mjs b/tools/android-compat-report.mjs new file mode 100644 index 000000000..65186f32c --- /dev/null +++ b/tools/android-compat-report.mjs @@ -0,0 +1,240 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright (c) 2024-present ESEngine Team +/** + * @file android-compat-report.mjs — one APK, every Android version, in one table. + * + * The compatibility matrix runs the SAME binary on one emulator per platform + * version and files a metrics JSON and a screenshot from each. This turns that + * pile into the thing a reviewer actually reads: a row per version, and the + * frames side by side underneath. + * + * The frames are here to be LOOKED AT. Nothing in this file judges a pixel — + * a scene that is legitimately dark and a renderer that died both produce a + * dark PNG, and no threshold tells them apart. What is gated is what can be + * decided without a human: did it install, did the host reach `ready`, did the + * boot record name an error. + * + * node tools/android-compat-report.mjs --dir build/compat --shots-base --out comment.md + * + * `--shots-base` is the URL the screenshots were published under; without it the + * table still renders and the image section says where the artifact is instead. + */ +import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; + +function parseArgs(argv) { + const opts = { dir: 'build/compat', out: 'compat-comment.md' }; + for (let i = 0; i < argv.length; i++) { + const key = argv[i].replace(/^--/, '').replace(/-([a-z])/g, (_, c) => c.toUpperCase()); + opts[key] = argv[++i]; + } + return opts; +} + +const opts = parseArgs(process.argv.slice(2)); + +/** Every metrics JSON under `--dir`, at any depth: one artifact per matrix job. */ +function collect(dir) { + const found = []; + const walk = (d) => { + for (const e of readdirSync(d, { withFileTypes: true })) { + const p = path.join(d, e.name); + if (e.isDirectory()) walk(p); + else if (e.name.endsWith('.json')) { + try { + found.push(JSON.parse(readFileSync(p, 'utf8'))); + } catch { + // A truncated file is a job that died mid-write. Worth saying so + // rather than crashing the report that would have explained it. + found.push({ label: path.basename(p, '.json'), ok: false, why: 'its metrics file is not readable' }); + } + } + } + }; + if (existsSync(dir)) walk(dir); + return found; +} + +const runs = collect(opts.dir); + +// No metrics at all means the matrix never ran — the APK build failed, or every +// job died before it could file anything. Still write a comment: failing here +// instead would lose the only place that says so, and leave a red check whose +// cause is in a different job's log. +if (!runs.length) { + mkdirSync(path.dirname(path.resolve(opts.out)), { recursive: true }); + writeFileSync(opts.out, ['', '## Android 兼容性', '', + '没有任何版本产出数据 — 说明矩阵没跑起来(APK 构建失败,或每个 job 都在写结果前就死了)。', + '原因在 `apk` / `compat` job 的日志里,不在这里。', ''].join('\n')); + console.error(`no metrics files under ${opts.dir} — wrote a comment saying so`); + process.exit(0); +} + +// A version whose job never filed anything must still get a row. The first real +// run lost API 29 to a job timeout and the table simply said "7 versions" — an +// absent row reads as "that version does not exist", which is the one conclusion +// a compatibility matrix must never imply by accident. +if (opts.expect) { + const seen = new Set(runs.map((r) => r.api)); + const wanted = String(opts.expect).replace(/[[\]\s]/g, '').split(',').map(Number).filter(Boolean); + for (const api of wanted) { + if (seen.has(api)) continue; + runs.push({ + api, release: null, label: `api${api}`, ok: false, missing: true, + why: '没测到 — 这个 job 没跑完(模拟器没启动起来,或 job 超时)', + }); + } +} + +// API level ascending, so the table reads oldest-first — the direction a +// compatibility floor is read in. +runs.sort((a, b) => (a.api ?? 0) - (b.api ?? 0) || String(a.label).localeCompare(String(b.label))); + +const mb = (kb) => (kb === null || kb === undefined ? '—' : `${Math.round(kb / 1024)} MB`); +const ms = (n) => (n === null || n === undefined ? '—' : `${n} ms`); +const pct = (c) => (c?.percent === null || c?.percent === undefined ? '—' : `${c.percent}%`); +const frames = (f) => (!f ? '—' : `${f.medianMs} / ${f.p95Ms} ms`); + +// The label is `api-`; the app column only appears when more than +// one was run, because a column with one repeated value is noise. +const appOf = (r) => String(r.label ?? '').replace(/^api\d+-?/, '') || '—'; +// Placeholders for versions that filed nothing carry no app name, and counting +// their '—' as one would flip a single-app run into per-app mode. +const apps = [...new Set(runs.filter((r) => !r.missing).map(appOf))]; +const perApp = apps.length > 1; + +// `am start -W`'s TotalTime and logcat's "Displayed" are the SAME measurement — +// launch to the activity being fully drawn — and the first run proved it, agreeing +// to the millisecond on all six versions that produced data. Both are still +// collected into the JSON as a cross-check, but showing them as two columns +// claimed two independent clocks where there is one. +const rows = runs.map((r) => { + const s = r.startup ?? {}; + return `| ${r.release ?? '?'} | ${r.api ?? '?'} ${perApp ? `| ${appOf(r)} ` : ''}` + + `| ${ms(s.readyMs)} | ${ms(s.totalMs)} ` + + `| ${mb(r.memory?.totalPssKb)} | ${mb(r.memory?.graphicsKb)} | ${pct(r.cpu)} | ${frames(r.frames)} ` + + `| ${r.ok ? '✓' : `✗ ${r.why || 'failed'}`} |`; +}); + +const out = []; +out.push(''); +out.push('## Android 兼容性'); +out.push(''); +const versions = new Set(runs.map((r) => r.api)); +const named = apps.filter((a) => a !== '—'); +out.push(`${perApp ? `${named.length} 个包` : '同一个包'}${named.length ? `(${named.join('、')})` : ''},` + + `在 ${versions.size} 个 Android 版本上各跑一台模拟器,每个版本装的是同一个构建产物。`); +out.push(''); +// Which template was wrapped changes what a red row means: against the released +// one it is the binary users installed, against a branch build it is a proposed +// fix. Stated because the table is read without the workflow next to it. +if (opts.templateSource === 'release') { + out.push('运行时模板取自**最新 release** —— 也就是用户实际装到手机上的那个二进制。'); +} else if (opts.templateSource === 'head') { + out.push('运行时模板**从这个分支构建** —— 测的是这里改的代码,不是已发布的版本。'); +} +out.push(''); +out.push(`| Android | API ${perApp ? '| app ' : ''}| ready | 启动到首帧 | PSS | Graphics | CPU | 帧间隔 中位/p95 | 结果 |`); +out.push(`|---|---|${perApp ? '---|' : ''}---|---|---|---|---|---|---|`); +out.push(...rows); +out.push(''); + +// "ran and broke" and "never ran" are different findings and must not share a +// count. The first is a result about Android; the second is a result about this +// pipeline, and reporting them together lets an untested version pass for a +// tested one. +const broken = runs.filter((r) => !r.ok && !r.missing); +const untested = runs.filter((r) => r.missing); + +if (broken.length) { + out.push(`**${new Set(broken.map((r) => r.api)).size}/${versions.size} 个 Android 版本装上了但没跑起来。**`); + out.push(''); + for (const r of broken) { + out.push(`- **Android ${r.release ?? '?'} (API ${r.api ?? '?'})**` + + `${perApp ? ` — ${appOf(r)}` : ''} — ${r.why || 'failed'}`); + for (const e of (r.errors ?? []).slice(0, 3)) out.push(` - \`${e.trim()}\``); + } + out.push(''); +} + +if (untested.length) { + out.push(`**${untested.length} 个版本没有数据 —— 这是关于这条流水线的结论,不是关于 Android 的。**`); + out.push('这些版本既没被证明可用,也没被证明有问题。'); + out.push(''); + for (const r of untested) out.push(`- **API ${r.api}** — ${r.why}`); + out.push(''); +} + +out.push('### 截图 — 需要人工看'); +out.push(''); +out.push('这里没有任何像素判据。上面的表只回答了「装上了、起来了、没报错」;'); +out.push('画面对不对只有人能判断。'); +out.push(''); +if (opts.shotsBase) { + // Two per row: a phone screenshot in a PR comment column is still tall enough + // to see, and eight in a single row would each be a thumbnail nobody can read. + for (let i = 0; i < runs.length; i += 2) { + const pair = runs.slice(i, i + 2); + out.push(`| ${pair.map((r) => `Android ${r.release ?? '?'} (API ${r.api ?? '?'})` + + (perApp ? ` — ${appOf(r)}` : '')).join(' | ')} |`); + out.push(`|${pair.map(() => '---').join('|')}|`); + out.push(`| ${pair.map((r) => (r.shot + ? `` + : '_(没有截图)_')).join(' | ')} |`); + out.push(''); + } +} else { + out.push('_截图没有发布到可引用的地址,在这次 run 的 artifact 里。_'); + out.push(''); +} + +// The renderer is stated once, because it changes what the numbers mean rather +// than being one of them: a hosted runner has no GPU, so Dawn is on the +// emulator's SwiftShader and every frame time above is a CPU rasteriser's. +// Comparing versions against each other is still valid; reading any of it as +// device performance is not. +const renderers = [...new Set(runs.map((r) => r.renderer).filter(Boolean))]; +if (renderers.length) { + out.push('### 这些数字的边界'); + out.push(''); + out.push(`渲染器:${renderers.map((r) => `\`${r}\``).join(', ')}`); + out.push(''); + if (renderers.some((r) => /swiftshader|llvmpipe|software/i.test(r))) { + out.push('**是软件渲染。** runner 没有 GPU,Dawn 跑在模拟器自带的 SwiftShader 上,'); + out.push('所以上面的帧间隔是 CPU 光栅化的耗时,GPU 占用和功耗在这里根本不存在。'); + out.push('跨版本互相比较仍然有意义,把任何一个数当成真机性能则没有。'); + out.push(''); + // Now that the row parses, it parses as zero — and a bare 0 invites the + // reading "the renderer allocates nothing", which is not what it means. + if (runs.some((r) => r.memory && r.memory.graphicsKb === 0)) { + out.push('`Graphics` 是 0 也是同一个原因:那一列统计的是 gralloc/GPU 归属的内存,'); + out.push('软件渲染下不存在这种分配,Dawn 的缓冲全落在 PSS 的其他分类里。'); + out.push('这一列只有在真机上才有意义。'); + out.push(''); + } + } +} + +const noted = runs.filter((r) => (r.notes ?? []).length); +if (noted.length) { + out.push('
没测到的项,以及原因'); + out.push(''); + for (const r of noted) { + out.push(`- **API ${r.api ?? '?'}** — ${r.notes.join('; ')}`); + } + out.push(''); + out.push('
'); + out.push(''); +} + +mkdirSync(path.dirname(path.resolve(opts.out)), { recursive: true }); +writeFileSync(opts.out, `${out.join('\n')}\n`); +console.log(`${versions.size} version(s): ${broken.length} broken, ${untested.length} untested → ${opts.out}`); + +// The verdict comes from the DATA, and only after the comment is on disk. +// +// It used to come from whether the matrix jobs went green, which put it in two +// places that could disagree: a version whose emulator never booted failed its +// job and looked identical to one whose app crashed. The jobs measure; this +// decides. Writing first means a red check always has the table that explains it. +if (broken.length || untested.length) process.exit(2); diff --git a/tools/android-compat-run.sh b/tools/android-compat-run.sh new file mode 100755 index 000000000..ee6be64ca --- /dev/null +++ b/tools/android-compat-run.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright (c) 2024-present ESEngine Team +# +# Install and measure every packaged canary on the emulator that is already up. +# +# This is a FILE, and not the workflow's `script:` input, because +# reactivecircus/android-emulator-runner executes that input one line at a time +# as separate `sh -c` invocations. A loop cannot survive it — `for apk in ...; do` +# arrives alone and dies with "end of file unexpected (expecting done)" — and +# nothing assigned on one line is visible on the next. The one-line workarounds +# (a folded `>-` scalar, or `sh -c` with an embedded newline) both put shell +# syntax somewhere it cannot be run or tested; a file can be run by hand. +# +# tools/android-compat-run.sh +# +# Every APK is attempted even after one fails, because which versions work is the +# entire output: stopping at the first break would report the oldest failure and +# hide every version above it. +set -euo pipefail + +API="${1:?usage: android-compat-run.sh [out-dir]}" + +# Resolved from this file, not from the caller's cwd: being runnable by hand is +# the reason it is a file at all, and a relative `tools/...` only works from the +# repository root. +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +OUT="${2:-$ROOT/build/compat}" + +mkdir -p "$OUT" + +shopt -s nullglob +APKS=("$ROOT"/apks/*.apk) +if [ ${#APKS[@]} -eq 0 ]; then + echo "::error::no APKs in $ROOT/apks — the packaging job produced nothing to install" + exit 1 +fi + +rc=0 +for apk in "${APKS[@]}"; do + name=$(basename "$apk" .apk) + label="api${API}-${name}" + echo "::group::${label}" + # --no-frame-judge: the frame is for a human to look at. The gate is install + + # `ready` + no recorded error. + node "$ROOT/tools/verify-native-boot.mjs" \ + --platform android --apk "$apk" \ + --label "$label" --no-frame-judge \ + --out "$OUT" \ + --metrics-out "${OUT}/${label}.json" || rc=$? + echo "::endgroup::" +done + +exit "$rc" diff --git a/tools/verify-native-boot.mjs b/tools/verify-native-boot.mjs index 73c1f110e..5c27f6f7e 100644 --- a/tools/verify-native-boot.mjs +++ b/tools/verify-native-boot.mjs @@ -24,6 +24,14 @@ * node tools/verify-native-boot.mjs --platform android --examples all * node tools/verify-native-boot.mjs --platform android --examples all --shard 1/3 * + * For a compatibility run — the same APK on one emulator per Android version — + * `--no-frame-judge` drops the pixel question entirely and `--metrics-out` files + * what the launch cost, so the versions can be compared and the frames reviewed + * by someone. There, a dark frame is a thing to look at, not a thing to fail on: + * + * node tools/verify-native-boot.mjs --platform android --apk game.apk \ + * --label api29 --no-frame-judge --metrics-out build/compat/api29.json + * * `--examples` packages each project the way the editor's Package dialog does and * runs the same two questions against every one, on ONE booted device: booting is * minutes and each app is seconds, so a device per example would spend the whole @@ -78,10 +86,15 @@ function parseArgs(argv) { minColors: 2, out: path.join(ROOT, 'build', 'native-boot'), template: path.join(ROOT, 'template'), + // Names the artifacts. One compatibility job per Android version writes + // into a directory the PR comment reads as a whole, so the version has to + // be in the filename or eight runs land on top of each other. + label: 'app', }; for (let i = 0; i < argv.length; i++) { const key = argv[i].replace(/^--/, ''); if (key === 'allow-skip') { opts.allowSkip = true; continue; } + if (key === 'no-frame-judge') { opts.frameJudge = false; continue; } opts[key.replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = argv[++i]; } opts.timeout = Number(opts.timeout); @@ -115,10 +128,39 @@ function quietly(what, cmd, args) { // Android — adb against whatever emulator or phone is attached // ============================================================================= +/** `+1s234ms`, `+834ms` — how the platform writes a launch duration. */ +function launchDuration(text) { + const m = /\+(?:(\d+)m)?(?:(\d+)s)?(\d+)ms/.exec(text ?? ''); + if (!m) return null; + return Number(m[1] ?? 0) * 60_000 + Number(m[2] ?? 0) * 1000 + Number(m[3]); +} + +const firstNumber = (re, text) => { + const m = re.exec(text ?? ''); + return m ? Number(m[1]) : null; +}; + +/** + * utime+stime for a pid, in clock ticks. + * + * `comm` is parenthesised and may itself contain spaces and parentheses, so the + * positional fields start after the LAST `)` — splitting the whole line on + * whitespace misreads any process whose name has a space in it. + */ +function cpuTicks(adb, pid) { + const stat = trySh(adb, ['shell', 'cat', `/proc/${pid}/stat`]).stdout ?? ''; + const fields = stat.slice(stat.lastIndexOf(')') + 1).trim().split(/\s+/); + if (fields.length < 13) return null; + const utime = Number(fields[11]); + const stime = Number(fields[12]); + return Number.isFinite(utime + stime) ? utime + stime : null; +} + function androidDriver(opts) { const adb = process.env.ANDROID_HOME ? path.join(process.env.ANDROID_HOME, 'platform-tools', 'adb') : 'adb'; const logFile = `/sdcard/Android/data/${APP_ID}/files/${LOG_NAME}`; + let lastReadError = ''; return { name: 'android', @@ -144,6 +186,14 @@ function androidDriver(opts) { prepare() { trySh(adb, ['shell', 'settings', 'put', 'global', 'hide_error_dialogs', '1']); trySh(adb, ['shell', 'settings', 'put', 'global', 'anr_show_background', '0']); + // The boot record lives in the app's external files directory, which + // scoped storage put out of the shell user's reach — on API 30 the game + // ran, drew, and reported "never reported ready", because the reader + // could not open a file that was there. An emulator image is userdebug, + // so take root ONCE here rather than inside the loop that polls the + // file. A device that refuses simply stays unrooted and the read falls + // back to saying why it failed. + if (trySh(adb, ['root']).status === 0) trySh(adb, ['wait-for-device']); }, install(apk) { trySh(adb, ['uninstall', APP_ID]); @@ -152,8 +202,11 @@ function androidDriver(opts) { trySh(adb, ['shell', 'rm', '-f', logFile]); trySh(adb, ['logcat', '-c']); }, + // `-W` makes this wait for the launch to finish and print what it cost, so + // the timing comes from the platform's own measurement rather than from a + // stopwatch around adb — which would also be timing adb. launch() { - sh(adb, ['shell', 'am', 'start', '-W', '-n', `${APP_ID}/android.app.NativeActivity`]); + return sh(adb, ['shell', 'am', 'start', '-W', '-n', `${APP_ID}/android.app.NativeActivity`]); }, stop() { trySh(adb, ['shell', 'am', 'force-stop', APP_ID]); @@ -161,6 +214,12 @@ function androidDriver(opts) { // Counting colors on a SCREEN, not on the game: an emulator that pops // "Pixel Launcher isn't responding" over a black app hands the check a // dialog full of colors and it calls that a rendered frame. + /** Has the launch already died? `am start -W` waits for the launch to + * finish, so by the time this is asked the process either exists or is + * gone for good. */ + died() { + return !trySh(adb, ['shell', 'pidof', APP_ID]).stdout.trim(); + }, foreground() { const out = trySh(adb, ['shell', 'dumpsys', 'activity', 'activities']).stdout ?? ''; const line = out.split('\n').find((l) => /ResumedActivity/.test(l)); @@ -172,13 +231,135 @@ function androidDriver(opts) { }, readLog() { const got = trySh(adb, ['shell', 'cat', logFile]); + // Why it failed, kept for the diagnostics. "No such file" (the app never + // got that far) and "Permission denied" (it did, and this cannot see it) + // are opposite conclusions that both arrive here as an empty string, and + // one of them spent a run looking like a broken Android version. + lastReadError = got.status === 0 ? '' : `${got.stderr ?? ''}`.trim(); return got.status === 0 ? got.stdout : ''; }, screenshot() { return execFileSync(adb, ['exec-out', 'screencap', '-p'], { maxBuffer: 64 * 1024 * 1024 }); }, + /** + * What the run cost on THIS platform version, read while the app is still + * up — every number here is gone the moment the process is. + * + * A field this version cannot answer for is `null`, never 0: the whole + * point is comparing one Android release against another, and a zero + * would read as "free" rather than "not measurable here". `notes` says + * which ones those were, so a gap in the table is explained rather than + * looking like a regression. + */ + async metrics(launchOutput) { + const notes = []; + // The DEVICE first, before anything that depends on the app being + // alive. Read after the early return below, the one row that matters + // most — the version that crashed — came out labelled "Android ? (API + // ?)", which is the row a reader needs the version on. + const device = { + api: Number(trySh(adb, ['shell', 'getprop', 'ro.build.version.sdk']).stdout.trim()) || null, + release: trySh(adb, ['shell', 'getprop', 'ro.build.version.release']).stdout.trim() || null, + renderer: /GLES:\s*(.+)/.exec( + trySh(adb, ['shell', 'dumpsys', 'SurfaceFlinger']).stdout ?? '')?.[1]?.trim() ?? null, + }; + const pid = trySh(adb, ['shell', 'pidof', APP_ID]).stdout.trim().split(/\s+/)[0] || null; + + const startup = { + // TotalTime is the activity reaching drawable; WaitTime adds the + // system's own work before it got there. + totalMs: firstNumber(/^TotalTime:\s*(\d+)/m, launchOutput), + waitMs: firstNumber(/^WaitTime:\s*(\d+)/m, launchOutput), + // The tag is ActivityTaskManager from API 29 and ActivityManager + // below it, so both are asked for rather than branching on version. + displayedMs: launchDuration( + (trySh(adb, ['logcat', '-d', '-s', 'ActivityTaskManager:I', 'ActivityManager:I']).stdout ?? '') + .split('\n').filter((l) => l.includes('Displayed') && l.includes(APP_ID)).pop()), + readyMs: null, // the caller fills this from the boot record + }; + + if (!pid) { + notes.push('the process was gone before anything could be sampled'); + return { ...device, startup, memory: null, cpu: null, frames: null, notes }; + } + + const meminfo = trySh(adb, ['shell', 'dumpsys', 'meminfo', APP_ID]).stdout ?? ''; + // The colon is optional because the same figure is labelled both ways in + // one dumpsys: the per-process table writes "Native Heap 1234" and + // the App Summary below it writes "Native Heap: 1234". Requiring + // whitespace after the label found neither Graphics nor Native Heap, so + // Dawn's Vulkan allocations read as "—" on every version. + const memRow = (label) => firstNumber(new RegExp(`^\\s*${label}:?\\s+(\\d+)`, 'm'), meminfo); + const memory = { + // "TOTAL PSS:" is the App Summary line on API 29+; older releases + // label the same figure "TOTAL" in the table above it. + totalPssKb: firstNumber(/^\s*TOTAL PSS:\s*(\d+)/m, meminfo) ?? memRow('TOTAL'), + nativeHeapKb: memRow('Native Heap'), + // Dawn's Vulkan allocations land here, not in Native Heap — which is + // why a renderer regression is invisible in the heap figure alone. + graphicsKb: memRow('Graphics'), + vmHwmKb: firstNumber(/^VmHWM:\s*(\d+)/m, + trySh(adb, ['shell', 'cat', `/proc/${pid}/status`]).stdout), + }; + if (memory.totalPssKb === null) notes.push('dumpsys meminfo named no total this version parses'); + + const ticksPerSec = Number(trySh(adb, ['shell', 'getconf', 'CLK_TCK']).stdout.trim()) || 100; + const before = cpuTicks(adb, pid); + const threads = firstNumber(/^Threads:\s*(\d+)/m, + trySh(adb, ['shell', 'cat', `/proc/${pid}/status`]).stdout); + let cpu = null; + if (before === null) { + notes.push('/proc is not readable for another uid on this version — no CPU figure'); + } else { + const windowMs = 2000; + await sleep(windowMs); + const after = cpuTicks(adb, pid); + cpu = after === null ? null : { + percent: Math.round(((after - before) / ticksPerSec) / (windowMs / 1000) * 1000) / 10, + threads, + windowMs, + }; + } + + // SurfaceFlinger, not `dumpsys gfxinfo`: gfxinfo reports HWUI, and a + // NativeActivity drawing to its own ANativeWindow through Vulkan never + // touches HWUI, so gfxinfo's framestats for this app are empty. + let frames = null; + const layer = (trySh(adb, ['shell', 'dumpsys', 'SurfaceFlinger', '--list']).stdout ?? '') + .split('\n').map((l) => l.trim()).filter((l) => l.includes(APP_ID)).pop(); + if (!layer) { + notes.push('SurfaceFlinger listed no layer for the app — no frame timing'); + } else { + const raw = trySh(adb, ['shell', 'dumpsys', 'SurfaceFlinger', '--latency', `'${layer}'`]).stdout ?? ''; + const lines = raw.trim().split('\n'); + const refreshNs = Number(lines[0]); + // Three timestamps a row; the middle one is actual present. A pending + // frame is reported as INT64_MAX and a dropped one as 0 — both are + // "no measurement", not a zero-length frame. + const present = lines.slice(1) + .map((l) => Number(l.trim().split(/\s+/)[1])) + .filter((n) => Number.isFinite(n) && n > 0 && n < 9.2e18); + const gaps = present.slice(1).map((n, i) => (n - present[i]) / 1e6).filter((ms) => ms > 0); + gaps.sort((a, b) => a - b); + frames = gaps.length < 2 ? null : { + count: gaps.length + 1, + medianMs: Math.round(gaps[Math.floor(gaps.length / 2)] * 100) / 100, + p95Ms: Math.round(gaps[Math.floor(gaps.length * 0.95)] * 100) / 100, + refreshHz: Number.isFinite(refreshNs) && refreshNs > 0 + ? Math.round(1e9 / refreshNs) : null, + }; + if (!frames) notes.push('SurfaceFlinger had too few presented frames to time'); + } + + return { ...device, startup, memory, cpu, frames, notes }; + }, diagnostics() { return [ + // First, because an unreadable record is a different failure from an + // unwritten one and the report cannot tell them apart on its own. + ['reading the boot record', lastReadError + ? `${lastReadError}\n${trySh(adb, ['shell', 'ls', '-l', path.posix.dirname(logFile)]).stdout ?? ''}` + : ''], ['logcat (EstellaSDK)', trySh(adb, ['logcat', '-d', '-s', 'EstellaSDK:*']).stdout], ['logcat (crashes)', trySh(adb, ['logcat', '-d', '-b', 'crash']).stdout], ]; @@ -294,15 +475,23 @@ async function verifyApp(driver, artifact, label, opts, judgeFrame = true) { let log = ''; let offScreen = null; + let launchOutput = ''; // One retry, because a system dialog stealing focus is the emulator having a // bad minute, not the game being broken — and a gate that reds on that gets // ignored within a week. for (let attempt = 0; attempt < 2; attempt++) { - driver.launch(); + launchOutput = driver.launch() ?? ''; const deadline = Date.now() + opts.timeout * 1000; while (Date.now() < deadline) { log = driver.readLog(); if (log.includes(READY)) break; + // A launch that is already dead will not report ready in another two + // minutes. Sitting out the full timeout twice turned each crashing + // version into seven minutes of waiting for nothing, which is what + // pushed the slower emulators past the job cap and cost the matrix a + // version per run — reported as "no data" on a version that had in + // fact crashed, which is the wrong answer twice over. + if (driver.died?.()) break; await sleep(2000); } // `ready` is the first frame submitted; presenting it is not instant. @@ -318,24 +507,33 @@ async function verifyApp(driver, artifact, label, opts, judgeFrame = true) { const shot = path.join(opts.out, `${driver.name}-${label}.png`); writeFileSync(shot, frame); writeFileSync(path.join(opts.out, `${driver.name}-${label}.log`), log); + + // Before stop(): a dead process has no memory, no threads and no layer. + const metrics = driver.metrics ? await driver.metrics(launchOutput) : null; driver.stop(); const image = decodePng(frame); const colors = distinctColors(image); const ready = log.includes(READY); const readyLine = log.split('\n').find((l) => l.includes(READY))?.trim() ?? ''; + if (metrics) metrics.startup.readyMs = firstNumber(/ready in (\d+) ms/, readyLine); // The record already carries structured errors, and they say far more than a // pixel count can: drawing-demo drew a black screen because a resize sent // es_onNativeVisibility into infinite recursion, and the record said so. const errors = log.split('\n').filter((l) => l.startsWith('ERROR [')); + // `--no-frame-judge` turns the pixel question off entirely: a compatibility + // run compares one Android version against another, and the frame is there to + // be LOOKED AT, not counted. Counting colors would red the run on a scene that + // is legitimately dark and still say nothing about what a reviewer can see. + const countColors = judgeFrame && opts.frameJudge !== false; const why = !ready ? 'never reported ready' : offScreen ? `the game was not on screen — ${offScreen}` : errors.length ? errors[0].trim() - : (judgeFrame && colors < opts.minColors) ? `the frame is ${colors} flat color` : ''; + : (countColors && colors < opts.minColors) ? `the frame is ${colors} flat color` : ''; return { - ok: !why, ready, colors, readyLine, errors, offScreen, + ok: !why, ready, colors, readyLine, errors, offScreen, metrics, size: `${image.width}x${image.height}`, log, shot, why, }; } @@ -421,10 +619,25 @@ if (!opts.examples) { console.error(`✗ no ${driver.name} app to verify (pass --${driver.artifactFlag} )`); process.exit(2); } - const r = await verifyApp(driver, artifact, 'app', opts); + const r = await verifyApp(driver, artifact, opts.label, opts); console.log(`boot record: ${r.ready ? r.readyLine : 'never reached "ready in"'}`); - console.log(`frame: ${r.size}, ${r.colors} distinct colors (need ${opts.minColors})`); + console.log(`frame: ${r.size}${opts.frameJudge === false ? '' : `, ${r.colors} distinct colors (need ${opts.minColors})`}`); console.log(`written: ${r.shot}`); + if (r.metrics) console.log(`metrics:\n${JSON.stringify(r.metrics, null, 2)}`); + + // Written whether or not the launch worked. A version that crashes is the + // result a compatibility matrix most needs to report, and a run that files + // nothing leaves a hole in the table that reads like the job never ran. + if (opts.metricsOut) { + mkdirSync(path.dirname(opts.metricsOut), { recursive: true }); + writeFileSync(opts.metricsOut, `${JSON.stringify({ + label: opts.label, device, ok: r.ok, why: r.why, ready: r.ready, + readyLine: r.readyLine, errors: r.errors, shot: path.basename(r.shot), + ...r.metrics, + }, null, 2)}\n`); + console.log(`metrics written: ${opts.metricsOut}`); + } + if (r.ok) { console.log(`\n✓ ${driver.name}: the packaged game started and drew a frame`); process.exit(0);