diff --git a/.github/workflows/android-compat.yml b/.github/workflows/android-compat.yml
index 70f652df2..4910b8914 100644
--- a/.github/workflows/android-compat.yml
+++ b/.github/workflows/android-compat.yml
@@ -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.'
@@ -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:
@@ -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
@@ -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
@@ -349,6 +368,7 @@ 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
@@ -356,8 +376,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
script: bash tools/android-compat-run.sh ${{ matrix.api }}
- name: Keep the frame, the record and the numbers
@@ -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
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4abb9a479..deed33f20 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/build-tools/cli.js b/build-tools/cli.js
index 7837fdc32..1528d5638 100644
--- a/build-tools/cli.js
+++ b/build-tools/cli.js
@@ -167,7 +167,9 @@ 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-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 ', 'Android platform (default: the manifest\'s minSdkVersion)')
.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 0831f24d8..e00581edd 100644
--- a/build-tools/tasks/native.js
+++ b/build-tools/tasks/native.js
@@ -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';
@@ -319,7 +320,7 @@ 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();
@@ -327,7 +328,7 @@ async function buildAndroidHost(options) {
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);
@@ -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
diff --git a/build-tools/tasks/nativeDeps.js b/build-tools/tasks/nativeDeps.js
index 027ec349c..b285437a9 100644
--- a/build-tools/tasks/nativeDeps.js
+++ b/build-tools/tasks/nativeDeps.js
@@ -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';
@@ -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);
}
/**
@@ -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';
@@ -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
diff --git a/build-tools/tasks/nativeTemplateEmit.js b/build-tools/tasks/nativeTemplateEmit.js
index 1ce6293ab..76b045bdd 100644
--- a/build-tools/tasks/nativeTemplateEmit.js
+++ b/build-tools/tasks/nativeTemplateEmit.js
@@ -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';
@@ -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) }
: {}),
});
diff --git a/build-tools/utils/androidFloor.js b/build-tools/utils/androidFloor.js
new file mode 100644
index 000000000..41bbfd64b
--- /dev/null
+++ b/build-tools/utils/androidFloor.js
@@ -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()}`;
+}
diff --git a/build-tools/utils/apk.js b/build-tools/utils/apk.js
index 25f9937de..77add482c 100644
--- a/build-tools/utils/apk.js
+++ b/build-tools/utils/apk.js
@@ -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');
diff --git a/build-tools/utils/gradleProject.js b/build-tools/utils/gradleProject.js
index 830537fd2..68034dab8 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] ?? 29);
+ const minSdk = Number(/android:minSdkVersion="(\d+)"/.exec(filled)?.[1] ?? 24);
const targetSdk = Number(/android:targetSdkVersion="(\d+)"/.exec(filled)?.[1] ?? 33);
const xml = filled
diff --git a/docs/astro/src/content/docs/guides/mobile.mdx b/docs/astro/src/content/docs/guides/mobile.mdx
index 37f2cc72e..662f2ef93 100644
--- a/docs/astro/src/content/docs/guides/mobile.mdx
+++ b/docs/astro/src/content/docs/guides/mobile.mdx
@@ -53,18 +53,30 @@ where Xcode does the assembling.
| | Android | iOS |
|---|---|---|
-| Minimum OS | **Android 10** (API 29) | **iOS 17.0** |
+| Minimum OS | **Android 7.0** (API 24) | **iOS 17.0** |
| Declared target | API 33 | — |
| CPU | `arm64-v8a` (every real device) + `x86_64` (the emulator) | arm64 device + simulator slice |
| GPU | **Vulkan 1.0.3 required** — declared `required="true"`, so a device without it does not see the app on Play | Metal |
-Android's floor is 29 because the engine's font path calls `AFontMatcher_create`,
-which is API 29 — below that there is nothing to fall back to. It was **declared** as
-26 through v0.37.0, which was a claim rather than a capability: those builds install
-on Android 10 and 11 and then fail to start, because the NDK was told to build against
-a higher API and turned every guarded symbol into a load-time requirement. Nothing
-that ran on v0.37.0 stops running on v0.38.0 — the number now says what was always
-true.
+Android's floor is 24 because that is where Vulkan and `AChoreographer` both arrive,
+and the engine cannot run without either. Read the GPU row before the OS row, though:
+Vulkan stayed **optional** for hardware until long after Android 7, so `required="true"`
+filters out far more devices than the API level does. What API 24 adds is the Android
+7 and 8 era hardware that does have a Vulkan driver — largely the flagships of the
+time — not every phone still running those releases.
+
+The floor was 29 through v0.40.0, because the font path called `AFontMatcher_create`
+and that is API 29 with nothing below it to fall back to. There is now: on 24 through
+28 the engine picks the font out of `/system/fonts` itself, asking each candidate
+whether it actually has a glyph for the character rather than trusting a family name.
+System font aliases and the platform's own fallback ordering are lost, so a character
+two fonts both cover may resolve to the other one — it draws either way.
+
+
+## Where it actually lands
+
+There is no directory to open, and that is deliberate: the web and the mini-game
+platforms have no path namespace to hand you, so a `getSaveDirectory()` would be
+a function only two of five platforms could answer, and every game calling it
+would grow a branch per platform. `Storage` is the portable answer — write through
+it and each platform's durable store is used for you.
+
+| Platform | Backing store |
+| --- | --- |
+| Web | `localStorage` |
+| WeChat / mini-games | `wx.setStorageSync` and friends |
+| iOS | `Application Support/estella-storage.json` |
+| Android | `files/estella-storage.json` (the app's internal data dir) |
+| Node | memory only — it does not outlive the process |
+
+On iOS this is Application Support rather than Caches on purpose — iOS empties
+Caches whenever it wants the space back, so a save kept there is one the player
+can lose. Both mobile directories are private to the app and included in the
+device backup a player restores a new phone from.
+
+`platformReadFile()` is **not** a way to reach these. It reads files packaged
+*into* the build (the APK's `assets/`, the iOS app bundle) and is read-only.
+
## See also
- [Scenes](/docs/guides/scene/) — loading and switching the scenes a save points at.
diff --git a/docs/astro/src/content/docs/zh-cn/guides/mobile.mdx b/docs/astro/src/content/docs/zh-cn/guides/mobile.mdx
index 68ab13834..f5d1c8f1d 100644
--- a/docs/astro/src/content/docs/zh-cn/guides/mobile.mdx
+++ b/docs/astro/src/content/docs/zh-cn/guides/mobile.mdx
@@ -46,16 +46,27 @@ Android 两者都给,是因为它两者都做得到:编辑器既能直接装配
| | Android | iOS |
|---|---|---|
-| 最低系统 | **Android 10**(API 29) | **iOS 17.0** |
+| 最低系统 | **Android 7.0**(API 24) | **iOS 17.0** |
| 声明的 target | API 33 | — |
| CPU | `arm64-v8a`(所有真机)+ `x86_64`(模拟器) | arm64 真机 + 模拟器切片 |
| GPU | **要求 Vulkan 1.0.3**——声明为 `required="true"`,不具备的设备在 Play 上根本看不到这个应用 | Metal |
-Android 的下限是 29,因为引擎的字体路径调用 `AFontMatcher_create`,那是 API 29——
-再往下没有可退的路。直到 v0.37.0 它**声明**的都还是 26,而那是个说法而非能力:
-那些构建能装到 Android 10 和 11 上,然后起不来——因为 NDK 被要求按更高的 API 构建,
-于是把每个受保护的符号都变成了加载期的硬依赖。v0.37.0 上能跑的东西在 v0.38.0 上
-一样能跑,这个数字只是终于说了实话。
+Android 的下限是 24,因为 Vulkan 和 `AChoreographer` 都在这一版到齐,而引擎缺哪个都跑
+不了。不过 GPU 那行要比系统那行先看:Vulkan 在硬件上一直是**可选**的,直到远晚于
+Android 7 才普及,所以 `required="true"` 挡掉的设备比 API 级别挡掉的多得多。API 24
+真正带来的是 Android 7、8 时代**有 Vulkan 驱动**的机器——主要是当年的旗舰——而不是
+所有还在跑这些版本的手机。
+
+直到 v0.40.0 下限还是 29,因为字体路径调用 `AFontMatcher_create`,那是 API 29 且往下
+没有可退的路。现在有了:在 24 到 28 上,引擎自己从 `/system/fonts` 里挑字体,逐个问候选
+字体**是否真的有这个字的字形**,而不是去信一个 family 名字。系统的字体别名和它自己的
+回退顺序都拿不到了,所以一个两种字体都覆盖的字可能落到另一种上——两种都画得出来。
+
+
+## 实际存在哪里
+
+没有目录可以打开,这是刻意的:Web 和小游戏平台根本没有路径命名空间可给,所以
+`getSaveDirectory()` 会是一个五个平台里只有两个答得上来的函数,而每个调用它的游戏
+都会因此长出逐平台分支。`Storage` 就是可移植的答案——写进去,各平台的耐久存储会被
+替你选好。
+
+| 平台 | 后端存储 |
+| --- | --- |
+| Web | `localStorage` |
+| 微信 / 小游戏 | `wx.setStorageSync` 等 |
+| iOS | `Application Support/estella-storage.json` |
+| Android | `files/estella-storage.json`(应用内部数据目录) |
+| Node | 仅内存——不跨进程存活 |
+
+iOS 上用的是 Application Support 而不是 Caches,这是有意为之——iOS 想要回空间时随时
+会清空 Caches,存在那里的存档玩家可能凭空丢失。两个移动平台的目录都是应用私有的,并且
+会进入玩家换新手机时恢复的设备备份。
+
+`platformReadFile()` **不是**读它们的途径。它读的是**打包进**构建产物里的文件
+(APK 的 `assets/`、iOS 的 app bundle),且只读。
+
## 参见
- [场景](/docs/zh-cn/guides/scene/) —— 加载与切换存档所指向的场景。
diff --git a/native/CMakeLists.txt b/native/CMakeLists.txt
index 1e16cedab..0522add08 100644
--- a/native/CMakeLists.txt
+++ b/native/CMakeLists.txt
@@ -243,6 +243,7 @@ if(ESTELLA_QUICKJS_DIR)
host/media/audio_spectrum.cpp
host/media/ktx2_decode.cpp
host/media/glyph_raster.cpp
+ host/media/font_scan.cpp
"${BASIS_TRANSCODER}" "${BASIS_ZSTD}" "${NATIVE_BINDINGS}" "${NATIVE_FN_BINDINGS}")
if(ESTELLA_IOS)
diff --git a/native/android/host/AndroidManifest.xml.in b/native/android/host/AndroidManifest.xml.in
index 787912588..80ab9421e 100644
--- a/native/android/host/AndroidManifest.xml.in
+++ b/native/android/host/AndroidManifest.xml.in
@@ -9,21 +9,25 @@
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
+ minSdkVersion 24 (Android 7.0), and the NDK MUST build against the same number
+ (`--platform`, build-tools/tasks/native.js). 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.
+
+ 24 is where Vulkan and AChoreographer both arrive, which is the real floor: the
+ host has no GLES path, so a device without Vulkan cannot run it at any API
+ level. The uses-feature below is what keeps such a device from being offered
+ the app at all, and it does more filtering than minSdkVersion does — Vulkan was
+ optional until well after 24.
-->
-
+
diff --git a/native/host/Host.hpp b/native/host/Host.hpp
index 7ae91accb..86e8f0a4d 100644
--- a/native/host/Host.hpp
+++ b/native/host/Host.hpp
@@ -93,9 +93,27 @@ struct Platform {
* app bundle on iOS. Empty when missing. Backs NativeBridge.readFile. */
virtual std::vector readAsset(const char* path) = 0;
- /** Writable private directory for the SDK bytecode cache; empty disables it. */
+ /**
+ * Writable private directory for things the host can REGENERATE — the SDK
+ * bytecode cache, downloaded hot-update content. Empty disables it.
+ *
+ * The platform is free to delete this whenever it wants the space back, and
+ * iOS does. Nothing a player would notice losing belongs here; that is
+ * {@link dataDir}, and the split exists because the two were once one
+ * directory whose durability quietly differed per platform.
+ */
virtual std::string cacheDir() = 0;
+ /**
+ * Writable private directory that SURVIVES — saves, settings, progress.
+ *
+ * Distinct from {@link cacheDir} in exactly one way that matters: the
+ * platform will not reclaim it, and it is included in the device backup a
+ * player restores a new phone from. Empty means this platform cannot promise
+ * that, and storage falls back to lasting only for the session.
+ */
+ virtual std::string dataDir() = 0;
+
/**
* Writable directory a PLAYER can reach — where the boot record goes.
*
diff --git a/native/host/Runtime.cpp b/native/host/Runtime.cpp
index e69a2c4d9..0540c72da 100644
--- a/native/host/Runtime.cpp
+++ b/native/host/Runtime.cpp
@@ -52,6 +52,7 @@ void createHost(Platform& platform) {
g_host = &state;
state.platform = &platform;
state.cacheDir = platform.cacheDir();
+ state.dataDir = platform.dataDir();
}
void hostLog(bool error, const char* fmt, ...) {
diff --git a/native/host/Runtime.hpp b/native/host/Runtime.hpp
index 8fb38e3e5..7d5a89f6a 100644
--- a/native/host/Runtime.hpp
+++ b/native/host/Runtime.hpp
@@ -70,7 +70,8 @@ struct HostState {
int nextFetchId = 1;
AudioEngine audio; ///< native sound (miniaudio); silent if no device
- std::string cacheDir; ///< app private dir — SDK bytecode cache + asset cache
+ std::string cacheDir; ///< reclaimable — SDK bytecode cache + hot-update content
+ std::string dataDir; ///< durable — saves and settings; survives what cacheDir does not
esengine::f32 w = 0, h = 0;
bool ready = false; ///< engine + JS booted once
diff --git a/native/host/bindings/AssetBindings.cpp b/native/host/bindings/AssetBindings.cpp
index ecc157d50..773b1bd14 100644
--- a/native/host/bindings/AssetBindings.cpp
+++ b/native/host/bindings/AssetBindings.cpp
@@ -2,13 +2,14 @@
// SPDX-FileCopyrightText: Copyright (c) 2024-present ESEngine Team
/**
* @file AssetBindings.cpp
- * @brief Packaged files, image decode, the writable cache, and UTF-8 decoding.
+ * @brief Packaged files, image decode, the writable stores, and UTF-8 decoding.
* @details What the SDK's platform layer reads a game off the device with. The
* decode path is "Path 2" — the host decodes an image to RGBA and the
* native ResourceManager uploads the bytes — because there is no
- * offscreen DOM canvas here. The cache is the hot-update offline store:
- * content-addressed keys written after verification, so a returning
- * player boots updated content with no network.
+ * offscreen DOM canvas here. There are two writable stores, and which
+ * one a caller picks is a statement about what may be lost: the cache
+ * is the hot-update offline store, content-addressed and refetchable,
+ * while data holds what a player would notice gone.
*
* @author ESEngine Team
* @date 2026
@@ -60,25 +61,19 @@ JSValue js_loadImagePixels(JSContext* ctx, JSValueConst, int, JSValueConst* argv
return obj;
}
-// The host's writable store, under Platform::cacheDir(). Backs both the
-// hot-update offline cache and (through it) key-value storage. Keys are
-// content hashes or plain names; anything else is refused rather than escaping
-// the directory.
-std::string cachePathFor(const char* key) {
- if (!key || !*key || host().cacheDir.empty()) return {};
+// A file in one of the host's two writable directories. Keys are content hashes
+// or plain names; anything else is refused rather than escaping the directory.
+std::string pathIn(const std::string& dir, const char* key) {
+ if (!key || !*key || dir.empty()) return {};
for (const char* c = key; *c; ++c) {
const bool ok = (*c >= 'a' && *c <= 'z') || (*c >= 'A' && *c <= 'Z')
|| (*c >= '0' && *c <= '9') || *c == '.' || *c == '_' || *c == '-';
if (!ok) return {};
}
- return host().cacheDir + "/" + key;
+ return dir + "/" + key;
}
-// es_readCacheFile(key) -> ArrayBuffer | null.
-JSValue js_readCacheFile(JSContext* ctx, JSValueConst, int, JSValueConst* argv) {
- const char* key = JS_ToCString(ctx, argv[0]);
- const std::string path = cachePathFor(key);
- if (key) JS_FreeCString(ctx, key);
+JSValue readFileAt(JSContext* ctx, const std::string& path) {
if (path.empty()) return JS_NULL;
FILE* f = fopen(path.c_str(), "rb");
if (!f) return JS_NULL;
@@ -92,29 +87,67 @@ JSValue js_readCacheFile(JSContext* ctx, JSValueConst, int, JSValueConst* argv)
return JS_NewArrayBufferCopy(ctx, bytes.data(), bytes.size());
}
-// es_writeCacheFile(key, ArrayBuffer | TypedArray | string) -> bool. A string is
-// written as UTF-8, so script-side JSON needs no TextEncoder.
-JSValue js_writeCacheFile(JSContext* ctx, JSValueConst, int argc, JSValueConst* argv) {
- if (argc < 2) return JS_FALSE;
- const char* key = JS_ToCString(ctx, argv[0]);
- const std::string path = cachePathFor(key);
- if (key) JS_FreeCString(ctx, key);
+// A string source is written as UTF-8, so script-side JSON needs no TextEncoder.
+JSValue writeFileAt(JSContext* ctx, const std::string& path, JSValueConst source) {
if (path.empty()) return JS_FALSE;
std::vector bytes;
- if (JS_IsString(argv[1])) {
+ if (JS_IsString(source)) {
size_t len = 0;
- if (const char* text = JS_ToCStringLen(ctx, &len, argv[1])) {
+ if (const char* text = JS_ToCStringLen(ctx, &len, source)) {
bytes.assign(text, text + len);
JS_FreeCString(ctx, text);
}
} else {
- readByteSource(ctx, argv[1], bytes);
+ readByteSource(ctx, source, bytes);
}
- FILE* f = fopen(path.c_str(), "wb");
+ // Write a temp file and rename over the target: a crash or a kill mid-write
+ // must not leave a truncated file where a save used to be. rename(2) within
+ // one directory is atomic, so a reader sees the old file or the new one.
+ const std::string tmp = path + ".tmp";
+ FILE* f = fopen(tmp.c_str(), "wb");
if (!f) return JS_FALSE;
- const bool ok = bytes.empty() || fwrite(bytes.data(), 1, bytes.size(), f) == bytes.size();
+ const bool wrote = bytes.empty() || fwrite(bytes.data(), 1, bytes.size(), f) == bytes.size();
+ const bool flushed = wrote && fflush(f) == 0;
fclose(f);
- return ok ? JS_TRUE : JS_FALSE;
+ if (!flushed || rename(tmp.c_str(), path.c_str()) != 0) {
+ remove(tmp.c_str());
+ return JS_FALSE;
+ }
+ return JS_TRUE;
+}
+
+// es_readCacheFile(key) -> ArrayBuffer | null. Reclaimable half: hot-update content.
+JSValue js_readCacheFile(JSContext* ctx, JSValueConst, int, JSValueConst* argv) {
+ const char* key = JS_ToCString(ctx, argv[0]);
+ const std::string path = pathIn(host().cacheDir, key);
+ if (key) JS_FreeCString(ctx, key);
+ return readFileAt(ctx, path);
+}
+
+// es_writeCacheFile(key, ArrayBuffer | TypedArray | string) -> bool.
+JSValue js_writeCacheFile(JSContext* ctx, JSValueConst, int argc, JSValueConst* argv) {
+ if (argc < 2) return JS_FALSE;
+ const char* key = JS_ToCString(ctx, argv[0]);
+ const std::string path = pathIn(host().cacheDir, key);
+ if (key) JS_FreeCString(ctx, key);
+ return writeFileAt(ctx, path, argv[1]);
+}
+
+// es_readDataFile(key) -> ArrayBuffer | null. Durable half: what a player keeps.
+JSValue js_readDataFile(JSContext* ctx, JSValueConst, int, JSValueConst* argv) {
+ const char* key = JS_ToCString(ctx, argv[0]);
+ const std::string path = pathIn(host().dataDir, key);
+ if (key) JS_FreeCString(ctx, key);
+ return readFileAt(ctx, path);
+}
+
+// es_writeDataFile(key, ArrayBuffer | TypedArray | string) -> bool.
+JSValue js_writeDataFile(JSContext* ctx, JSValueConst, int argc, JSValueConst* argv) {
+ if (argc < 2) return JS_FALSE;
+ const char* key = JS_ToCString(ctx, argv[0]);
+ const std::string path = pathIn(host().dataDir, key);
+ if (key) JS_FreeCString(ctx, key);
+ return writeFileAt(ctx, path, argv[1]);
}
// es_utf8Decode(ArrayBuffer | TypedArray) -> string. Backs the TextDecoder the
@@ -149,6 +182,8 @@ void registerAssetBindings(HostState& h, JSValue global) {
bindGlobal(h, global, "es_loadImagePixels", js_loadImagePixels, 1);
bindGlobal(h, global, "es_readCacheFile", js_readCacheFile, 1);
bindGlobal(h, global, "es_writeCacheFile", js_writeCacheFile, 2);
+ bindGlobal(h, global, "es_readDataFile", js_readDataFile, 1);
+ bindGlobal(h, global, "es_writeDataFile", js_writeDataFile, 2);
bindGlobal(h, global, "es_utf8Decode", js_utf8Decode, 1);
}
diff --git a/native/host/media/font_scan.cpp b/native/host/media/font_scan.cpp
new file mode 100644
index 000000000..b38de8b20
--- /dev/null
+++ b/native/host/media/font_scan.cpp
@@ -0,0 +1,194 @@
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-FileCopyrightText: Copyright (c) 2024-present ESEngine Team
+/**
+ * @file font_scan.cpp
+ * @brief Directory font matching by codepoint coverage.
+ *
+ * @author ESEngine Team
+ * @date 2026
+ *
+ * @copyright Copyright (c) 2026 ESEngine Team
+ * Licensed under the Apache License, Version 2.0.
+ */
+#include "font_scan.hpp"
+
+// The implementation lives in glyph_raster.cpp; this TU only needs the decls.
+#include "stb_truetype.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+using namespace esengine;
+
+namespace eshost {
+namespace {
+
+bool hasSuffix(const std::string& s, const char* suffix) {
+ const size_t n = strlen(suffix);
+ if (s.size() < n) return false;
+ return std::equal(s.end() - n, s.end(), suffix,
+ [](char a, char b) { return std::tolower(a) == std::tolower(b); });
+}
+
+/** Letters and digits only, lowercased — so "Noto Sans CJK" and
+ * "NotoSansCJK-Regular.ttc" can be compared without either being wrong. */
+std::string fold(const std::string& s) {
+ std::string out;
+ out.reserve(s.size());
+ for (const char c : s) {
+ if (std::isalnum((unsigned char)c)) out.push_back((char)std::tolower(c));
+ }
+ return out;
+}
+
+std::vector readFile(const std::string& path) {
+ std::vector bytes;
+ FILE* f = fopen(path.c_str(), "rb");
+ if (!f) return bytes;
+ fseek(f, 0, SEEK_END);
+ const long size = ftell(f);
+ fseek(f, 0, SEEK_SET);
+ if (size > 0) {
+ bytes.resize((size_t)size);
+ if (fread(bytes.data(), 1, bytes.size(), f) != bytes.size()) bytes.clear();
+ }
+ fclose(f);
+ return bytes;
+}
+
+/** Every font file in @p dir, listed once. Names only — a system font directory
+ * holds well over a hundred megabytes across its CJK files, so nothing is read
+ * until a candidate is actually being considered. */
+const std::vector& listing(const char* dir) {
+ static std::unordered_map> cache;
+ auto it = cache.find(dir);
+ if (it != cache.end()) return it->second;
+
+ std::vector names;
+ if (DIR* d = opendir(dir)) {
+ while (const dirent* e = readdir(d)) {
+ const std::string name = e->d_name;
+ if (hasSuffix(name, ".ttf") || hasSuffix(name, ".otf") || hasSuffix(name, ".ttc")) {
+ names.push_back(name);
+ }
+ }
+ closedir(d);
+ }
+ // Stable order, so the same device answers the same way every launch.
+ std::sort(names.begin(), names.end());
+ return cache.emplace(dir, std::move(names)).first->second;
+}
+
+/** Generic CSS families have no file of their own; these are what Android has
+ * shipped under those names since well before the floor. */
+const char* const* genericStems(const std::string& folded, size_t& count) {
+ static const char* kSans[] = {"roboto", "droidsans", "notosans"};
+ static const char* kSerif[] = {"notoserif", "droidserif"};
+ static const char* kMono[] = {"droidsansmono", "cutivemono", "notomono"};
+ if (folded == "serif") { count = 2; return kSerif; }
+ if (folded == "monospace" || folded == "mono") { count = 3; return kMono; }
+ count = 3;
+ return kSans; // sans-serif, and anything unrecognized
+}
+
+int rank(const std::string& name, const std::string& family, bool wantBold, bool wantItalic) {
+ const std::string folded = fold(name);
+ const std::string wanted = fold(family.empty() ? "sans-serif" : family);
+
+ int score = 0;
+ if (!wanted.empty() && folded.find(wanted) != std::string::npos) {
+ score += 100;
+ } else {
+ size_t count = 0;
+ const char* const* stems = genericStems(wanted, count);
+ for (size_t i = 0; i < count; ++i) {
+ if (folded.find(stems[i]) != std::string::npos) { score += 60 - (int)i; break; }
+ }
+ }
+ // Style is read off the filename because the alternative is parsing every
+ // candidate's OS/2 table to sort candidates we have not read yet. Android's
+ // own files are named for their style; a device whose are not still gets a
+ // correct glyph, drawn from a face this scores lower.
+ const bool isBold = folded.find("bold") != std::string::npos;
+ const bool isItalic = folded.find("italic") != std::string::npos
+ || folded.find("oblique") != std::string::npos;
+ if (isBold == wantBold) score += 10;
+ if (isItalic == wantItalic) score += 5;
+ return score;
+}
+
+/** Paths that covered a codepoint before, newest first. CJK resolves to one huge
+ * file, and without this every glyph in a sentence would re-read the candidates
+ * ahead of it before arriving at the same answer. */
+std::vector& recentFor(const std::string& family, int style) {
+ static std::unordered_map> mru;
+ return mru[family + "\x1f" + std::to_string(style)];
+}
+
+void remember(std::vector& mru, const std::string& name) {
+ mru.erase(std::remove(mru.begin(), mru.end(), name), mru.end());
+ mru.insert(mru.begin(), name);
+ if (mru.size() > 4) mru.resize(4);
+}
+
+/** Does any face in this file draw @p codepoint? Fills the face index if so. */
+bool covers(const std::vector& bytes, u32 codepoint, int& faceIndex) {
+ const int faces = stbtt_GetNumberOfFonts(bytes.data());
+ for (int i = 0; i < (faces > 0 ? faces : 1); ++i) {
+ const int offset = stbtt_GetFontOffsetForIndex(bytes.data(), i);
+ if (offset < 0) continue;
+ stbtt_fontinfo info{};
+ if (!stbtt_InitFont(&info, bytes.data(), offset)) continue;
+ if (stbtt_FindGlyphIndex(&info, (int)codepoint) != 0) { faceIndex = i; return true; }
+ }
+ return false;
+}
+
+} // namespace
+
+FontFile scanFontDir(const char* dir, const std::string& family, u32 codepoint, int style) {
+ FontFile out;
+ const std::vector& names = listing(dir);
+ if (names.empty()) return out;
+
+ const bool wantBold = (style & GLYPH_BOLD) != 0;
+ const bool wantItalic = (style & GLYPH_ITALIC) != 0;
+ const u32 wanted = codepoint ? codepoint : (u32)'A';
+
+ std::vector& mru = recentFor(family, style);
+ std::vector order(mru);
+ std::vector rest;
+ for (const std::string& name : names) {
+ if (std::find(mru.begin(), mru.end(), name) == mru.end()) rest.push_back(name);
+ }
+ std::stable_sort(rest.begin(), rest.end(), [&](const std::string& a, const std::string& b) {
+ return rank(a, family, wantBold, wantItalic) > rank(b, family, wantBold, wantItalic);
+ });
+ order.insert(order.end(), rest.begin(), rest.end());
+
+ for (const std::string& name : order) {
+ const std::string path = std::string(dir) + "/" + name;
+ std::vector bytes = readFile(path);
+ if (bytes.empty()) continue;
+ int faceIndex = 0;
+ if (!covers(bytes, wanted, faceIndex)) continue; // freed on the next pass
+
+ const std::string folded = fold(name);
+ out.path = path;
+ out.bytes = std::move(bytes);
+ out.faceIndex = faceIndex;
+ out.syntheticBold = wantBold && folded.find("bold") == std::string::npos;
+ out.syntheticItalic = wantItalic && folded.find("italic") == std::string::npos
+ && folded.find("oblique") == std::string::npos;
+ remember(mru, name);
+ return out;
+ }
+ return out;
+}
+
+} // namespace eshost
diff --git a/native/host/media/font_scan.hpp b/native/host/media/font_scan.hpp
new file mode 100644
index 000000000..4f16d700a
--- /dev/null
+++ b/native/host/media/font_scan.hpp
@@ -0,0 +1,39 @@
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-FileCopyrightText: Copyright (c) 2024-present ESEngine Team
+/**
+ * @file font_scan.hpp
+ * @brief Pick a font file out of a directory, for platforms with no matcher.
+ * @details {@link Platform::loadFont} asks for a file that covers one codepoint,
+ * which iOS answers with CoreText and Android with AFontMatcher — but
+ * AFontMatcher is API 29 and the Android floor is 24, so between them
+ * there is no OS matcher to ask. Choosing a file out of a directory of
+ * them is not Android knowledge, so it lives here beside the rasterizer
+ * that already owns stb_truetype rather than in one platform's glue.
+ *
+ * @author ESEngine Team
+ * @date 2026
+ *
+ * @copyright Copyright (c) 2026 ESEngine Team
+ * Licensed under the Apache License, Version 2.0.
+ */
+#pragma once
+
+#include "Host.hpp"
+#include "glyph_raster.hpp" // GLYPH_BOLD / GLYPH_ITALIC — the `style` below is these
+
+#include
+
+namespace eshost {
+
+/**
+ * The first font in @p dir that has a glyph for @p codepoint, preferring files
+ * whose name looks like @p family and the requested @p style.
+ *
+ * Coverage is decided by asking the font, not by trusting a name: a family is
+ * only the starting order, and the answer is whichever candidate actually draws
+ * the character. An empty {@link FontFile::path} means nothing in the directory
+ * covers it.
+ */
+FontFile scanFontDir(const char* dir, const std::string& family, esengine::u32 codepoint, int style);
+
+} // namespace eshost
diff --git a/native/host/platform/android.cpp b/native/host/platform/android.cpp
index 10c525439..d481edc18 100644
--- a/native/host/platform/android.cpp
+++ b/native/host/platform/android.cpp
@@ -32,6 +32,7 @@
#include "Host.hpp"
#include "media/glyph_raster.hpp" // GLYPH_BOLD / GLYPH_ITALIC, for the font match
+#include "media/font_scan.hpp" // the pre-29 stand-in for AFontMatcher
#define LOG_TAG "EstellaSDK"
@@ -146,7 +147,8 @@ struct AndroidPlatform final : eshost::Platform {
AAssetManager* assets = nullptr; // APK assets/ — the game + its content
ANativeWindow* window = nullptr;
JavaVM* vm = nullptr; // for JNI HttpURLConnection off-thread
- std::string cache; // app private dir — SDK bytecode cache
+ std::string cache; // getCacheDir() — the system may reclaim it
+ std::string data; // internalDataPath — files/, survives
std::string logs; // Android/data//files — a player can open this
// Read an APK asset (assets/) fully into a buffer; empty if missing.
@@ -166,6 +168,8 @@ struct AndroidPlatform final : eshost::Platform {
std::string cacheDir() override { return cache; }
+ std::string dataDir() override { return data; }
+
std::string logDir() override { return logs; }
/**
@@ -223,7 +227,19 @@ struct AndroidPlatform final : eshost::Platform {
// installed families, applies weight/italic, and — the part worth having —
// falls back per codepoint, so CJK text resolves to Noto without the host
// hard-coding a single font path.
+ //
+ // Below 29 there is no such API, and `/system/fonts` is what the matcher is
+ // reading anyway. Scanning it ourselves loses the system's own family
+ // aliases and its ordering, so a codepoint two fonts both cover may resolve
+ // to the other one — the character draws either way, which is the property
+ // that matters.
eshost::FontFile loadFont(const std::string& family, u32 codepoint, int style) override {
+ if (__builtin_available(android 29, *)) return matchFont(family, codepoint, style);
+ return eshost::scanFontDir("/system/fonts", family, codepoint, style);
+ }
+
+ eshost::FontFile matchFont(const std::string& family, u32 codepoint, int style)
+ __attribute__((availability(android, introduced = 29))) {
eshost::FontFile out;
AFontMatcher* matcher = AFontMatcher_create();
if (!matcher) return out;
@@ -502,19 +518,17 @@ void onAppCmd(android_app* app, int32_t cmd) {
//
// 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.
+// This predates the build turning on ANDROID_WEAK_API_DEFS, which is what makes
+// `__builtin_available` mean anything: without it the NDK marks a newer symbol
+// `unavailable` outright — not "call me under a check" but "this build cannot
+// see me" — and no guard satisfies that. Every other guarded call here now goes
+// through the flag; ADPF still comes through dlsym because it works and this is
+// not the change to test it in.
//
-// 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.
+// The objection to the flag was that its absence would silently restore the
+// failure it prevents. That was true while the floor equalled the newest API
+// this file called, when dropping it changed nothing. Below that floor it is a
+// compile error naming the symbol, which is how the flag got turned on.
struct PerformanceHints {
void* session = nullptr;
@@ -644,8 +658,8 @@ struct FrameDriver {
if (__builtin_available(android 29, *)) {
AChoreographer_postFrameCallback64(choreographer, &FrameDriver::onVsync64, this);
} else {
- // Deprecated in 29 and the only one that exists below it. minSdk is 26,
- // so this branch is the two releases the 64-bit call cannot serve — not
+ // Deprecated in 29 and the only one that exists below it. minSdk is 24,
+ // so this branch is the five releases the 64-bit call cannot serve — not
// an oversight, which is why the warning is turned off rather than the
// call changed.
#pragma clang diagnostic push
@@ -805,6 +819,52 @@ void onWindowFocusChanged(ANativeActivity* activity, int hasFocus) {
if (g_glueWindowFocusChanged) g_glueWindowFocusChanged(activity, hasFocus);
}
+/**
+ * `Context.getCacheDir()`, asked rather than assembled.
+ *
+ * ANativeActivity hands over internalDataPath and externalDataPath and stops
+ * there, so the cache directory has to come over JNI. Deriving it from
+ * internalDataPath by swapping `files` for `cache` would be a guess about a
+ * layout that has already moved once (`/data/data` to `/data/user/0`), and the
+ * guess fails silently — as a directory that is simply never written.
+ *
+ * Empty on failure, which disables the bytecode cache and the hot-update store:
+ * both regenerate, so a slower boot is the whole cost.
+ */
+std::string queryCacheDir(ANativeActivity* activity) {
+ if (!activity || !activity->vm || !activity->clazz) return {};
+ JNIEnv* env = nullptr;
+ if (activity->vm->GetEnv(reinterpret_cast(&env), JNI_VERSION_1_6) != JNI_OK) {
+ if (activity->vm->AttachCurrentThread(&env, nullptr) != JNI_OK || !env) return {};
+ }
+ std::string out;
+ jclass ctxCls = env->GetObjectClass(activity->clazz);
+ jmethodID getCacheDir = env->GetMethodID(ctxCls, "getCacheDir", "()Ljava/io/File;");
+ if (getCacheDir) {
+ if (jobject file = env->CallObjectMethod(activity->clazz, getCacheDir)) {
+ jclass fileCls = env->GetObjectClass(file);
+ jmethodID getPath = env->GetMethodID(fileCls, "getAbsolutePath", "()Ljava/lang/String;");
+ if (getPath) {
+ if (jstring path = (jstring)env->CallObjectMethod(file, getPath)) {
+ if (const char* utf8 = env->GetStringUTFChars(path, nullptr)) {
+ out = utf8;
+ env->ReleaseStringUTFChars(path, utf8);
+ }
+ env->DeleteLocalRef(path);
+ }
+ }
+ env->DeleteLocalRef(fileCls);
+ env->DeleteLocalRef(file);
+ }
+ }
+ env->DeleteLocalRef(ctxCls);
+ if (env->ExceptionCheck()) {
+ env->ExceptionClear();
+ return {};
+ }
+ return out;
+}
+
} // namespace
void android_main(android_app* app) {
@@ -813,7 +873,9 @@ void android_main(android_app* app) {
// Before boot: whether there is an editing surface decides whether the
// es_textEditor_* entry points are bound at all.
attachTextEditor(app->activity);
- if (app->activity->internalDataPath) g_platform.cache = app->activity->internalDataPath;
+ // files/ holds what a player keeps; cache/ is the system's to reclaim.
+ if (app->activity->internalDataPath) g_platform.data = app->activity->internalDataPath;
+ g_platform.cache = queryCacheDir(app->activity);
// The boot record goes where a person can reach it without a cable.
if (app->activity->externalDataPath) g_platform.logs = app->activity->externalDataPath;
app->onAppCmd = onAppCmd;
diff --git a/native/host/platform/ios.mm b/native/host/platform/ios.mm
index 4a3d1667c..d073807fa 100644
--- a/native/host/platform/ios.mm
+++ b/native/host/platform/ios.mm
@@ -98,6 +98,26 @@ void textEditorWrite(const std::string& value, int selectionStart, int selection
return std::string([dirs[0] UTF8String]);
}
+ /** Application Support, not Caches: iOS empties Caches whenever it wants the
+ * space back, which is correct for content that refetches and wrong for a
+ * save. Unlike the other two this directory does not exist until an app
+ * makes it, so create it — and only report it once it is really there,
+ * since an empty answer degrades storage to the session rather than
+ * handing out a path that every write will fail against. */
+ std::string dataDir() override {
+ NSArray* dirs = NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, YES);
+ if (dirs.count == 0) return {};
+ NSString* path = dirs[0];
+ NSError* err = nil;
+ if (![[NSFileManager defaultManager] createDirectoryAtPath:path
+ withIntermediateDirectories:YES
+ attributes:nil
+ error:&err]) {
+ return {};
+ }
+ return std::string([path UTF8String]);
+ }
+
/** Documents, not Caches: the record exists to be sent, and Documents is the
* directory the Files app shows (with UIFileSharingEnabled) and iTunes/Finder
* can copy off a device that has none. */
diff --git a/sdk/etc/index.native.api.md b/sdk/etc/index.native.api.md
index ebda86b06..6ef27db04 100644
--- a/sdk/etc/index.native.api.md
+++ b/sdk/etc/index.native.api.md
@@ -4214,6 +4214,7 @@ es_loadImagePixels: (path: string) => { width: number; height: number; pixels: A
es_rasterizeGlyph: ((request: PlatformGlyphRequest) => { pixels: ArrayBuffer; width: number; height: number; advance: number; bearingX: number; bearingY: number; } | null) | undefined
es_readAsset: (path: string) => ArrayBuffer | null
es_readCacheFile: ((key: string) => ArrayBuffer | null) | undefined
+es_readDataFile: ((key: string) => ArrayBuffer | null) | undefined
es_removeStorageItem: ((key: string) => void) | undefined
es_setStorageItem: ((key: string, value: string) => void) | undefined
es_storageKeys: (() => string[]) | undefined
@@ -4221,6 +4222,7 @@ es_textEditor_blur: (() => void) | undefined
es_textEditor_focus: ((value: string, selectionStart: number, selectionEnd: number, multiline: boolean, maxLength: number, password: boolean) => void) | undefined
es_textEditor_write: ((value: string, selectionStart: number, selectionEnd: number) => void) | undefined
es_writeCacheFile: ((key: string, bytes: ArrayBuffer | Uint8Array | string) => boolean) | undefined
+es_writeDataFile: ((key: string, bytes: ArrayBuffer | Uint8Array | string) => boolean) | undefined
```
## NativeInputListener — interface @beta
diff --git a/sdk/src/platform/native/hostBridge.ts b/sdk/src/platform/native/hostBridge.ts
index 3bded7f00..54005cbcc 100644
--- a/sdk/src/platform/native/hostBridge.ts
+++ b/sdk/src/platform/native/hostBridge.ts
@@ -39,12 +39,18 @@ export interface NativeHostBindings {
pixels: ArrayBuffer; width: number; height: number;
advance: number; bearingX: number; bearingY: number;
} | null;
- /** A writable byte store (the host's cache dir). Optional: without it there
- * is no offline hot-update cache and no persistence for storage. */
+ /** A writable byte store the platform may reclaim (the host's cache dir).
+ * Optional: without it there is no offline hot-update cache. */
es_readCacheFile?(key: string): ArrayBuffer | null;
es_writeCacheFile?(key: string, bytes: ArrayBuffer | Uint8Array | string): boolean;
+ /** The durable store, for what a player would notice losing. Separate from the
+ * cache pair above because the platform is allowed to empty a cache and iOS
+ * does: a host that maps both onto one directory has decided saves are
+ * disposable, whether or not it meant to. */
+ es_readDataFile?(key: string): ArrayBuffer | null;
+ es_writeDataFile?(key: string, bytes: ArrayBuffer | Uint8Array | string): boolean;
/** Native key-value store, when the host has one (NSUserDefaults /
- * SharedPreferences). Absent hosts persist through es_writeCacheFile. */
+ * SharedPreferences). Absent hosts persist through es_writeDataFile. */
es_getStorageItem?(key: string): string | null;
es_setStorageItem?(key: string, value: string): void;
es_removeStorageItem?(key: string): void;
@@ -336,9 +342,14 @@ const STORAGE_FILE = 'estella-storage.json';
/**
* Key-value storage, best available: the host's own store, else a JSON file in
- * its writable cache dir, else memory for the session. The API is synchronous
+ * its durable directory, else memory for the session. The API is synchronous
* (localStorage's shape), so the file variant keeps the map in memory and writes
* through on every mutation — storage holds saves and settings, not bulk data.
+ *
+ * The file goes to the DATA store, never the cache. An older host that binds only
+ * the cache pair still persists through it, because a cache that usually survives
+ * beats losing the save at every exit — but it is the wrong directory on any
+ * platform that reclaims one, so say so once rather than let a player find out.
*/
function hostStorage(bindings: NativeHostBindings): Pick<
NativeBridge, 'getStorageItem' | 'setStorageItem' | 'removeStorageItem' | 'storageKeys'
@@ -352,13 +363,20 @@ function hostStorage(bindings: NativeHostBindings): Pick<
};
}
+ const durable = typeof bindings.es_readDataFile === 'function'
+ && typeof bindings.es_writeDataFile === 'function';
+ const read = durable ? bindings.es_readDataFile! : bindings.es_readCacheFile;
+ const write = durable ? bindings.es_writeDataFile! : bindings.es_writeCacheFile;
+
const entries = new Map();
- const persistent = typeof bindings.es_readCacheFile === 'function'
- && typeof bindings.es_writeCacheFile === 'function';
+ const persistent = typeof read === 'function' && typeof write === 'function';
if (!persistent) {
- log.warn('native', 'host has no storage or cache bindings — saves last only for this session');
+ log.warn('native', 'host has no storage or file bindings — saves last only for this session');
} else {
- const bytes = bindings.es_readCacheFile!(STORAGE_FILE);
+ if (!durable) {
+ log.warn('native', 'host binds no durable store — saves go to its cache, which the platform may reclaim');
+ }
+ const bytes = read!(STORAGE_FILE);
if (bytes) {
try {
for (const [k, v] of Object.entries(JSON.parse(new TextDecoder().decode(bytes)) as Record)) {
@@ -373,7 +391,7 @@ function hostStorage(bindings: NativeHostBindings): Pick<
if (!persistent) return;
// A string, not encoded bytes: the host writes UTF-8 itself, and a native
// JS engine has no TextEncoder to reach for.
- bindings.es_writeCacheFile!(STORAGE_FILE, JSON.stringify(Object.fromEntries(entries)));
+ write!(STORAGE_FILE, JSON.stringify(Object.fromEntries(entries)));
};
return {
getStorageItem: (key) => entries.get(key) ?? null,
diff --git a/sdk/tests/native-host-bridge.test.ts b/sdk/tests/native-host-bridge.test.ts
index 606b579e3..dbe8c7e7f 100644
--- a/sdk/tests/native-host-bridge.test.ts
+++ b/sdk/tests/native-host-bridge.test.ts
@@ -74,7 +74,52 @@ describe('createHostBridge', () => {
expect(sink.onTouchStart).toHaveBeenCalledTimes(1);
});
- it('persists storage through the host cache file', () => {
+ /** A host with both stores, kept apart so a test can say which one was written. */
+ const twoStoreScope = (): { scope: ReturnType; cache: Map; data: Map } => {
+ const cache = new Map();
+ const data = new Map();
+ const reader = (store: Map) => (key: string) => {
+ const text = store.get(key);
+ return text === undefined ? null : new TextEncoder().encode(text).buffer;
+ };
+ return {
+ cache,
+ data,
+ scope: hostScope({
+ es_readCacheFile: reader(cache),
+ es_writeCacheFile: (key: string, bytes: string) => { cache.set(key, bytes); return true; },
+ es_readDataFile: reader(data),
+ es_writeDataFile: (key: string, bytes: string) => { data.set(key, bytes); return true; },
+ }),
+ };
+ };
+
+ it('persists storage through the host data file', () => {
+ const { scope } = twoStoreScope();
+ createHostBridge(scope).setStorageItem('save', '{"level":3}');
+ // A fresh bridge (the next launch) reads what the previous one wrote.
+ expect(createHostBridge(scope).getStorageItem('save')).toBe('{"level":3}');
+ });
+
+ // The whole point of the split: a platform is allowed to empty its cache, so a
+ // save that lands there is a save the player can lose.
+ it('writes saves to the durable store and never to the cache', () => {
+ const { scope, cache, data } = twoStoreScope();
+ createHostBridge(scope).setStorageItem('save', '{"level":3}');
+ expect([...data.keys()]).toEqual(['estella-storage.json']);
+ expect(cache.size).toBe(0);
+ });
+
+ it('survives a cache the platform reclaimed', () => {
+ const { scope, cache } = twoStoreScope();
+ createHostBridge(scope).setStorageItem('save', '{"level":3}');
+ cache.clear();
+ expect(createHostBridge(scope).getStorageItem('save')).toBe('{"level":3}');
+ });
+
+ // An older shell binds only the cache pair. Persisting there beats losing the
+ // save at every exit, so the fallback stays.
+ it('still persists through a host that binds only the cache pair', () => {
const files = new Map();
const scope = hostScope({
es_readCacheFile: (key: string) => {
@@ -85,7 +130,6 @@ describe('createHostBridge', () => {
});
createHostBridge(scope).setStorageItem('save', '{"level":3}');
- // A fresh bridge (the next launch) reads what the previous one wrote.
expect(createHostBridge(scope).getStorageItem('save')).toBe('{"level":3}');
});