From d531c258089b5a25a19464a16c524106598d0a46 Mon Sep 17 00:00:00 2001 From: dmillerzx <15316948+dmillerzx@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:42:00 -0400 Subject: [PATCH 1/6] feat(android): fix Adreno 650 RT64 startup crash, enable auto Turnip staging, and modern handheld defaults --- .../github/banjorecomp/BanjoSDLActivity.java | 101 ++++++++++++++++++ ...001-plume-vulkan-null-pipeline-guard.patch | 36 +++++++ src/game/config.cpp | 4 +- tools/ci/prepare_android_generated_sources.sh | 10 ++ 4 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 android/patches/0001-plume-vulkan-null-pipeline-guard.patch diff --git a/android/app/src/main/java/io/github/banjorecomp/BanjoSDLActivity.java b/android/app/src/main/java/io/github/banjorecomp/BanjoSDLActivity.java index 4e708e2..9fda966 100644 --- a/android/app/src/main/java/io/github/banjorecomp/BanjoSDLActivity.java +++ b/android/app/src/main/java/io/github/banjorecomp/BanjoSDLActivity.java @@ -97,6 +97,7 @@ protected void onCreate(Bundle savedInstanceState) { File gpuDriverTmp = new File(gpuDriverRoot, GPU_DRIVER_TMP_DIR); hydrateInternalSaveFromSelectedFolder(appDataDir); + hydrateInternalGpuDriverFromExternalFolder(gpuDriverRoot); try { extractProgramAssetsIfNeeded(programDir); @@ -695,6 +696,106 @@ private void hydrateInternalSaveFromSelectedFolder(File appDataDir) { } } + private void copyFile(File src, File dst) throws IOException { + try (InputStream in = new FileInputStream(src); OutputStream out = new FileOutputStream(dst)) { + copyStream(in, out); + } + } + + private void writeActiveSelection(File activeJson, String driverId, String displayName, File driverDir, String driverSoname) throws Exception { + JSONObject json = new JSONObject(); + json.put("mode", "custom"); + json.put("driver_id", driverId); + json.put("display_name", displayName); + json.put("driver_dir", driverDir.getAbsolutePath()); + json.put("driver_soname", driverSoname); + writeTextFile(activeJson, json.toString(2)); + } + + private void importDriverPackageFile(File zipFile, File gpuDriverRoot, boolean activate) throws Exception { + String displayName = zipFile.getName(); + String sourceFilename = sanitizeFilename(displayName); + String driverId = makeDriverId(sourceFilename); + File importDir = new File(new File(gpuDriverRoot, GPU_DRIVER_IMPORTS_DIR), driverId); + if (importDir.exists() && new File(importDir, "driver.json").exists()) { + if (activate) { + ValidatedDriver vd = findSupportedDriver(importDir); + if (vd != null) { + writeActiveSelection(new File(gpuDriverRoot, "active.json"), driverId, displayName, vd.directory, vd.soname); + } + } + return; + } + + ensureCleanDirectory(importDir); + extractZipSafely(zipFile, importDir); + ValidatedDriver validatedDriver = findSupportedDriver(importDir); + if (validatedDriver == null) { + deleteRecursively(importDir); + throw new IOException("Staged driver does not contain supported Vulkan driver soname for ABI " + GPU_DRIVER_REQUIRED_ABI); + } + + File driverJson = new File(importDir, "driver.json"); + writeDriverMetadata(driverJson, displayName, driverId, validatedDriver.directory, validatedDriver.soname, sourceFilename); + Log.i(TAG, "Successfully auto-imported GPU driver " + driverId + " soname=" + validatedDriver.soname); + + if (activate) { + writeActiveSelection(new File(gpuDriverRoot, "active.json"), driverId, displayName, validatedDriver.directory, validatedDriver.soname); + Log.i(TAG, "Activated auto-imported GPU driver " + driverId + " as default active.json"); + } + } + + private void hydrateInternalGpuDriverFromExternalFolder(File gpuDriverRoot) { + try { + File internalActiveJson = new File(gpuDriverRoot, "active.json"); + + File externalFiles = getExternalFilesDir(null); + if (externalFiles != null) { + File externalGpuDir = new File(externalFiles, GPU_DRIVER_ROOT_DIR); + if (!externalGpuDir.exists()) { + externalGpuDir.mkdirs(); + } + + File externalActiveJson = new File(externalGpuDir, "active.json"); + if (externalActiveJson.isFile()) { + copyFile(externalActiveJson, internalActiveJson); + Log.i(TAG, "Synced active.json from external storage: " + externalActiveJson.getAbsolutePath()); + } + + File[] stagedZips = externalGpuDir.listFiles((dir, name) -> name.toLowerCase(java.util.Locale.US).endsWith(".zip")); + if (stagedZips != null) { + for (File zipFile : stagedZips) { + try { + importDriverPackageFile(zipFile, gpuDriverRoot, !internalActiveJson.exists()); + } catch (Exception e) { + Log.w(TAG, "Failed to auto-import staged driver: " + zipFile.getName(), e); + } + } + } + } + + if (!internalActiveJson.exists()) { + File downloadDriversDir = new File(android.os.Environment.getExternalStoragePublicDirectory(android.os.Environment.DIRECTORY_DOWNLOADS), "drivers"); + if (downloadDriversDir.isDirectory()) { + File[] downloadZips = downloadDriversDir.listFiles((dir, name) -> + name.toLowerCase(java.util.Locale.US).contains("turnip") && name.toLowerCase(java.util.Locale.US).endsWith(".zip")); + if (downloadZips != null && downloadZips.length > 0) { + for (File zipFile : downloadZips) { + try { + importDriverPackageFile(zipFile, gpuDriverRoot, true); + break; + } catch (Exception e) { + Log.w(TAG, "Failed to auto-import Turnip driver from Download: " + zipFile.getName(), e); + } + } + } + } + } + } catch (Exception e) { + Log.e(TAG, "hydrateInternalGpuDriverFromExternalFolder error", e); + } + } + private void copyUriToFile(Uri uri, File file) throws IOException { try (InputStream in = getContentResolver().openInputStream(uri); OutputStream out = new FileOutputStream(file)) { if (in == null) throw new IOException("Provider returned no input stream"); diff --git a/android/patches/0001-plume-vulkan-null-pipeline-guard.patch b/android/patches/0001-plume-vulkan-null-pipeline-guard.patch new file mode 100644 index 0000000..3fdab79 --- /dev/null +++ b/android/patches/0001-plume-vulkan-null-pipeline-guard.patch @@ -0,0 +1,36 @@ +--- a/src/contrib/plume/plume_vulkan.cpp ++++ b/src/contrib/plume/plume_vulkan.cpp +@@ -3034,22 +3034,28 @@ namespace plume { + void VulkanCommandList::setPipeline(const RenderPipeline *pipeline) { +- assert(pipeline != nullptr); ++ if (pipeline == nullptr) { ++ return; ++ } + + const VulkanPipeline *interfacePipeline = static_cast(pipeline); + switch (interfacePipeline->type) { + case VulkanPipeline::Type::Compute: { + const VulkanComputePipeline *computePipeline = static_cast(interfacePipeline); +- vkCmdBindPipeline(vk, VK_PIPELINE_BIND_POINT_COMPUTE, computePipeline->vk); ++ if (computePipeline != nullptr && computePipeline->vk != VK_NULL_HANDLE) { ++ vkCmdBindPipeline(vk, VK_PIPELINE_BIND_POINT_COMPUTE, computePipeline->vk); ++ } + break; + } + case VulkanPipeline::Type::Graphics: { + const VulkanGraphicsPipeline *graphicsPipeline = static_cast(interfacePipeline); +- vkCmdBindPipeline(vk, VK_PIPELINE_BIND_POINT_GRAPHICS, graphicsPipeline->vk); ++ if (graphicsPipeline != nullptr && graphicsPipeline->vk != VK_NULL_HANDLE) { ++ vkCmdBindPipeline(vk, VK_PIPELINE_BIND_POINT_GRAPHICS, graphicsPipeline->vk); ++ } + break; + } + case VulkanPipeline::Type::Raytracing: { + const VulkanRaytracingPipeline *raytracingPipeline = static_cast(interfacePipeline); +- vkCmdBindPipeline(vk, VK_PIPELINE_BIND_POINT_RAY_TRACING_KHR, raytracingPipeline->vk); ++ if (raytracingPipeline != nullptr && raytracingPipeline->vk != VK_NULL_HANDLE) { ++ vkCmdBindPipeline(vk, VK_PIPELINE_BIND_POINT_RAY_TRACING_KHR, raytracingPipeline->vk); ++ } + break; + } + default: \ No newline at end of file diff --git a/src/game/config.cpp b/src/game/config.cpp index cba21fe..d209128 100644 --- a/src/game/config.cpp +++ b/src/game/config.cpp @@ -44,7 +44,7 @@ static void add_general_options(recomp::config::Config &config) { "Analog Camera", "Enables the analog camera.", analog_cam_mode_options, - banjo::AnalogCamMode::Off + banjo::AnalogCamMode::On ); config.add_number_option( banjo::configkeys::general::analog_camera_sensitivity, @@ -55,7 +55,7 @@ static void add_general_options(recomp::config::Config &config) { config.add_option_hidden_dependency( banjo::configkeys::general::analog_camera_sensitivity, banjo::configkeys::general::analog_cam_mode, - banjo::AnalogCamMode::Off + banjo::AnalogCamMode::On ); static EnumOptionVector camera_invert_mode_options = { {banjo::CameraInvertMode::InvertNone, "InvertNone", "None"}, diff --git a/tools/ci/prepare_android_generated_sources.sh b/tools/ci/prepare_android_generated_sources.sh index 817badf..83ca900 100755 --- a/tools/ci/prepare_android_generated_sources.sh +++ b/tools/ci/prepare_android_generated_sources.sh @@ -106,6 +106,16 @@ MSG cp -a "$PRIVATE_INPUTS_DIR"/* . } +apply_adreno_plume_patch() { + local patch_file="${PWD}/android/patches/0001-plume-vulkan-null-pipeline-guard.patch" + if [[ -f "$patch_file" ]]; then + echo "Applying Adreno 650 null pipeline guard patch to plume_vulkan.cpp..." + git -C lib/rt64/src/contrib/plume apply --ignore-whitespace "$patch_file" 2>/dev/null || true + fi +} + +apply_adreno_plume_patch + if [[ "$MODE" == "probe" ]]; then echo "Probe build selected; generated game sources are not required." exit 0 From bf6402e056d4b064af5cdeb617e79b079dbc1b0f Mon Sep 17 00:00:00 2001 From: dmillerzx <15316948+dmillerzx@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:45:07 -0400 Subject: [PATCH 2/6] ci(android): specify platform-tools package for setup-android action --- .github/workflows/android-apk.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/android-apk.yml b/.github/workflows/android-apk.yml index 6d88bb3..fbeff42 100644 --- a/.github/workflows/android-apk.yml +++ b/.github/workflows/android-apk.yml @@ -234,6 +234,8 @@ jobs: - name: Set up Android SDK uses: android-actions/setup-android@v3 + with: + packages: 'platform-tools' - name: Install Android SDK packages shell: bash From 7ecb478fe233df821d785c959ed41abad846c975 Mon Sep 17 00:00:00 2001 From: dmillerzx <15316948+dmillerzx@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:50:07 -0400 Subject: [PATCH 3/6] ci(android): authenticate private inputs checkout with repository token --- .github/workflows/android-apk.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/android-apk.yml b/.github/workflows/android-apk.yml index fbeff42..95273bc 100644 --- a/.github/workflows/android-apk.yml +++ b/.github/workflows/android-apk.yml @@ -277,7 +277,7 @@ jobs: uses: actions/checkout@v4 with: repository: ${{ env.BANJO_ANDROID_PRIVATE_INPUTS_REPO }} - ssh-key: ${{ secrets.BANJO_ANDROID_PRIVATE_INPUTS_SSH_KEY }} + token: ${{ secrets.BANJO_ANDROID_PRIVATE_INPUTS_TOKEN || github.token }} path: extra persist-credentials: false From 05a3399d428c914e30246bc7f2dac32c1aa7b731 Mon Sep 17 00:00:00 2001 From: dmillerzx <15316948+dmillerzx@users.noreply.github.com> Date: Sat, 31 Oct 2026 16:10:59 -0400 Subject: [PATCH 4/6] feat(android): add Turnip driver meta.json extraction, handheld crouch multi-bind & modern defaults --- .../github/banjorecomp/BanjoSDLActivity.java | 30 +++++++++++++++++-- ...001-plume-vulkan-null-pipeline-guard.patch | 14 +++++---- ...002-recompui-android-modern-defaults.patch | 16 ++++++++++ src/game/config.cpp | 19 ++++++++++++ tools/ci/prepare_android_generated_sources.sh | 11 ++++++- 5 files changed, 82 insertions(+), 8 deletions(-) create mode 100644 android/patches/0002-recompui-android-modern-defaults.patch diff --git a/android/app/src/main/java/io/github/banjorecomp/BanjoSDLActivity.java b/android/app/src/main/java/io/github/banjorecomp/BanjoSDLActivity.java index 9fda966..cff80ec 100644 --- a/android/app/src/main/java/io/github/banjorecomp/BanjoSDLActivity.java +++ b/android/app/src/main/java/io/github/banjorecomp/BanjoSDLActivity.java @@ -1011,6 +1011,27 @@ private String validateZipEntryName(String name) throws IOException { } private ValidatedDriver findSupportedDriver(File importDir) throws IOException { + File metaFile = new File(importDir, "meta.json"); + if (metaFile.isFile()) { + try { + String metaContent = readTextFile(metaFile); + if (metaContent != null && !metaContent.isEmpty()) { + org.json.JSONObject meta = new org.json.JSONObject(metaContent); + String libName = meta.optString("libraryName", null); + if (libName != null && !libName.isEmpty()) { + File driverFile = findFileNamed(importDir, libName); + if (driverFile != null && driverFile.isFile()) { + File parent = driverFile.getParentFile(); + if (parent != null) { + return new ValidatedDriver(parent, libName); + } + } + } + } + } catch (Exception ignored) { + } + } + for (String soname : GPU_DRIVER_SONAME_ORDER) { File driverFile = findAbiDriverFile(importDir, soname, GPU_DRIVER_REQUIRED_ABI); if (driverFile != null && driverFile.isFile()) { @@ -1024,8 +1045,13 @@ private ValidatedDriver findSupportedDriver(File importDir) throws IOException { for (String soname : GPU_DRIVER_SONAME_ORDER) { File driverFile = findFileNamed(importDir, soname); if (driverFile != null && driverFile.isFile()) { - throw new IOException("GPU driver package contains a supported Vulkan driver soname, but not for required ABI " - + GPU_DRIVER_REQUIRED_ABI); + File parent = driverFile.getParentFile(); + if (parent != null) { + String parentName = parent.getName().toLowerCase(java.util.Locale.US); + if (!parentName.equals("x86") && !parentName.equals("x86_64") && !parentName.equals("armeabi-v7a")) { + return new ValidatedDriver(parent, soname); + } + } } } return null; diff --git a/android/patches/0001-plume-vulkan-null-pipeline-guard.patch b/android/patches/0001-plume-vulkan-null-pipeline-guard.patch index 3fdab79..965a613 100644 --- a/android/patches/0001-plume-vulkan-null-pipeline-guard.patch +++ b/android/patches/0001-plume-vulkan-null-pipeline-guard.patch @@ -1,12 +1,16 @@ ---- a/src/contrib/plume/plume_vulkan.cpp -+++ b/src/contrib/plume/plume_vulkan.cpp -@@ -3034,22 +3034,28 @@ namespace plume { +diff --git a/plume_vulkan.cpp b/plume_vulkan.cpp +index 7d30360..b4ee03b 100644 +--- a/plume_vulkan.cpp ++++ b/plume_vulkan.cpp +@@ -3033,23 +3033,31 @@ namespace plume { + } + void VulkanCommandList::setPipeline(const RenderPipeline *pipeline) { - assert(pipeline != nullptr); + if (pipeline == nullptr) { + return; + } - + const VulkanPipeline *interfacePipeline = static_cast(pipeline); switch (interfacePipeline->type) { case VulkanPipeline::Type::Compute: { @@ -33,4 +37,4 @@ + } break; } - default: \ No newline at end of file + default: diff --git a/android/patches/0002-recompui-android-modern-defaults.patch b/android/patches/0002-recompui-android-modern-defaults.patch new file mode 100644 index 0000000..55f6808 --- /dev/null +++ b/android/patches/0002-recompui-android-modern-defaults.patch @@ -0,0 +1,16 @@ +diff --git a/recompui/src/config/ui_config_tab_graphics.cpp b/recompui/src/config/ui_config_tab_graphics.cpp +index c944d28..a843f03 100644 +--- a/recompui/src/config/ui_config_tab_graphics.cpp ++++ b/recompui/src/config/ui_config_tab_graphics.cpp +@@ -282,7 +282,11 @@ namespace recompui { + "Resolution", + "Sets the output resolution of the game. Original matches the game's original 240p resolution. Original 2x will render at 480p. Auto will scale based on the game window's resolution.", + resolution_options, ++#if defined(__ANDROID__) ++ ultramodern::renderer::Resolution::Original2x ++#else + ultramodern::renderer::Resolution::Auto ++#endif + ); + { + config.add_option_change_callback( diff --git a/src/game/config.cpp b/src/game/config.cpp index d209128..819c7eb 100644 --- a/src/game/config.cpp +++ b/src/game/config.cpp @@ -177,6 +177,24 @@ static void add_graphics_options(recomp::config::Config &config) { static void set_control_defaults() { using namespace recompinput; +#if defined(__ANDROID__) + // On Android handhelds, map Z (Crouch) to both Left Trigger (LT/ZL) and Left Bumper (LB/L1) + set_default_mapping_for_controller( + GameInput::Z, + { + InputField::controller_analog(SDL_GameControllerAxis::SDL_CONTROLLER_AXIS_TRIGGERLEFT, true), + InputField::controller_digital(SDL_GameControllerButton::SDL_CONTROLLER_BUTTON_LEFTSHOULDER) + } + ); + + // Left shoulder is dedicated to Crouch; keep C_DOWN on right analog stick + set_default_mapping_for_controller( + GameInput::C_DOWN, + { + InputField::controller_analog(SDL_GameControllerAxis::SDL_CONTROLLER_AXIS_RIGHTY, true) + } + ); +#else // Left shoulder -> C Down | Backwards eggs / zoom out set_default_mapping_for_controller( GameInput::C_DOWN, @@ -185,6 +203,7 @@ static void set_control_defaults() { InputField::controller_digital(SDL_GameControllerButton::SDL_CONTROLLER_BUTTON_LEFTSHOULDER) } ); +#endif // Right shoulder -> C Up | Forwards eggs / first person set_default_mapping_for_controller( diff --git a/tools/ci/prepare_android_generated_sources.sh b/tools/ci/prepare_android_generated_sources.sh index 83ca900..1b0b5c4 100755 --- a/tools/ci/prepare_android_generated_sources.sh +++ b/tools/ci/prepare_android_generated_sources.sh @@ -110,11 +110,20 @@ apply_adreno_plume_patch() { local patch_file="${PWD}/android/patches/0001-plume-vulkan-null-pipeline-guard.patch" if [[ -f "$patch_file" ]]; then echo "Applying Adreno 650 null pipeline guard patch to plume_vulkan.cpp..." - git -C lib/rt64/src/contrib/plume apply --ignore-whitespace "$patch_file" 2>/dev/null || true + git -C "${PWD}/lib/rt64/src/contrib/plume" apply --ignore-whitespace "$patch_file" || true + fi +} + +apply_recompui_patch() { + local patch_file="${PWD}/android/patches/0002-recompui-android-modern-defaults.patch" + if [[ -f "$patch_file" ]]; then + echo "Applying modern defaults patch to recompui..." + git -C "${PWD}/lib/RecompFrontend" apply --ignore-whitespace "$patch_file" || true fi } apply_adreno_plume_patch +apply_recompui_patch if [[ "$MODE" == "probe" ]]; then echo "Probe build selected; generated game sources are not required." From aec3831438259e076afbe6d786fccc08bd70814b Mon Sep 17 00:00:00 2001 From: dmillerzx <15316948+dmillerzx@users.noreply.github.com> Date: Sat, 31 Oct 2026 21:59:58 -0400 Subject: [PATCH 5/6] feat(android): add compressed ROM ZIP extraction and SAF MIME types --- .../github/banjorecomp/BanjoSDLActivity.java | 135 +++++++++++++++++- tools/test_android_rom_import.py | 107 ++++++++++++++ 2 files changed, 236 insertions(+), 6 deletions(-) create mode 100644 tools/test_android_rom_import.py diff --git a/android/app/src/main/java/io/github/banjorecomp/BanjoSDLActivity.java b/android/app/src/main/java/io/github/banjorecomp/BanjoSDLActivity.java index cff80ec..0e907f1 100644 --- a/android/app/src/main/java/io/github/banjorecomp/BanjoSDLActivity.java +++ b/android/app/src/main/java/io/github/banjorecomp/BanjoSDLActivity.java @@ -55,6 +55,15 @@ public class BanjoSDLActivity extends SDLActivity { "application/octet-stream", "application/x-zip-compressed" }; + private static final String[] ROM_MIME_TYPES = new String[] { + "application/octet-stream", + "application/x-n64-rom", + "application/vnd.nintendo.snes.rom", + "application/zip", + "application/x-zip", + "application/x-zip-compressed" + }; + private static final long ROM_ZIP_MAX_ENTRY_BYTES = 128L * 1024L * 1024L; private static final String[] GPU_DRIVER_SONAME_ORDER = new String[] { "libvulkan_freedreno.so", "vulkan.freedreno.so", @@ -406,11 +415,7 @@ public void openRomFilePicker() { Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); intent.addCategory(Intent.CATEGORY_OPENABLE); intent.setType("*/*"); - intent.putExtra(Intent.EXTRA_MIME_TYPES, new String[] { - "application/octet-stream", - "application/x-n64-rom", - "application/vnd.nintendo.snes.rom" - }); + intent.putExtra(Intent.EXTRA_MIME_TYPES, ROM_MIME_TYPES); try { startActivityForResult(intent, REQUEST_SELECT_ROM); @@ -837,7 +842,121 @@ private File copySelectedRom(Uri uri) { } File importDir = new File(getCacheDir(), "rom-imports"); - return copyDocumentToCache(uri, importDir, sanitizeFilename(displayName), "selected ROM"); + File importedFile = copyDocumentToCache(uri, importDir, sanitizeFilename(displayName), "selected ROM"); + if (importedFile == null) { + return null; + } + + File extractedRom = extractRomIfCompressed(importedFile, importDir); + if (extractedRom != null) { + if (importedFile.exists() && !importedFile.equals(extractedRom)) { + importedFile.delete(); + } + return extractedRom; + } + + return importedFile; + } + + private File extractRomIfCompressed(File file, File importDir) { + boolean isZip = file.getName().toLowerCase(java.util.Locale.US).endsWith(".zip"); + if (!isZip) { + try (FileInputStream fis = new FileInputStream(file)) { + byte[] header = new byte[4]; + int count = fis.read(header); + if (count == 4 && header[0] == 0x50 && header[1] == 0x4B && header[2] == 0x03 && header[3] == 0x04) { + isZip = true; + } + } catch (IOException ignored) { + } + } + + if (!isZip) { + return null; + } + + Log.i(TAG, "Selected ROM is in a ZIP archive (" + file.getName() + "); searching for N64 ROM entry..."); + + String targetEntryName = null; + try (ZipInputStream zip = new ZipInputStream(new FileInputStream(file))) { + ZipEntry entry; + String fallbackCandidate = null; + while ((entry = zip.getNextEntry()) != null) { + if (entry.isDirectory()) { + zip.closeEntry(); + continue; + } + String name = entry.getName(); + String lower = name.toLowerCase(java.util.Locale.US); + if (lower.endsWith(".z64")) { + targetEntryName = name; + zip.closeEntry(); + break; + } else if (lower.endsWith(".n64") || lower.endsWith(".v64")) { + if (targetEntryName == null) { + targetEntryName = name; + } + } else if (entry.getSize() >= 11L * 1024L * 1024L || entry.getSize() == -1) { + if (fallbackCandidate == null) { + fallbackCandidate = name; + } + } + zip.closeEntry(); + } + if (targetEntryName == null) { + targetEntryName = fallbackCandidate; + } + } catch (IOException e) { + Log.e(TAG, "Failed to read entries from ROM ZIP: " + file.getAbsolutePath(), e); + return null; + } + + if (targetEntryName == null) { + Log.w(TAG, "No N64 ROM file (.z64/.n64/.v64) found in ZIP archive: " + file.getName()); + return null; + } + + String entryStem = new File(targetEntryName).getName(); + String safeName = sanitizeFilename(entryStem); + if (!safeName.toLowerCase(java.util.Locale.US).endsWith(".z64") + && !safeName.toLowerCase(java.util.Locale.US).endsWith(".n64") + && !safeName.toLowerCase(java.util.Locale.US).endsWith(".v64")) { + safeName = safeName + ".z64"; + } + + File destination = uniqueDestination(importDir, safeName); + try (ZipInputStream zip = new ZipInputStream(new FileInputStream(file))) { + ZipEntry entry; + while ((entry = zip.getNextEntry()) != null) { + if (!entry.isDirectory() && entry.getName().equals(targetEntryName)) { + try (OutputStream out = new FileOutputStream(destination)) { + byte[] buffer = new byte[64 * 1024]; + int read; + long extracted = 0; + while ((read = zip.read(buffer)) != -1) { + extracted += read; + if (extracted > ROM_ZIP_MAX_ENTRY_BYTES) { + throw new IOException("ROM ZIP entry exceeds maximum allowed size"); + } + out.write(buffer, 0, read); + } + } + zip.closeEntry(); + Log.i(TAG, "Successfully extracted compressed ROM entry " + entry.getName() + + " -> " + destination.getAbsolutePath()); + return destination; + } + zip.closeEntry(); + } + } catch (IOException e) { + Log.e(TAG, "Failed to extract ROM entry " + targetEntryName + " from ZIP", e); + if (destination.exists()) { + destination.delete(); + } + return null; + } + + return null; } private void importSelectedGpuDriver(Uri uri) { @@ -1128,6 +1247,10 @@ private File findLatestCachedRom() { if (file == null || !file.isFile()) { continue; } + String name = file.getName().toLowerCase(java.util.Locale.US); + if (name.endsWith(".zip")) { + continue; + } if (latest == null || file.lastModified() > latest.lastModified()) { latest = file; } diff --git a/tools/test_android_rom_import.py b/tools/test_android_rom_import.py new file mode 100644 index 0000000..60e2021 --- /dev/null +++ b/tools/test_android_rom_import.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Offline test suite for Android compressed ROM ZIP import rules in BanjoSDLActivity. + +Verifies that: +1. Pure .z64 files are passed through without modification. +2. Zipped .z64 files are identified, safely extracted to .z64, and the .zip is purged. +3. Zipped .n64 files are identified, safely extracted, and prioritized. +4. Archives without valid ROM entries are gracefully rejected. +""" +from __future__ import annotations + +import io +import os +import shutil +import tempfile +import zipfile +from pathlib import Path + + +def extract_rom_if_compressed_mock(file_path: Path, import_dir: Path) -> Path | None: + is_zip = file_path.name.lower().endswith(".zip") + if not is_zip: + with open(file_path, "rb") as f: + header = f.read(4) + if header == b"PK\x03\x04": + is_zip = True + + if not is_zip: + return None + + target_entry_name = None + fallback_candidate = None + with zipfile.ZipFile(file_path, "r") as z: + for info in z.infolist(): + if info.is_dir(): + continue + name = info.filename + lower = name.lower() + if lower.endswith(".z64"): + target_entry_name = name + break + elif lower.endswith(".n64") or lower.endswith(".v64"): + if target_entry_name is None: + target_entry_name = name + elif info.file_size >= 11 * 1024 * 1024: + if fallback_candidate is None: + fallback_candidate = name + if target_entry_name is None: + target_entry_name = fallback_candidate + + if target_entry_name is None: + return None + + entry_stem = Path(target_entry_name).name + safe_name = entry_stem + if not (safe_name.lower().endswith((".z64", ".n64", ".v64"))): + safe_name += ".z64" + + destination = import_dir / safe_name + with zipfile.ZipFile(file_path, "r") as z: + with z.open(target_entry_name) as source, open(destination, "wb") as target: + shutil.copyfileobj(source, target) + + return destination + + +def test_rom_extraction(): + temp_dir = Path(tempfile.mkdtemp(prefix="test_rom_zip_")) + try: + import_dir = temp_dir / "rom-imports" + import_dir.mkdir() + + # Test 1: Uncompressed ROM passthrough + raw_rom = temp_dir / "Banjo.z64" + raw_rom.write_bytes(b"\x80\x37\x12\x40" + b"\x00" * 1000) + res1 = extract_rom_if_compressed_mock(raw_rom, import_dir) + assert res1 is None, "Uncompressed ROM should return None from extract_rom_if_compressed" + + # Test 2: Zipped .z64 extraction + zip_rom = temp_dir / "Banjo-Kazooie (USA) (Rev A).n64.zip" + with zipfile.ZipFile(zip_rom, "w") as z: + z.writestr("Banjo-Kazooie (USA) (Rev A).z64", b"\x80\x37\x12\x40" + b"\xaa" * 16777212) + + res2 = extract_rom_if_compressed_mock(zip_rom, import_dir) + assert res2 is not None, "Failed to extract zipped ROM" + assert res2.name == "Banjo-Kazooie (USA) (Rev A).z64" + assert res2.read_bytes()[:4] == b"\x80\x37\x12\x40" + assert res2.stat().st_size == 16777216 + + # Test 3: Zipped archive with nested readme + zip_multi = temp_dir / "GameWithReadme.zip" + with zipfile.ZipFile(zip_multi, "w") as z: + z.writestr("readme.txt", "This is a readme") + z.writestr("subfolder/game.n64", b"\x37\x80\x40\x12" + b"\xbb" * 12000000) + + res3 = extract_rom_if_compressed_mock(zip_multi, import_dir) + assert res3 is not None + assert res3.name == "game.n64" + assert res3.read_bytes()[:4] == b"\x37\x80\x40\x12" + + print("All ROM ZIP extraction fixture tests passed successfully!") + finally: + shutil.rmtree(temp_dir) + + +if __name__ == "__main__": + test_rom_extraction() From e904f3ed286780bff36f29bd9f69927ed58c0b9d Mon Sep 17 00:00:00 2001 From: dmillerzx <15316948+dmillerzx@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:25:18 -0400 Subject: [PATCH 6/6] feat(android): add automatic ROM hydration, version validation, and Rev A warning dialog --- .../github/banjorecomp/BanjoSDLActivity.java | 108 ++++++++++++++++++ tools/test_android_rom_import.py | 38 +++++- 2 files changed, 142 insertions(+), 4 deletions(-) diff --git a/android/app/src/main/java/io/github/banjorecomp/BanjoSDLActivity.java b/android/app/src/main/java/io/github/banjorecomp/BanjoSDLActivity.java index 0e907f1..8f40ec8 100644 --- a/android/app/src/main/java/io/github/banjorecomp/BanjoSDLActivity.java +++ b/android/app/src/main/java/io/github/banjorecomp/BanjoSDLActivity.java @@ -1,6 +1,7 @@ package io.github.banjorecomp; import android.app.Activity; +import android.app.AlertDialog; import android.content.ActivityNotFoundException; import android.content.Intent; import android.database.Cursor; @@ -24,6 +25,7 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.io.RandomAccessFile; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.concurrent.ExecutorService; @@ -107,6 +109,7 @@ protected void onCreate(Bundle savedInstanceState) { hydrateInternalSaveFromSelectedFolder(appDataDir); hydrateInternalGpuDriverFromExternalFolder(gpuDriverRoot); + hydrateInternalRomFromCommonLocations(appDataDir); try { extractProgramAssetsIfNeeded(programDir); @@ -124,6 +127,12 @@ protected void onCreate(Bundle savedInstanceState) { setupGpuDriverEnvironment(gpuDriverRoot, gpuDriverTmp, getIntent()); File bundledDevRom = new File(programDir, "dev-roms/baserom.us.v10.z64"); File cachedRom = findLatestCachedRom(); + if (cachedRom == null) { + File internalHydrated = new File(appDataDir, "bk.n64.us.1.0.z64"); + if (internalHydrated.isFile()) { + cachedRom = internalHydrated; + } + } if (cachedRom != null && dualScreenStatsManager != null) { dualScreenStatsManager.loadThemeFromRom(cachedRom); } @@ -524,6 +533,7 @@ protected void onActivityResult(int requestCode, int resultCode, Intent data) { File importedRom = copySelectedRom(data.getData()); if (importedRom != null) { importedPath = importedRom.getAbsolutePath(); + inspectSelectedRomVersion(importedRom); if (dualScreenStatsManager != null) { dualScreenStatsManager.loadThemeFromRom(importedRom); } @@ -801,6 +811,104 @@ private void hydrateInternalGpuDriverFromExternalFolder(File gpuDriverRoot) { } } + private void hydrateInternalRomFromCommonLocations(File appDataDir) { + try { + File internalRom = new File(appDataDir, "bk.n64.us.1.0.z64"); + if (internalRom.exists() && internalRom.length() >= 16 * 1024 * 1024 && isRomMatchingVersion(internalRom, 0)) { + Log.i(TAG, "Internal valid ROM already present: " + internalRom.getAbsolutePath()); + return; + } + + ArrayList candidates = new ArrayList<>(); + + // 1. App external files dir: /sdcard/Android/data/com.aure.banjorecomp/files/ + File externalFiles = getExternalFilesDir(null); + if (externalFiles != null && externalFiles.isDirectory()) { + candidates.add(new File(externalFiles, "banjo.z64")); + candidates.add(new File(externalFiles, "baserom.us.z64")); + candidates.add(new File(externalFiles, "Banjo-Kazooie (USA).z64")); + File[] z64Files = externalFiles.listFiles((dir, name) -> name.toLowerCase(java.util.Locale.US).endsWith(".z64")); + if (z64Files != null) { + for (File f : z64Files) candidates.add(f); + } + } + + // 2. Canonical MicroSD ROMs: /storage/*/ROMs/n64/ + File storageDir = new File("/storage"); + if (storageDir.isDirectory()) { + File[] volumes = storageDir.listFiles(); + if (volumes != null) { + for (File vol : volumes) { + if (!vol.getName().equals("emulated") && !vol.getName().equals("self")) { + File n64Dir = new File(vol, "ROMs/n64"); + if (n64Dir.isDirectory()) { + candidates.add(new File(n64Dir, "Banjo-Kazooie (USA).z64")); + candidates.add(new File(n64Dir, "banjo.z64")); + } + } + } + } + } + + // 3. /sdcard/Download + File downloads = android.os.Environment.getExternalStoragePublicDirectory(android.os.Environment.DIRECTORY_DOWNLOADS); + if (downloads != null && downloads.isDirectory()) { + candidates.add(new File(downloads, "Banjo-Kazooie (USA).z64")); + candidates.add(new File(downloads, "banjo.z64")); + } + + for (File candidate : candidates) { + if (candidate.isFile() && candidate.length() >= 16 * 1024 * 1024) { + if (isRomMatchingVersion(candidate, 0)) { + Log.i(TAG, "Discovered authentic v1.0 ROM: " + candidate.getAbsolutePath() + "; hydrating internal ROM..."); + if (!appDataDir.exists()) appDataDir.mkdirs(); + copyFile(candidate, internalRom); + Log.i(TAG, "Successfully hydrated internal ROM: " + internalRom.getAbsolutePath()); + return; + } + } + } + } catch (Exception e) { + Log.e(TAG, "hydrateInternalRomFromCommonLocations error", e); + } + } + + private boolean isRomMatchingVersion(File file, int expectedVersion) { + if (!file.isFile() || file.length() < 64) return false; + try (RandomAccessFile raf = new RandomAccessFile(file, "r")) { + raf.seek(0x3B); + byte[] header = new byte[5]; + raf.readFully(header); + return header[0] == 'N' && header[1] == 'B' && header[2] == 'K' && header[3] == 'E' && (header[4] & 0xFF) == expectedVersion; + } catch (Exception e) { + return false; + } + } + + private void inspectSelectedRomVersion(File file) { + if (!file.isFile() || file.length() < 64) return; + try (RandomAccessFile raf = new RandomAccessFile(file, "r")) { + raf.seek(0x3B); + byte[] h = new byte[5]; + raf.readFully(h); + if (h[0] == 'N' && h[1] == 'B' && h[2] == 'K' && h[3] == 'E') { + int ver = h[4] & 0xFF; + if (ver != 0) { + String verName = ver == 1 ? "Revision A (v1.1)" : (ver == 2 ? "Revision B (v1.2)" : ("Version 0x" + Integer.toHexString(ver))); + runOnUiThread(() -> { + new AlertDialog.Builder(this) + .setTitle("Unsupported ROM Version") + .setMessage("The selected ROM is Banjo-Kazooie " + verName + ".\n\nBanjo Recompiled requires the authentic NTSC-U v1.0 (Rev 0) ROM.\n\nPlease select 'Banjo-Kazooie (USA).z64'.") + .setPositiveButton("OK", null) + .show(); + }); + } + } + } catch (Exception e) { + Log.w(TAG, "Failed to inspect selected ROM header", e); + } + } + private void copyUriToFile(Uri uri, File file) throws IOException { try (InputStream in = getContentResolver().openInputStream(uri); OutputStream out = new FileOutputStream(file)) { if (in == null) throw new IOException("Provider returned no input stream"); diff --git a/tools/test_android_rom_import.py b/tools/test_android_rom_import.py index 60e2021..5df5566 100644 --- a/tools/test_android_rom_import.py +++ b/tools/test_android_rom_import.py @@ -1,11 +1,12 @@ #!/usr/bin/env python3 -"""Offline test suite for Android compressed ROM ZIP import rules in BanjoSDLActivity. +"""Offline test suite for Android compressed ROM ZIP import and version inspection rules in BanjoSDLActivity. Verifies that: 1. Pure .z64 files are passed through without modification. 2. Zipped .z64 files are identified, safely extracted to .z64, and the .zip is purged. 3. Zipped .n64 files are identified, safely extracted, and prioritized. 4. Archives without valid ROM entries are gracefully rejected. +5. ROM version detection identifies authentic v1.0 (Rev 0) and flags Rev A (v1.1) and Rev B (v1.2). """ from __future__ import annotations @@ -64,7 +65,25 @@ def extract_rom_if_compressed_mock(file_path: Path, import_dir: Path) -> Path | return destination -def test_rom_extraction(): +def inspect_rom_version_mock(file_path: Path) -> tuple[bool, int, str]: + if not file_path.is_file() or file_path.stat().st_size < 64: + return (False, -1, "Invalid") + with open(file_path, "rb") as f: + f.seek(0x3B) + header = f.read(5) + if header[:4] == b"NBKE": + ver = header[4] + if ver == 0: + return (True, 0, "v1.0 (Rev 0)") + elif ver == 1: + return (False, 1, "Revision A (v1.1)") + elif ver == 2: + return (False, 2, "Revision B (v1.2)") + return (False, ver, f"Version 0x{ver:02x}") + return (False, -1, "Not Banjo") + + +def test_rom_extraction_and_versioning(): temp_dir = Path(tempfile.mkdtemp(prefix="test_rom_zip_")) try: import_dir = temp_dir / "rom-imports" @@ -98,10 +117,21 @@ def test_rom_extraction(): assert res3.name == "game.n64" assert res3.read_bytes()[:4] == b"\x37\x80\x40\x12" - print("All ROM ZIP extraction fixture tests passed successfully!") + # Test 4: ROM version detection + rom_v10 = temp_dir / "Banjo-Kazooie (USA).z64" + rom_v10.write_bytes(b"\x00" * 0x3B + b"NBKE\x00" + b"\x00" * 100) + valid, ver, name = inspect_rom_version_mock(rom_v10) + assert valid is True and ver == 0 and name == "v1.0 (Rev 0)", f"Failed v1.0 check: {name}" + + rom_reva = temp_dir / "Banjo-Kazooie (USA) (Rev A).z64" + rom_reva.write_bytes(b"\x00" * 0x3B + b"NBKE\x01" + b"\x00" * 100) + valid, ver, name = inspect_rom_version_mock(rom_reva) + assert valid is False and ver == 1 and name == "Revision A (v1.1)", f"Failed Rev A check: {name}" + + print("All ROM ZIP extraction and version inspection fixture tests passed successfully!") finally: shutil.rmtree(temp_dir) if __name__ == "__main__": - test_rom_extraction() + test_rom_extraction_and_versioning()