mirror of
https://git.citron-emu.org/citron/emulator
synced 2025-12-19 10:43:33 +00:00
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:
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user