diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..e28460e --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,70 @@ +name: Build Android APK + +on: + push: + branches: + - master + paths: + - '.github/workflows/android.yml' + - 'CMakeLists.txt' + - 'platform/android/**' + - 'src/**' + - 'thirdparty/**' + - 'tools/android/**' + pull_request: + paths: + - '.github/workflows/android.yml' + - 'CMakeLists.txt' + - 'platform/android/**' + - 'src/**' + - 'thirdparty/**' + - 'tools/android/**' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-24.04 + + steps: + - name: Check out the repository + uses: actions/checkout@v6 + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '17' + cache: gradle + + - name: Install Android build dependencies + run: | + SDKMANAGER="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" + yes | "$SDKMANAGER" --licenses > /dev/null + "$SDKMANAGER" \ + 'platforms;android-35' \ + 'build-tools;35.0.0' \ + 'ndk;27.0.12077973' \ + 'cmake;4.1.2' + + - name: Set the SDK location for Gradle + run: printf 'sdk.dir=%s\n' "$ANDROID_HOME" > platform/android/local.properties + + - name: Build the debug APK + run: sh tools/android/build-apk.sh + + - name: Verify the APK + run: test -s build/android/debug/OpenShadowFlare-android-debug.apk + + - name: Upload the APK + uses: actions/upload-artifact@v4 + with: + name: openshadowflare-android-debug + path: build/android/debug/OpenShadowFlare-android-debug.apk + if-no-files-found: error diff --git a/.gitignore b/.gitignore index ecf66f0..468eb6d 100644 --- a/.gitignore +++ b/.gitignore @@ -38,9 +38,19 @@ __pycache__/ *.py[cod] .idea +*.iml .vs .vscode /build/ build*/ tmp/ ShadowEngine/ + +# Gradle build products +/platform/android/.gradle/ +/platform/android/.idea/ +/platform/android/local.properties +/platform/android/build/ +/platform/android/app/build/ +/platform/android/app/.cxx/ +/platform/android/app/.externalNativeBuild/ diff --git a/CMakeLists.txt b/CMakeLists.txt index fa0f2a4..a619eb1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -40,11 +40,14 @@ set_property( PROPERTY STRINGS lgl nxfb ) -add_subdirectory(thirdparty/lwl) -add_subdirectory(thirdparty/lal) +if(OPENSHADOWFLARE_BUILD_EXE OR BUILD_TESTING) + add_subdirectory(thirdparty/lwl) + add_subdirectory(thirdparty/lal) +endif() add_subdirectory(thirdparty/twl) add_subdirectory(thirdparty/tal) -if(OPENSHADOWFLARE_PRESENTATION_BACKEND STREQUAL "lgl" OR BUILD_TESTING) +if((OPENSHADOWFLARE_BUILD_EXE AND + OPENSHADOWFLARE_PRESENTATION_BACKEND STREQUAL "lgl") OR BUILD_TESTING) add_subdirectory(thirdparty/lgl) endif() diff --git a/documentation/android-port.md b/documentation/android-port.md new file mode 100644 index 0000000..b6ff515 --- /dev/null +++ b/documentation/android-port.md @@ -0,0 +1,73 @@ +# Android port + +OpenShadowFlare runs on Android 8.0 (API 26) or newer as a NativeActivity. It +draws through the TWL backend (EGL + OpenGL ES 2, GPU-scaled to an aspect-fit +rectangle) and plays audio through TAL on AAudio (44.1 kHz stereo). The game is +the native shared library `libmain.so`; there is no Java or Kotlin code. + +## What to install + +Android Studio is optional. Install a JDK 17 and the Android command-line SDK +tools, then use them to install: + +- Android SDK Platform 35 +- Build Tools 35.0.0 +- NDK 27.0.12077973 +- CMake 4.1.2 + +### Set the SDK location + +Gradle reads the SDK path from the untracked +`platform/android/local.properties`. Copy the tracked template and edit the path +for your machine: + +```bash +cp platform/android/local.properties.example platform/android/local.properties +``` + +Windows (forward slashes are fine): + +```properties +sdk.dir=C:/Users//AppData/Local/Android/Sdk +``` + +Linux: + +```properties +sdk.dir=/home//Android/Sdk +``` + +Do not commit this file; it is machine-specific and already in `.gitignore`. + +## Build + +```bash +sh tools/android/build-apk.sh +``` + +The installable debug APK is written to: + +```text +build/android/debug/OpenShadowFlare-android-debug.apk +``` + +## Game data + +The retail data is not packaged. After installing the APK, copy `ShadowFlare` directory to the app's external files directory so the game finds it at: + +```text +/sdcard/Android/data/org.openshadowflare.game/files/ShadowFlare/ +``` + +That folder must hold the retail data at its top level, for example: + +```text +ShadowFlare/ +├── SFlare.Cfg +├── System/ +├── Map/ +├── Player/ +├── Character/ +├── Scenario/ +└── Save/ +``` diff --git a/platform/android/app/build.gradle b/platform/android/app/build.gradle new file mode 100644 index 0000000..b3f0f91 --- /dev/null +++ b/platform/android/app/build.gradle @@ -0,0 +1,40 @@ +plugins { + id 'com.android.application' +} + +android { + namespace 'org.openshadowflare.game' + compileSdk 35 + ndkVersion '27.0.12077973' + + defaultConfig { + applicationId 'org.openshadowflare.game' + minSdk 26 + targetSdk 35 + versionCode 2 + versionName '0.1.0' + + ndk { + def osfAbi = project.findProperty('osfAbi') + if (osfAbi) { + abiFilters osfAbi.toString() + } else { + abiFilters 'arm64-v8a', 'x86_64' + } + } + + externalNativeBuild { + cmake { + cppFlags '-std=c++17' + arguments '-DBUILD_TESTING=OFF', '-DOPENSHADOWFLARE_BUILD_EXE=OFF' + } + } + } + + externalNativeBuild { + cmake { + path file('../../../CMakeLists.txt') + version '4.1.2' + } + } +} diff --git a/platform/android/app/src/main/AndroidManifest.xml b/platform/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..5bfe261 --- /dev/null +++ b/platform/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + diff --git a/platform/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/platform/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..037cc48 Binary files /dev/null and b/platform/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/platform/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/platform/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..3575cf6 Binary files /dev/null and b/platform/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/platform/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/platform/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..2deb899 Binary files /dev/null and b/platform/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/platform/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/platform/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..93527e1 Binary files /dev/null and b/platform/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/platform/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/platform/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4fc1d00 Binary files /dev/null and b/platform/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/platform/android/build.gradle b/platform/android/build.gradle new file mode 100644 index 0000000..5b7ccba --- /dev/null +++ b/platform/android/build.gradle @@ -0,0 +1,3 @@ +plugins { + id 'com.android.application' version '8.7.3' apply false +} diff --git a/platform/android/gradle.properties b/platform/android/gradle.properties new file mode 100644 index 0000000..74cc53f --- /dev/null +++ b/platform/android/gradle.properties @@ -0,0 +1,13 @@ +# Reuse a warm Gradle daemon between builds instead of starting a fresh JVM each +# time (the build script no longer passes --no-daemon). +org.gradle.daemon=true + +# Run independent work in parallel and reuse task outputs across builds. +org.gradle.parallel=true +org.gradle.caching=true + +# Skip the configuration phase when nothing that affects it has changed. +org.gradle.configuration-cache=true + +# Give the daemon enough heap to avoid GC pauses on larger builds. +org.gradle.jvmargs=-Xmx2048m diff --git a/platform/android/gradle/wrapper/gradle-wrapper.jar b/platform/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..61285a6 Binary files /dev/null and b/platform/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/platform/android/gradle/wrapper/gradle-wrapper.properties b/platform/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..19a6bde --- /dev/null +++ b/platform/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/platform/android/gradlew b/platform/android/gradlew new file mode 100644 index 0000000..adff685 --- /dev/null +++ b/platform/android/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 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\n' "$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 + + + +# 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" ) + + 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, 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" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# 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/platform/android/local.properties.example b/platform/android/local.properties.example new file mode 100644 index 0000000..95acf2c --- /dev/null +++ b/platform/android/local.properties.example @@ -0,0 +1,6 @@ +# Copy this file to local.properties, then edit the SDK path for your platform. +# Forward slashes work on Windows. +# Windows example: +sdk.dir=C:/Users//AppData/Local/Android/Sdk +# Linux example (replace the Windows line above): +# sdk.dir=/home//Android/Sdk diff --git a/platform/android/native/osf_android_main.c b/platform/android/native/osf_android_main.c new file mode 100644 index 0000000..7c61266 --- /dev/null +++ b/platform/android/native/osf_android_main.c @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2026 Michael Binder and contributors + * + * This file is part of OpenShadowFlare. + * + * OpenShadowFlare is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * + * OpenShadowFlare is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * details. + * + * You should have received a copy of the GNU General Public License along + * with OpenShadowFlare. If not, see . + */ + +#include + +#include "backends/android/twl_android.h" +#include "core/memory_budget.h" +#include "runtime/application.h" + +#include +#include +#include + +static uint8_t g_main_memory[SF_MAIN_ARENA_BYTES]; +static uint8_t g_video_memory[SF_VIDEO_MEMORY_LIMIT_BYTES]; + +void android_main(struct android_app *app) { + char data_root[512]; + const char *external = + (app && app->activity) ? app->activity->externalDataPath : NULL; + const char *requested = NULL; + if (external) { + snprintf(data_root, sizeof(data_root), "%s/ShadowFlare", external); + requested = data_root; + } + twl_android_set_app(app); + sf_application_run( + g_main_memory, sizeof(g_main_memory), + g_video_memory, sizeof(g_video_memory), + NULL, requested); +} diff --git a/platform/android/settings.gradle b/platform/android/settings.gradle new file mode 100644 index 0000000..2ce3453 --- /dev/null +++ b/platform/android/settings.gradle @@ -0,0 +1,13 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { google(); mavenCentral() } +} +rootProject.name = "OpenShadowFlare" +include(":app") diff --git a/shadowflare/CMakeLists.txt b/shadowflare/CMakeLists.txt index ded4f4c..ffbf813 100644 --- a/shadowflare/CMakeLists.txt +++ b/shadowflare/CMakeLists.txt @@ -183,15 +183,45 @@ add_library( add_library(ShadowFlare::Core ALIAS shadowflare_core) sf_configure_c99_target(shadowflare_core) -add_executable( - osf - main.c - runtime/application.c - runtime/gameplay_runtime.c - runtime/screen_runtime.c -) -add_executable(ShadowFlare::Game ALIAS osf) -sf_configure_c99_target(osf) +if(ANDROID) + add_library( + osf + SHARED + runtime/application.c + runtime/gameplay_runtime.c + runtime/screen_runtime.c + ) + set_target_properties(osf PROPERTIES OUTPUT_NAME main) + add_library(ShadowFlare::Game ALIAS osf) + sf_configure_c99_target(osf) + + add_library( + osf_android_entry + STATIC + "${PROJECT_SOURCE_DIR}/platform/android/native/osf_android_main.c" + "${CMAKE_ANDROID_NDK}/sources/android/native_app_glue/android_native_app_glue.c" + ) + target_include_directories( + osf_android_entry + PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}" + "${PROJECT_SOURCE_DIR}/thirdparty/twl" + "${CMAKE_ANDROID_NDK}/sources/android/native_app_glue" + ) + target_link_libraries(osf_android_entry PRIVATE android log) + target_link_libraries(osf PRIVATE osf_android_entry) + target_link_options(osf PRIVATE "LINKER:-u,ANativeActivity_onCreate") +else() + add_executable( + osf + main.c + runtime/application.c + runtime/gameplay_runtime.c + runtime/screen_runtime.c + ) + add_executable(ShadowFlare::Game ALIAS osf) + sf_configure_c99_target(osf) +endif() set( OPENSHADOWFLARE_C99_PRESENTATION_HZ "60" @@ -220,19 +250,20 @@ if(OPENSHADOWFLARE_ENABLE_DEBUG_TOOLS) sf_configure_c99_target(shadowflare_profiler) target_link_libraries(osf PRIVATE ShadowFlare::Profiler) target_compile_definitions(osf PRIVATE SF_ENABLE_PROFILING=1) - - add_executable( - osf-measure - ../utils/osf-measure.c - runtime/gameplay_runtime.c - runtime/screen_runtime.c - ) - sf_configure_c99_target(osf-measure) - target_link_libraries( - osf-measure - PRIVATE - ShadowFlare::Core - Tal::Tal - Twl::Twl - ) + if(NOT ANDROID) + add_executable( + osf-measure + ../utils/osf-measure.c + runtime/gameplay_runtime.c + runtime/screen_runtime.c + ) + sf_configure_c99_target(osf-measure) + target_link_libraries( + osf-measure + PRIVATE + ShadowFlare::Core + Tal::Tal + Twl::Twl + ) + endif() endif() diff --git a/shadowflare/core/memory_budget.h b/shadowflare/core/memory_budget.h index 1d5def3..52e8026 100644 --- a/shadowflare/core/memory_budget.h +++ b/shadowflare/core/memory_budget.h @@ -23,12 +23,20 @@ #define SF_KIBIBYTE 1024u #define SF_MEBIBYTE (1024u * SF_KIBIBYTE) +#if defined(__ANDROID__) +#define SF_MAIN_MEMORY_LIMIT_BYTES (32u * SF_MEBIBYTE) +#else #define SF_MAIN_MEMORY_LIMIT_BYTES (8u * SF_MEBIBYTE) +#endif #define SF_MAIN_SYSTEM_RESERVE_BYTES (1u * SF_MEBIBYTE) #define SF_MAIN_ARENA_BYTES \ (SF_MAIN_MEMORY_LIMIT_BYTES - SF_MAIN_SYSTEM_RESERVE_BYTES) +#if defined(__ANDROID__) +#define SF_VIDEO_MEMORY_LIMIT_BYTES (16u * SF_MEBIBYTE) +#else #define SF_VIDEO_MEMORY_LIMIT_BYTES (4u * SF_MEBIBYTE) +#endif #define SF_FRAME_WIDTH 640u #define SF_FRAME_HEIGHT 480u #define SF_FRAME_BYTES_PER_PIXEL 2u diff --git a/thirdparty/tal/CMakeLists.txt b/thirdparty/tal/CMakeLists.txt index 6593504..1a16ec4 100644 --- a/thirdparty/tal/CMakeLists.txt +++ b/thirdparty/tal/CMakeLists.txt @@ -44,6 +44,9 @@ elseif(APPLE) "${TAL_AUDIOUNIT_FRAMEWORK}" Threads::Threads ) +elseif(ANDROID) + target_sources(tal PRIVATE tal_android.c) + target_link_libraries(tal PRIVATE aaudio) elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux") target_sources(tal PRIVATE tal_alsa.c) find_package(ALSA REQUIRED) diff --git a/thirdparty/tal/tal_android.c b/thirdparty/tal/tal_android.c new file mode 100644 index 0000000..5390c0f --- /dev/null +++ b/thirdparty/tal/tal_android.c @@ -0,0 +1,194 @@ +/* + * Copyright (C) 2026 Michael Binder and contributors + * + * This file is part of TAL. + * + * TAL is free software: you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation, either version 3 of the License, or (at your option) any later + * version. + * + * TAL is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU General Public License for details. + * + * You should have received a copy of the GNU General Public License along + * with TAL. If not, see . + */ + +#include "tal_internal.h" + +#include + +#include + +#define TAL_ANDROID_OUTPUT_SAMPLE_RATE 44100 +#define TAL_ANDROID_OUTPUT_CHANNELS 2 +#define TAL_ANDROID_CALLBACK_FRAMES 256u +#define TAL_ANDROID_MAX_INPUT_FRAMES 256u + +typedef struct { + Tal *tal; + AAudioStream *stream; + pthread_mutex_t mutex; + uint32_t out_rate; + bool mutex_ready; + bool running; + int16_t input[TAL_ANDROID_MAX_INPUT_FRAMES]; +} TalAndroid; + +size_t tal_backend_memory_alignment(void) { + return _Alignof(TalAndroid); +} + +size_t tal_backend_memory_required(const TalConfig *config) { + (void) config; + return sizeof(TalAndroid); +} + +void tal_backend_lock(Tal *tal) { + TalAndroid *android = tal ? (TalAndroid *) tal->backend : NULL; + if (android && android->mutex_ready) { + pthread_mutex_lock(&android->mutex); + } +} + +void tal_backend_unlock(Tal *tal) { + TalAndroid *android = tal ? (TalAndroid *) tal->backend : NULL; + if (android && android->mutex_ready) { + pthread_mutex_unlock(&android->mutex); + } +} + +static aaudio_data_callback_result_t tal_android_callback( + AAudioStream *stream, void *user_data, void *audio_data, + int32_t frame_count) { + TalAndroid *android = (TalAndroid *) user_data; + int16_t *output = (int16_t *) audio_data; + const uint32_t frames = (uint32_t) frame_count; + const uint32_t input_rate = android->tal->config.sample_rate; + uint32_t input_frames; + uint32_t frame; + (void) stream; + + input_frames = (uint32_t) ((uint64_t) frames * input_rate / android->out_rate); + if (input_frames == 0u) { + input_frames = 1u; + } + if (input_frames > TAL_ANDROID_MAX_INPUT_FRAMES) { + input_frames = TAL_ANDROID_MAX_INPUT_FRAMES; + } + + pthread_mutex_lock(&android->mutex); + if (!android->running || + tal_internal_render(android->tal, android->input, input_frames) != + TAL_RESULT_OK) { + pthread_mutex_unlock(&android->mutex); + tal_internal_zero( + output, + (size_t) frames * TAL_ANDROID_OUTPUT_CHANNELS * sizeof(int16_t)); + return AAUDIO_CALLBACK_RESULT_CONTINUE; + } + pthread_mutex_unlock(&android->mutex); + + for (frame = 0u; frame < frames; ++frame) { + uint32_t index = + (uint32_t) ((uint64_t) frame * input_rate / android->out_rate); + if (index >= input_frames) { + index = input_frames - 1u; + } + output[frame * TAL_ANDROID_OUTPUT_CHANNELS] = android->input[index]; + output[frame * TAL_ANDROID_OUTPUT_CHANNELS + 1u] = android->input[index]; + } + return AAUDIO_CALLBACK_RESULT_CONTINUE; +} + +TalResult tal_backend_init( + Tal *tal, void *memory, size_t memory_size, const TalConfig *config) { + TalAndroid *android; + AAudioStreamBuilder *builder = NULL; + aaudio_result_t result; + if (!tal || !memory || !config || memory_size < sizeof(TalAndroid)) { + return TAL_RESULT_INVALID_ARGUMENT; + } + if (config->channels != 1u) { + return TAL_RESULT_BACKEND_UNAVAILABLE; + } + + android = (TalAndroid *) memory; + android->tal = tal; + android->stream = NULL; + android->running = false; + android->mutex_ready = false; + android->out_rate = TAL_ANDROID_OUTPUT_SAMPLE_RATE; + + if (pthread_mutex_init(&android->mutex, NULL) != 0) { + return TAL_RESULT_BACKEND_FAILURE; + } + android->mutex_ready = true; + + if (AAudio_createStreamBuilder(&builder) != AAUDIO_OK) { + tal_backend_shutdown(tal); + return TAL_RESULT_BACKEND_UNAVAILABLE; + } + AAudioStreamBuilder_setDirection(builder, AAUDIO_DIRECTION_OUTPUT); + AAudioStreamBuilder_setFormat(builder, AAUDIO_FORMAT_PCM_I16); + AAudioStreamBuilder_setChannelCount(builder, TAL_ANDROID_OUTPUT_CHANNELS); + AAudioStreamBuilder_setSampleRate(builder, TAL_ANDROID_OUTPUT_SAMPLE_RATE); + AAudioStreamBuilder_setPerformanceMode( + builder, AAUDIO_PERFORMANCE_MODE_LOW_LATENCY); + AAudioStreamBuilder_setFramesPerDataCallback( + builder, TAL_ANDROID_CALLBACK_FRAMES); + AAudioStreamBuilder_setDataCallback(builder, tal_android_callback, android); + result = AAudioStreamBuilder_openStream(builder, &android->stream); + AAudioStreamBuilder_delete(builder); + if (result != AAUDIO_OK || !android->stream) { + tal_backend_shutdown(tal); + return TAL_RESULT_BACKEND_FAILURE; + } + + if (AAudioStream_getChannelCount(android->stream) != + TAL_ANDROID_OUTPUT_CHANNELS || + AAudioStream_getFormat(android->stream) != AAUDIO_FORMAT_PCM_I16) { + tal_backend_shutdown(tal); + return TAL_RESULT_BACKEND_UNAVAILABLE; + } + android->out_rate = (uint32_t) AAudioStream_getSampleRate(android->stream); + if (android->out_rate == 0u) { + android->out_rate = TAL_ANDROID_OUTPUT_SAMPLE_RATE; + } + + android->running = true; + result = AAudioStream_requestStart(android->stream); + if (result != AAUDIO_OK) { + tal_backend_shutdown(tal); + return TAL_RESULT_BACKEND_FAILURE; + } + return TAL_RESULT_OK; +} + +void tal_backend_shutdown(Tal *tal) { + TalAndroid *android = tal ? (TalAndroid *) tal->backend : NULL; + if (!android) { + return; + } + if (android->mutex_ready) { + pthread_mutex_lock(&android->mutex); + android->running = false; + pthread_mutex_unlock(&android->mutex); + } + if (android->stream) { + AAudioStream_requestStop(android->stream); + AAudioStream_close(android->stream); + android->stream = NULL; + } + if (android->mutex_ready) { + pthread_mutex_destroy(&android->mutex); + android->mutex_ready = false; + } +} + +TalResult tal_backend_update(Tal *tal) { + (void) tal; + return TAL_RESULT_OK; +} diff --git a/thirdparty/twl/CMakeLists.txt b/thirdparty/twl/CMakeLists.txt index 5e4388a..f38d4ea 100644 --- a/thirdparty/twl/CMakeLists.txt +++ b/thirdparty/twl/CMakeLists.txt @@ -74,6 +74,20 @@ elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux") find_package(X11 REQUIRED) find_package(OpenGL REQUIRED) target_link_libraries(twl PRIVATE X11::X11 OpenGL::GL) +elseif(ANDROID) + target_sources( + twl + PRIVATE + backends/android/window.c + backends/android/presentation.c + backends/android/input.c + ) + target_include_directories( + twl + PRIVATE + "${CMAKE_ANDROID_NDK}/sources/android/native_app_glue" + ) + target_link_libraries(twl PUBLIC EGL GLESv2 android log) else() target_sources(twl PRIVATE backends/null/backend.c) endif() diff --git a/thirdparty/twl/backends/android/backend.h b/thirdparty/twl/backends/android/backend.h new file mode 100644 index 0000000..af48ed4 --- /dev/null +++ b/thirdparty/twl/backends/android/backend.h @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2026 Michael Binder and contributors + * + * This file is part of TWL. + * + * TWL is free software: you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation, either version 3 of the License, or (at your option) any later + * version. + * + * TWL is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU General Public License for details. + * + * You should have received a copy of the GNU General Public License along + * with TWL. If not, see . + */ + +#ifndef TWL_ANDROID_BACKEND_H +#define TWL_ANDROID_BACKEND_H + +#include "twl_internal.h" + +#include +#include +#include + +typedef struct { + struct android_app *app; + EGLDisplay display; + EGLConfig config; + EGLContext context; + EGLSurface surface; + GLuint program; + GLuint texture; + GLuint vbo; + GLint attr_pos; + GLint attr_uv; + GLint uniform_tex; + uint16_t *staging; + uint32_t frame_width; + uint32_t frame_height; + int32_t surface_width; + int32_t surface_height; + int32_t view_x; + int32_t view_y; + int32_t view_w; + int32_t view_h; + int32_t pointer_x; + int32_t pointer_y; + bool gl_ready; + bool window_ready; + bool touching; + bool quit_pushed; +} TwlAndroid; + +void twl_android_on_command(struct android_app *app, int32_t command); +int32_t twl_android_on_input(struct android_app *app, AInputEvent *event); +void twl_android_pump_once(TwlAndroid *android, int timeout_ms); + +#endif diff --git a/thirdparty/twl/backends/android/input.c b/thirdparty/twl/backends/android/input.c new file mode 100644 index 0000000..ef23d96 --- /dev/null +++ b/thirdparty/twl/backends/android/input.c @@ -0,0 +1,209 @@ +/* + * Copyright (C) 2026 Michael Binder and contributors + * + * This file is part of TWL. + * + * TWL is free software: you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation, either version 3 of the License, or (at your option) any later + * version. + * + * TWL is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU General Public License for details. + */ + +#include "backend.h" + +#include +#include + +static int32_t twl_android_clamp(int32_t value, int32_t low, int32_t high) { + if (value < low) return low; + if (value > high) return high; + return value; +} + +static void twl_android_push(Twl *twl, TwlEvent *event) { + event->timestamp_us = twl_backend_time_microseconds(twl); + twl_internal_push_event(twl, event); +} + +static void twl_android_push_pointer( + Twl *twl, TwlEventType type, int32_t x, int32_t y) { + TwlEvent event; + twl_internal_zero(&event, sizeof(event)); + event.type = type; + event.x = x; + event.y = y; + event.button = 1u; + twl_android_push(twl, &event); +} + +static void twl_android_push_key(Twl *twl, TwlEventType type, TwlKey key) { + TwlEvent event; + twl_internal_zero(&event, sizeof(event)); + event.type = type; + event.key = key; + twl_android_push(twl, &event); +} + +static void twl_android_motion_to_frame( + const TwlAndroid *android, float raw_x, float raw_y, + int32_t *out_x, int32_t *out_y) { + const int32_t view_w = android->view_w > 0 ? android->view_w : 1; + const int32_t view_h = android->view_h > 0 ? android->view_h : 1; + *out_x = twl_android_clamp( + ((int32_t) raw_x - android->view_x) * (int32_t) android->frame_width / + view_w, + 0, (int32_t) android->frame_width - 1); + *out_y = twl_android_clamp( + ((int32_t) raw_y - android->view_y) * (int32_t) android->frame_height / + view_h, + 0, (int32_t) android->frame_height - 1); +} + +static int32_t twl_android_on_motion( + Twl *twl, TwlAndroid *android, AInputEvent *event) { + const int32_t action = + AMotionEvent_getAction(event) & AMOTION_EVENT_ACTION_MASK; + int32_t x; + int32_t y; + twl_android_motion_to_frame( + android, AMotionEvent_getX(event, 0), AMotionEvent_getY(event, 0), &x, &y); + + switch (action) { + case AMOTION_EVENT_ACTION_DOWN: + twl_android_push_pointer(twl, TWL_EVENT_POINTER_MOVE, x, y); + twl_android_push_pointer(twl, TWL_EVENT_POINTER_DOWN, x, y); + android->touching = true; + break; + case AMOTION_EVENT_ACTION_MOVE: + twl_android_push_pointer(twl, TWL_EVENT_POINTER_MOVE, x, y); + break; + case AMOTION_EVENT_ACTION_UP: + case AMOTION_EVENT_ACTION_CANCEL: + twl_android_push_pointer(twl, TWL_EVENT_POINTER_UP, x, y); + android->touching = false; + break; + default: + break; + } + android->pointer_x = x; + android->pointer_y = y; + return 1; +} + +static void twl_android_push_text(Twl *twl, uint32_t codepoint) { + TwlEvent event; + twl_internal_zero(&event, sizeof(event)); + event.type = TWL_EVENT_TEXT; + event.codepoint = codepoint; + twl_android_push(twl, &event); +} + +static uint32_t twl_android_char_for(int32_t code, int32_t meta) { + const int upper = + ((meta & AMETA_SHIFT_ON) != 0) ^ ((meta & AMETA_CAPS_LOCK_ON) != 0); + if (code >= AKEYCODE_A && code <= AKEYCODE_Z) { + return (uint32_t) ((upper ? 'A' : 'a') + (code - AKEYCODE_A)); + } + if (code >= AKEYCODE_0 && code <= AKEYCODE_9) { + return (uint32_t) ('0' + (code - AKEYCODE_0)); + } + if (code == AKEYCODE_SPACE) { + return (uint32_t) ' '; + } + return 0u; +} + +static TwlKey twl_android_key_for(int32_t code) { + if (code >= AKEYCODE_A && code <= AKEYCODE_Z) { + return (TwlKey) (TWL_KEY_A + (code - AKEYCODE_A)); + } + if (code >= AKEYCODE_0 && code <= AKEYCODE_9) { + return (TwlKey) (TWL_KEY_0 + (code - AKEYCODE_0)); + } + switch (code) { + case AKEYCODE_BACK: return TWL_KEY_ESCAPE; + case AKEYCODE_ENTER: + case AKEYCODE_DPAD_CENTER: return TWL_KEY_RETURN; + case AKEYCODE_DPAD_UP: return TWL_KEY_UP; + case AKEYCODE_DPAD_DOWN: return TWL_KEY_DOWN; + case AKEYCODE_DPAD_LEFT: return TWL_KEY_LEFT; + case AKEYCODE_DPAD_RIGHT: return TWL_KEY_RIGHT; + case AKEYCODE_DEL: return TWL_KEY_BACKSPACE; + case AKEYCODE_FORWARD_DEL: return TWL_KEY_DELETE; + case AKEYCODE_SPACE: return TWL_KEY_SPACE; + case AKEYCODE_TAB: return TWL_KEY_TAB; + default: return TWL_KEY_UNKNOWN; + } +} + +static int32_t twl_android_on_key(Twl *twl, AInputEvent *event) { + const int32_t action = AKeyEvent_getAction(event); + const int32_t code = AKeyEvent_getKeyCode(event); + const TwlKey key = twl_android_key_for(code); + if (key == TWL_KEY_UNKNOWN) { + return 0; + } + if (action == AKEY_EVENT_ACTION_DOWN) { + const uint32_t codepoint = + twl_android_char_for(code, AKeyEvent_getMetaState(event)); + twl_android_push_key(twl, TWL_EVENT_KEY_DOWN, key); + if (codepoint != 0u) { + twl_android_push_text(twl, codepoint); + } + } else if (action == AKEY_EVENT_ACTION_UP) { + twl_android_push_key(twl, TWL_EVENT_KEY_UP, key); + } + return 1; +} + +int32_t twl_android_on_input(struct android_app *app, AInputEvent *event) { + Twl *twl = (Twl *) app->userData; + TwlAndroid *android = twl ? (TwlAndroid *) twl->backend : NULL; + if (!android) { + return 0; + } + if (AInputEvent_getType(event) == AINPUT_EVENT_TYPE_MOTION) { + return twl_android_on_motion(twl, android, event); + } + if (AInputEvent_getType(event) == AINPUT_EVENT_TYPE_KEY) { + return twl_android_on_key(twl, event); + } + return 0; +} + +void twl_android_pump_once(TwlAndroid *android, int timeout_ms) { + struct android_poll_source *source = NULL; + int events = 0; + if (ALooper_pollOnce(timeout_ms, NULL, &events, (void **) &source) >= 0 && + source) { + source->process(android->app, source); + } +} + +void twl_backend_pump_events(Twl *twl) { + TwlAndroid *android = twl ? (TwlAndroid *) twl->backend : NULL; + struct android_poll_source *source = NULL; + int events = 0; + if (!android) { + return; + } + while (ALooper_pollOnce(0, NULL, &events, (void **) &source) >= 0) { + if (source) { + source->process(android->app, source); + } + if (android->app->destroyRequested) { + break; + } + } + if (android->app->destroyRequested && !android->quit_pushed) { + TwlEvent event; + twl_internal_zero(&event, sizeof(event)); + event.type = TWL_EVENT_QUIT; + twl_android_push(twl, &event); + android->quit_pushed = true; + } +} diff --git a/thirdparty/twl/backends/android/presentation.c b/thirdparty/twl/backends/android/presentation.c new file mode 100644 index 0000000..e748b3c --- /dev/null +++ b/thirdparty/twl/backends/android/presentation.c @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2026 Michael Binder and contributors + * + * This file is part of TWL. + * + * TWL is free software: you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation, either version 3 of the License, or (at your option) any later + * version. + * + * TWL is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU General Public License for details. + */ + +#include "backend.h" + +#include + +TwlResult twl_backend_prepare_frame(Twl *twl, const TwlSurface *surface) { + TwlAndroid *android = twl ? (TwlAndroid *) twl->backend : NULL; + const uint16_t *src; + size_t src_stride; + uint32_t y; + uint32_t x; + if (!android || !surface || !surface->pixels) { + return TWL_RESULT_INVALID_ARGUMENT; + } + if (surface->format != TWL_PIXEL_RGB555 || + surface->width != android->frame_width || + surface->height != android->frame_height) { + return TWL_RESULT_INVALID_ARGUMENT; + } + if (!android->window_ready || !android->gl_ready) { + return TWL_RESULT_OK; + } + + src = (const uint16_t *) surface->pixels; + src_stride = surface->stride_bytes / sizeof(uint16_t); + for (y = 0; y < android->frame_height; ++y) { + const uint16_t *source_row = src + (size_t) y * src_stride; + uint16_t *dest_row = android->staging + (size_t) y * android->frame_width; + for (x = 0; x < android->frame_width; ++x) { + dest_row[x] = (uint16_t) ((source_row[x] << 1) | 1u); + } + } + + glBindTexture(GL_TEXTURE_2D, android->texture); + glTexSubImage2D( + GL_TEXTURE_2D, 0, 0, 0, (GLsizei) android->frame_width, + (GLsizei) android->frame_height, GL_RGBA, GL_UNSIGNED_SHORT_5_5_5_1, + android->staging); + return TWL_RESULT_OK; +} + +TwlResult twl_backend_display_frame(Twl *twl) { + TwlAndroid *android = twl ? (TwlAndroid *) twl->backend : NULL; + if (!android) { + return TWL_RESULT_INVALID_ARGUMENT; + } + if (!android->window_ready || !android->gl_ready) { + return TWL_RESULT_OK; + } + + glViewport(0, 0, android->surface_width, android->surface_height); + glClearColor(0.0f, 0.0f, 0.0f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + + glViewport( + android->view_x, android->view_y, android->view_w, android->view_h); + glUseProgram(android->program); + glBindBuffer(GL_ARRAY_BUFFER, android->vbo); + glEnableVertexAttribArray((GLuint) android->attr_pos); + glVertexAttribPointer( + (GLuint) android->attr_pos, 2, GL_FLOAT, GL_FALSE, + (GLsizei) (4 * sizeof(GLfloat)), (const void *) 0); + glEnableVertexAttribArray((GLuint) android->attr_uv); + glVertexAttribPointer( + (GLuint) android->attr_uv, 2, GL_FLOAT, GL_FALSE, + (GLsizei) (4 * sizeof(GLfloat)), + (const void *) (2 * sizeof(GLfloat))); + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, android->texture); + glUniform1i(android->uniform_tex, 0); + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + + eglSwapBuffers(android->display, android->surface); + return TWL_RESULT_OK; +} diff --git a/thirdparty/twl/backends/android/twl_android.h b/thirdparty/twl/backends/android/twl_android.h new file mode 100644 index 0000000..118af06 --- /dev/null +++ b/thirdparty/twl/backends/android/twl_android.h @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2026 Michael Binder and contributors + * + * This file is part of TWL. + * + * TWL is free software: you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation, either version 3 of the License, or (at your option) any later + * version. + * + * TWL is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU General Public License for details. + * + * You should have received a copy of the GNU General Public License along + * with TWL. If not, see . + */ + +#ifndef TWL_ANDROID_H +#define TWL_ANDROID_H + +struct android_app; + +#ifdef __cplusplus +extern "C" { +#endif + +void twl_android_set_app(struct android_app *app); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/thirdparty/twl/backends/android/window.c b/thirdparty/twl/backends/android/window.c new file mode 100644 index 0000000..32690b7 --- /dev/null +++ b/thirdparty/twl/backends/android/window.c @@ -0,0 +1,329 @@ +/* + * Copyright (C) 2026 Michael Binder and contributors + * + * This file is part of TWL. + * + * TWL is free software: you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation, either version 3 of the License, or (at your option) any later + * version. + * + * TWL is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU General Public License for details. + */ + +#include "backend.h" +#include "twl_android.h" + +#include + +#include + +#define TWL_ANDROID_LOG(...) \ + __android_log_print(ANDROID_LOG_INFO, "OpenShadowFlare", __VA_ARGS__) + +static const GLfloat twl_android_quad[] = { + -1.0f, -1.0f, 0.0f, 1.0f, + 1.0f, -1.0f, 1.0f, 1.0f, + -1.0f, 1.0f, 0.0f, 0.0f, + 1.0f, 1.0f, 1.0f, 0.0f, +}; + +static const char *const twl_android_vertex_source = + "attribute vec2 a_pos;\n" + "attribute vec2 a_uv;\n" + "varying vec2 v_uv;\n" + "void main() {\n" + " v_uv = a_uv;\n" + " gl_Position = vec4(a_pos, 0.0, 1.0);\n" + "}\n"; + +static const char *const twl_android_fragment_source = + "precision mediump float;\n" + "varying vec2 v_uv;\n" + "uniform sampler2D u_tex;\n" + "void main() {\n" + " gl_FragColor = texture2D(u_tex, v_uv).bgra;\n" + "}\n"; + +static struct android_app *twl_android_pending_app; + +void twl_android_set_app(struct android_app *app) { + twl_android_pending_app = app; +} + +static void twl_android_fit_viewport(TwlAndroid *android) { + const int64_t content_w = (int64_t) android->frame_width; + const int64_t content_h = (int64_t) android->frame_height; + const int32_t surface_w = android->surface_width; + const int32_t surface_h = android->surface_height; + if (surface_w <= 0 || surface_h <= 0) { + return; + } + if (content_w * surface_h >= content_h * surface_w) { + android->view_w = surface_w; + android->view_h = (int32_t) (content_h * surface_w / content_w); + } else { + android->view_h = surface_h; + android->view_w = (int32_t) (content_w * surface_h / content_h); + } + android->view_x = (surface_w - android->view_w) / 2; + android->view_y = (surface_h - android->view_h) / 2; +} + +static GLuint twl_android_compile(GLenum type, const char *source) { + GLuint shader = glCreateShader(type); + GLint compiled = 0; + if (!shader) { + return 0u; + } + glShaderSource(shader, 1, &source, NULL); + glCompileShader(shader); + glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled); + if (!compiled) { + char log[256]; + glGetShaderInfoLog(shader, (GLsizei) sizeof(log), NULL, log); + TWL_ANDROID_LOG("shader compile failed: %s", log); + glDeleteShader(shader); + return 0u; + } + return shader; +} + +static bool twl_android_build_gl(TwlAndroid *android) { + GLuint vertex = twl_android_compile(GL_VERTEX_SHADER, twl_android_vertex_source); + GLuint fragment = + twl_android_compile(GL_FRAGMENT_SHADER, twl_android_fragment_source); + GLint linked = 0; + if (!vertex || !fragment) { + if (vertex) glDeleteShader(vertex); + if (fragment) glDeleteShader(fragment); + return false; + } + android->program = glCreateProgram(); + glAttachShader(android->program, vertex); + glAttachShader(android->program, fragment); + glLinkProgram(android->program); + glDeleteShader(vertex); + glDeleteShader(fragment); + glGetProgramiv(android->program, GL_LINK_STATUS, &linked); + if (!linked) { + char log[256]; + glGetProgramInfoLog(android->program, (GLsizei) sizeof(log), NULL, log); + TWL_ANDROID_LOG("program link failed: %s", log); + return false; + } + android->attr_pos = glGetAttribLocation(android->program, "a_pos"); + android->attr_uv = glGetAttribLocation(android->program, "a_uv"); + android->uniform_tex = glGetUniformLocation(android->program, "u_tex"); + + glGenBuffers(1, &android->vbo); + glBindBuffer(GL_ARRAY_BUFFER, android->vbo); + glBufferData( + GL_ARRAY_BUFFER, sizeof(twl_android_quad), twl_android_quad, + GL_STATIC_DRAW); + + glGenTextures(1, &android->texture); + glBindTexture(GL_TEXTURE_2D, android->texture); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glTexImage2D( + GL_TEXTURE_2D, 0, GL_RGBA, (GLsizei) android->frame_width, + (GLsizei) android->frame_height, 0, GL_RGBA, GL_UNSIGNED_SHORT_5_5_5_1, + NULL); + + android->gl_ready = true; + return true; +} + +static void twl_android_query_size(TwlAndroid *android) { + EGLint width = 0; + EGLint height = 0; + eglQuerySurface(android->display, android->surface, EGL_WIDTH, &width); + eglQuerySurface(android->display, android->surface, EGL_HEIGHT, &height); + android->surface_width = width; + android->surface_height = height; + twl_android_fit_viewport(android); +} + +static void twl_android_create_surface(TwlAndroid *android) { + if (android->surface != EGL_NO_SURFACE || !android->app->window) { + return; + } + android->surface = eglCreateWindowSurface( + android->display, android->config, android->app->window, NULL); + if (android->surface == EGL_NO_SURFACE) { + TWL_ANDROID_LOG("eglCreateWindowSurface failed: 0x%x", eglGetError()); + return; + } + if (!eglMakeCurrent( + android->display, android->surface, android->surface, + android->context)) { + TWL_ANDROID_LOG("eglMakeCurrent failed: 0x%x", eglGetError()); + eglDestroySurface(android->display, android->surface); + android->surface = EGL_NO_SURFACE; + return; + } + eglSwapInterval(android->display, 1); + if (!android->gl_ready && !twl_android_build_gl(android)) { + return; + } + twl_android_query_size(android); + android->window_ready = true; +} + +static void twl_android_destroy_surface(TwlAndroid *android) { + android->window_ready = false; + if (android->display == EGL_NO_DISPLAY) { + return; + } + eglMakeCurrent( + android->display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT); + if (android->surface != EGL_NO_SURFACE) { + eglDestroySurface(android->display, android->surface); + android->surface = EGL_NO_SURFACE; + } +} + +void twl_android_on_command(struct android_app *app, int32_t command) { + Twl *twl = (Twl *) app->userData; + TwlAndroid *android = twl ? (TwlAndroid *) twl->backend : NULL; + if (!android) { + return; + } + switch (command) { + case APP_CMD_INIT_WINDOW: + twl_android_create_surface(android); + break; + case APP_CMD_TERM_WINDOW: + twl_android_destroy_surface(android); + break; + case APP_CMD_WINDOW_RESIZED: + case APP_CMD_CONFIG_CHANGED: + if (android->window_ready) { + twl_android_query_size(android); + } + break; + default: + break; + } +} + +size_t twl_backend_memory_alignment(void) { + return _Alignof(TwlAndroid); +} + +size_t twl_backend_memory_required(const TwlConfig *config) { + const uint32_t width = (config && config->width) ? config->width : 640u; + const uint32_t height = (config && config->height) ? config->height : 480u; + return sizeof(TwlAndroid) + + (size_t) width * (size_t) height * sizeof(uint16_t); +} + +TwlResult twl_backend_init( + Twl *twl, void *memory, size_t memory_size, const TwlConfig *config) { + TwlAndroid *android; + size_t required; + const EGLint config_attributes[] = { + EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT, + EGL_SURFACE_TYPE, EGL_WINDOW_BIT, + EGL_RED_SIZE, 8, + EGL_GREEN_SIZE, 8, + EGL_BLUE_SIZE, 8, + EGL_NONE + }; + const EGLint context_attributes[] = { + EGL_CONTEXT_CLIENT_VERSION, 2, EGL_NONE + }; + EGLint config_count = 0; + if (!twl || !memory || !config) { + return TWL_RESULT_INVALID_ARGUMENT; + } + if (!twl_android_pending_app) { + return TWL_RESULT_BACKEND_UNAVAILABLE; + } + android = (TwlAndroid *) memory; + android->frame_width = config->width ? config->width : 640u; + android->frame_height = config->height ? config->height : 480u; + required = sizeof(TwlAndroid) + + (size_t) android->frame_width * android->frame_height * sizeof(uint16_t); + if (memory_size < required) { + return TWL_RESULT_INSUFFICIENT_MEMORY; + } + android->staging = (uint16_t *) ((uint8_t *) memory + sizeof(TwlAndroid)); + android->app = twl_android_pending_app; + android->display = EGL_NO_DISPLAY; + android->surface = EGL_NO_SURFACE; + android->context = EGL_NO_CONTEXT; + android->pointer_x = (int32_t) (android->frame_width / 2u); + android->pointer_y = (int32_t) (android->frame_height / 2u); + + android->display = eglGetDisplay(EGL_DEFAULT_DISPLAY); + if (android->display == EGL_NO_DISPLAY || + !eglInitialize(android->display, NULL, NULL)) { + return TWL_RESULT_BACKEND_FAILURE; + } + if (!eglChooseConfig( + android->display, config_attributes, &android->config, 1, + &config_count) || + config_count < 1) { + return TWL_RESULT_BACKEND_FAILURE; + } + android->context = eglCreateContext( + android->display, android->config, EGL_NO_CONTEXT, context_attributes); + if (android->context == EGL_NO_CONTEXT) { + return TWL_RESULT_BACKEND_FAILURE; + } + + android->app->userData = twl; + android->app->onAppCmd = twl_android_on_command; + android->app->onInputEvent = twl_android_on_input; + + twl_internal_set_display_size( + twl, android->frame_width, android->frame_height); + + while (!android->window_ready && !android->app->destroyRequested) { + twl_android_pump_once(android, -1); + } + return TWL_RESULT_OK; +} + +void twl_backend_shutdown(Twl *twl) { + TwlAndroid *android = twl ? (TwlAndroid *) twl->backend : NULL; + if (!android) { + return; + } + twl_android_destroy_surface(android); + if (android->display != EGL_NO_DISPLAY) { + if (android->context != EGL_NO_CONTEXT) { + eglDestroyContext(android->display, android->context); + android->context = EGL_NO_CONTEXT; + } + eglTerminate(android->display); + android->display = EGL_NO_DISPLAY; + } + if (android->app) { + android->app->userData = NULL; + android->app->onAppCmd = NULL; + android->app->onInputEvent = NULL; + } +} + +uint64_t twl_backend_time_microseconds(const Twl *twl) { + struct timespec now; + (void) twl; + clock_gettime(CLOCK_MONOTONIC, &now); + return (uint64_t) now.tv_sec * UINT64_C(1000000) + + (uint64_t) now.tv_nsec / UINT64_C(1000); +} + +void twl_backend_sleep_microseconds(Twl *twl, uint64_t duration) { + struct timespec request; + (void) twl; + request.tv_sec = (time_t) (duration / UINT64_C(1000000)); + request.tv_nsec = (long) ((duration % UINT64_C(1000000)) * UINT64_C(1000)); + nanosleep(&request, NULL); +} diff --git a/tools/android/build-apk.sh b/tools/android/build-apk.sh new file mode 100644 index 0000000..2c35d36 --- /dev/null +++ b/tools/android/build-apk.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env sh + +set -eu + +ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) +ANDROID_DIR="$ROOT_DIR/platform/android" +GRADLE_WRAPPER="$ANDROID_DIR/gradlew" +LOCAL_PROPERTIES="$ANDROID_DIR/local.properties" +GRADLE_APK="$ANDROID_DIR/app/build/outputs/apk/debug/app-debug.apk" +OUTPUT_DIR="$ROOT_DIR/build/android/debug" +OUTPUT_APK="$OUTPUT_DIR/OpenShadowFlare-android-debug.apk" + +if [ ! -f "$GRADLE_WRAPPER" ]; then + echo "Android Gradle wrapper not found: $GRADLE_WRAPPER" >&2 + exit 1 +fi + +if [ ! -f "$LOCAL_PROPERTIES" ]; then + cat >&2 <&2 + exit 1 +fi + +mkdir -p "$OUTPUT_DIR" +cp "$GRADLE_APK" "$OUTPUT_APK" +echo "APK: $OUTPUT_APK"