mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-17 17:27:19 +02:00
Votes: a vote is decided by the votes cast, not by everyone on the server. It
passes when yes is its percentage of them, at the end of its time, or sooner
once the players still to vote could not change it. A kick still needs two
yes. The card's bar is yes in green from the left and no in red from the
right, with a mark where yes has to reach. No protocol change.
Map versions: a map installed at another version than the host's is no longer
loaded as it is. The download card offers the host's version (MAP VERSION,
UPDATE), which replaces the installed one and is applied while the game runs.
For that the live merge keeps each mod's fingerprint with where its archives
were placed, and places a mod again when its files have changed since: before,
a mod replaced while the game ran was read where the old one lay. When the
host's version is gone from Thunderstore, or the old one's files are in use,
the player is told and leaves the session.
Dedicated server: "maps reload" reads the Mods folder again, so a map copied
in while the server runs can be switched to at once. The server window reads a
copy of the map list.
Map names: a custom map's level is dingolevel_reskate_<map>, and "reskate" was
left in its name ("/map reskate badlands"). The map's own name is shown and
typed now; the old spelling still finds it.
as a level unloads, the game takes each mesh variation out of its registry by the
entry's mesh and reads it unchecked, and this map's meshes unload before
their databases. A guard on that one function has the read of a mesh that is
gone go on with the next entry, and logs how many were passed over.
203 lines
9.3 KiB
C++
203 lines
9.3 KiB
C++
#include "mod_merge_internal.h"
|
|
#include "Engine/Core/Json/json.h"
|
|
#include "Engine/Core/Platform/path_text.h"
|
|
|
|
#include <Windows.h>
|
|
#include <bcrypt.h>
|
|
#include <algorithm>
|
|
#include <cstdio>
|
|
#include <fstream>
|
|
#include <stdexcept>
|
|
|
|
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<char>(ch + ('a' - 'A'));
|
|
return result;
|
|
}
|
|
|
|
std::vector<std::byte> 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<std::uint64_t>(length) > maximumTocBytes)
|
|
throw std::runtime_error("File exceeds the TOC size limit: " + path_utf8(path));
|
|
std::vector<std::byte> bytes(static_cast<std::size_t>(length));
|
|
input.seekg(0);
|
|
if (!bytes.empty() &&
|
|
!input.read(reinterpret_cast<char*>(bytes.data()), static_cast<std::streamsize>(bytes.size())))
|
|
throw std::runtime_error("Cannot read " + path_utf8(path));
|
|
return bytes;
|
|
}
|
|
|
|
void write_file(const fs::path& path, std::span<const std::byte> 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<const char*>(bytes.data()),
|
|
static_cast<std::streamsize>(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<char> buffer(1 << 20);
|
|
while (input) {
|
|
input.read(buffer.data(), static_cast<std::streamsize>(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<const std::byte> 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<PUCHAR>(reinterpret_cast<const unsigned char*>(bytes.data())),
|
|
static_cast<ULONG>(bytes.size()),
|
|
reinterpret_cast<PUCHAR>(digest.bytes.data()),
|
|
static_cast<ULONG>(digest.bytes.size()));
|
|
BCryptCloseAlgorithmProvider(algorithm, 0);
|
|
if (status < 0) throw std::runtime_error("Cannot hash the merged asset");
|
|
return digest;
|
|
}
|
|
|
|
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<char>(input), std::istreambuf_iterator<char>()};
|
|
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<std::uint16_t>()}] =
|
|
{spot.at("archive").get<std::uint16_t>(), spot.at("offset").get<std::uint64_t>()};
|
|
}
|
|
}
|
|
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<unsigned>(where.second);
|
|
row["archive"] = static_cast<unsigned>(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
|