diff --git a/core/relinker/CMakeLists.txt b/core/relinker/CMakeLists.txt index c53be3987b..32a504086f 100644 --- a/core/relinker/CMakeLists.txt +++ b/core/relinker/CMakeLists.txt @@ -69,6 +69,7 @@ add_library(relinker_core STATIC ${CMAKE_CURRENT_SOURCE_DIR}/relinker/src/analysis/ValidationPolicy.cpp ${CMAKE_CURRENT_SOURCE_DIR}/relinker/src/output/SysVDynamicSectionBuilder.cpp ${CMAKE_CURRENT_SOURCE_DIR}/relinker/src/output/CallRegistryWriter.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/relinker/src/patching/GamePatch.cpp ${CMAKE_CURRENT_SOURCE_DIR}/relinker/src/pipeline/Conversion.cpp ${CMAKE_CURRENT_SOURCE_DIR}/relinker/src/pipeline/RelinkerPipeline.cpp ) @@ -93,6 +94,10 @@ if(BUILD_TESTING) target_link_libraries(relinker_conversion_tests PRIVATE relinker_core) add_test(NAME relinker_conversion COMMAND relinker_conversion_tests) set_tests_properties(relinker_conversion PROPERTIES TIMEOUT 20) + add_test_executable(relinker_game_patch_tests ${CMAKE_CURRENT_SOURCE_DIR}/relinker/tests/GamePatchTests.cpp) + target_link_libraries(relinker_game_patch_tests PRIVATE relinker_core) + add_test(NAME relinker_game_patch COMMAND relinker_game_patch_tests) + set_tests_properties(relinker_game_patch PROPERTIES TIMEOUT 30) endif() if(ANYPS5_BUILD_WASM) @@ -501,6 +506,8 @@ if(BUILD_TESTING) ${CMAKE_CURRENT_SOURCE_DIR}/relinker/tests/test_guest_module_directories.py $) add_test(NAME relinker_executables COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/relinker/tests/test_executables.py $) + add_test(NAME game_patches COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/relinker/tests/test_game_patches.py $) add_test(NAME sce_module_path COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/relinker/tests/test_sce_module_path.py $) add_test(NAME linux_guest_stack COMMAND ${Python3_EXECUTABLE} diff --git a/core/relinker/cli/include/Cli.hpp b/core/relinker/cli/include/Cli.hpp index 52a12f2356..1f67f40631 100644 --- a/core/relinker/cli/include/Cli.hpp +++ b/core/relinker/cli/include/Cli.hpp @@ -30,6 +30,10 @@ struct Args { std::string inputPath; std::string outputPath; std::string sceModulePath; + std::string patchesPath; + bool noPatches = false; + std::string verifyPatchesPath; + bool verifyOutput = false; std::string runPath = "$ORIGIN/libs"; std::set excludedSceModules; std::vector moduleDirectories; diff --git a/core/relinker/cli/src/CliArgs.cpp b/core/relinker/cli/src/CliArgs.cpp index 86743356d6..98f66d4868 100644 --- a/core/relinker/cli/src/CliArgs.cpp +++ b/core/relinker/cli/src/CliArgs.cpp @@ -5,16 +5,36 @@ #include #include #include +#include +#include #include #include namespace Cli { const char* Usage() { - return "Usage: relinker [--help] [--windows | --macos] [--windows-diagnostics] [--windows-gui] [--trace-imports] [--windows-fixed-base
] [--linux-fixed-base
] [--skip-syscall-check | --trap-sdk-syscall-stubs] [--skip-sce-module | --sce-module-path ] [--exclude-sce-module ]... [--module-dir ]... [--executable ]... [--to-intel] [--to-rosetta] [unused-filter=0|1|2] [--registry] [--rpath ] [--lazy-binding] [--autorun] \n" + return "Usage: relinker [--help] [--windows | --macos] [--windows-diagnostics] [--windows-gui] [--trace-imports] [--windows-fixed-base
] [--linux-fixed-base
] [--skip-syscall-check | --trap-sdk-syscall-stubs] [--skip-sce-module | --sce-module-path ] [--exclude-sce-module ]... [--module-dir ]... [--executable ]... [--to-intel] [--to-rosetta] [unused-filter=0|1|2] [--registry] [--rpath ] [--lazy-binding] [--autorun] [--patches | --no-patches] \n" + " relinker --verify-patches [--verify-output] [--skip-sce-module | --sce-module-path ] [--exclude-sce-module ]... [--module-dir ]... [--executable ]... \n" "Example: relinker input.elf output.elf"; } +namespace { + +constexpr std::string_view kVerifyRejectedOptions[] = { + "--windows", "--macos", "--windows-gui", "--windows-diagnostics", "--windows-fixed-base", "--trace-imports", "--linux-fixed-base", + "--to-intel", "--to-rosetta", "--skip-syscall-check", "--trap-sdk-syscall-stubs", "unused-filter=", + "--rpath", "--registry", "--lazy-binding", "--autorun", "--patches", "--no-patches", +}; + +constexpr std::string_view kModuleDiscoveryOptions[] = {"--sce-module-path", "--module-dir", "--exclude-sce-module", "--skip-sce-module"}; + +template +bool IsOneOf(const std::string& option, const std::string_view (&names)[Count]) { + return std::find(std::begin(names), std::end(names), option) != std::end(names); +} + +} + static std::uint64_t ParseLinuxFixedBase(std::string_view value) { constexpr std::uint64_t lowest = 0x10000; constexpr std::uint64_t highest = 0x7fffffff0000; @@ -32,9 +52,12 @@ Args ParseArgs(int argc, char* argv[]) { Args args; bool unusedFilterSpecified = false; bool sceModulePathSpecified = false; + std::vector givenOptions; for (int i = 1; i < argc; ++i) { const std::string arg = argv[i]; + if (arg.rfind("--", 0) == 0) givenOptions.push_back(arg); + else if (arg.rfind("unused-filter=", 0) == 0) givenOptions.push_back("unused-filter="); if (arg == "--help") { args.showHelp = true; } else if (arg == "--windows-fixed-base") { @@ -87,6 +110,18 @@ Args ParseArgs(int argc, char* argv[]) { throw std::runtime_error("unused-filter must be specified once with a value of 0, 1 or 2"); args.unusedFilterLevel = static_cast(value[0] - '0'); unusedFilterSpecified = true; + } else if (arg == "--patches") { + if (!args.patchesPath.empty()) throw std::runtime_error("--patches cannot be repeated"); + if (i + 1 >= argc || std::string(argv[i + 1]).empty() || std::string(argv[i + 1]).starts_with("--")) throw std::runtime_error("--patches requires a file"); + args.patchesPath = argv[++i]; + } else if (arg == "--no-patches") { + args.noPatches = true; + } else if (arg == "--verify-patches") { + if (!args.verifyPatchesPath.empty()) throw std::runtime_error("--verify-patches cannot be repeated"); + if (i + 1 >= argc || std::string(argv[i + 1]).empty() || std::string(argv[i + 1]).starts_with("--")) throw std::runtime_error("--verify-patches requires a file"); + args.verifyPatchesPath = argv[++i]; + } else if (arg == "--verify-output") { + args.verifyOutput = true; } else if (arg == "--registry") { args.writeRegistry = true; } else if (arg == "--rpath") { @@ -120,6 +155,19 @@ Args ParseArgs(int argc, char* argv[]) { } } + const bool verifying = !args.verifyPatchesPath.empty(); + if (args.verifyOutput && !verifying) throw std::runtime_error("--verify-output requires --verify-patches"); + if (!args.patchesPath.empty() && args.noPatches) throw std::runtime_error("--patches conflicts with --no-patches"); + if (verifying) { + for (const auto& option : givenOptions) { + if (IsOneOf(option, kVerifyRejectedOptions)) + throw std::runtime_error(option + " cannot be used with --verify-patches"); + if (args.verifyOutput && IsOneOf(option, kModuleDiscoveryOptions)) + throw std::runtime_error("module discovery options have no effect with --verify-output"); + } + if (!args.outputPath.empty()) throw std::runtime_error("unexpected argument: " + args.outputPath); + } + if (args.skipSceModule && sceModulePathSpecified) throw std::runtime_error("--sce-module-path conflicts with --skip-sce-module"); if (args.skipSyscallCheck && args.trapSdkSyscallStubs) throw std::runtime_error("--skip-syscall-check conflicts with --trap-sdk-syscall-stubs"); @@ -154,11 +202,11 @@ Args ParseArgs(int argc, char* argv[]) { if (args.linuxFixedBase && args.lazyBinding) throw std::runtime_error("--linux-fixed-base conflicts with --lazy-binding"); - if (!args.showHelp && (args.inputPath.empty() || args.outputPath.empty())) + if (!args.showHelp && (args.inputPath.empty() || (!verifying && args.outputPath.empty()))) throw std::runtime_error(Usage()); - if (!sceModulePathSpecified && !args.skipSceModule && !args.inputPath.empty()) args.sceModulePath = Io::Utf8Path(std::filesystem::absolute(Io::NativePath(args.inputPath)).parent_path()); + if (!sceModulePathSpecified && !args.skipSceModule && !args.inputPath.empty() && !args.verifyOutput) args.sceModulePath = Io::Utf8Path(std::filesystem::absolute(Io::NativePath(args.inputPath)).parent_path()); return args; } diff --git a/core/relinker/cli/tests/CliArgsTests.cpp b/core/relinker/cli/tests/CliArgsTests.cpp index 6d0bd857a3..1f5c745192 100644 --- a/core/relinker/cli/tests/CliArgsTests.cpp +++ b/core/relinker/cli/tests/CliArgsTests.cpp @@ -278,6 +278,43 @@ void targetSelection() { expectThrows({"--to-rosetta", "--windows", "input.elf", "output.elf"}, rosettaConflict); } +void patchOptions() { + auto args = parse({"--patches", "p.toml", "in.elf", "out.elf"}); + require(args.patchesPath == "p.toml" && !args.noPatches, "--patches value"); + args = parse({"--no-patches", "in.elf", "out.elf"}); + require(args.noPatches && args.patchesPath.empty(), "--no-patches"); + expectThrows({"--patches", "p.toml", "--no-patches", "in.elf", "out.elf"}, "--patches conflicts with --no-patches"); + expectThrows({"--patches", "p.toml", "--patches", "q.toml", "in.elf", "out.elf"}, "--patches cannot be repeated"); + expectThrows({"in.elf", "out.elf", "--patches"}, "--patches requires a file"); +} + +void verifyMode() { + auto args = parse({"--verify-patches", "p.toml", "in.elf"}); + require(args.verifyPatchesPath == "p.toml" && args.inputPath == "in.elf" && args.outputPath.empty() && !args.verifyOutput, "verify positional"); + require(!args.sceModulePath.empty(), "input mode keeps the default module path"); + args = parse({"in.elf", "--verify-patches", "p.toml", "--sce-module-path", "m", "--module-dir", "d", "--exclude-sce-module", "x.prx", "--executable", "a/b.bin"}); + require(args.sceModulePath == "m" && args.executables.size() == 1, "input mode options"); + args = parse({"--verify-patches", "p.toml", "--skip-sce-module", "in.elf"}); + require(args.skipSceModule, "input mode --skip-sce-module"); + args = parse({"--verify-patches", "p.toml", "--verify-output", "out.elf", "--executable", "a/b.bin"}); + require(args.verifyOutput && args.sceModulePath.empty() && args.executables.size() == 1, "output mode accepts --executable"); + const std::string noEffect = "module discovery options have no effect with --verify-output"; + expectThrows({"--verify-patches", "p.toml", "--verify-output", "out.elf", "--sce-module-path", "m"}, noEffect); + expectThrows({"--verify-patches", "p.toml", "--verify-output", "out.elf", "--module-dir", "d"}, noEffect); + expectThrows({"--verify-patches", "p.toml", "--verify-output", "out.elf", "--exclude-sce-module", "x.prx"}, noEffect); + expectThrows({"--verify-patches", "p.toml", "--verify-output", "out.elf", "--skip-sce-module"}, noEffect); + expectThrows({"--verify-patches", "p.toml", "in.elf", "extra"}, "unexpected argument: extra"); + for (const auto* option : {"--windows", "--macos", "--to-intel", "--to-rosetta", "--trace-imports", "--lazy-binding", "--autorun", "--registry", "--skip-syscall-check", "--no-patches"}) + expectThrows({"--verify-patches", "p.toml", option, "in.elf"}, std::string(option) + " cannot be used with --verify-patches"); + expectThrows({"--verify-patches", "p.toml", "--rpath", "x", "in.elf"}, "--rpath cannot be used with --verify-patches"); + expectThrows({"--verify-patches", "p.toml", "--patches", "q.toml", "in.elf"}, "--patches cannot be used with --verify-patches"); + expectThrows({"--verify-patches", "p.toml", "unused-filter=1", "in.elf"}, "unused-filter= cannot be used with --verify-patches"); + expectThrows({"--verify-patches", "p.toml", "--linux-fixed-base", "0x10000", "in.elf"}, "--linux-fixed-base cannot be used with --verify-patches"); + expectThrows({"--verify-output", "in.elf", "out.elf"}, "--verify-output requires --verify-patches"); + expectThrows({"--verify-patches"}, "--verify-patches requires a file"); + expectUsage({"--verify-patches", "p.toml"}); +} + int main() { try { defaults(); @@ -308,6 +345,8 @@ int main() { missingPositionals(); windowsFixedBase(); linuxFixedBase(); + patchOptions(); + verifyMode(); } catch (const std::exception& error) { std::cerr << error.what() << '\n'; return 1; diff --git a/core/relinker/main.cpp b/core/relinker/main.cpp index 1d5bc3c10c..338d3cf688 100644 --- a/core/relinker/main.cpp +++ b/core/relinker/main.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -32,9 +33,10 @@ struct ConvertedExecutable { std::vector GuestArtifacts; std::vector Bytes; std::string Diagnostics; + std::vector AppliedPatches; }; -ConvertedExecutable Convert(const Cli::Args& args, const std::string& inputPath, const std::string& absPath, const bool mainExecutable, Cli::ConsoleProgress& progress) { +ConvertedExecutable Convert(const Cli::Args& args, const std::string& inputPath, const std::string& absPath, const bool mainExecutable, const std::string& targetName, const std::vector& patches, Cli::ConsoleProgress& progress) { Io::FileReader fileReader; Relinker::ConversionInput input; input.Executable = fileReader.Read(inputPath); @@ -63,11 +65,13 @@ ConvertedExecutable Convert(const Cli::Args& args, const std::string& inputPath, options.WindowsDiagnostics = args.windowsDiagnostics && mainExecutable; options.LinuxFixedBase = args.linuxFixedBase; options.ExcludedModules = args.excludedSceModules; + options.Patches = patches; + options.TargetName = targetName; progress.IncludeFile(Io::Utf8Path(input.InputPath)); progress.IncludeFile(Io::Utf8Path(input.OutputPath)); for (const auto& module : input.Modules) progress.IncludeFile(Io::Utf8Path(module.Path)); auto converted = Relinker::Conversion(progress).Convert(std::move(input), options); - return {inputPath, absPath, std::move(converted.Analysis), std::move(converted.Modules), std::move(converted.Executable), std::move(converted.WindowsDiagnostics)}; + return {inputPath, absPath, std::move(converted.Analysis), std::move(converted.Modules), std::move(converted.Executable), std::move(converted.WindowsDiagnostics), std::move(converted.AppliedPatches)}; } #ifdef _WIN32 @@ -164,6 +168,93 @@ bool SameOutputDestination(const std::filesystem::path& first, const std::filesy return false; } +std::string ReadText(const std::string& path) { + const auto bytes = Io::FileReader().Read(path); + return std::string(bytes.begin(), bytes.end()); +} + +std::filesystem::path ExecutableRelative(const std::string& executable) { + const auto relative = Io::NativePath(executable).lexically_normal(); + if (relative.empty() || relative.has_root_path() || *relative.begin() == ".." || !relative.has_filename()) + throw Domain::RelinkerException("--executable must be a file inside the guest module parent directory: " + executable); + return relative; +} + +std::vector ModuleDirectories(const Cli::Args& args, const std::filesystem::path& moduleRoot) { + std::vector moduleDirectories; + for (const auto* name : {"sce_module", "sce_modules", "prx"}) moduleDirectories.push_back(moduleRoot / name); + for (const auto& name : args.moduleDirectories) moduleDirectories.push_back((moduleRoot / Io::NativePath(name)).lexically_normal()); + for (auto& directory : moduleDirectories) if (!directory.has_filename()) directory = directory.parent_path(); + return moduleDirectories; +} + +std::filesystem::path ResolveExtraExecutable(const std::filesystem::path& moduleRoot, const std::vector& moduleDirectories, const std::filesystem::path& relative, const std::string& executable) { + const auto input = moduleRoot / relative; + if (!std::filesystem::is_regular_file(input)) throw Domain::RelinkerException("--executable is not a file: " + Io::Utf8Path(input)); + if (std::any_of(moduleDirectories.begin(), moduleDirectories.end(), [&](const auto& directory) { return Cli::SameDirectory(directory, input.parent_path()); })) + throw Domain::RelinkerException("--executable is in a guest module directory: " + executable); + return input; +} + +std::vector LoadPatches(const Cli::Args& args) { + if (args.noPatches) return {}; + std::filesystem::path path; + if (!args.patchesPath.empty()) { + path = Io::NativePath(args.patchesPath); + if (!std::filesystem::is_regular_file(path)) throw Domain::RelinkerException("Patch file not found: " + args.patchesPath); + } else { + path = Io::NativePath(args.inputPath).parent_path() / Relinker::GamePatch::PatchFileName; + if (!std::filesystem::is_regular_file(path)) return {}; + } + std::cout << "Patches: " << Io::Utf8Path(path) << '\n'; + return Relinker::GamePatch::ParsePatchFile(ReadText(Io::Utf8Path(path))).Entries; +} + +int RunVerifyPatches(const Cli::Args& args) { + using namespace Relinker::GamePatch; + const auto file = ParsePatchFile(ReadText(args.verifyPatchesPath)); + Io::FileReader reader; + std::vector executables; + std::vector modules; + const auto inputPath = Io::NativePath(args.inputPath); + if (!std::filesystem::is_regular_file(inputPath)) + throw Domain::RelinkerException((args.verifyOutput ? "output not found: " : "input not found: ") + args.inputPath); + if (args.verifyOutput) { + executables.push_back({file.MainTarget.value_or(std::string()), reader.Read(args.inputPath)}); + for (const auto& executable : args.executables) { + const auto relative = ExecutableRelative(executable); + const auto output = OutputExecutablePath(inputPath, executable); + if (!std::filesystem::is_regular_file(output)) throw Domain::RelinkerException("output not found: " + Io::Utf8Path(output)); + executables.push_back({relative.generic_string(), reader.Read(Io::Utf8Path(output))}); + } + } else { + executables.push_back({Io::Utf8Path(inputPath.filename()), reader.Read(args.inputPath)}); + std::filesystem::path moduleRoot = args.executables.empty() ? std::filesystem::path() : std::filesystem::absolute(Io::NativePath(args.sceModulePath)).lexically_normal(); + if (!args.skipSceModule) { + auto native = Cli::ReadModules(std::filesystem::absolute(inputPath).lexically_normal(), Io::NativePath(args.sceModulePath), args.excludedSceModules, args.moduleDirectories); + for (auto& module : native.Modules) modules.push_back({ModuleTargetName(module.Path, native.Root), std::move(module.Bytes)}); + } + const auto moduleDirectories = ModuleDirectories(args, moduleRoot); + for (const auto& executable : args.executables) { + const auto relative = ExecutableRelative(executable); + const auto input = ResolveExtraExecutable(moduleRoot, moduleDirectories, relative, executable); + executables.push_back({relative.generic_string(), reader.Read(Io::Utf8Path(input))}); + } + } + const auto results = VerifyPatches(file, executables, modules, args.verifyOutput); + std::size_t mismatches = 0; + std::size_t notChecked = 0; + for (const auto& result : results) { + std::cout << StatusName(result.Status) << ": " << (result.Name.empty() ? std::string() : result.Name + " ") << result.Target << " va=0x" << std::hex << result.VirtualAddress; + if (result.Status != VerifyStatus::NotChecked) std::cout << " off=0x" << result.Offset; + std::cout << std::dec << ' ' << (result.Status == VerifyStatus::NotChecked ? "(" + result.Detail + ")" : result.Detail) << '\n'; + if (result.Status == VerifyStatus::Mismatch) ++mismatches; + if (result.Status == VerifyStatus::NotChecked) ++notChecked; + } + std::cout << "Verified " << results.size() << " patches: " << results.size() - mismatches - notChecked << " ok, " << notChecked << " not-checked, " << mismatches << " mismatch\n"; + return mismatches == 0 ? 0 : 1; +} + int Run(const int argc, char* argv[]) { Cli::Args args; try { @@ -180,6 +271,7 @@ int Run(const int argc, char* argv[]) { Cli::ConsoleProgress progress(std::cout); try { + if (!args.verifyPatchesPath.empty()) return RunVerifyPatches(args); auto extension = Io::Utf8Path(Io::NativePath(args.outputPath).extension()); for (auto& character : extension) if (character >= 'A' && character <= 'Z') character = static_cast(character + ('a' - 'A')); if (!args.toWindows && !args.toMacos && extension == ".exe") std::cerr << "WARNING: Output filename ends with .exe, but --windows was not specified. The output will be a Linux ELF executable.\n"; @@ -200,21 +292,11 @@ int Run(const int argc, char* argv[]) { std::vector extraExecutables; const auto outputDirectory = Io::NativePath(absPath).parent_path(); const auto moduleRoot = args.executables.empty() ? std::filesystem::path() : std::filesystem::absolute(Io::NativePath(args.sceModulePath)).lexically_normal(); - std::vector moduleDirectories; - for (const auto* name : {"sce_module", "sce_modules", "prx"}) moduleDirectories.push_back(moduleRoot / name); - for (const auto& name : args.moduleDirectories) moduleDirectories.push_back((moduleRoot / Io::NativePath(name)).lexically_normal()); - for (auto& directory : moduleDirectories) if (!directory.has_filename()) directory = directory.parent_path(); + const auto moduleDirectories = ModuleDirectories(args, moduleRoot); for (const auto& executable : args.executables) { - const auto relative = Io::NativePath(executable).lexically_normal(); - if (relative.empty() || relative.has_root_path() || *relative.begin() == ".." || !relative.has_filename()) - throw Domain::RelinkerException("--executable must be a file inside the guest module parent directory: " + executable); - const auto input = moduleRoot / relative; - if (!std::filesystem::is_regular_file(input)) throw Domain::RelinkerException("--executable is not a file: " + Io::Utf8Path(input)); - if (std::any_of(moduleDirectories.begin(), moduleDirectories.end(), [&](const auto& directory) { return Cli::SameDirectory(directory, input.parent_path()); })) - throw Domain::RelinkerException("--executable is in a guest module directory: " + executable); - std::string name; - for (const auto& part : relative) name += (name.empty() ? "" : ".") + Io::Utf8Path(part); - const auto output = outputDirectory / Io::NativePath(name + Io::Utf8Path(Io::NativePath(absPath).extension())); + const auto relative = ExecutableRelative(executable); + const auto input = ResolveExtraExecutable(moduleRoot, moduleDirectories, relative, executable); + const auto output = Relinker::GamePatch::OutputExecutablePath(Io::NativePath(absPath), executable); if (std::filesystem::equivalent(Io::NativePath(args.inputPath), input)) throw Domain::RelinkerException("Executable is converted twice: " + executable); if (output.lexically_normal() == Io::NativePath(absPath).lexically_normal()) throw Domain::RelinkerException("Executable outputs collide: " + Io::Utf8Path(output)); for (const auto& extra : extraExecutables) { @@ -224,6 +306,8 @@ int Run(const int argc, char* argv[]) { extraExecutables.push_back({executable, relative, input, output}); } + const auto patches = LoadPatches(args); + const std::string mainTarget = Io::Utf8Path(Io::NativePath(args.inputPath).filename()); progress.IncludeFile(args.inputPath); progress.IncludeFile(absPath); for (const auto& extra : extraExecutables) { @@ -231,10 +315,10 @@ int Run(const int argc, char* argv[]) { progress.IncludeFile(Io::Utf8Path(extra.Output)); } std::vector executables; - executables.push_back(Convert(args, args.inputPath, absPath, true, progress)); + executables.push_back(Convert(args, args.inputPath, absPath, true, mainTarget, patches, progress)); for (const auto& [executable, relative, input, output] : extraExecutables) { std::cout << "Executable: " << relative.generic_string() << '\n'; - auto converted = Convert(args, Io::Utf8Path(input), Io::Utf8Path(output), false, progress); + auto converted = Convert(args, Io::Utf8Path(input), Io::Utf8Path(output), false, relative.generic_string(), patches, progress); const auto& mainArtifacts = executables.front().GuestArtifacts; const bool shared = converted.GuestArtifacts.size() == mainArtifacts.size() && std::equal(mainArtifacts.begin(), mainArtifacts.end(), converted.GuestArtifacts.begin(), [](const auto& left, const auto& right) { return left.Path == right.Path && left.Bytes == right.Bytes; @@ -243,6 +327,23 @@ int Run(const int argc, char* argv[]) { executables.push_back(std::move(converted)); } const auto& guestArtifacts = executables.front().GuestArtifacts; + if (!patches.empty()) { + std::vector targetNames{mainTarget}; + for (const auto& extra : extraExecutables) targetNames.push_back(extra.Relative.generic_string()); + if (!guestArtifacts.empty()) { + const auto patchModuleRoot = std::filesystem::absolute(Io::NativePath(args.sceModulePath)).lexically_normal(); + for (const auto& artifact : guestArtifacts) targetNames.push_back(Relinker::GamePatch::ModuleTargetName(artifact.SourcePath, patchModuleRoot)); + } + Relinker::GamePatch::MatchTargets(patches, targetNames); + } + std::vector> appliedRecords; + for (const auto& executable : executables) appliedRecords.push_back(executable.AppliedPatches); + const auto appliedPatches = Relinker::GamePatch::AggregateManifest(appliedRecords); + const auto patchFile = args.patchesPath.empty() ? Io::NativePath(args.inputPath).parent_path() / Relinker::GamePatch::PatchFileName : Io::NativePath(args.patchesPath); + std::filesystem::path manifestPath; + std::string manifestText; + if (!appliedPatches.empty()) manifestPath = Io::NativePath(absPath).parent_path() / Io::NativePath(Io::Utf8Path(Io::NativePath(absPath).stem()) + Relinker::GamePatch::ManifestSuffix); + if (!manifestPath.empty()) manifestText = Relinker::GamePatch::WriteManifest(appliedPatches, mainTarget, Io::Utf8Path(patchFile)); const Elfpatcher::Linux::LinuxDesktopEntryWriter desktopEntryWriter; const auto desktopEntry = args.toWindows || args.toMacos ? std::nullopt : desktopEntryWriter.Prepare(Io::NativePath(args.inputPath).parent_path() / "sce_sys", Io::NativePath(absPath)); @@ -284,6 +385,11 @@ int Run(const int argc, char* argv[]) { } checkOutputInputs(Io::NativePath(absPath), "Executable"); + if (!manifestPath.empty()) { + checkOutputInputs(manifestPath, "Patch manifest"); + if (std::filesystem::exists(manifestPath) && std::filesystem::exists(patchFile) && std::filesystem::equivalent(manifestPath, patchFile)) + throw Domain::RelinkerException("Patch manifest output would overwrite the patch file: " + Io::Utf8Path(manifestPath)); + } for (const auto& artifact : guestArtifacts) checkOutputInputs(artifact.Path, "Guest module"); std::vector outputPaths; @@ -291,6 +397,7 @@ int Run(const int argc, char* argv[]) { for (const auto& artifact : guestArtifacts) outputPaths.push_back(artifact.Path); outputPaths.insert(outputPaths.end(), registryPaths.begin(), registryPaths.end()); outputPaths.insert(outputPaths.end(), neededPaths.begin(), neededPaths.end()); + if (!manifestPath.empty()) outputPaths.push_back(manifestPath); if (desktopEntry) { outputPaths.push_back(desktopEntry->IconPath); outputPaths.push_back(desktopEntry->EntryPath); @@ -334,7 +441,9 @@ int Run(const int argc, char* argv[]) { fileWriter.Write(Io::Utf8Path(neededPaths[executables.size() + index]), registryWriter->WriteNeededLibraries(guestArtifacts[index].NeededLibraries)); } } + if (!manifestPath.empty()) fileWriter.Write(Io::Utf8Path(manifestPath), manifestText); progress.Finish(); + if (!manifestPath.empty()) std::cout << "Patch manifest: " << Io::Utf8Path(manifestPath) << '\n'; if (desktopEntry) std::cout << "Desktop entry: " << Io::Utf8Path(desktopEntryWriter.Write(*desktopEntry)) << '\n'; std::cout << "\nConversion complete\n" << "Output file: " << absPath << '\n' diff --git a/core/relinker/relinker/include/relinker/guest/GuestImage.hpp b/core/relinker/relinker/include/relinker/guest/GuestImage.hpp index 31e5781a54..96d9aa8941 100644 --- a/core/relinker/relinker/include/relinker/guest/GuestImage.hpp +++ b/core/relinker/relinker/include/relinker/guest/GuestImage.hpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -43,6 +44,7 @@ struct GuestImage { std::vector ModuleNames; std::vector Bytes; std::vector Trampolines; + std::vector PatchedRanges; std::vector Headers; std::vector Symbols; std::vector Dependencies; @@ -72,6 +74,7 @@ struct GuestArtifact { struct GuestModuleInput { std::filesystem::path Path; std::vector Bytes; + std::vector PatchedRanges; }; class GuestModuleBuilder { diff --git a/core/relinker/relinker/include/relinker/patching/GamePatch.hpp b/core/relinker/relinker/include/relinker/patching/GamePatch.hpp new file mode 100644 index 0000000000..d893a7015e --- /dev/null +++ b/core/relinker/relinker/include/relinker/patching/GamePatch.hpp @@ -0,0 +1,131 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Relinker::GamePatch { + +inline constexpr char PatchFileName[] = "relinker-patches.toml"; +inline constexpr char ManifestSuffix[] = ".patches.toml"; + +struct PatchInteger { + bool Negative = false; + std::uint64_t Magnitude = 0; +}; + +struct PatchEntry { + std::string Target; + std::uint64_t VirtualAddress = 0; + std::vector Expected; + std::vector Value; + std::string Description; + std::string Name; + std::string Type; + PatchInteger ExpectedNumber; + PatchInteger ValueNumber; + std::optional Min; + std::optional Max; + std::optional Align; + std::size_t Line = 0; +}; + +struct PatchFile { + std::vector Entries; + std::optional MainTarget; +}; + +struct PatchedRange { + std::uint64_t Offset = 0; + std::uint64_t Length = 0; + std::uint64_t VirtualAddress = 0; +}; + +struct AppliedPatch { + std::string Target; + std::uint64_t VirtualAddress = 0; + std::uint64_t Offset = 0; + std::vector Expected; + std::vector Value; + std::string Description; + std::string Name; + std::string Type; + PatchInteger ExpectedNumber; + PatchInteger ValueNumber; + std::optional Min; + std::optional Max; + std::optional Align; +}; + +struct PatchedTarget { + std::vector Applied; + std::vector Ranges; +}; + +struct WriterRange { + std::uint64_t Offset = 0; + std::uint64_t Length = 0; + std::string Writer; +}; + +struct WriterSites { + std::span Reports; + std::span Trampolines; + std::span RelinkPatches; + std::span Traps; + std::span RelaData; + std::span RelaPltData; +}; + +enum class VerifyStatus { Applicable, Applied, NotChecked, Mismatch }; + +struct VerifyTarget { + std::string Name; + std::vector Bytes; +}; + +struct VerifyResult { + std::string Name; + std::string Target; + std::uint64_t VirtualAddress = 0; + std::uint64_t Offset = 0; + VerifyStatus Status = VerifyStatus::Mismatch; + std::string Detail; +}; + +PatchFile ParsePatchFile(std::string_view text); + +std::vector ResolveRanges(const ElfReader& reader, const std::vector& entries); + +std::vector ApplyPatches(std::vector& bytes, const std::string& target, const std::vector& entries, const std::vector& ranges); + +PatchedTarget PatchTarget(std::vector& bytes, const std::string& target, const std::vector& entries); + +void CheckOverlaps(const std::string& target, const std::vector& patched, const WriterSites& writers); + +void MatchTargets(const std::vector& entries, const std::vector& targetNames); + +std::vector AggregateManifest(const std::vector>& records); + +std::string WriteManifest(const std::vector& patches, const std::string& mainTarget, const std::string& configPath); + +std::vector VerifyPatches(const PatchFile& file, const std::vector& executables, const std::vector& modules, bool outputMode); + +std::string_view StatusName(VerifyStatus status); + +std::filesystem::path OutputExecutablePath(const std::filesystem::path& outputPath, const std::string& executableArgument); + +std::string Hex(std::span bytes); + +std::string FormatApplied(const AppliedPatch& patch); + +std::string ModuleTargetName(const std::filesystem::path& modulePath, const std::filesystem::path& moduleRoot); + +} diff --git a/core/relinker/relinker/include/relinker/pipeline/Conversion.hpp b/core/relinker/relinker/include/relinker/pipeline/Conversion.hpp index 7feb6e2939..547b975432 100644 --- a/core/relinker/relinker/include/relinker/pipeline/Conversion.hpp +++ b/core/relinker/relinker/include/relinker/pipeline/Conversion.hpp @@ -3,6 +3,7 @@ #include #include #include +#include #include namespace Relinker { @@ -24,6 +25,8 @@ struct ConversionOptions { std::optional LinuxFixedBase; std::string RunPath = "$ORIGIN/libs"; std::set ExcludedModules; + std::vector Patches; + std::string TargetName; }; struct ConversionInput { @@ -41,6 +44,7 @@ struct ConversionOutput { std::vector Modules; RelinkResult Analysis; std::string WindowsDiagnostics; + std::vector AppliedPatches; }; class Conversion { diff --git a/core/relinker/relinker/src/guest/GuestModuleBuilder.cpp b/core/relinker/relinker/src/guest/GuestModuleBuilder.cpp index 2d94dcf5dc..4bddff2c9f 100644 --- a/core/relinker/relinker/src/guest/GuestModuleBuilder.cpp +++ b/core/relinker/relinker/src/guest/GuestModuleBuilder.cpp @@ -70,7 +70,9 @@ std::vector GuestModuleBuilder::Build(const std::filesystem::path if (excludedModules.contains(Io::Utf8Path(path.filename()))) throw Domain::RelinkerException("Excluded module supplied to converter: " + Io::Utf8Path(path)); if (discovered.contains(path)) throw Domain::RelinkerException("Duplicate module path: " + Io::Utf8Path(path)); paths.push_back(path); - discovered.emplace(path, GuestImageReader().Read(path, std::move(module.Bytes), windows)); + auto image = GuestImageReader().Read(path, std::move(module.Bytes), windows); + image.PatchedRanges = std::move(module.PatchedRanges); + discovered.emplace(path, std::move(image)); } const auto neededNames = ReadNeededNames(dynamic); std::set missingNeeded; @@ -337,7 +339,9 @@ std::vector GuestModuleBuilder::Build(const std::filesystem::path auto converted = Codegen::MakeAmd64OnlyConverter(toRosetta ? Codegen::Amd64OnlyTarget::Rosetta : Codegen::Amd64OnlyTarget::Intel, [&](auto completed, auto total) { progress.Report({Domain::ProgressStage::Lowering, Io::Utf8Path(image.SourcePath), completed, total}); })->Convert(std::move(image.Bytes), image.Headers); image.Trampolines = std::move(converted.Trampolines); image.Bytes = std::move(converted.Bytes); + if (!image.PatchedRanges.empty()) GamePatch::CheckOverlaps(Io::Utf8Path(image.SourcePath), image.PatchedRanges, {.Reports = converted.Reports, .Trampolines = image.Trampolines}); } + if (!image.PatchedRanges.empty()) GamePatch::CheckOverlaps(Io::Utf8Path(image.SourcePath), image.PatchedRanges, {.RelaData = image.Dynamic.RelaData, .RelaPltData = image.Dynamic.RelaPltData}); const bool sceImage = std::any_of(image.Headers.begin(), image.Headers.end(), [](const Domain::ProgramHeader& header) { return header.Type == 0x61000000; }); const auto unwindFunctions = sceImage ? UnusedNidFilter::ReadExceptionFunctions(image.Bytes, image.Headers, {}, {}) : std::vector{}; for (const auto& header : codeHeaders) { @@ -348,6 +352,10 @@ std::vector GuestModuleBuilder::Build(const std::filesystem::path for (const auto offset : syscallScanner.ScanCodeSectionForSyscalls(code, header.MappedAddress, code.size(), Io::Utf8Path(image.SourcePath))) { if (offset > code.size() || code.size() - offset < 2u) throw Domain::RelinkerException("Syscall trap patch exceeds guest executable segment", header.MappedAddress + offset); + if (!image.PatchedRanges.empty()) { + const GamePatch::WriterRange trap{header.Offset + offset, 2, "syscall trap"}; + GamePatch::CheckOverlaps(Io::Utf8Path(image.SourcePath), image.PatchedRanges, {.Traps = std::span(&trap, 1)}); + } image.Bytes[header.Offset + offset] = 0x0F; image.Bytes[header.Offset + offset + 1] = 0x0B; } diff --git a/core/relinker/relinker/src/patching/GamePatch.cpp b/core/relinker/relinker/src/patching/GamePatch.cpp new file mode 100644 index 0000000000..4eaed00f32 --- /dev/null +++ b/core/relinker/relinker/src/patching/GamePatch.cpp @@ -0,0 +1,602 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Relinker::GamePatch { + +namespace { + +using Integer = PatchInteger; + +struct RawValue { + bool IsString = false; + std::string Text; + Integer Number; + std::size_t Line = 0; +}; + +struct IntegerType { + std::string_view Name; + unsigned Bits; + bool Signed; +}; + +constexpr std::array kIntegerTypes{{ + {"u8", 8, false}, {"u16", 16, false}, {"u32", 32, false}, {"u64", 64, false}, + {"i8", 8, true}, {"i16", 16, true}, {"i32", 32, true}, {"i64", 64, true}, +}}; + +constexpr std::array kIgnoredTopLevelKeys{"relinker_version", "relinker_commit", "config", "config_sha256", "main_target", "settings", "settings_sha256"}; +constexpr std::array kReservedNames{"format", "relinker_version", "relinker_commit", "config", "config_sha256", "main_target", "settings", "settings_sha256"}; +constexpr std::array kEntryKeys{"name", "target", "va", "type", "expected", "value", "min", "max", "align", "description"}; + +std::string HexValue(const std::uint64_t value) { + std::ostringstream stream; + stream << "0x" << std::hex << value; + return stream.str(); +} + +std::string IntegerText(const Integer& number) { + return number.Negative ? "-" + std::to_string(number.Magnitude) : HexValue(number.Magnitude); +} + +[[noreturn]] void FailLine(const std::size_t line, const std::string& message, const std::string_view text) { + throw Domain::RelinkerException("patch file line " + std::to_string(line) + ": " + message + ": `" + std::string(text) + "`"); +} + +std::string_view Trim(std::string_view text) { + constexpr std::string_view whitespace = " \t\r"; + const auto first = text.find_first_not_of(whitespace); + if (first == std::string_view::npos) return {}; + return text.substr(first, text.find_last_not_of(whitespace) - first + 1); +} + +std::string_view StripComment(const std::string_view line) { + bool inString = false; + for (std::size_t index = 0; index < line.size(); ++index) { + if (line[index] == '"') inString = !inString; + else if (line[index] == '#' && !inString) return line.substr(0, index); + } + return line; +} + +bool Contains(const auto& names, const std::string_view name) { + return std::find(names.begin(), names.end(), name) != names.end(); +} + +bool IsKeyCharacter(const char character) { + return (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') || (character >= '0' && character <= '9') || character == '_'; +} + +int HexDigit(const char character) { + if (character >= '0' && character <= '9') return character - '0'; + if (character >= 'a' && character <= 'f') return character - 'a' + 10; + if (character >= 'A' && character <= 'F') return character - 'A' + 10; + return -1; +} + +Integer ParseInteger(const std::string_view text, const std::size_t line, const std::string_view whole) { + std::string_view digits = text; + Integer number; + if (digits.starts_with('-')) { + number.Negative = true; + digits.remove_prefix(1); + } + if (digits.empty()) FailLine(line, "unsupported value", whole); + const bool hexadecimal = digits.starts_with("0x"); + if (hexadecimal) { + if (number.Negative) FailLine(line, "hexadecimal integers cannot be negative", whole); + digits.remove_prefix(2); + if (digits.empty()) FailLine(line, "unsupported value", whole); + } + if (!hexadecimal && digits.size() > 1 && digits.front() == '0') FailLine(line, "decimal integers cannot have leading zeros", whole); + for (const char character : digits) { + const int digit = HexDigit(character); + if (digit < 0 || (!hexadecimal && digit > 9)) { + if (!hexadecimal && (character == '.' || character == 'e' || character == 'E')) FailLine(line, "floats are not supported", whole); + FailLine(line, "unsupported value", whole); + } + const std::uint64_t base = hexadecimal ? 16 : 10; + if (number.Magnitude > (UINT64_MAX - static_cast(digit)) / base) FailLine(line, "integer is out of range", whole); + number.Magnitude = number.Magnitude * base + static_cast(digit); + } + if (number.Magnitude == 0) number.Negative = false; + return number; +} + +RawValue ParseValue(const std::string_view text, const std::size_t line, const std::string_view whole) { + RawValue value; + value.Line = line; + if (text.empty()) FailLine(line, "missing value", whole); + if (text.front() == '"') { + if (text.starts_with("\"\"\"")) FailLine(line, "multiline strings are not supported", whole); + const auto end = text.find('"', 1); + if (end == std::string_view::npos) FailLine(line, "unterminated string", whole); + if (end + 1 != text.size()) FailLine(line, "unexpected text after string", whole); + const auto content = text.substr(1, end - 1); + if (content.find('\\') != std::string_view::npos) FailLine(line, "escapes are not supported in strings", whole); + for (const char character : content) + if (static_cast(character) < 0x20 && character != '\t') FailLine(line, "control characters are not supported in strings", whole); + value.IsString = true; + value.Text = std::string(content); + return value; + } + if (text.front() == '\'') FailLine(line, "single-quoted strings are not supported", whole); + if (text.front() == '[') FailLine(line, "arrays are not supported", whole); + if (text.front() == '{') FailLine(line, "inline tables are not supported", whole); + if (text == "true" || text == "false") FailLine(line, "booleans are not supported", whole); + if (text == "inf" || text == "nan" || text.front() == '+') FailLine(line, "unsupported value", whole); + value.Number = ParseInteger(text, line, whole); + return value; +} + +std::vector ParseHexBytes(const RawValue& value, const std::string_view key, const std::string_view whole) { + if (!value.IsString) FailLine(value.Line, std::string(key) + " must be a hex byte string without a type", whole); + if (value.Text.empty() || value.Text.size() % 2 != 0) FailLine(value.Line, std::string(key) + " must be a nonempty even-length hex byte string", value.Text); + std::vector bytes; + for (std::size_t index = 0; index < value.Text.size(); index += 2) { + const int high = HexDigit(value.Text[index]); + const int low = HexDigit(value.Text[index + 1]); + if (high < 0 || low < 0) FailLine(value.Line, std::string(key) + " contains a non-hex character", value.Text); + bytes.push_back(static_cast(high * 16 + low)); + } + return bytes; +} + +bool IntegerLess(const Integer& left, const Integer& right) { + if (left.Negative != right.Negative) return left.Negative; + return left.Negative ? left.Magnitude > right.Magnitude : left.Magnitude < right.Magnitude; +} + +bool FitsType(const Integer& number, const IntegerType& type) { + if (!type.Signed) return !number.Negative && (type.Bits == 64 || number.Magnitude < (std::uint64_t{1} << type.Bits)); + const std::uint64_t limit = std::uint64_t{1} << (type.Bits - 1); + return number.Negative ? number.Magnitude <= limit : number.Magnitude <= limit - 1; +} + +std::vector EncodeInteger(const Integer& number, const IntegerType& type) { + const std::uint64_t raw = number.Negative ? std::uint64_t{0} - number.Magnitude : number.Magnitude; + std::vector bytes; + for (unsigned index = 0; index < type.Bits / 8; ++index) bytes.push_back(static_cast(raw >> (8 * index))); + return bytes; +} + +using Table = std::map; + +const RawValue* Find(const Table& table, const std::string& key) { + const auto found = table.find(key); + return found == table.end() ? nullptr : &found->second; +} + +PatchEntry BuildEntry(const Table& table, const std::size_t headerLine, std::set& names) { + PatchEntry entry; + entry.Line = headerLine; + const auto require = [&](const char* key) -> const RawValue& { + const auto* value = Find(table, key); + if (!value) FailLine(headerLine, std::string("missing key `") + key + "`", "[[patch]]"); + return *value; + }; + if (const auto* name = Find(table, "name")) { + if (!name->IsString || name->Text.empty() || !std::all_of(name->Text.begin(), name->Text.end(), [](const char c) { return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_'; })) + FailLine(name->Line, "name must be a nonempty string of [a-z0-9_]", name->Text); + if (Contains(kReservedNames, name->Text)) FailLine(name->Line, "name is reserved", name->Text); + if (!names.insert(name->Text).second) FailLine(name->Line, "duplicate name", name->Text); + entry.Name = name->Text; + } + const auto& target = require("target"); + if (!target.IsString || target.Text.empty()) FailLine(target.Line, "target must be a nonempty string", target.Text); + entry.Target = target.Text; + const auto& address = require("va"); + if (address.IsString || address.Number.Negative) FailLine(address.Line, "va must be a nonnegative integer", address.Text); + entry.VirtualAddress = address.Number.Magnitude; + const auto& expected = require("expected"); + const auto& replacement = require("value"); + if (const auto* description = Find(table, "description")) { + if (!description->IsString) FailLine(description->Line, "description must be a string", description->Text); + entry.Description = description->Text; + } + const auto* type = Find(table, "type"); + if (!type) { + for (const char* key : {"min", "max", "align"}) + if (const auto* value = Find(table, key)) FailLine(value->Line, std::string(key) + " requires a type", key); + entry.Expected = ParseHexBytes(expected, "expected", "expected"); + entry.Value = ParseHexBytes(replacement, "value", "value"); + if (entry.Expected.size() != entry.Value.size()) FailLine(replacement.Line, "expected and value must have the same width", "value"); + return entry; + } + if (!type->IsString) FailLine(type->Line, "type must be a string", type->Text); + const auto found = std::find_if(kIntegerTypes.begin(), kIntegerTypes.end(), [&](const IntegerType& candidate) { return candidate.Name == type->Text; }); + if (found == kIntegerTypes.end()) FailLine(type->Line, "unknown type", type->Text); + const auto integer = [&](const RawValue& value, const char* key) -> const Integer& { + if (value.IsString) FailLine(value.Line, std::string(key) + " must be an integer for a typed entry", value.Text); + return value.Number; + }; + const auto& expectedNumber = integer(expected, "expected"); + const auto& valueNumber = integer(replacement, "value"); + if (!FitsType(expectedNumber, *found)) FailLine(expected.Line, "expected does not fit " + std::string(found->Name), "expected"); + if (!FitsType(valueNumber, *found)) FailLine(replacement.Line, "value does not fit " + std::string(found->Name), "value"); + if (const auto* minimum = Find(table, "min"); minimum && IntegerLess(valueNumber, integer(*minimum, "min"))) FailLine(minimum->Line, "value is below min", "min"); + if (const auto* maximum = Find(table, "max"); maximum && IntegerLess(integer(*maximum, "max"), valueNumber)) FailLine(maximum->Line, "value is above max", "max"); + if (const auto* align = Find(table, "align")) { + const auto& alignment = integer(*align, "align"); + if (alignment.Negative || alignment.Magnitude == 0) FailLine(align->Line, "align must be positive", "align"); + if (valueNumber.Magnitude % alignment.Magnitude != 0) FailLine(align->Line, "value is not a multiple of align", "align"); + } + entry.Type = type->Text; + entry.ExpectedNumber = expectedNumber; + entry.ValueNumber = valueNumber; + if (const auto* minimum = Find(table, "min")) entry.Min = minimum->Number; + if (const auto* maximum = Find(table, "max")) entry.Max = maximum->Number; + if (const auto* align = Find(table, "align")) entry.Align = align->Number; + entry.Expected = EncodeInteger(expectedNumber, *found); + entry.Value = EncodeInteger(valueNumber, *found); + return entry; +} + +struct EntryRange { + std::optional Range; + std::string Reason; +}; + +std::uint64_t HeaderTableEnd(const ElfHeader& header) { + const std::uint64_t table = header.ProgramHeaderOffset + static_cast(header.ProgramHeaderCount) * header.ProgramHeaderEntrySize; + return std::max(64, table); +} + +EntryRange ResolveEntry(const std::vector& segments, const std::uint64_t fileSize, const std::uint64_t headerEnd, const PatchEntry& entry) { + const std::uint64_t length = entry.Expected.size(); + for (const auto& segment : segments) { + if (segment.Type != Elfpatcher::PT_LOAD) continue; + if (entry.VirtualAddress < segment.MappedAddress) continue; + const std::uint64_t delta = entry.VirtualAddress - segment.MappedAddress; + if (delta >= segment.FileSize || length > segment.FileSize - delta) continue; + if (segment.Offset > fileSize || segment.FileSize > fileSize - segment.Offset) return {std::nullopt, "PT_LOAD file mapping is out of bounds"}; + const PatchedRange range{segment.Offset + delta, length, entry.VirtualAddress}; + if (range.Offset < headerEnd) return {std::nullopt, "range overlaps the ELF header or program header table"}; + return {range, {}}; + } + return {std::nullopt, "range is not inside the file bytes of one PT_LOAD"}; +} + +std::vector ResolveEach(const ElfReader& reader, const std::vector& entries) { + const auto segments = reader.ReadProgramHeaders(); + const auto headerEnd = HeaderTableEnd(reader.ReadHeader()); + std::vector resolved; + for (const auto& entry : entries) resolved.push_back(ResolveEntry(segments, reader.GetFileSize(), headerEnd, entry)); + return resolved; +} + +std::string Describe(const PatchEntry& entry) { + return "patch " + entry.Target + " va=" + HexValue(entry.VirtualAddress) + " (line " + std::to_string(entry.Line) + ")"; +} + +bool Overlaps(const std::uint64_t firstOffset, const std::uint64_t firstLength, const std::uint64_t secondOffset, const std::uint64_t secondLength) { + return firstOffset < secondOffset + secondLength && secondOffset < firstOffset + firstLength; +} + +void RejectEntryOverlaps(const std::vector& entries, const std::vector& resolved) { + for (std::size_t index = 0; index < entries.size(); ++index) { + if (!resolved[index].Range) continue; + for (std::size_t previous = 0; previous < index; ++previous) { + if (!resolved[previous].Range) continue; + const auto& first = *resolved[previous].Range; + const auto& second = *resolved[index].Range; + if (Overlaps(first.Offset, first.Length, second.Offset, second.Length)) + throw Domain::RelinkerException(Describe(entries[index]) + " overlaps " + Describe(entries[previous])); + } + } +} + +bool IsElfDyn(const std::vector& bytes) { + return bytes.size() >= 18 && bytes[0] == 0x7f && bytes[1] == 'E' && bytes[2] == 'L' && bytes[3] == 'F' && bytes[16] == Elfpatcher::ET_DYN && bytes[17] == 0; +} + +VerifyResult VerifyOne(const PatchEntry& entry, const EntryRange& resolved, const std::vector& bytes, const bool outputMode) { + VerifyResult result; + result.Name = entry.Name; + result.Target = entry.Target; + result.VirtualAddress = entry.VirtualAddress; + if (!resolved.Range) { + result.Detail = resolved.Reason; + return result; + } + result.Offset = resolved.Range->Offset; + const auto first = bytes.begin() + static_cast(resolved.Range->Offset); + const std::vector found(first, first + static_cast(resolved.Range->Length)); + const auto& wanted = outputMode ? entry.Value : entry.Expected; + const auto& other = outputMode ? entry.Expected : entry.Value; + if (found == wanted) { + result.Status = outputMode ? VerifyStatus::Applied : VerifyStatus::Applicable; + result.Detail = Hex(found); + } else if (found == other) { + result.Detail = outputMode ? "patch is not applied: found " + Hex(found) : "input already holds the patched value " + Hex(found); + } else { + result.Detail = "expected " + Hex(wanted) + " found " + Hex(found); + } + return result; +} + +const VerifyTarget* FindTarget(const std::vector& targets, const std::string& name) { + const auto found = std::find_if(targets.begin(), targets.end(), [&](const VerifyTarget& target) { return target.Name == name; }); + return found == targets.end() ? nullptr : &*found; +} + +} + +std::string Hex(const std::span bytes) { + constexpr char digits[] = "0123456789abcdef"; + std::string text; + for (const auto byte : bytes) { + text += digits[byte >> 4]; + text += digits[byte & 15]; + } + return text; +} + +std::string FormatApplied(const AppliedPatch& patch) { + std::string text = "Patch: " + patch.Target + (patch.Name.empty() ? std::string() : " [" + patch.Name + "]") + " va=" + HexValue(patch.VirtualAddress) + " off=" + HexValue(patch.Offset) + " " + Hex(patch.Expected) + " -> " + Hex(patch.Value); + if (!patch.Description.empty()) text += " (" + patch.Description + ")"; + return text; +} + +std::string ModuleTargetName(const std::filesystem::path& modulePath, const std::filesystem::path& moduleRoot) { + auto name = Io::Utf8Path(modulePath.lexically_normal().lexically_relative(moduleRoot.lexically_normal())); + std::replace(name.begin(), name.end(), '\\', '/'); + return name; +} + +PatchFile ParsePatchFile(const std::string_view text) { + PatchFile file; + Table top; + Table current; + std::size_t entryLine = 0; + std::set names; + bool inEntry = false; + const auto finishEntry = [&] { + if (inEntry) file.Entries.push_back(BuildEntry(current, entryLine, names)); + current.clear(); + }; + std::size_t lineNumber = 0; + std::size_t position = 0; + while (position <= text.size()) { + auto end = text.find('\n', position); + if (end == std::string_view::npos) end = text.size(); + const auto whole = text.substr(position, end - position); + position = end + 1; + ++lineNumber; + const auto line = Trim(StripComment(whole)); + if (line.empty()) continue; + if (line.front() == '[') { + if (line != "[[patch]]") FailLine(lineNumber, "unsupported table header", Trim(whole)); + finishEntry(); + inEntry = true; + entryLine = lineNumber; + continue; + } + std::size_t keyEnd = 0; + while (keyEnd < line.size() && IsKeyCharacter(line[keyEnd])) ++keyEnd; + const std::string key(line.substr(0, keyEnd)); + const auto rest = Trim(line.substr(keyEnd)); + if (key.empty() || rest.empty() || rest.front() != '=') { + if (!rest.empty() && rest.front() == '.') FailLine(lineNumber, "dotted keys are not supported", line); + if (key.empty() && (line.front() == '"' || line.front() == '\'')) FailLine(lineNumber, "quoted keys are not supported", line); + FailLine(lineNumber, "expected `key = value`", line); + } + auto value = ParseValue(Trim(rest.substr(1)), lineNumber, line); + auto& table = inEntry ? current : top; + if (inEntry ? !Contains(kEntryKeys, key) : !(key == "format" || Contains(kIgnoredTopLevelKeys, key))) + FailLine(lineNumber, inEntry ? "unknown key" : "unknown top-level key", line); + if (table.contains(key)) FailLine(lineNumber, "duplicate key", line); + table.emplace(key, std::move(value)); + } + finishEntry(); + const auto* format = Find(top, "format"); + if (!format) throw Domain::RelinkerException("patch file: `format = 1` is required"); + if (format->IsString || format->Number.Negative || format->Number.Magnitude != 1) FailLine(format->Line, "unsupported format", "format"); + if (const auto* mainTarget = Find(top, "main_target")) { + if (!mainTarget->IsString || mainTarget->Text.empty()) FailLine(mainTarget->Line, "main_target must be a nonempty string", "main_target"); + file.MainTarget = mainTarget->Text; + } + return file; +} + +std::vector ResolveRanges(const ElfReader& reader, const std::vector& entries) { + const auto resolved = ResolveEach(reader, entries); + std::vector ranges; + for (std::size_t index = 0; index < entries.size(); ++index) { + if (!resolved[index].Range) throw Domain::RelinkerException(Describe(entries[index]) + ": " + resolved[index].Reason); + ranges.push_back(*resolved[index].Range); + } + RejectEntryOverlaps(entries, resolved); + return ranges; +} + +std::vector ApplyPatches(std::vector& bytes, const std::string& target, const std::vector& entries, const std::vector& ranges) { + if (entries.size() != ranges.size()) throw Domain::RelinkerException("Patch ranges do not match patch entries"); + for (std::size_t index = 0; index < entries.size(); ++index) { + const auto& range = ranges[index]; + if (range.Offset > bytes.size() || range.Length > bytes.size() - range.Offset) throw Domain::RelinkerException(Describe(entries[index]) + ": range exceeds the image", range.Offset); + const auto first = bytes.begin() + static_cast(range.Offset); + if (!std::equal(entries[index].Expected.begin(), entries[index].Expected.end(), first)) + throw Domain::RelinkerException(Describe(entries[index]) + " off=" + HexValue(range.Offset) + ": expected " + Hex(entries[index].Expected) + " found " + Hex(std::span(&*first, range.Length)), range.Offset); + } + std::vector applied; + for (std::size_t index = 0; index < entries.size(); ++index) { + std::copy(entries[index].Value.begin(), entries[index].Value.end(), bytes.begin() + static_cast(ranges[index].Offset)); + applied.push_back({target, entries[index].VirtualAddress, ranges[index].Offset, entries[index].Expected, entries[index].Value, entries[index].Description, entries[index].Name, entries[index].Type, entries[index].ExpectedNumber, entries[index].ValueNumber, entries[index].Min, entries[index].Max, entries[index].Align}); + } + return applied; +} + +PatchedTarget PatchTarget(std::vector& bytes, const std::string& target, const std::vector& entries) { + std::vector own; + for (const auto& entry : entries) if (entry.Target == target) own.push_back(entry); + if (own.empty()) return {}; + ElfReader reader(std::move(bytes)); + PatchedTarget patched; + try { + patched.Ranges = ResolveRanges(reader, own); + } catch (...) { + bytes = reader.TakeBytes(); + throw; + } + bytes = reader.TakeBytes(); + patched.Applied = ApplyPatches(bytes, target, own, patched.Ranges); + return patched; +} + +void CheckOverlaps(const std::string& target, const std::vector& patched, const WriterSites& writers) { + if (patched.empty()) return; + std::vector sites; + for (const auto& report : writers.Reports) + if (report.Lowering != Codegen::Amd64OnlyLowering::Kept) sites.push_back({report.Offset, report.OriginalLength, "Intel in-place report"}); + for (const auto& site : writers.Trampolines) sites.push_back({site.Offset, site.Length, "Intel trampoline site"}); + for (const auto& patch : writers.RelinkPatches) sites.push_back({patch.Offset, patch.Bytes.size(), "relinker patch"}); + sites.insert(sites.end(), writers.Traps.begin(), writers.Traps.end()); + const auto relaViolation = [&](const std::span table, const char* name, const PatchedRange& range) { + for (std::size_t position = 0; position + 24 <= table.size(); position += 24) { + std::uint64_t relocation = 0; + for (unsigned byte = 0; byte < 8; ++byte) relocation |= static_cast(table[position + byte]) << (8 * byte); + if (Overlaps(range.VirtualAddress, range.Length, relocation, 8)) + throw Domain::RelinkerException("patch " + target + " va=" + HexValue(range.VirtualAddress) + " off=" + HexValue(range.Offset) + " overlaps " + name + " relocation target at va=" + HexValue(relocation), range.Offset); + } + }; + for (const auto& range : patched) { + relaViolation(writers.RelaData, "RelaData", range); + relaViolation(writers.RelaPltData, "RelaPltData", range); + } + for (const auto& range : patched) + for (const auto& site : sites) + if (Overlaps(range.Offset, range.Length, site.Offset, site.Length)) + throw Domain::RelinkerException("patch " + target + " va=" + HexValue(range.VirtualAddress) + " off=" + HexValue(range.Offset) + " overlaps " + site.Writer + " at off=" + HexValue(site.Offset) + " length " + std::to_string(site.Length), range.Offset); +} + +void MatchTargets(const std::vector& entries, const std::vector& targetNames) { + for (const auto& entry : entries) { + const auto count = std::count(targetNames.begin(), targetNames.end(), entry.Target); + if (count == 0) throw Domain::RelinkerException("unmatched patch target: " + entry.Target + " (patch file line " + std::to_string(entry.Line) + ")"); + if (count > 1) throw Domain::RelinkerException("ambiguous patch target: " + entry.Target + " (patch file line " + std::to_string(entry.Line) + ")"); + } +} + +std::vector AggregateManifest(const std::vector>& records) { + std::vector merged; + for (const auto& group : records) { + for (const auto& patch : group) { + const bool known = std::any_of(merged.begin(), merged.end(), [&](const AppliedPatch& existing) { + return existing.Target == patch.Target && existing.VirtualAddress == patch.VirtualAddress && existing.Offset == patch.Offset; + }); + if (!known) merged.push_back(patch); + } + } + return merged; +} + +std::string WriteManifest(const std::vector& patches, const std::string& mainTarget, const std::string& configPath) { + const auto holdable = [](const std::string& text) { + return std::none_of(text.begin(), text.end(), [](const char character) { + return character == '"' || character == '\\' || (static_cast(character) < 0x20 && character != '\t') || character == 0x7f; + }); + }; + const auto quoted = [&](const std::string& text, const char* field) { + if (!holdable(text)) throw Domain::RelinkerException(std::string("Patch manifest cannot hold the ") + field + ": " + text); + return "\"" + text + "\""; + }; + std::string config = std::filesystem::path(Io::NativePath(configPath)).generic_string(); + if (!config.empty() && !holdable(config)) config = std::filesystem::path(Io::NativePath(configPath)).filename().generic_string(); + std::ostringstream manifest; + manifest << "format = 1\nmain_target = " << quoted(mainTarget, "main_target") << '\n'; + if (!config.empty()) manifest << "config = " << quoted(config, "config") << '\n'; + for (const auto& patch : patches) { + manifest << "\n[[patch]]\n"; + if (!patch.Name.empty()) manifest << "name = " << quoted(patch.Name, "name") << '\n'; + manifest << "target = " << quoted(patch.Target, "target") << "\nva = " << HexValue(patch.VirtualAddress) << '\n'; + if (patch.Type.empty()) { + manifest << "expected = \"" << Hex(patch.Expected) << "\"\nvalue = \"" << Hex(patch.Value) << "\"\n"; + } else { + manifest << "type = " << quoted(patch.Type, "type") << "\nexpected = " << IntegerText(patch.ExpectedNumber) << "\nvalue = " << IntegerText(patch.ValueNumber) << '\n'; + if (patch.Min) manifest << "min = " << IntegerText(*patch.Min) << '\n'; + if (patch.Max) manifest << "max = " << IntegerText(*patch.Max) << '\n'; + if (patch.Align) manifest << "align = " << IntegerText(*patch.Align) << '\n'; + } + if (!patch.Description.empty()) manifest << "description = " << quoted(patch.Description, "description") << '\n'; + } + return manifest.str(); +} + +std::string_view StatusName(const VerifyStatus status) { + switch (status) { + case VerifyStatus::Applicable: return "applicable"; + case VerifyStatus::Applied: return "applied"; + case VerifyStatus::NotChecked: return "not-checked"; + case VerifyStatus::Mismatch: break; + } + return "mismatch"; +} + +std::vector VerifyPatches(const PatchFile& file, const std::vector& executables, const std::vector& modules, const bool outputMode) { + if (outputMode) { + if (!file.MainTarget) throw Domain::RelinkerException("The patch file has no main_target, so its entries cannot be matched to an output: verify the input executable, or verify the manifest written by the conversion"); + for (std::size_t index = 0; index < executables.size(); ++index) + for (std::size_t other = index + 1; other < executables.size(); ++other) + if (executables[index].Name == executables[other].Name) throw Domain::RelinkerException("ambiguous patch target: " + executables[index].Name); + for (const auto& executable : executables) + if (!IsElfDyn(executable.Bytes)) throw Domain::RelinkerException(executable.Name + " is not an ET_DYN Linux ELF output (ET_EXEC from --linux-fixed-base, PE and Mach-O outputs are not supported): verify the input instead"); + } else { + std::vector names; + for (const auto& executable : executables) names.push_back(executable.Name); + for (const auto& module : modules) names.push_back(module.Name); + try { + MatchTargets(file.Entries, names); + } catch (const Domain::RelinkerException& error) { + if (std::string_view(error.what()).starts_with("unmatched")) throw Domain::RelinkerException(std::string(error.what()) + "; to check a converted output, use --verify-output"); + throw; + } + } + std::vector results(file.Entries.size()); + std::vector done(file.Entries.size()); + for (std::size_t index = 0; index < file.Entries.size(); ++index) { + if (done[index]) continue; + const auto& name = file.Entries[index].Target; + const auto* target = FindTarget(executables, name); + if (!target && !outputMode) target = FindTarget(modules, name); + std::vector indices; + for (std::size_t other = index; other < file.Entries.size(); ++other) if (file.Entries[other].Target == name) indices.push_back(other); + if (!target) { + for (const auto other : indices) { + const auto& entry = file.Entries[other]; + results[other] = {entry.Name, entry.Target, entry.VirtualAddress, 0, VerifyStatus::NotChecked, "module, or executable not named with --executable"}; + done[other] = true; + } + continue; + } + std::vector own; + for (const auto other : indices) own.push_back(file.Entries[other]); + const ElfReader reader(target->Bytes); + const auto resolved = ResolveEach(reader, own); + RejectEntryOverlaps(own, resolved); + for (std::size_t position = 0; position < indices.size(); ++position) { + results[indices[position]] = VerifyOne(own[position], resolved[position], target->Bytes, outputMode); + done[indices[position]] = true; + } + } + return results; +} + +std::filesystem::path OutputExecutablePath(const std::filesystem::path& outputPath, const std::string& executableArgument) { + const auto relative = Io::NativePath(executableArgument).lexically_normal(); + if (relative.empty() || relative.has_root_path() || *relative.begin() == ".." || !relative.has_filename()) + throw Domain::RelinkerException("--executable must be a file inside the guest module parent directory: " + executableArgument); + std::string name; + for (const auto& part : relative) name += (name.empty() ? "" : ".") + Io::Utf8Path(part); + return outputPath.parent_path() / Io::NativePath(name + Io::Utf8Path(outputPath.extension())); +} + +} diff --git a/core/relinker/relinker/src/pipeline/Conversion.cpp b/core/relinker/relinker/src/pipeline/Conversion.cpp index 5fda86d3ae..724e1dfaba 100644 --- a/core/relinker/relinker/src/pipeline/Conversion.cpp +++ b/core/relinker/relinker/src/pipeline/Conversion.cpp @@ -29,6 +29,7 @@ #include #include #include +#include namespace Relinker { @@ -49,12 +50,23 @@ ConversionOutput Conversion::Convert(ConversionInput input, const ConversionOpti const bool macos = options.Target == TargetSystem::MacOs; inputProgress.Report({Domain::ProgressStage::Executable, Io::Utf8Path(input.InputPath), 0, 1}); auto sourceBytes = std::move(input.Executable); + std::vector appliedPatches; + std::vector patchedRanges; + if (!options.Patches.empty()) { + auto patched = GamePatch::PatchTarget(sourceBytes, options.TargetName, options.Patches); + patchedRanges = std::move(patched.Ranges); + for (auto& patch : patched.Applied) { + inputProgress.Message(GamePatch::FormatApplied(patch) + "\n"); + appliedPatches.push_back(std::move(patch)); + } + } std::vector trampolines; if (options.ToIntel) { const auto segments = Relinker::ElfReader(sourceBytes).ReadProgramHeaders(); auto converted = Codegen::MakeAmd64OnlyConverter(options.ToRosetta ? Codegen::Amd64OnlyTarget::Rosetta : Codegen::Amd64OnlyTarget::Intel, [&](auto completed, auto total) { inputProgress.Report({Domain::ProgressStage::Lowering, {}, completed, total}); })->Convert(std::move(sourceBytes), segments); sourceBytes = std::move(converted.Bytes); trampolines = std::move(converted.Trampolines); + GamePatch::CheckOverlaps(options.TargetName, patchedRanges, {.Reports = converted.Reports, .Trampolines = trampolines}); std::map stubsByName; const auto counted = [](const std::string& name) { return name == "VRSQRTPS" || name == "VRCPPS" || name == "256-bit store" || name == "scalar compare"; }; for (const auto& report : converted.Reports) { @@ -92,6 +104,18 @@ ConversionOutput Conversion::Convert(ConversionInput input, const ConversionOpti throw Domain::RelinkerException("Relinker patch exceeds source image", patch.Offset); for (std::size_t index = 0; index < patch.Bytes.size(); ++index) sourceBytes[patch.Offset + index] = patch.Bytes[index]; } + GamePatch::CheckOverlaps(options.TargetName, patchedRanges, {.RelinkPatches = result.Patches, .RelaData = result.DynamicSection.RelaData, .RelaPltData = result.DynamicSection.RelaPltData}); + + if (!options.Patches.empty()) { + for (auto& module : input.Modules) { + auto patched = GamePatch::PatchTarget(module.Bytes, GamePatch::ModuleTargetName(module.Path, input.ModuleRoot), options.Patches); + module.PatchedRanges = std::move(patched.Ranges); + for (auto& patch : patched.Applied) { + inputProgress.Message(GamePatch::FormatApplied(patch) + "\n"); + appliedPatches.push_back(std::move(patch)); + } + } + } std::vector guestArtifacts; if (!input.Modules.empty()) { @@ -131,7 +155,7 @@ ConversionOutput Conversion::Convert(ConversionInput input, const ConversionOpti std::string diagnostics; if (windows) diagnostics = std::static_pointer_cast(patcher)->GetDiagnostics(); inputProgress.Report({Domain::ProgressStage::Executable, Io::Utf8Path(input.InputPath), 1, 1}); - return {std::move(executableBytes), std::move(guestArtifacts), std::move(result), std::move(diagnostics)}; + return {std::move(executableBytes), std::move(guestArtifacts), std::move(result), std::move(diagnostics), std::move(appliedPatches)}; } catch (Domain::RelinkerException& error) { if (error.InputPath.empty()) error.InputPath = Io::Utf8Path(input.InputPath); throw; diff --git a/core/relinker/relinker/tests/GamePatchTests.cpp b/core/relinker/relinker/tests/GamePatchTests.cpp new file mode 100644 index 0000000000..57e0f26043 --- /dev/null +++ b/core/relinker/relinker/tests/GamePatchTests.cpp @@ -0,0 +1,522 @@ +#include "TestProgress.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace Relinker; +using namespace Relinker::GamePatch; + +void require(const bool condition, const std::string& message) { + if (!condition) throw std::runtime_error(message); +} + +template +void rejects(TAction action, const std::string& fragment) { + try { + action(); + } catch (const Domain::RelinkerException& error) { + require(std::string(error.what()).find(fragment) != std::string::npos, "wrong error, expected `" + fragment + "` in: " + error.what()); + return; + } + throw std::runtime_error("accepted: expected `" + fragment + "`"); +} + +std::string header(const std::string& body) { + return "format = 1\n" + body; +} + +void rejectsText(const std::string& text, const std::size_t line, const std::string& fragment = {}) { + rejects([&] { ParsePatchFile(text); }, "line " + std::to_string(line) + ":"); + if (!fragment.empty()) rejects([&] { ParsePatchFile(text); }, fragment); +} + +PatchEntry entry(const std::string& target, const std::uint64_t address, std::vector expected, std::vector value, const std::string& name = {}) { + PatchEntry result; + result.Target = target; + result.VirtualAddress = address; + result.Expected = std::move(expected); + result.Value = std::move(value); + result.Name = name; + result.Line = 1; + return result; +} + +std::vector twoSegmentFixture() { + std::vector bytes(0x3000); + const std::array magic{0x7f, 'E', 'L', 'F', 2, 1, 1}; + std::copy(magic.begin(), magic.end(), bytes.begin()); + Io::WriteU16(bytes, 16, 3); + Io::WriteU16(bytes, 18, 62); + Io::WriteU32(bytes, 20, 1); + Io::WriteU64(bytes, 32, 64); + Io::WriteU16(bytes, 52, 64); + Io::WriteU16(bytes, 54, 56); + Io::WriteU16(bytes, 56, 2); + const auto segment = [&](const std::size_t index, const std::uint64_t offset, const std::uint64_t address, const std::uint64_t fileSize, const std::uint64_t memorySize) { + const auto at = 64 + index * 56; + Io::WriteU32(bytes, at, 1); + Io::WriteU32(bytes, at + 4, 6); + Io::WriteU64(bytes, at + 8, offset); + Io::WriteU64(bytes, at + 16, address); + Io::WriteU64(bytes, at + 24, address); + Io::WriteU64(bytes, at + 32, fileSize); + Io::WriteU64(bytes, at + 40, memorySize); + Io::WriteU64(bytes, at + 48, 8); + }; + segment(0, 0, 0, 0x1100, 0x1100); + segment(1, 0x2000, 0x1100, 0x100, 0x300); + for (std::size_t index = 0; index < 0x100; ++index) { + bytes[0x1000 + index] = static_cast(index); + bytes[0x2000 + index] = static_cast(0x80 + index); + } + return bytes; +} + +std::vector conversionFixture() { + std::vector bytes(0x3000); + const std::array magic{0x7f, 'E', 'L', 'F', 2, 1, 1}; + std::copy(magic.begin(), magic.end(), bytes.begin()); + Io::WriteU16(bytes, 16, 3); + Io::WriteU16(bytes, 18, 62); + Io::WriteU32(bytes, 20, 1); + Io::WriteU64(bytes, 24, 0x1000); + Io::WriteU64(bytes, 32, 64); + Io::WriteU16(bytes, 52, 64); + Io::WriteU16(bytes, 54, 56); + Io::WriteU16(bytes, 56, 8); + Io::WriteU16(bytes, 58, 64); + const auto programHeader = [&](std::size_t index, unsigned type, unsigned flags, unsigned offset, unsigned size) { + const auto at = 64 + index * 56; + Io::WriteU32(bytes, at, type); + Io::WriteU32(bytes, at + 4, flags); + Io::WriteU64(bytes, at + 8, offset); + Io::WriteU64(bytes, at + 16, offset); + Io::WriteU64(bytes, at + 24, offset); + Io::WriteU64(bytes, at + 32, size); + Io::WriteU64(bytes, at + 40, size); + Io::WriteU64(bytes, at + 48, 8); + }; + programHeader(0, 1, 5, 0x1000, 8); + programHeader(1, 1, 6, 0x2000, 0x1000); + for (std::size_t index = 3; index < 8; ++index) programHeader(index, 0x6fffff01, 0, 0, 0); + bytes[0x1000] = 0xc3; + const std::array, 9> tags{{{5, 0x2600}, {10, 1}, {6, 0x2620}, {11, 24}, {7, 0x2700}, {8, 0}, {9, 24}, {4, 0x2680}, {0, 0}}}; + programHeader(2, 2, 6, 0x2000, tags.size() * 16); + for (std::size_t index = 0; index < tags.size(); ++index) { + Io::WriteU64(bytes, 0x2000 + index * 16, tags[index].first); + Io::WriteU64(bytes, 0x2008 + index * 16, tags[index].second); + } + Io::WriteU32(bytes, 0x2680, 1); + Io::WriteU32(bytes, 0x2684, 1); + return bytes; +} + +ConversionInput conversionInput(const std::vector& executable) { + ConversionInput result; + result.Executable = executable; + result.InputPath = "virtual/title/custom-name.bin"; + result.OutputPath = "virtual/output/title"; + result.ModuleRoot = "virtual/title"; + result.Modules.push_back({"virtual/title/sce_module/plugin.prx", conversionFixture()}); + return result; +} + +void grammarAccepted() { + const auto file = ParsePatchFile( + "# leading comment\n" + "format = 1 # trailing comment\n" + "relinker_version = \"x\"\n" + "relinker_commit = \"y\"\n" + "config = \"z\"\n" + "config_sha256 = \"w\"\n" + "main_target = \"eboot.elf\"\n" + "\n" + "[[patch]]\n" + "name = \"pool_size\"\n" + "target = \"eboot.elf\"\n" + "va = 0x1234\n" + "type = \"i32\"\n" + "expected = 0x10000000\n" + "value = 0x18000000\n" + "max = 0x7f000000\n" + "align = 0x1000\n" + "description = \"has # inside\"\n" + "\n" + "[[patch]]\n" + "target = \"sce_module/libc.prx\"\n" + "va = 4096\n" + "expected = \"00ff\"\n" + "value = \"AB12\"\n"); + require(file.MainTarget == std::string("eboot.elf"), "main_target was not read"); + require(file.Entries.size() == 2, "wrong entry count"); + const auto& typed = file.Entries[0]; + require(typed.Name == "pool_size" && typed.Target == "eboot.elf" && typed.VirtualAddress == 0x1234, "typed entry fields"); + require(typed.Expected == std::vector({0, 0, 0, 0x10}) && typed.Value == std::vector({0, 0, 0, 0x18}), "typed encoding is not little-endian"); + require(typed.Description == "has # inside" && typed.Line == 9, "description or line"); + const auto& raw = file.Entries[1]; + require(raw.Name.empty() && raw.VirtualAddress == 4096, "raw entry fields"); + require(raw.Expected == std::vector({0x00, 0xff}) && raw.Value == std::vector({0xab, 0x12}), "raw hex bytes"); + require(!ParsePatchFile("format = 1\n").MainTarget && ParsePatchFile("format = 1\n").Entries.empty(), "empty file"); +} + +void grammarRejected() { + rejects([] { ParsePatchFile(""); }, "format = 1"); + rejectsText("format = 2\n", 1, "unsupported format"); + rejectsText(header("flavour = 1\n"), 2, "unknown top-level key"); + rejectsText(header("[[patch]]\ntarget = \"a\"\nva = 1\nexpected = \"00\"\nvalue = \"01\"\ncolour = 1\n"), 7, "unknown key"); + rejectsText(header("[[patch]]\ntarget = \"a\"\ntarget = \"b\"\n"), 4, "duplicate key"); + rejectsText(header("[patch]\n"), 2, "unsupported table header"); + rejectsText(header("[other]\n"), 2, "unsupported table header"); + rejectsText(header("[[patch]]\ndescription = \"a\\nb\"\n"), 3, "escapes"); + rejectsText(header("[[patch]]\ndescription = [1, 2]\n"), 3, "arrays"); + rejectsText(header("[[patch]]\ndescription = 'x'\n"), 3, "single-quoted"); + rejectsText(header("[[patch]]\ndescription = { a = 1 }\n"), 3, "inline tables"); + rejectsText(header("[[patch]]\ndescription = \"\"\"x\"\"\"\n"), 3, "multiline"); + rejectsText(header("[[patch]]\nva = 1.5\n"), 3, "floats"); + rejectsText(header("[[patch]]\nva = true\n"), 3, "booleans"); + rejectsText(header("[[patch]]\nsub.key = 1\n"), 3, "dotted keys"); + rejectsText(header("[[patch]]\n\"quoted\" = 1\n"), 3, "quoted keys"); + rejectsText(header("[[patch]]\nva\n"), 3, "key = value"); + rejectsText(header("[[patch]]\nva = 1_000\n"), 3); + rejectsText(header("[[patch]]\nva = \"unterminated\n"), 3, "unterminated"); +} + +std::string typedEntry(const std::string& type, const std::string& expected, const std::string& value, const std::string& extra = {}) { + return header("[[patch]]\ntarget = \"a\"\nva = 1\ntype = \"" + type + "\"\nexpected = " + expected + "\nvalue = " + value + "\n" + extra); +} + +void integerRanges() { + struct Case { const char* Type; const char* Fits; const char* Overflows; std::size_t Width; }; + for (const auto& c : {Case{"u8", "255", "256", 1}, Case{"u16", "65535", "65536", 2}, Case{"u32", "0xffffffff", "0x100000000", 4}, Case{"u64", "0xffffffffffffffff", "0x10000000000000000", 8}, + Case{"i8", "127", "128", 1}, Case{"i16", "32767", "32768", 2}, Case{"i32", "0x7fffffff", "0x80000000", 4}, Case{"i64", "0x7fffffffffffffff", "0x8000000000000000", 8}}) { + const auto accepted = ParsePatchFile(typedEntry(c.Type, "0", c.Fits)); + require(accepted.Entries.front().Value.size() == c.Width, std::string("width of ") + c.Type); + rejects([&] { ParsePatchFile(typedEntry(c.Type, "0", c.Overflows)); }, "line 7"); + } + require(ParsePatchFile(typedEntry("i32", "-1", "-2147483648")).Entries.front().Value == std::vector({0, 0, 0, 0x80}), "i32 minimum"); + require(ParsePatchFile(typedEntry("i8", "-128", "-1")).Entries.front().Value == std::vector({0xff}), "i8 -1"); + require(ParsePatchFile(typedEntry("i64", "-1", "-1")).Entries.front().Expected == std::vector(8, 0xff), "i64 -1"); + rejects([] { ParsePatchFile(typedEntry("i8", "0", "-129")); }, "line 7"); + rejects([] { ParsePatchFile(typedEntry("u8", "0", "-1")); }, "line 7"); + rejects([] { ParsePatchFile(typedEntry("i16", "40000", "0")); }, "line 6"); + rejects([] { ParsePatchFile(typedEntry("f32", "0", "0")); }, "unknown type"); + rejects([] { ParsePatchFile(typedEntry("u32", "\"00\"", "1")); }, "integer"); +} + +void boundsAndWidth() { + ParsePatchFile(typedEntry("u32", "0", "8", "min = 8\nmax = 8\nalign = 8\n")); + rejects([] { ParsePatchFile(typedEntry("u32", "0", "7", "min = 8\n")); }, "below min"); + rejects([] { ParsePatchFile(typedEntry("u32", "0", "9", "max = 8\n")); }, "above max"); + rejects([] { ParsePatchFile(typedEntry("u32", "0", "12", "align = 8\n")); }, "multiple of align"); + rejects([] { ParsePatchFile(typedEntry("u32", "0", "8", "align = 0\n")); }, "align must be positive"); + ParsePatchFile(typedEntry("i32", "0", "-2097152", "align = 2097152\n")); + rejects([] { ParsePatchFile(typedEntry("i32", "0", "-1048576", "align = 2097152\n")); }, "multiple of align"); + rejects([] { ParsePatchFile(typedEntry("u32", "0", "768MiB")); }, "line 7"); + rejects([] { ParsePatchFile(typedEntry("u32", "0", "007")); }, "leading zeros"); + ParsePatchFile(typedEntry("u32", "0", "0")); + ParsePatchFile(typedEntry("i32", "-0", "10")); + rejects([] { ParsePatchFile(header("[[patch]]\ntarget = \"a\"\nva = 1\nexpected = \"00\"\nvalue = \"0000\"\nmin = 1\n")); }, "requires a type"); + rejects([] { ParsePatchFile(header("[[patch]]\ntarget = \"a\"\nva = 1\nexpected = \"00\"\nvalue = \"0000\"\n")); }, "same width"); + rejects([] { ParsePatchFile(header("[[patch]]\ntarget = \"a\"\nva = 1\nexpected = \"0\"\nvalue = \"1\"\n")); }, "even-length"); + rejects([] { ParsePatchFile(header("[[patch]]\ntarget = \"a\"\nva = 1\nexpected = \"zz\"\nvalue = \"00\"\n")); }, "non-hex"); + rejects([] { ParsePatchFile(header("[[patch]]\nva = 1\nexpected = \"00\"\nvalue = \"01\"\n")); }, "missing key `target`"); + rejects([] { ParsePatchFile(header("[[patch]]\ntarget = \"a\"\nva = -1\nexpected = \"00\"\nvalue = \"01\"\n")); }, "va must be"); +} + +std::string namedEntry(const std::string& name, const std::string& extra = {}) { + return header("[[patch]]\nname = \"" + name + "\"\ntarget = \"a\"\nva = 1\nexpected = \"00\"\nvalue = \"01\"\n" + extra); +} + +void names() { + require(ParsePatchFile(namedEntry("pool_size_2")).Entries.front().Name == "pool_size_2", "valid name"); + for (const char* bad : {"Upper", "has-dash", "has space", "", "dot.ted", "\xc3\xa9"}) rejects([&] { ParsePatchFile(namedEntry(bad)); }, "line 3: name must be"); + rejects([] { ParsePatchFile(header("[[patch]]\nname = 5\ntarget = \"a\"\nva = 1\nexpected = \"00\"\nvalue = \"01\"\n")); }, "line 3: name must be"); + rejects([] { ParsePatchFile(namedEntry("same") + "\n[[patch]]\nname = \"same\"\ntarget = \"a\"\nva = 9\nexpected = \"00\"\nvalue = \"01\"\n"); }, "line 10: duplicate name"); + ParsePatchFile(namedEntry("one") + "\n[[patch]]\nname = \"two\"\ntarget = \"a\"\nva = 9\nexpected = \"00\"\nvalue = \"01\"\n"); +} + +void ranges() { + const ElfReader reader(twoSegmentFixture()); + const auto resolved = ResolveRanges(reader, {entry("t", 0x1000, {0, 1}, {9, 9}), entry("t", 0x1100, {0x80}, {1}), entry("t", 0x11fc, {0xfc, 0xfd, 0xfe, 0xff}, {0, 0, 0, 0})}); + require(resolved[0].Offset == 0x1000 && resolved[0].Length == 2, "first segment translation"); + require(resolved[1].Offset == 0x2000, "second segment translation"); + require(resolved[2].Offset == 0x20fc, "second segment end"); + rejects([&] { ResolveRanges(reader, {entry("t", 0x10fe, {0, 0, 0, 0}, {1, 1, 1, 1})}); }, "one PT_LOAD"); + rejects([&] { ResolveRanges(reader, {entry("t", 0x1200, {0}, {1})}); }, "one PT_LOAD"); + rejects([&] { ResolveRanges(reader, {entry("t", 0x11fe, {0, 0, 0}, {1, 1, 1})}); }, "one PT_LOAD"); + rejects([&] { ResolveRanges(reader, {entry("t", 0x5000, {0}, {1})}); }, "one PT_LOAD"); + rejects([&] { ResolveRanges(reader, {entry("t", 0x1000, {0, 1, 2}, {1, 1, 1}), entry("t", 0x1002, {2, 3}, {1, 1})}); }, "overlaps"); + ResolveRanges(reader, {entry("t", 0x1000, {0, 1}, {1, 1}), entry("t", 0x1002, {2, 3}, {1, 1})}); +} + +void headerRange() { + const ElfReader reader(twoSegmentFixture()); + rejects([&] { ResolveRanges(reader, {entry("t", 0x0, {0x7f}, {0})}); }, "program header table"); + rejects([&] { ResolveRanges(reader, {entry("t", 0x40, {0}, {1})}); }, "program header table"); + rejects([&] { ResolveRanges(reader, {entry("t", 0xaf, {0}, {1})}); }, "program header table"); + const auto ok = ResolveRanges(reader, {entry("t", 0xb0, {0}, {1})}); + require(ok.front().Offset == 0xb0, "range after the header table was rejected"); +} + +void applyAndMismatch() { + auto bytes = twoSegmentFixture(); + const auto original = bytes; + const std::vector entries{entry("t", 0x1000, {0, 1}, {9, 8}, "first"), entry("t", 0x1100, {0x80}, {1})}; + const auto patched = PatchTarget(bytes, "t", entries); + require(patched.Applied.size() == 2 && patched.Applied[0].Offset == 0x1000 && patched.Applied[0].Name == "first", "applied records"); + require(bytes[0x1000] == 9 && bytes[0x1001] == 8 && bytes[0x2000] == 1, "values were not written"); + for (std::size_t index = 0; index < bytes.size(); ++index) + if (index != 0x1000 && index != 0x1001 && index != 0x2000) require(bytes[index] == original[index], "byte outside the patch changed"); + + bytes = original; + rejects([&] { PatchTarget(bytes, "t", {entry("t", 0x1000, {0, 1}, {9, 8}), entry("t", 0x1100, {0x81}, {1})}); }, "expected 81 found 80"); + require(bytes == original, "a mismatch wrote bytes"); + require(PatchTarget(bytes, "other", entries).Applied.empty() && bytes == original, "entry hit another target"); +} + +void overlapWithWriters() { + const std::vector patched{{0x1010, 2, 0x401010}}; + const auto reject = [&](const WriterSites& sites, const std::string& writer) { rejects([&] { CheckOverlaps("t", patched, sites); }, writer); }; + const std::vector inPlace{{"VPSHUFB", 0x100f, 4, 4, Codegen::Amd64OnlyLowering::InPlace}}; + reject({.Reports = inPlace}, "Intel in-place report"); + const std::vector kept{{"VPSHUFB", 0x100f, 4, 4, Codegen::Amd64OnlyLowering::Kept}}; + CheckOverlaps("t", patched, {.Reports = kept}); + const std::vector distant{{"VPSHUFB", 0x1000, 4, 4, Codegen::Amd64OnlyLowering::InPlace}}; + CheckOverlaps("t", patched, {.Reports = distant}); + const std::vector absorbed{{0x1008, 0x401008, 0x10, {}, {}, 0}}; + reject({.Trampolines = absorbed}, "Intel trampoline site"); + const std::vector shortSite{{0x1008, 0x401008, 4, {}, {}, 0}}; + CheckOverlaps("t", patched, {.Trampolines = shortSite}); + const std::vector relink{{0x1011, {1, 2, 3}}}; + reject({.RelinkPatches = relink}, "relinker patch"); + const std::vector relinkAfter{{0x1012, {1, 2, 3}}}; + CheckOverlaps("t", patched, {.RelinkPatches = relinkAfter}); + const std::vector trap{{0x1011, 2, "syscall trap"}}; + reject({.Traps = trap}, "syscall trap"); + const std::vector trapBefore{{0x100e, 2, "syscall trap"}}; + CheckOverlaps("t", patched, {.Traps = trapBefore}); + const std::vector window{{0x100f, 4, "window"}}; + reject({.Traps = window}, "window"); +} + +void conversion() { + TestProgress progress; + const Conversion converter(progress); + const auto executable = conversionFixture(); + ConversionOptions plain; + plain.TargetName = "custom-name.bin"; + const auto baseline = converter.Convert(conversionInput(executable), plain); + require(baseline.AppliedPatches.empty(), "patches applied without entries"); + + ConversionOptions patched = plain; + patched.Patches = {entry("custom-name.bin", 0x1001, {0}, {0x90}, "nop"), entry("sce_module/plugin.prx", 0x1002, {0}, {0x41})}; + const auto output = converter.Convert(conversionInput(executable), patched); + require(output.AppliedPatches.size() == 2, "executable and module patches were not both applied"); + require(output.AppliedPatches[0].Target == "custom-name.bin" && output.AppliedPatches[1].Target == "sce_module/plugin.prx", "applied targets"); + require(output.Executable != baseline.Executable && output.Modules.front().Bytes != baseline.Modules.front().Bytes, "patches changed nothing"); + require(progress.Messages.find("Patch: custom-name.bin [nop] va=0x1001 off=0x1001 00 -> 90\n") != std::string::npos, "progress line: " + progress.Messages); + require(progress.Messages.find("Patch: sce_module/plugin.prx va=0x1002 off=0x1002 00 -> 41\n") != std::string::npos, "module progress line"); + + ConversionOptions other = patched; + other.TargetName = "other.bin"; + auto different = executable; + different[0x1005] = 0x90; + const auto second = converter.Convert(conversionInput(different), other); + require(second.Modules.size() == output.Modules.size() && second.Modules.front().Bytes == output.Modules.front().Bytes, "shared modules differ between executables"); + require(second.AppliedPatches.size() == 1, "an executable entry hit another target"); + + ConversionOptions failing = plain; + failing.Patches = {entry("custom-name.bin", 0x1001, {7}, {1})}; + rejects([&] { converter.Convert(conversionInput(executable), failing); }, "expected 07 found 00"); + failing.Patches = {entry("sce_module/plugin.prx", 0x1002, {7}, {1})}; + rejects([&] { converter.Convert(conversionInput(executable), failing); }, "expected 07 found 00"); + failing.Patches = {entry("custom-name.bin", 0x1000, {0xc3, 0}, {0, 0}), entry("custom-name.bin", 0x1001, {0}, {1})}; + rejects([&] { converter.Convert(conversionInput(executable), failing); }, "overlaps"); + + const auto repeated = converter.Convert(conversionInput(executable), plain); + require(repeated.Executable == baseline.Executable && repeated.Modules.front().Bytes == baseline.Modules.front().Bytes, "no entries changed the output"); +} + +void targets() { + const std::vector entries{entry("a.elf", 1, {0}, {1}), entry("sce_module/libc.prx", 1, {0}, {1})}; + MatchTargets(entries, {"a.elf", "b.elf", "sce_module/libc.prx"}); + rejects([&] { MatchTargets(entries, {"a.elf", "b.elf"}); }, "unmatched patch target: sce_module/libc.prx"); + rejects([&] { MatchTargets(entries, {"a.elf", "a.elf", "sce_module/libc.prx"}); }, "ambiguous patch target: a.elf"); + MatchTargets({}, {}); +} + +void verify() { + auto bytes = twoSegmentFixture(); + const std::vector executables{{"main.elf", bytes}}; + auto file = ParsePatchFile(header("main_target = \"main.elf\"\n[[patch]]\nname = \"one\"\ntarget = \"main.elf\"\nva = 0x1000\nexpected = \"0001\"\nvalue = \"0908\"\n" + "[[patch]]\ntarget = \"main.elf\"\nva = 0x1100\nexpected = \"ff\"\nvalue = \"01\"\n" + "[[patch]]\ntarget = \"sce_module/libc.prx\"\nva = 0x1000\nexpected = \"00\"\nvalue = \"01\"\n")); + const auto before = bytes; + const std::vector modules{{"sce_module/libc.prx", twoSegmentFixture()}}; + auto input = VerifyPatches(file, executables, modules, false); + require(input.size() == 3 && input[0].Status == VerifyStatus::Applicable && input[0].Name == "one" && input[0].Offset == 0x1000, "input applicable"); + require(input[1].Status == VerifyStatus::Mismatch && input[1].Detail.find("expected ff found 80") != std::string::npos, "input mismatch"); + require(input[2].Status == VerifyStatus::Applicable, "module entry in input mode"); + require(bytes == before && executables.front().Bytes == before, "verify wrote bytes"); + + auto patchedBytes = twoSegmentFixture(); + patchedBytes[0x1000] = 9; + patchedBytes[0x1001] = 8; + patchedBytes[0x2000] = 1; + const std::vector outputs{{"main.elf", patchedBytes}}; + auto output = VerifyPatches(file, outputs, {}, true); + require(output[0].Status == VerifyStatus::Applied && output[1].Status == VerifyStatus::Applied, "output applied"); + require(output[2].Status == VerifyStatus::NotChecked && output[2].Detail == "module, or executable not named with --executable", "output module not-checked"); + rejects([&] { VerifyPatches(file, {{"main.elf", patchedBytes}, {"main.elf", patchedBytes}}, {}, true); }, "ambiguous patch target: main.elf"); + rejects([&] { VerifyPatches(file, {{"main.elf", patchedBytes}, {"bin/a.elf", patchedBytes}, {"bin/a.elf", patchedBytes}}, {}, true); }, "ambiguous patch target: bin/a.elf"); + output = VerifyPatches(file, {{"main.elf", twoSegmentFixture()}}, {}, true); + require(output[0].Status == VerifyStatus::Mismatch && output[0].Detail.find("not applied") != std::string::npos, "unpatched output"); + + rejects([&] { VerifyPatches(ParsePatchFile(header("[[patch]]\ntarget = \"typo.prx\"\nva = 0x1000\nexpected = \"00\"\nvalue = \"01\"\n")), executables, modules, false); }, "unmatched patch target: typo.prx"); + rejects([&] { VerifyPatches(ParsePatchFile(header("[[patch]]\ntarget = \"typo.prx\"\nva = 0x1000\nexpected = \"00\"\nvalue = \"01\"\n")), executables, modules, false); }, "--verify-output"); + rejects([&] { VerifyPatches(ParsePatchFile(header("[[patch]]\ntarget = \"main.elf\"\nva = 0x1000\nexpected = \"00\"\nvalue = \"01\"\n")), outputs, {}, true); }, "no main_target"); + auto exec = twoSegmentFixture(); + Io::WriteU16(exec, 16, 2); + rejects([&] { VerifyPatches(file, {{"main.elf", exec}}, {}, true); }, "verify the input instead"); + rejects([&] { VerifyPatches(file, {{"main.elf", {'M', 'Z', 0x90, 0}}}, {}, true); }, "verify the input instead"); + rejects([&] { VerifyPatches(file, {{"main.elf", {0xcf, 0xfa, 0xed, 0xfe, 7, 0, 0, 1}}}, {}, true); }, "verify the input instead"); + rejects([&] { VerifyPatches(file, {{"main.elf", {}}}, {}, true); }, "verify the input instead"); + + auto sce = twoSegmentFixture(); + Io::WriteU16(sce, 16, 0xfe10); + require(VerifyPatches(ParsePatchFile(header("[[patch]]\ntarget = \"main.elf\"\nva = 0x1000\nexpected = \"00\"\nvalue = \"01\"\n")), {{"main.elf", sce}}, {}, false).front().Status == VerifyStatus::Applicable, + "input mode must accept any e_type"); + + require(OutputExecutablePath("out/app.elf", "bin/tool.elf") == std::filesystem::path("out/bin.tool.elf.elf"), "output executable name"); + require(OutputExecutablePath("out/app.exe", "./a/b/c.bin") == std::filesystem::path("out/a.b.c.bin.exe"), "output executable extension"); + for (const char* bad : {"../x", "/abs/x", "dir/", ""}) rejects([&] { OutputExecutablePath("out/app.elf", bad); }, "--executable must be a file"); +} + +void rangeFailureInVerify() { + const auto file = ParsePatchFile(header("[[patch]]\ntarget = \"main.elf\"\nva = 0x1200\nexpected = \"00\"\nvalue = \"01\"\n")); + const auto results = VerifyPatches(file, {{"main.elf", twoSegmentFixture()}}, {}, false); + require(results.front().Status == VerifyStatus::Mismatch && results.front().Detail.find("PT_LOAD") != std::string::npos, "range failure should be a mismatch"); +} + +void manifest() { + const AppliedPatch first{"main.elf", 0x1000, 0x1000, {0, 1}, {9, 8}, "set \"x\" later", "alpha"}; + const AppliedPatch second{"sce_module/libc.prx", 0x2000, 0x2000, {5}, {6}, "", ""}; + AppliedPatch duplicate = first; + const auto merged = AggregateManifest({{first, second}, {duplicate, second}}); + require(merged.size() == 2, "records were not deduplicated"); + rejects([&] { WriteManifest(merged, "main.elf", ""); }, "Patch manifest cannot hold the description: set \"x\" later"); + + const AppliedPatch plain{"main.elf", 0x1000, 0x1000, {0, 1}, {9, 8}, "plain description", "alpha"}; + const auto text = WriteManifest(AggregateManifest({{plain}, {second}}), "main.elf", ""); + const auto parsed = ParsePatchFile(text); + require(parsed.MainTarget == std::string("main.elf"), "main_target did not round-trip"); + require(parsed.Entries.size() == 2, "entry count did not round-trip"); + require(parsed.Entries[0].Name == "alpha" && parsed.Entries[0].Target == "main.elf" && parsed.Entries[0].VirtualAddress == 0x1000, "first entry"); + require(parsed.Entries[0].Expected == plain.Expected && parsed.Entries[0].Value == plain.Value && parsed.Entries[0].Description == "plain description", "first bytes"); + require(parsed.Entries[1].Name.empty() && parsed.Entries[1].Target == "sce_module/libc.prx" && parsed.Entries[1].Description.empty(), "second entry"); + require(text.find("name = \"alpha\"") != std::string::npos && text.find("main_target = \"main.elf\"") != std::string::npos, "manifest text"); + require(WriteManifest(AggregateManifest({{plain}, {second}}), "main.elf", "") == text, "manifest is not deterministic"); +} + +void reservedNames() { + for (const char* reserved : {"format", "relinker_version", "relinker_commit", "config", "config_sha256", "main_target", "settings", "settings_sha256"}) + rejects([&] { ParsePatchFile(namedEntry(reserved)); }, "line 3: name is reserved"); + ParsePatchFile(header("settings = \"a\"\nsettings_sha256 = \"b\"\n")); +} + +void relocationOverlap() { + std::vector table(48); + Io::WriteU64(table, 0, 0x2010); + Io::WriteU64(table, 24, 0x2100); + const std::vector hit{{0x1010, 2, 0x2017}}; + const std::vector adjacent{{0x1010, 2, 0x2018}}; + const std::vector before{{0x1010, 2, 0x200f}}; + rejects([&] { CheckOverlaps("t", hit, {.RelaData = table}); }, "RelaData relocation target"); + rejects([&] { CheckOverlaps("t", hit, {.RelaPltData = table}); }, "RelaPltData relocation target"); + rejects([&] { CheckOverlaps("t", {{0x1010, 1, 0x2100}}, {.RelaData = table}); }, "relocation target at va=0x2100"); + rejects([&] { CheckOverlaps("t", before, {.RelaPltData = table}); }, "RelaPltData"); + CheckOverlaps("t", adjacent, {.RelaData = table, .RelaPltData = table}); + CheckOverlaps("t", {{0x1010, 2, 0x200c}}, {.RelaData = table}); +} + +void manifestConfig() { + const AppliedPatch patch{"main.elf", 0x1000, 0x1000, {0}, {1}, "", ""}; + const auto config = [&](const std::string& path) { return WriteManifest({patch}, "main.elf", path); }; + require(config("").find("config =") == std::string::npos, "empty config was recorded"); + require(config("dir/p.toml").find("config = \"dir/p.toml\"\n") != std::string::npos, "forward-slash path"); + rejects([&] { config("dir/p\x7f.toml"); }, "Patch manifest cannot hold the config"); + rejects([&] { config("dir\\p.toml"); }, "Patch manifest cannot hold the config: dir\\p.toml"); + const auto quoted = config("we\"ird/p.toml"); + require(quoted.find("config = \"p.toml\"\n") != std::string::npos, "quote path was not reduced to the file name"); + require(ParsePatchFile(quoted).Entries.size() == 1, "manifest with a reduced config did not parse"); + rejects([&] { config("dir/a\"b.toml"); }, "Patch manifest cannot hold the config"); + const auto hold = [&](AppliedPatch bad, const std::string& field) { rejects([&] { WriteManifest({bad}, "main.elf", ""); }, "Patch manifest cannot hold the " + field + ": "); }; + auto bad = patch; + bad.Name = "a\"b"; + hold(bad, "name"); + bad = patch; + bad.Target = "a\"b"; + hold(bad, "target"); + bad = patch; + bad.Description = "a\"b"; + hold(bad, "description"); +} + +void typedManifest() { + const auto file = ParsePatchFile(header("main_target = \"main.elf\"\n[[patch]]\nname = \"neg\"\ntarget = \"main.elf\"\nva = 0x1000\ntype = \"i32\"\nexpected = -1\nvalue = 0x18000000\nmin = -5\nmax = 0x7f000000\nalign = 0x1000\n" + "[[patch]]\ntarget = \"main.elf\"\nva = 0x1100\ntype = \"u8\"\nexpected = 128\nvalue = 1\n" + "[[patch]]\ntarget = \"main.elf\"\nva = 0x1104\nexpected = \"84\"\nvalue = \"02\"\n")); + auto bytes = twoSegmentFixture(); + Io::WriteU32(bytes, 0x1000, 0xffffffff); + const auto before = bytes; + const auto patched = PatchTarget(bytes, "main.elf", file.Entries); + require(patched.Applied.size() == 3 && patched.Applied[0].Type == "i32" && patched.Applied[0].Min && patched.Applied[0].Min->Negative, "typed fields are not carried"); + const auto text = WriteManifest(AggregateManifest({patched.Applied}), "main.elf", ""); + require(text.find("type = \"i32\"") != std::string::npos && text.find("expected = -1\n") != std::string::npos && text.find("min = -5\n") != std::string::npos + && text.find("max = 0x7f000000\n") != std::string::npos && text.find("align = 0x1000\n") != std::string::npos && text.find("value = 0x18000000\n") != std::string::npos, "typed manifest text: " + text); + require(text.find("expected = \"84\"") != std::string::npos, "raw entry stayed raw"); + const auto reparsed = ParsePatchFile(text); + require(reparsed.Entries.size() == 3 && reparsed.Entries[0].Name == "neg" && reparsed.Entries[0].Type == "i32", "typed round-trip"); + for (std::size_t index = 0; index < 3; ++index) + require(reparsed.Entries[index].Expected == file.Entries[index].Expected && reparsed.Entries[index].Value == file.Entries[index].Value, "round-trip bytes"); + auto again = before; + PatchTarget(again, "main.elf", reparsed.Entries); + require(again == bytes, "re-applying the manifest gives different bytes"); +} + +} + +int main() { + try { + grammarAccepted(); + grammarRejected(); + integerRanges(); + boundsAndWidth(); + names(); + reservedNames(); + relocationOverlap(); + typedManifest(); + manifestConfig(); + ranges(); + headerRange(); + applyAndMismatch(); + overlapWithWriters(); + conversion(); + targets(); + verify(); + rangeFailureInVerify(); + manifest(); + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } + return 0; +} diff --git a/core/relinker/relinker/tests/test_game_patches.py b/core/relinker/relinker/tests/test_game_patches.py new file mode 100644 index 0000000000..b66eeabd61 --- /dev/null +++ b/core/relinker/relinker/tests/test_game_patches.py @@ -0,0 +1,239 @@ +from pathlib import Path +import subprocess +import sys +import tempfile + +import struct + +from test_guest_host_libc import DYNAMIC, importing +from test_guest_intel_trampolines import PLAIN_SITE, SITE, elf_loads, guest_fixture, main_fixture +from test_guest_syscall_trap import GENERIC_STUB +import test_jmprel_table_bounds +from test_guest_none_relocations import JUMP_SLOT, RELATIVE, TARGET, module + + +def patch_site(image): + for _, flags, offset, mapped, _, file_size, _, _ in elf_loads(image): + if flags & 1: + return mapped + file_size - 1, image[offset + file_size - 1] + raise AssertionError("No executable segment") + + +def patch_text(address, expected, name="", target="eboot.bin"): + lines = ["format = 1", "", "[[patch]]"] + if name: + lines.append(f'name = "{name}"') + lines += [f'target = "{target}"', f"va = {address:#x}", f'expected = "{expected:02x}"', f'value = "{expected ^ 1:02x}"', 'description = "flip one bit"', ""] + return "\n".join(lines) + + +def main(): + relinker = Path(sys.argv[1]).resolve() + image = bytes(main_fixture()) + address, expected = patch_site(image) + with tempfile.TemporaryDirectory(prefix="anyps5-game-patches-") as directory: + work = Path(directory) + + def run(*arguments): + return subprocess.run([str(relinker), *arguments], capture_output=True, text=True, timeout=60) + + def case(name): + root = work / name + (root / "in").mkdir(parents=True) + (root / "out").mkdir() + (root / "in" / "eboot.bin").write_bytes(image) + return root, root / "in" / "eboot.bin", root / "out" / "app.elf" + + root, source, output = case("plain") + result = run("--skip-sce-module", str(source), str(output)) + assert result.returncode == 0, result.stderr + baseline = output.read_bytes() + assert not list(output.parent.glob("*.patches.toml")), "manifest written without patches" + + (source.parent / "relinker-patches.toml").write_text(patch_text(address, expected, "flip_bit")) + result = run("--skip-sce-module", "--no-patches", str(source), str(output)) + assert result.returncode == 0 and output.read_bytes() == baseline, result.stderr + assert "Patches:" not in result.stdout and not list(output.parent.glob("*.patches.toml")) + + result = run("--skip-sce-module", str(source), str(output)) + assert result.returncode == 0, (result.stdout, result.stderr) + patched = output.read_bytes() + assert patched != baseline + assert f"Patches: {source.parent / 'relinker-patches.toml'}\n" in result.stdout, result.stdout + assert f"Patch: eboot.bin [flip_bit] va={address:#x} off=" in result.stdout, result.stdout + assert f"{expected:02x} -> {expected ^ 1:02x} (flip one bit)\n" in result.stdout, result.stdout + manifest = output.parent / "app.patches.toml" + text = manifest.read_text() + assert 'main_target = "eboot.bin"' in text and 'name = "flip_bit"' in text, text + assert f"Patch manifest: {manifest}\n" in result.stdout + assert f'config = "{source.parent / "relinker-patches.toml"}"\n' in text, text + + quoted = work / "quoted" + (quoted / "out").mkdir(parents=True) + (quoted / "eboot.bin").write_bytes(image) + (quoted / 'we"ird.toml').write_text(patch_text(address, expected)) + result = run("--skip-sce-module", "--patches", str(quoted / 'we"ird.toml'), str(quoted / "eboot.bin"), str(quoted / "out" / "app.elf")) + assert result.returncode == 2 and "Patch manifest cannot hold the config" in result.stderr, (result.stdout, result.stderr) + assert not list((quoted / "out").iterdir()), "output written despite the manifest error" + + reduced = work / 'we"ird' + (reduced / "out").mkdir(parents=True) + (reduced / "eboot.bin").write_bytes(image) + (reduced / "p.toml").write_text(patch_text(address, expected)) + result = run("--skip-sce-module", "--patches", str(reduced / "p.toml"), str(reduced / "eboot.bin"), str(reduced / "out" / "app.elf")) + assert result.returncode == 0, (result.stdout, result.stderr) + assert 'config = "p.toml"\n' in (reduced / "out" / "app.patches.toml").read_text() + + result = run("--verify-patches", str(manifest), str(source), "--skip-sce-module") + assert result.returncode == 0 and "applicable: flip_bit eboot.bin" in result.stdout, (result.stdout, result.stderr) + before = sorted(str(path) for path in work.rglob("*")) + result = run("--verify-patches", str(manifest), str(output), "--verify-output") + assert result.returncode == 0 and "applied: flip_bit eboot.bin" in result.stdout, (result.stdout, result.stderr) + assert sorted(str(path) for path in work.rglob("*")) == before, "verify wrote files" + renamed = work / "renamed" + renamed.mkdir() + (renamed / "eboot.bin").write_bytes(patched) + result = run("--verify-patches", str(manifest), str(renamed / "eboot.bin"), "--skip-sce-module") + assert result.returncode == 1 and "mismatch: flip_bit eboot.bin" in result.stdout, (result.stdout, result.stderr) + + other = work / "reproduce" + other.mkdir() + (source.parent / "relinker-patches.toml").unlink() + result = run("--skip-sce-module", "--patches", str(manifest), str(source), str(other / "app.elf")) + assert result.returncode == 0 and (other / "app.elf").read_bytes() == patched, result.stderr + + clash = work / "clash" + (clash / "in").mkdir(parents=True) + (clash / "in" / "eboot.bin").write_bytes(image) + (clash / "in" / "app.patches.toml").write_text(patch_text(address, expected)) + result = run("--skip-sce-module", "--patches", str(clash / "in" / "app.patches.toml"), str(clash / "in" / "eboot.bin"), str(clash / "in" / "app.elf")) + assert result.returncode == 2 and "would overwrite the patch file" in result.stderr, (result.stdout, result.stderr) + assert not (clash / "in" / "app.elf").exists() + + hand = work / "hand.toml" + hand.write_text(patch_text(address, expected)) + result = run("--verify-patches", str(hand), str(output), "--verify-output") + assert result.returncode == 2 and "no main_target" in result.stderr, result.stderr + + root, source, output = case("wrong-bytes") + (root / "p.toml").write_text(patch_text(address, expected ^ 0x55)) + result = run("--skip-sce-module", "--patches", str(root / "p.toml"), str(source), str(output)) + assert result.returncode == 2 and "expected" in result.stderr and "found" in result.stderr, result.stderr + assert not list(output.parent.iterdir()), "output written despite a failed patch" + result = run("--verify-patches", str(root / "p.toml"), str(source), "--skip-sce-module") + assert result.returncode == 1 and "mismatch: eboot.bin" in result.stdout, result.stdout + + root, source, output = case("unmatched") + (root / "p.toml").write_text(patch_text(address, expected, target="missing.prx")) + result = run("--skip-sce-module", "--patches", str(root / "p.toml"), str(source), str(output)) + assert result.returncode == 2 and "unmatched patch target: missing.prx" in result.stderr, result.stderr + assert not list(output.parent.iterdir()) + result = run("--verify-patches", str(root / "p.toml"), str(source), "--skip-sce-module") + assert result.returncode == 2 and "--verify-output" in result.stderr, result.stderr + + root, source, output = case("extra") + (root / "in" / "picker").mkdir() + (root / "in" / "picker" / "picker.bin").write_bytes(image) + (root / "p.toml").write_text(patch_text(address, expected, target="picker/picker.bin")) + (root / "in" / "sce_module").mkdir() + result = run("--executable", "picker/picker.bin", "--patches", str(root / "p.toml"), str(source), str(output)) + assert result.returncode == 0, (result.stdout, result.stderr) + manifest = output.parent / "app.patches.toml" + result = run("--verify-patches", str(manifest), str(output), "--verify-output", "--executable", "picker/picker.bin") + assert result.returncode == 0 and "applied: picker/picker.bin" in result.stdout, (result.stdout, result.stderr) + (output.parent / "picker.picker.bin.elf").unlink() + result = run("--verify-patches", str(manifest), str(output), "--verify-output", "--executable", "picker/picker.bin") + assert result.returncode == 2 and "output not found" in result.stderr, result.stderr + + relocation_overlap(relinker, work) + writer_overlap(relinker, work) + + +def relocation_overlap(relinker, work): + def run(*arguments): + return subprocess.run([str(relinker), *arguments], capture_output=True, text=True, timeout=60) + + def patch_file(path, target, address, expected): + path.write_text(f'format = 1\n\n[[patch]]\ntarget = "{target}"\nva = {address:#x}\nexpected = "{expected}"\nvalue = "ffff"\n') + + for kind, rela, plt in (("rela", [RELATIVE], []), ("plt", [], [JUMP_SLOT])): + image = bytes(module(rela, plt)) + for offset, overlaps in ((TARGET + 4, True), (TARGET + 8, False)): + case = work / f"mod-{kind}-{offset:x}" + (case / "in" / "sce_module").mkdir(parents=True) + (case / "in" / "sce_module" / "module.prx").write_bytes(image) + (case / "in" / "eboot.bin").write_bytes(main_fixture()) + (case / "out").mkdir() + expected = image[0x2000 + (offset - 0x2000):0x2000 + (offset - 0x2000) + 2].hex() + patch_file(case / "p.toml", "sce_module/module.prx", offset, expected) + result = run("--patches", str(case / "p.toml"), str(case / "in" / "eboot.bin"), str(case / "out" / "app.elf")) + if overlaps: + assert result.returncode == 2 and "relocation target" in result.stderr, (kind, result.stdout, result.stderr) + assert not list((case / "out").iterdir()), "output written despite the overlap" + else: + assert result.returncode == 0, (kind, result.stdout, result.stderr) + + image = bytearray(importing()) + struct.pack_into("", func)` sets the NID directly when the name is unknown. Every other library keeps the names libc exports; it reads them from `libc.exports`, which `nid_patcher --write-export-list` rewrites only when that list changes. On Windows the link writes each import library with `SOURCE_DATE_EPOCH=0` and `ranlib -D` and replaces it only when its bytes change. A source edit that keeps the exports therefore relinks and re-patches only its own library. +## Game patches + +[`GamePatch.cpp`](../../core/relinker/relinker/src/patching/GamePatch.cpp) parses the patch file, a strict subset of TOML. `Conversion::Convert` applies the entries of its own target to the executable bytes right after reading them, and to each bundled module before `GuestModuleBuilder` reads it, so every later stage sees patched bytes. Entry virtual addresses are translated through the `PT_LOAD` segments, and a range may not touch the ELF header or program header table, which the output writers rewrite. The patched file ranges are then checked against every in-place writer: the `--to-intel` reports and trampoline sites, `RelinkPatch` records and syscall trap ranges. The patched virtual address ranges are checked against the 8-byte targets of the `RelaData` and `RelaPltData` relocations, which the loader or the output writers overwrite. `main.cpp` builds the manifest text before it writes any output. `main.cpp` matches targets after the conversions, aggregates the applied records into `.patches.toml`, and runs `--verify-patches` through `GamePatch::VerifyPatches`, which reads only bytes. + ## Windows deferred module initialization Runtime-loaded Windows modules export `__aps5_guest_initialize`: the entry RVA, the initializer count and the RVAs of initializer pointer slots. [DynamicLoader.cpp](../../core/libs/prx/libkernel/Module/src/DynamicLoader.cpp) consumes the first two words in the mapped image after initialization, so another reference to that image does not run it again. Unloading the image discards this state; a later load reads the original table from the file, including when Windows reuses the same base address. Native references outside the guest handle table retain the same initialized image. diff --git a/docs/user/USAGE.md b/docs/user/USAGE.md index 5c1719fe3a..789a4cdc76 100644 --- a/docs/user/USAGE.md +++ b/docs/user/USAGE.md @@ -72,6 +72,10 @@ All switches are disabled by default. `unused-filter` defaults to `0`; `--rpath` | `--sce-module-path ` | Parent of the module directories, not the module directory itself. Default: input ELF parent; relative input paths resolve from the process working directory. Explicit absolute path: used directly. Explicit relative path (including `.` and `..`): relative to the process working directory. Output path has no effect. Requires one nonempty value; repeats, nonexistent or nondirectory paths, access failures, and combination with `--skip-sce-module` are errors. | | `--module-dir ` | Relink the ELF modules of a directory under the module parent directory, besides `sce_module`/`sce_modules`/`prx`; outputs keep the relative path. A module the executable or another module needs is initialized at startup, any other when the title loads it with `sceKernelLoadStartModule`, with its start arguments (for example Unity plugins in `Media/Plugins`). Repeatable; `.` is the parent itself, without the input ELF. Conflicts with `--skip-sce-module`. | | `--executable ` | Also convert another executable of the title, which it starts with `sceSystemServiceLoadExec`; the path is relative to the module parent directory, for example `picker/picker.bin`. It is written beside the output, named after the path with `/` replaced by `.` and given the output's extension (`picker.picker.bin.exe`), and uses the same `libs/`, `app0/` and guest modules. It is an error if a guest module converts differently for it. Repeatable. Conflicts with `--skip-sce-module`. | +| `--patches ` | Apply the byte patches of this file during conversion, see [game patches](#game-patches). Overrides `relinker-patches.toml` discovery. Conflicts with `--no-patches`. | +| `--no-patches` | Do not apply patches, and do not load `relinker-patches.toml` from the input directory. | +| `--verify-patches ` | Check a patch file against `` without writing anything, see [game patches](#game-patches). Takes one path and no output path. | +| `--verify-output` | With `--verify-patches`, treat `` as a converted Linux output instead of an input executable. | | `--skip-sce-module` | Deprecated. Disable bundled module processing and module directory checks. Conflicts with explicit `--sce-module-path`. | | `--exclude-sce-module ` | Deprecated. Exclude a bundled module by exact filename, not path. Repeat for multiple files; a missing filename is an error. Conflicts with `--skip-sce-module`. | | `--skip-syscall-check` | Deprecated. Disable syscall scanning in the executable and bundled modules. | @@ -84,6 +88,58 @@ The `--skip-sce-module`, `--exclude-sce-module `, `--skip-syscall-check`, `--trap-sdk-syscall-stubs` is also for debugging. It preserves a runtime failure if a syscall wrapper is called; it does not implement FreeBSD syscalls or make these applications generally safe to run. +## Game patches + +A patch file changes bytes of the input executable or of a bundled module before conversion, for a title that hard-codes a value the host cannot meet. The relinker checks the bytes it replaces, so a patch for another version of the title fails instead of corrupting it. With no patch file the output is unchanged. + +`relinker-patches.toml` in the directory of the input executable is loaded automatically and announced as `Patches: `. `--patches ` names another file, and `--no-patches` loads none. Each applied patch is reported: + +``` +Patch: [] va=0x
off=0x -> () +``` + +The file is a subset of TOML; any other TOML syntax is an error that names the line. It is UTF-8, with blank lines, `#` comments outside strings, `[[patch]]` entries and `key = value` lines. Integers are decimal or `0x` hexadecimal, strings are double-quoted without escapes, and keys match `[A-Za-z0-9_]+`. + +```toml +format = 1 + +[[patch]] +name = "pool_size" +target = "eboot.elf" +va = 0x1234 +type = "i32" +expected = 0x10000000 +value = 0x18000000 +max = 0x7f000000 +align = 0x1000 +description = "pool size" +``` + +`format = 1` is required. The top-level keys `relinker_version`, `relinker_commit`, `config`, `config_sha256`, `main_target`, `settings` and `settings_sha256` are accepted in any file; only `main_target` is used, by `--verify-output`. Any other top-level key is an error. + +| Entry key | Meaning | +|-----------|---------| +| `name` | Optional. A setting name of `[a-z0-9_]`, unique within the file and not one of the reserved top-level key names (`format` and the seven keys above), used by a later `--set` option. Shown in the progress line, the manifest and the verify report. | +| `target` | Required. The main executable's file name, or for `--executable` files and bundled modules the path relative to the module parent directory, for example `sce_module/libc.prx`. | +| `va` | Required. The virtual address of the ELF as a disassembler shows it. | +| `type` | Optional: `u8 u16 u32 u64 i8 i16 i32 i64`. Then `expected`, `value`, `min`, `max` and `align` are integers, range-checked and written little-endian. Without it, `expected` and `value` are hexadecimal byte strings of the same length. | +| `expected` | Required. The bytes the input must hold. | +| `value` | Required. The bytes to write. | +| `min`, `max`, `align` | Optional, typed entries only; checked against `value`. | +| `description` | Optional. Reported and copied to the manifest. | + +x86 `mov r/m64, imm32` and `add`/`sub`/`cmp r/m64, imm32` sign-extend their immediate. To patch a 64-bit field through an imm32 use `type = "i32"`, and add `min` to keep negative values out. + +Entries apply in file order. A patch range must lie in the file bytes of one `PT_LOAD` and outside the ELF header and program header table. The conversion fails before writing any output if bytes differ from `expected`, a range is not valid, two entries overlap, a target matches no file or more than one file, or a range overlaps bytes the relinker itself rewrites (`--to-intel` replacements and trampolines, relinker patches, syscall traps) or the 8-byte target of a relocation of the file. + +When at least one patch is applied, `.patches.toml` is written beside the output. It lists every applied entry for every converted file, with `main_target` and `config` (the patch file path in forward-slash form, reduced to its file name when the path holds a `"`, backslash or control character; the conversion fails before writing any output when even that cannot be held); typed entries keep their `type`, integers and `min`, `max` and `align`, and `--patches` accepts it to reproduce the conversion. The manifest must not replace the patch file it was read from. + +`--verify-patches ` writes nothing and exits `0` when no entry is a mismatch. Without `--verify-output`, `` is the input executable of any type, modules are found as in a conversion (module options and `--executable` apply), and each entry is `applicable` (the input holds `expected`) or `mismatch`. A target that matches nothing is an error that suggests `--verify-output`. + +With `--verify-output`, `` is a converted Linux ELF (`ET_DYN`); `ET_EXEC` output from `--linux-fixed-base`, PE and Mach-O outputs are refused. The file must have `main_target`, so verify the generated manifest, not a hand-written file. The entry whose target is `main_target` is checked against ``, and entries for the `--executable` strings given are checked against the output file derived as in a conversion; an entry holding `value` is `applied`. Every other entry is `not-checked (module, or executable not named with --executable)`, including a mistyped module target. Two targets with the same name, such as an `--executable` equal to `main_target`, are an `ambiguous patch target` error. Module discovery options are rejected with `--verify-output`. + +Verify mode rejects the conversion options (`--windows`, `--macos`, `--to-intel`, `--rpath`, `--patches`, `--no-patches` and the like) and a second path. + ## Runtime layout For an executable that assumes its image starts at `0x400000`, use `relinker --windows --windows-fixed-base 0x400000 source/input.elf app.exe`. This maps its lowest ELF load page there; the PE headers occupy the preceding 64 KiB. Use this option only when the executable requires an exact address. Without it, the Windows executable has no `DYNAMIC_BASE` and loads at its preferred base `0x140000000`; relocations are kept, so Windows relocates it if that range is taken. Modules keep `DYNAMIC_BASE`. This option does not change macOS output. @@ -273,7 +329,7 @@ With `APS5_STALL_WATCHDOG_SECS=`, a queue that has waited `n` seconds without ## Exit codes -`0`: conversion succeeded or help was displayed. `1`: invalid arguments. `2`: conversion failed; the error is printed to stderr. With `--autorun`, successful conversion returns the launched application's exit code. +`0`: conversion succeeded or help was displayed. `1`: invalid arguments, or a `--verify-patches` entry that is a mismatch. `2`: conversion failed; the error is printed to stderr. With `--autorun`, successful conversion returns the launched application's exit code. ## Import audit