mirror of
https://git.eden-emu.dev/eden-emu/eden
synced 2026-04-10 05:28:56 +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
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
40
src/yuzu/ryujinx_dialog.cpp
Normal file
40
src/yuzu/ryujinx_dialog.cpp
Normal file
|
|
@ -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);
|
||||
}
|
||||
32
src/yuzu/ryujinx_dialog.h
Normal file
32
src/yuzu/ryujinx_dialog.h
Normal file
|
|
@ -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
|
||||
81
src/yuzu/ryujinx_dialog.ui
Normal file
81
src/yuzu/ryujinx_dialog.ui
Normal file
|
|
@ -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 "From Eden", previous save data stored in Ryujinx will be deleted, and vice versa for "From Ryujinx".</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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue