#include "mod_merge_internal.h" #include "Engine/Core/Json/json.h" #include "Engine/Core/Platform/path_text.h" #include "Engine/Resource/cas_codec.h" #include "Engine/Resource/ebx_fixup_repair.h" #include #include #include #include #include #include namespace dingosdk::mods::detail { namespace { constexpr std::size_t maximumTocBytes = 64 * 1024 * 1024; } // namespace std::string lower(std::string_view text) { std::string result(text); for (auto& ch : result) if (ch >= 'A' && ch <= 'Z') ch = static_cast(ch + ('a' - 'A')); return result; } std::vector read_file(const fs::path& path) { std::ifstream input(path, std::ios::binary | std::ios::ate); if (!input) throw std::runtime_error("Cannot open " + path_utf8(path)); const auto length = input.tellg(); if (length < 0 || static_cast(length) > maximumTocBytes) throw std::runtime_error("File exceeds the TOC size limit: " + path_utf8(path)); std::vector bytes(static_cast(length)); input.seekg(0); if (!bytes.empty() && !input.read(reinterpret_cast(bytes.data()), static_cast(bytes.size()))) throw std::runtime_error("Cannot read " + path_utf8(path)); return bytes; } void write_file(const fs::path& path, std::span bytes) { fs::create_directories(path.parent_path()); const auto temporary = fs::path(path.wstring() + L".tmp"); { std::ofstream output(temporary, std::ios::binary | std::ios::trunc); if (!output || !output.write(reinterpret_cast(bytes.data()), static_cast(bytes.size())) || !output.flush()) // ~ofstream ignores a failed flush throw std::runtime_error("Cannot write " + path_utf8(temporary)); } if (!MoveFileExW(temporary.c_str(), path.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) { const auto error = GetLastError(); DeleteFileW(temporary.c_str()); throw std::runtime_error("Cannot publish " + path_utf8(path) + " (Windows error " + std::to_string(error) + ")"); } } // A mod's archive is hard linked into the patch, so appending to it would write // straight into the mod's own folder. Anything the merge adds to an archive // therefore gives it a private copy first. void unshare(const fs::path& path) { const auto handle = CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); if (handle == INVALID_HANDLE_VALUE) return; BY_HANDLE_FILE_INFORMATION information{}; const auto known = GetFileInformationByHandle(handle, &information) != FALSE; CloseHandle(handle); if (!known || information.nNumberOfLinks <= 1) return; const auto temporary = fs::path(path.wstring() + L".unshared"); std::error_code error; fs::copy_file(path, temporary, fs::copy_options::overwrite_existing, error); if (error) throw std::runtime_error("Cannot give " + path_utf8(path) + " a private copy: " + error.message()); if (!MoveFileExW(temporary.c_str(), path.c_str(), MOVEFILE_REPLACE_EXISTING)) { const auto failure = GetLastError(); DeleteFileW(temporary.c_str()); throw std::runtime_error("Cannot replace the shared " + path_utf8(path) + " (Windows error " + std::to_string(failure) + ")"); } } std::uint64_t append_file(const fs::path& from, const fs::path& to) { fs::create_directories(to.parent_path()); unshare(to); std::ifstream input(from, std::ios::binary); if (!input) throw std::runtime_error("Cannot read " + path_utf8(from)); // tellp() on a stream opened for append reads 0 until the first write, so // the block's start comes from the file itself. std::error_code error; const auto existing = fs::file_size(to, error); const auto start = error ? std::uint64_t{} : existing; std::ofstream output(to, std::ios::binary | std::ios::app); if (!output) throw std::runtime_error("Cannot append to " + path_utf8(to)); // A megabyte of stack is a stack overflow; the copy buffer lives on the heap. std::vector buffer(1 << 20); while (input) { input.read(buffer.data(), static_cast(buffer.size())); if (input.gcount() && !output.write(buffer.data(), input.gcount())) throw std::runtime_error("Cannot append to " + path_utf8(to)); } if (!output) throw std::runtime_error("Cannot append to " + path_utf8(to)); return start; } // Hard link when the volume allows it, so a 400 MB archive costs nothing. void link_or_copy(const fs::path& from, const fs::path& to) { fs::create_directories(to.parent_path()); std::error_code error; fs::remove(to, error); if (CreateHardLinkW(to.c_str(), from.c_str(), nullptr)) return; fs::copy_file(from, to, fs::copy_options::overwrite_existing, error); if (error) throw std::runtime_error("Cannot place " + path_utf8(to) + ": " + error.message()); } RelativeFiles scan(const fs::path& directory) { RelativeFiles result; std::error_code error; const auto root = directory / L"Win32"; if (!fs::is_directory(root, error) || error) return result; for (fs::recursive_directory_iterator it(root, error), end; it != end && !error; it.increment(error)) { if (!it->is_regular_file(error) || error) { error.clear(); continue; } // The game only names archives in ASCII: anything else is not one of its files. const auto relative = ascii_path(fs::relative(it->path(), directory, error)); if (error || relative.empty()) { error.clear(); continue; } const auto extension = lower(ascii_path(it->path().extension())); if (extension == ".toc") result.tocs.push_back(relative); else if (extension == ".cas") result.archives.push_back(relative); } std::ranges::sort(result.tocs); std::ranges::sort(result.archives); return result; } fb::Sha1 sha1_of(std::span bytes) { BCRYPT_ALG_HANDLE algorithm{}; if (BCryptOpenAlgorithmProvider(&algorithm, BCRYPT_SHA1_ALGORITHM, nullptr, 0) < 0) throw std::runtime_error("Cannot open SHA-1"); fb::Sha1 digest; const auto status = BCryptHash(algorithm, nullptr, 0, const_cast(reinterpret_cast(bytes.data())), static_cast(bytes.size()), reinterpret_cast(digest.bytes.data()), static_cast(digest.bytes.size())); BCryptCloseAlgorithmProvider(algorithm, 0); if (status < 0) throw std::runtime_error("Cannot hash the merged asset"); return digest; } std::size_t repair_ebx_payload(std::vector& encoded, fb::BundleAsset& asset, const fs::path& gameRoot) { std::optional repaired; try { repaired = fb::ebx::repair_type_fixups(fb::decode_cas(encoded, {gameRoot})); } catch (const std::exception&) { return 0; } if (!repaired) return 0; encoded = fb::encode_cas(repaired->bytes, {gameRoot}); asset.sha1 = sha1_of(repaired->bytes); asset.originalSize = repaired->bytes.size(); return repaired->removed; } PlacementRecord read_placements(const fs::path& file) { PlacementRecord record; std::ifstream input(file, std::ios::binary); if (!input) throw std::runtime_error("The launch's archive placements are missing; restart the game to merge mods"); const std::string text{std::istreambuf_iterator(input), std::istreambuf_iterator()}; const auto root = Json::parse(text); for (const auto& [name, spots] : root.at("mods").items()) { auto& placement = record.mods[name]; for (std::size_t i = 0; i < spots.size(); ++i) { const auto& spot = spots.at(i); placement.at[{spot.at("directory").string(), spot.at("number").get()}] = {spot.at("archive").get(), spot.at("offset").get()}; } } if (root.contains("fingerprints")) for (const auto& [name, files] : root.at("fingerprints").items()) if (const auto found = record.mods.find(name); found != record.mods.end() && files.is_string()) found->second.fingerprint = files.string(); const auto& tocs = root.at("tocs"); for (std::size_t i = 0; i < tocs.size(); ++i) record.tocs.push_back(tocs.at(i).string()); if (root.contains("root")) { const auto& order = root.at("root"); for (std::size_t i = 0; i < order.size(); ++i) record.root.push_back(order.at(i).string()); } return record; } void write_placements(const fs::path& file, const PlacementRecord& record) { auto root = Json::object(); root["schema"] = 1u; auto mods = Json::object(); for (const auto& [name, placement] : record.mods) { auto spots = Json::array(); for (const auto& [where, spot] : placement.at) { auto row = Json::object(); row["directory"] = where.first; row["number"] = static_cast(where.second); row["archive"] = static_cast(spot.archive); row["offset"] = spot.offset; spots.push_back(std::move(row)); } mods[name] = std::move(spots); } root["mods"] = std::move(mods); auto fingerprints = Json::object(); for (const auto& [name, placement] : record.mods) if (!placement.fingerprint.empty()) fingerprints[name] = placement.fingerprint; root["fingerprints"] = std::move(fingerprints); auto tocs = Json::array(); for (const auto& toc : record.tocs) tocs.push_back(toc); root["tocs"] = std::move(tocs); auto order = Json::array(); for (const auto& name : record.root) order.push_back(name); root["root"] = std::move(order); const auto text = root.dump(2); write_file(file, std::as_bytes(std::span(text))); } } // namespace dingosdk::mods::detail