Files
ReSkate/Engine/Vfs/builtin_packs.cpp
AyeZeeBB fffd34ca80 Mods: conflicts resolved in MY MODS, official mods live in Mods, and big maps merge faster
MY MODS: a table with an animated drag for the load order, a conflicts bar with Check conflicts, and a Resolve popup whose choices are kept. The check runs at startup and after changes; conflicts no longer show as problems, block Play, or hold mod files during an update.
Conflicts: only game EBX that cannot be combined, or Lua scripts with different versions; colourways and same-name additions are not conflicts.
Official mods: installed in Mods/<Namespace-Name> and turned on or off through mods.json, so they show in MY MODS; the old .reskate-official folder is moved over.
Merge: a scan cache (Mods/.reskate-scan.txt) keeps each unchanged mod's repair, mesh and texture scans, so big maps are not re-read every merge.
Launcher: an announcement from the backend shows under STATUS, with HIDE.
2026-10-18 16:33:53 -05:00

370 lines
17 KiB
C++

#include "builtin_packs.h"
#include "builtin_pack_format.h"
#include "mod_list.h"
#include "Engine/Core/Platform/path_text.h"
#include <Windows.h>
#include <miniz.h>
#include <zstd.h>
#include <algorithm>
#include <cctype>
#include <iterator>
#include <format>
#include <fstream>
#include <cstring>
#include <span>
#include <string_view>
#include <utility>
#include <vector>
namespace dingosdk::mods {
namespace {
namespace fs = std::filesystem;
// Each pack: its RCDATA resource in ReSkate.dll (cmake/Runtime.cmake) and its folder name.
struct Embedded {
const wchar_t* resource;
const char* name;
};
constexpr Embedded embedded[]{{L"BUILTIN_PACK_FPV_DRONE", "fpv_drone"}};
// Retired mods (builtin_packs.h), in small letters.
constexpr std::string_view retired[]{"fourth_studio-board_break"};
std::string lower_text(std::string_view text) {
std::string out(text);
std::transform(out.begin(), out.end(), out.begin(), [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
return out;
}
constexpr std::uint64_t max_pack_bytes = builtin_pack::max_bytes;
constexpr std::string_view version_file = ".builtin-version";
struct Source {
HMODULE module{};
bool owned{};
Source() = default;
Source(const Source&) = delete;
Source& operator=(const Source&) = delete;
~Source() {
if (owned && module) FreeLibrary(module);
}
};
bool carries_packs(HMODULE module) {
return module && std::any_of(std::begin(embedded), std::end(embedded), [&](const Embedded& pack) {
return FindResourceW(module, pack.resource, MAKEINTRESOURCEW(10) /* RT_RCDATA */) != nullptr;
});
}
// ReSkate.dll: this module when it is the DLL (the game), else ReSkate.dll beside the program
// (the launcher, which sits beside Skate.exe and the DLL), as data only.
void open_source(Source& source) {
HMODULE self{};
if (GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
reinterpret_cast<LPCWSTR>(&carries_packs), &self) && carries_packs(self)) {
source.module = self;
return;
}
if (const auto loaded = GetModuleHandleW(L"ReSkate.dll"); carries_packs(loaded)) {
source.module = loaded;
return;
}
std::wstring program(32768, L'\0');
const auto length = GetModuleFileNameW(nullptr, program.data(), static_cast<DWORD>(program.size()));
if (!length || length >= program.size()) return;
program.resize(length);
const auto dll = fs::path(program).parent_path() / L"ReSkate.dll";
source.module = LoadLibraryExW(dll.c_str(), nullptr, LOAD_LIBRARY_AS_DATAFILE | LOAD_LIBRARY_AS_IMAGE_RESOURCE);
source.owned = source.module != nullptr;
}
std::span<const unsigned char> resource_bytes(HMODULE module, const wchar_t* name) {
const auto resource = FindResourceW(module, name, MAKEINTRESOURCEW(10));
if (!resource) return {};
const auto loaded = LoadResource(module, resource);
const auto size = SizeofResource(module, resource);
const auto* data = loaded ? static_cast<const unsigned char*>(LockResource(loaded)) : nullptr;
return data && size ? std::span<const unsigned char>(data, size) : std::span<const unsigned char>{};
}
// Which build of a pack this is: enough to tell when the one embedded changed.
std::string version_of(std::span<const unsigned char> bytes) {
std::uint64_t hash = 0xcbf29ce484222325ULL;
for (const auto byte : bytes) hash = (hash ^ byte) * 0x100000001b3ULL;
return std::format("{:016x}-{}", hash, bytes.size());
}
std::string read_text(const fs::path& path) {
std::ifstream input(path, std::ios::binary);
return {std::istreambuf_iterator<char>(input), std::istreambuf_iterator<char>()};
}
bool safe_entry(std::string_view name) { return builtin_pack::safe_name(name); }
// Writes `size` bytes to a new file at `path` (its folders made first).
bool write_file(const fs::path& path, const unsigned char* data, std::uint64_t size) {
std::error_code error;
fs::create_directories(path.parent_path(), error);
struct File {
HANDLE value;
~File() { if (value != INVALID_HANDLE_VALUE) CloseHandle(value); }
} file{CreateFileW(path.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr)};
if (file.value == INVALID_HANDLE_VALUE) return false;
while (size) {
const auto chunk = static_cast<DWORD>(std::min<std::uint64_t>(size, 64ull * 1024 * 1024));
DWORD done{};
if (!WriteFile(file.value, data, chunk, &done, nullptr) || done != chunk) return false;
data += chunk;
size -= chunk;
}
return true;
}
// A pack in ReSkate's own format (builtin_pack_format.h): one zstd frame, unpacked whole.
bool unpack_packed(std::span<const unsigned char> bytes, const fs::path& target, std::string& issue) {
const auto size = ZSTD_getFrameContentSize(bytes.data(), bytes.size());
if (size == ZSTD_CONTENTSIZE_ERROR || size == ZSTD_CONTENTSIZE_UNKNOWN || size > max_pack_bytes + 16ull * 1024 * 1024) {
issue = "the embedded pack is not one ReSkate made";
return false;
}
std::vector<unsigned char> plain(static_cast<std::size_t>(size));
const auto unpacked = ZSTD_decompress(plain.data(), plain.size(), bytes.data(), bytes.size());
if (ZSTD_isError(unpacked) || unpacked != plain.size()) {
issue = std::string("the embedded pack could not be decompressed: ") + (ZSTD_isError(unpacked) ? ZSTD_getErrorName(unpacked) : "short");
return false;
}
std::size_t at = 0;
const auto take = [&](void* out, std::size_t count) {
if (count > plain.size() - at) return false;
std::memcpy(out, plain.data() + at, count);
at += count;
return true;
};
std::array<char, builtin_pack::magic.size()> magic{};
std::uint32_t count{};
if (!take(magic.data(), magic.size()) || magic != builtin_pack::magic || !take(&count, sizeof(count)) || count > 100000) {
issue = "the embedded pack is not one ReSkate made";
return false;
}
std::vector<std::pair<std::string, std::uint64_t>> files(count);
std::uint64_t total{};
for (auto& [name, length] : files) {
std::uint16_t name_length{};
if (!take(&name_length, sizeof(name_length))) { issue = "the embedded pack's list is cut short"; return false; }
name.resize(name_length);
if (!take(name.data(), name.size()) || !take(&length, sizeof(length))) { issue = "the embedded pack's list is cut short"; return false; }
total += length;
if (!safe_entry(name) || total > max_pack_bytes) {
issue = "the embedded pack has an entry it may not: " + name;
return false;
}
}
for (const auto& [name, length] : files) {
if (length > plain.size() - at) { issue = "the embedded pack is cut short at " + name; return false; }
if (!write_file(target / fs::path(name), plain.data() + at, length)) { issue = "could not unpack " + name; return false; }
at += static_cast<std::size_t>(length);
}
if (at != plain.size()) {
issue = "the embedded pack has bytes past its last file";
return false;
}
return true;
}
bool unpack(std::span<const unsigned char> bytes, const fs::path& target, std::string& issue) {
// ReSkate's own format (a zstd frame); else a plain zip, as a build without the packer embeds.
if (bytes.size() >= 4 && bytes[0] == 0x28 && bytes[1] == 0xb5 && bytes[2] == 0x2f && bytes[3] == 0xfd)
return unpack_packed(bytes, target, issue);
struct Zip {
mz_zip_archive value{};
~Zip() { mz_zip_reader_end(&value); }
} zip;
if (!mz_zip_reader_init_mem(&zip.value, bytes.data(), bytes.size(), 0)) {
issue = "the embedded pack is not a zip";
return false;
}
const auto count = mz_zip_reader_get_num_files(&zip.value);
std::uint64_t total{};
for (mz_uint index = 0; index < count; ++index) {
mz_zip_archive_file_stat info{};
if (!mz_zip_reader_file_stat(&zip.value, index, &info) || info.m_is_encrypted) {
issue = "the embedded pack has an unreadable entry";
return false;
}
if (info.m_is_directory) continue;
total += info.m_uncomp_size;
if (!safe_entry(info.m_filename) || total > max_pack_bytes) {
issue = std::string("the embedded pack has an entry it may not: ") + info.m_filename;
return false;
}
const auto path = target / fs::path(info.m_filename);
fs::create_directories(path.parent_path());
struct File {
HANDLE value;
~File() { if (value != INVALID_HANDLE_VALUE) CloseHandle(value); }
} file{CreateFileW(path.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr)};
struct Sink {
HANDLE file;
std::uint64_t written{}, limit;
} sink{file.value, 0, info.m_uncomp_size};
const auto write = [](void* opaque, mz_uint64 offset, const void* buffer, size_t size) -> size_t {
auto& target = *static_cast<Sink*>(opaque);
if (offset != target.written || size > target.limit - target.written) return 0;
DWORD done{};
if (!WriteFile(target.file, buffer, static_cast<DWORD>(size), &done, nullptr) || done != size) return 0;
target.written += size;
return size;
};
if (file.value == INVALID_HANDLE_VALUE || !mz_zip_reader_extract_to_callback(&zip.value, index, write, &sink, 0) ||
sink.written != info.m_uncomp_size) {
issue = std::string("could not unpack ") + info.m_filename;
return false;
}
}
return true;
}
} // namespace
bool unpack_builtin_pack(std::span<const unsigned char> bytes, const fs::path& target, std::string& issue) noexcept {
try {
return unpack(bytes, target, issue);
} catch (const std::exception& failure) {
issue = failure.what();
return false;
}
}
namespace {
// The official mods turned off (official_off_file), in small letters.
std::vector<std::string> official_off_list(const fs::path& mods_root) {
std::vector<std::string> names;
std::ifstream in(mods_root / std::string(official_folder) / std::string(official_off_file), std::ios::binary);
for (std::string line; std::getline(in, line);) {
while (!line.empty() && std::isspace(static_cast<unsigned char>(line.back()))) line.pop_back();
const auto first = line.find_first_not_of(" \t");
if (first == std::string::npos) continue;
auto name = lower_text(std::string_view(line).substr(first));
if (std::find(names.begin(), names.end(), name) == names.end()) names.push_back(std::move(name));
}
return names;
}
} // namespace
std::vector<std::string> move_official_mods(const fs::path& mods_root) noexcept {
std::vector<std::string> done;
try {
const auto kept = mods_root / std::string(official_folder);
std::error_code error;
if (!fs::is_directory(kept, error)) return done;
const auto off = official_off_list(mods_root);
bool listed = true;
for (fs::directory_iterator at(kept, error), end; !error && at != end; at.increment(error)) {
const auto wide = at->path().filename().wstring();
if (!at->is_directory(error) || wide.empty() || wide.front() == L'.' || wide.find(L".new") != std::wstring::npos ||
!official_mod(at->path()))
continue;
const auto name = path_utf8(at->path().filename());
const auto target = mods_root / at->path().filename();
std::error_code moved;
if (fs::exists(target, moved)) {
// Moved before, by a launcher that stopped before it was done: this copy is left over.
if (official_mod(target)) {
fs::remove_all(at->path(), moved);
continue;
}
// A copy of the player's own, which the official one replaces as it did from there.
fs::remove_all(target, moved);
if (moved) {
done.push_back(name + " could not be moved into the Mods folder: a copy there could not be replaced");
continue;
}
}
fs::rename(at->path(), target, moved);
if (moved) {
done.push_back(name + " could not be moved into the Mods folder (" + moved.message() + ")");
continue;
}
const bool on = std::find(off.begin(), off.end(), lower_text(name)) == off.end();
listed = set_mod_enabled(mods_root, name, on) && listed;
done.push_back(name + " moved into the Mods folder" + (on ? "" : ", switched off as it was"));
}
// The list of those turned off is in mods.json now.
if (listed) fs::remove(kept / std::string(official_off_file), error);
} catch (...) {}
return done;
}
bool official_mod(const fs::path& directory) noexcept {
std::error_code error;
return fs::is_regular_file(directory / std::string(official_stamp), error);
}
bool official_off(const fs::path& mods_root, std::string_view folder) noexcept {
return !folder.empty() && mod_disabled(mods_root, folder);
}
bool set_official_off(const fs::path& mods_root, std::string_view folder, bool off) noexcept {
return set_mod_enabled(mods_root, folder, !off);
}
bool official_in_use(const fs::path& mods_root, std::string_view folder) noexcept {
try {
return !folder.empty() && official_mod(mods_root / std::string(folder)) && !official_off(mods_root, folder);
} catch (...) {
return false;
}
}
bool retired_mod(std::string_view folder) noexcept {
try {
return std::find(std::begin(retired), std::end(retired), lower_text(folder)) != std::end(retired);
} catch (...) {
return false;
}
}
bool kept_by_reskate(const fs::path&, std::string_view folder) noexcept {
return !folder.empty() && retired_mod(folder);
}
std::vector<BuiltinPack> unpack_builtin_packs(const fs::path& mods_root, std::vector<std::string>& notes) noexcept {
std::vector<BuiltinPack> packs;
try {
Source source;
open_source(source);
for (const auto& pack : embedded) {
if (!source.module) break;
const auto bytes = resource_bytes(source.module, pack.resource);
if (bytes.empty()) continue;
const auto directory = mods_root / std::string(builtin_folder) / pack.name;
const auto version = version_of(bytes);
std::error_code error;
if (read_text(directory / version_file) != version) {
// Unpacked beside it, then swapped in: a pack is never half there.
const auto staging = fs::path(directory.wstring() + std::format(L".unpacking-{}", GetCurrentProcessId()));
fs::remove_all(staging, error);
std::string issue;
if (!unpack(bytes, staging, issue)) {
fs::remove_all(staging, error);
notes.push_back(std::format("Built-in content {} could not be unpacked: {}", pack.name, issue));
continue;
}
std::ofstream(staging / version_file, std::ios::binary) << version;
fs::remove_all(directory, error);
fs::rename(staging, directory, error);
if (error) {
fs::remove_all(staging, error);
notes.push_back(std::format("Built-in content {} could not be put in place ({})", pack.name, error.message()));
continue;
}
notes.push_back(std::format("Built-in content {} unpacked ({} bytes)", pack.name, bytes.size()));
}
packs.push_back({std::string("reskate-builtin-") + pack.name, directory});
}
// What an older ReSkate carried and this one does not (it became an official mod, or was
// dropped) goes: the Board Break deck list reads every folder in here.
std::error_code error;
const auto folder = mods_root / std::string(builtin_folder);
std::vector<fs::path> stale;
for (fs::directory_iterator at(folder, error), end; !error && at != end; at.increment(error))
if (std::none_of(packs.begin(), packs.end(), [&](const BuiltinPack& pack) { return pack.directory == at->path(); }))
stale.push_back(at->path());
for (const auto& path : stale) {
fs::remove_all(path, error);
if (!error) notes.push_back("Built-in content " + path_utf8(path.filename()) + " is no longer carried; removed");
}
if (packs.empty()) fs::remove(folder, error);
} catch (const std::exception& failure) {
notes.push_back(std::string("Built-in content could not be prepared: ") + failure.what());
}
return packs;
}
} // namespace dingosdk::mods