80 changes: 62 additions & 18 deletions Source/Core/Core/WiiRoot.cpp
Expand Up @@ -35,9 +35,19 @@ namespace Core
namespace FS = IOS::HLE::FS;

static std::string s_temp_wii_root;
static std::string s_temp_redirect_root;
static bool s_wii_root_initialized = false;
static std::vector<IOS::HLE::FS::NandRedirect> s_nand_redirects;

// When Temp NAND + Redirects are both active, we need to keep track of where each redirect path
// should be copied back to after a successful session finish.
struct TempRedirectPath
{
std::string real_path;
std::string temp_path;
};
static std::vector<TempRedirectPath> s_temp_nand_redirects;

const std::vector<IOS::HLE::FS::NandRedirect>& GetActiveNandRedirects()
{
return s_nand_redirects;
Expand Down Expand Up @@ -175,6 +185,28 @@ static void InitializeDeterministicWiiSaves(FS::FileSystem* session_fs,
WARN_LOG_FMT(CORE, "Failed to copy Mii database to the NAND");
}
}

const auto& netplay_redirect_folder = boot_session_data.GetWiiSyncRedirectFolder();
if (!netplay_redirect_folder.empty())
File::CopyDir(netplay_redirect_folder, s_temp_redirect_root + "/");
}
}

static void MoveToBackupIfExists(const std::string& path)
{
if (File::Exists(path))
{
const std::string backup_path = path.substr(0, path.size() - 1) + ".backup" DIR_SEP;
WARN_LOG_FMT(IOS_FS, "Temporary directory at {} exists, moving to backup...", path);

// If backup exists, delete it as we don't want a mess
if (File::Exists(backup_path))
{
WARN_LOG_FMT(IOS_FS, "Temporary backup directory at {} exists, deleting...", backup_path);
File::DeleteDirRecursively(backup_path);
}

File::CopyDir(path, backup_path, true);
}
}

Expand All @@ -185,24 +217,13 @@ void InitializeWiiRoot(bool use_temporary)
if (use_temporary)
{
s_temp_wii_root = File::GetUserPath(D_USER_IDX) + "WiiSession" DIR_SEP;
s_temp_redirect_root = File::GetUserPath(D_USER_IDX) + "RedirectSession" DIR_SEP;
WARN_LOG_FMT(IOS_FS, "Using temporary directory {} for minimal Wii FS", s_temp_wii_root);
WARN_LOG_FMT(IOS_FS, "Using temporary directory {} for redirected saves", s_temp_redirect_root);

// If directory exists, make a backup
if (File::Exists(s_temp_wii_root))
{
const std::string backup_path =
s_temp_wii_root.substr(0, s_temp_wii_root.size() - 1) + ".backup" DIR_SEP;
WARN_LOG_FMT(IOS_FS, "Temporary Wii FS directory exists, moving to backup...");

// If backup exists, delete it as we don't want a mess
if (File::Exists(backup_path))
{
WARN_LOG_FMT(IOS_FS, "Temporary Wii FS backup directory exists, deleting...");
File::DeleteDirRecursively(backup_path);
}

File::CopyDir(s_temp_wii_root, backup_path, true);
}
MoveToBackupIfExists(s_temp_wii_root);
MoveToBackupIfExists(s_temp_redirect_root);

File::SetUserPath(D_SESSION_WIIROOT_IDX, s_temp_wii_root);
}
Expand All @@ -221,6 +242,9 @@ void ShutdownWiiRoot()
{
File::DeleteDirRecursively(s_temp_wii_root);
s_temp_wii_root.clear();
File::DeleteDirRecursively(s_temp_redirect_root);
s_temp_redirect_root.clear();
s_temp_nand_redirects.clear();
}

s_nand_redirects.clear();
Expand Down Expand Up @@ -312,24 +336,35 @@ void InitializeWiiFileSystemContents(
if (!CopySysmenuFilesToFS(fs.get(), File::GetSysDirectory() + WII_USER_DIR, ""))
WARN_LOG_FMT(CORE, "Failed to copy initial System Menu files to the NAND");

if (WiiRootIsTemporary())
const bool is_temp_nand = WiiRootIsTemporary();
if (is_temp_nand)
{
// Generate a SYSCONF with default settings for the temporary Wii NAND.
SysConf sysconf{fs};
sysconf.Save();

InitializeDeterministicWiiSaves(fs.get(), boot_session_data);
}
else if (save_redirect)

if (save_redirect)
{
const u64 title_id = SConfig::GetInstance().GetTitleID();
std::string source_path = Common::GetTitleDataPath(title_id);

if (is_temp_nand)
{
// remember the actual path for copying back on shutdown and redirect to a temp folder instead
s_temp_nand_redirects.emplace_back(
TempRedirectPath{save_redirect->m_target_path, s_temp_redirect_root});
save_redirect->m_target_path = s_temp_redirect_root;
}

if (!File::IsDirectory(save_redirect->m_target_path))
{
File::CreateFullPath(save_redirect->m_target_path + "/");
if (save_redirect->m_clone)
{
File::CopyDir(Common::GetTitleDataPath(title_id, Common::FROM_CONFIGURED_ROOT),
File::CopyDir(Common::GetTitleDataPath(title_id, Common::FROM_SESSION_ROOT),
save_redirect->m_target_path);
}
}
Expand All @@ -347,7 +382,16 @@ void CleanUpWiiFileSystemContents(const BootSessionData& boot_session_data)
return;
}

// copy back the temp nand redirected files to where they should normally be redirected to
for (const auto& redirect : s_temp_nand_redirects)
File::CopyDir(redirect.temp_path, redirect.real_path + "/", true);

IOS::HLE::EmulationKernel* ios = IOS::HLE::GetIOS();

// clear the redirects in the session FS, otherwise the back-copy might grab redirected files
s_nand_redirects.clear();
ios->GetFS()->SetNandRedirects({});

const auto configured_fs = FS::MakeFileSystem(FS::Location::Configured);

// Copy back Mii data
Expand Down
2 changes: 2 additions & 0 deletions Source/Core/DiscIO/Blob.cpp
Expand Up @@ -50,6 +50,8 @@ std::string GetName(BlobType blob_type, bool translate)
return "WIA";
case BlobType::RVZ:
return "RVZ";
case BlobType::MOD_DESCRIPTOR:
return translate_str("Mod");
default:
return "";
}
Expand Down
1 change: 1 addition & 0 deletions Source/Core/DiscIO/Blob.h
Expand Up @@ -39,6 +39,7 @@ enum class BlobType
TGC,
WIA,
RVZ,
MOD_DESCRIPTOR,
};

std::string GetName(BlobType blob_type, bool translate);
Expand Down
81 changes: 79 additions & 2 deletions Source/Core/DiscIO/GameModDescriptor.cpp
Expand Up @@ -115,10 +115,15 @@ std::optional<GameModDescriptor> ParseGameModDescriptorString(std::string_view j
return std::nullopt;

GameModDescriptor descriptor;
bool is_game_mod_descriptor = false;
bool is_valid_version = false;
for (const auto& [key, value] : json_root.get<picojson::object>())
{
if (key == "version" && value.is<double>())
if (key == "type" && value.is<std::string>())
{
is_game_mod_descriptor = value.get<std::string>() == "dolphin-game-mod-descriptor";
}
else if (key == "version" && value.is<double>())
{
is_valid_version = value.get<double>() == 1.0;
}
Expand All @@ -140,8 +145,80 @@ std::optional<GameModDescriptor> ParseGameModDescriptorString(std::string_view j
ParseRiivolutionObject(json_directory, value.get<picojson::object>());
}
}
if (!is_valid_version)
if (!is_game_mod_descriptor || !is_valid_version)
return std::nullopt;
return descriptor;
}

static picojson::object
WriteGameModDescriptorRiivolution(const GameModDescriptorRiivolution& riivolution)
{
picojson::array json_patches;
for (const auto& patch : riivolution.patches)
{
picojson::object json_patch;
if (!patch.xml.empty())
json_patch["xml"] = picojson::value(patch.xml);
if (!patch.root.empty())
json_patch["root"] = picojson::value(patch.root);
if (!patch.options.empty())
{
picojson::array json_options;
for (const auto& option : patch.options)
{
picojson::object json_option;
if (!option.section_name.empty())
json_option["section-name"] = picojson::value(option.section_name);
if (!option.option_id.empty())
json_option["option-id"] = picojson::value(option.option_id);
if (!option.option_name.empty())
json_option["option-name"] = picojson::value(option.option_name);
json_option["choice"] = picojson::value(static_cast<double>(option.choice));
json_options.emplace_back(std::move(json_option));
}
json_patch["options"] = picojson::value(std::move(json_options));
}
json_patches.emplace_back(std::move(json_patch));
}

picojson::object json_riivolution;
json_riivolution["patches"] = picojson::value(std::move(json_patches));
return json_riivolution;
}

std::string WriteGameModDescriptorString(const GameModDescriptor& descriptor, bool pretty)
{
picojson::object json_root;
json_root["type"] = picojson::value("dolphin-game-mod-descriptor");
json_root["version"] = picojson::value(1.0);
if (!descriptor.base_file.empty())
json_root["base-file"] = picojson::value(descriptor.base_file);
if (!descriptor.display_name.empty())
json_root["display-name"] = picojson::value(descriptor.display_name);
if (!descriptor.banner.empty())
json_root["banner"] = picojson::value(descriptor.banner);
if (descriptor.riivolution)
{
json_root["riivolution"] =
picojson::value(WriteGameModDescriptorRiivolution(*descriptor.riivolution));
}
return picojson::value(json_root).serialize(pretty);
}

bool WriteGameModDescriptorFile(const std::string& filename, const GameModDescriptor& descriptor,
bool pretty)
{
auto json = WriteGameModDescriptorString(descriptor, pretty);
if (json.empty())
return false;

::File::IOFile f(filename, "wb");
if (!f)
return false;

if (!f.WriteString(json))
return false;

return true;
}
} // namespace DiscIO
3 changes: 3 additions & 0 deletions Source/Core/DiscIO/GameModDescriptor.h
Expand Up @@ -43,4 +43,7 @@ struct GameModDescriptor
std::optional<GameModDescriptor> ParseGameModDescriptorFile(const std::string& filename);
std::optional<GameModDescriptor> ParseGameModDescriptorString(std::string_view json,
std::string_view json_path);
std::string WriteGameModDescriptorString(const GameModDescriptor& descriptor, bool pretty);
bool WriteGameModDescriptorFile(const std::string& filename, const GameModDescriptor& descriptor,
bool pretty);
} // namespace DiscIO
14 changes: 8 additions & 6 deletions Source/Core/DolphinQt/GameList/GameList.cpp
Expand Up @@ -352,16 +352,17 @@ void GameList::ShowContextMenu(const QPoint&)
else
{
const auto game = GetSelectedGame();
const bool is_mod_descriptor = game->IsModDescriptor();
DiscIO::Platform platform = game->GetPlatform();
menu->addAction(tr("&Properties"), this, &GameList::OpenProperties);
if (platform != DiscIO::Platform::ELFOrDOL)
if (!is_mod_descriptor && platform != DiscIO::Platform::ELFOrDOL)
{
menu->addAction(tr("&Wiki"), this, &GameList::OpenWiki);
}

menu->addSeparator();

if (DiscIO::IsDisc(platform))
if (!is_mod_descriptor && DiscIO::IsDisc(platform))
{
menu->addAction(tr("Start with Riivolution Patches..."), this,
&GameList::StartWithRiivolution);
Expand All @@ -382,7 +383,7 @@ void GameList::ShowContextMenu(const QPoint&)
menu->addSeparator();
}

if (platform == DiscIO::Platform::WiiDisc)
if (!is_mod_descriptor && platform == DiscIO::Platform::WiiDisc)
{
auto* perform_disc_update = menu->addAction(tr("Perform System Update"), this,
[this, file_path = game->GetFilePath()] {
Expand All @@ -394,7 +395,7 @@ void GameList::ShowContextMenu(const QPoint&)
perform_disc_update->setEnabled(!Core::IsRunning() || !SConfig::GetInstance().bWii);
}

if (platform == DiscIO::Platform::WiiWAD)
if (!is_mod_descriptor && platform == DiscIO::Platform::WiiWAD)
{
QAction* wad_install_action = new QAction(tr("Install to the NAND"), menu);
QAction* wad_uninstall_action = new QAction(tr("Uninstall from the NAND"), menu);
Expand All @@ -420,14 +421,15 @@ void GameList::ShowContextMenu(const QPoint&)
menu->addSeparator();
}

if (platform == DiscIO::Platform::WiiWAD || platform == DiscIO::Platform::WiiDisc)
if (!is_mod_descriptor &&
(platform == DiscIO::Platform::WiiWAD || platform == DiscIO::Platform::WiiDisc))
{
menu->addAction(tr("Open Wii &Save Folder"), this, &GameList::OpenWiiSaveFolder);
menu->addAction(tr("Export Wii Save"), this, &GameList::ExportWiiSave);
menu->addSeparator();
}

if (platform == DiscIO::Platform::GameCubeDisc)
if (!is_mod_descriptor && platform == DiscIO::Platform::GameCubeDisc)
{
menu->addAction(tr("Open GameCube &Save Folder"), this, &GameList::OpenGCSaveFolder);
menu->addSeparator();
Expand Down
2 changes: 1 addition & 1 deletion Source/Core/DolphinQt/GameList/GameTracker.cpp
Expand Up @@ -27,7 +27,7 @@ static const QStringList game_filters{
QStringLiteral("*.[gG][cC][zZ]"), QStringLiteral("*.[wW][bB][fF][sS]"),
QStringLiteral("*.[wW][iI][aA]"), QStringLiteral("*.[rR][vV][zZ]"),
QStringLiteral("*.[wW][aA][dD]"), QStringLiteral("*.[eE][lL][fF]"),
QStringLiteral("*.[dD][oO][lL]")};
QStringLiteral("*.[dD][oO][lL]"), QStringLiteral("*.[jJ][sS][oO][nN]")};

GameTracker::GameTracker(QObject* parent) : QFileSystemWatcher(parent)
{
Expand Down
8 changes: 5 additions & 3 deletions Source/Core/DolphinQt/MainWindow.cpp
Expand Up @@ -733,8 +733,10 @@ QStringList MainWindow::PromptFileNames()
QStringList paths = DolphinFileDialog::getOpenFileNames(
this, tr("Select a File"),
settings.value(QStringLiteral("mainwindow/lastdir"), QString{}).toString(),
tr("All GC/Wii files (*.elf *.dol *.gcm *.iso *.tgc *.wbfs *.ciso *.gcz *.wia *.rvz *.wad "
"*.dff *.m3u);;All Files (*)"));
QStringLiteral("%1 (*.elf *.dol *.gcm *.iso *.tgc *.wbfs *.ciso *.gcz *.wia *.rvz *.wad "
"*.dff *.m3u *.json);;%2 (*)")
.arg(tr("All GC/Wii files"))
.arg(tr("All Files")));

if (!paths.isEmpty())
{
Expand Down Expand Up @@ -1845,7 +1847,7 @@ void MainWindow::ShowRiivolutionBootWidget(const UICommon::GameFile& game)

auto& disc = std::get<BootParameters::Disc>(boot_params->parameters);
RiivolutionBootWidget w(disc.volume->GetGameID(), disc.volume->GetRevision(),
disc.volume->GetDiscNumber(), this);
disc.volume->GetDiscNumber(), game.GetFilePath(), this);
w.exec();
if (!w.ShouldBoot())
return;
Expand Down
4 changes: 2 additions & 2 deletions Source/Core/DolphinQt/NetPlay/NetPlayDialog.cpp
Expand Up @@ -1179,9 +1179,9 @@ void NetPlayDialog::SetChunkedProgress(const int pid, const u64 progress)
});
}

void NetPlayDialog::SetHostWiiSyncTitles(std::vector<u64> titles)
void NetPlayDialog::SetHostWiiSyncData(std::vector<u64> titles, std::string redirect_folder)
{
auto client = Settings::Instance().GetNetPlayClient();
if (client)
client->SetWiiSyncData(nullptr, std::move(titles));
client->SetWiiSyncData(nullptr, std::move(titles), std::move(redirect_folder));
}
2 changes: 1 addition & 1 deletion Source/Core/DolphinQt/NetPlay/NetPlayDialog.h
Expand Up @@ -95,7 +95,7 @@ class NetPlayDialog : public QDialog, public NetPlay::NetPlayUI
void HideChunkedProgressDialog() override;
void SetChunkedProgress(int pid, u64 progress) override;

void SetHostWiiSyncTitles(std::vector<u64> titles) override;
void SetHostWiiSyncData(std::vector<u64> titles, std::string redirect_folder) override;

signals:
void Stop();
Expand Down
66 changes: 61 additions & 5 deletions Source/Core/DolphinQt/RiivolutionBootWidget.cpp
Expand Up @@ -23,6 +23,7 @@

#include "Common/FileSearch.h"
#include "Common/FileUtil.h"
#include "DiscIO/GameModDescriptor.h"
#include "DiscIO/RiivolutionParser.h"
#include "DiscIO/RiivolutionPatcher.h"
#include "DolphinQt/QtUtils/ModalMessageBox.h"
Expand All @@ -38,8 +39,10 @@ struct GuiRiivolutionPatchIndex
Q_DECLARE_METATYPE(GuiRiivolutionPatchIndex);

RiivolutionBootWidget::RiivolutionBootWidget(std::string game_id, std::optional<u16> revision,
std::optional<u8> disc, QWidget* parent)
: QDialog(parent), m_game_id(std::move(game_id)), m_revision(revision), m_disc_number(disc)
std::optional<u8> disc, std::string base_game_path,
QWidget* parent)
: QDialog(parent), m_game_id(std::move(game_id)), m_revision(revision), m_disc_number(disc),
m_base_game_path(std::move(base_game_path))
{
setWindowTitle(tr("Start with Riivolution Patches"));
setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint);
Expand All @@ -57,6 +60,7 @@ void RiivolutionBootWidget::CreateWidgets()
auto* open_xml_button = new QPushButton(tr("Open Riivolution XML..."));
auto* boot_game_button = new QPushButton(tr("Start"));
boot_game_button->setDefault(true);
auto* save_preset_button = new QPushButton(tr("Save as Preset..."));
auto* group_box = new QGroupBox();
auto* scroll_area = new QScrollArea();

Expand All @@ -71,6 +75,7 @@ void RiivolutionBootWidget::CreateWidgets()
auto* button_layout = new QHBoxLayout();
button_layout->addStretch();
button_layout->addWidget(open_xml_button, 0, Qt::AlignRight);
button_layout->addWidget(save_preset_button, 0, Qt::AlignRight);
button_layout->addWidget(boot_game_button, 0, Qt::AlignRight);

auto* layout = new QVBoxLayout();
Expand All @@ -80,6 +85,7 @@ void RiivolutionBootWidget::CreateWidgets()

connect(open_xml_button, &QPushButton::clicked, this, &RiivolutionBootWidget::OpenXML);
connect(boot_game_button, &QPushButton::clicked, this, &RiivolutionBootWidget::BootGame);
connect(save_preset_button, &QPushButton::clicked, this, &RiivolutionBootWidget::SaveAsPreset);
}

void RiivolutionBootWidget::LoadMatchingXMLs()
Expand Down Expand Up @@ -144,13 +150,14 @@ void RiivolutionBootWidget::OpenXML()
}
}

void RiivolutionBootWidget::MakeGUIForParsedFile(const std::string& path, std::string root,
void RiivolutionBootWidget::MakeGUIForParsedFile(std::string path, std::string root,
DiscIO::Riivolution::Disc input_disc)
{
const size_t disc_index = m_discs.size();
const auto& disc = m_discs.emplace_back(DiscWithRoot{std::move(input_disc), std::move(root)});
const auto& disc =
m_discs.emplace_back(DiscWithRoot{std::move(input_disc), std::move(root), std::move(path)});

auto* disc_box = new QGroupBox(QFileInfo(QString::fromStdString(path)).fileName());
auto* disc_box = new QGroupBox(QFileInfo(QString::fromStdString(disc.path)).fileName());
auto* disc_layout = new QVBoxLayout();
disc_box->setLayout(disc_layout);

Expand Down Expand Up @@ -279,3 +286,52 @@ void RiivolutionBootWidget::BootGame()
m_should_boot = true;
close();
}

void RiivolutionBootWidget::SaveAsPreset()
{
DiscIO::GameModDescriptor descriptor;
descriptor.base_file = m_base_game_path;

DiscIO::GameModDescriptorRiivolution riivolution_descriptor;
for (const auto& disc : m_discs)
{
// filter out XMLs that don't actually contribute to the preset
auto patches = disc.disc.GeneratePatches(m_game_id);
if (patches.empty())
continue;

auto& descriptor_patch = riivolution_descriptor.patches.emplace_back();
descriptor_patch.xml = disc.path;
descriptor_patch.root = disc.root;
for (const auto& section : disc.disc.m_sections)
{
for (const auto& option : section.m_options)
{
auto& descriptor_option = descriptor_patch.options.emplace_back();
descriptor_option.section_name = section.m_name;
if (!option.m_id.empty())
descriptor_option.option_id = option.m_id;
else
descriptor_option.option_name = option.m_name;
descriptor_option.choice = option.m_selected_choice;
}
}
}

if (!riivolution_descriptor.patches.empty())
descriptor.riivolution = std::move(riivolution_descriptor);

QDir dir = QFileInfo(QString::fromStdString(m_base_game_path)).dir();
QString target_path = QFileDialog::getSaveFileName(this, tr("Save Preset"), dir.absolutePath(),
QStringLiteral("%1 (*.json);;%2 (*)")
.arg(tr("Dolphin Game Mod Preset"))
.arg(tr("All Files")));
if (target_path.isEmpty())
return;

descriptor.display_name = QFileInfo(target_path).fileName().toStdString();
auto dot = descriptor.display_name.rfind('.');
if (dot != std::string::npos)
descriptor.display_name = descriptor.display_name.substr(0, dot);
DiscIO::WriteGameModDescriptorFile(target_path.toStdString(), descriptor, true);
}
8 changes: 6 additions & 2 deletions Source/Core/DolphinQt/RiivolutionBootWidget.h
Expand Up @@ -19,7 +19,8 @@ class RiivolutionBootWidget : public QDialog
Q_OBJECT
public:
explicit RiivolutionBootWidget(std::string game_id, std::optional<u16> revision,
std::optional<u8> disc, QWidget* parent = nullptr);
std::optional<u8> disc, std::string base_game_path,
QWidget* parent = nullptr);
~RiivolutionBootWidget();

bool ShouldBoot() const { return m_should_boot; }
Expand All @@ -30,21 +31,24 @@ class RiivolutionBootWidget : public QDialog

void LoadMatchingXMLs();
void OpenXML();
void MakeGUIForParsedFile(const std::string& path, std::string root,
void MakeGUIForParsedFile(std::string path, std::string root,
DiscIO::Riivolution::Disc input_disc);
std::optional<DiscIO::Riivolution::Config> LoadConfigXML(const std::string& root_directory);
void SaveConfigXMLs();
void BootGame();
void SaveAsPreset();

std::string m_game_id;
std::optional<u16> m_revision;
std::optional<u8> m_disc_number;
std::string m_base_game_path;

bool m_should_boot = false;
struct DiscWithRoot
{
DiscIO::Riivolution::Disc disc;
std::string root;
std::string path;
};
std::vector<DiscWithRoot> m_discs;
std::vector<DiscIO::Riivolution::Patch> m_patches;
Expand Down
6 changes: 4 additions & 2 deletions Source/Core/DolphinQt/Settings/PathPane.cpp
Expand Up @@ -44,8 +44,10 @@ void PathPane::BrowseDefaultGame()
{
QString file = QDir::toNativeSeparators(DolphinFileDialog::getOpenFileName(
this, tr("Select a Game"), Settings::Instance().GetDefaultGame(),
tr("All GC/Wii files (*.elf *.dol *.gcm *.iso *.tgc *.wbfs "
"*.ciso *.gcz *.wia *.rvz *.wad *.m3u);;All Files (*)")));
QStringLiteral("%1 (*.elf *.dol *.gcm *.iso *.tgc *.wbfs *.ciso *.gcz *.wia *.rvz *.wad "
"*.m3u *.json);;%2 (*)")
.arg(tr("All GC/Wii files"))
.arg(tr("All Files"))));

if (!file.isEmpty())
Settings::Instance().SetDefaultGame(file);
Expand Down
126 changes: 119 additions & 7 deletions Source/Core/UICommon/GameFile.cpp
Expand Up @@ -22,6 +22,7 @@
#include <mbedtls/sha1.h>
#include <pugixml.hpp>

#include "Common/BitUtils.h"
#include "Common/ChunkFile.h"
#include "Common/CommonPaths.h"
#include "Common/CommonTypes.h"
Expand All @@ -43,6 +44,7 @@
#include "DiscIO/Blob.h"
#include "DiscIO/DiscExtractor.h"
#include "DiscIO/Enums.h"
#include "DiscIO/GameModDescriptor.h"
#include "DiscIO/Volume.h"
#include "DiscIO/WiiSaveBanner.h"

Expand Down Expand Up @@ -163,6 +165,32 @@ GameFile::GameFile(std::string path) : m_file_path(std::move(path))
m_platform = DiscIO::Platform::ELFOrDOL;
m_blob_type = DiscIO::BlobType::DIRECTORY;
}

if (!IsValid() && GetExtension() == ".json")
{
auto descriptor = DiscIO::ParseGameModDescriptorFile(m_file_path);
if (descriptor)
{
GameFile proxy(descriptor->base_file);
if (proxy.IsValid())
{
m_valid = true;
m_file_size = File::GetSize(m_file_path);
m_long_names.emplace(DiscIO::Language::English, std::move(descriptor->display_name));
m_internal_name = proxy.GetInternalName();
m_game_id = proxy.GetGameID();
m_gametdb_id = proxy.GetGameTDBID();
m_title_id = proxy.GetTitleID();
m_maker_id = proxy.GetMakerID();
m_region = proxy.GetRegion();
m_country = proxy.GetCountry();
m_platform = proxy.GetPlatform();
m_revision = proxy.GetRevision();
m_disc_number = proxy.GetDiscNumber();
m_blob_type = DiscIO::BlobType::MOD_DESCRIPTOR;
}
}
}
}

GameFile::~GameFile() = default;
Expand Down Expand Up @@ -470,6 +498,18 @@ bool GameFile::ReadPNGBanner(const std::string& path)
return true;
}

bool GameFile::TryLoadGameModDescriptorBanner()
{
if (m_blob_type != DiscIO::BlobType::MOD_DESCRIPTOR)
return false;

auto descriptor = DiscIO::ParseGameModDescriptorFile(m_file_path);
if (!descriptor)
return false;

return ReadPNGBanner(descriptor->banner);
}

bool GameFile::CustomBannerChanged()
{
std::string path, name;
Expand All @@ -482,8 +522,12 @@ bool GameFile::CustomBannerChanged()
// Homebrew Channel icon naming. Typical for DOLs and ELFs, but we also support it for volumes.
if (!ReadPNGBanner(path + "icon.png"))
{
// If no custom icon is found, go back to the non-custom one.
m_pending.custom_banner = {};
// If it's a game mod descriptor file, it may specify its own custom banner.
if (!TryLoadGameModDescriptorBanner())
{
// If no custom icon is found, go back to the non-custom one.
m_pending.custom_banner = {};
}
}
}

Expand All @@ -499,6 +543,8 @@ const std::string& GameFile::GetName(const Core::TitleDatabase& title_database)
{
if (!m_custom_name.empty())
return m_custom_name;
if (IsModDescriptor())
return GetName(Variant::LongAndPossiblyCustom);

const std::string& database_name = title_database.GetTitleName(m_gametdb_id, GetConfigLanguage());
return database_name.empty() ? GetName(Variant::LongAndPossiblyCustom) : database_name;
Expand Down Expand Up @@ -579,23 +625,83 @@ std::string GameFile::GetNetPlayName(const Core::TitleDatabase& title_database)
return name + " (" + ss.str() + ")";
}

static std::array<u8, 20> GetHash(u32 value)
{
auto data = Common::BitCastToArray<u8>(value);
std::array<u8, 20> hash;
mbedtls_sha1_ret(reinterpret_cast<const unsigned char*>(data.data()), data.size(), hash.data());
return hash;
}

static std::array<u8, 20> GetHash(std::string_view str)
{
std::array<u8, 20> hash;
mbedtls_sha1_ret(reinterpret_cast<const unsigned char*>(str.data()), str.size(), hash.data());
return hash;
}

static std::optional<std::array<u8, 20>> GetFileHash(const std::string& path)
{
std::string buffer;
if (!File::ReadFileToString(path, buffer))
return std::nullopt;
return GetHash(buffer);
}

static std::optional<std::array<u8, 20>> MixHash(const std::optional<std::array<u8, 20>>& lhs,
const std::optional<std::array<u8, 20>>& rhs)
{
if (!lhs && !rhs)
return std::nullopt;
if (!lhs || !rhs)
return !rhs ? lhs : rhs;
std::array<u8, 20> result;
for (size_t i = 0; i < result.size(); ++i)
result[i] = (*lhs)[i] ^ (*rhs)[(i + 1) % result.size()];
return result;
}

std::array<u8, 20> GameFile::GetSyncHash() const
{
std::array<u8, 20> hash{};
std::optional<std::array<u8, 20>> hash;

if (m_platform == DiscIO::Platform::ELFOrDOL)
{
std::string buffer;
if (File::ReadFileToString(m_file_path, buffer))
mbedtls_sha1_ret(reinterpret_cast<unsigned char*>(buffer.data()), buffer.size(), hash.data());
hash = GetFileHash(m_file_path);
}
else if (m_blob_type == DiscIO::BlobType::MOD_DESCRIPTOR)
{
auto descriptor = DiscIO::ParseGameModDescriptorFile(m_file_path);
if (descriptor)
{
GameFile proxy(descriptor->base_file);
if (proxy.IsValid())
hash = proxy.GetSyncHash();

// add patches to hash if they're enabled
if (descriptor->riivolution)
{
for (const auto& patch : descriptor->riivolution->patches)
{
hash = MixHash(hash, GetFileHash(patch.xml));
for (const auto& option : patch.options)
{
hash = MixHash(hash, GetHash(option.section_name));
hash = MixHash(hash, GetHash(option.option_id));
hash = MixHash(hash, GetHash(option.option_name));
hash = MixHash(hash, GetHash(option.choice));
}
}
}
}
}
else
{
if (std::unique_ptr<DiscIO::Volume> volume = DiscIO::CreateVolume(m_file_path))
hash = volume->GetSyncHash();
}

return hash;
return hash.value_or(std::array<u8, 20>{});
}

NetPlay::SyncIdentifier GameFile::GetSyncIdentifier() const
Expand Down Expand Up @@ -652,6 +758,7 @@ bool GameFile::ShouldShowFileFormatDetails() const
case DiscIO::BlobType::PLAIN:
break;
case DiscIO::BlobType::DRIVE:
case DiscIO::BlobType::MOD_DESCRIPTOR:
return false;
default:
return true;
Expand Down Expand Up @@ -699,6 +806,11 @@ bool GameFile::ShouldAllowConversion() const
return DiscIO::IsDisc(m_platform) && m_volume_size_is_accurate;
}

bool GameFile::IsModDescriptor() const
{
return m_blob_type == DiscIO::BlobType::MOD_DESCRIPTOR;
}

const GameBanner& GameFile::GetBannerImage() const
{
return m_custom_banner.empty() ? m_volume_banner : m_custom_banner;
Expand Down
2 changes: 2 additions & 0 deletions Source/Core/UICommon/GameFile.h
Expand Up @@ -107,6 +107,7 @@ class GameFile final
bool IsVolumeSizeAccurate() const { return m_volume_size_is_accurate; }
bool IsDatelDisc() const { return m_is_datel_disc; }
bool IsNKit() const { return m_is_nkit; }
bool IsModDescriptor() const;
const GameBanner& GetBannerImage() const;
const GameCover& GetCoverImage() const;
void DoState(PointerWrap& p);
Expand All @@ -132,6 +133,7 @@ class GameFile final
bool IsElfOrDol() const;
bool ReadXMLMetadata(const std::string& path);
bool ReadPNGBanner(const std::string& path);
bool TryLoadGameModDescriptorBanner();

// IMPORTANT: Nearly all data members must be save/restored in DoState.
// If anything is changed, make sure DoState handles it properly and
Expand Down
7 changes: 4 additions & 3 deletions Source/Core/UICommon/GameFileCache.cpp
Expand Up @@ -27,13 +27,14 @@

namespace UICommon
{
static constexpr u32 CACHE_REVISION = 20; // Last changed in PR 9461
static constexpr u32 CACHE_REVISION = 21; // Last changed in PR 10187

std::vector<std::string> FindAllGamePaths(const std::vector<std::string>& directories_to_scan,
bool recursive_scan)
{
static const std::vector<std::string> search_extensions = {
".gcm", ".tgc", ".iso", ".ciso", ".gcz", ".wbfs", ".wia", ".rvz", ".wad", ".dol", ".elf"};
static const std::vector<std::string> search_extensions = {".gcm", ".tgc", ".iso", ".ciso",
".gcz", ".wbfs", ".wia", ".rvz",
".wad", ".dol", ".elf", ".json"};

// TODO: We could process paths iteratively as they are found
return Common::DoFileSearch(directories_to_scan, search_extensions, recursive_scan);
Expand Down
66 changes: 66 additions & 0 deletions docs/game-mod-descriptor.json
@@ -0,0 +1,66 @@
{
"$schema": "https://raw.githubusercontent.com/dolphin-emu/dolphin/master/docs/game-mod-descriptor.json",
"title": "Dolphin Game Mod Descriptor",
"type": "object",
"required": ["type", "version", "base-file"],
"properties": {
"type": {
"type": "string",
"pattern": "^dolphin-game-mod-descriptor$"
},
"version": {
"type": "integer"
},
"base-file": {
"type": "string"
},
"display-name": {
"type": "string"
},
"banner": {
"type": "string"
},
"riivolution": {
"type": "object",
"required": ["patches"],
"properties": {
"patches": {
"type": "array",
"items": {
"type": "object",
"required": ["xml", "root", "options"],
"properties": {
"xml": {
"type": "string"
},
"root": {
"type": "string"
},
"options": {
"type": "array",
"items": {
"type": "object",
"required": ["choice"],
"properties": {
"section-name": {
"type": "string"
},
"option-id": {
"type": "string"
},
"option-name": {
"type": "string"
},
"choice": {
"type": "integer"
}
}
}
}
}
}
}
}
}
}
}