mirror of
https://git.citron-emu.org/citron/emulator
synced 2025-12-19 10:43:33 +00:00
Merge pull request 'feature/android-game-file-extraction' (#61) from feature/android-game-file-extraction into main
Reviewed-on: https://git.citron-emu.org/Citron/Emulator/pulls/61
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <codecvt>
|
||||
@@ -6,6 +7,7 @@
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <dlfcn.h>
|
||||
#include <functional>
|
||||
|
||||
#ifdef ARCHITECTURE_arm64
|
||||
#include <adrenotools/driver.h>
|
||||
@@ -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<jlong>(total_size));
|
||||
auto jprogress = env->CallStaticObjectMethod(jLongClass, jLongValueOf, static_cast<jlong>(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<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,
|
||||
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"
|
||||
|
||||
@@ -145,6 +145,17 @@
|
||||
<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_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>
|
||||
<string name="select_dump_location_description">Choose where to save the extracted files. Select a location or use the default dump directory.</string>
|
||||
<string name="select_location">Select Location</string>
|
||||
<string name="use_default_location">Use Default</string>
|
||||
|
||||
<!-- Applet launcher strings -->
|
||||
<string name="applets">Applet launcher</string>
|
||||
|
||||
Reference in New Issue
Block a user