mirror of
https://git.eden-emu.dev/eden-emu/eden
synced 2026-04-10 09:48:58 +02:00
[qt] Ryujinx save data link (#2815)
This adds an action to the Game List context menu that lets users link save data from Eden to Ryujinx, or vice versa. Unfortunately, this isn't so simple to deal with due to the way Ryujinx's saves work. Ryujinx stores its saves in the... config directory... in `bis/user/save`. Unlike Yuzu, however, it doesn't store things by TitleID, instead it's just a bunch of directories from 000...01 to 000...0f and so on. The way it *maps* TitleID to SaveID is via `imkvdb.arc` in `bis/system/save/8000000000000000/0/` and also an identical copy in the `1` directory for... some reason. `imkvdb.arc` is handled by `FlatMapKeyValueStore` in LibHac, which, as the name implies, is a key-value storage system that `imkvdb.arc`, and seemingly `imkvdb.arc` alone, uses. The way this class is written is really weird, almost as if it's designed to accommodate more types of kvdbs... but for now we can safely assume that there aren't gonna be any other `kvdb` implementations added to HorizonNX. Regardless, the file format is ridiculously simple so I didn't actually need to do a deep dive into C# code... of which I can basically only read Avalonia. A simple `xxd` on the `imkvdb.arc` is all that's needed, and here's everything that matters: - The `IMKV` magic header (4 bytes) - 8 bytes that don't really have anything useful to us, except for a size byte (presumably a `u32`) strewn at offset `0x08` from the start of the file, which is useless to us - Then we start the `IMEN` list. I don't know what the `IM` stands for, but `IMEN` is just, well, an ENtry. Offsets shown are relative to the start of the `IMEN` header. * 4-byte `IMEN` magic header at 0x0 * 8 bytes of filler data. It contains two `0x40` bytes, but I'm not really sure what they do * TitleID (u64) at `0xC`, for example `00a0 df10 501f 0001` for Legends: Arceus (the byte order is swapped) * 0x38 bytes of filler starting at offset 0x14 * SaveID (u64) at `0x4C`, for example `0a00 0000 0000 0000` for my Legends: Arceus save * 0x38 bytes of filler starting at offset 0x54 Full example for Legends: Arceus: ``` 000001b0: 494d 454e 4000 0000 4000 0000 00a0 df10 IMEN@...@....... 000001c0: 501f 0001 0100 0000 0000 0000 0000 0000 P............... 000001d0: 0000 0000 0000 0000 0000 0000 0100 0000 ................ 000001e0: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 000001f0: 0000 0000 0000 0000 0000 0000 0a00 0000 ................ 00000200: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 00000210: 0000 0000 0100 0000 0000 0000 0000 0000 ................ 00000220: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 00000230: 0000 0000 0000 0000 0000 0000 494d 454e ............IMEN ``` Ultimately, the size of the `IMEN` sits at 0x8C or 140 bytes. With this knowledge reading all the TitleID -> SaveID pairs is basically free, and outside of validation and stuff is like 15 lines of relevant code. Some interesting caveats, though: - There are two entries for some TitleIDs for... some reason? Ignoring the second one seems to work though. - Within each save directory, there are directories `0` and `1`... and only `0` ever seems used??? It's where Ryujinx points you to for save, so I just chose to use that. Once everything is parsed, the rest of the implementation is extremely trivial: - When the user requests a Ryujinx link, match the current program_id to the corresponding SaveID in `imkvdb` - If it doesn't exist, just error out (save data is probably nonexistent) - If it does though, give the user the option to use Eden's current save data OR Ryujinx's current save data. Old save data is deleted depending on which one you chose. Signed-off-by: crueter <crueter@eden-emu.dev> Reviewed-on: https://git.eden-emu.dev/eden-emu/eden/pulls/2815 Reviewed-by: Lizzie <lizzie@eden-emu.dev> Reviewed-by: MaranBr <maranbr@eden-emu.dev>
This commit is contained in:
parent
61ab1be0e7
commit
39f226a853
32 changed files with 664 additions and 47 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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()};
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
130
src/qt_common/util/fs.cpp
Normal file
130
src/qt_common/util/fs.cpp
Normal file
|
|
@ -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
|
||||
22
src/qt_common/util/fs.h
Normal file
22
src/qt_common/util/fs.h
Normal file
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue