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 <zephyron@citron-emu.org>
This commit is contained in:
Zephyron
2025-11-29 09:16:33 +10:00
parent 32c4a9060d
commit 261bab49a7
4 changed files with 300 additions and 0 deletions

View File

@@ -1,4 +1,5 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project // SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-FileCopyrightText: 2025 citron Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
package org.citron.citron_emu package org.citron.citron_emu
@@ -464,4 +465,30 @@ object NativeLibrary {
* Checks if all necessary keys are present for decryption * Checks if all necessary keys are present for decryption
*/ */
external fun areKeysPresent(): Boolean 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
} }

View File

@@ -1,4 +1,5 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project // SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-FileCopyrightText: 2025 citron Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
package org.citron.citron_emu.fragments package org.citron.citron_emu.fragments
@@ -25,6 +26,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.citron.citron_emu.HomeNavigationDirections import org.citron.citron_emu.HomeNavigationDirections
import org.citron.citron_emu.NativeLibrary
import org.citron.citron_emu.R import org.citron.citron_emu.R
import org.citron.citron_emu.CitronApplication import org.citron.citron_emu.CitronApplication
import org.citron.citron_emu.adapters.GamePropertiesAdapter 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 { binding.listProperties.apply {

View File

@@ -1,4 +1,5 @@
// SPDX-FileCopyrightText: Copyright 2023 yuzu Emulator Project // SPDX-FileCopyrightText: Copyright 2023 yuzu Emulator Project
// SPDX-FileCopyrightText: Copyright 2025 Citron Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
#include <codecvt> #include <codecvt>
@@ -6,6 +7,7 @@
#include <string> #include <string>
#include <string_view> #include <string_view>
#include <dlfcn.h> #include <dlfcn.h>
#include <functional>
#ifdef ARCHITECTURE_arm64 #ifdef ARCHITECTURE_arm64
#include <adrenotools/driver.h> #include <adrenotools/driver.h>
@@ -41,6 +43,7 @@
#include "core/file_sys/submission_package.h" #include "core/file_sys/submission_package.h"
#include "core/file_sys/vfs/vfs.h" #include "core/file_sys/vfs/vfs.h"
#include "core/file_sys/vfs/vfs_real.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/cabinet.h"
#include "core/frontend/applets/controller.h" #include "core/frontend/applets/controller.h"
#include "core/frontend/applets/error.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(); 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<FileSys::RealVfsDirectory>(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<bool(const FileSys::VirtualDir&, const FileSys::VirtualDir&)> 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" } // extern "C"

View File

@@ -145,6 +145,14 @@
<string name="keys_missing">Encryption keys are missing</string> <string name="keys_missing">Encryption keys are missing</string>
<string name="keys_missing_description">Firmware and retail games cannot be decrypted</string> <string name="keys_missing_description">Firmware and retail games cannot be decrypted</string>
<string name="keys_missing_help">https://discord.gg/citron</string> <string name="keys_missing_help">https://discord.gg/citron</string>
<string name="dump_romfs">Dump RomFS</string>
<string name="dump_romfs_description">Extract the RomFS filesystem from the game</string>
<string name="dump_exefs">Dump ExeFS</string>
<string name="dump_exefs_description">Extract the ExeFS filesystem from the game</string>
<string name="dump_romfs_extracting">Extracting RomFS…</string>
<string name="dump_exefs_extracting">Extracting ExeFS…</string>
<string name="dump_success">Extraction completed successfully</string>
<string name="dump_failed">Extraction failed</string>
<!-- Applet launcher strings --> <!-- Applet launcher strings -->
<string name="applets">Applet launcher</string> <string name="applets">Applet launcher</string>