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:
Zephyron
2025-12-03 01:39:40 +00:00
4 changed files with 443 additions and 0 deletions

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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"

View File

@@ -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>