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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Normalize all text files to LF in the repository and on checkout.
# This prevents CRLF warnings on Windows (core.autocrlf=true) and ensures
# shell scripts are always LF so they execute correctly on Linux/Mac.
* text=auto eol=lf

# Windows batch and cmd scripts require CRLF to run correctly on Windows.
*.bat text eol=crlf
*.cmd text eol=crlf

# Binary files — never modify line endings.
*.jar binary
*.bin binary
*.png binary
*.apk binary
*.aab binary
*.z64 binary
*.n64 binary
*.v64 binary
*.rom binary
*.elf binary
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
.vscode/c_cpp_properties.json
.vscode/launch.json

# Jetbrains / Android Studio settings
.idea/

# Input elf and ROM files (local/private only)
*.elf
*.z64
Expand Down
44 changes: 31 additions & 13 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -143,13 +143,29 @@ target_sources(PatchesLib PRIVATE

set_source_files_properties(${CMAKE_SOURCE_DIR}/RecompiledPatches/patches.c PROPERTIES COMPILE_FLAGS -fno-strict-aliasing)

# Build patches elf
if(NOT DEFINED PATCHES_C_COMPILER)
set(PATCHES_C_COMPILER clang)
# RecompiledPatches elf compilation needs a host clang/ld.lld.
# On Windows, we prefer the NDK's toolchain if a host one isn't explicitly on PATH.
if(CMAKE_HOST_WIN32 AND CMAKE_SYSTEM_NAME MATCHES "Android")
if(NOT DEFINED PATCHES_C_COMPILER)
set(PATCHES_C_COMPILER "clang.exe")
endif()
if(NOT DEFINED PATCHES_LD)
set(PATCHES_LD "ld.lld.exe")
endif()
else()
if(NOT DEFINED PATCHES_C_COMPILER)
set(PATCHES_C_COMPILER clang)
endif()
if(NOT DEFINED PATCHES_LD)
set(PATCHES_LD ld.lld)
endif()
endif()

if(NOT DEFINED PATCHES_LD)
set(PATCHES_LD ld.lld)
# N64Recomp is a host tool built separately and placed at the source root.
if(CMAKE_HOST_WIN32)
set(N64RECOMP_HOST_TOOL "${CMAKE_SOURCE_DIR}/N64Recomp.exe")
else()
set(N64RECOMP_HOST_TOOL "${CMAKE_SOURCE_DIR}/N64Recomp")
endif()

add_custom_target(PatchesBin
Expand All @@ -160,8 +176,8 @@ add_custom_target(PatchesBin

# Generate patches_bin.c from patches.bin
add_custom_command(OUTPUT ${CMAKE_SOURCE_DIR}/RecompiledPatches/patches_bin.c
COMMAND file_to_c ${CMAKE_SOURCE_DIR}/patches/patches.bin bk_patches_bin ${CMAKE_SOURCE_DIR}/RecompiledPatches/patches_bin.c ${CMAKE_SOURCE_DIR}/RecompiledPatches/patches_bin.h
DEPENDS ${CMAKE_SOURCE_DIR}/patches/patches.bin
COMMAND ${FILE_TO_C_TOOL} ${CMAKE_SOURCE_DIR}/patches/patches.bin bk_patches_bin ${CMAKE_SOURCE_DIR}/RecompiledPatches/patches_bin.c ${CMAKE_SOURCE_DIR}/RecompiledPatches/patches_bin.h
DEPENDS ${CMAKE_SOURCE_DIR}/patches/patches.bin ${RT64_FILE_TO_C_PY}
)

# Recompile patches elf into patches.c and patches.bin
Expand All @@ -171,7 +187,7 @@ add_custom_command(OUTPUT
${CMAKE_SOURCE_DIR}/RecompiledPatches/recomp_overlays.inl
${CMAKE_SOURCE_DIR}/RecompiledPatches/funcs.h
# TODO: Look into why modifying patches requires two builds to take
COMMAND ./N64Recomp patches.toml
COMMAND ${N64RECOMP_HOST_TOOL} patches.toml
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
DEPENDS ${CMAKE_SOURCE_DIR}/patches/patches.elf
)
Expand Down Expand Up @@ -226,8 +242,8 @@ target_include_directories(${BANJO_RECOMP_TARGET} PRIVATE

# Generate icon_bytes.c from the app icon PNG.
add_custom_command(OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/icon_bytes.c ${CMAKE_CURRENT_BINARY_DIR}/icon_bytes.h
COMMAND file_to_c ${CMAKE_SOURCE_DIR}/icons/app.png icon_bytes ${CMAKE_CURRENT_BINARY_DIR}/icon_bytes.c ${CMAKE_CURRENT_BINARY_DIR}/icon_bytes.h
DEPENDS ${CMAKE_SOURCE_DIR}/icons/app.png
COMMAND ${FILE_TO_C_TOOL} ${CMAKE_SOURCE_DIR}/icons/app.png icon_bytes ${CMAKE_CURRENT_BINARY_DIR}/icon_bytes.c ${CMAKE_CURRENT_BINARY_DIR}/icon_bytes.h
DEPENDS ${CMAKE_SOURCE_DIR}/icons/app.png ${RT64_FILE_TO_C_PY}
)

target_sources(${BANJO_RECOMP_TARGET} PRIVATE ${CMAKE_CURRENT_BINARY_DIR}/icon_bytes.c)
Expand Down Expand Up @@ -383,7 +399,9 @@ else()
endif()
else()
# Android and other cross-builds run DXC on the build host, not the target.
if(CMAKE_HOST_SYSTEM_PROCESSOR MATCHES "x86_64|amd64|AMD64")
if (CMAKE_HOST_WIN32)
set (DXC "${PROJECT_SOURCE_DIR}/lib/rt64/src/contrib/dxc/bin/x64/dxc.exe")
elseif(CMAKE_HOST_SYSTEM_PROCESSOR MATCHES "x86_64|amd64|AMD64")
set (DXC "LD_LIBRARY_PATH=${PROJECT_SOURCE_DIR}/lib/rt64/src/contrib/dxc/lib/x64" "${PROJECT_SOURCE_DIR}/lib/rt64/src/contrib/dxc/bin/x64/dxc-linux")
else()
set (DXC "LD_LIBRARY_PATH=${PROJECT_SOURCE_DIR}/lib/rt64/src/contrib/dxc/lib/arm64" "${PROJECT_SOURCE_DIR}/lib/rt64/src/contrib/dxc/bin/arm64/dxc-linux")
Expand Down Expand Up @@ -425,8 +443,8 @@ foreach(NRM_FILE ${NRM_FILES})

add_custom_command(
OUTPUT ${OUT_C} ${OUT_H}
COMMAND file_to_c ${NRM_FILE} ${NRM_NAME} ${OUT_C} ${OUT_H}
DEPENDS ${NRM_FILE}
COMMAND ${FILE_TO_C_TOOL} ${NRM_FILE} ${NRM_NAME} ${OUT_C} ${OUT_H}
DEPENDS ${NRM_FILE} ${RT64_FILE_TO_C_PY}
)

list(APPEND GENERATED_NRM_SOURCES ${OUT_C})
Expand Down
53 changes: 36 additions & 17 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,19 @@ plugins {
}

final String homeDir = System.getenv('HOME') ?: System.getProperty('user.home')
final String androidSdk = System.getenv('ANDROID_HOME') ?: "${homeDir}/Android/Sdk"
final String androidNdk = System.getenv('ANDROID_NDK_HOME') ?: "${androidSdk}/ndk/28.2.13676358"
final String sdl2Prefix = System.getenv('BANJO_ANDROID_SDL2_PREFIX') ?: "${homeDir}/Android/prefixes/SDL2-2.32.10-android-arm64"
final String freetypePrefix = System.getenv('BANJO_ANDROID_FREETYPE_PREFIX') ?: "${homeDir}/Android/prefixes/freetype-2.13.3-android-arm64"
final String androidSdk = (System.getenv('ANDROID_HOME') ?: {
String defaultSdk = "${homeDir}/Android/Sdk"
if (System.getProperty('os.name').toLowerCase().contains('windows')) {
String appDataSdk = "${System.getenv('LOCALAPPDATA')}/Android/Sdk"
if (!new File(defaultSdk).exists() && new File(appDataSdk).exists()) {
return appDataSdk
}
}
return defaultSdk
}.call()).replace('\\', '/')
final String androidNdk = (System.getenv('ANDROID_NDK_HOME') ?: "${androidSdk}/ndk/28.2.13676358").replace('\\', '/')
final String sdl2Prefix = (System.getenv('BANJO_ANDROID_SDL2_PREFIX') ?: "${homeDir}/Android/prefixes/SDL2-2.32.10-android-arm64").replace('\\', '/')
final String freetypePrefix = (System.getenv('BANJO_ANDROID_FREETYPE_PREFIX') ?: "${homeDir}/Android/prefixes/freetype-2.13.3-android-arm64").replace('\\', '/')
final String defaultDevRomDir = System.getenv('BANJO_ANDROID_DEV_ROM_DIR') ?: project.findProperty('banjoDevRomDir') ?: rootProject.projectDir.parentFile.absolutePath
final String bundledBaserom = System.getenv('BANJO_ANDROID_BASEROM') ?: new File(defaultDevRomDir, 'lib/bk-decomp/baserom.us.v10.z64').absolutePath
final String bundledDecompressedRom = System.getenv('BANJO_ANDROID_DECOMPRESSED_ROM') ?: new File(defaultDevRomDir, 'banjo.us.v10.decompressed.z64').absolutePath
Expand All @@ -17,7 +26,7 @@ final boolean banjoRuntimeApk = !banjoProbe
final boolean banjoNativeOptimized = (project.findProperty('banjoNativeOptimized') ?: (banjoRuntimeApk ? 'true' : 'false')).toString().toBoolean()
final boolean banjoDualScreenDebug = (project.findProperty('banjoDualScreenDebug') ?: 'false').toString().toBoolean()
final String banjoVersionName = System.getenv('BANJO_ANDROID_VERSION_NAME') ?: (banjoProbe ? '0.1.0-android-sdl-probe' : (banjoBundleDevRoms ? '0.1.0-android-dev-roms' : '0.1.0-android-runtime'))
final int banjoVersionCode = (System.getenv('BANJO_ANDROID_VERSION_CODE') ?: project.findProperty('banjoVersionCode') ?: '1').toString().toInteger()
final int banjoVersionCode = (System.getenv('BANJO_ANDROID_VERSION_CODE') ?: project.findProperty('banjoVersionCode') ?: '2').toString().toInteger()
final String releaseStoreFile = System.getenv('BANJO_ANDROID_KEYSTORE_FILE')
final String releaseStorePassword = System.getenv('BANJO_ANDROID_KEYSTORE_PASSWORD')
final String releaseKeyAlias = System.getenv('BANJO_ANDROID_KEY_ALIAS')
Expand Down Expand Up @@ -62,6 +71,8 @@ android {
// Android's Gradle Debug variant drives CMake with CMAKE_BUILD_TYPE=Debug,
// which otherwise gives Clang no optimization flags. The full game is CPU
// heavy enough that an -O0 native build is not representative for perf testing.
// On Windows, -O2 on hundreds of large machine-generated files can OOM Ninja.
// Use -O1 for a better balance if memory issues persist.
cFlags '-O2', '-g', '-DNDEBUG'
cppFlags '-O2', '-g', '-DNDEBUG'
}
Expand Down Expand Up @@ -120,13 +131,13 @@ android {

String findOnPath(String name) {
final String pathValue = System.getenv('PATH') ?: ''
final boolean isWindows = System.getProperty('os.name').toLowerCase().contains('windows')
final List<String> candidates = isWindows ? [name, "${name}.exe", "${name}.cmd"] : [name]
for (String pathEntry : pathValue.split(File.pathSeparator)) {
if (!pathEntry) {
continue
}
File candidate = new File(pathEntry, name)
if (candidate.isFile() && candidate.canExecute()) {
return candidate.absolutePath
if (!pathEntry) continue
for (String candidateName : candidates) {
File candidate = new File(pathEntry, candidateName)
if (candidate.isFile() && candidate.canExecute()) return candidate.absolutePath
}
}
return null
Expand All @@ -145,7 +156,10 @@ tasks.register('validateAndroidBuildEnvironment') {
requirePath(file(androidSdk), 'ANDROID_HOME does not point at an Android SDK')
requirePath(file(androidNdk), 'ANDROID_NDK_HOME does not point at the required Android NDK 28.2.13676358')
requirePath(new File(androidNdk, 'build/cmake/android.toolchain.cmake'), 'Android NDK toolchain file is missing')
requirePath(new File(androidNdk, 'toolchains/llvm/prebuilt/linux-x86_64/bin/ld.lld'), 'Android NDK ld.lld is missing')
final boolean isWindows = System.getProperty('os.name').toLowerCase().contains('windows')
final String ndkHostArch = isWindows ? 'windows-x86_64' : 'linux-x86_64'
final String ldLldBin = isWindows ? 'ld.lld.exe' : 'ld.lld'
requirePath(new File(androidNdk, "toolchains/llvm/prebuilt/${ndkHostArch}/bin/${ldLldBin}"), "Android NDK ${ldLldBin} is missing")
requirePath(new File(sdl2Prefix, 'lib/cmake/SDL2'), 'BANJO_ANDROID_SDL2_PREFIX is missing SDL2 CMake config')
requirePath(new File(sdl2Prefix, 'lib/libSDL2.so'), 'BANJO_ANDROID_SDL2_PREFIX is missing libSDL2.so for packaging')
requirePath(new File(freetypePrefix, 'include/freetype2'), 'BANJO_ANDROID_FREETYPE_PREFIX is missing Freetype headers')
Expand All @@ -158,11 +172,16 @@ tasks.register('validateAndroidBuildEnvironment') {
}
requirePath(new File(rootProject.projectDir.parentFile, 'rsp/n_aspMain.cpp'), 'Runtime APK builds need generated rsp/n_aspMain.cpp; run tools/ci/prepare_android_generated_sources.sh first')
requirePath(new File(rootProject.projectDir.parentFile, 'RecompiledPatches/patches.c'), 'Runtime APK builds need generated RecompiledPatches/patches.c; run tools/ci/prepare_android_generated_sources.sh first')
if (findOnPath('clang') == null) {
throw new GradleException('Runtime patch/source regeneration expects host clang on PATH; source /home/hermes/.config/android-build-env.sh and prefer /usr/lib/llvm-19/bin before the NDK toolchain bin')
}
if (findOnPath('ld.lld') == null) {
throw new GradleException('Runtime patch/source regeneration expects host-visible ld.lld on PATH; /home/hermes/.config/android-build-env.sh creates/uses a symlink when the host package lacks one')
// clang and ld.lld are only needed to (re)build patches/patches.bin via the patches Makefile.
// Skip this check when patches.bin already exists — CMake will not invoke the patches build step.
final boolean patchesBinExists = new File(rootProject.projectDir.parentFile, 'patches/patches.bin').exists()
if (!patchesBinExists) {
if (findOnPath('clang') == null) {
throw new GradleException('Runtime patch/source regeneration expects host clang on PATH; on Linux install clang via your package manager and prefer /usr/lib/llvm-XX/bin, on Windows ensure the NDK toolchain bin is on PATH')
}
if (findOnPath('ld.lld') == null) {
throw new GradleException('Runtime patch/source regeneration expects host ld.lld on PATH; on Linux install lld via your package manager (or symlink ld.lld to the versioned binary), on Windows ensure the NDK toolchain bin is on PATH')
}
}
}

Expand Down
6 changes: 5 additions & 1 deletion android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<application
android:allowBackup="false"
android:hasCode="true"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="Banjo Recompiled">
android:label="Banjo Recompiled"
android:requestLegacyExternalStorage="true">
<activity
android:name="io.github.banjorecomp.BanjoSDLActivity"
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
Expand Down
102 changes: 102 additions & 0 deletions android/app/src/main/java/io/github/banjorecomp/BanjoSDLActivity.java
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
package io.github.banjorecomp;

import android.app.Activity;
import android.app.AlertDialog;
import android.content.ActivityNotFoundException;
import android.content.DialogInterface;
import android.content.Intent;
import android.database.Cursor;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Environment;
import android.provider.Settings;
import android.provider.OpenableColumns;
import android.util.Log;
import android.view.KeyEvent;
Expand All @@ -29,6 +33,7 @@ public class BanjoSDLActivity extends SDLActivity {
private static final String TAG = "BanjoSDLActivity";
private static final int REQUEST_INSTALL_MODS = 1001;
private static final int REQUEST_SELECT_ROM = 1002;
private static final int REQUEST_STORAGE_PERMISSION = 1003;
private static final String PROGRAM_ASSET_STAMP_FILE = ".program-assets-stamp";
private static final String EXTRA_DUAL_SCREEN_PREVIEW = "dualscreen_preview";
private static final String EXTRA_DUAL_SCREEN_PREVIEW_CLEAR = "dualscreen_preview_clear";
Expand All @@ -54,6 +59,19 @@ protected void onCreate(Bundle savedInstanceState) {
File programDir = new File(getFilesDir(), "program");
File appDataDir = new File(getFilesDir(), "data");

if (hasAllFilesPermission()) {
File sharedDir = new File(Environment.getExternalStorageDirectory(), "BanjoRecompiled");
if (!sharedDir.exists()) {
sharedDir.mkdirs();
}
if (sharedDir.exists() && sharedDir.canWrite()) {
migrateDataIfNeeded(appDataDir, sharedDir);
appDataDir = sharedDir;
}
} else {
requestAllFilesPermission();
}

try {
extractProgramAssetsIfNeeded(programDir);
} catch (IOException e) {
Expand Down Expand Up @@ -672,4 +690,88 @@ private void copyAssetFile(String assetPath, File destination) throws IOExceptio
}
}
}

private boolean hasAllFilesPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
return Environment.isExternalStorageManager();
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
return checkSelfPermission(android.Manifest.permission.WRITE_EXTERNAL_STORAGE)
== android.content.pm.PackageManager.PERMISSION_GRANTED;
}
return true;
}

private void requestAllFilesPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
runOnUiThread(() -> {
new AlertDialog.Builder(this)
.setTitle("Storage Permission Required")
.setCancelable(false)
.setMessage("Banjo Recompiled needs access to your shared storage to store and load mods and save files from the 'BanjoRecompiled' folder. This allows you to easily manage your files and prevents data loss if the app is uninstalled.\n\nPlease enable 'All files access' in the next screen.")
.setPositiveButton("Go to Settings", (dialog, which) -> {
try {
Intent intent = new Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION);
intent.addCategory("android.intent.category.DEFAULT");
intent.setData(Uri.parse(String.format("package:%s", getPackageName())));
startActivity(intent);
} catch (Exception e) {
Intent intent = new Intent();
intent.setAction(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION);
startActivity(intent);
}
})
.setNegativeButton("Use Private Storage", (dialog, which) -> {
// Continue with private storage
})
.show();
});
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
requestPermissions(new String[]{android.Manifest.permission.WRITE_EXTERNAL_STORAGE},
REQUEST_STORAGE_PERMISSION);
}
}

private void migrateDataIfNeeded(File oldDataDir, File newDataDir) {
if (!oldDataDir.exists() || !oldDataDir.isDirectory()) {
return;
}

// We only migrate if the new directory is empty (besides maybe the directory itself)
File[] newFiles = newDataDir.listFiles();
if (newFiles != null && newFiles.length > 0) {
return;
}

Log.i(TAG, "Migrating data from " + oldDataDir.getAbsolutePath() + " to " + newDataDir.getAbsolutePath());
try {
copyDirectory(oldDataDir, newDataDir);
Log.i(TAG, "Migration successful");
} catch (IOException e) {
Log.e(TAG, "Migration failed", e);
}
}

private void copyDirectory(File source, File destination) throws IOException {
if (source.isDirectory()) {
if (!destination.exists() && !destination.mkdirs()) {
throw new IOException("Failed to create directory " + destination);
}
String[] children = source.list();
if (children != null) {
for (String child : children) {
copyDirectory(new File(source, child), new File(destination, child));
}
}
} else {
try (InputStream in = new FileInputStream(source);
OutputStream out = new FileOutputStream(destination)) {
byte[] buf = new byte[64 * 1024];
int len;
while ((len = in.read(buf)) > 0) {
out.write(buf, 0, len);
}
}
}
}
}
Binary file added android/gradle/wrapper/gradle-wrapper.jar
Binary file not shown.
7 changes: 7 additions & 0 deletions android/gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading