From 261bab49a727b859120294d32a3d8ddae0759641 Mon Sep 17 00:00:00 2001 From: Zephyron Date: Sat, 29 Nov 2025 09:16:33 +1000 Subject: [PATCH] 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