From 261bab49a727b859120294d32a3d8ddae0759641 Mon Sep 17 00:00:00 2001 From: Zephyron Date: Sat, 29 Nov 2025 09:16:33 +1000 Subject: [PATCH 1/3] feat(android): add RomFS and ExeFS dumping support Changes: - Add JNI functions dumpRomFS() and dumpExeFS() in native.cpp - Add Kotlin interface methods in NativeLibrary.kt - Add UI options in GamePropertiesFragment for dumping - Add string resources for dump operations - Extract files to dump/{title_id}/romfs and dump/{title_id}/exefs - Support patched and updated game content during extraction Signed-off-by: Zephyron --- .../org/citron/citron_emu/NativeLibrary.kt | 27 +++ .../fragments/GamePropertiesFragment.kt | 59 +++++ src/android/app/src/main/jni/native.cpp | 206 ++++++++++++++++++ .../app/src/main/res/values/strings.xml | 8 + 4 files changed, 300 insertions(+) diff --git a/src/android/app/src/main/java/org/citron/citron_emu/NativeLibrary.kt b/src/android/app/src/main/java/org/citron/citron_emu/NativeLibrary.kt index 0dd1f6157..e778947f5 100644 --- a/src/android/app/src/main/java/org/citron/citron_emu/NativeLibrary.kt +++ b/src/android/app/src/main/java/org/citron/citron_emu/NativeLibrary.kt @@ -1,4 +1,5 @@ // SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-FileCopyrightText: 2025 citron Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later package org.citron.citron_emu @@ -464,4 +465,30 @@ object NativeLibrary { * Checks if all necessary keys are present for decryption */ external fun areKeysPresent(): Boolean + + /** + * Dumps the RomFS from a game to the dump directory + * @param gamePath Path to the game file + * @param programId String representation of the game's program ID + * @param callback Progress callback. Return true to cancel. Parameters: (max: Long, progress: Long) + * @return true if successful, false otherwise + */ + external fun dumpRomFS( + gamePath: String, + programId: String, + callback: (max: Long, progress: Long) -> Boolean + ): Boolean + + /** + * Dumps the ExeFS from a game to the dump directory + * @param gamePath Path to the game file + * @param programId String representation of the game's program ID + * @param callback Progress callback. Return true to cancel. Parameters: (max: Long, progress: Long) + * @return true if successful, false otherwise + */ + external fun dumpExeFS( + gamePath: String, + programId: String, + callback: (max: Long, progress: Long) -> Boolean + ): Boolean } diff --git a/src/android/app/src/main/java/org/citron/citron_emu/fragments/GamePropertiesFragment.kt b/src/android/app/src/main/java/org/citron/citron_emu/fragments/GamePropertiesFragment.kt index 203cbd70a..4645afe89 100644 --- a/src/android/app/src/main/java/org/citron/citron_emu/fragments/GamePropertiesFragment.kt +++ b/src/android/app/src/main/java/org/citron/citron_emu/fragments/GamePropertiesFragment.kt @@ -1,4 +1,5 @@ // SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-FileCopyrightText: 2025 citron Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later package org.citron.citron_emu.fragments @@ -25,6 +26,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.citron.citron_emu.HomeNavigationDirections +import org.citron.citron_emu.NativeLibrary import org.citron.citron_emu.R import org.citron.citron_emu.CitronApplication import org.citron.citron_emu.adapters.GamePropertiesAdapter @@ -273,6 +275,63 @@ class GamePropertiesFragment : Fragment() { } ) } + + // Add RomFS and ExeFS dump options + add( + SubmenuProperty( + R.string.dump_romfs, + R.string.dump_romfs_description, + R.drawable.ic_save + ) { + ProgressDialogFragment.newInstance( + requireActivity(), + R.string.dump_romfs_extracting, + false + ) { _, _ -> + val success = NativeLibrary.dumpRomFS( + args.game.path, + args.game.programIdHex, + { max, progress -> + // Progress callback - return true to cancel + false + } + ) + if (success) { + getString(R.string.dump_success) + } else { + getString(R.string.dump_failed) + } + }.show(parentFragmentManager, ProgressDialogFragment.TAG) + } + ) + + add( + SubmenuProperty( + R.string.dump_exefs, + R.string.dump_exefs_description, + R.drawable.ic_save + ) { + ProgressDialogFragment.newInstance( + requireActivity(), + R.string.dump_exefs_extracting, + false + ) { _, _ -> + val success = NativeLibrary.dumpExeFS( + args.game.path, + args.game.programIdHex, + { max, progress -> + // Progress callback - return true to cancel + false + } + ) + if (success) { + getString(R.string.dump_success) + } else { + getString(R.string.dump_failed) + } + }.show(parentFragmentManager, ProgressDialogFragment.TAG) + } + ) } } binding.listProperties.apply { diff --git a/src/android/app/src/main/jni/native.cpp b/src/android/app/src/main/jni/native.cpp index 620fc33d3..c56021497 100644 --- a/src/android/app/src/main/jni/native.cpp +++ b/src/android/app/src/main/jni/native.cpp @@ -1,4 +1,5 @@ // SPDX-FileCopyrightText: Copyright 2023 yuzu Emulator Project +// SPDX-FileCopyrightText: Copyright 2025 Citron Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later #include @@ -6,6 +7,7 @@ #include #include #include +#include #ifdef ARCHITECTURE_arm64 #include @@ -41,6 +43,7 @@ #include "core/file_sys/submission_package.h" #include "core/file_sys/vfs/vfs.h" #include "core/file_sys/vfs/vfs_real.h" +#include "core/file_sys/romfs.h" #include "core/frontend/applets/cabinet.h" #include "core/frontend/applets/controller.h" #include "core/frontend/applets/error.h" @@ -878,4 +881,207 @@ jboolean Java_org_citron_citron_1emu_NativeLibrary_areKeysPresent(JNIEnv* env, j return ContentManager::AreKeysPresent(); } +jboolean Java_org_citron_citron_1emu_NativeLibrary_dumpRomFS(JNIEnv* env, jobject jobj, + jstring jgamePath, jstring jprogramId, + jobject jcallback) { + const auto game_path = Common::Android::GetJString(env, jgamePath); + const auto program_id = EmulationSession::GetProgramId(env, jprogramId); + + auto& system = EmulationSession::GetInstance().System(); + auto& vfs = *system.GetFilesystem(); + + const auto loader = Loader::GetLoader(system, vfs.OpenFile(game_path, FileSys::OpenMode::Read)); + if (loader == nullptr) { + return false; + } + + FileSys::VirtualFile packed_update_raw{}; + loader->ReadUpdateRaw(packed_update_raw); + + const auto& installed = system.GetContentProvider(); + + // Find the base NCA for the program + u64 title_id = program_id; + const auto type = FileSys::ContentRecordType::Program; + auto base_nca = installed.GetEntry(title_id, type); + if (!base_nca || base_nca->GetStatus() != Loader::ResultStatus::Success) { + // Try to find any matching entry + const auto entries = installed.ListEntriesFilter(FileSys::TitleType::Application, type); + bool found = false; + for (const auto& entry : entries) { + if (FileSys::GetBaseTitleID(entry.title_id) == program_id) { + title_id = entry.title_id; + base_nca = installed.GetEntry(title_id, type); + if (base_nca && base_nca->GetStatus() == Loader::ResultStatus::Success) { + found = true; + break; + } + } + } + if (!found || !base_nca) { + return false; + } + } + + const FileSys::NCA update_nca{packed_update_raw, nullptr}; + if (type != FileSys::ContentRecordType::Program || + update_nca.GetStatus() != Loader::ResultStatus::ErrorMissingBKTRBaseRomFS || + update_nca.GetTitleId() != FileSys::GetUpdateTitleID(title_id)) { + packed_update_raw = {}; + } + + const auto base_romfs = base_nca->GetRomFS(); + if (!base_romfs) { + return false; + } + + const auto dump_dir = Common::FS::GetCitronPath(Common::FS::CitronPath::DumpDir); + const auto romfs_dir = fmt::format("{:016X}/romfs", title_id); + const auto path = dump_dir / romfs_dir; + + const FileSys::PatchManager pm{title_id, system.GetFileSystemController(), installed}; + auto romfs = pm.PatchRomFS(base_nca.get(), base_romfs, type, packed_update_raw, false); + + if (!romfs) { + return false; + } + + const auto extracted = FileSys::ExtractRomFS(romfs); + if (extracted == nullptr) { + return false; + } + + // Create output directory + const auto out_dir = std::make_shared(path); + if (!out_dir || !out_dir->IsWritable()) { + return false; + } + + // Copy RomFS recursively + const auto total_size = romfs->GetSize(); + size_t read_size = 0; + + auto jlambdaClass = env->GetObjectClass(jcallback); + auto jlambdaInvokeMethod = env->GetMethodID( + jlambdaClass, "invoke", "(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;"); + + const auto callback = [env, jcallback, jlambdaInvokeMethod, &read_size, total_size]( + size_t current_read) -> bool { + read_size += current_read; + auto jwasCancelled = env->CallObjectMethod( + jcallback, jlambdaInvokeMethod, Common::Android::ToJLong(env, total_size), + Common::Android::ToJLong(env, read_size)); + return Common::Android::GetJBoolean(env, jwasCancelled); + }; + + // Recursive copy function + const std::function copyDir = + [&](const FileSys::VirtualDir& src, const FileSys::VirtualDir& dest) -> bool { + if (!src || !dest) { + return false; + } + + // Copy files + for (const auto& file : src->GetFiles()) { + if (callback(0)) { // Check for cancellation + return false; + } + const auto out_file = dest->CreateFile(file->GetName()); + if (!FileSys::VfsRawCopy(file, out_file)) { + return false; + } + if (callback(file->GetSize())) { + return false; + } + } + + // Copy subdirectories + for (const auto& dir : src->GetSubdirectories()) { + if (callback(0)) { + return false; + } + const auto out_subdir = dest->CreateSubdirectory(dir->GetName()); + if (!copyDir(dir, out_subdir)) { + return false; + } + } + + return true; + }; + + return copyDir(extracted, out_dir); +} + +jboolean Java_org_citron_citron_1emu_NativeLibrary_dumpExeFS(JNIEnv* env, jobject jobj, + jstring jgamePath, jstring jprogramId, + jobject jcallback) { + const auto game_path = Common::Android::GetJString(env, jgamePath); + const auto program_id = EmulationSession::GetProgramId(env, jprogramId); + + auto& system = EmulationSession::GetInstance().System(); + auto& vfs = *system.GetFilesystem(); + + const auto loader = Loader::GetLoader(system, vfs.OpenFile(game_path, FileSys::OpenMode::Read)); + if (loader == nullptr) { + return false; + } + + const auto& installed = system.GetContentProvider(); + + // Find the base NCA for the program + u64 title_id = program_id; + const auto type = FileSys::ContentRecordType::Program; + auto base_nca = installed.GetEntry(title_id, type); + if (!base_nca) { + // Try to find any matching entry + const auto entries = installed.ListEntriesFilter(FileSys::TitleType::Application, type); + for (const auto& entry : entries) { + if (FileSys::GetBaseTitleID(entry.title_id) == program_id) { + title_id = entry.title_id; + base_nca = installed.GetEntry(title_id, type); + if (base_nca && base_nca->GetStatus() == Loader::ResultStatus::Success) { + break; + } + } + } + if (!base_nca || base_nca->GetStatus() != Loader::ResultStatus::Success) { + return false; + } + } + + auto exefs = base_nca->GetExeFS(); + if (!exefs) { + // Try update NCA + const auto update_nca = installed.GetEntry(FileSys::GetUpdateTitleID(title_id), type); + if (update_nca) { + exefs = update_nca->GetExeFS(); + } + if (!exefs) { + return false; + } + } + + // Apply patches + const FileSys::PatchManager pm{title_id, system.GetFileSystemController(), installed}; + exefs = pm.PatchExeFS(exefs); + + if (!exefs) { + return false; + } + + // Get dump directory + const auto dump_dir = system.GetFileSystemController().GetModificationDumpRoot(title_id); + if (!dump_dir) { + return false; + } + + const auto exefs_dir = FileSys::GetOrCreateDirectoryRelative(dump_dir, "/exefs"); + if (!exefs_dir) { + return false; + } + + // Copy ExeFS - callback is unused for ExeFS as it's typically small + return FileSys::VfsRawCopyD(exefs, exefs_dir); +} + } // extern "C" diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index ff9b63cbc..74e307a9b 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -145,6 +145,14 @@ Encryption keys are missing Firmware and retail games cannot be decrypted https://discord.gg/citron + Dump RomFS + Extract the RomFS filesystem from the game + Dump ExeFS + Extract the ExeFS filesystem from the game + Extracting RomFS… + Extracting ExeFS… + Extraction completed successfully + Extraction failed Applet launcher From 9c250aabdacd519a02d6357d52407783b07ea0ed Mon Sep 17 00:00:00 2001 From: Zephyron Date: Sat, 29 Nov 2025 09:20:40 +1000 Subject: [PATCH 2/3] feat(android): add custom dump location selection for RomFS/ExeFS Allow users to specify a custom directory for dumping game files via the Android document picker, with fallback to default dump directory. Adds optional dumpPath parameter to both dump functions and UI dialog for location selection. Signed-off-by: Zephyron --- .../org/citron/citron_emu/NativeLibrary.kt | 4 + .../fragments/GamePropertiesFragment.kt | 165 ++++++++++++++---- src/android/app/src/main/jni/native.cpp | 45 ++++- .../app/src/main/res/values/strings.xml | 3 + 4 files changed, 177 insertions(+), 40 deletions(-) diff --git a/src/android/app/src/main/java/org/citron/citron_emu/NativeLibrary.kt b/src/android/app/src/main/java/org/citron/citron_emu/NativeLibrary.kt index e778947f5..b6d6d77ca 100644 --- a/src/android/app/src/main/java/org/citron/citron_emu/NativeLibrary.kt +++ b/src/android/app/src/main/java/org/citron/citron_emu/NativeLibrary.kt @@ -470,12 +470,14 @@ object NativeLibrary { * Dumps the RomFS from a game to the dump directory * @param gamePath Path to the game file * @param programId String representation of the game's program ID + * @param dumpPath Optional custom dump path. If null or empty, uses default dump directory * @param callback Progress callback. Return true to cancel. Parameters: (max: Long, progress: Long) * @return true if successful, false otherwise */ external fun dumpRomFS( gamePath: String, programId: String, + dumpPath: String?, callback: (max: Long, progress: Long) -> Boolean ): Boolean @@ -483,12 +485,14 @@ object NativeLibrary { * Dumps the ExeFS from a game to the dump directory * @param gamePath Path to the game file * @param programId String representation of the game's program ID + * @param dumpPath Optional custom dump path. If null or empty, uses default dump directory * @param callback Progress callback. Return true to cancel. Parameters: (max: Long, progress: Long) * @return true if successful, false otherwise */ external fun dumpExeFS( gamePath: String, programId: String, + dumpPath: String?, callback: (max: Long, progress: Long) -> Boolean ): Boolean } diff --git a/src/android/app/src/main/java/org/citron/citron_emu/fragments/GamePropertiesFragment.kt b/src/android/app/src/main/java/org/citron/citron_emu/fragments/GamePropertiesFragment.kt index 4645afe89..6356820f5 100644 --- a/src/android/app/src/main/java/org/citron/citron_emu/fragments/GamePropertiesFragment.kt +++ b/src/android/app/src/main/java/org/citron/citron_emu/fragments/GamePropertiesFragment.kt @@ -40,6 +40,7 @@ import org.citron.citron_emu.model.InstallableProperty import org.citron.citron_emu.model.SubmenuProperty import org.citron.citron_emu.model.TaskState import org.citron.citron_emu.utils.DirectoryInitialization +import org.citron.citron_emu.utils.DocumentsTree import org.citron.citron_emu.utils.FileUtil import org.citron.citron_emu.utils.GameIconUtils import org.citron.citron_emu.utils.GpuDriverHelper @@ -47,6 +48,7 @@ import org.citron.citron_emu.utils.MemoryUtil import org.citron.citron_emu.utils.ViewUtils.marquee import org.citron.citron_emu.utils.ViewUtils.updateMargins import org.citron.citron_emu.utils.collect +import androidx.documentfile.provider.DocumentFile import java.io.BufferedOutputStream import java.io.File @@ -283,25 +285,23 @@ class GamePropertiesFragment : Fragment() { R.string.dump_romfs_description, R.drawable.ic_save ) { - ProgressDialogFragment.newInstance( + // Show dialog to select dump location or use default + MessageDialogFragment.newInstance( requireActivity(), - R.string.dump_romfs_extracting, - false - ) { _, _ -> - val success = NativeLibrary.dumpRomFS( - args.game.path, - args.game.programIdHex, - { max, progress -> - // Progress callback - return true to cancel - false - } - ) - if (success) { - getString(R.string.dump_success) - } else { - getString(R.string.dump_failed) + titleId = R.string.dump_romfs, + descriptionId = R.string.select_dump_location_description, + positiveButtonTitleId = R.string.select_location, + negativeButtonTitleId = R.string.use_default_location, + positiveAction = { + // User wants to select a custom location + pendingDumpType = "romfs" + selectDumpDirectory.launch(null) + }, + negativeAction = { + // Use default location + performRomFSDump(null) } - }.show(parentFragmentManager, ProgressDialogFragment.TAG) + ).show(parentFragmentManager, MessageDialogFragment.TAG) } ) @@ -311,25 +311,23 @@ class GamePropertiesFragment : Fragment() { R.string.dump_exefs_description, R.drawable.ic_save ) { - ProgressDialogFragment.newInstance( + // Show dialog to select dump location or use default + MessageDialogFragment.newInstance( requireActivity(), - R.string.dump_exefs_extracting, - false - ) { _, _ -> - val success = NativeLibrary.dumpExeFS( - args.game.path, - args.game.programIdHex, - { max, progress -> - // Progress callback - return true to cancel - false - } - ) - if (success) { - getString(R.string.dump_success) - } else { - getString(R.string.dump_failed) + titleId = R.string.dump_exefs, + descriptionId = R.string.select_dump_location_description, + positiveButtonTitleId = R.string.select_location, + negativeButtonTitleId = R.string.use_default_location, + positiveAction = { + // User wants to select a custom location + pendingDumpType = "exefs" + selectDumpDirectory.launch(null) + }, + negativeAction = { + // Use default location + performExeFSDump(null) } - }.show(parentFragmentManager, ProgressDialogFragment.TAG) + ).show(parentFragmentManager, MessageDialogFragment.TAG) } ) } @@ -387,6 +385,22 @@ class GamePropertiesFragment : Fragment() { windowInsets } + private var pendingDumpType: String? = null // "romfs" or "exefs" + + private val selectDumpDirectory = + registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { result -> + if (result == null) { + return@registerForActivityResult + } + // Store the selected directory URI and perform the dump + val selectedUri = result.toString() + when (pendingDumpType) { + "romfs" -> performRomFSDump(selectedUri) + "exefs" -> performExeFSDump(selectedUri) + } + pendingDumpType = null + } + private val importSaves = registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> if (result == null) { @@ -480,4 +494,87 @@ class GamePropertiesFragment : Fragment() { } }.show(parentFragmentManager, ProgressDialogFragment.TAG) } + + private fun performRomFSDump(dumpPathUri: String?) { + ProgressDialogFragment.newInstance( + requireActivity(), + R.string.dump_romfs_extracting, + false + ) { _, _ -> + // Convert URI to file path if needed + val dumpPathString = dumpPathUri?.let { uriString -> + try { + val uri = android.net.Uri.parse(uriString) + // For document tree URIs, try to get the actual file path + if (DocumentsTree.isNativePath(uriString)) { + uriString + } else { + // Try to extract file path from document URI + // For document tree URIs, we can't easily get a native path + // So we'll pass the URI and let the native code handle it + // or extract path using DocumentFile + val docFile = DocumentFile.fromTreeUri(requireContext(), uri) + docFile?.uri?.path ?: uriString + } + } catch (e: Exception) { + null + } + } + + val success = NativeLibrary.dumpRomFS( + args.game.path, + args.game.programIdHex, + dumpPathString, + { max, progress -> + // Progress callback - return true to cancel + false + } + ) + if (success) { + getString(R.string.dump_success) + } else { + getString(R.string.dump_failed) + } + }.show(parentFragmentManager, ProgressDialogFragment.TAG) + } + + private fun performExeFSDump(dumpPathUri: String?) { + ProgressDialogFragment.newInstance( + requireActivity(), + R.string.dump_exefs_extracting, + false + ) { _, _ -> + // Convert URI to file path if needed + val dumpPathString = dumpPathUri?.let { uriString -> + try { + val uri = android.net.Uri.parse(uriString) + // For document tree URIs, try to get the actual file path + if (DocumentsTree.isNativePath(uriString)) { + uriString + } else { + // Try to extract file path from document URI + val docFile = DocumentFile.fromTreeUri(requireContext(), uri) + docFile?.uri?.path ?: uriString + } + } catch (e: Exception) { + null + } + } + + val success = NativeLibrary.dumpExeFS( + args.game.path, + args.game.programIdHex, + dumpPathString, + { max, progress -> + // Progress callback - return true to cancel + false + } + ) + if (success) { + getString(R.string.dump_success) + } else { + getString(R.string.dump_failed) + } + }.show(parentFragmentManager, ProgressDialogFragment.TAG) + } } diff --git a/src/android/app/src/main/jni/native.cpp b/src/android/app/src/main/jni/native.cpp index c56021497..18a0241b2 100644 --- a/src/android/app/src/main/jni/native.cpp +++ b/src/android/app/src/main/jni/native.cpp @@ -883,7 +883,7 @@ jboolean Java_org_citron_citron_1emu_NativeLibrary_areKeysPresent(JNIEnv* env, j jboolean Java_org_citron_citron_1emu_NativeLibrary_dumpRomFS(JNIEnv* env, jobject jobj, jstring jgamePath, jstring jprogramId, - jobject jcallback) { + jstring jdumpPath, jobject jcallback) { const auto game_path = Common::Android::GetJString(env, jgamePath); const auto program_id = EmulationSession::GetProgramId(env, jprogramId); @@ -935,7 +935,25 @@ jboolean Java_org_citron_citron_1emu_NativeLibrary_dumpRomFS(JNIEnv* env, jobjec return false; } - const auto dump_dir = Common::FS::GetCitronPath(Common::FS::CitronPath::DumpDir); + // Use custom dump path if provided, otherwise use default + std::filesystem::path dump_dir; + if (jdumpPath != nullptr) { + const auto custom_path = Common::Android::GetJString(env, jdumpPath); + if (!custom_path.empty()) { + // Check if it's a native path (starts with /) or try to use it as-is + if (custom_path[0] == '/') { + dump_dir = std::filesystem::path(custom_path); + } else { + // Try to parse as URI and extract path + // For document tree URIs, we can't easily get a native path + // So fall back to default + dump_dir = Common::FS::GetCitronPath(Common::FS::CitronPath::DumpDir); + } + } + } + if (dump_dir.empty()) { + dump_dir = Common::FS::GetCitronPath(Common::FS::CitronPath::DumpDir); + } const auto romfs_dir = fmt::format("{:016X}/romfs", title_id); const auto path = dump_dir / romfs_dir; @@ -1014,7 +1032,7 @@ jboolean Java_org_citron_citron_1emu_NativeLibrary_dumpRomFS(JNIEnv* env, jobjec jboolean Java_org_citron_citron_1emu_NativeLibrary_dumpExeFS(JNIEnv* env, jobject jobj, jstring jgamePath, jstring jprogramId, - jobject jcallback) { + jstring jdumpPath, jobject jcallback) { const auto game_path = Common::Android::GetJString(env, jgamePath); const auto program_id = EmulationSession::GetProgramId(env, jprogramId); @@ -1069,10 +1087,25 @@ jboolean Java_org_citron_citron_1emu_NativeLibrary_dumpExeFS(JNIEnv* env, jobjec return false; } - // Get dump directory - const auto dump_dir = system.GetFileSystemController().GetModificationDumpRoot(title_id); + // Use custom dump path if provided, otherwise use default + FileSys::VirtualDir dump_dir; + if (jdumpPath != nullptr) { + const auto custom_path = Common::Android::GetJString(env, jdumpPath); + if (!custom_path.empty() && custom_path[0] == '/') { + // Create a real VFS directory from the custom path (native path only) + const auto custom_dir = std::make_shared( + std::filesystem::path(custom_path)); + if (custom_dir && custom_dir->IsWritable()) { + dump_dir = custom_dir; + } + } + } if (!dump_dir) { - return false; + // Fall back to default dump directory + dump_dir = system.GetFileSystemController().GetModificationDumpRoot(title_id); + if (!dump_dir) { + return false; + } } const auto exefs_dir = FileSys::GetOrCreateDirectoryRelative(dump_dir, "/exefs"); diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index 74e307a9b..461436a3d 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -153,6 +153,9 @@ Extracting ExeFS… Extraction completed successfully Extraction failed + Choose where to save the extracted files. Select a location or use the default dump directory. + Select Location + Use Default Applet launcher From 5bff8ff11d040341fc39591f57b36f08709fca84 Mon Sep 17 00:00:00 2001 From: Zephyron Date: Sat, 29 Nov 2025 12:20:38 +1000 Subject: [PATCH 3/3] fix(android): fix compilation errors in dump functions Use VFS API for directory creation and Java Long.valueOf() for progress callbacks. Fix variable shadowing and add JNI reference cleanup. Signed-off-by: Zephyron --- src/android/app/src/main/jni/native.cpp | 28 +++++++++++++++---------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/src/android/app/src/main/jni/native.cpp b/src/android/app/src/main/jni/native.cpp index 18a0241b2..04f5548b8 100644 --- a/src/android/app/src/main/jni/native.cpp +++ b/src/android/app/src/main/jni/native.cpp @@ -969,8 +969,9 @@ jboolean Java_org_citron_citron_1emu_NativeLibrary_dumpRomFS(JNIEnv* env, jobjec return false; } - // Create output directory - const auto out_dir = std::make_shared(path); + // Create output directory using VFS + const auto path_str = Common::FS::PathToUTF8String(path); + const auto out_dir = vfs.CreateDirectory(path_str, FileSys::OpenMode::ReadWrite); if (!out_dir || !out_dir->IsWritable()) { return false; } @@ -983,12 +984,18 @@ jboolean Java_org_citron_citron_1emu_NativeLibrary_dumpRomFS(JNIEnv* env, jobjec auto jlambdaInvokeMethod = env->GetMethodID( jlambdaClass, "invoke", "(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;"); - const auto callback = [env, jcallback, jlambdaInvokeMethod, &read_size, total_size]( + // Helper to create Java Long objects + auto jLongClass = env->FindClass("java/lang/Long"); + auto jLongValueOf = env->GetStaticMethodID(jLongClass, "valueOf", "(J)Ljava/lang/Long;"); + + const auto callback = [env, jcallback, jlambdaInvokeMethod, jLongClass, jLongValueOf, &read_size, total_size]( size_t current_read) -> bool { read_size += current_read; - auto jwasCancelled = env->CallObjectMethod( - jcallback, jlambdaInvokeMethod, Common::Android::ToJLong(env, total_size), - Common::Android::ToJLong(env, read_size)); + auto jmax = env->CallStaticObjectMethod(jLongClass, jLongValueOf, static_cast(total_size)); + auto jprogress = env->CallStaticObjectMethod(jLongClass, jLongValueOf, static_cast(read_size)); + auto jwasCancelled = env->CallObjectMethod(jcallback, jlambdaInvokeMethod, jmax, jprogress); + env->DeleteLocalRef(jmax); + env->DeleteLocalRef(jprogress); return Common::Android::GetJBoolean(env, jwasCancelled); }; @@ -1092,11 +1099,10 @@ jboolean Java_org_citron_citron_1emu_NativeLibrary_dumpExeFS(JNIEnv* env, jobjec if (jdumpPath != nullptr) { const auto custom_path = Common::Android::GetJString(env, jdumpPath); if (!custom_path.empty() && custom_path[0] == '/') { - // Create a real VFS directory from the custom path (native path only) - const auto custom_dir = std::make_shared( - std::filesystem::path(custom_path)); - if (custom_dir && custom_dir->IsWritable()) { - dump_dir = custom_dir; + // Create directory using VFS (native path only) + dump_dir = vfs.CreateDirectory(custom_path, FileSys::OpenMode::ReadWrite); + if (!dump_dir || !dump_dir->IsWritable()) { + dump_dir = nullptr; } } }