From 63384358b964e94c74d88531e1bc0dbe90089858 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Fri, 31 Jul 2026 18:42:14 -0400 Subject: [PATCH 1/8] ci(android): build every ABI and gate on 16 KB page alignment android/ had Gradle sources and a cargo ndk layout but no workflow at all, so nothing built it and nothing could regress visibly. The new workflow cross-builds rustysnes-android for arm64-v8a, armeabi-v7a, x86_64 and x86; asserts every 64-bit .so's PT_LOAD segments are 16 KB aligned; assembles a debug APK; and checks the APK actually carries each ABI. The alignment gate is the store-facing one. Play requires 16 KB-aligned segments for 16 KB-page devices and a 4 KB-aligned library simply fails to load there. The NDK has defaulted to 16 KB since r27, so this is a REGRESSION gate against an NDK downgrade or a stray linker flag -- the kind of thing otherwise found by a store rejection. It fails closed when the .so glob matches nothing, so a build producing no libraries cannot pass it silently. Two actions only, both already pinned in the committed tree: the NDK comes from the runner image's own sdkmanager, and the JDK and Gradle are preinstalled. Adding setup-ndk/setup-java/setup-gradle would have put three more third-party actions on the surface the v1.26.0 hardening pass just tightened. assembleDebug, not release: signing material is maintainer-only and stays out of CI, per docs/mobile-readiness.md. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/android.yml | 161 ++++++++++++++++++++++++++++++++++ CHANGELOG.md | 17 ++++ 2 files changed, 178 insertions(+) create mode 100644 .github/workflows/android.yml diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 00000000..3c603f68 --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,161 @@ +name: Android + +on: + push: + branches: [main] + paths: + - "crates/rustysnes-android/**" + - "crates/rustysnes-mobile/**" + - "crates/rustysnes-gfx-shaders/**" + - "android/**" + - ".github/workflows/android.yml" + pull_request: + paths: + - "crates/rustysnes-android/**" + - "crates/rustysnes-mobile/**" + - "crates/rustysnes-gfx-shaders/**" + - "android/**" + - ".github/workflows/android.yml" + schedule: + # A ~60-day refresh, matching `ios.yml`'s cadence and for the same reason: the NDK and the + # Android Gradle Plugin move under us, and this workflow is the project's only exercise of + # either. Offset from `ios.yml` (1st) to the 15th so the two mobile jobs do not collide. + - cron: "0 8 15 */2 *" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + env: + CARGO_NET_RETRY: "10" + CARGO_TERM_COLOR: always + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - uses: ./.github/actions/rust-setup + + # The four ABIs Play accepts for a 64-bit-required listing plus the two 32-bit ones the + # `minSdk = 21` floor still reaches. `cargo ndk` maps each to its NDK triple. + - name: Add the Android targets + run: | + rustup target add \ + aarch64-linux-android \ + armv7-linux-androideabi \ + x86_64-linux-android \ + i686-linux-android + + - name: Confirm the Android targets actually installed + run: rustup target list --installed | grep -E 'android' | sort + + # The NDK comes from the runner image's own Android SDK rather than a third-party action. + # `ubuntu-latest` ships `$ANDROID_HOME` with `sdkmanager`, so this adds no new supply-chain + # surface — the v1.26.0 hardening pass is the reason that matters here. + # + # r27 is the floor: 16 KB page alignment became the NDK default there, and the gate below + # asserts it rather than assuming it. + - name: Install the NDK from the runner's Android SDK + run: | + set -euo pipefail + sdkmanager --install "ndk;27.2.12479018" + echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/27.2.12479018" >> "$GITHUB_ENV" + + - name: Install cargo-ndk + run: cargo install cargo-ndk --locked --version ^3 + + # `--build-std` is deliberately NOT used: the shipped build must be reproducible from a + # stable toolchain, and `rust-toolchain.toml` pins 1.96 stable. + - name: Build the JNI libraries + run: | + cargo ndk \ + -t arm64-v8a -t armeabi-v7a -t x86_64 -t x86 \ + -o android/app/src/main/jniLibs \ + build --release -p rustysnes-android + + - name: Show what was produced + run: find android/app/src/main/jniLibs -name '*.so' -printf '%p %s bytes\n' | sort + + # --------------------------------------------------------------------------------------- + # The 16 KB page-alignment gate. + # + # Play requires every shipped `.so` to have 16 KB-aligned LOAD segments for devices with a + # 16 KB page size; a 4 KB-aligned library simply fails to load there. The NDK has defaulted + # to 16 KB since r27, so this is a REGRESSION gate rather than a porting task — it catches an + # NDK downgrade or a stray linker flag, which is exactly the sort of thing that would + # otherwise be found by a store rejection. + # + # Checked on the 64-bit ABIs only: the requirement is a property of 64-bit page sizes, and + # the 32-bit libraries are 4 KB-aligned by design. + # --------------------------------------------------------------------------------------- + - name: Assert 16 KB ELF page alignment on the 64-bit ABIs + run: | + set -euo pipefail + fail=0 + checked=0 + for abi in arm64-v8a x86_64; do + for so in android/app/src/main/jniLibs/"$abi"/*.so; do + [ -e "$so" ] || { echo "::error::no .so produced for $abi"; exit 1; } + checked=$((checked + 1)) + # Every PT_LOAD's Align column, in hex. 16 KB = 0x4000. + bad=$(readelf -lW "$so" \ + | awk '$1 == "LOAD" { print $NF }' \ + | grep -vc '0x4000$' || true) + if [ "$bad" -ne 0 ]; then + echo "::error::$so has $bad LOAD segment(s) not aligned to 16 KB (0x4000)" + readelf -lW "$so" | awk '$1 == "LOAD"' + fail=1 + else + echo "ok: $so — all LOAD segments 16 KB aligned" + fi + done + done + if [ "$checked" -eq 0 ]; then + echo "::error::the alignment gate checked nothing — the .so glob matched no files" + exit 1 + fi + exit "$fail" + + # Both the JDK and Gradle are preinstalled on `ubuntu-latest`; using them keeps this + # workflow to two actions, both already pinned elsewhere in this repo. + - name: Show the Java and Gradle the runner provides + run: | + java -version + gradle --version + + # `assembleDebug`, not `assembleRelease`: a release build needs signing material this + # workflow deliberately does not hold (see `docs/mobile-readiness.md`). What this proves is + # that the Kotlin sources, the manifest, and the freshly built `.so`s package together. + - name: Assemble the debug APK + working-directory: android + run: gradle --no-daemon assembleDebug + + - name: Confirm the APK carries every ABI + run: | + set -euo pipefail + apk=$(find android -name '*-debug.apk' | head -1) + [ -n "$apk" ] || { echo "::error::no debug APK was produced"; exit 1; } + echo "APK: $apk" + for abi in arm64-v8a armeabi-v7a x86_64 x86; do + if unzip -l "$apk" | grep -q "lib/$abi/"; then + echo "ok: $abi present" + else + echo "::error::the APK is missing lib/$abi/ — jniLibs packaging regressed" + exit 1 + fi + done + + - name: Upload the APK + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: rustysnes-debug-apk + path: android/app/build/outputs/apk/debug/*.apk + if-no-files-found: error + retention-days: 14 diff --git a/CHANGELOG.md b/CHANGELOG.md index e7b98330..90df4f89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -86,6 +86,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 generate. The on-screen controls that would drive it are still outstanding; `docs/mobile-readiness.md` now says which half is done. +- **Android CI, with the 16 KB page-alignment gate (`v1.30.0`).** `android/` had Gradle sources and + a `cargo ndk` layout but **no workflow at all** — nothing built it, so nothing could regress + visibly. `.github/workflows/android.yml` cross-builds `rustysnes-android` for all four ABIs, + asserts every 64-bit `.so`'s `PT_LOAD` segments are 16 KB aligned, assembles a debug APK, and + checks the APK actually carries each ABI. + + The alignment check is the store-facing one: Play requires 16 KB-aligned segments for 16 KB-page + devices and a 4 KB-aligned library simply fails to load there. The NDK has defaulted to 16 KB + since r27, so this is a **regression gate** — it catches an NDK downgrade or a stray linker flag, + which would otherwise surface as a store rejection. It fails closed if the `.so` glob matches + nothing, so a build that produces no libraries cannot pass it silently. + + Deliberately two actions only, both already pinned in this repo: the NDK comes from the runner + image's own `sdkmanager` and the JDK and Gradle are preinstalled, rather than adding three + third-party actions to the supply-chain surface the `v1.26.0` pass tightened. `assembleDebug`, not + release — signing material is maintainer-only and stays out of CI. + - **AccuracySNES: the Mesen2 oracle diagnosed, and a stale frame budget aligned.** Three `v1.28.0` items ended at "no oracle can arbitrate", so the headless runner was investigated rather than accepted as environmental. Established: the hang is genuinely pre-existing (the `v1.25.0`-era image From c72ab4d7a18c9971a9474064a5b1789f09de9e16 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Fri, 31 Jul 2026 18:44:44 -0400 Subject: [PATCH 2/8] build(android): commit the Gradle wrapper, and have CI use it The project had no wrapper, so every build -- CI or local -- used whatever Gradle happened to be installed, and this environment only ever had a cached 8.10 distribution. That is the drift a wrapper removes. gradlew, gradlew.bat and gradle/wrapper/ (jar + properties) are the official artefacts of the 8.10 distribution, generated by 'gradle wrapper --gradle-version 8.10 --distribution-type bin'. The workflow now runs ./gradlew rather than the runner's preinstalled copy, so CI and a developer's machine build with the same Gradle. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/android.yml | 14 +- CHANGELOG.md | 12 +- android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43583 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + android/gradlew | 252 ++++++++++++++++++ android/gradlew.bat | 94 +++++++ 6 files changed, 371 insertions(+), 8 deletions(-) create mode 100644 android/gradle/wrapper/gradle-wrapper.jar create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100755 android/gradlew create mode 100644 android/gradlew.bat diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 3c603f68..76b1ddd6 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -123,19 +123,23 @@ jobs: fi exit "$fail" - # Both the JDK and Gradle are preinstalled on `ubuntu-latest`; using them keeps this - # workflow to two actions, both already pinned elsewhere in this repo. - - name: Show the Java and Gradle the runner provides + # The JDK is preinstalled on `ubuntu-latest`; Gradle comes from the committed wrapper rather + # than the runner's copy, so CI and a developer's machine build with the SAME Gradle. The + # runner image's preinstalled version moves under us, which is the drift a wrapper exists to + # remove — and until now this project had no wrapper at all, so every build depended on + # whatever Gradle happened to be installed. + - name: Show the Java, and the Gradle the wrapper pins + working-directory: android run: | java -version - gradle --version + ./gradlew --version # `assembleDebug`, not `assembleRelease`: a release build needs signing material this # workflow deliberately does not hold (see `docs/mobile-readiness.md`). What this proves is # that the Kotlin sources, the manifest, and the freshly built `.so`s package together. - name: Assemble the debug APK working-directory: android - run: gradle --no-daemon assembleDebug + run: ./gradlew --no-daemon assembleDebug - name: Confirm the APK carries every ABI run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 90df4f89..47c89a08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -99,9 +99,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 nothing, so a build that produces no libraries cannot pass it silently. Deliberately two actions only, both already pinned in this repo: the NDK comes from the runner - image's own `sdkmanager` and the JDK and Gradle are preinstalled, rather than adding three - third-party actions to the supply-chain surface the `v1.26.0` pass tightened. `assembleDebug`, not - release — signing material is maintainer-only and stays out of CI. + image's own `sdkmanager` and the JDK is preinstalled, rather than adding three third-party actions + to the supply-chain surface the `v1.26.0` pass tightened. `assembleDebug`, not release — signing + material is maintainer-only and stays out of CI. + + The **Gradle wrapper is now committed** (`android/gradlew`, `gradlew.bat`, and + `gradle/wrapper/`, pinning Gradle 8.10). The project had none, so every build — CI or local — + used whatever Gradle happened to be installed, and this environment only ever had a cached + distribution. CI now runs `./gradlew`, so it and a developer's machine build with the same + Gradle. - **AccuracySNES: the Mesen2 oracle diagnosed, and a stale frame budget aligned.** Three `v1.28.0` items ended at "no oracle can arbitrate", so the headless runner was investigated rather than diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..a4b76b9530d66f5e68d973ea569d8e19de379189 GIT binary patch literal 43583 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 yu?e%!_ptzGB|8GrCfu%p?(_ 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 GZ~q4|mN%#X literal 0 HcmV?d00001 diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..9355b415 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 00000000..f5feea6d --- /dev/null +++ b/android/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 00000000..9b42019c --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega From b000b179303381707e862337ceaa9c1f98bc35c2 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Fri, 31 Jul 2026 18:49:03 -0400 Subject: [PATCH 3/8] =?UTF-8?q?ci(android):=20sdkmanager=20is=20not=20on?= =?UTF-8?q?=20PATH=20=E2=80=94=20use=20the=20SDK's=20own=20copy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First CI run failed with 'sdkmanager: command not found'. ubuntu-latest sets $ANDROID_HOME but does not put the SDK's cmdline-tools on PATH. Uses the full path, asserts $ANDROID_HOME is set, and fails with a directory listing if the binary is not where expected -- so the next runner-image change reports what it found rather than exit 127. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/android.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 76b1ddd6..90909c19 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -65,7 +65,16 @@ jobs: - name: Install the NDK from the runner's Android SDK run: | set -euo pipefail - sdkmanager --install "ndk;27.2.12479018" + # `sdkmanager` is NOT on PATH on `ubuntu-latest` even though `$ANDROID_HOME` is set — + # it lives under the SDK's own cmdline-tools. Found by this workflow's first CI run. + : "${ANDROID_HOME:?the runner image is expected to provide an Android SDK}" + sdk="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" + if [ ! -x "$sdk" ]; then + echo "::error::no sdkmanager at $sdk" + ls -la "$ANDROID_HOME/cmdline-tools" || true + exit 1 + fi + "$sdk" --install "ndk;27.2.12479018" echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/27.2.12479018" >> "$GITHUB_ENV" - name: Install cargo-ndk From 6cd71d0d64f79da23080a66ed5be9a38133059f0 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Fri, 31 Jul 2026 19:16:59 -0400 Subject: [PATCH 4/8] ci(android): align the 64-bit libraries explicitly, not by NDK default The alignment gate failed on its first run with 4 KB-aligned arm64-v8a and x86_64 libraries -- what Play rejects. The gate was not broken; the libraries were really misaligned. Its own comment claimed 16 KB was the NDK default from r27 onward, which made the check look like the faulty part. Bisected locally instead: with the same NDK r27c, cargo-ndk 3.5.4 emits 0x1000 and 4.1.2 emits 0x4000. The linker invocation decides the alignment, not the NDK version, and the workflow's `--version ^3` pin resolved to 3.5.4. Pass -C link-arg=-Wl,-z,max-page-size=16384 explicitly for the 64-bit ABIs, verified to produce 0x4000 under the failing 3.5.4, so the result does not depend on a tool default a version range cannot promise. The 32-bit ABIs move to their own step and stay 4 KB by design -- listing them together would relink the 64-bit ones without the flag. Both comments that asserted the NDK default are corrected to say what was measured. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/android.yml | 39 ++++++++++++++++++++++++++++------- CHANGELOG.md | 17 +++++++++++---- 2 files changed, 44 insertions(+), 12 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 90909c19..1fbecb7d 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -60,8 +60,9 @@ jobs: # `ubuntu-latest` ships `$ANDROID_HOME` with `sdkmanager`, so this adds no new supply-chain # surface — the v1.26.0 hardening pass is the reason that matters here. # - # r27 is the floor: 16 KB page alignment became the NDK default there, and the gate below - # asserts it rather than assuming it. + # r27 is the floor. Note that pinning the NDK is NOT by itself what produces 16 KB-aligned + # libraries — see the build step below, which passes the alignment explicitly because the + # first CI run proved the NDK version does not decide it. - name: Install the NDK from the runner's Android SDK run: | set -euo pipefail @@ -82,10 +83,30 @@ jobs: # `--build-std` is deliberately NOT used: the shipped build must be reproducible from a # stable toolchain, and `rust-toolchain.toml` pins 1.96 stable. - - name: Build the JNI libraries + # + # `max-page-size=16384` is passed EXPLICITLY rather than relied upon as an NDK default. This + # workflow's first CI run failed the gate below with 4 KB-aligned LOAD segments, and the + # cause was measured, not guessed: with the same NDK r27c, `cargo-ndk` 3.5.4 emits 0x1000 and + # 4.1.2 emits 0x4000 — the linker invocation decides it, not the NDK. Passing the flag makes + # the result independent of which `cargo-ndk` resolves, which a version range cannot promise. + # Applied to the 64-bit ABIs only, matching what the gate checks and what Play requires; the + # 32-bit libraries stay 4 KB by design. + - name: Build the JNI libraries (64-bit ABIs, 16 KB-aligned) + env: + RUSTFLAGS: "-C link-arg=-Wl,-z,max-page-size=16384" run: | cargo ndk \ - -t arm64-v8a -t armeabi-v7a -t x86_64 -t x86 \ + -t arm64-v8a -t x86_64 \ + -o android/app/src/main/jniLibs \ + build --release -p rustysnes-android + + # Only the two 32-bit ABIs here. Re-listing the 64-bit ones would relink them without the + # flag above and overwrite the aligned output with 4 KB libraries — the gate would catch it, + # but silently undoing the previous step is worth not writing in the first place. + - name: Build the JNI libraries (32-bit ABIs) + run: | + cargo ndk \ + -t armeabi-v7a -t x86 \ -o android/app/src/main/jniLibs \ build --release -p rustysnes-android @@ -96,10 +117,12 @@ jobs: # The 16 KB page-alignment gate. # # Play requires every shipped `.so` to have 16 KB-aligned LOAD segments for devices with a - # 16 KB page size; a 4 KB-aligned library simply fails to load there. The NDK has defaulted - # to 16 KB since r27, so this is a REGRESSION gate rather than a porting task — it catches an - # NDK downgrade or a stray linker flag, which is exactly the sort of thing that would - # otherwise be found by a store rejection. + # 16 KB page size; a 4 KB-aligned library simply fails to load there. + # + # This gate earned its place on its first run: it failed, and the 4 KB libraries were real. + # It was written expecting to be a formality that confirmed an NDK default — the build step + # above records what the measurement actually showed. Treat a failure here as a genuine + # finding, not as a broken check. # # Checked on the 64-bit ABIs only: the requirement is a property of 64-bit page sizes, and # the 32-bit libraries are 4 KB-aligned by design. diff --git a/CHANGELOG.md b/CHANGELOG.md index 47c89a08..32a6f972 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,10 +93,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 checks the APK actually carries each ABI. The alignment check is the store-facing one: Play requires 16 KB-aligned segments for 16 KB-page - devices and a 4 KB-aligned library simply fails to load there. The NDK has defaulted to 16 KB - since r27, so this is a **regression gate** — it catches an NDK downgrade or a stray linker flag, - which would otherwise surface as a store rejection. It fails closed if the `.so` glob matches - nothing, so a build that produces no libraries cannot pass it silently. + devices and a 4 KB-aligned library simply fails to load there. It fails closed if the `.so` glob + matches nothing, so a build that produces no libraries cannot pass it silently. + + **It was written as a formality and immediately found a real defect.** The first CI run produced + 4 KB-aligned `arm64-v8a` and `x86_64` libraries — exactly what Play rejects. The gate's own + comment had asserted that 16 KB was the NDK default from r27 onward, so the obvious reading was + that the check was broken. It was not. Reproduced locally and bisected: with the **same** NDK + r27c, `cargo-ndk` 3.5.4 emits `0x1000` and 4.1.2 emits `0x4000`. The alignment is decided by the + linker invocation, not by the NDK version — and the workflow pinned `cargo-ndk` to `^3`, which + resolved to 3.5.4. Fixed by passing `-C link-arg=-Wl,-z,max-page-size=16384` explicitly for the + 64-bit ABIs (verified to produce `0x4000` under the failing 3.5.4), so the result no longer + depends on a tool default that a version range cannot promise. The 32-bit ABIs build in a + separate step and stay 4 KB by design. Deliberately two actions only, both already pinned in this repo: the NDK comes from the runner image's own `sdkmanager` and the JDK is preinstalled, rather than adding three third-party actions From 7136b0336a970a03274f23a155af715e5dcfc10e Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Fri, 31 Jul 2026 19:40:31 -0400 Subject: [PATCH 5/8] build(android): commit the missing gradle.properties The app could not be built from a clean checkout. AGP fails :app:checkDebugAarMetadata outright with "contains AndroidX dependencies, but the `android.useAndroidX` property is not enabled", and android/gradle.properties had simply never been committed. Every Android build this project has done -- including the real APK installed on an AVD in v1.15.0 -- ran on a machine that already had those settings in a user-level ~/.gradle/gradle.properties, so nothing surfaced the gap until CI built from a bare checkout. enableJetifier is explicitly off: it rewrites legacy support-library artifacts and there are none here, so leaving it on would cost build time on every dependency for no effect. The Kotlin daemon heap is raised because Compose otherwise OOMs on a runner rather than failing legibly. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 7 +++++++ android/gradle.properties | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 android/gradle.properties diff --git a/CHANGELOG.md b/CHANGELOG.md index 32a6f972..82c6ac4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -107,6 +107,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 depends on a tool default that a version range cannot promise. The 32-bit ABIs build in a separate step and stay 4 KB by design. + **The workflow also found that the Android app could not be built from a clean checkout at all.** + `android/gradle.properties` had never been committed, so `:app:checkDebugAarMetadata` fails with + "contains AndroidX dependencies, but the `android.useAndroidX` property is not enabled" — every + Android build this project has ever done ran on a machine that already had those settings in a + user-level `~/.gradle/gradle.properties`. Now committed, with `enableJetifier` explicitly off + (there are no legacy support-library artifacts to rewrite) and a raised Kotlin daemon heap. + Deliberately two actions only, both already pinned in this repo: the NDK comes from the runner image's own `sdkmanager` and the JDK is preinstalled, rather than adding three third-party actions to the supply-chain surface the `v1.26.0` pass tightened. `assembleDebug`, not release — signing diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 00000000..900c6a03 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,22 @@ +# Gradle properties for the Android shell. +# +# This file was MISSING from the repository until `v1.30.0`, and its absence made the app +# unbuildable from a clean checkout: `:app:checkDebugAarMetadata` fails outright with +# "contains AndroidX dependencies, but the `android.useAndroidX` property is not enabled". +# Every previous Android build in this project ran on a machine that already had these settings +# somewhere Gradle could see them (a user-level `~/.gradle/gradle.properties`), so nothing ever +# surfaced the gap. `android.yml`'s first green run is what found it. + +# Required: every dependency in `app/build.gradle.kts` is AndroidX (Compose, activity-compose, +# core-ktx). Without this, AGP refuses the build rather than risking a support-library collision. +android.useAndroidX=true + +# Not needed and deliberately off: Jetifier rewrites *legacy* support-library artifacts, and this +# project has none. Leaving it on costs build time on every dependency for no effect. +android.enableJetifier=false + +kotlin.code.style=official + +# The Kotlin/Compose compiler needs more heap than Gradle's 512 MB default; a CI runner otherwise +# fails with an OOM inside the Kotlin daemon rather than a legible error. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 From 72d51723a03ad24a8a39adace5f373aac2fb466d Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Fri, 31 Jul 2026 19:59:32 -0400 Subject: [PATCH 6/8] ci(android): gate the APK's libraries, not a build that never ships The APK carries three libraries per ABI -- librustysnes_android.so, librustysnes_mobile.so and librustysnes_monetization.so -- all built by Gradle's own cargoNdkBuild task. The workflow's standalone pre-build produced only rustysnes-android, and Gradle's task did not inherit the max-page-size flag, so the alignment gate could pass on artifacts that never ship while the ones that do were 4 KB aligned. Found by CodeRabbit on this PR; worse than reported, since the effect is a false pass on the store-facing requirement rather than only missing coverage. Build all three crates in both pre-build steps, set RUSTFLAGS on the Gradle step, and add a second gate over the assembled APK's lib/ -- pinned at exactly six 64-bit libraries so a packaging change that drops one fails instead of passing more quietly. Also add Cargo.lock and crates/rustysnes-monetization/** to the path filters, so a lockfile-only dependency bump still runs this workflow. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/android.yml | 53 +++++++++++++++++++++++++++++++++-- CHANGELOG.md | 10 +++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 1fbecb7d..fdf93055 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -6,15 +6,19 @@ on: paths: - "crates/rustysnes-android/**" - "crates/rustysnes-mobile/**" + - "crates/rustysnes-monetization/**" - "crates/rustysnes-gfx-shaders/**" - "android/**" + - "Cargo.lock" - ".github/workflows/android.yml" pull_request: paths: - "crates/rustysnes-android/**" - "crates/rustysnes-mobile/**" + - "crates/rustysnes-monetization/**" - "crates/rustysnes-gfx-shaders/**" - "android/**" + - "Cargo.lock" - ".github/workflows/android.yml" schedule: # A ~60-day refresh, matching `ios.yml`'s cadence and for the same reason: the NDK and the @@ -98,7 +102,7 @@ jobs: cargo ndk \ -t arm64-v8a -t x86_64 \ -o android/app/src/main/jniLibs \ - build --release -p rustysnes-android + build --release -p rustysnes-android -p rustysnes-mobile -p rustysnes-monetization # Only the two 32-bit ABIs here. Re-listing the 64-bit ones would relink them without the # flag above and overwrite the aligned output with 4 KB libraries — the gate would catch it, @@ -108,7 +112,7 @@ jobs: cargo ndk \ -t armeabi-v7a -t x86 \ -o android/app/src/main/jniLibs \ - build --release -p rustysnes-android + build --release -p rustysnes-android -p rustysnes-mobile -p rustysnes-monetization - name: Show what was produced run: find android/app/src/main/jniLibs -name '*.so' -printf '%p %s bytes\n' | sort @@ -169,8 +173,15 @@ jobs: # `assembleDebug`, not `assembleRelease`: a release build needs signing material this # workflow deliberately does not hold (see `docs/mobile-readiness.md`). What this proves is # that the Kotlin sources, the manifest, and the freshly built `.so`s package together. + # `RUSTFLAGS` again, because Gradle's own `cargoNdkBuild` task re-runs `cargo ndk` for all + # three crates and does NOT inherit the flag from the steps above -- it inherits this process + # environment instead. Without it the APK ships 4 KB-aligned libraries even though the + # standalone build above produced 16 KB ones, which is exactly the false pass the APK-level + # gate below now catches. - name: Assemble the debug APK working-directory: android + env: + RUSTFLAGS: "-C link-arg=-Wl,-z,max-page-size=16384" run: ./gradlew --no-daemon assembleDebug - name: Confirm the APK carries every ABI @@ -188,6 +199,44 @@ jobs: fi done + # The authoritative alignment gate: the APK is what ships, and it carries THREE libraries per + # ABI (`librustysnes_android.so`, `librustysnes_mobile.so`, `librustysnes_monetization.so`), + # built by Gradle's own `cargoNdkBuild`. The earlier jniLibs gate checks a pre-Gradle build and + # would pass while the packaged libraries were misaligned -- a false pass CodeRabbit caught on + # this PR. Both are kept: the early one fails fast, this one is the truth. + - name: Assert 16 KB page alignment inside the APK + run: | + set -euo pipefail + apk=$(find android -name '*-debug.apk' | head -1) + [ -n "$apk" ] || { echo "::error::no debug APK was produced"; exit 1; } + work=$(mktemp -d) + unzip -q "$apk" 'lib/*' -d "$work" + fail=0 + checked=0 + for abi in arm64-v8a x86_64; do + for so in "$work/lib/$abi"/*.so; do + [ -e "$so" ] || { echo "::error::the APK has no .so for $abi"; exit 1; } + checked=$((checked + 1)) + bad=$(readelf -lW "$so" \ + | awk '$1 == "LOAD" { print $NF }' \ + | grep -vc '0x4000$' || true) + if [ "$bad" -ne 0 ]; then + echo "::error::$(basename "$so") ($abi) has $bad LOAD segment(s) not 16 KB aligned" + readelf -lW "$so" | awk '$1 == "LOAD"' + fail=1 + else + echo "ok: $abi/$(basename "$so")" + fi + done + done + # Three libraries times two 64-bit ABIs. Pinned rather than left as "more than zero", so + # a packaging change that silently drops one is a failure and not a quieter pass. + if [ "$checked" -ne 6 ]; then + echo "::error::expected 6 64-bit libraries in the APK, checked $checked" + exit 1 + fi + exit "$fail" + - name: Upload the APK uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 82c6ac4e..41c3090d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -114,6 +114,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 user-level `~/.gradle/gradle.properties`. Now committed, with `enableJetifier` explicitly off (there are no legacy support-library artifacts to rewrite) and a raised Kotlin daemon heap. + **The alignment gate was itself checking the wrong artifacts, and CodeRabbit caught it.** The APK + carries **three** libraries per ABI (`librustysnes_android.so`, `librustysnes_mobile.so`, + `librustysnes_monetization.so`), all built by Gradle's own `cargoNdkBuild` — while the workflow's + standalone pre-build produced only `rustysnes-android`, and Gradle's task did not inherit the + alignment flag. So the gate could pass on libraries that never ship while the ones that do ship + were 4 KB aligned. Fixed on both sides: the pre-build now covers all three crates, the Gradle step + carries `RUSTFLAGS`, and a second gate runs over the **assembled APK's** `lib/` — pinned at exactly + six 64-bit libraries so a packaging change that drops one fails rather than passing more quietly. + `Cargo.lock` and `crates/rustysnes-monetization/**` are now in the path filters too. + Deliberately two actions only, both already pinned in this repo: the NDK comes from the runner image's own `sdkmanager` and the JDK is preinstalled, rather than adding three third-party actions to the supply-chain surface the `v1.26.0` pass tightened. `assembleDebug`, not release — signing From fb4b874141fc11f38dc240a19d37c2d5ea5dcd72 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Fri, 31 Jul 2026 20:13:39 -0400 Subject: [PATCH 7/8] ci(android): test 16 KB alignment by divisibility, not equality The APK-level gate failed on `libjnidispatch.so`, and the gate was wrong, not the library. It compared each PT_LOAD Align to 0x4000 for equality, but the requirement is "aligned to at least 16 KB" -- a divisibility property. JNA ships that library at 0x10000 (64 KB), which is valid and stricter. Measured before acting rather than bumping the dependency on a guess: the JNA AARs for 5.15.0/5.16.0/5.17.0/5.19.1 are 0x10000, 0x4000, 0x4000, 0x4000 -- every one of them fine. A version bump would have "fixed" nothing. The test is now `align % 16384 == 0`, checked against real files to accept 64 KB and 16 KB and reject 4 KB. Also replace the hardcoded "expect exactly 6 libraries" assertion, which was simply wrong -- the APK carries 10, two of them third-party -- with a by-name presence check for the three this project builds. A count would need revising whenever a dependency adds a native library. Every library in the APK is alignment-checked, not only ours, because Play's requirement is a property of the shipped package. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/android.yml | 64 ++++++++++++++++++++++------------- CHANGELOG.md | 14 ++++++++ 2 files changed, 55 insertions(+), 23 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index fdf93055..4230e939 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -134,22 +134,30 @@ jobs: - name: Assert 16 KB ELF page alignment on the 64-bit ABIs run: | set -euo pipefail + # The requirement is "aligned to AT LEAST 16 KB", so the test is divisibility, not + # equality. An exact `== 0x4000` match rejects a library that is MORE strictly aligned -- + # JNA's `libjnidispatch.so` ships at `0x10000` (64 KB), which is perfectly valid, and the + # equality test flagged it as a violation on this workflow's first APK-level run. Bash + # parses the `0x` prefix inside `$(( ))`. + aligned16k() { + local a + for a in $(readelf -lW "$1" | awk '$1 == "LOAD" { print $NF }'); do + [ $(( a % 16384 )) -eq 0 ] || return 1 + done + return 0 + } fail=0 checked=0 for abi in arm64-v8a x86_64; do for so in android/app/src/main/jniLibs/"$abi"/*.so; do [ -e "$so" ] || { echo "::error::no .so produced for $abi"; exit 1; } checked=$((checked + 1)) - # Every PT_LOAD's Align column, in hex. 16 KB = 0x4000. - bad=$(readelf -lW "$so" \ - | awk '$1 == "LOAD" { print $NF }' \ - | grep -vc '0x4000$' || true) - if [ "$bad" -ne 0 ]; then - echo "::error::$so has $bad LOAD segment(s) not aligned to 16 KB (0x4000)" + if aligned16k "$so"; then + echo "ok: $so" + else + echo "::error::$so has LOAD segment(s) not a multiple of 16 KB" readelf -lW "$so" | awk '$1 == "LOAD"' fail=1 - else - echo "ok: $so — all LOAD segments 16 KB aligned" fi done done @@ -209,32 +217,42 @@ jobs: set -euo pipefail apk=$(find android -name '*-debug.apk' | head -1) [ -n "$apk" ] || { echo "::error::no debug APK was produced"; exit 1; } + # Same divisibility test as the jniLibs gate above -- see its comment for why an exact + # `0x4000` match is wrong. + aligned16k() { + local a + for a in $(readelf -lW "$1" | awk '$1 == "LOAD" { print $NF }'); do + [ $(( a % 16384 )) -eq 0 ] || return 1 + done + return 0 + } work=$(mktemp -d) unzip -q "$apk" 'lib/*' -d "$work" fail=0 - checked=0 + # EVERY library in the APK is checked, not only ours: Play's requirement is a property of + # the shipped package, so a misaligned third-party dependency fails the listing just as + # surely as a misaligned one of ours. for abi in arm64-v8a x86_64; do for so in "$work/lib/$abi"/*.so; do [ -e "$so" ] || { echo "::error::the APK has no .so for $abi"; exit 1; } - checked=$((checked + 1)) - bad=$(readelf -lW "$so" \ - | awk '$1 == "LOAD" { print $NF }' \ - | grep -vc '0x4000$' || true) - if [ "$bad" -ne 0 ]; then - echo "::error::$(basename "$so") ($abi) has $bad LOAD segment(s) not 16 KB aligned" + if aligned16k "$so"; then + echo "ok: $abi/$(basename "$so")" + else + echo "::error::$(basename "$so") ($abi) has LOAD segment(s) not a multiple of 16 KB" readelf -lW "$so" | awk '$1 == "LOAD"' fail=1 - else - echo "ok: $abi/$(basename "$so")" fi done + # Presence, by name, for the three this project builds. A count would have to be + # revised every time a dependency adds or drops a native library -- and a wrong count + # is what this gate got wrong first time round, failing on a correct APK. + for want in librustysnes_android.so librustysnes_mobile.so librustysnes_monetization.so; do + [ -e "$work/lib/$abi/$want" ] || { + echo "::error::the APK is missing lib/$abi/$want -- jniLibs packaging regressed" + fail=1 + } + done done - # Three libraries times two 64-bit ABIs. Pinned rather than left as "more than zero", so - # a packaging change that silently drops one is a failure and not a quieter pass. - if [ "$checked" -ne 6 ]; then - echo "::error::expected 6 64-bit libraries in the APK, checked $checked" - exit 1 - fi exit "$fail" - name: Upload the APK diff --git a/CHANGELOG.md b/CHANGELOG.md index 41c3090d..3f2e3498 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -124,6 +124,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 six 64-bit libraries so a packaging change that drops one fails rather than passing more quietly. `Cargo.lock` and `crates/rustysnes-monetization/**` are now in the path filters too. + **And the gate's own alignment test was wrong, which the APK run exposed.** It compared each + `PT_LOAD` Align to `0x4000` for *equality*, but the requirement is "aligned to at least 16 KB" — + a divisibility property. JNA's `libjnidispatch.so` ships at `0x10000` (64 KB), which is valid and + in fact stricter, and the equality test reported it as a violation. Measured before acting: + pulling the JNA AARs for 5.15.0/5.16.0/5.17.0/5.19.1 shows 5.15.0 at `0x10000` and the later ones + at `0x4000` — all fine — so bumping the dependency would have "fixed" nothing. The test is now + `align % 16384 == 0`, verified against real files to accept 64 KB and 16 KB and reject 4 KB. + + The hardcoded "expect exactly 6 libraries" assertion was wrong too (the APK carries 10, including + two third-party ones) and is replaced by a by-name presence check for the three this project + builds — a count would need revising whenever a dependency adds a native library. Every library + in the APK is alignment-checked, not just ours: Play's requirement is a property of the package, + so a misaligned dependency fails the listing just as surely. + Deliberately two actions only, both already pinned in this repo: the NDK comes from the runner image's own `sdkmanager` and the JDK is preinstalled, rather than adding three third-party actions to the supply-chain surface the `v1.26.0` pass tightened. `assembleDebug`, not release — signing From 3aefce4b24e8666a797fd024ab54a6067fac8d81 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Fri, 31 Jul 2026 20:36:54 -0400 Subject: [PATCH 8/8] build(android): bump JNA to 5.17.0 for 16 KB page alignment JNA 5.15.0 ships libjnidispatch.so 4 KB aligned on x86_64, armeabi-v7a and x86, and 64 KB aligned only on arm64-v8a. The APK therefore carried libraries that violate Play's 16 KB page-size requirement. This corrects the previous commit's message, which claimed a version bump would fix nothing. That was measured on arm64-v8a alone and generalised -- the one ABI where 5.15.0 happens to be fine. Reading all four ABIs of the published AARs: 5.15.0 arm64 0x10000 x86_64 0x1000 armv7 0x1000 x86 0x1000 5.16.0+ arm64 0x4000 x86_64 0x4000 armv7 0x4000 x86 0x4000 5.16.0 is the first release that fixes it; 5.17.0 is the nearest settled patch line after that change. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 24 +++++++++++++++++------- android/app/build.gradle.kts | 13 ++++++++++++- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f2e3498..0e75f23c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -124,13 +124,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 six 64-bit libraries so a packaging change that drops one fails rather than passing more quietly. `Cargo.lock` and `crates/rustysnes-monetization/**` are now in the path filters too. - **And the gate's own alignment test was wrong, which the APK run exposed.** It compared each - `PT_LOAD` Align to `0x4000` for *equality*, but the requirement is "aligned to at least 16 KB" — - a divisibility property. JNA's `libjnidispatch.so` ships at `0x10000` (64 KB), which is valid and - in fact stricter, and the equality test reported it as a violation. Measured before acting: - pulling the JNA AARs for 5.15.0/5.16.0/5.17.0/5.19.1 shows 5.15.0 at `0x10000` and the later ones - at `0x4000` — all fine — so bumping the dependency would have "fixed" nothing. The test is now - `align % 16384 == 0`, verified against real files to accept 64 KB and 16 KB and reject 4 KB. + **The gate's own alignment test was wrong too, and so was a shipped `libjnidispatch.so`.** The + test compared each `PT_LOAD` Align to `0x4000` for *equality*, but the requirement is "aligned to + at least 16 KB" — a divisibility property, and 64 KB satisfies it. Now `align % 16384 == 0`, + verified against real files to accept 64 KB and 16 KB and reject 4 KB. + + Fixing that exposed a genuine one underneath, and finding it required measuring **per ABI** — the + first pass checked only `arm64-v8a` and generalised, which produced a confident wrong conclusion + that no dependency bump was needed. Reading the published JNA AARs: + + | version | arm64-v8a | x86_64 | armeabi-v7a | x86 | + |---|---|---|---|---| + | 5.15.0 | `0x10000` | `0x1000` | `0x1000` | `0x1000` | + | 5.16.0+ | `0x4000` | `0x4000` | `0x4000` | `0x4000` | + + So the pinned 5.15.0 satisfied the requirement on arm64 and **violated it on every other ABI**. + Bumped to 5.17.0 — 5.16.0 is the first release that fixes it, 5.17.0 the nearest settled patch + line after that change. The hardcoded "expect exactly 6 libraries" assertion was wrong too (the APK carries 10, including two third-party ones) and is replaced by a by-name presence check for the three this project diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 7acf73f5..78e3c2e2 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -141,5 +141,16 @@ dependencies { // The AAR classifier is required on Android -- the plain `net.java.dev.jna:jna` jar (what // UniFFI's Kotlin bindings assume on a desktop JVM) does not bundle Android's native // `libjnidispatch.so`. - implementation("net.java.dev.jna:jna:5.15.0@aar") + // + // 5.17.0, not 5.15.0, because of the 16 KB page-alignment requirement -- and the version was + // chosen by measuring the published AARs, per ABI, rather than from a changelog: + // + // 5.15.0 arm64-v8a 0x10000 x86_64 0x1000 armeabi-v7a 0x1000 x86 0x1000 + // 5.16.0+ arm64-v8a 0x4000 x86_64 0x4000 armeabi-v7a 0x4000 x86 0x4000 + // + // So 5.15.0 satisfies the requirement on arm64 (64 KB is a multiple of 16 KB) and violates it + // on every other ABI -- which is why checking one ABI and generalising gave the wrong answer + // the first time. 5.16.0 is the first release that fixes it; 5.17.0 is taken as the nearest + // settled patch line after that change. + implementation("net.java.dev.jna:jna:5.17.0@aar") }