From c35738fa9b5a29dcac1ef5c48e168a11365ea9aa Mon Sep 17 00:00:00 2001 From: esengine <359807859@qq.com> Date: Wed, 29 Jul 2026 17:08:42 -0700 Subject: [PATCH 01/14] test(native): measure what a launch cost, and let the frame go unjudged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A compatibility run installs the same package on one emulator per Android version, and the question it asks is not the one this harness was built for. The frame is there to be LOOKED AT: a scene that is legitimately dark and a renderer that died produce the same dark PNG, and no threshold separates them. So --no-frame-judge drops the pixel question entirely, leaving the gate at what can be decided without a human — did it install, did the host reach ready, did the boot record name an error. What replaces it is measurement. --metrics-out files what the launch cost, read while the app is still up because a dead process has no memory, no threads and no layer: startup from three independent clocks (am start -W, the host's own "ready in", and the platform's Displayed line), PSS with Graphics broken out because Dawn's Vulkan allocations never appear in the native heap, CPU from /proc diffed over a window, and frame intervals from SurfaceFlinger. SurfaceFlinger, not dumpsys gfxinfo: gfxinfo reports HWUI, and a NativeActivity drawing to its own ANativeWindow through Vulkan never touches HWUI, so its framestats for this app are empty. A field a version cannot answer for is null, never 0 — the point is comparing one Android release against another, and a zero reads as "free" rather than "not measurable here". notes says which ones those were. --- tools/verify-native-boot.mjs | 190 +++++++++++++++++++++++++++++++++-- 1 file changed, 184 insertions(+), 6 deletions(-) diff --git a/tools/verify-native-boot.mjs b/tools/verify-native-boot.mjs index 73c1f110..3fad52a5 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,6 +128,34 @@ 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'; @@ -152,8 +193,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]); @@ -177,6 +221,115 @@ function androidDriver(opts) { 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 = []; + 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 { startup, memory: null, cpu: null, frames: null, notes }; + } + + const meminfo = trySh(adb, ['shell', 'dumpsys', 'meminfo', APP_ID]).stdout ?? ''; + 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'); + } + + // Recorded rather than judged. On a runner this is SwiftShader — a CPU + // rasteriser — so the frame times above are a software renderer's, and + // GPU utilisation and power do not exist to be read at all. Comparing + // them across Android versions is still meaningful; reading them as + // device performance is not. + const renderer = /GLES:\s*(.+)/.exec( + trySh(adb, ['shell', 'dumpsys', 'SurfaceFlinger']).stdout ?? '')?.[1]?.trim() ?? null; + + return { + 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, + startup, memory, cpu, frames, renderer, notes, + }; + }, diagnostics() { return [ ['logcat (EstellaSDK)', trySh(adb, ['logcat', '-d', '-s', 'EstellaSDK:*']).stdout], @@ -294,11 +447,12 @@ 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(); @@ -318,24 +472,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 +584,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); From b71f7220d1c0c7f5462875136b51a7f6e9d7378e Mon Sep 17 00:00:00 2001 From: esengine <359807859@qq.com> Date: Wed, 29 Jul 2026 17:08:54 -0700 Subject: [PATCH 02/14] ci(android): run one package on every Android version from 10 up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit native-smoke asks whether the templates can produce a launching app at all, and asks it on ONE platform version — API 34. That leaves the failure it exists for uncovered: a host that runs on the version CI happens to boot and dies on the ones users have. A user reported Android 10 crashing on launch; nothing here could have caught it, and nothing here can reproduce it without a device. So a package is built ONCE and installed on one emulator per Android release. One build, many versions: a difference between two rows cannot then be a difference between two builds. 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 the file, and a floor that stops existing fails loudly instead of quietly shrinking the matrix. Preview platforms are named by codename and are skipped — a matrix that reds on an unreleased platform gets ignored within a week. The report goes into the PR because that is where it gets read. It states the renderer on every run rather than trusting anyone to remember it: a hosted runner has no GPU, Dawn is on the emulator's SwiftShader, so the frame times are a CPU rasteriser's and GPU utilisation does not exist to be read at all. Comparing versions against each other is still valid; reading any of it as device performance is not. The frames are published to an orphan branch because GitHub renders an image in a comment only from a URL, and an artifact has none. --- .github/workflows/android-compat.yml | 375 +++++++++++++++++++++++++++ tools/android-compat-report.mjs | 183 +++++++++++++ 2 files changed, 558 insertions(+) create mode 100644 .github/workflows/android-compat.yml create mode 100644 tools/android-compat-report.mjs diff --git a/.github/workflows/android-compat.yml b/.github/workflows/android-compat.yml new file mode 100644 index 00000000..1bb70b5b --- /dev/null +++ b/.github/workflows/android-compat.yml @@ -0,0 +1,375 @@ +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 + +permissions: + contents: read + +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: Build the APK once + runs-on: ubuntu-latest + timeout-minutes: 120 + steps: + # The host links box2d, spine and glm out of third_party; a checkout without + # submodules fails at configure on a directory with no CMakeLists.txt. + - uses: actions/checkout@v7 + with: + submodules: recursive + + - uses: ./.github/actions/setup + + # Compiled INTO the host binary, which is why the template is matched to the + # editor version that ships with it. + - name: Build SDK + run: pnpm --filter ./sdk build + + - 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" + + # Shares the release pipeline's cache key exactly, so a PR that does not move + # the pins reuses the Dawn build the last release produced instead of + # spending two hours reproducing it. + - name: Cache the pinned checkouts + Dawn builds + 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 + uses: nttld/setup-ndk@v1 + id: ndk + with: + ndk-version: r28 + + - name: Fetch Dawn + QuickJS at their pinned commits + run: node build-tools/cli.js native --fetch-deps + + # x86_64 only: every emulator in the matrix is x86_64, and arm64 would double + # the build for a slice nothing here installs. The API-level surface a + # compatibility run is looking for is in the host's source, not in its ABI. + - name: Build the Android runtime template (x86_64) + env: + ANDROID_NDK_HOME: ${{ steps.ndk.outputs.ndk-path }} + run: 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. Built here and only + # here: 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 + timeout-minutes: 45 + 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 + + - name: Install, launch and measure on API ${{ matrix.api }} + 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 + script: | + set -euo pipefail + mkdir -p build/compat + rc=0 + for apk in apks/*.apk; do + name=$(basename "$apk" .apk) + label="api${{ matrix.api }}-$name" + echo "::group::$label" + # --no-frame-judge: the frame is for a human. The gate here is + # install + `ready` + no recorded error. + node tools/verify-native-boot.mjs \ + --platform android --apk "$apk" \ + --label "$label" --no-frame-judge \ + --out build/compat \ + --metrics-out "build/compat/$label.json" || rc=$? + echo "::endgroup::" + done + exit $rc + + - 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 + + # --------------------------------------------------------------------------- + report: + name: Report into the PR + needs: [versions, 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" + + - name: Build the table + run: | + node tools/android-compat-report.mjs \ + --dir build/compat \ + ${{ 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. + - name: Fail if any version could not run the app + if: needs.compat.result != 'success' + run: | + echo "::error::at least one Android version did not start the app — see the table in the PR comment" + exit 1 diff --git a/tools/android-compat-report.mjs b/tools/android-compat-report.mjs new file mode 100644 index 00000000..844bc0bd --- /dev/null +++ b/tools/android-compat-report.mjs @@ -0,0 +1,183 @@ +// 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); +} + +// 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+-?/, '') || '—'; +const apps = [...new Set(runs.map(appOf))]; +const perApp = apps.length > 1; + +const rows = runs.map((r) => { + const s = r.startup ?? {}; + return `| ${r.release ?? '?'} | ${r.api ?? '?'} ${perApp ? `| ${appOf(r)} ` : ''}` + + `| ${ms(s.readyMs)} | ${ms(s.displayedMs)} | ${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(''); +out.push(`| Android | API ${perApp ? '| app ' : ''}| ready | 首帧上屏 | am start | PSS | Graphics | CPU | 帧间隔 中位/p95 | 结果 |`); +out.push(`|---|---|${perApp ? '---|' : ''}---|---|---|---|---|---|---|---|`); +out.push(...rows); +out.push(''); + +const broken = runs.filter((r) => !r.ok); +if (broken.length) { + const brokenVersions = new Set(broken.map((r) => r.api)); + out.push(`**${brokenVersions.size}/${versions.size} 个 Android 版本没跑起来**` + + `(${broken.length}/${runs.length} 次运行失败)。`); + 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(''); +} + +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(''); + } +} + +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(`${runs.length} version(s), ${broken.length} broken → ${opts.out}`); + +// The report always writes. Whether the RUN fails is the matrix job's call, not +// this summariser's — exiting non-zero here would lose the comment that says why. From 6733f3f96183339742bd600ad70802aca1a20441 Mon Sep 17 00:00:00 2001 From: esengine <359807859@qq.com> Date: Wed, 29 Jul 2026 17:49:58 -0700 Subject: [PATCH 03/14] ci(android): wrap the released template unless the PR changes the host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first run spent nineteen minutes building and then failed to emit anything, for two unrelated reasons worth separating. It failed because of an x86_64-only build. Every emulator in the matrix is x86_64, so arm64 looked like pure waste — but ANDROID_ABIS names both as what a template must carry and the emitter refuses an incomplete one. The compile succeeded and the emit step rejected it: "Emitted template is incomplete: lib/arm64-v8a/...". Building both is ~35 minutes, which makes the second reason the interesting one. It was slow because it built at all. A released template was already built by the release pipeline, 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 that resembles it. Nothing in a compatibility run needs a fresh host unless the host is what changed. So the source is decided from what the PR touches — native/ or the dependency pins means build, anything else means download — and which way it went is reported next to the numbers, because "the shipped binary" and "a build of this branch" are different claims and a red row means something different under each. Separately: the Dawn cache the release pipeline writes is scoped to a tag ref, which no branch can read and no later tag can either. Every release rebuilds Dawn from scratch and so did this. Warming it from the default branch is its own change; this one just stops needing it in the common case. --- .github/workflows/android-compat.yml | 103 ++++++++++++++++++++++----- tools/android-compat-report.mjs | 9 +++ 2 files changed, 95 insertions(+), 17 deletions(-) diff --git a/.github/workflows/android-compat.yml b/.github/workflows/android-compat.yml index 1bb70b5b..902746a0 100644 --- a/.github/workflows/android-compat.yml +++ b/.github/workflows/android-compat.yml @@ -50,6 +50,12 @@ on: 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 @@ -109,33 +115,89 @@ jobs: # --------------------------------------------------------------------------- apk: - name: Build the APK once + 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: - # The host links box2d, spine and glm out of third_party; a checkout without - # submodules fails at configure on a directory with no CMakeLists.txt. + # 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 - # Compiled INTO the host binary, which is why the template is matched to the - # editor version that ships with it. + # 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) + if echo "$CHANGED" | grep -qE '^(native/|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" - # Shares the release pipeline's cache key exactly, so a PR that does not move - # the pins reuses the Dawn build the last release produced instead of - # spending two hours reproducing it. + # 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 @@ -143,21 +205,27 @@ jobs: 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 - # x86_64 only: every emulator in the matrix is x86_64, and arm64 would double - # the build for a slice nothing here installs. The API-level surface a - # compatibility run is looking for is in the host's source, not in its ABI. - - name: Build the Android runtime template (x86_64) + # 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 x86_64 --template-out artifacts + 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: | @@ -165,9 +233,9 @@ jobs: mkdir -p template unzip -q artifacts/*.zip -d template - # Packaged the way the editor's Package dialog does it. Built here and only - # here: every version in the matrix installs the identical file, so a - # difference between two rows cannot be a difference between two builds. + # 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 @@ -266,7 +334,7 @@ jobs: # --------------------------------------------------------------------------- report: name: Report into the PR - needs: [versions, compat] + 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' @@ -341,6 +409,7 @@ jobs: run: | node tools/android-compat-report.mjs \ --dir build/compat \ + --template-source '${{ needs.apk.outputs.template-from }}' \ ${{ steps.shots.outputs.base && format('--shots-base {0}', steps.shots.outputs.base) || '' }} \ --out compat-comment.md cat compat-comment.md >> "$GITHUB_STEP_SUMMARY" diff --git a/tools/android-compat-report.mjs b/tools/android-compat-report.mjs index 844bc0bd..107750ac 100644 --- a/tools/android-compat-report.mjs +++ b/tools/android-compat-report.mjs @@ -102,6 +102,15 @@ 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 | 首帧上屏 | am start | PSS | Graphics | CPU | 帧间隔 中位/p95 | 结果 |`); out.push(`|---|---|${perApp ? '---|' : ''}---|---|---|---|---|---|---|---|`); out.push(...rows); From 6c4eaae411203ddc98433f495b741660c685fae2 Mon Sep 17 00:00:00 2001 From: esengine <359807859@qq.com> Date: Wed, 29 Jul 2026 17:56:24 -0700 Subject: [PATCH 04/14] build(native): warm the Dawn cache from the default branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 caches it saved are readable by nothing: not by a branch, not by a PR, and not by the next release either, since every new tag is its own scope. Both are still sitting there — 937 MB for android under refs/tags/v0.37.0, 401 MB for ios, plus v0.36.0's — having never been read once. Every release has paid for a cold sixteen-minute-per-ABI Dawn build and then written the result somewhere it could never be fetched from. Writing it from master instead is the fix, because a default-branch cache is in scope for every run in the repository. The job restores first and builds only on a miss, which is what makes a weekly trigger cheap rather than absurd: GitHub evicts a cache untouched for seven days and a restore counts as touching it, so an ordinary week downloads the cache, finds it complete and stops. It rebuilds when the pins move, which is what the push trigger watches. `--build-deps` exists because a warm-up wants Dawn and nothing else. Building the host alongside it would only add ways for the cache job to fail for reasons that have nothing to do with what it is caching. It covers every ABI on android and both sysroot slices on ios — warming one and leaving the other cold would look like a hit and behave like a miss. --- .github/workflows/native-deps-cache.yml | 120 ++++++++++++++++++++++++ build-tools/cli.js | 1 + build-tools/tasks/native.js | 37 +++++++- 3 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/native-deps-cache.yml diff --git a/.github/workflows/native-deps-cache.yml b/.github/workflows/native-deps-cache.yml new file mode 100644 index 00000000..5ca399d6 --- /dev/null +++ b/.github/workflows/native-deps-cache.yml @@ -0,0 +1,120 @@ +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' + 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 9808f41a..fe0e5309 100644 --- a/build-tools/cli.js +++ b/build-tools/cli.js @@ -162,6 +162,7 @@ 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)') diff --git a/build-tools/tasks/native.js b/build-tools/tasks/native.js index 6409d349..fec2d02b 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'; @@ -644,8 +644,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)); From f393489c9bf9842bfec0602bf12724bd623d9159 Mon Sep 17 00:00:00 2001 From: esengine <359807859@qq.com> Date: Wed, 29 Jul 2026 17:56:24 -0700 Subject: [PATCH 05/14] ci(android): the emulator script is sh, so stop writing bash in it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit android-emulator-runner runs `script` through /usr/bin/sh. `set -o pipefail` is a bashism, dash rejects it on line one, and the step died before installing anything — on all eight versions at once. Every job reported failure and not one of them had tested an Android version, which is the worst shape a red matrix can take: it looked exactly like the compatibility break it was built to find. There are no pipes in that script, so `set -eu` loses nothing. --- .github/workflows/android-compat.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/android-compat.yml b/.github/workflows/android-compat.yml index 902746a0..d865e452 100644 --- a/.github/workflows/android-compat.yml +++ b/.github/workflows/android-compat.yml @@ -304,8 +304,11 @@ jobs: 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 + # POSIX sh, not bash: this action runs `script` through /usr/bin/sh, so + # `set -o pipefail` aborts the whole thing on its first line — which it + # did, on all eight versions, before a single app was installed. script: | - set -euo pipefail + set -eu mkdir -p build/compat rc=0 for apk in apks/*.apk; do From eb3e6e854c10fb9b1024500e4600f90ded14bcdb Mon Sep 17 00:00:00 2001 From: esengine <359807859@qq.com> Date: Wed, 29 Jul 2026 18:00:41 -0700 Subject: [PATCH 06/14] build(native): let merging the warm-up be what warms it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit workflow_dispatch resolves only against the default branch, so this job cannot be run before it is merged — dispatching it on the branch returns 404. And once merged, a pins-only trigger would leave the cache cold until the pins next moved or the Monday cron came round, which is up to a week of releases still paying for a build nobody can reuse. Watching this file too costs one extra run, at the moment it lands, and it is the run that makes every later one cheap. --- .github/workflows/native-deps-cache.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/native-deps-cache.yml b/.github/workflows/native-deps-cache.yml index 5ca399d6..a16bd40f 100644 --- a/.github/workflows/native-deps-cache.yml +++ b/.github/workflows/native-deps-cache.yml @@ -27,6 +27,11 @@ on: 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' From d6f2bc11b91084e07a47edcff3a34f884bbbd864 Mon Sep 17 00:00:00 2001 From: esengine <359807859@qq.com> Date: Wed, 29 Jul 2026 18:04:30 -0700 Subject: [PATCH 07/14] ci(android): the emulator action runs script line by line, so use a file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching to POSIX sh was necessary and not sufficient. The action does not run `script` as a script at all — it runs one `sh -c` per line: [command]/usr/bin/sh -c set -eu [command]/usr/bin/sh -c mkdir -p build/compat [command]/usr/bin/sh -c rc=0 [command]/usr/bin/sh -c for apk in apks/*.apk; do /usr/bin/sh: 1: Syntax error: end of file unexpected (expecting "done") So a loop cannot exist there, and nothing assigned on one line is visible on the next — `rc=0` was three separate shells ago by the time anything read it. native-smoke has been folding its invocation into a single line with `>-` all along, which is why it works and this did not. A file instead of a fold: shell syntax that cannot be run outside CI cannot be tested outside CI either, and this one now resolves its own root so it runs from any directory. Both failures presented identically to the compatibility break this exists to find — eight red versions, none of them having installed anything. Worth naming: a matrix that cannot tell "the platform is broken" from "the harness is broken" is not yet an instrument. Also a concurrency group, because `paths` on a pull_request matches the PR's whole diff rather than the push that arrived: every push re-ran all eight emulators, and three pushes had queued twenty-four of them. --- .github/workflows/android-compat.yml | 33 +++++++---------- tools/android-compat-run.sh | 54 ++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 21 deletions(-) create mode 100755 tools/android-compat-run.sh diff --git a/.github/workflows/android-compat.yml b/.github/workflows/android-compat.yml index d865e452..970793eb 100644 --- a/.github/workflows/android-compat.yml +++ b/.github/workflows/android-compat.yml @@ -60,6 +60,13 @@ on: 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 @@ -304,27 +311,11 @@ jobs: 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 - # POSIX sh, not bash: this action runs `script` through /usr/bin/sh, so - # `set -o pipefail` aborts the whole thing on its first line — which it - # did, on all eight versions, before a single app was installed. - script: | - set -eu - mkdir -p build/compat - rc=0 - for apk in apks/*.apk; do - name=$(basename "$apk" .apk) - label="api${{ matrix.api }}-$name" - echo "::group::$label" - # --no-frame-judge: the frame is for a human. The gate here is - # install + `ready` + no recorded error. - node tools/verify-native-boot.mjs \ - --platform android --apk "$apk" \ - --label "$label" --no-frame-judge \ - --out build/compat \ - --metrics-out "build/compat/$label.json" || rc=$? - echo "::endgroup::" - done - exit $rc + # 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: Keep the frame, the record and the numbers if: always() diff --git a/tools/android-compat-run.sh b/tools/android-compat-run.sh new file mode 100755 index 00000000..ee6be64c --- /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" From 99cea4d5311bee91f18f344ac3e5d42de98ec987 Mon Sep 17 00:00:00 2001 From: esengine <359807859@qq.com> Date: Wed, 29 Jul 2026 18:57:47 -0700 Subject: [PATCH 08/14] test(native): fix what the first real matrix showed was wrong with the instrument MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six versions produced data and it was wrong in three ways, each visible only because there were finally numbers to read. The device identity was read after an early return taken when the process is gone, so API 30 — the one version that crashed, the row a reader most needs labelled — came out as "Android ? (API ?)". It is read first now: which phone this is does not depend on whether the app survived on it. `Graphics` was "—" on every row. dumpsys labels the same figure both ways in one report ("Native Heap 1234" in the table, "Native Heap: 1234" in the App Summary) and the pattern demanded whitespace, so it matched neither. That was the one memory number worth having: Dawn's Vulkan allocations are counted there and nowhere else, which is why 67-73 MB looked plausible and was not. "首帧上屏" and "am start" were the same measurement. They agreed to the millisecond on all six versions, because TotalTime from `am start -W` IS the Displayed event. Two columns claimed two independent clocks where there is one; both are still collected as a cross-check, one is shown. And API 29 filed nothing at all — its emulator never booted and the job sat for its full 45 minutes — so the table said "7 versions" and left no trace of the eighth. An absent row reads as "that version does not exist", which is the one thing a compatibility matrix must never imply by accident. Expected versions are passed in now and a missing one gets a row saying so, counted separately from the versions that ran and broke: the first is a result about Android, the second is a result about this pipeline. The cap is 20 minutes, near what a real run costs. --- .github/workflows/android-compat.yml | 7 +++- tools/android-compat-report.mjs | 51 +++++++++++++++++++++++----- tools/verify-native-boot.mjs | 33 ++++++++++-------- 3 files changed, 67 insertions(+), 24 deletions(-) diff --git a/.github/workflows/android-compat.yml b/.github/workflows/android-compat.yml index 970793eb..aff24cdf 100644 --- a/.github/workflows/android-compat.yml +++ b/.github/workflows/android-compat.yml @@ -276,7 +276,11 @@ jobs: name: Android API ${{ matrix.api }} needs: [versions, apk] runs-on: ubuntu-latest - timeout-minutes: 45 + # 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 @@ -404,6 +408,7 @@ jobs: 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" diff --git a/tools/android-compat-report.mjs b/tools/android-compat-report.mjs index 107750ac..3b93409f 100644 --- a/tools/android-compat-report.mjs +++ b/tools/android-compat-report.mjs @@ -70,6 +70,22 @@ if (!runs.length) { 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))); @@ -82,13 +98,20 @@ 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+-?/, '') || '—'; -const apps = [...new Set(runs.map(appOf))]; +// 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.displayedMs)} | ${ms(s.totalMs)} ` + + `| ${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'}`} |`; }); @@ -111,16 +134,20 @@ if (opts.templateSource === 'release') { out.push('运行时模板**从这个分支构建** —— 测的是这里改的代码,不是已发布的版本。'); } out.push(''); -out.push(`| Android | API ${perApp ? '| app ' : ''}| ready | 首帧上屏 | am start | PSS | Graphics | CPU | 帧间隔 中位/p95 | 结果 |`); -out.push(`|---|---|${perApp ? '---|' : ''}---|---|---|---|---|---|---|---|`); +out.push(`| Android | API ${perApp ? '| app ' : ''}| ready | 启动到首帧 | PSS | Graphics | CPU | 帧间隔 中位/p95 | 结果 |`); +out.push(`|---|---|${perApp ? '---|' : ''}---|---|---|---|---|---|---|`); out.push(...rows); out.push(''); -const broken = runs.filter((r) => !r.ok); +// "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) { - const brokenVersions = new Set(broken.map((r) => r.api)); - out.push(`**${brokenVersions.size}/${versions.size} 个 Android 版本没跑起来**` - + `(${broken.length}/${runs.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 ?? '?'})**` @@ -130,6 +157,14 @@ if (broken.length) { 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('这里没有任何像素判据。上面的表只回答了「装上了、起来了、没报错」;'); diff --git a/tools/verify-native-boot.mjs b/tools/verify-native-boot.mjs index 3fad52a5..23706ed6 100644 --- a/tools/verify-native-boot.mjs +++ b/tools/verify-native-boot.mjs @@ -233,6 +233,16 @@ function androidDriver(opts) { */ 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 = { @@ -250,11 +260,16 @@ function androidDriver(opts) { if (!pid) { notes.push('the process was gone before anything could be sampled'); - return { startup, memory: null, cpu: null, frames: null, notes }; + return { ...device, startup, memory: null, cpu: null, frames: null, notes }; } const meminfo = trySh(adb, ['shell', 'dumpsys', 'meminfo', APP_ID]).stdout ?? ''; - const memRow = (label) => firstNumber(new RegExp(`^\\s*${label}\\s+(\\d+)`, 'm'), meminfo); + // 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. @@ -316,19 +331,7 @@ function androidDriver(opts) { if (!frames) notes.push('SurfaceFlinger had too few presented frames to time'); } - // Recorded rather than judged. On a runner this is SwiftShader — a CPU - // rasteriser — so the frame times above are a software renderer's, and - // GPU utilisation and power do not exist to be read at all. Comparing - // them across Android versions is still meaningful; reading them as - // device performance is not. - const renderer = /GLES:\s*(.+)/.exec( - trySh(adb, ['shell', 'dumpsys', 'SurfaceFlinger']).stdout ?? '')?.[1]?.trim() ?? null; - - return { - 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, - startup, memory, cpu, frames, renderer, notes, - }; + return { ...device, startup, memory, cpu, frames, notes }; }, diagnostics() { return [ From c140ce82b90f981b247ccca3be864dcbfd9d4aa5 Mon Sep 17 00:00:00 2001 From: esengine <359807859@qq.com> Date: Wed, 29 Jul 2026 19:24:04 -0700 Subject: [PATCH 09/14] test(native): stop waiting on a launch that is already dead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each crashing version cost seven minutes: the wait for `ready` sat out its full two-minute timeout, twice, on a process that had died in the first second. That is what pushed the slower emulators past the job cap — and the version it cost was reported as "no data" when it had in fact crashed, which is the wrong answer twice over. API 29 and API 30 traded places between two runs for no reason other than which one's emulator took longer to boot. `am start -W` has already waited for the launch to finish, so a missing pid is final rather than early. Checking it turns seven minutes into about twenty seconds and gives the whole matrix the headroom it was missing. Also: `Graphics` parses now and parses as 0, which invites reading it as "the renderer allocates nothing". It means the column counts gralloc/GPU-attributed memory and software rendering makes no such allocations — said in the report, next to the SwiftShader caveat it belongs to. --- tools/android-compat-report.mjs | 8 ++++++++ tools/verify-native-boot.mjs | 13 +++++++++++++ 2 files changed, 21 insertions(+) diff --git a/tools/android-compat-report.mjs b/tools/android-compat-report.mjs index 3b93409f..269603e3 100644 --- a/tools/android-compat-report.mjs +++ b/tools/android-compat-report.mjs @@ -204,6 +204,14 @@ if (renderers.length) { 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(''); + } } } diff --git a/tools/verify-native-boot.mjs b/tools/verify-native-boot.mjs index 23706ed6..1f2bfba1 100644 --- a/tools/verify-native-boot.mjs +++ b/tools/verify-native-boot.mjs @@ -205,6 +205,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)); @@ -460,6 +466,13 @@ async function verifyApp(driver, artifact, label, opts, judgeFrame = true) { 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. From 96635fa941532a0f1374d931aac9fb1edb87c117 Mon Sep 17 00:00:00 2001 From: esengine <359807859@qq.com> Date: Wed, 29 Jul 2026 19:51:15 -0700 Subject: [PATCH 10/14] ci(android): retry a version that produced nothing, and judge from the data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API 29 and API 30 traded places across three runs, each time costing the matrix a version. Neither was our failure: older levels download their system image and then sometimes never reach sys.boot_completed at all. A matrix built to say "every version works" cannot drop one at random and still mean anything. So each version is attempted twice — but the retry is conditional on NOTHING having been filed, because a metrics file means the script ran and reached a verdict. Re-running a crash would burn an emulator to learn what is already known. That condition is also the distinction the old arrangement could not draw: "the app crashed" and "the emulator never came up" both failed the step, and only one of them is an answer. Which moves the verdict. The jobs MEASURE and the report JUDGES: a matrix job now fails only when it measured nothing, and whether the run is red comes from the table's own contents. Previously it came from `needs.compat.result`, which is the same conflation one level up — a version whose emulator died reddened the run identically to a version whose app died, and the PR comment was left as the only place the two could be told apart. The report exits non-zero for broken OR untested, after writing the comment, so a red check always arrives with the table that explains it. --- .github/workflows/android-compat.yml | 63 ++++++++++++++++++++++++++-- tools/android-compat-report.mjs | 13 ++++-- 2 files changed, 68 insertions(+), 8 deletions(-) diff --git a/.github/workflows/android-compat.yml b/.github/workflows/android-compat.yml index aff24cdf..bfdcae4f 100644 --- a/.github/workflows/android-compat.yml +++ b/.github/workflows/android-compat.yml @@ -307,7 +307,19 @@ jobs: 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 }} @@ -321,6 +333,28 @@ jobs: # 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 @@ -329,6 +363,20 @@ jobs: 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 @@ -403,7 +451,12 @@ jobs: 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 \ @@ -435,9 +488,11 @@ jobs: 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. - - name: Fail if any version could not run the app - if: needs.compat.result != 'success' + # 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::at least one Android version did not start the app — see the table in the PR comment" + echo "::error::some Android version is broken or was never measured — see the table in the PR comment" exit 1 diff --git a/tools/android-compat-report.mjs b/tools/android-compat-report.mjs index 269603e3..65186f32 100644 --- a/tools/android-compat-report.mjs +++ b/tools/android-compat-report.mjs @@ -229,7 +229,12 @@ if (noted.length) { mkdirSync(path.dirname(path.resolve(opts.out)), { recursive: true }); writeFileSync(opts.out, `${out.join('\n')}\n`); -console.log(`${runs.length} version(s), ${broken.length} broken → ${opts.out}`); - -// The report always writes. Whether the RUN fails is the matrix job's call, not -// this summariser's — exiting non-zero here would lose the comment that says why. +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); From 426dfa3d0e23cfe378e90e3b13223f1e26ca13db Mon Sep 17 00:00:00 2001 From: esengine <359807859@qq.com> Date: Thu, 30 Jul 2026 07:31:26 -0700 Subject: [PATCH 11/14] fix(android): build against the API level the manifest declares MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The released host cannot be loaded on Android 10 or 11. Not slow, not broken on screen — the dynamic linker refuses the library and the process is gone before a line of our code runs: java.lang.UnsatisfiedLinkError: dlopen failed: cannot locate symbol "APerformanceHint_getManager" The NDK emits a weak reference for a symbol newer than the build target and a strong one otherwise, and compiles `__builtin_available` out in the second case. Built at android-33, the guard around ADPF — correctly written for its API 31 — was dead code, and the symbol became a load-time requirement on every device. The guard was never wrong. The build target was. So the target follows the manifest, and both are 29 rather than 26. 26 was already fiction: loadFont calls AFontMatcher_create unguarded and that is API 29, so Android 8 and 9 could only ever have crashed differently. Declaring what the code actually supports costs nothing and stops promising three releases nobody has tested. android-29 also makes the ADPF guard live again, which is what it was written to be. The compatibility matrix decides where its template comes from by what a PR touches, and watched only native/. This change is entirely in build-tools — the API level is a default there, not a value in native/ — so the rule would have tested the old released binary and reported the fix as not working. --- .github/workflows/android-compat.yml | 7 ++++++- build-tools/cli.js | 2 +- build-tools/tasks/native.js | 9 ++++++++- build-tools/tasks/nativeDeps.js | 2 +- build-tools/tasks/nativeTemplateEmit.js | 2 +- build-tools/utils/apk.js | 2 +- build-tools/utils/gradleProject.js | 2 +- desktop/tests/android-project.test.ts | 4 ++-- native/android/host/AndroidManifest.xml.in | 12 +++++++++++- 9 files changed, 32 insertions(+), 10 deletions(-) diff --git a/.github/workflows/android-compat.yml b/.github/workflows/android-compat.yml index bfdcae4f..70f652df 100644 --- a/.github/workflows/android-compat.yml +++ b/.github/workflows/android-compat.yml @@ -167,7 +167,12 @@ jobs: if [ -z "$WANT" ] || [ "$WANT" = 'auto' ]; then if [ "$GITHUB_EVENT_NAME" = 'pull_request' ]; then CHANGED=$(gh pr diff '${{ github.event.pull_request.number }}' --name-only) - if echo "$CHANGED" | grep -qE '^(native/|toolchain\.manifest\.json)'; then + # 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 diff --git a/build-tools/cli.js b/build-tools/cli.js index fe0e5309..9332042a 100644 --- a/build-tools/cli.js +++ b/build-tools/cli.js @@ -167,7 +167,7 @@ program .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 fec2d02b..0831f24d 100644 --- a/build-tools/tasks/native.js +++ b/build-tools/tasks/native.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(); diff --git a/build-tools/tasks/nativeDeps.js b/build-tools/tasks/nativeDeps.js index bf197606..027ec349 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 0dd9fbda..1ce6293a 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 29447266..25f9937d 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 6c0a8413..830537fd 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 9a591a5e..01c9e130 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 b173606c..78791258 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. --> - + From 93ed09b60f7ace69140633353f38f4e32262e921 Mon Sep 17 00:00:00 2001 From: esengine <359807859@qq.com> Date: Thu, 30 Jul 2026 07:56:41 -0700 Subject: [PATCH 12/14] fix(android): guard ADPF at 33, which is what the NDK header declares MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lowering the build target to the manifest's floor made these guards live, and the compiler immediately rejected them: error: 'APerformanceHint_getManager' is unavailable: introduced in Android 33 note: 'APerformanceHint_getManager' has been explicitly marked unavailable here .../sysroot/usr/include/android/performance_hint.h:128 Two things are true at once, and reading only the second is what put 31 here. The header declares __INTRODUCED_IN(33); the symbol nevertheless resolves on 31 and 32 devices, which is why those two versions ran a host built against 33 whose guards were dead code and whose reference was consequently strong. Runtime says 31, the header says 33, and the header is the authority at compile time — it refuses a guard below its own introduced version regardless of what a device happens to export. So a hint session is given up on Android 12 and 12L in exchange for the app loading at all on 10 and 11. That is the right trade twice over: ADPF is a scheduling hint, and the alternative is a build target that silently disarms every availability guard in the file. --- native/host/platform/android.cpp | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/native/host/platform/android.cpp b/native/host/platform/android.cpp index c11a2f83..ba6b58bc 100644 --- a/native/host/platform/android.cpp +++ b/native/host/platform/android.cpp @@ -506,8 +506,16 @@ struct PerformanceHints { // deadline is the panel's cadence, whatever it happens to be. static constexpr int64_t kFrameBudgetNanos = 16'666'667; + // 33, which is what the NDK header declares, not the 31 these guards used to + // say. The symbol does resolve on 31 and 32 devices — that is why they ran a + // host built against 33, whose guards were compiled out and whose reference + // was therefore strong — but the header is the authority at compile time and + // refuses a guard below its own introduced version. Building against the + // manifest's floor makes these live, so they have to be right: a hint session + // is given up on Android 12 in exchange for the app loading at all on 10. + void open() { - if (__builtin_available(android 31, *)) { + if (__builtin_available(android 33, *)) { 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 — @@ -523,14 +531,14 @@ 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, *)) { + if (__builtin_available(android 33, *)) { APerformanceHint_reportActualWorkDuration(session, nanos); } } ~PerformanceHints() { if (!session) return; - if (__builtin_available(android 31, *)) APerformanceHint_closeSession(session); + if (__builtin_available(android 33, *)) APerformanceHint_closeSession(session); } }; From e4fe7c8828d52ff52e83c8416500dec3d9e1b4ab Mon Sep 17 00:00:00 2001 From: esengine <359807859@qq.com> Date: Thu, 30 Jul 2026 08:24:08 -0700 Subject: [PATCH 13/14] fix(android): resolve ADPF through dlsym, since a guard cannot reach it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raising the guard to 33 was still wrong, and the compiler said so twice: error: 'APerformanceHint_getManager' is unavailable: introduced in Android 33 note: explicitly marked unavailable here `__builtin_available` does not unlock an API the NDK has marked unavailable. That annotation is not "call me under a check" — it is "this build cannot see me", and no guard version changes it. The only ways through are to raise the build target, which is the bug being fixed, or to opt the build into weak API references with ANDROID_WEAK_API_DEFS. Neither belongs in a toolchain flag. A flag that silently stops applying restores exactly this failure — a host that loads on the CI emulator and not on a user's phone — and nothing in the source would show it. So the four symbols are looked up by hand, against RTLD_DEFAULT, with opaque pointers so the header's annotated typedefs are not needed either. A device that exports them gets a hint session; a device that does not gets no session and a line in the log saying so. The dependency now lives in the code that depends on it. That it compiles at all is the proof it is honest about its own floor. --- native/host/platform/android.cpp | 69 ++++++++++++++++++++++---------- 1 file changed, 48 insertions(+), 21 deletions(-) diff --git a/native/host/platform/android.cpp b/native/host/platform/android.cpp index ba6b58bc..10c52543 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,29 +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; - // 33, which is what the NDK header declares, not the 31 these guards used to - // say. The symbol does resolve on 31 and 32 devices — that is why they ran a - // host built against 33, whose guards were compiled out and whose reference - // was therefore strong — but the header is the authority at compile time and - // refuses a guard below its own introduced version. Building against the - // manifest's floor makes these live, so they have to be right: a hint session - // is given up on Android 12 in exchange for the app loading at all on 10. + // 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 33, *)) { - 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"); @@ -531,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 33, *)) { - APerformanceHint_reportActualWorkDuration(session, nanos); - } + reportFn(session, nanos); } ~PerformanceHints() { - if (!session) return; - if (__builtin_available(android 33, *)) APerformanceHint_closeSession(session); + if (session) closeFn(session); } }; From a75901a930b412f2c1e3344dc6e33de68a01995d Mon Sep 17 00:00:00 2001 From: esengine <359807859@qq.com> Date: Thu, 30 Jul 2026 09:10:16 -0700 Subject: [PATCH 14/14] test(native): read the boot record as root, and say so when the read fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Android 10 fix works — API 29 reaches ready in 461 ms. API 30 then failed a different way, and the way it failed is the point: the game ran, drew, held 89.5% of a CPU and presented frames every 114 ms, and the verdict was "never reported ready". The record was there. The reader could not open it. It lives in the app's external files directory, which scoped storage put out of the shell user's reach. So root is taken once in prepare(), where an emulator image will grant it, rather than inside the two-second poll that reads the file. The rest of this is the instrument admitting what it could not distinguish. "No such file" — the app never got that far — and "Permission denied" — it did, and this cannot see it — are opposite conclusions, and both arrived as an empty string. That cost a run reported as a broken Android version. The error is kept and printed first in the diagnostics, with a listing of the directory beside it. --- tools/verify-native-boot.mjs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tools/verify-native-boot.mjs b/tools/verify-native-boot.mjs index 1f2bfba1..5c27f6f7 100644 --- a/tools/verify-native-boot.mjs +++ b/tools/verify-native-boot.mjs @@ -160,6 +160,7 @@ 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', @@ -185,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]); @@ -222,6 +231,11 @@ 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() { @@ -341,6 +355,11 @@ function androidDriver(opts) { }, 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], ];