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..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 @@ -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,34 @@ 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 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 + + /** + * 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 203cbd70a..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 @@ -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 @@ -38,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 @@ -45,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 @@ -273,6 +277,59 @@ class GamePropertiesFragment : Fragment() { } ) } + + // Add RomFS and ExeFS dump options + add( + SubmenuProperty( + R.string.dump_romfs, + R.string.dump_romfs_description, + R.drawable.ic_save + ) { + // Show dialog to select dump location or use default + MessageDialogFragment.newInstance( + requireActivity(), + 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, MessageDialogFragment.TAG) + } + ) + + add( + SubmenuProperty( + R.string.dump_exefs, + R.string.dump_exefs_description, + R.drawable.ic_save + ) { + // Show dialog to select dump location or use default + MessageDialogFragment.newInstance( + requireActivity(), + 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, MessageDialogFragment.TAG) + } + ) } } binding.listProperties.apply { @@ -328,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) { @@ -421,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 620fc33d3..04f5548b8 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,246 @@ 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, + jstring jdumpPath, 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; + } + + // 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; + + 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 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; + } + + // 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;"); + + // 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 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); + }; + + // 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, + jstring jdumpPath, 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; + } + + // 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 directory using VFS (native path only) + dump_dir = vfs.CreateDirectory(custom_path, FileSys::OpenMode::ReadWrite); + if (!dump_dir || !dump_dir->IsWritable()) { + dump_dir = nullptr; + } + } + } + if (!dump_dir) { + // 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"); + 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..461436a3d 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -145,6 +145,17 @@ 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 + Choose where to save the extracted files. Select a location or use the default dump directory. + Select Location + Use Default Applet launcher