diff --git a/.github/workflows/android-apk.yml b/.github/workflows/android-apk.yml index 6d88bb3..95273bc 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 @@ -275,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 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..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; @@ -55,6 +57,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", @@ -97,6 +108,8 @@ protected void onCreate(Bundle savedInstanceState) { File gpuDriverTmp = new File(gpuDriverRoot, GPU_DRIVER_TMP_DIR); hydrateInternalSaveFromSelectedFolder(appDataDir); + hydrateInternalGpuDriverFromExternalFolder(gpuDriverRoot); + hydrateInternalRomFromCommonLocations(appDataDir); try { extractProgramAssetsIfNeeded(programDir); @@ -114,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); } @@ -405,11 +424,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); @@ -518,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); } @@ -695,6 +711,204 @@ 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 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"); @@ -736,7 +950,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) { @@ -910,6 +1238,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()) { @@ -923,8 +1272,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; @@ -1001,6 +1355,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/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..965a613 --- /dev/null +++ b/android/patches/0001-plume-vulkan-null-pipeline-guard.patch @@ -0,0 +1,40 @@ +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: { + 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: 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 cba21fe..819c7eb 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"}, @@ -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 817badf..1b0b5c4 100755 --- a/tools/ci/prepare_android_generated_sources.sh +++ b/tools/ci/prepare_android_generated_sources.sh @@ -106,6 +106,25 @@ 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 "${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." exit 0 diff --git a/tools/test_android_rom_import.py b/tools/test_android_rom_import.py new file mode 100644 index 0000000..5df5566 --- /dev/null +++ b/tools/test_android_rom_import.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""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 + +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 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" + 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" + + # 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_and_versioning()