From f60f80b631dfbc0947bc8638b7858bbf122f78f0 Mon Sep 17 00:00:00 2001 From: nmzik Date: Mon, 27 Jul 2026 16:19:31 +0200 Subject: [PATCH] launcher: add local game patches (#111) launcher: add game patches --- src/emulator.cpp | 11 +- src/emulator.h | 1 + .../forms/configuration_list_widget.ui | 5 + src/launcher/include/configuration.h | 2 + src/launcher/include/patchesDialog.h | 34 +++ src/launcher/src/configurationItem.cpp | 14 +- src/launcher/src/configurationListWidget.cpp | 26 +- src/launcher/src/mainDialog.cpp | 6 + src/launcher/src/patchesDialog.cpp | 103 +++++++ src/loader/gamePatch.cpp | 265 ++++++++++++++++++ src/loader/gamePatch.h | 20 ++ src/loader/runtimeLinker.cpp | 6 +- src/loader/runtimeLinker.h | 2 +- src/main.cpp | 16 +- 14 files changed, 497 insertions(+), 14 deletions(-) create mode 100644 src/launcher/include/patchesDialog.h create mode 100644 src/launcher/src/patchesDialog.cpp create mode 100644 src/loader/gamePatch.cpp create mode 100644 src/loader/gamePatch.h diff --git a/src/emulator.cpp b/src/emulator.cpp index 54fb5cfab..60854ee18 100644 --- a/src/emulator.cpp +++ b/src/emulator.cpp @@ -159,13 +159,14 @@ static void LoadElf(const std::filesystem::path& elf, bool dbg_print_reloc = fal } } -static void Execute() { +static void Execute(const std::filesystem::path& game_patch) { + auto patch_path = game_patch; Common::Thread guest_thread( - [](void* /*unused*/) { + [](void* param) { auto* rt = Common::Singleton::Instance(); - rt->Execute(); + rt->Execute(*static_cast(param)); }, - nullptr); + &patch_path); Libs::Graphics::WindowRun(); std::quick_exit(0); } @@ -207,7 +208,7 @@ void Run(const RunOptions& options) { LoadElf(options.elf); - Execute(); + Execute(options.game_patch); } } // namespace Emulator diff --git a/src/emulator.h b/src/emulator.h index fab32fe82..cf8f345f2 100644 --- a/src/emulator.h +++ b/src/emulator.h @@ -12,6 +12,7 @@ struct RunOptions { Config::ConfigOptions config; std::filesystem::path app0_dir; std::filesystem::path elf; + std::filesystem::path game_patch; }; void Run(const RunOptions& options); diff --git a/src/launcher/forms/configuration_list_widget.ui b/src/launcher/forms/configuration_list_widget.ui index 6bed94c49..0be1d1c4d 100644 --- a/src/launcher/forms/configuration_list_widget.ui +++ b/src/launcher/forms/configuration_list_widget.ui @@ -183,6 +183,11 @@ Serial + + + Game Version + + Firmware Version diff --git a/src/launcher/include/configuration.h b/src/launcher/include/configuration.h index 74fb81e2f..17e2f6790 100644 --- a/src/launcher/include/configuration.h +++ b/src/launcher/include/configuration.h @@ -73,6 +73,7 @@ public: QString name; QString title_id; /* Serial / title id from sce_sys/param.json */ + QString gameVersion; /* appVersion / contentVersion from sce_sys/param.json */ QString firmwareVer; /* requiredSystemSoftwareVersion from sce_sys/param.json */ QString basedir; /* Game base directory */ QString game_path; /* Launcher-unique game path */ @@ -117,6 +118,7 @@ public: void CopyFrom(const Configuration& other) { name = other.name; title_id = other.title_id; + gameVersion = other.gameVersion; firmwareVer = other.firmwareVer; basedir = other.basedir; game_path = other.game_path; diff --git a/src/launcher/include/patchesDialog.h b/src/launcher/include/patchesDialog.h new file mode 100644 index 000000000..84c9b0447 --- /dev/null +++ b/src/launcher/include/patchesDialog.h @@ -0,0 +1,34 @@ +#ifndef PATCHES_DIALOG_H +#define PATCHES_DIALOG_H + +#include "common.h" + +#include +#include + +class Configuration; +class QLabel; +class QListWidget; +class QPushButton; + +class PatchesDialog final: public QDialog { +public: + explicit PatchesDialog(const Configuration& game, QWidget* parent = nullptr); + ~PatchesDialog() override = default; + + KYTY_QT_CLASS_NO_COPY(PatchesDialog); + + [[nodiscard]] static bool IsSupportedTitleId(const QString& title_id); + [[nodiscard]] static QString PatchPlanPath(const QString& title_id); + +private: + void Load(); + void Save(); + + QString m_title_id; + QListWidget* m_patches = nullptr; + QLabel* m_status = nullptr; + QPushButton* m_apply = nullptr; +}; + +#endif // PATCHES_DIALOG_H diff --git a/src/launcher/src/configurationItem.cpp b/src/launcher/src/configurationItem.cpp index 8901c5b0d..510729b4d 100644 --- a/src/launcher/src/configurationItem.cpp +++ b/src/launcher/src/configurationItem.cpp @@ -24,6 +24,7 @@ namespace { enum Column { NameColumn, SerialColumn, + GameVersionColumn, FirmwareVersionColumn, PathColumn, StatusColumn, @@ -85,6 +86,9 @@ QString GetDisplayText(const Configuration& info) { if (!info.title_id.isEmpty()) { lines.append(QStringLiteral("Serial ID: %1").arg(info.title_id)); } + if (!info.gameVersion.isEmpty()) { + lines.append(QStringLiteral("Game version: %1").arg(info.gameVersion)); + } if (!info.firmwareVer.isEmpty()) { lines.append(QStringLiteral("Firmware version: %1").arg(info.firmwareVer)); } @@ -169,6 +173,8 @@ void ConfigurationItem::Update() { setText(NameColumn, m_info->name); setText(SerialColumn, m_info->title_id); + setText(GameVersionColumn, + m_info->gameVersion.isEmpty() ? QStringLiteral("\u2014") : m_info->gameVersion); setText(FirmwareVersionColumn, m_info->firmwareVer.isEmpty() ? QStringLiteral("\u2014") : m_info->firmwareVer); setText(PathColumn, path); @@ -198,9 +204,13 @@ bool ConfigurationItem::operator<(const QTreeWidgetItem& other) const { case StatusColumn: return GetStatusText(m_info->game_status) < GetStatusText(other_item->m_info->game_status); + case GameVersionColumn: case FirmwareVersionColumn: { - const auto& version = m_info->firmwareVer; - const auto& other_version = other_item->m_info->firmwareVer; + const auto& version = column == GameVersionColumn ? m_info->gameVersion + : m_info->firmwareVer; + const auto& other_version = column == GameVersionColumn + ? other_item->m_info->gameVersion + : other_item->m_info->firmwareVer; if (version.isEmpty() || other_version.isEmpty()) { return version.isEmpty() && !other_version.isEmpty(); } diff --git a/src/launcher/src/configurationListWidget.cpp b/src/launcher/src/configurationListWidget.cpp index b25cb5009..af0f093f4 100644 --- a/src/launcher/src/configurationListWidget.cpp +++ b/src/launcher/src/configurationListWidget.cpp @@ -1,5 +1,6 @@ #include "configurationListWidget.h" +#include "patchesDialog.h" #include "common.h" #include "compatibilityDatabase.h" #include "configuration.h" @@ -55,10 +56,11 @@ constexpr char SAVE_DATA_DIR[] = "_SaveData"; constexpr int GAME_NAME_COLUMN = 0; constexpr int GAME_SERIAL_COLUMN = 1; -constexpr int GAME_FIRMWARE_VERSION_COLUMN = 2; -constexpr int GAME_PATH_COLUMN = 3; -constexpr int GAME_STATUS_COLUMN = 4; -constexpr int GAME_COMMENT_COLUMN = 5; +constexpr int GAME_VERSION_COLUMN = 2; +constexpr int GAME_FIRMWARE_VERSION_COLUMN = 3; +constexpr int GAME_PATH_COLUMN = 4; +constexpr int GAME_STATUS_COLUMN = 5; +constexpr int GAME_COMMENT_COLUMN = 6; static QString NormalizeGameDirectory(const QString& dir) { const auto trimmed = dir.trimmed(); @@ -239,6 +241,7 @@ ConfigurationListWidget::ConfigurationListWidget(QWidget* parent) m_ui->cfgs_list->setSortingEnabled(true); m_ui->cfgs_list->setColumnWidth(GAME_NAME_COLUMN, 320); m_ui->cfgs_list->setColumnWidth(GAME_SERIAL_COLUMN, 110); + m_ui->cfgs_list->setColumnWidth(GAME_VERSION_COLUMN, 120); m_ui->cfgs_list->setColumnWidth(GAME_FIRMWARE_VERSION_COLUMN, 150); m_ui->cfgs_list->setColumnWidth(GAME_PATH_COLUMN, 320); m_ui->cfgs_list->setColumnWidth(GAME_STATUS_COLUMN, 150); @@ -403,6 +406,7 @@ static Configuration* CloneConfiguration(const Configuration& source) { struct GameMetadata { QString title_name; QString title_id; + QString gameVersion; QString firmwareVer; }; @@ -488,6 +492,10 @@ static GameMetadata GetGameMetadata(const QString& param_file, const QString& fa } ret.title_id = GetJsonString(root, QStringLiteral("titleId")); + ret.gameVersion = GetJsonString(root, QStringLiteral("appVersion")); + if (ret.gameVersion.isEmpty()) { + ret.gameVersion = GetJsonString(root, QStringLiteral("contentVersion")); + } ret.firmwareVer = GetFirmwareVersion(root); return ret; @@ -501,6 +509,7 @@ static void SetGameFiles(Configuration& info, const QString& game_dir, const QSt info.basedir = game.absolutePath(); info.name = metadata.title_name; info.title_id = metadata.title_id; + info.gameVersion = metadata.gameVersion; info.firmwareVer = metadata.firmwareVer; if (info.name.isEmpty()) { @@ -866,6 +875,15 @@ void ConfigurationListWidget::show_context_menu(const QPoint& pos) { style()->standardIcon(QStyle::SP_FileDialogContentsView), tr("View trophies...")); connect(action_view_trophies, &QAction::triggered, this, &ConfigurationListWidget::ViewTrophies); + QAction* action_patches = menu.addAction(tr("Patches (experimental)...")); + connect(action_patches, &QAction::triggered, this, [this, item]() { + if (item != nullptr) { + auto* dialog = new PatchesDialog(item->GetInfo(), this); + dialog->show(); + } + }); + action_patches->setVisible(item != nullptr && + PatchesDialog::IsSupportedTitleId(item->GetInfo().title_id)); QAction* action_remove_save_data = menu.addAction(style()->standardIcon(QStyle::SP_DialogDiscardButton), tr("Remove save data..."), this, SLOT(remove_save_data())); diff --git a/src/launcher/src/mainDialog.cpp b/src/launcher/src/mainDialog.cpp index 1d97a29f0..75c4f85a3 100644 --- a/src/launcher/src/mainDialog.cpp +++ b/src/launcher/src/mainDialog.cpp @@ -4,6 +4,7 @@ #include "configuration.h" #include "configurationItem.h" #include "configurationListWidget.h" +#include "patchesDialog.h" #include #include @@ -228,6 +229,11 @@ static QStringList CreateEmulatorArgs(const Configuration& info) { } args << "--game" << game; + const auto patch_plan = PatchesDialog::PatchPlanPath(info.title_id); + if (QFileInfo::exists(patch_plan)) { + args << "--game-patch" << patch_plan; + } + return args; } diff --git a/src/launcher/src/patchesDialog.cpp b/src/launcher/src/patchesDialog.cpp new file mode 100644 index 000000000..efa1874d9 --- /dev/null +++ b/src/launcher/src/patchesDialog.cpp @@ -0,0 +1,103 @@ +#include "patchesDialog.h" + +#include "configuration.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +PatchesDialog::PatchesDialog(const Configuration& game, QWidget* parent) + : QDialog(parent), m_title_id(game.title_id.trimmed().toUpper()) { + setAttribute(Qt::WA_DeleteOnClose); + setWindowTitle(tr("Patches (Experimental) - %1").arg(game.name)); + resize(640, 480); + + auto* layout = new QVBoxLayout(this); + m_patches = new QListWidget(this); + m_status = new QLabel(this); + m_apply = new QPushButton(tr("Apply selection"), this); + auto* close = new QPushButton(tr("Close"), this); + + m_status->setWordWrap(true); + layout->addWidget(m_patches); + layout->addWidget(m_status); + + auto* buttons = new QDialogButtonBox(this); + buttons->addButton(m_apply, QDialogButtonBox::ActionRole); + buttons->addButton(close, QDialogButtonBox::RejectRole); + layout->addWidget(buttons); + + connect(m_apply, &QPushButton::clicked, this, &PatchesDialog::Save); + connect(close, &QPushButton::clicked, this, &QDialog::close); + + Load(); +} + +bool PatchesDialog::IsSupportedTitleId(const QString& title_id) { + return title_id.trimmed().toUpper().startsWith(QStringLiteral("PPSA")); +} + +QString PatchesDialog::PatchPlanPath(const QString& title_id) { + return QDir(QCoreApplication::applicationDirPath()) + .filePath(QStringLiteral("_Patches/%1.json").arg(title_id.trimmed().toUpper())); +} + +void PatchesDialog::Load() { + QFile file(PatchPlanPath(m_title_id)); + if (!file.open(QIODevice::ReadOnly)) { + m_status->setText(tr("No local patch file: %1").arg(file.fileName())); + m_apply->setEnabled(false); + return; + } + + const auto patches = QJsonDocument::fromJson(file.readAll()) + .object() + .value(QStringLiteral("patches")) + .toArray(); + for (const auto& value: patches) { + const auto patch = value.toObject(); + auto* item = new QListWidgetItem(patch.value(QStringLiteral("name")).toString(), m_patches); + item->setFlags(item->flags() | Qt::ItemIsUserCheckable); + item->setCheckState(patch.value(QStringLiteral("enabled")).toBool(true) ? Qt::Checked + : Qt::Unchecked); + } + + m_apply->setEnabled(!patches.isEmpty()); + m_status->setText(tr("Loaded %1 patch(es) from %2.").arg(patches.size()).arg(file.fileName())); +} + +void PatchesDialog::Save() { + const auto path = PatchPlanPath(m_title_id); + QFile input(path); + if (!input.open(QIODevice::ReadOnly)) { + return; + } + + auto document = QJsonDocument::fromJson(input.readAll()); + input.close(); + auto root = document.object(); + auto patches = root.value(QStringLiteral("patches")).toArray(); + for (int index = 0; index < patches.size(); index++) { + auto patch = patches[index].toObject(); + patch.insert(QStringLiteral("enabled"), + m_patches->item(index)->checkState() == Qt::Checked); + patches[index] = patch; + } + root.insert(QStringLiteral("patches"), patches); + document.setObject(root); + + QSaveFile output(path); + if (output.open(QIODevice::WriteOnly) && output.write(document.toJson()) >= 0 && + output.commit()) { + m_status->setText(tr("Patch selection saved.")); + } +} diff --git a/src/loader/gamePatch.cpp b/src/loader/gamePatch.cpp new file mode 100644 index 000000000..d376b67db --- /dev/null +++ b/src/loader/gamePatch.cpp @@ -0,0 +1,265 @@ +#include "loader/gamePatch.h" + +#include "common/stringUtils.h" +#include "common/virtualMemory.h" +#include "loader/elf.h" +#include "loader/runtimeLinker.h" +#include "loader/systemContent.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Loader::GamePatch { + +namespace { + +struct Write { + std::string patch_name; + uint64_t address = 0; + std::vector expected; + std::vector replacement; +}; + +struct Plan { + std::string title_id; + std::string game_version; + std::string process; + std::vector writes; + std::vector patch_names; +}; + +using Json = nlohmann::json; + +bool Fail(std::string* error, std::string message) { + *error = std::move(message); + return false; +} + +const Json::string_t* StringField(const Json& object, const char* name) { + const auto value = object.find(name); + return value == object.end() ? nullptr : value->get_ptr(); +} + +const Json::array_t* ArrayField(const Json& object, const char* name) { + const auto value = object.find(name); + return value == object.end() ? nullptr : value->get_ptr(); +} + +bool ParseBytes(std::string_view text, std::vector* bytes) { + if (text.empty() || (text.size() % 2) != 0) { + return false; + } + + bytes->resize(text.size() / 2); + for (size_t index = 0; index < bytes->size(); index++) { + unsigned int value = 0; + const char* begin = text.data() + index * 2; + const auto [end, error] = std::from_chars(begin, begin + 2, value, 16); + if (error != std::errc {} || end != begin + 2) { + return false; + } + (*bytes)[index] = static_cast(value); + } + return true; +} + +bool ReadJson(const std::filesystem::path& path, Json* root, std::string* error) { + std::ifstream file(path, std::ios::binary); + if (!file) { + return Fail(error, "could not read patch plan"); + } + + *root = Json::parse(file, nullptr, false); + return root->is_object() || Fail(error, "patch plan is not valid JSON"); +} + +bool LoadPlan(const std::filesystem::path& path, Plan* plan, std::string* error) { + Json root; + if (!ReadJson(path, &root, error)) { + return false; + } + + const auto* title_id = StringField(root, "title_id"); + const auto* game_version = StringField(root, "game_version"); + const auto* process = StringField(root, "process"); + const auto* patches = ArrayField(root, "patches"); + if (title_id == nullptr || game_version == nullptr || process == nullptr || + patches == nullptr) { + return Fail(error, "invalid patch plan"); + } + + plan->title_id = *title_id; + plan->game_version = *game_version; + plan->process = *process; + + for (const auto& patch_json: *patches) { + const auto enabled = patch_json.find("enabled"); + if (enabled != patch_json.end() && enabled->is_boolean() && !enabled->get()) { + continue; + } + + const auto* name = StringField(patch_json, "name"); + const auto* writes = ArrayField(patch_json, "writes"); + if (name == nullptr || writes == nullptr) { + return Fail(error, "invalid patch entry"); + } + + plan->patch_names.push_back(*name); + for (const auto& write_json: *writes) { + const auto* expected = StringField(write_json, "expected"); + const auto* replacement = StringField(write_json, "replacement"); + if (expected == nullptr || replacement == nullptr) { + return Fail(error, "invalid patch write"); + } + + Write write { + .patch_name = *name, + }; + if (!ParseBytes(*expected, &write.expected) || + !ParseBytes(*replacement, &write.replacement) || + write.expected.size() != write.replacement.size()) { + return Fail(error, "invalid patch bytes"); + } + + plan->writes.push_back(std::move(write)); + } + } + return true; +} + +bool ValidateTarget(const Plan& plan, const Program* program, std::string* error) { + if (program == nullptr || program->elf == nullptr || program->base_vaddr == 0) { + return Fail(error, "main executable is not loaded"); + } + + std::string title_id; + std::string game_version; + if (!SystemContentParamSfoGetString("TITLE_ID", &title_id) || + !SystemContentParamSfoGetString("APP_VER", &game_version)) { + return Fail(error, "game metadata is incomplete"); + } + if (!Common::EqualNoCase(program->file_name.filename().string(), plan.process) || + !Common::EqualNoCase(title_id, plan.title_id) || game_version != plan.game_version) { + return Fail(error, "patch plan does not match the loaded game"); + } + return true; +} + +Common::VirtualMemory::Mode ReadableMode(Elf64_Word flags) { + const bool executable = (flags & PF_X) != 0; + const bool writable = (flags & PF_W) != 0; + if (executable && writable) { + return Common::VirtualMemory::Mode::ExecuteReadWrite; + } + if (executable) { + return Common::VirtualMemory::Mode::ExecuteRead; + } + return writable ? Common::VirtualMemory::Mode::ReadWrite : Common::VirtualMemory::Mode::Read; +} + +bool ResolveWrite(const Program& program, Write* write, std::string* error) { + const auto* ehdr = program.elf->GetEhdr(); + const auto* phdr = program.elf->GetPhdr(); + + uint64_t match = 0; + size_t match_count = 0; + for (Elf64_Half index = 0; index < ehdr->e_phnum; index++) { + const auto& segment = phdr[index]; + const bool loaded = segment.p_type == PT_LOAD || segment.p_type == PT_OS_RELRO; + if (!loaded || segment.p_filesz < write->expected.size()) { + continue; + } + + const auto segment_address = program.base_vaddr + segment.p_vaddr; + const bool add_read = (segment.p_flags & PF_R) == 0; + Common::VirtualMemory::Mode old_mode {}; + if (add_read && !Common::VirtualMemory::Protect(segment_address, segment.p_memsz, + ReadableMode(segment.p_flags), &old_mode)) { + return Fail(error, "could not read a loaded executable segment"); + } + + const auto* begin = reinterpret_cast(segment_address); + const auto* end = begin + segment.p_filesz; + for (auto* current = begin; current < end;) { + const auto* found = + std::search(current, end, write->expected.begin(), write->expected.end()); + if (found == end) { + break; + } + + const auto address = reinterpret_cast(found); + if (match == 0) { + match = address; + } + match_count++; + current = found + 1; + } + + if (add_read && + !Common::VirtualMemory::Protect(segment_address, segment.p_memsz, old_mode)) { + return Fail(error, "could not restore executable segment protection"); + } + } + + ::printf("Game patch: found %zu entries for '%s'\n", match_count, + write->patch_name.c_str()); + if (match == 0) { + return Fail(error, "original bytes not found for '" + write->patch_name + "'"); + } + write->address = match; + return true; +} + +bool PrepareWrites(Plan* plan, const Program& program, std::string* error) { + for (auto& write: plan->writes) { + if (!ResolveWrite(program, &write, error)) { + return false; + } + } + return true; +} + +bool ApplyWrites(Plan* plan, std::string* error) { + for (auto& write: plan->writes) { + Common::VirtualMemory::Mode old_mode {}; + const auto size = write.replacement.size(); + if (!Common::VirtualMemory::Protect( + write.address, size, Common::VirtualMemory::Mode::ExecuteReadWrite, &old_mode)) { + return Fail(error, "could not make patch memory writable"); + } + + std::memcpy(reinterpret_cast(write.address), write.replacement.data(), size); + if (!Common::VirtualMemory::Protect(write.address, size, old_mode) || + !Common::VirtualMemory::FlushInstructionCache(write.address, size)) { + return Fail(error, "could not finalize patch"); + } + } + return true; +} + +} // namespace + +bool Apply(const std::filesystem::path& plan_path, Program* program) { + Plan plan; + std::string error; + if (LoadPlan(plan_path, &plan, &error) && ValidateTarget(plan, program, &error) && + PrepareWrites(&plan, *program, &error) && ApplyWrites(&plan, &error)) { + for (const auto& name: plan.patch_names) { + ::printf("Successfully applied patch: %s\n", name.c_str()); + } + return true; + } + + ::printf("Game patch error: %s\n", error.c_str()); + return false; +} + +} // namespace Loader::GamePatch diff --git a/src/loader/gamePatch.h b/src/loader/gamePatch.h new file mode 100644 index 000000000..80a1bcba8 --- /dev/null +++ b/src/loader/gamePatch.h @@ -0,0 +1,20 @@ +#ifndef KYTY_LOADER_GAME_PATCH_H_ +#define KYTY_LOADER_GAME_PATCH_H_ + +#include "common/common.h" + +#include + +namespace Loader { + +struct Program; + +namespace GamePatch { + +bool Apply(const std::filesystem::path& plan_path, Program* program); + +} // namespace GamePatch + +} // namespace Loader + +#endif // KYTY_LOADER_GAME_PATCH_H_ diff --git a/src/loader/runtimeLinker.cpp b/src/loader/runtimeLinker.cpp index 981955463..ab3d29c7a 100644 --- a/src/loader/runtimeLinker.cpp +++ b/src/loader/runtimeLinker.cpp @@ -17,6 +17,7 @@ #include "kernel/memory.h" #include "kernel/pthread.h" #include "loader/elf.h" +#include "loader/gamePatch.h" #include "loader/jit.h" #include "loader/symbolDatabase.h" #include "loader/x64InstructionEmulator.h" @@ -1326,7 +1327,7 @@ void RuntimeLinker::SaveProgram(Program* program, const std::filesystem::path& e } } -void RuntimeLinker::Execute() { +void RuntimeLinker::Execute(const std::filesystem::path& game_patch) { KYTY_PROFILER_THREAD("Thread_Main"); Libs::LibKernel::PthreadInitSelfForMainThread(); @@ -1346,6 +1347,9 @@ void RuntimeLinker::Execute() { PreloadAdjacentPrograms(); RelocateAll(); + if (!game_patch.empty()) { + GamePatch::Apply(game_patch, m_programs.empty() ? nullptr : m_programs.front()); + } StartAllModules(); LOGF_COLOR(Log::Color::BrightYellow, "---\n--- Execute: %s\n---\n", "Main"); diff --git a/src/loader/runtimeLinker.h b/src/loader/runtimeLinker.h index 5536978eb..8133e78e7 100644 --- a/src/loader/runtimeLinker.h +++ b/src/loader/runtimeLinker.h @@ -154,7 +154,7 @@ public: void RelocateAll(); void RelocateProgram(Program* program); - void Execute(); + void Execute(const std::filesystem::path& game_patch = {}); int StartModule(Program* program, size_t args, const void* argp, module_func_t func); int StopModule(Program* program, size_t args, const void* argp, module_func_t func); void StartAllModules(); diff --git a/src/main.cpp b/src/main.cpp index 6d0aa7136..94fb17f9b 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -42,6 +42,8 @@ static void PrintUsage() { ::printf("kyty_emulator --game [options]\n\n"); ::printf("Options:\n"); ::printf(" --game Game directory or ELF to load.\n"); + ::printf( + " --game-patch Validated patch plan to apply before entry.\n"); ::printf(" --screen-width Window width. Default: 1280.\n"); ::printf(" --screen-height Window height. Default: 720.\n"); ::printf(" --vblank-frequency Virtual vblank frequency. Default: 60.\n"); @@ -59,7 +61,8 @@ static void PrintUsage() { ::printf(" --spirv-debug-printf Enable SPIR-V debug printf.\n"); ::printf(" --ngg-rectlist-draw Draw rect-list auto draws using the NGG " "4-vertex path.\n"); - ::printf(" --readback-linear-images Read back writable linear images on submit.\n"); + ::printf( + " --readback-linear-images Read back writable linear images on submit.\n"); ::printf(" --rd Enable RenderDoc capture.\n"); } @@ -147,6 +150,17 @@ static bool ParseArgs(int argc, char* argv[], RunOptions& options, bool& show_he ::printf("--game must point to an existing directory or ELF: %s\n", value.c_str()); return false; } + } else if (arg == "--game-patch") { + if (!options.game_patch.empty()) { + ::printf("--game-patch can only be specified once\n"); + return false; + } + value = Common::FixFilenameSlash(value); + if (!Common::File::IsFileExisting(value)) { + ::printf("--game-patch must point to an existing file: %s\n", value.c_str()); + return false; + } + options.game_patch = value; } else if (arg == "--screen-width") { options.config.screen_width = static_cast(Common::ToInt32(value)); } else if (arg == "--screen-height") {