mirror of
https://git.eden-emu.dev/eden-emu/eden
synced 2026-04-10 11:58:59 +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
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
93
src/common/fs/ryujinx_compat.cpp
Normal file
93
src/common/fs/ryujinx_compat.cpp
Normal file
|
|
@ -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
|
||||
40
src/common/fs/ryujinx_compat.h
Normal file
40
src/common/fs/ryujinx_compat.h
Normal file
|
|
@ -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
|
||||
43
src/common/fs/symlink.cpp
Normal file
43
src/common/fs/symlink.cpp
Normal file
|
|
@ -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
|
||||
12
src/common/fs/symlink.h
Normal file
12
src/common/fs/symlink.h
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue