From 60a01817ce8825ef3fece10e4acd858c3f481b6b Mon Sep 17 00:00:00 2001 From: nmzik Date: Sat, 29 Aug 2026 20:48:05 +0400 Subject: [PATCH] loader: add support for etaHEN cheats --- src/launcher/src/configurationListWidget.cpp | 2 +- src/launcher/src/patchesDialog.cpp | 86 +++-- src/loader/gamePatch.cpp | 350 +++++++++++-------- src/loader/gamePatch.h | 6 +- src/loader/runtimeLinker.cpp | 9 +- src/main.cpp | 6 +- 6 files changed, 277 insertions(+), 182 deletions(-) diff --git a/src/launcher/src/configurationListWidget.cpp b/src/launcher/src/configurationListWidget.cpp index aeb4aa61e..55a42f033 100644 --- a/src/launcher/src/configurationListWidget.cpp +++ b/src/launcher/src/configurationListWidget.cpp @@ -889,7 +889,7 @@ 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)...")); + QAction* action_patches = menu.addAction(tr("Cheats (experimental)...")); connect(action_patches, &QAction::triggered, this, [this, item]() { if (item != nullptr) { auto* dialog = new PatchesDialog(item->GetInfo(), this); diff --git a/src/launcher/src/patchesDialog.cpp b/src/launcher/src/patchesDialog.cpp index df310ccdf..04618aa8a 100644 --- a/src/launcher/src/patchesDialog.cpp +++ b/src/launcher/src/patchesDialog.cpp @@ -18,7 +18,7 @@ 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)); + setWindowTitle(tr("Cheats (Experimental) - %1").arg(game.name)); resize(640, 480); auto* layout = new QVBoxLayout(this); @@ -54,46 +54,50 @@ QString PatchesDialog::PatchPlanPath(const QString& title_id) { 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_status->setText(tr("No local cheat file: %1").arg(file.fileName())); m_apply->setEnabled(false); return; } const auto document = QJsonDocument::fromJson(file.readAll()); if (document.isNull() || !document.isObject()) { - m_status->setText(tr("Invalid patch JSON in %1.").arg(file.fileName())); + m_status->setText(tr("Invalid cheat JSON in %1.").arg(file.fileName())); m_apply->setEnabled(false); return; } - const auto root = document.object(); - const auto patches = root.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()); - if (patches.isEmpty()) { - // shadPS4/ETAHen cheat files use "mods"; Kyty only reads a "patches" array. - if (root.contains(QStringLiteral("mods"))) { - m_status->setText( - tr("This file uses a \"mods\" list. Kyty expects a top-level \"patches\" " - "array in %1.") - .arg(file.fileName())); - } else if (!root.contains(QStringLiteral("patches"))) { - m_status->setText( - tr("No \"patches\" array in %1.").arg(file.fileName())); - } else { - m_status->setText(tr("Loaded 0 patch(es) from %1.").arg(file.fileName())); - } + const auto root = document.object(); + if (!root.value(QStringLiteral("mods")).isArray()) { + m_status->setText(tr("Cheat JSON has no \"mods\" array in %1.").arg(file.fileName())); + m_apply->setEnabled(false); return; } - m_status->setText(tr("Loaded %1 patch(es) from %2.").arg(patches.size()).arg(file.fileName())); + const auto mods = root.value(QStringLiteral("mods")).toArray(); + for (const auto& value: mods) { + if (!value.isObject()) { + m_status->setText(tr("Invalid cheat entry in %1.").arg(file.fileName())); + m_apply->setEnabled(false); + m_patches->clear(); + return; + } + const auto mod = value.toObject(); + const auto name = mod.value(QStringLiteral("name")); + const auto enabled = mod.value(QStringLiteral("enabled")); + if (!name.isString() || name.toString().isEmpty() || + (!enabled.isUndefined() && !enabled.isBool())) { + m_status->setText(tr("Invalid cheat entry in %1.").arg(file.fileName())); + m_apply->setEnabled(false); + m_patches->clear(); + return; + } + auto* item = new QListWidgetItem(name.toString(), m_patches); + item->setFlags(item->flags() | Qt::ItemIsUserCheckable); + item->setCheckState(enabled.toBool(true) ? Qt::Checked : Qt::Unchecked); + } + + m_apply->setEnabled(!mods.isEmpty()); + m_status->setText(tr("Loaded %1 cheat(s) from %2.").arg(mods.size()).arg(file.fileName())); } void PatchesDialog::Save() { @@ -105,20 +109,28 @@ void PatchesDialog::Save() { 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; + const auto changed_message = tr("Cheat file changed; reload the dialog."); + if (!document.isObject()) { + m_status->setText(changed_message); + return; } - root.insert(QStringLiteral("patches"), patches); + auto root = document.object(); + auto mods = root.value(QStringLiteral("mods")).toArray(); + if (mods.size() != m_patches->count()) { + m_status->setText(changed_message); + return; + } + for (int index = 0; index < mods.size(); index++) { + auto mod = mods[index].toObject(); + mod.insert(QStringLiteral("enabled"), m_patches->item(index)->checkState() == Qt::Checked); + mods[index] = mod; + } + root.insert(QStringLiteral("mods"), mods); 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.")); + m_status->setText(tr("Cheat selection saved.")); } } diff --git a/src/loader/gamePatch.cpp b/src/loader/gamePatch.cpp index b7515493d..d4db4ef56 100644 --- a/src/loader/gamePatch.cpp +++ b/src/loader/gamePatch.cpp @@ -8,207 +8,235 @@ #include "loader/systemContent.h" #include -#include #include #include #include +#include #include #include -#include -#include +#include #include namespace Loader::GamePatch { namespace { +constexpr uint64_t kPageSize = 0x4000; + struct Write { - std::string patch_name; - uint64_t address = 0; - std::vector expected; - std::vector replacement; + uint64_t source_address = 0; + uint64_t target_address = 0; + std::vector off; + std::vector on; }; struct Plan { std::string title_id; - std::string game_version; + std::string version; std::string process; std::vector writes; - std::vector patch_names; + std::vector mod_names; + std::vector cave_pages; }; using Json = nlohmann::json; +std::unique_ptr g_pending_plan; +std::vector g_applied_cave_pages; + 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) { +bool ParseBytes(const std::string& 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); + (*bytes)[index] = static_cast(std::stoul(text.substr(index * 2, 2), nullptr, 16)); } return true; } -bool ReadJson(const std::filesystem::path& path, Json* root, std::string* error) { +bool LoadPlan(const std::filesystem::path& path, Plan* plan, std::string* error) { std::ifstream file(path, std::ios::binary); if (!file) { - return Fail(error, "could not read patch plan"); + return Fail(error, "could not read cheat file"); } - *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 root = Json::parse(file, nullptr, false); + if (!root.is_object() || !root.contains("id") || !root.contains("version") || + !root.contains("process") || !root.contains("mods")) { + return Fail(error, "expected a GoldHEN-style mods JSON file"); } + plan->title_id = root["id"].get(); + plan->version = root["version"].get(); + plan->process = root["process"].get(); - 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()) { + for (const auto& mod: root["mods"]) { + if (!mod.value("enabled", true)) { 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"); + const auto name = mod["name"].get(); + plan->mod_names.push_back(name); + for (const auto& entry: mod["memory"]) { + Write write; + write.source_address = std::stoull(entry["offset"].get(), nullptr, 16); + if (!ParseBytes(entry["off"].get(), &write.off) || + !ParseBytes(entry["on"].get(), &write.on) || + write.off.size() != write.on.size()) { + return Fail(error, "invalid mod memory bytes"); } - - 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) { +bool ValidateGame(const Plan& plan, const Program* main_program, std::string* error) { + if (main_program == nullptr || main_program->base_vaddr == 0) { return Fail(error, "main executable is not loaded"); } - std::string title_id; - std::string game_version; + std::string 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"); + !SystemContentParamSfoGetString("APP_VER", &version) || + !Common::EqualNoCase(main_program->file_name.filename().string(), plan.process) || + !Common::EqualNoCase(title_id, plan.title_id) || version != plan.version) { + return Fail(error, "cheat file does not match the loaded game"); } return true; } -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 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; - } +void Translate(const Program& program, uint64_t source_base, uint64_t source_address, + uint64_t* target_address) { + if (source_address >= source_base) { + *target_address = program.base_vaddr + source_address - source_base; + return; } - - ::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; + *target_address = program.base_vaddr - (source_base - source_address); } -bool PrepareWrites(Plan* plan, const Program& program, std::string* error) { +bool IsInsideProgram(const Program& program, uint64_t address, size_t size) { + return size != 0 && address >= program.base_vaddr && + address + size <= program.base_vaddr + program.mapped_size; +} + +bool IsZero(const std::vector& bytes) { + return std::all_of(bytes.begin(), bytes.end(), [](uint8_t byte) { return byte == 0; }); +} + +bool TranslateAndValidate(Plan* plan, const Program& program, uint64_t source_base) { for (auto& write: plan->writes) { - if (!ResolveWrite(program, &write, error)) { + Translate(program, source_base, write.source_address, &write.target_address); + if (!IsZero(write.off) && + (!IsInsideProgram(program, write.target_address, write.off.size()) || + std::memcmp(reinterpret_cast(write.target_address), write.off.data(), + write.off.size()) != 0)) { return false; } } return true; } -bool ApplyWrites(Plan* plan, std::string* error) { - for (auto& write: plan->writes) { - const auto size = write.replacement.size(); - std::memcpy(reinterpret_cast(write.address), write.replacement.data(), size); - if (!Common::VirtualMemory::FlushInstructionCache(write.address, size)) { - return Fail(error, "could not finalize patch"); +bool Matches(Plan* plan, const Program& program, uint64_t* source_base) { + const auto anchor = std::find_if(plan->writes.begin(), plan->writes.end(), + [](const Write& write) { return !IsZero(write.off); }); + if (anchor == plan->writes.end() || program.mapped_size < anchor->off.size()) { + return false; + } + + const auto* ehdr = program.elf->GetEhdr(); + const auto* phdr = program.elf->GetPhdr(); + for (Elf64_Half index = 0; index < ehdr->e_phnum; index++) { + const auto& segment = phdr[index]; + if ((segment.p_type != PT_LOAD && segment.p_type != PT_OS_RELRO) || + segment.p_filesz < anchor->off.size()) { + continue; + } + + const auto* begin = reinterpret_cast(program.base_vaddr + segment.p_vaddr); + const auto* end = begin + segment.p_filesz; + for (auto* current = begin; current < end;) { + const auto* found = std::search(current, end, anchor->off.begin(), anchor->off.end()); + if (found == end) { + break; + } + const auto rva = static_cast(found - begin) + segment.p_vaddr; + if (anchor->source_address >= rva) { + *source_base = anchor->source_address - rva; + if (TranslateAndValidate(plan, program, *source_base)) { + return true; + } + } + current = found + 1; + } + } + return false; +} + +void ReleaseCaves(std::vector* cave_pages) { + for (auto page = cave_pages->rbegin(); page != cave_pages->rend(); ++page) { + EXIT_IF(!Libs::LibKernel::Memory::FreeGuestMemory(*page, kPageSize)); + } + cave_pages->clear(); +} + +bool AllocateCave(Plan* plan, const Program& program, std::string* error) { + std::vector pages; + for (const auto& write: plan->writes) { + if (IsInsideProgram(program, write.target_address, write.off.size())) { + continue; + } + if (!IsZero(write.off)) { + return Fail(error, "non-zero write is outside the matched module"); + } + + const auto write_end = write.target_address + write.off.size(); + for (auto page = write.target_address & ~(kPageSize - 1); page < write_end; + page += kPageSize) { + if (std::find(pages.begin(), pages.end(), page) == pages.end()) { + pages.push_back(page); + } + } + } + + for (const auto page: pages) { + const auto allocated = Libs::LibKernel::Memory::AllocateRuntimeMemory( + page, kPageSize, Common::VirtualMemory::Mode::ExecuteReadWrite, "game_cheat_code_cave", + true); + if (allocated != page) { + if (allocated != 0) { + Libs::LibKernel::Memory::FreeGuestMemory(allocated, kPageSize); + } + ReleaseCaves(&plan->cave_pages); + return Fail(error, "could not allocate code-cave page"); + } + plan->cave_pages.push_back(page); + } + return true; +} + +bool ApplyWrites(Plan* plan, const Program& program, std::string* error) { + if (!AllocateCave(plan, program, error)) { + return false; + } + + for (int external = 1; external >= 0; external--) { + for (const auto& write: plan->writes) { + if (static_cast( + !IsInsideProgram(program, write.target_address, write.on.size())) != external) { + continue; + } + std::memcpy(reinterpret_cast(write.target_address), write.on.data(), + write.on.size()); + if (!Common::VirtualMemory::FlushInstructionCache(write.target_address, + write.on.size())) { + return Fail(error, "could not finalize cheat write"); + } } } return true; @@ -216,19 +244,63 @@ bool ApplyWrites(Plan* plan, std::string* error) { } // namespace -bool Apply(const std::filesystem::path& plan_path, Program* program) { +bool Apply(const std::filesystem::path& plan_path, Program* main_program, + const std::vector& programs) { 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()); - } + if (!LoadPlan(plan_path, &plan, &error) || !ValidateGame(plan, main_program, &error)) { + ::printf("Game cheat error: %s\n", error.c_str()); + return false; + } + if (plan.writes.empty()) { return true; } - ::printf("Game patch error: %s\n", error.c_str()); - return false; + g_pending_plan = std::make_unique(std::move(plan)); + for (auto* program: programs) { + if (!ApplyPending(program)) { + return false; + } + if (g_pending_plan == nullptr) { + return true; + } + } + ::printf("Game cheat: waiting for the matching module\n"); + return true; +} + +bool ApplyPending(Program* program) { + uint64_t source_base = 0; + if (g_pending_plan == nullptr || program == nullptr || program->elf == nullptr || + !Matches(g_pending_plan.get(), *program, &source_base)) { + return true; + } + ::printf("Game cheat: matched %s with source base 0x%llx\n", + program->file_name.filename().string().c_str(), + static_cast(source_base)); + + std::string error; + if (!ApplyWrites(g_pending_plan.get(), *program, &error)) { + ReleaseCaves(&g_pending_plan->cave_pages); + g_pending_plan.reset(); + ::printf("Game cheat error: %s\n", error.c_str()); + return false; + } + + for (const auto& name: g_pending_plan->mod_names) { + ::printf("Successfully applied cheat: %s\n", name.c_str()); + } + g_applied_cave_pages = std::move(g_pending_plan->cave_pages); + g_pending_plan.reset(); + return true; +} + +void Clear() { + if (g_pending_plan != nullptr) { + ReleaseCaves(&g_pending_plan->cave_pages); + } + g_pending_plan.reset(); + ReleaseCaves(&g_applied_cave_pages); } } // namespace Loader::GamePatch diff --git a/src/loader/gamePatch.h b/src/loader/gamePatch.h index 80a1bcba8..b29d796eb 100644 --- a/src/loader/gamePatch.h +++ b/src/loader/gamePatch.h @@ -4,6 +4,7 @@ #include "common/common.h" #include +#include namespace Loader { @@ -11,7 +12,10 @@ struct Program; namespace GamePatch { -bool Apply(const std::filesystem::path& plan_path, Program* program); +bool Apply(const std::filesystem::path& plan_path, Program* main_program, + const std::vector& programs); +bool ApplyPending(Program* program); +void Clear(); } // namespace GamePatch diff --git a/src/loader/runtimeLinker.cpp b/src/loader/runtimeLinker.cpp index 6b97a02d8..c48b68135 100644 --- a/src/loader/runtimeLinker.cpp +++ b/src/loader/runtimeLinker.cpp @@ -1289,6 +1289,9 @@ void RuntimeLinker::RelocateProgram(Program* program) { EXIT_IF(std::find(m_programs.begin(), m_programs.end(), program) == m_programs.end()); Relocate(program); + if (!GamePatch::ApplyPending(program)) { + EXIT("Failed to apply pending game cheat\n"); + } } void RuntimeLinker::UnloadProgram(Program* program) { @@ -1412,7 +1415,10 @@ void RuntimeLinker::Execute(const std::filesystem::path& game_patch) { RelocateAll(); if (!game_patch.empty()) { - GamePatch::Apply(game_patch, m_programs.empty() ? nullptr : m_programs.front()); + if (!GamePatch::Apply(game_patch, m_programs.empty() ? nullptr : m_programs.front(), + m_programs)) { + EXIT("Failed to apply game cheat\n"); + } } StartAllModules(); @@ -1436,6 +1442,7 @@ void RuntimeLinker::Clear() { // EXIT_NOT_IMPLEMENTED(!Common::Thread::IsMainThread()); Common::LockGuard lock(m_mutex); + GamePatch::Clear(); for (auto* p: m_programs) { DeleteProgram(p); diff --git a/src/main.cpp b/src/main.cpp index f6c0d2f55..e186fea52 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -43,15 +43,15 @@ 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(" --game-patch ETAHen cheat file.\n"); ::printf(" --screen-width Window width. Default: 1280.\n"); ::printf(" --screen-height Window height. Default: 720.\n"); ::printf( " --user-name Local user name (1-16 bytes). Default: Kyty.\n"); ::printf(" --user-id Local user ID. Default: %d.\n", Config::DEFAULT_USER_ID); - ::printf(" --present-mode Fifo, Mailbox, or Immediate. Default: Fifo.\n"); + ::printf( + " --present-mode Fifo, Mailbox, or Immediate. Default: Fifo.\n"); ::printf(" --fullscreen Run in borderless desktop fullscreen.\n"); ::printf(" --vblank-frequency Virtual vblank frequency. Default: 60.\n"); ::printf(" --console-language <0-29> Console language. Default: 1 (English US).\n");