Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 50 additions & 15 deletions .github/workflows/android-compat.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,9 @@ on:
default: 'space-shooter'
type: string
api-floor:
description: 'Oldest API level to test (29 = Android 10).'
description: 'Oldest API level to test (24 = Android 7.0, the manifest floor).'
required: false
default: '29'
default: '24'
type: string
template-source:
description: 'Which runtime template to wrap: the latest release, this branch (~35 min), or decide from what the PR touches.'
Expand All @@ -68,17 +68,23 @@ concurrency:
cancel-in-progress: true

env:
# Android 10. The manifest claims 26, which is a wider promise than anything
# here checks — raising this floor is a decision about what we support, so it is
# stated once, in one place, rather than implied by a matrix.
ANDROID_API_FLOOR: ${{ inputs.api-floor || '29' }}
# Android 7.0, matching the manifest's minSdkVersion — the promise and the thing
# that checks it must be the same number, or one of them is decoration. Changing
# this floor is a decision about what we support, so it is stated once, in one
# place, rather than implied by a matrix.
#
# An emulator below API 28 may have no Vulkan at all, and the host has no GLES
# path, so those rows can red for the runner's reasons rather than ours. That is
# a result worth having rather than a reason to hide them: this matrix exists to
# say which versions work, and "we cannot tell from CI" is one of the answers.
ANDROID_API_FLOOR: ${{ inputs.api-floor || '24' }}
ANDROID_PROFILE: pixel_6
CANARY_EXAMPLES: ${{ inputs.examples || 'space-shooter' }}

jobs:
# ---------------------------------------------------------------------------
versions:
name: Which Android versions can this runner boot
name: Which Android versions this runner has images for
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
Expand Down Expand Up @@ -282,10 +288,14 @@ jobs:
needs: [versions, apk]
runs-on: ubuntu-latest
# A version that works finishes in about three minutes and one that crashes in
# seven. API 29 once failed to boot at all and sat here for the full 45, so the
# cap is set near what a real run costs: a stuck emulator should be reported as
# "no data" quickly, not held open in case it recovers.
timeout-minutes: 20
# seven, so this wants to be near what a real run costs: a stuck emulator
# should be reported as "no data" quickly, not held open in case it recovers.
#
# But it has to hold TWO of them. At 20 it did not, and the retry below was
# fiction: API 24 sat in the boot poll from 01:11 to 01:30 and the job cap
# cancelled it mid-attempt, so "no data after two attempts" was printed after
# one. Two bounded attempts plus the system-image download is what this covers.
timeout-minutes: 40
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
Expand Down Expand Up @@ -330,8 +340,17 @@ jobs:
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
# An era-appropriate device below API 26: `pixel_6` is a 2021 profile,
# and pairing it with a 2016 system image is one of the two things that
# kept 24 and 25 from ever reaching sys.boot_completed.
profile: ${{ matrix.api <= 25 && 'pixel' || env.ANDROID_PROFILE }}
# The other one: the action boots from a snapshot by default, and an old
# image that cannot load the one it was given hangs instead of saying so.
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -no-snapshot-load -camera-back none -camera-front none -memory 4096 -cores 2
# Explicit, and short enough that two attempts fit the job cap. The
# default (600) did not bound anything here — the poll ran nineteen
# minutes — so this is the number that has to be believed, not inherited.
emulator-boot-timeout: 420
# 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
Expand All @@ -349,15 +368,25 @@ jobs:
fi

- name: Try API ${{ matrix.api }} once more
id: retry
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
# An era-appropriate device below API 26: `pixel_6` is a 2021 profile,
# and pairing it with a 2016 system image is one of the two things that
# kept 24 and 25 from ever reaching sys.boot_completed.
profile: ${{ matrix.api <= 25 && 'pixel' || env.ANDROID_PROFILE }}
# The other one: the action boots from a snapshot by default, and an old
# image that cannot load the one it was given hangs instead of saying so.
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -no-snapshot-load -camera-back none -camera-front none -memory 4096 -cores 2
# Explicit, and short enough that two attempts fit the job cap. The
# default (600) did not bound anything here — the poll ran nineteen
# minutes — so this is the number that has to be believed, not inherited.
emulator-boot-timeout: 420
script: bash tools/android-compat-run.sh ${{ matrix.api }}

- name: Keep the frame, the record and the numbers
Expand All @@ -377,6 +406,12 @@ jobs:
run: |
if ls build/compat/*.json >/dev/null 2>&1; then
echo "measured API ${{ matrix.api }}"
elif [ "${{ steps.retry.conclusion }}" = "skipped" ] || [ -z "${{ steps.retry.conclusion }}" ]; then
# The retry never ran, so "two attempts" would be a lie — and the
# difference matters: one says this Android version is broken, the
# other says this job ran out of time before it could find out.
echo "::error::API ${{ matrix.api }} produced no data and the second attempt never ran — the job hit its own cap first"
exit 1
else
echo "::error::API ${{ matrix.api }} produced no data after two attempts"
exit 1
Expand Down
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,33 @@ published separately; it ships inside the editor.

## [Unreleased]

### Changed

- **Android's minimum is API 24 (Android 7.0), lowered from 29.** The floor was 29
because the font path called `AFontMatcher_create` and nothing below it could
answer; on 24 through 28 the engine now picks the font out of `/system/fonts`
itself, asking each candidate whether it has a glyph for the character rather
than trusting a family name. Read the Vulkan requirement alongside this: it is
unchanged and still `required="true"`, and it filters far more devices than the
API level does, so what 24 adds is Android 7/8-era hardware that has a Vulkan
driver — not every phone on those releases. The compatibility matrix now starts
at 24; emulators below API 28 may have no Vulkan, so those rows can report "no
data" rather than a verdict.

### Fixed

- **Saves no longer live in a directory the platform may delete.** Key/value
storage went to the host's cache directory, whose stated purpose was the
regenerable bytecode cache. On iOS that is `NSCachesDirectory`, which the system
empties when it wants the space back and no backup includes — so a player's
saves and settings could vanish between launches. Android put the same file in
`files/`, which nothing reclaims: one API, two opposite promises. There is now a
durable directory distinct from the reclaimable one (Application Support on iOS,
`internalDataPath` on Android) and storage writes there. Android's cache
directory is also a real cache directory now, so the hot-update store can be
reclaimed instead of growing forever. Writes go through a temp file and a
rename, so a kill mid-write cannot truncate a save.

## [0.40.0] - 2026-08-02

The agent went from working to being pleasant to work with. It answers from the keyboard
Expand Down
4 changes: 3 additions & 1 deletion build-tools/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,9 @@ program
.option('--dawn-build <dir>', 'Dawn build dir for this target (default: <dawn>/out-<target>, built if absent)')
.option('--quickjs <dir>', 'QuickJS-ng source dir (default: the pinned checkout; or ESTELLA_QUICKJS_DIR)')
.option('--abi <abi>', 'Android ABI', 'arm64-v8a')
.option('--platform <platform>', 'Android platform', 'android-29')
// No default: the floor is the manifest's, and repeating it here is how the
// two drift. An unset value reaches androidMinPlatform() in the task.
.option('--platform <platform>', 'Android platform (default: the manifest\'s minSdkVersion)')
.option('--ios-min <version>', '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)
Expand Down
16 changes: 14 additions & 2 deletions build-tools/tasks/native.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { runCommand, getCpuCount, resolvePython } from '../utils/emscripten.js';
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 { androidMinPlatform } from '../utils/androidFloor.js';
import {
ANDROID_ABIS, BYTECODE_FILE, findTemplate, iosTemplateSources, templateStoreDir,
} from '../utils/nativeTemplate.js';
Expand Down Expand Up @@ -319,15 +320,15 @@ async function buildAndroidHost(options) {
// 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 { abi = 'arm64-v8a', platform = androidMinPlatform() } = options;
const rootDir = config.paths.root;

const sdk = requireSdk();
const ndk = requireNdk(sdk);
const toolchain = path.join(ndk, 'build', 'cmake', 'android.toolchain.cmake');
const { cmake, ninja } = sdkCmake(sdk);

const { dawnDir, dawnBuild } = await dawnPaths(options, 'android', { ndk, cmake, ninja });
const { dawnDir, dawnBuild } = await dawnPaths({ ...options, platform }, 'android', { ndk, cmake, ninja });
// One build tree per ABI, beside the generated sources they share — a second
// architecture must not overwrite the first one's objects.
const buildDir = path.join(rootDir, 'build/cmake/native', abi);
Expand All @@ -349,6 +350,17 @@ async function buildAndroidHost(options) {
`-DCMAKE_TOOLCHAIN_FILE=${toolchain}`,
`-DANDROID_ABI=${abi}`,
`-DANDROID_PLATFORM=${platform}`,
// Without this the NDK marks every symbol newer than the platform
// `unavailable` outright — a hard compile error that no availability
// guard can satisfy, because the annotation means "this build cannot see
// it" rather than "call me under a check". ON makes those references
// weak, which is what gives `__builtin_available` something to test.
//
// Turning it on used to be the riskier choice: while the floor equalled
// the newest API the host called, dropping the flag changed nothing and
// would have gone unnoticed. Below that floor it cannot — the build stops
// and names the symbol, which is how this line came to be here.
'-DANDROID_WEAK_API_DEFS=ON',
'-DANDROID_STL=c++_shared',
'-DCMAKE_BUILD_TYPE=Release',
// Emit build/cmake/native/compile_commands.json so editor IntelliSense (the
Expand Down
22 changes: 16 additions & 6 deletions build-tools/tasks/nativeDeps.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
// without a flag.

import path from 'path';
import { androidMinPlatform } from '../utils/androidFloor.js';
import { existsSync, readFileSync } from 'fs';
import { mkdir } from 'fs/promises';
import config from '../build.config.js';
Expand Down Expand Up @@ -109,10 +110,18 @@ export function dawnLibrary(dawnBuild, target) {

/** Dawn's build directory for a target. Android's is per-ABI — an emulator build
* and a device build are different binaries — with the default ABI keeping the
* plain name a checkout may already have. */
export function dawnBuildDir(dawn, target, abi) {
const suffix = target === 'android' && abi && abi !== 'arm64-v8a' ? `-${abi}` : '';
return path.join(dawn, DAWN_TARGETS[target].out + suffix);
* plain name a checkout may already have.
*
* The API level is part of it for the same reason the ABI is: a Dawn compiled
* against a newer platform links symbols the host's own floor promised not to
* need, and the result installs and then fails to load on the versions the floor
* exists to cover. Sharing one directory across levels made lowering the floor a
* no-op on whichever machine already had a build. */
export function dawnBuildDir(dawn, target, abi, androidPlatform) {
if (target !== 'android') return path.join(dawn, DAWN_TARGETS[target].out);
const abiSuffix = abi && abi !== 'arm64-v8a' ? `-${abi}` : '';
const apiSuffix = androidPlatform ? `-${String(androidPlatform).replace(/^android-/, 'api')}` : '';
return path.join(dawn, DAWN_TARGETS[target].out + abiSuffix + apiSuffix);
}

/**
Expand All @@ -124,7 +133,8 @@ export function dawnBuildDir(dawn, target, abi) {
export async function ensureDawnBuild(options) {
const target = DAWN_TARGETS[options.target];
if (!target) throw new Error(`Unknown Dawn target ${options.target}.`);
const buildDir = options.buildDir || dawnBuildDir(options.dawn, options.target, options.abi);
const buildDir = options.buildDir
|| dawnBuildDir(options.dawn, options.target, options.abi, options.androidPlatform);
if (existsSync(dawnLibrary(buildDir, options.target))) return buildDir;

const cmake = options.cmake || 'cmake';
Expand All @@ -145,7 +155,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-29'}`,
`-DANDROID_PLATFORM=${options.androidPlatform || androidMinPlatform()}`,
'-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
Expand Down
3 changes: 2 additions & 1 deletion build-tools/tasks/nativeTemplateEmit.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
// to put a game on a phone.

import path from 'path';
import { androidMinPlatform } from '../utils/androidFloor.js';
import { existsSync, readFileSync, writeFileSync } from 'fs';
import { createHash } from 'crypto';
import { mkdir, rm, cp, readdir } from 'fs/promises';
Expand Down Expand Up @@ -200,7 +201,7 @@ export async function emitNativeTemplate(options) {
spineVersion: options.spineVersion || '4.2',
...(platform === 'ios' ? { deploymentTarget: options.deploymentTarget || '17.0' } : {}),
...(platform === 'android'
? { androidPlatform: options.androidPlatform || 'android-29', abis: templateAbis(dir) }
? { androidPlatform: options.androidPlatform || androidMinPlatform(), abis: templateAbis(dir) }
: {}),
});

Expand Down
44 changes: 44 additions & 0 deletions build-tools/utils/androidFloor.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright (c) 2024-present ESEngine Team
/**
* @file The Android API floor, read from the one place that declares it.
*
* The NDK must build against exactly the manifest's minSdkVersion. Above it,
* every `__builtin_available` guard compiles out and its symbol becomes a
* load-time requirement, so the app installs on an older device and then fails
* before a line of our code runs — which is how a build targeted at android-33
* shipped a host that could not dlopen below API 31.
*
* That coupling used to be a rule someone had to remember while editing four
* defaults in four files. Reading the number instead means the manifest is the
* only place it is written, and the two cannot disagree.
*/
import { readFileSync } from 'fs';
import path from 'path';

import config from '../build.config.js';

/** The template every packaged game's manifest is filled from. */
export function manifestTemplatePath() {
return path.join(config.paths.root, 'native', 'android', 'host', 'AndroidManifest.xml.in');
}

let cached = null;

/** `minSdkVersion` as declared, e.g. 24. */
export function androidMinSdk() {
if (cached !== null) return cached;
const template = manifestTemplatePath();
const found = /android:minSdkVersion="(\d+)"/.exec(readFileSync(template, 'utf8'));
if (!found) {
throw new Error(`No android:minSdkVersion in ${template} — it is the single source for `
+ 'the API level the NDK builds against, so a build cannot proceed without it.');
}
cached = Number(found[1]);
return cached;
}

/** The same number as the NDK spells it, e.g. `android-24`. */
export function androidMinPlatform() {
return `android-${androidMinSdk()}`;
}
7 changes: 4 additions & 3 deletions build-tools/utils/apk.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,10 @@ import { fillTemplate, androidScreenOrientation } from './nativeApp.js';
* smaller page size also divides. */
const PAGE_ALIGNMENT = 16384;

/** 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. */
/** APK Signature Scheme v2, introduced in API 24 — exactly the minSdk, so every
* device that can install this can verify it and v1 (JAR signing) would be dead
* weight. Its PKCS#7 is the only part of APK signing that is genuinely hard to
* write, so a floor below 24 would cost considerably more than one number. */
const V2_BLOCK_ID = 0x7109871a;
const SIG_ALGO_RSA_PKCS1_SHA256 = 0x0103;
const APK_SIG_BLOCK_MAGIC = Buffer.from('APK Sig Block 42', 'latin1');
Expand Down
2 changes: 1 addition & 1 deletion build-tools/utils/gradleProject.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export function gradleManifest(templateXml, app) {
HAS_CODE: 'true',
});

const minSdk = Number(/android:minSdkVersion="(\d+)"/.exec(filled)?.[1] ?? 29);
const minSdk = Number(/android:minSdkVersion="(\d+)"/.exec(filled)?.[1] ?? 24);
const targetSdk = Number(/android:targetSdkVersion="(\d+)"/.exec(filled)?.[1] ?? 33);

const xml = filled
Expand Down
Loading