Files
ReSkate/Engine/Vfs/mod_list.cpp
T
2026-10-02 14:40:41 -05:00

434 lines
20 KiB
C++

#include "mod_list.h"
#include "Engine/Game/Build/supported_build.h"
#include "Engine/Core/Json/json.h"
#include <Windows.h>
#include <algorithm>
#include <cstdint>
#include <fstream>
#include <map>
#include <set>
#include <stdexcept>
#include <utility>
namespace dingosdk::mods {
namespace {
namespace fs = std::filesystem;
constexpr char layout_name[] = "layout.toc";
constexpr char levels_name[] = "reskate-levels.json";
// Written by the tool that built the mod (Studio, since 2026-09-24): who made
// it and when. Purely informational, so it is read loosely and never rejected.
constexpr char build_name[] = "reskate-build.json";
constexpr std::size_t maximum_order_bytes = 256 * 1024;
constexpr std::size_t maximum_build_bytes = 16 * 1024;
constexpr std::size_t maximum_info_bytes = 32 * 1024;
constexpr std::size_t maximum_levels_bytes = 64 * 1024;
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<unsigned char> read_file(const fs::path& path, std::size_t maximum) {
std::ifstream input(path, std::ios::binary | std::ios::ate);
if (!input) throw std::runtime_error("Cannot open " + path.string());
const auto length = input.tellg();
if (length < 0 || static_cast<std::uint64_t>(length) > maximum)
throw std::runtime_error("File exceeds size limit: " + path.string());
std::vector<unsigned char> 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.string());
return bytes;
}
// Text bound for display and the log, kept to printable characters and a sane
// length. UTF-8 sequences pass through; control bytes become '?'.
std::string printable(std::string_view text, std::size_t limit) {
std::string result;
for (const auto value : text.substr(0, limit)) {
const auto ch = static_cast<unsigned char>(value);
result += ch >= 0x20 && ch != 0x7f ? value : '?';
}
if (text.size() > limit) {
while (!result.empty() && (static_cast<unsigned char>(result.back()) & 0xc0) == 0x80) result.pop_back();
if (!result.empty() && static_cast<unsigned char>(result.back()) >= 0xc0) result.pop_back();
result += "...";
}
return result;
}
// Players and tools write these by hand, and several Windows editors still
// prepend a byte-order mark, so skip one rather than rejecting the file.
Json parse(const fs::path& path, std::size_t maximum) {
const auto bytes = read_file(path, maximum);
std::string_view text(reinterpret_cast<const char*>(bytes.data()), bytes.size());
if (text.starts_with("\xef\xbb\xbf")) text.remove_prefix(3);
return Json::parse(text, JsonLimits{maximum, 8, 8192});
}
std::string string_field(const Json& root, const char* name, std::size_t limit) {
return root.contains(name) && root.at(name).is_string() ? printable(root.at(name).string(), limit) : std::string{};
}
// reskate-build.json: {"schema":1,"tool":"ReSkateStudio","version":"1.0.0",
// "built":"2026-09-24T18:35:00Z"}. Extra fields are fine; a broken file is a
// note, never a reason to skip the mod.
void read_build_info(Mod& mod, std::vector<std::string>& notes) {
const auto path = mod.directory / build_name;
std::error_code error;
if (!fs::is_regular_file(path, error) || error) return;
try {
const auto root = parse(path, maximum_build_bytes);
if (!root.is_object()) throw std::runtime_error("not a JSON object");
mod.tool = string_field(root, "tool", 64);
mod.version = string_field(root, "version", 64);
mod.built = string_field(root, "built", 64);
if (mod.tool.empty() && mod.version.empty() && mod.built.empty())
throw std::runtime_error("it names no tool, version or build time");
} catch (const std::exception& failure) {
notes.push_back("Mod " + mod.name + ": " + build_name + " could not be read (" + failure.what() + ")");
}
}
// manifest.json (or reskate-mod.json for mods made before manifests): the
// author's own description of the mod.
void read_mod_info(Mod& mod, std::vector<std::string>& notes) {
std::error_code error;
const bool manifest = fs::is_regular_file(mod.directory / manifest_file, error);
const char* name = manifest ? manifest_file : info_file;
const auto path = mod.directory / name;
if (!fs::is_regular_file(path, error) || error) return;
try {
const auto root = parse(path, maximum_info_bytes);
if (!root.is_object()) throw std::runtime_error("not a JSON object");
if (auto title = string_field(root, "name", 64); !title.empty()) mod.title = std::move(title);
mod.author = string_field(root, "author", 64);
auto version = string_field(root, manifest ? "version_number" : "version", 32);
if (version.empty()) version = string_field(root, manifest ? "version" : "version_number", 32);
if (!version.empty()) mod.version = std::move(version);
mod.description = string_field(root, "description", 1024);
} catch (const std::exception& failure) {
notes.push_back("Mod " + mod.name + ": " + name + " could not be read (" + failure.what() + ")");
}
}
// The level assets the mod registers, for its summary line and for naming
// the mod when one of them never finishes loading. The strict reading that
// decides what the level list shows happens in the runtime, with its own log.
void read_levels(Mod& mod) {
if (!mod.provides_levels) return;
try {
const auto root = parse(mod.directory / levels_name, maximum_levels_bytes);
if (!root.is_object() || !root.contains("levels") || !root.at("levels").is_array()) return;
for (const auto& row : root.at("levels")) {
if (!row.is_object() || !row.contains("asset") || !row.at("asset").is_string()) continue;
mod.levels.push_back(printable(row.at("asset").string(), 160));
if (mod.levels.size() >= 64) break;
}
} catch (...) {
// The runtime reports what is wrong with the file when it reads it.
}
}
// .reskate-studio-patch: the stamp line, then key=value lines. A mod that ships game data must
// record the Skate.exe it was built for, and it must be this one (supported_build).
void read_studio_marker(Mod& mod) {
const auto path = mod.directory / studio_marker_file;
std::error_code error;
if (fs::is_regular_file(path, error) && !error) {
mod.studio_marker = true;
try {
const auto bytes = read_file(path, 4096);
std::string_view text(reinterpret_cast<const char*>(bytes.data()), bytes.size());
if (text.starts_with("\xef\xbb\xbf")) text.remove_prefix(3);
for (std::size_t at = 0; at < text.size();) {
const auto end = text.find('\n', at);
auto line = text.substr(at, end == std::string_view::npos ? std::string_view::npos : end - at);
if (!line.empty() && line.back() == '\r') line.remove_suffix(1);
if (line.starts_with("skate_sha256=")) {
auto value = lower(line.substr(13));
const bool hex = value.size() == 64 && std::all_of(value.begin(), value.end(), [](char c) {
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f');
});
if (hex) mod.skate_sha256 = std::move(value);
}
if (end == std::string_view::npos) break;
at = end + 1;
}
} catch (...) {}
}
if (mod.studio_marker) {
if (mod.skate_sha256.empty())
mod.outdated = "it was built by an older ReSkate Studio that does not record which game version it is for";
else if (mod.skate_sha256 != supported_build::game_sha256)
mod.outdated = "it was built for another game version (Skate.exe " + mod.skate_sha256.substr(0, 12) + ")";
} else if (mod.provides_layout) {
mod.outdated = "it ships game data without a ReSkate Studio game version stamp";
}
}
std::vector<std::string> folders_on_disk(const fs::path& root) {
std::vector<std::string> names;
std::error_code error;
for (const auto& entry : fs::directory_iterator(root, error)) {
if (error) break;
if (!entry.is_directory(error) || error) { error.clear(); continue; }
const auto name = ascii_path(entry.path().filename());
if (valid_mod_name(name)) names.push_back(name);
}
std::sort(names.begin(), names.end(), [](const std::string& a, const std::string& b) {
return lower(a) < lower(b);
});
return names;
}
// mods.json rows in priority order. Throws when the file is malformed, so a
// typo cannot silently change which mods load.
std::vector<std::pair<std::string, bool>> read_order(const fs::path& path) {
const auto root = parse(path, maximum_order_bytes);
if (!root.is_object() || root.size() != 2 || !root.contains("schema") || !root.contains("mods"))
throw std::runtime_error("mods.json must hold exactly schema and mods");
if (!root.at("schema").is_number_integer() || root.at("schema").get<std::uint64_t>() != 1)
throw std::runtime_error("mods.json schema must be integer 1");
const auto& rows = root.at("mods");
if (!rows.is_array()) throw std::runtime_error("mods.json mods must be an array");
if (rows.size() > maximum_mods)
throw std::runtime_error("mods.json lists more than " + std::to_string(maximum_mods) + " mods");
std::vector<std::pair<std::string, bool>> order;
std::set<std::string, std::less<>> seen;
for (const auto& row : rows) {
if (!row.is_object() || row.size() != 2 || !row.contains("name") || !row.contains("enabled"))
throw std::runtime_error("each mods.json row must hold exactly name and enabled");
const auto& name = row.at("name");
const auto& enabled = row.at("enabled");
if (!name.is_string() || !valid_mod_name(name.string()))
throw std::runtime_error("mods.json name is not a plain folder name");
if (!enabled.is_boolean()) throw std::runtime_error("mods.json enabled must be true or false");
if (!seen.emplace(lower(name.string())).second)
throw std::runtime_error("mods.json lists " + name.string() + " twice");
order.emplace_back(name.string(), enabled.get<bool>());
}
return order;
}
} // namespace
std::string mod_fingerprint(const std::filesystem::path& directory) {
std::vector<std::string> files;
std::error_code error;
for (fs::recursive_directory_iterator it(directory, error), end; !error && it != end; it.increment(error)) {
if (!it->is_regular_file(error)) { error.clear(); continue; }
const auto size = it->file_size(error);
const auto written = it->last_write_time(error).time_since_epoch().count();
// Wide, so any file name counts; ASCII names give the same text as before.
const auto relative = fs::relative(it->path(), directory, error).generic_wstring();
std::string name;
for (const auto ch : relative) {
if (ch < 0x80) name.push_back(static_cast<char>(ch));
else name += '#' + std::to_string(static_cast<unsigned>(ch));
}
files.push_back(lower(name) + '|' +
std::to_string(size) + '|' + std::to_string(written));
error.clear();
}
std::sort(files.begin(), files.end());
std::uint64_t hash = 1469598103934665603ull; // FNV-1a
for (const auto& file : files)
for (const auto c : file + '\n') { hash ^= static_cast<unsigned char>(c); hash *= 1099511628211ull; }
char text[17]{};
std::snprintf(text, sizeof(text), "%016llx", static_cast<unsigned long long>(hash));
return std::to_string(files.size()) + '-' + text;
}
std::map<std::string, Exclusion, std::less<>> read_exclusions(const std::filesystem::path& mods_root) noexcept {
std::map<std::string, Exclusion, std::less<>> result;
try {
const auto path = mods_root / exclusions_file;
std::error_code error;
if (!fs::is_regular_file(path, error)) return result;
const auto root = parse(path, maximum_order_bytes);
if (!root.is_object() || !root.contains("mods") || !root.at("mods").is_object()) return result;
for (const auto& [name, row] : root.at("mods").items()) {
if (!valid_mod_name(name) || !row.is_object()) continue;
Exclusion exclusion;
exclusion.fingerprint = row.value("fingerprint", std::string{});
exclusion.sdk = row.value("sdk", std::string{});
if (row.contains("problems") && row.at("problems").is_array())
for (const auto& problem : row.at("problems"))
if (problem.is_string() && exclusion.problems.size() < 16)
exclusion.problems.push_back(printable(problem.string(), 600));
if (!exclusion.fingerprint.empty()) result.emplace(name, std::move(exclusion));
}
} catch (...) { /* An unreadable file just means merging again. */ }
return result;
}
void write_exclusions(const std::filesystem::path& mods_root,
const std::map<std::string, Exclusion, std::less<>>& exclusions) noexcept {
try {
const auto path = mods_root / exclusions_file;
std::error_code error;
if (exclusions.empty()) { fs::remove(path, error); return; }
auto rows = Json::object();
for (const auto& [name, exclusion] : exclusions) {
auto row = Json::object();
row["fingerprint"] = exclusion.fingerprint;
row["sdk"] = exclusion.sdk;
auto problems = Json::array();
for (std::size_t i = 0; i < exclusion.problems.size() && i < 16; ++i)
problems.push_back(exclusion.problems[i].substr(0, 600));
row["problems"] = std::move(problems);
rows[name] = std::move(row);
}
auto root = Json::object();
root["schema"] = 1;
root["mods"] = std::move(rows);
const auto text = root.dump(2) + "\n";
auto temporary = path;
temporary += L".tmp";
{
std::ofstream output(temporary, std::ios::binary | std::ios::trunc);
output.write(text.data(), static_cast<std::streamsize>(text.size()));
if (!output.flush()) return;
}
MoveFileExW(temporary.c_str(), path.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH);
} catch (...) {}
}
std::string ascii_path(const std::filesystem::path& path) {
const auto wide = path.generic_wstring();
std::string text;
text.reserve(wide.size());
for (const auto ch : wide) {
if (ch == 0 || ch > 0x7f) return {};
text.push_back(static_cast<char>(ch));
}
return text;
}
bool valid_mod_name(std::string_view name) {
if (name.empty() || name.size() > maximum_mod_name || name.front() == '.') return false;
return std::all_of(name.begin(), name.end(), [](char value) {
const auto ch = static_cast<unsigned char>(value);
return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') ||
(ch >= '0' && ch <= '9') || value == '_' || value == '-' || value == '.' || value == ' ';
});
}
ModList scan_mods(const std::filesystem::path& data_root) noexcept {
ModList result;
try {
if (data_root.empty()) {
result.issue = "The engine data root could not be resolved";
return result;
}
result.root = data_root / mods_folder;
std::error_code error;
if (!fs::is_directory(result.root, error) || error) return result;
result.present = true;
const auto names = folders_on_disk(result.root);
std::map<std::string, std::string, std::less<>> present;
for (const auto& name : names) present.emplace(lower(name), name);
std::vector<std::pair<std::string, bool>> order;
const auto order_path = result.root / order_file;
if (fs::is_regular_file(order_path, error)) {
try {
order = read_order(order_path);
} catch (const std::exception& failure) {
result.issue = failure.what();
order.clear();
}
}
// Folders nobody listed still load, after the listed ones, so dropping
// a mod in works without editing mods.json.
std::set<std::string, std::less<>> listed;
for (const auto& [name, enabled] : order) listed.emplace(lower(name));
for (const auto& name : names)
if (!listed.contains(lower(name))) order.emplace_back(name, true);
for (const auto& [name, enabled] : order) {
const auto found = present.find(lower(name));
if (found == present.end()) {
result.missing.push_back(name);
continue;
}
ModEntry entry;
entry.enabled = enabled;
auto& mod = entry.mod;
mod.name = found->second;
mod.title = mod.name;
mod.directory = result.root / mod.name;
mod.provides_layout = fs::is_regular_file(mod.directory / layout_name, error);
mod.provides_levels = fs::is_regular_file(mod.directory / levels_name, error);
for (fs::directory_iterator it(mod.directory / "parks", error), end; !error && it != end; it.increment(error)) {
auto file = ascii_path(it->path().filename());
if (!file.ends_with(".park.json") || mod.park_maps.size() >= 16) continue;
file.resize(file.size() - 10);
mod.park_maps.push_back(std::move(file));
}
error.clear();
std::sort(mod.park_maps.begin(), mod.park_maps.end());
read_build_info(mod, result.notes);
read_mod_info(mod, result.notes);
read_levels(mod);
read_studio_marker(mod);
result.entries.push_back(std::move(entry));
}
// Only mods named in the exclusions file are fingerprinted.
for (const auto& [name, exclusion] : read_exclusions(result.root))
for (const auto& entry : result.entries)
if (entry.mod.name == name && mod_fingerprint(entry.mod.directory) == exclusion.fingerprint)
result.excluded.emplace(name, exclusion.problems);
} catch (const std::exception& failure) {
if (result.issue.empty()) result.issue = failure.what();
} catch (...) {
if (result.issue.empty()) result.issue = "The Mods folder could not be read";
}
return result;
}
void save_mod_order(const std::filesystem::path& mods_root, const std::vector<ModEntry>& entries) {
if (entries.size() > maximum_mods)
throw std::runtime_error("At most " + std::to_string(maximum_mods) + " mods can be listed");
auto rows = Json::array();
for (const auto& entry : entries) {
if (!valid_mod_name(entry.mod.name))
throw std::runtime_error("\"" + entry.mod.name + "\" is not a valid mod folder name");
auto row = Json::object();
row["name"] = entry.mod.name;
row["enabled"] = entry.enabled;
rows.push_back(std::move(row));
}
auto root = Json::object();
root["schema"] = 1;
root["mods"] = std::move(rows);
const auto text = root.dump(2) + "\n";
fs::create_directories(mods_root);
const auto target = mods_root / order_file;
auto temporary = target;
temporary += L".tmp";
{
std::ofstream output(temporary, std::ios::binary | std::ios::trunc);
if (!output || !output.write(text.data(), static_cast<std::streamsize>(text.size())) || !output.flush())
throw std::runtime_error("Cannot write " + temporary.string());
}
if (!MoveFileExW(temporary.c_str(), target.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) {
std::error_code ignored;
fs::remove(temporary, ignored);
throw std::runtime_error("Cannot replace " + target.string() + " (Windows error " +
std::to_string(GetLastError()) + ")");
}
}
} // namespace dingosdk::mods