#include "mod_list.h" #include "Engine/Game/Build/supported_build.h" #include "Engine/Core/Json/json.h" #include "Engine/Core/Platform/path_text.h" #include #include #include #include #include #include #include #include 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"; // mods.json and the list of mods left out: as much as the JSON reader itself takes, so the // number of mods is not held down by the size of the file that lists them. constexpr std::size_t maximum_order_bytes = 4 * 1024 * 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(ch + ('a' - 'A')); return result; } std::vector 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_utf8(path)); const auto length = input.tellg(); if (length < 0 || static_cast(length) > maximum) throw std::runtime_error("File exceeds 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; } // 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(value); result += ch >= 0x20 && ch != 0x7f ? value : '?'; } if (text.size() > limit) { while (!result.empty() && (static_cast(result.back()) & 0xc0) == 0x80) result.pop_back(); if (!result.empty() && static_cast(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(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& 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& 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(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 folders_on_disk(const fs::path& root) { std::vector 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> 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() != 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"); std::vector> order; std::set> 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()); } return order; } } // namespace std::string mod_fingerprint(const std::filesystem::path& directory) { std::vector 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(ch)); else name += '#' + std::to_string(static_cast(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(c); hash *= 1099511628211ull; } char text[17]{}; std::snprintf(text, sizeof(text), "%016llx", static_cast(hash)); return std::to_string(files.size()) + '-' + text; } std::map> read_exclusions(const std::filesystem::path& mods_root) noexcept { std::map> 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>& 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(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(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(value); return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || value == '_' || value == '-' || value == '.' || value == ' '; }); } Mod describe_mod(const std::filesystem::path& directory, std::string name, std::vector& notes) { std::error_code error; Mod mod; mod.name = std::move(name); mod.title = mod.name; mod.directory = directory; 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)); } std::sort(mod.park_maps.begin(), mod.park_maps.end()); read_build_info(mod, notes); read_mod_info(mod, notes); read_levels(mod); read_studio_marker(mod); return mod; } 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> present; for (const auto& name : names) present.emplace(lower(name), name); std::vector> 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> 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; entry.mod = describe_mod(result.root / found->second, found->second, result.notes); 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& entries) { 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(text.size())) || !output.flush()) throw std::runtime_error("Cannot write " + path_utf8(temporary)); } 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 " + path_utf8(target) + " (Windows error " + std::to_string(GetLastError()) + ")"); } } } // namespace dingosdk::mods