aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/common/CMakeLists.txt2
-rw-r--r--src/common/fs/fs_paths.h1
-rw-r--r--src/common/fs/path_util.cpp33
-rw-r--r--src/common/fs/path_util.h18
-rw-r--r--src/common/fs/ryujinx_compat.cpp93
-rw-r--r--src/common/fs/ryujinx_compat.h40
-rw-r--r--src/common/fs/symlink.cpp43
-rw-r--r--src/common/fs/symlink.h12
-rw-r--r--src/frontend_common/data_manager.cpp12
-rw-r--r--src/frontend_common/data_manager.h4
-rw-r--r--src/qt_common/CMakeLists.txt3
-rw-r--r--src/qt_common/abstract/frontend.cpp (renamed from src/qt_common/abstract/qt_frontend_util.cpp)2
-rw-r--r--src/qt_common/abstract/frontend.h (renamed from src/qt_common/abstract/qt_frontend_util.h)6
-rw-r--r--src/qt_common/qt_common.cpp10
-rw-r--r--src/qt_common/qt_string_lookup.h23
-rw-r--r--src/qt_common/util/content.cpp6
-rw-r--r--src/qt_common/util/content.h6
-rw-r--r--src/qt_common/util/fs.cpp130
-rw-r--r--src/qt_common/util/fs.h22
-rw-r--r--src/qt_common/util/game.cpp2
-rw-r--r--src/qt_common/util/path.cpp2
-rw-r--r--src/yuzu/CMakeLists.txt1
-rw-r--r--src/yuzu/data_dialog.cpp2
-rw-r--r--src/yuzu/game_list.cpp5
-rw-r--r--src/yuzu/game_list.h1
-rw-r--r--src/yuzu/main.cpp61
-rw-r--r--src/yuzu/main.h3
-rw-r--r--src/yuzu/migration_worker.cpp9
-rw-r--r--src/yuzu/migration_worker.h6
-rw-r--r--src/yuzu/ryujinx_dialog.cpp40
-rw-r--r--src/yuzu/ryujinx_dialog.h32
-rw-r--r--src/yuzu/ryujinx_dialog.ui81
32 files changed, 664 insertions, 47 deletions
diff --git a/src/common/CMakeLists.txt b/src/common/CMakeLists.txt
index b6b2931fd9..2c2eeb189c 100644
--- a/src/common/CMakeLists.txt
+++ b/src/common/CMakeLists.txt
@@ -153,6 +153,8 @@ add_library(
wall_clock.h
zstd_compression.cpp
zstd_compression.h
+ fs/ryujinx_compat.h fs/ryujinx_compat.cpp
+ fs/symlink.h fs/symlink.cpp
)
if(WIN32)
diff --git a/src/common/fs/fs_paths.h b/src/common/fs/fs_paths.h
index 5cdf9be39d..640a83c44b 100644
--- a/src/common/fs/fs_paths.h
+++ b/src/common/fs/fs_paths.h
@@ -33,6 +33,7 @@
#define SUDACHI_DIR "sudachi"
#define YUZU_DIR "yuzu"
#define SUYU_DIR "suyu"
+#define RYUJINX_DIR "Ryujinx"
// yuzu-specific files
#define LOG_FILE "eden_log.txt"
diff --git a/src/common/fs/path_util.cpp b/src/common/fs/path_util.cpp
index a095e0c239..dc1071b9ca 100644
--- a/src/common/fs/path_util.cpp
+++ b/src/common/fs/path_util.cpp
@@ -84,7 +84,7 @@ public:
return eden_paths.at(eden_path);
}
- [[nodiscard]] const fs::path& GetLegacyPathImpl(LegacyPath legacy_path) {
+ [[nodiscard]] const fs::path& GetLegacyPathImpl(EmuPath legacy_path) {
return legacy_paths.at(legacy_path);
}
@@ -98,7 +98,7 @@ public:
eden_paths.insert_or_assign(eden_path, new_path);
}
- void SetLegacyPathImpl(LegacyPath legacy_path, const fs::path& new_path) {
+ void SetLegacyPathImpl(EmuPath legacy_path, const fs::path& new_path) {
legacy_paths.insert_or_assign(legacy_path, new_path);
}
@@ -118,9 +118,9 @@ public:
}
eden_path_cache = eden_path / CACHE_DIR;
eden_path_config = eden_path / CONFIG_DIR;
-#define LEGACY_PATH(titleName, upperName) GenerateLegacyPath(LegacyPath::titleName##Dir, GetAppDataRoamingDirectory() / upperName##_DIR); \
- GenerateLegacyPath(LegacyPath::titleName##ConfigDir, GetAppDataRoamingDirectory() / upperName##_DIR / CONFIG_DIR); \
- GenerateLegacyPath(LegacyPath::titleName##CacheDir, GetAppDataRoamingDirectory() / upperName##_DIR / CACHE_DIR);
+#define LEGACY_PATH(titleName, upperName) GenerateLegacyPath(EmuPath::titleName##Dir, GetAppDataRoamingDirectory() / upperName##_DIR); \
+ GenerateLegacyPath(EmuPath::titleName##ConfigDir, GetAppDataRoamingDirectory() / upperName##_DIR / CONFIG_DIR); \
+ GenerateLegacyPath(EmuPath::titleName##CacheDir, GetAppDataRoamingDirectory() / upperName##_DIR / CACHE_DIR);
LEGACY_PATH(Citron, CITRON)
LEGACY_PATH(Sudachi, SUDACHI)
LEGACY_PATH(Yuzu, YUZU)
@@ -140,9 +140,9 @@ public:
eden_path_cache = eden_path / CACHE_DIR;
eden_path_config = eden_path / CONFIG_DIR;
}
-#define LEGACY_PATH(titleName, upperName) GenerateLegacyPath(LegacyPath::titleName##Dir, GetDataDirectory("XDG_DATA_HOME") / upperName##_DIR); \
- GenerateLegacyPath(LegacyPath::titleName##ConfigDir, GetDataDirectory("XDG_CONFIG_HOME") / upperName##_DIR); \
- GenerateLegacyPath(LegacyPath::titleName##CacheDir, GetDataDirectory("XDG_CACHE_HOME") / upperName##_DIR);
+#define LEGACY_PATH(titleName, upperName) GenerateLegacyPath(EmuPath::titleName##Dir, GetDataDirectory("XDG_DATA_HOME") / upperName##_DIR); \
+ GenerateLegacyPath(EmuPath::titleName##ConfigDir, GetDataDirectory("XDG_CONFIG_HOME") / upperName##_DIR); \
+ GenerateLegacyPath(EmuPath::titleName##CacheDir, GetDataDirectory("XDG_CACHE_HOME") / upperName##_DIR);
LEGACY_PATH(Citron, CITRON)
LEGACY_PATH(Sudachi, SUDACHI)
LEGACY_PATH(Yuzu, YUZU)
@@ -165,6 +165,15 @@ public:
GenerateEdenPath(EdenPath::ShaderDir, eden_path / SHADER_DIR);
GenerateEdenPath(EdenPath::TASDir, eden_path / TAS_DIR);
GenerateEdenPath(EdenPath::IconsDir, eden_path / ICONS_DIR);
+
+#ifdef _WIN32
+ GenerateLegacyPath(EmuPath::RyujinxDir, GetAppDataRoamingDirectory() / RYUJINX_DIR);
+#else
+ // In Ryujinx's infinite wisdom, it places EVERYTHING in the config directory on UNIX
+ // This is incredibly stupid and violates a million XDG standards, but whatever
+ GenerateLegacyPath(EmuPath::RyujinxDir, GetDataDirectory("XDG_CONFIG_HOME") / RYUJINX_DIR);
+#endif
+
}
private:
@@ -179,12 +188,12 @@ private:
SetEdenPathImpl(eden_path, new_path);
}
- void GenerateLegacyPath(LegacyPath legacy_path, const fs::path& new_path) {
+ void GenerateLegacyPath(EmuPath legacy_path, const fs::path& new_path) {
SetLegacyPathImpl(legacy_path, new_path);
}
std::unordered_map<EdenPath, fs::path> eden_paths;
- std::unordered_map<LegacyPath, fs::path> legacy_paths;
+ std::unordered_map<EmuPath, fs::path> legacy_paths;
};
bool ValidatePath(const fs::path& path) {
@@ -272,7 +281,7 @@ const fs::path& GetEdenPath(EdenPath eden_path) {
return PathManagerImpl::GetInstance().GetEdenPathImpl(eden_path);
}
-const std::filesystem::path& GetLegacyPath(LegacyPath legacy_path) {
+const std::filesystem::path& GetLegacyPath(EmuPath legacy_path) {
return PathManagerImpl::GetInstance().GetLegacyPathImpl(legacy_path);
}
@@ -280,7 +289,7 @@ std::string GetEdenPathString(EdenPath eden_path) {
return PathToUTF8String(GetEdenPath(eden_path));
}
-std::string GetLegacyPathString(LegacyPath legacy_path) {
+std::string GetLegacyPathString(EmuPath legacy_path) {
return PathToUTF8String(GetLegacyPath(legacy_path));
}
diff --git a/src/common/fs/path_util.h b/src/common/fs/path_util.h
index b34efc472f..dc800b2892 100644
--- a/src/common/fs/path_util.h
+++ b/src/common/fs/path_util.h
@@ -32,22 +32,26 @@ enum class EdenPath {
IconsDir, // Where Icons for Windows shortcuts are stored.
};
-enum LegacyPath {
- CitronDir, // Citron Directories for migration
+// migration/compat dirs
+enum EmuPath {
+ CitronDir,
CitronConfigDir,
CitronCacheDir,
- SudachiDir, // Sudachi Directories for migration
+ SudachiDir,
SudachiConfigDir,
SudachiCacheDir,
- YuzuDir, // Yuzu Directories for migration
+ YuzuDir,
YuzuConfigDir,
YuzuCacheDir,
- SuyuDir, // Suyu Directories for migration
+ SuyuDir,
SuyuConfigDir,
SuyuCacheDir,
+
+ // used exclusively for save data linking
+ RyujinxDir,
};
/**
@@ -229,7 +233,7 @@ void SetAppDirectory(const std::string& app_directory);
*
* @returns The filesystem path associated with the LegacyPath enum.
*/
-[[nodiscard]] const std::filesystem::path& GetLegacyPath(LegacyPath legacy_path);
+[[nodiscard]] const std::filesystem::path& GetLegacyPath(EmuPath legacy_path);
/**
* Gets the filesystem path associated with the EdenPath enum as a UTF-8 encoded std::string.
@@ -247,7 +251,7 @@ void SetAppDirectory(const std::string& app_directory);
*
* @returns The filesystem path associated with the LegacyPath enum as a UTF-8 encoded std::string.
*/
-[[nodiscard]] std::string GetLegacyPathString(LegacyPath legacy_path);
+[[nodiscard]] std::string GetLegacyPathString(EmuPath legacy_path);
/**
* Sets a new filesystem path associated with the EdenPath enum.
diff --git a/src/common/fs/ryujinx_compat.cpp b/src/common/fs/ryujinx_compat.cpp
new file mode 100644
index 0000000000..610ba736a7
--- /dev/null
+++ b/src/common/fs/ryujinx_compat.cpp
@@ -0,0 +1,93 @@
+// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "ryujinx_compat.h"
+#include "common/fs/path_util.h"
+#include <cstddef>
+#include <cstring>
+#include <fmt/ranges.h>
+#include <fstream>
+
+namespace Common::FS {
+
+namespace fs = std::filesystem;
+
+fs::path GetKvdbPath()
+{
+ return GetLegacyPath(EmuPath::RyujinxDir) / "bis" / "system" / "save" / "8000000000000000" / "0"
+ / "imkvdb.arc";
+}
+
+fs::path GetRyuSavePath(const u64 &save_id)
+{
+ std::string hex = fmt::format("{:016x}", save_id);
+
+ // TODO: what's the difference between 0 and 1?
+ return GetLegacyPath(EmuPath::RyujinxDir) / "bis" / "user" / "save" / hex / "0";
+}
+
+IMENReadResult ReadKvdb(const fs::path &path, std::vector<IMEN> &imens)
+{
+ std::ifstream kvdb{path, std::ios::binary | std::ios::ate};
+
+ if (!kvdb) {
+ return IMENReadResult::Nonexistent;
+ }
+
+ size_t file_size = kvdb.tellg();
+
+ // IMKV header + 8 bytes
+ if (file_size < 0xB) {
+ return IMENReadResult::NoHeader;
+ }
+
+ // magic (not the wizard kind)
+ kvdb.seekg(0, std::ios::beg);
+ char header[12];
+ kvdb.read(header, 12);
+
+ if (std::memcmp(header, IMKV_MAGIC, 4) != 0) {
+ return IMENReadResult::InvalidMagic;
+ }
+
+ // calculate num. of imens left
+ std::size_t remaining = (file_size - 12);
+ std::size_t num_imens = remaining / IMEN_SIZE;
+
+ // File is misaligned and probably corrupt (rip)
+ if (remaining % IMEN_SIZE != 0) {
+ return IMENReadResult::Misaligned;
+ }
+
+ // if there aren't any IMENs, it's empty and we can safely no-op out of here
+ if (num_imens == 0) {
+ return IMENReadResult::NoImens;
+ }
+
+ imens.reserve(num_imens);
+
+ // initially I wanted to do a struct, but imkvdb is 140 bytes
+ // while the compiler will murder you if you try to align u64 to 4 bytes
+ for (std::size_t i = 0; i < num_imens; ++i) {
+ char magic[4];
+ u64 title_id = 0;
+ u64 save_id = 0;
+
+ kvdb.read(magic, 4);
+ if (std::memcmp(magic, IMEN_MAGIC, 4) != 0) {
+ return IMENReadResult::InvalidMagic;
+ }
+
+ kvdb.ignore(0x8);
+ kvdb.read(reinterpret_cast<char *>(&title_id), 8);
+ kvdb.ignore(0x38);
+ kvdb.read(reinterpret_cast<char *>(&save_id), 8);
+ kvdb.ignore(0x38);
+
+ imens.emplace_back(IMEN{title_id, save_id});
+ }
+
+ return IMENReadResult::Success;
+}
+
+} // namespace Common::FS
diff --git a/src/common/fs/ryujinx_compat.h b/src/common/fs/ryujinx_compat.h
new file mode 100644
index 0000000000..6273754074
--- /dev/null
+++ b/src/common/fs/ryujinx_compat.h
@@ -0,0 +1,40 @@
+// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#pragma once
+
+#include "common/common_types.h"
+#include <filesystem>
+#include <vector>
+
+namespace fs = std::filesystem;
+
+namespace Common::FS {
+
+constexpr const char IMEN_MAGIC[4] = {0x49, 0x4d, 0x45, 0x4e};
+constexpr const char IMKV_MAGIC[4] = {0x49, 0x4d, 0x4b, 0x56};
+constexpr const u8 IMEN_SIZE = 0x8c;
+
+fs::path GetKvdbPath();
+fs::path GetRyuSavePath(const u64 &program_id);
+
+enum class IMENReadResult {
+ Nonexistent, // ryujinx not found
+ NoHeader, // file isn't big enough for header
+ InvalidMagic, // no IMKV or IMEN header
+ Misaligned, // file isn't aligned to expected IMEN boundaries
+ NoImens, // no-op, there are no IMENs
+ Success, // :)
+};
+
+struct IMEN
+{
+ u64 title_id;
+ u64 save_id;
+};
+
+static_assert(sizeof(IMEN) == 0x10, "IMEN has incorrect size.");
+
+IMENReadResult ReadKvdb(const fs::path &path, std::vector<IMEN> &imens);
+
+} // namespace Common::FS
diff --git a/src/common/fs/symlink.cpp b/src/common/fs/symlink.cpp
new file mode 100644
index 0000000000..9c9fe3d50b
--- /dev/null
+++ b/src/common/fs/symlink.cpp
@@ -0,0 +1,43 @@
+// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "symlink.h"
+
+#ifdef _WIN32
+#include <windows.h>
+#include <fmt/format.h>
+#endif
+
+namespace fs = std::filesystem;
+
+// The sole purpose of this file is to treat symlinks like symlinks on POSIX,
+// or treat them as directory junctions on Windows.
+// This is because, for some inexplicable reason, Microsoft has locked symbolic
+// links behind a "security policy", whereas directory junctions--functionally identical
+// for directories, by the way--are not. Why? I don't know.
+
+namespace Common::FS {
+
+bool CreateSymlink(const fs::path &from, const fs::path &to)
+{
+#ifdef _WIN32
+ const std::string command = fmt::format("mklink /J {} {}", to.string(), from.string());
+ return system(command.c_str()) == 0;
+#else
+ std::error_code ec;
+ fs::create_directory_symlink(from, to, ec);
+ return !ec;
+#endif
+}
+
+bool IsSymlink(const fs::path &path)
+{
+#ifdef _WIN32
+ auto attributes = GetFileAttributesW(path.wstring().c_str());
+ return attributes & FILE_ATTRIBUTE_REPARSE_POINT;
+#else
+ return fs::is_symlink(path);
+#endif
+}
+
+} // namespace Common::FS
diff --git a/src/common/fs/symlink.h b/src/common/fs/symlink.h
new file mode 100644
index 0000000000..3bfff95ebf
--- /dev/null
+++ b/src/common/fs/symlink.h
@@ -0,0 +1,12 @@
+// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#pragma once
+
+#include <filesystem>
+namespace Common::FS {
+
+bool CreateSymlink(const std::filesystem::path &from, const std::filesystem::path &to);
+bool IsSymlink(const std::filesystem::path &path);
+
+} // namespace Common::FS
diff --git a/src/frontend_common/data_manager.cpp b/src/frontend_common/data_manager.cpp
index 83eccbde17..1df828d028 100644
--- a/src/frontend_common/data_manager.cpp
+++ b/src/frontend_common/data_manager.cpp
@@ -4,14 +4,13 @@
#include "data_manager.h"
#include "common/assert.h"
#include "common/fs/path_util.h"
-#include <filesystem>
#include <fmt/format.h>
namespace FrontendCommon::DataManager {
namespace fs = std::filesystem;
-const std::string GetDataDir(DataDir dir, const std::string &user_id)
+const fs::path GetDataDir(DataDir dir, const std::string &user_id)
{
const fs::path nand_dir = Common::FS::GetEdenPath(Common::FS::EdenPath::NANDDir);
@@ -35,6 +34,11 @@ const std::string GetDataDir(DataDir dir, const std::string &user_id)
return "";
}
+const std::string GetDataDirString(DataDir dir, const std::string &user_id)
+{
+ return GetDataDir(dir, user_id).string();
+}
+
u64 ClearDir(DataDir dir, const std::string &user_id)
{
fs::path data_dir = GetDataDir(dir, user_id);
@@ -65,7 +69,7 @@ u64 DataDirSize(DataDir dir)
if (!fs::exists(data_dir))
return 0;
- for (const auto& entry : fs::recursive_directory_iterator(data_dir)) {
+ for (const auto &entry : fs::recursive_directory_iterator(data_dir)) {
if (!entry.is_directory()) {
size += entry.file_size();
}
@@ -74,4 +78,4 @@ u64 DataDirSize(DataDir dir)
return size;
}
-}
+} // namespace FrontendCommon::DataManager
diff --git a/src/frontend_common/data_manager.h b/src/frontend_common/data_manager.h
index d67bd20467..c075ab54b6 100644
--- a/src/frontend_common/data_manager.h
+++ b/src/frontend_common/data_manager.h
@@ -6,12 +6,14 @@
#include "common/common_types.h"
#include <string>
+#include <filesystem>
namespace FrontendCommon::DataManager {
enum class DataDir { Saves, UserNand, SysNand, Mods, Shaders };
-const std::string GetDataDir(DataDir dir, const std::string &user_id = "");
+const std::filesystem::path GetDataDir(DataDir dir, const std::string &user_id = "");
+const std::string GetDataDirString(DataDir dir, const std::string &user_id = "");
u64 ClearDir(DataDir dir, const std::string &user_id = "");
diff --git a/src/qt_common/CMakeLists.txt b/src/qt_common/CMakeLists.txt
index 39dd1a7460..75ef717583 100644
--- a/src/qt_common/CMakeLists.txt
+++ b/src/qt_common/CMakeLists.txt
@@ -20,13 +20,14 @@ add_library(qt_common STATIC
util/applet.h util/applet.cpp
util/compress.h util/compress.cpp
- abstract/qt_frontend_util.h abstract/qt_frontend_util.cpp
+ abstract/frontend.h abstract/frontend.cpp
abstract/qt_progress_dialog.h abstract/qt_progress_dialog.cpp
qt_string_lookup.h
qt_compat.h
discord/discord.h
+ util/fs.h util/fs.cpp
)
create_target_directory_groups(qt_common)
diff --git a/src/qt_common/abstract/qt_frontend_util.cpp b/src/qt_common/abstract/frontend.cpp
index 3fe0ba0a80..2ed76f8ea9 100644
--- a/src/qt_common/abstract/qt_frontend_util.cpp
+++ b/src/qt_common/abstract/frontend.cpp
@@ -1,7 +1,7 @@
// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
-#include "qt_frontend_util.h"
+#include "frontend.h"
#include "qt_common/qt_common.h"
#ifdef YUZU_QT_WIDGETS
diff --git a/src/qt_common/abstract/qt_frontend_util.h b/src/qt_common/abstract/frontend.h
index 59e8f15a4e..b496b37cbe 100644
--- a/src/qt_common/abstract/qt_frontend_util.h
+++ b/src/qt_common/abstract/frontend.h
@@ -1,8 +1,8 @@
// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
-#ifndef QT_FRONTEND_UTIL_H
-#define QT_FRONTEND_UTIL_H
+#ifndef FRONTEND_H
+#define FRONTEND_H
#include <QGuiApplication>
#include "qt_common/qt_common.h"
@@ -136,4 +136,4 @@ const QString GetSaveFileName(const QString &title,
Options options = Options());
} // namespace QtCommon::Frontend
-#endif // QT_FRONTEND_UTIL_H
+#endif // FRONTEND_H
diff --git a/src/qt_common/qt_common.cpp b/src/qt_common/qt_common.cpp
index 1fa3df98cf..f2091df866 100644
--- a/src/qt_common/qt_common.cpp
+++ b/src/qt_common/qt_common.cpp
@@ -3,11 +3,14 @@
#include "qt_common.h"
#include "common/fs/fs.h"
+#include "common/fs/ryujinx_compat.h"
#include <QGuiApplication>
#include <QStringLiteral>
#include "common/logging/log.h"
#include "core/frontend/emu_window.h"
+#include "qt_common/abstract/frontend.h"
+#include "qt_common/qt_string_lookup.h"
#include <QFile>
@@ -33,7 +36,8 @@ std::unique_ptr<Core::System> system = nullptr;
std::shared_ptr<FileSys::RealVfsFilesystem> vfs = nullptr;
std::unique_ptr<FileSys::ManualContentProvider> provider = nullptr;
-Core::Frontend::WindowSystemType GetWindowSystemType() {
+Core::Frontend::WindowSystemType GetWindowSystemType()
+{
// Determine WSI type based on Qt platform.
QString platform_name = QGuiApplication::platformName();
if (platform_name == QStringLiteral("windows"))
@@ -101,9 +105,11 @@ void Init(QObject* root)
provider = std::make_unique<FileSys::ManualContentProvider>();
}
-std::filesystem::path GetEdenCommand() {
+std::filesystem::path GetEdenCommand()
+{
std::filesystem::path command;
+ // TODO: flatpak?
QString appimage = QString::fromLocal8Bit(getenv("APPIMAGE"));
if (!appimage.isEmpty()) {
command = std::filesystem::path{appimage.toStdString()};
diff --git a/src/qt_common/qt_string_lookup.h b/src/qt_common/qt_string_lookup.h
index 7756bcdaf2..42d71e6f9d 100644
--- a/src/qt_common/qt_string_lookup.h
+++ b/src/qt_common/qt_string_lookup.h
@@ -42,9 +42,18 @@ enum StringKey {
MigrationTooltipClearOld,
MigrationTooltipLinkOld,
+ // ryujinx
+ KvdbNonexistent,
+ KvdbNoHeader,
+ KvdbInvalidMagic,
+ KvdbMisaligned,
+ KvdbNoImens,
+ RyujinxNoSaveId,
+
};
-static const frozen::map<StringKey, frozen::string, 21> strings = {
+static const constexpr frozen::map<StringKey, frozen::string, 27> strings = {
+ // 0-4
{SavesTooltip,
QT_TR_NOOP("Contains game save data. DO NOT REMOVE UNLESS YOU KNOW WHAT YOU'RE DOING!")},
{ShadersTooltip,
@@ -54,6 +63,7 @@ static const frozen::map<StringKey, frozen::string, 21> strings = {
{ModsTooltip, QT_TR_NOOP("Contains game mods, patches, and cheats.")},
// Key install
+ // 5-9
{KeyInstallSuccess, QT_TR_NOOP("Decryption Keys were successfully installed")},
{KeyInstallInvalidDir, QT_TR_NOOP("Unable to read key directory, aborting")},
{KeyInstallErrorFailedCopy, QT_TR_NOOP("One or more keys failed to copy.")},
@@ -65,6 +75,7 @@ static const frozen::map<StringKey, frozen::string, 21> strings = {
"re-dump keys.")},
// fw install
+ // 10-14
{FwInstallSuccess, QT_TR_NOOP("Successfully installed firmware version %1")},
{FwInstallNoNCAs, QT_TR_NOOP("Unable to locate potential firmware NCA files")},
{FwInstallFailedDelete, QT_TR_NOOP("Failed to delete one or more firmware files.")},
@@ -75,6 +86,7 @@ static const frozen::map<StringKey, frozen::string, 21> strings = {
"Eden or re-install firmware.")},
// migrator
+ // 15-20
{MigrationPromptPrefix, QT_TR_NOOP("Eden has detected user data for the following emulators:")},
{MigrationPrompt,
QT_TR_NOOP("Would you like to migrate your data for use in Eden?\n"
@@ -93,6 +105,15 @@ static const frozen::map<StringKey, frozen::string, 21> strings = {
{MigrationTooltipLinkOld,
QT_TR_NOOP("Creates a filesystem link between the old directory and Eden directory.\n"
"This is recommended if you want to share data between emulators.")},
+
+ // why am I writing these comments again
+ // 21-26
+ {KvdbNonexistent, QT_TR_NOOP("Ryujinx title database does not exist.")},
+ {KvdbNoHeader, QT_TR_NOOP("Invalid header on Ryujinx title database.")},
+ {KvdbInvalidMagic, QT_TR_NOOP("Invalid magic header on Ryujinx title database.")},
+ {KvdbMisaligned, QT_TR_NOOP("Invalid byte alignment on Ryujinx title database.")},
+ {KvdbNoImens, QT_TR_NOOP("No items found in Ryujinx title database.")},
+ {RyujinxNoSaveId, QT_TR_NOOP("Title %1 not found in Ryujinx title database.")},
};
static inline const QString Lookup(StringKey key)
diff --git a/src/qt_common/util/content.cpp b/src/qt_common/util/content.cpp
index 4360efeef0..df4435f567 100644
--- a/src/qt_common/util/content.cpp
+++ b/src/qt_common/util/content.cpp
@@ -11,7 +11,7 @@
#include "frontend_common/firmware_manager.h"
#include "compress.h"
-#include "qt_common/abstract/qt_frontend_util.h"
+#include "qt_common/abstract/frontend.h"
#include "qt_common/abstract/qt_progress_dialog.h"
#include "qt_common/qt_common.h"
@@ -404,7 +404,7 @@ void ExportDataDir(FrontendCommon::DataManager::DataDir data_dir,
std::function<void()> callback)
{
using namespace QtCommon::Frontend;
- const std::string dir = FrontendCommon::DataManager::GetDataDir(data_dir, user_id);
+ const std::string dir = FrontendCommon::DataManager::GetDataDirString(data_dir, user_id);
const QString zip_dump_location = GetSaveFileName(tr("Select Export Location"),
tr("%1.zip").arg(name),
@@ -468,7 +468,7 @@ void ImportDataDir(FrontendCommon::DataManager::DataDir data_dir,
const std::string& user_id,
std::function<void()> callback)
{
- const std::string dir = FrontendCommon::DataManager::GetDataDir(data_dir, user_id);
+ const std::string dir = FrontendCommon::DataManager::GetDataDirString(data_dir, user_id);
using namespace QtCommon::Frontend;
diff --git a/src/qt_common/util/content.h b/src/qt_common/util/content.h
index d1384546e1..4102737fb1 100644
--- a/src/qt_common/util/content.h
+++ b/src/qt_common/util/content.h
@@ -25,7 +25,8 @@ enum class FirmwareInstallResult {
inline const QString GetFirmwareInstallResultString(FirmwareInstallResult result)
{
- return QtCommon::StringLookup::Lookup(static_cast<StringLookup::StringKey>((int) result + (int) QtCommon::StringLookup::FwInstallSuccess));
+ return QtCommon::StringLookup::Lookup(static_cast<StringLookup::StringKey>(
+ (int) result + (int) QtCommon::StringLookup::FwInstallSuccess));
}
/**
@@ -36,7 +37,8 @@ inline const QString GetFirmwareInstallResultString(FirmwareInstallResult result
inline const QString GetKeyInstallResultString(FirmwareManager::KeyInstallResult result)
{
// this can probably be made into a common function of sorts
- return QtCommon::StringLookup::Lookup(static_cast<StringLookup::StringKey>((int) result + (int) QtCommon::StringLookup::KeyInstallSuccess));
+ return QtCommon::StringLookup::Lookup(static_cast<StringLookup::StringKey>(
+ (int) result + (int) QtCommon::StringLookup::KeyInstallSuccess));
}
void InstallFirmware(const QString &location, bool recursive);
diff --git a/src/qt_common/util/fs.cpp b/src/qt_common/util/fs.cpp
new file mode 100644
index 0000000000..da59a4e675
--- /dev/null
+++ b/src/qt_common/util/fs.cpp
@@ -0,0 +1,130 @@
+// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include <filesystem>
+#include "fs.h"
+#include "common/fs/ryujinx_compat.h"
+#include "common/fs/symlink.h"
+#include "frontend_common/data_manager.h"
+#include "qt_common/abstract/frontend.h"
+#include "qt_common/qt_string_lookup.h"
+
+namespace fs = std::filesystem;
+
+namespace QtCommon::FS {
+
+void LinkRyujinx(std::filesystem::path &from, std::filesystem::path &to)
+{
+ std::error_code ec;
+
+ // "ignore" errors--if the dir fails to be deleted, error handling later will handle it
+ fs::remove_all(to, ec);
+
+ if (Common::FS::CreateSymlink(from, to)) {
+ QtCommon::Frontend::Information(tr("Linked Save Data"), tr("Save data has been linked."));
+ } else {
+ QtCommon::Frontend::Critical(
+ tr("Failed to link save data"),
+ tr("Could not link directory:\n\t%1\nTo:\n\t%2").arg(QString::fromStdString(from.string()), QString::fromStdString(to.string())));
+ }
+}
+
+bool CheckUnlink(const fs::path &eden_dir, const fs::path &ryu_dir)
+{
+ bool eden_link = Common::FS::IsSymlink(eden_dir);
+ bool ryu_link = Common::FS::IsSymlink(ryu_dir);
+
+ if (!(eden_link || ryu_link))
+ return false;
+
+ auto result = QtCommon::Frontend::Warning(
+ tr("Already Linked"),
+ tr("This title is already linked to Ryujinx. Would you like to unlink it?"),
+ QtCommon::Frontend::StandardButton::Yes | QtCommon::Frontend::StandardButton::No);
+
+ if (result != QtCommon::Frontend::StandardButton::Yes)
+ return true;
+
+ fs::path linked;
+ fs::path orig;
+
+ if (eden_link) {
+ linked = eden_dir;
+ orig = ryu_dir;
+ } else {
+ linked = ryu_dir;
+ orig = eden_dir;
+ }
+
+ // first cleanup the symlink/junction,
+ try {
+ // NB: do NOT use remove_all, as Windows treats this as a remove_all to the target,
+ // NOT the junction
+ fs::remove(linked);
+ } catch (std::exception &e) {
+ QtCommon::Frontend::Critical(
+ tr("Failed to unlink old directory"),
+ tr("OS returned error: %1").arg(QString::fromStdString(e.what())));
+ return true;
+ }
+
+ // then COPY the other dir
+ try {
+ fs::copy(orig, linked, fs::copy_options::recursive);
+ } catch (std::exception &e) {
+ QtCommon::Frontend::Critical(
+ tr("Failed to copy save data"),
+ tr("OS returned error: %1").arg(QString::fromStdString(e.what())));
+ }
+
+ QtCommon::Frontend::Information(
+ tr("Unlink Successful"),
+ tr("Successfully unlinked Ryujinx save data. Save data has been kept intact."));
+
+ return true;
+}
+
+u64 GetRyujinxSaveID(const u64 &program_id)
+{
+ auto path = Common::FS::GetKvdbPath();
+ std::vector<Common::FS::IMEN> imens;
+ Common::FS::IMENReadResult res = Common::FS::ReadKvdb(path, imens);
+
+ if (res == Common::FS::IMENReadResult::Success) {
+ // TODO: this can probably be done with std::find_if but I'm lazy
+ for (const Common::FS::IMEN &imen : imens) {
+ if (imen.title_id == program_id)
+ return imen.save_id;
+ }
+
+ QtCommon::Frontend::Critical(
+ tr("Could not find Ryujinx save data"),
+ StringLookup::Lookup(StringLookup::RyujinxNoSaveId).arg(program_id, 0, 16));
+ } else {
+ // TODO: make this long thing a function or something
+ QString caption = StringLookup::Lookup(
+ static_cast<StringLookup::StringKey>((int) res + (int) StringLookup::KvdbNonexistent));
+ QtCommon::Frontend::Critical(tr("Could not find Ryujinx save data"), caption);
+ }
+
+ return -1;
+}
+
+std::optional<std::pair<fs::path, fs::path> > GetEmuPaths(
+ const u64 program_id, const u64 save_id, const std::string &user_id)
+{
+ fs::path ryu_dir = Common::FS::GetRyuSavePath(save_id);
+
+ if (user_id.empty())
+ return std::nullopt;
+
+ std::string hex_program = fmt::format("{:016X}", program_id);
+ fs::path eden_dir
+ = FrontendCommon::DataManager::GetDataDir(FrontendCommon::DataManager::DataDir::Saves,
+ user_id)
+ / hex_program;
+
+ return std::make_pair(eden_dir, ryu_dir);
+}
+
+} // namespace QtCommon::FS
diff --git a/src/qt_common/util/fs.h b/src/qt_common/util/fs.h
new file mode 100644
index 0000000000..ee8859a2e5
--- /dev/null
+++ b/src/qt_common/util/fs.h
@@ -0,0 +1,22 @@
+// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "common/common_types.h"
+#include <filesystem>
+#include <optional>
+
+#pragma once
+
+namespace QtCommon::FS {
+
+void LinkRyujinx(std::filesystem::path &from, std::filesystem::path &to);
+u64 GetRyujinxSaveID(const u64 &program_id);
+
+/// @brief {eden, ryu}
+std::optional<std::pair<std::filesystem::path, std::filesystem::path>> GetEmuPaths(
+ const u64 program_id, const u64 save_id, const std::string &user_id);
+
+/// returns FALSE if the dirs are NOT linked
+bool CheckUnlink(const std::filesystem::path &eden_dir, const std::filesystem::path &ryu_dir);
+
+} // namespace QtCommon::FS
diff --git a/src/qt_common/util/game.cpp b/src/qt_common/util/game.cpp
index e5018d24cb..e34a388993 100644
--- a/src/qt_common/util/game.cpp
+++ b/src/qt_common/util/game.cpp
@@ -8,7 +8,7 @@
#include "core/file_sys/savedata_factory.h"
#include "core/hle/service/am/am_types.h"
#include "frontend_common/content_manager.h"
-#include "qt_common/abstract/qt_frontend_util.h"
+#include "qt_common/abstract/frontend.h"
#include "qt_common/config/uisettings.h"
#include "qt_common/qt_common.h"
#include "yuzu/util/util.h"
diff --git a/src/qt_common/util/path.cpp b/src/qt_common/util/path.cpp
index 93e8007eb9..73689058c6 100644
--- a/src/qt_common/util/path.cpp
+++ b/src/qt_common/util/path.cpp
@@ -7,7 +7,7 @@
#include <QUrl>
#include "common/fs/fs.h"
#include "common/fs/path_util.h"
-#include "qt_common/abstract/qt_frontend_util.h"
+#include "qt_common/abstract/frontend.h"
#include <fmt/format.h>
namespace QtCommon::Path {
diff --git a/src/yuzu/CMakeLists.txt b/src/yuzu/CMakeLists.txt
index 4b1e87b6ed..d6082dd00b 100644
--- a/src/yuzu/CMakeLists.txt
+++ b/src/yuzu/CMakeLists.txt
@@ -236,6 +236,7 @@ add_executable(yuzu
data_dialog.h data_dialog.cpp data_dialog.ui
data_widget.ui
+ ryujinx_dialog.h ryujinx_dialog.cpp ryujinx_dialog.ui
)
set_target_properties(yuzu PROPERTIES OUTPUT_NAME "eden")
diff --git a/src/yuzu/data_dialog.cpp b/src/yuzu/data_dialog.cpp
index dc3e06f89f..008e810eae 100644
--- a/src/yuzu/data_dialog.cpp
+++ b/src/yuzu/data_dialog.cpp
@@ -95,7 +95,7 @@ void DataWidget::open()
user_id = selectProfile();
}
QDesktopServices::openUrl(QUrl::fromLocalFile(
- QString::fromStdString(FrontendCommon::DataManager::GetDataDir(m_dir, user_id))));
+ QString::fromStdString(FrontendCommon::DataManager::GetDataDirString(m_dir, user_id))));
}
void DataWidget::upload()
diff --git a/src/yuzu/game_list.cpp b/src/yuzu/game_list.cpp
index 875e794181..ae8e4688b4 100644
--- a/src/yuzu/game_list.cpp
+++ b/src/yuzu/game_list.cpp
@@ -542,6 +542,7 @@ void GameList::PopupContextMenu(const QPoint& menu_location) {
}
void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::string& path) {
+ // TODO(crueter): Refactor this and make it less bad
QAction* favorite = context_menu.addAction(tr("Favorite"));
context_menu.addSeparator();
QAction* start_game = context_menu.addAction(tr("Start Game"));
@@ -581,6 +582,7 @@ void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::stri
#endif
context_menu.addSeparator();
QAction* properties = context_menu.addAction(tr("Configure Game"));
+ QAction* ryujinx = context_menu.addAction(tr("Link to Ryujinx"));
favorite->setVisible(program_id != 0);
favorite->setCheckable(true);
@@ -662,6 +664,9 @@ void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::stri
#endif
connect(properties, &QAction::triggered,
[this, path]() { emit OpenPerGameGeneralRequested(path); });
+
+ connect(ryujinx, &QAction::triggered, [this, program_id]() { emit LinkToRyujinxRequested(program_id);
+ });
};
void GameList::AddCustomDirPopup(QMenu& context_menu, QModelIndex selected) {
diff --git a/src/yuzu/game_list.h b/src/yuzu/game_list.h
index b4cdf61f0d..0b3ac7fcde 100644
--- a/src/yuzu/game_list.h
+++ b/src/yuzu/game_list.h
@@ -113,6 +113,7 @@ signals:
void NavigateToGamedbEntryRequested(u64 program_id,
const CompatibilityList& compatibility_list);
void OpenPerGameGeneralRequested(const std::string& file);
+ void LinkToRyujinxRequested(const u64 &program_id);
void OpenDirectory(const QString& directory);
void AddDirectory();
void ShowList(bool show);
diff --git a/src/yuzu/main.cpp b/src/yuzu/main.cpp
index 8fb5575b41..849ffa84de 100644
--- a/src/yuzu/main.cpp
+++ b/src/yuzu/main.cpp
@@ -6,10 +6,12 @@
#include "core/tools/renderdoc.h"
#include "frontend_common/firmware_manager.h"
#include "qt_common/qt_common.h"
+#include "qt_common/abstract/frontend.h"
#include "qt_common/util/content.h"
#include "qt_common/util/game.h"
#include "qt_common/util/meta.h"
#include "qt_common/util/path.h"
+#include "qt_common/util/fs.h"
#include <clocale>
#include <cmath>
#include <memory>
@@ -108,6 +110,7 @@ static FileSys::VirtualFile VfsDirectoryCreateFileWrapper(const FileSys::Virtual
#include "common/detached_tasks.h"
#include "common/fs/fs.h"
#include "common/fs/path_util.h"
+#include "common/fs/ryujinx_compat.h"
#include "common/literals.h"
#include "common/logging/backend.h"
#include "common/logging/log.h"
@@ -160,6 +163,7 @@ static FileSys::VirtualFile VfsDirectoryCreateFileWrapper(const FileSys::Virtual
#include "yuzu/debugger/wait_tree.h"
#include "yuzu/data_dialog.h"
#include "yuzu/deps_dialog.h"
+#include "yuzu/ryujinx_dialog.h"
#include "qt_common/discord/discord.h"
#include "yuzu/game_list.h"
#include "yuzu/game_list_p.h"
@@ -1597,6 +1601,8 @@ void GMainWindow::ConnectWidgetEvents() {
connect(game_list, &GameList::OpenPerGameGeneralRequested, this,
&GMainWindow::OnGameListOpenPerGameProperties);
+ connect(game_list, &GameList::LinkToRyujinxRequested, this,
+ &GMainWindow::OnLinkToRyujinx);
connect(this, &GMainWindow::UpdateInstallProgress, this,
&GMainWindow::IncrementInstallProgress);
@@ -2875,6 +2881,61 @@ void GMainWindow::OnGameListOpenPerGameProperties(const std::string& file) {
OpenPerGameConfiguration(title_id, file);
}
+std::string GMainWindow::GetProfileID()
+{
+ const auto select_profile = [this] {
+ const Core::Frontend::ProfileSelectParameters parameters{
+ .mode = Service::AM::Frontend::UiMode::UserSelector,
+ .invalid_uid_list = {},
+ .display_options = {},
+ .purpose = Service::AM::Frontend::UserSelectionPurpose::General,
+ };
+ QtProfileSelectionDialog dialog(*QtCommon::system, this, parameters);
+ dialog.setWindowFlags(Qt::Dialog | Qt::CustomizeWindowHint | Qt::WindowTitleHint
+ | Qt::WindowSystemMenuHint | Qt::WindowCloseButtonHint);
+ dialog.setWindowModality(Qt::WindowModal);
+
+ if (dialog.exec() == QDialog::Rejected) {
+ return -1;
+ }
+
+ return dialog.GetIndex();
+ };
+
+ const auto index = select_profile();
+ if (index == -1) {
+ return "";
+ }
+
+ const auto uuid = QtCommon::system->GetProfileManager().GetUser(static_cast<std::size_t>(index));
+ ASSERT(uuid);
+
+ const auto user_id = uuid->AsU128();
+
+ return fmt::format("{:016X}{:016X}", user_id[1], user_id[0]);
+}
+
+void GMainWindow::OnLinkToRyujinx(const u64& program_id)
+{
+ u64 save_id = QtCommon::FS::GetRyujinxSaveID(program_id);
+ if (save_id == (u64) -1)
+ return;
+
+ const std::string user_id = GetProfileID();
+
+ auto paths = QtCommon::FS::GetEmuPaths(program_id, save_id, user_id);
+ if (!paths)
+ return;
+
+ auto eden_dir = paths.value().first;
+ auto ryu_dir = paths.value().second;
+
+ if (!QtCommon::FS::CheckUnlink(eden_dir, ryu_dir)) {
+ RyujinxDialog dialog(eden_dir, ryu_dir, this);
+ dialog.exec();
+ }
+}
+
void GMainWindow::OnMenuLoadFile() {
if (is_load_file_select_active) {
return;
diff --git a/src/yuzu/main.h b/src/yuzu/main.h
index ce04d6d152..ae5d74215a 100644
--- a/src/yuzu/main.h
+++ b/src/yuzu/main.h
@@ -358,6 +358,7 @@ private slots:
void OnGameListAddDirectory();
void OnGameListShowList(bool show);
void OnGameListOpenPerGameProperties(const std::string& file);
+ void OnLinkToRyujinx(const u64& program_id);
void OnMenuLoadFile();
void OnMenuLoadFolder();
void IncrementInstallProgress();
@@ -470,6 +471,8 @@ private:
QMessageBox::StandardButtons(QMessageBox::Yes | QMessageBox::No),
QMessageBox::StandardButton defaultButton = QMessageBox::NoButton);
+ std::string GetProfileID();
+
std::unique_ptr<Ui::MainWindow> ui;
std::unique_ptr<DiscordRPC::DiscordInterface> discord_rpc;
diff --git a/src/yuzu/migration_worker.cpp b/src/yuzu/migration_worker.cpp
index b39bd22584..50b6d1ebd4 100644
--- a/src/yuzu/migration_worker.cpp
+++ b/src/yuzu/migration_worker.cpp
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "migration_worker.h"
+#include "common/fs/symlink.h"
#include <QMap>
#include <boost/algorithm/string/predicate.hpp>
@@ -37,7 +38,7 @@ void MigrationWorker::process()
try {
fs::remove_all(eden_dir);
} catch (fs::filesystem_error &_) {
- // ignore because linux does stupid crap sometimes.
+ // ignore because linux does stupid crap sometimes
}
switch (strategy) {
@@ -46,7 +47,7 @@ void MigrationWorker::process()
// Windows 11 has random permission nonsense to deal with.
try {
- fs::create_directory_symlink(legacy_user_dir, eden_dir);
+ Common::FS::CreateSymlink(legacy_user_dir, eden_dir);
} catch (const fs::filesystem_error &e) {
emit error(tr("Linking the old directory failed. You may need to re-run with "
"administrative privileges on Windows.\nOS gave error: %1")
@@ -58,11 +59,11 @@ void MigrationWorker::process()
// are already children of the root directory
#ifndef WIN32
if (fs::is_directory(legacy_config_dir)) {
- fs::create_directory_symlink(legacy_config_dir, config_dir);
+ Common::FS::CreateSymlink(legacy_config_dir, config_dir);
}
if (fs::is_directory(legacy_cache_dir)) {
- fs::create_directory_symlink(legacy_cache_dir, cache_dir);
+ Common::FS::CreateSymlink(legacy_cache_dir, cache_dir);
}
#endif
diff --git a/src/yuzu/migration_worker.h b/src/yuzu/migration_worker.h
index 5f9e400db0..2ff6e8c481 100644
--- a/src/yuzu/migration_worker.h
+++ b/src/yuzu/migration_worker.h
@@ -12,9 +12,9 @@ using namespace Common::FS;
typedef struct Emulator {
const char *m_name;
- LegacyPath e_user_dir;
- LegacyPath e_config_dir;
- LegacyPath e_cache_dir;
+ EmuPath e_user_dir;
+ EmuPath e_config_dir;
+ EmuPath e_cache_dir;
const std::string get_user_dir() const {
return Common::FS::GetLegacyPath(e_user_dir).string();
diff --git a/src/yuzu/ryujinx_dialog.cpp b/src/yuzu/ryujinx_dialog.cpp
new file mode 100644
index 0000000000..db10c06d93
--- /dev/null
+++ b/src/yuzu/ryujinx_dialog.cpp
@@ -0,0 +1,40 @@
+// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "ryujinx_dialog.h"
+#include "qt_common/util/fs.h"
+#include "ui_ryujinx_dialog.h"
+#include <filesystem>
+
+namespace fs = std::filesystem;
+
+RyujinxDialog::RyujinxDialog(std::filesystem::path eden_path,
+ std::filesystem::path ryu_path,
+ QWidget *parent)
+ : QDialog(parent)
+ , ui(new Ui::RyujinxDialog)
+ , m_eden(eden_path.make_preferred())
+ , m_ryu(ryu_path.make_preferred())
+{
+ ui->setupUi(this);
+
+ connect(ui->eden, &QPushButton::clicked, this, &RyujinxDialog::fromEden);
+ connect(ui->ryujinx, &QPushButton::clicked, this, &RyujinxDialog::fromRyujinx);
+}
+
+RyujinxDialog::~RyujinxDialog()
+{
+ delete ui;
+}
+
+void RyujinxDialog::fromEden()
+{
+ accept();
+ QtCommon::FS::LinkRyujinx(m_eden, m_ryu);
+}
+
+void RyujinxDialog::fromRyujinx()
+{
+ accept();
+ QtCommon::FS::LinkRyujinx(m_ryu, m_eden);
+}
diff --git a/src/yuzu/ryujinx_dialog.h b/src/yuzu/ryujinx_dialog.h
new file mode 100644
index 0000000000..63cebe483c
--- /dev/null
+++ b/src/yuzu/ryujinx_dialog.h
@@ -0,0 +1,32 @@
+// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#ifndef RYUJINX_DIALOG_H
+#define RYUJINX_DIALOG_H
+
+#include <QDialog>
+#include <filesystem>
+
+namespace Ui {
+class RyujinxDialog;
+}
+
+class RyujinxDialog : public QDialog
+{
+ Q_OBJECT
+
+public:
+ explicit RyujinxDialog(std::filesystem::path eden_path, std::filesystem::path ryu_path, QWidget *parent = nullptr);
+ ~RyujinxDialog();
+
+private slots:
+ void fromEden();
+ void fromRyujinx();
+
+private:
+ Ui::RyujinxDialog *ui;
+ std::filesystem::path m_eden;
+ std::filesystem::path m_ryu;
+};
+
+#endif // RYUJINX_DIALOG_H
diff --git a/src/yuzu/ryujinx_dialog.ui b/src/yuzu/ryujinx_dialog.ui
new file mode 100644
index 0000000000..c4fb5ae51f
--- /dev/null
+++ b/src/yuzu/ryujinx_dialog.ui
@@ -0,0 +1,81 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>RyujinxDialog</class>
+ <widget class="QDialog" name="RyujinxDialog">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>404</width>
+ <height>170</height>
+ </rect>
+ </property>
+ <property name="windowTitle">
+ <string>Ryujinx Link</string>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout">
+ <item>
+ <widget class="QLabel" name="label">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Preferred" vsizetype="Expanding">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="text">
+ <string>Linking save data to Ryujinx lets both Ryujinx and Eden reference the same save files for your games.
+
+By selecting &quot;From Eden&quot;, previous save data stored in Ryujinx will be deleted, and vice versa for &quot;From Ryujinx&quot;.</string>
+ </property>
+ <property name="wordWrap">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <layout class="QHBoxLayout" name="horizontalLayout">
+ <item>
+ <widget class="QPushButton" name="eden">
+ <property name="text">
+ <string>From Eden</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QPushButton" name="ryujinx">
+ <property name="text">
+ <string>From Ryujinx</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QPushButton" name="cancel">
+ <property name="text">
+ <string>Cancel</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ </layout>
+ </widget>
+ <resources/>
+ <connections>
+ <connection>
+ <sender>cancel</sender>
+ <signal>clicked()</signal>
+ <receiver>RyujinxDialog</receiver>
+ <slot>reject()</slot>
+ <hints>
+ <hint type="sourcelabel">
+ <x>331</x>
+ <y>147</y>
+ </hint>
+ <hint type="destinationlabel">
+ <x>201</x>
+ <y>84</y>
+ </hint>
+ </hints>
+ </connection>
+ </connections>
+</ui>