Files
ReSkate/Launcher/mod_profiles.cpp
T
AyeZeeBB 2e186b9839 Launcher: mod profiles, sharing them by code or file, and Thunderstore dependencies
The Mods page has a third tab, PROFILES. A profile is a saved list of which
mods are on, in what order and at what version; every mod stays installed
once, in the one Mods folder. Loading a profile rewrites mods.json, switches
off what it does not name, and downloads from Thunderstore what it names
that is missing. Profiles live in Mods/profiles.json.

A profile travels the way r2modman's do, so the two managers read each
other's: export.r2x inside a .r2z file, and a code on Thunderstore's
profile service. Config files in an r2modman profile are ignored.

Installing or updating a Thunderstore package now brings the packages it
depends on, needed ones first, leaving out any already installed at a new
enough version. A mod pack is a package that is nothing else.

A kick, by an admin or by a passed vote, used to keep the player out until
the server restarted. "kick_minutes" in the players section of the config
(1-1440; 0, the default, is still until the restart) and the console
command "kick-time <minutes>|restart" now set how long it lasts.

The player is told how long when they are kicked, and how many minutes are
left if they try to join early. A kick keeps the time it was given, so
changing the setting is for later kicks. Bans are unchanged.
2026-10-13 00:55:29 -05:00

442 lines
18 KiB
C++

#include "Launcher/mod_profiles.h"
#include "Engine/Core/Json/json.h"
#include <miniz.h>
#include <algorithm>
#include <array>
#include <charconv>
#include <cstdint>
#include <format>
#include <fstream>
#include <map>
#include <set>
#include <stdexcept>
namespace fs = std::filesystem;
namespace dingosdk::launcher_profiles {
namespace {
[[noreturn]] void fail(const std::string& message) { throw std::runtime_error(message); }
constexpr char export_name[] = "export.r2x";
constexpr char payload_prefix[] = "#r2modman";
constexpr std::size_t max_export_bytes = 1024 * 1024, max_payload_bytes = 8 * 1024 * 1024;
constexpr std::string_view base64_digits = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
// Folder names are compared as Windows compares them.
std::string folded(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::string_view trimmed(std::string_view text) {
while (!text.empty() && (text.front() == ' ' || text.front() == '\t')) text.remove_prefix(1);
while (!text.empty() && (text.back() == ' ' || text.back() == '\t' || text.back() == '\r')) text.remove_suffix(1);
return text;
}
bool semantic(std::string_view version) {
int parts = 1;
bool digit = false;
for (const char ch : version) {
if (ch == '.') {
if (!digit) return false;
++parts;
digit = false;
} else if (ch >= '0' && ch <= '9') digit = true;
else return false;
}
return digit && parts == 3;
}
// A YAML scalar as plain text: quotes taken off, and a double-quoted one's escapes undone.
std::string scalar(std::string_view value) {
value = trimmed(value);
if (value.size() >= 2 && value.front() == '\'' && value.back() == '\'') {
std::string out;
const auto inner = value.substr(1, value.size() - 2);
for (std::size_t i = 0; i < inner.size(); ++i) {
out += inner[i];
if (inner[i] == '\'' && i + 1 < inner.size() && inner[i + 1] == '\'') ++i;
}
return out;
}
if (value.size() >= 2 && value.front() == '"' && value.back() == '"') {
std::string out;
const auto inner = value.substr(1, value.size() - 2);
for (std::size_t i = 0; i < inner.size(); ++i) {
if (inner[i] == '\\' && i + 1 < inner.size()) ++i;
out += inner[i];
}
return out;
}
// An unquoted value ends at a comment.
if (const auto comment = value.find(" #"); comment != std::string_view::npos) value = trimmed(value.substr(0, comment));
return std::string(value);
}
std::string yaml_quoted(std::string_view text) {
std::string out = "\"";
for (const char ch : text) {
if (ch == '"' || ch == '\\') out += '\\';
out += ch;
}
return out + '"';
}
std::wstring widen(std::string_view text) { return std::wstring(text.begin(), text.end()); }
struct Zip {
mz_zip_archive value{};
bool reader{};
~Zip() {
if (reader) mz_zip_reader_end(&value);
}
};
// A zip holding one file, stored as it is. miniz is built here without its writer, and a
// profile is a few kilobytes of text.
std::string stored_zip(std::string_view name, std::string_view data) {
std::string out;
const auto put = [&](std::uint32_t value, int bytes) {
for (int i = 0; i < bytes; ++i) out += static_cast<char>((value >> (8 * i)) & 0xff);
};
const auto crc = static_cast<std::uint32_t>(
mz_crc32(MZ_CRC32_INIT, reinterpret_cast<const unsigned char*>(data.data()), data.size()));
const auto size = static_cast<std::uint32_t>(data.size()), length = static_cast<std::uint32_t>(name.size());
constexpr std::uint32_t dos_date = 0x21; // 1 January 1980: the format's zero
// Local header, then the file.
put(0x04034b50, 4); put(20, 2); put(0, 2); put(0, 2); put(0, 2); put(dos_date, 2);
put(crc, 4); put(size, 4); put(size, 4); put(length, 2); put(0, 2);
out += name;
out += data;
// Central directory with its one entry.
const auto central = static_cast<std::uint32_t>(out.size());
put(0x02014b50, 4); put(20, 2); put(20, 2); put(0, 2); put(0, 2); put(0, 2); put(dos_date, 2);
put(crc, 4); put(size, 4); put(size, 4); put(length, 2); put(0, 2); put(0, 2); put(0, 2); put(0, 2); put(0, 4); put(0, 4);
out += name;
const auto central_size = static_cast<std::uint32_t>(out.size()) - central;
put(0x06054b50, 4); put(0, 2); put(0, 2); put(1, 2); put(1, 2); put(central_size, 4); put(central, 4); put(0, 2);
return out;
}
std::string base64(std::string_view bytes) {
std::string out;
out.reserve((bytes.size() + 2) / 3 * 4);
for (std::size_t i = 0; i < bytes.size(); i += 3) {
const auto at = [&](std::size_t offset) {
return i + offset < bytes.size() ? static_cast<unsigned char>(bytes[i + offset]) : 0u;
};
const unsigned block = (at(0) << 16) | (at(1) << 8) | at(2);
out += base64_digits[(block >> 18) & 63];
out += base64_digits[(block >> 12) & 63];
out += i + 1 < bytes.size() ? base64_digits[(block >> 6) & 63] : '=';
out += i + 2 < bytes.size() ? base64_digits[block & 63] : '=';
}
return out;
}
std::string unbase64(std::string_view text) {
std::string out;
unsigned block{};
int bits{};
for (const char ch : text) {
if (ch == '=' || ch == '\r' || ch == '\n' || ch == ' ' || ch == '\t') continue;
const auto digit = base64_digits.find(ch);
if (digit == std::string_view::npos) fail("The profile code holds text that is not a profile.");
block = (block << 6) | static_cast<unsigned>(digit);
bits += 6;
if (bits >= 8) {
bits -= 8;
out += static_cast<char>((block >> bits) & 0xff);
}
}
return out;
}
} // namespace
Profile* Profiles::find(std::string_view name) {
for (auto& profile : list)
if (folded(profile.name) == folded(name)) return &profile;
return nullptr;
}
std::string Profiles::free_name(std::string_view wanted) const {
const auto taken = [&](std::string_view name) {
return std::any_of(list.begin(), list.end(), [&](const Profile& p) { return folded(p.name) == folded(name); });
};
std::string name(wanted);
for (int number = 2; taken(name); ++number) {
const auto suffix = std::format(" ({})", number);
name = std::string(wanted.substr(0, max_name_bytes - std::min(max_name_bytes, suffix.size()))) + suffix;
}
return name;
}
bool valid_profile_name(std::string_view name) {
if (name.empty() || name.size() > max_name_bytes || name.front() == ' ' || name.back() == ' ' || name.back() == '.')
return false;
return std::all_of(name.begin(), name.end(), [](char ch) {
const auto byte = static_cast<unsigned char>(ch);
return byte >= 0x20 && byte != 0x7f && std::string_view("<>:\"/\\|?*").find(ch) == std::string_view::npos;
});
}
std::string clean_profile_name(std::string_view name) {
std::string out;
for (const char ch : name) {
const auto byte = static_cast<unsigned char>(ch);
if (byte < 0x20 || byte == 0x7f || std::string_view("<>:\"/\\|?*").find(ch) != std::string_view::npos) continue;
out += ch;
}
while (out.size() > max_name_bytes) {
// Never cut a UTF-8 character in half.
do out.pop_back();
while (!out.empty() && (static_cast<unsigned char>(out.back()) & 0xC0) == 0x80);
}
while (!out.empty() && out.front() == ' ') out.erase(out.begin());
while (!out.empty() && (out.back() == ' ' || out.back() == '.')) out.pop_back();
return out.empty() ? std::string("Profile") : out;
}
Profiles load(const fs::path& mods_root) noexcept {
Profiles profiles;
try {
std::ifstream input(mods_root / profiles_file, std::ios::binary);
if (!input) return profiles;
const std::string text{std::istreambuf_iterator<char>(input), std::istreambuf_iterator<char>()};
const auto root = Json::parse(text, JsonLimits{8 * 1024 * 1024, 8, 1024 * 1024});
if (!root.is_object() || !root.contains("profiles") || !root.at("profiles").is_array()) return profiles;
for (const auto& row : root.at("profiles")) {
if (!row.is_object() || profiles.list.size() >= max_profiles) continue;
Profile profile;
profile.name = row.value("name", "");
if (!valid_profile_name(profile.name) || profiles.find(profile.name)) continue;
if (row.contains("mods") && row.at("mods").is_array())
for (const auto& item : row.at("mods")) {
if (!item.is_object() || profile.mods.size() >= max_profile_mods) continue;
ProfileMod mod{item.value("name", ""), item.value("version", ""), item.value("enabled", true)};
if (mods::valid_mod_name(mod.name)) profile.mods.push_back(std::move(mod));
}
profiles.list.push_back(std::move(profile));
}
profiles.active = root.value("active", "");
if (!profiles.find(profiles.active)) profiles.active.clear();
} catch (...) {
profiles = {};
}
return profiles;
}
void save(const fs::path& mods_root, const Profiles& profiles) {
auto list = Json::array();
for (const auto& profile : profiles.list) {
auto mods = Json::array();
for (const auto& mod : profile.mods) {
Json row = Json::object();
row["name"] = mod.name;
row["version"] = mod.version;
row["enabled"] = mod.enabled;
mods.push_back(std::move(row));
}
Json row = Json::object();
row["name"] = profile.name;
row["mods"] = std::move(mods);
list.push_back(std::move(row));
}
Json root = Json::object();
root["active"] = profiles.active;
root["profiles"] = std::move(list);
const auto target = mods_root / profiles_file;
auto temporary = target;
temporary += L".tmp";
{
std::ofstream output(temporary, std::ios::binary | std::ios::trunc);
output << root.dump(2) << '\n';
if (!output.flush()) fail("Could not write the profiles file in the Mods folder.");
}
std::error_code error;
fs::rename(temporary, target, error);
if (error) {
fs::remove(temporary, error);
fail("Could not replace the profiles file in the Mods folder.");
}
}
Profile capture(std::string name, const mods::ModList& list) {
Profile profile;
profile.name = std::move(name);
for (const auto& entry : list.entries) {
if (profile.mods.size() >= max_profile_mods) break;
profile.mods.push_back({entry.mod.name, semantic(entry.mod.version) ? entry.mod.version : std::string("0.0.0"),
entry.enabled});
}
return profile;
}
Applied apply(const Profile& profile, const mods::ModList& list) {
Applied result;
std::map<std::string, const mods::ModEntry*> installed;
for (const auto& entry : list.entries) installed.emplace(folded(entry.mod.name), &entry);
std::set<std::string> used;
for (const auto& mod : profile.mods) {
const auto key = folded(mod.name);
const auto found = installed.find(key);
if (found == installed.end()) {
if (mod.enabled) result.missing.push_back(mod);
continue;
}
if (!used.insert(key).second) continue;
result.entries.push_back(*found->second);
result.entries.back().enabled = mod.enabled;
}
for (const auto& entry : list.entries)
if (!used.contains(folded(entry.mod.name))) {
result.entries.push_back(entry);
result.entries.back().enabled = false;
}
return result;
}
bool in_use(const Profile& profile, const mods::ModList& list) {
const auto applied = apply(profile, list);
if (!applied.missing.empty()) return false;
const auto enabled = [](const std::vector<mods::ModEntry>& entries) {
std::vector<std::string> names;
for (const auto& entry : entries)
if (entry.enabled) names.push_back(folded(entry.mod.name));
return names;
};
return enabled(applied.entries) == enabled(list.entries);
}
std::string export_text(const Profile& profile) {
std::string out = "profileName: " + yaml_quoted(profile.name) + "\nmods:\n";
for (const auto& mod : profile.mods) {
std::array<int, 3> parts{};
if (semantic(mod.version)) {
std::size_t at{};
for (auto& part : parts) {
const auto end = std::min(mod.version.find('.', at), mod.version.size());
std::from_chars(mod.version.data() + at, mod.version.data() + end, part);
at = end + 1;
}
}
out += std::format(" - name: {}\n version:\n major: {}\n minor: {}\n patch: {}\n enabled: {}\n",
yaml_quoted(mod.name), parts[0], parts[1], parts[2], mod.enabled ? "true" : "false");
}
if (profile.mods.empty()) out.replace(out.size() - 1, 1, " []\n");
return out;
}
Profile parse_export(std::string_view text) {
if (text.size() > max_export_bytes) fail("The profile is too large to read.");
Profile profile;
bool named{}, listed{};
std::array<int, 3> version{};
const auto finish = [&] {
if (!profile.mods.empty() && profile.mods.back().version.empty())
profile.mods.back().version = std::format("{}.{}.{}", version[0], version[1], version[2]);
};
for (std::size_t at = 0; at < text.size();) {
const auto end = std::min(text.find('\n', at), text.size());
auto line = text.substr(at, end - at);
at = end + 1;
const bool top = !line.empty() && line.front() != ' ' && line.front() != '\t' && line.front() != '-';
line = trimmed(line);
if (line.empty() || line.front() == '#') continue;
if (line.front() == '-') {
// The next mod. What follows the dash is its first field.
finish();
if (profile.mods.size() >= max_profile_mods) fail("The profile lists more mods than can be read.");
profile.mods.emplace_back();
version = {};
line = trimmed(line.substr(1));
if (line.empty()) continue;
}
const auto colon = line.find(':');
if (colon == std::string_view::npos) continue;
const auto key = trimmed(line.substr(0, colon));
const auto value = scalar(line.substr(colon + 1));
if (top && key == "profileName") {
profile.name = value;
named = true;
} else if (top && key == "mods") {
listed = true;
} else if (!profile.mods.empty()) {
auto& mod = profile.mods.back();
const auto number = [&](int& part) { std::from_chars(value.data(), value.data() + value.size(), part); };
if (key == "name") mod.name = value;
else if (key == "major") number(version[0]);
else if (key == "minor") number(version[1]);
else if (key == "patch") number(version[2]);
else if (key == "enabled") mod.enabled = value != "false";
}
}
finish();
if (!named || !listed) fail("That is not a mod profile (it has no profileName and mods).");
profile.name = clean_profile_name(profile.name);
// A name that could not be a mod folder here is not a mod this launcher can hold.
std::erase_if(profile.mods, [](const ProfileMod& mod) { return !mods::valid_mod_name(mod.name); });
return profile;
}
std::string export_archive(const Profile& profile) {
return stored_zip(export_name, export_text(profile));
}
Profile parse_archive(std::string_view zip_bytes) {
// r2modman also accepts the bare export.r2x where a .r2z is asked for.
if (!zip_bytes.starts_with("PK")) return parse_export(zip_bytes);
Zip zip;
if (!mz_zip_reader_init_mem(&zip.value, zip_bytes.data(), zip_bytes.size(), 0)) fail("The profile file is not a readable archive.");
zip.reader = true;
const auto index = mz_zip_reader_locate_file(&zip.value, export_name, nullptr, 0);
mz_zip_archive_file_stat entry{};
if (index < 0 || !mz_zip_reader_file_stat(&zip.value, static_cast<mz_uint>(index), &entry) || entry.m_is_directory ||
entry.m_is_encrypted)
fail("The profile file has no export.r2x in it.");
if (entry.m_uncomp_size > max_export_bytes) fail("The profile is too large to read.");
std::string text(static_cast<std::size_t>(entry.m_uncomp_size), '\0');
if (!mz_zip_reader_extract_to_mem(&zip.value, static_cast<mz_uint>(index), text.data(), text.size(), 0))
fail("The profile file is damaged.");
return parse_export(text);
}
std::wstring code_create_url() { return L"https://thunderstore.io/api/experimental/legacyprofile/create/"; }
std::wstring code_get_url(std::string_view code) {
return L"https://thunderstore.io/api/experimental/legacyprofile/get/" + widen(code) + L"/";
}
std::string code_payload(const Profile& profile) {
return std::string(payload_prefix) + "\n" + base64(export_archive(profile));
}
Profile parse_code_payload(std::string_view payload) {
if (payload.size() > max_payload_bytes) fail("The profile is too large to read.");
if (!payload.starts_with(payload_prefix)) fail("That code does not lead to a mod profile.");
return parse_archive(unbase64(payload.substr(sizeof(payload_prefix) - 1)));
}
std::string parse_created_code(std::string_view json) {
const auto root = Json::parse(json, JsonLimits{64 * 1024, 4, 1024});
const auto code = root.is_object() ? root.value("key", "") : std::string();
if (!valid_code(code)) fail("Thunderstore answered without a profile code.");
return code;
}
bool valid_code(std::string_view code) {
return code.size() >= 8 && code.size() <= 64 && std::all_of(code.begin(), code.end(), [](char ch) {
return (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F') || ch == '-';
});
}
} // namespace dingosdk::launcher_profiles