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}'); });