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.
This commit is contained in:
AyeZeeBB committed 2026-10-13 00:55:29 -05:00
1 parent 242ac11814
commit 2e186b9839
24 files changed
+1488 -38

No files matched your search

+158
View File
@@ -0,0 +1,158 @@
// Mod profiles: storage, applying one to the installed mods, and r2modman's export formats.
#include "Launcher/mod_profiles.h"
#include <filesystem>
#include <fstream>
#include <iostream>
#include <string>
#include <tuple>
#include <vector>
using namespace dingosdk;
namespace profiles = launcher_profiles;
namespace {
int failures = 0;
void check(bool condition, const char* what) {
if (condition) return;
std::cerr << "FAIL: " << what << '\n';
++failures;
}
template<class F> std::string error_of(F&& run) {
try { run(); } catch (const std::exception& failure) { return failure.what(); }
return {};
}
mods::ModList installed(std::initializer_list<std::tuple<const char*, const char*, bool>> rows) {
mods::ModList list;
for (const auto& [name, version, enabled] : rows) {
mods::ModEntry entry;
entry.mod.name = name;
entry.mod.version = version;
entry.enabled = enabled;
list.entries.push_back(std::move(entry));
}
return list;
}
std::vector<std::string> order(const std::vector<mods::ModEntry>& entries) {
std::vector<std::string> names;
for (const auto& entry : entries) names.push_back(entry.mod.name + (entry.enabled ? "+" : "-"));
return names;
}
void names() {
check(profiles::valid_profile_name("Street maps"), "An ordinary name was refused");
for (const auto* name : {"", " lead", "trail ", "dot.", "a/b", "a\\b", "what?", "a:b", "tab\there"})
check(!profiles::valid_profile_name(name), "A name that cannot be a file name was accepted");
check(!profiles::valid_profile_name(std::string(49, 'a')), "An overlong name was accepted");
check(profiles::clean_profile_name(" My: <best> pack?. ") == "My best pack", "A name was not cleaned");
check(profiles::clean_profile_name("???") == "Profile", "A name with nothing usable had no fallback");
check(profiles::valid_profile_name(profiles::clean_profile_name(std::string(80, 'x'))), "A long name was not cut to fit");
profiles::Profiles set;
set.list = {{"Pack", {}}, {"Pack (2)", {}}};
check(set.free_name("Other") == "Other" && set.free_name("pack") == "pack (3)", "A free name was not found");
check(set.find("PACK") == &set.list[0], "Profiles are not found whatever the case");
}
void applying() {
const auto list = installed({{"Team-A", "1.0.0", true}, {"Team-B", "2.1.0", false}, {"Local Mod", "wip", true}});
const auto captured = profiles::capture("Now", list);
check(captured.mods.size() == 3 && captured.mods[1] == profiles::ProfileMod{"Team-B", "2.1.0", false} &&
captured.mods[2].version == "0.0.0", "The installed mods were not captured as they are");
check(profiles::in_use(captured, list), "A profile just captured is not in use");
const profiles::Profile profile{"Pack", {{"team-b", "2.1.0", true}, {"Team-C", "1.2.3", true}, {"Team-D", "1.0.0", false},
{"Team-A", "1.0.0", false}, {"Team-B", "2.1.0", false}}};
const auto applied = profiles::apply(profile, list);
check(order(applied.entries) == std::vector<std::string>{"Team-B+", "Team-A-", "Local Mod-"},
"A profile's order and switches were not applied, or a mod outside it stayed on");
check(applied.missing.size() == 1 && applied.missing[0].name == "Team-C" && applied.missing[0].version == "1.2.3",
"The mods to download are not the missing ones that are switched on");
check(!profiles::in_use(profile, list), "A profile with a mod to download counts as in use");
auto after = list;
after.entries = applied.entries;
const profiles::Profile have{"Have", {{"Team-B", "2.1.0", true}, {"Team-A", "1.0.0", false}}};
check(profiles::in_use(have, after) && !profiles::in_use(have, list), "In use does not follow which mods are on");
}
void storage(const std::filesystem::path& root) {
std::filesystem::remove_all(root);
std::filesystem::create_directories(root);
check(profiles::load(root).list.empty(), "A Mods folder with no profiles file has profiles");
profiles::Profiles set;
set.list = {{"Street", {{"Team-A", "1.0.0", true}, {"Team-B", "2.0.0", false}}}, {"Empty", {}}};
set.active = "Street";
profiles::save(root, set);
const auto read = profiles::load(root);
check(read.active == "Street" && read.list == set.list, "Profiles did not survive saving and loading");
std::ofstream(root / profiles::profiles_file) << R"({"active":"Gone","profiles":[{"name":"ok","mods":[{"name":"../x"},{"name":"Fine"}]},)"
R"({"name":"bad/name"},{"name":"ok"},7]})";
const auto loose = profiles::load(root);
check(loose.list.size() == 1 && loose.list[0].mods.size() == 1 && loose.list[0].mods[0].name == "Fine" &&
loose.list[0].mods[0].enabled && loose.active.empty(), "A damaged profiles file was not read loosely");
std::ofstream(root / profiles::profiles_file) << "not json";
check(profiles::load(root).list.empty(), "An unreadable profiles file was not treated as none");
}
void exporting(const std::filesystem::path& root) {
const profiles::Profile profile{"My \"best\" pack", {{"Team-Map_One", "1.10.3", true}, {"Local Mod", "0.0.0", false}}};
const auto text = profiles::export_text(profile);
check(text.find("profileName: \"My \\\"best\\\" pack\"") == 0 && text.find(" minor: 10\n") != std::string::npos &&
text.find(" enabled: false\n") != std::string::npos, "The export is not r2modman's layout");
// clean_profile_name drops the quotes, which a file name cannot hold.
auto expected = profile;
expected.name = "My best pack";
check(profiles::parse_export(text) == expected, "An export did not read back");
check(profiles::parse_archive(profiles::export_archive(profile)) == expected, "A .r2z did not read back");
// Left in the scratch folder, for opening with another zip reader.
const auto archive = profiles::export_archive(profile);
std::ofstream(root / "exported.r2z", std::ios::binary).write(archive.data(), static_cast<std::streamsize>(archive.size()));
check(profiles::parse_code_payload(profiles::code_payload(profile)) == expected, "A code payload did not read back");
check(profiles::code_payload(profile).starts_with("#r2modman\n"), "A code payload lacks r2modman's first line");
check(profiles::parse_export(profiles::export_text({"Empty", {}})) == profiles::Profile{"Empty", {}},
"A profile with no mods did not read back");
// As r2modman's own YAML writer lays one out: unquoted, list items not indented, CRLF.
const auto theirs = profiles::parse_export("profileName: Default\r\nmods:\r\n- name: BepInEx-BepInExPack\r\n version:\r\n"
" major: 5\r\n minor: 4\r\n patch: 2100\r\n enabled: true\r\n- name: 'Team-Other'\r\n version:\r\n"
" major: 1\r\n minor: 0\r\n patch: 0\r\n enabled: false\r\n- name: bad/name\r\n enabled: true\r\n");
check(theirs.name == "Default" && theirs.mods.size() == 2 &&
theirs.mods[0] == profiles::ProfileMod{"BepInEx-BepInExPack", "5.4.2100", true} &&
theirs.mods[1] == profiles::ProfileMod{"Team-Other", "1.0.0", false}, "r2modman's own export was misread");
check(!error_of([] { profiles::parse_export("just: text\n"); }).empty(), "Text that is not a profile was accepted");
check(!error_of([] { profiles::parse_archive("PK\x03\x04 not really a zip"); }).empty(), "A broken archive was accepted");
check(!error_of([] { profiles::parse_code_payload("<html>not found</html>"); }).empty(), "A page that is not a profile was accepted");
check(!error_of([] { profiles::parse_code_payload("#r2modman\n!!!!"); }).empty(), "A payload that is not base64 was accepted");
}
void codes() {
check(profiles::valid_code("0193a2b4-7c1d-4e5f-8a9b-0c1d2e3f4a5b") && !profiles::valid_code("short") &&
!profiles::valid_code("0193a2b4/../../etc") && !profiles::valid_code("0193a2b4 7c1d 4e5f"), "Profile codes are not checked");
check(profiles::parse_created_code(R"({"key":"0193a2b4-7c1d-4e5f-8a9b-0c1d2e3f4a5b"})") == "0193a2b4-7c1d-4e5f-8a9b-0c1d2e3f4a5b",
"The code in a create answer was not read");
check(!error_of([] { profiles::parse_created_code(R"({"detail":"throttled"})"); }).empty(), "An answer with no code was accepted");
check(profiles::code_get_url("abc-123") == L"https://thunderstore.io/api/experimental/legacyprofile/get/abc-123/",
"The address of a code is wrong");
}
} // namespace
int wmain(int argc, wchar_t** argv) {
if (argc != 2) {
std::cerr << "Pass a scratch folder\n";
return 2;
}
names();
applying();
storage(std::filesystem::path(argv[1]) / L"profiles");
exporting(std::filesystem::path(argv[1]) / L"profiles");
codes();
if (failures) return 1;
std::cout << "Mod profile names, applying, storage, exports and codes passed.\n";
return 0;
}
+35
View File
@@ -173,6 +173,41 @@ int main() {
check(wide.lines.size() == 1 && wide.lines[0].text == std::string(1999, 'a'), "An overlong line is cut between characters");
}
// ------------------------------------------------ dependencies and mod packs
{
const auto version = [](const char* owner, const char* name, const char* number, const char* needs) {
return std::string(R"({"name":")") + name + R"(","full_name":")" + owner + "-" + name + R"(","owner":")" + owner +
R"(","versions":[{"version_number":")" + number + R"(","download_url":"https://thunderstore.io/d/)" + name +
R"(/","dependencies":[)" + needs + R"(]},{"version_number":"0.9.0","download_url":"https://thunderstore.io/d/old/)" +
name + R"(/"}]})";
};
const auto shop = thunderstore::parse_listing("[" +
version("Zee", "Pack", "1.0.0", R"("Zee-Map-1.2.0","Kit-Rails-2.0.0","Gone-Mod-1.0.0")") + "," +
version("Zee", "Map", "1.2.0", R"("Kit-Rails-1.0.0","Kit-Core-1.0.0")") + "," +
version("Kit", "Rails", "2.0.0", R"("Kit-Core-1.0.0","Zee-Map-1.0.0")") + "," +
version("Kit", "Core", "1.0.0", "") + "]");
check(shop.size() == 4 && shop[0].latest().dependencies.size() == 3, "A version's dependencies are read");
check(thunderstore::split_dependency("Zee-Desert_Springs-1.10.0") == std::pair<std::string, std::string>{"Zee-Desert_Springs", "1.10.0"} &&
thunderstore::split_dependency("Zee-Map") == std::pair<std::string, std::string>{"Zee-Map", ""},
"A dependency splits into its package and version");
const auto names = [](const std::vector<thunderstore::Package>& packages) {
std::string text;
for (const auto& package : packages) text += package.full_name + " ";
return text;
};
std::vector<std::string> unknown;
check(names(thunderstore::with_dependencies(shop[0], shop, {}, &unknown)) == "Kit-Core Kit-Rails Zee-Map Zee-Pack ",
"A pack's mods are gathered once each, needed ones first, through a cycle");
check(unknown == std::vector<std::string>{"Gone-Mod"}, "A dependency the listing lacks is reported");
check(names(thunderstore::with_dependencies(shop[0], shop, {{"Kit-Core", "1.0.0"}, {"Kit-Rails", "1.5.0"}, {"Zee-Map", "1.2.0"}})) ==
"Kit-Rails Zee-Pack ", "Dependencies installed at a new enough version are left out, and an old one is updated");
check(names(thunderstore::with_dependencies(shop[3], shop, {})) == "Kit-Core ", "A package that needs nothing comes alone");
check(shop[1].at_version("0.9.0").latest().number == "0.9.0" && shop[1].at_version("7.7.7").latest().number == "1.2.0" &&
shop[1].at_version("0.9.0").versions.size() == 2, "A package is taken at an older version when it has it");
check(thunderstore::find_package(shop, "Kit-Rails") == &shop[2] && !thunderstore::find_package(shop, "kit-rails-2.0.0"),
"A package is found by its full name");
}
// ------------------------------------------------ community
check(thunderstore::listing_index_url("reskate") == L"https://thunderstore.io/c/reskate/api/v1/package-listing-index/",
"The listing index URL");
+35
View File
@@ -4,6 +4,7 @@
#include "game_settings.h"
#include "launch.h"
#include "text_encoding.h"
#include "mod_profiles.h"
#include "thunderstore.h"
#include "Engine/Vfs/mod_list.h"
@@ -488,9 +489,30 @@ struct ModsPanel {
Icons icons;
Readmes readmes;
// PROFILES (gui_mods_profiles.cpp).
launcher_profiles::Profiles profiles;
bool profiles_loaded{};
std::array<char, 64> profile_name{}; // typed for a new profile
std::array<char, 96> profile_code{}; // typed to import
std::array<char, 64> profile_renamed{}; // typed in the rename popup
std::string profile_rename; // the profile being renamed
std::string profile_delete; // the profile awaiting "Delete" confirmation
// Loaded while its missing mods download: put in its order again once they are in.
std::string profile_settle;
// Talking to Thunderstore's profile service, off the UI thread.
struct ProfileResult {
bool ready{}, error{};
std::string message, clipboard;
std::optional<launcher_profiles::Profile> imported;
};
std::thread profile_worker;
std::atomic<bool> profile_busy{};
ProfileResult profile_result; // worker -> UI, under mutex
~ModsPanel() {
cancel = true;
if (worker.joinable()) worker.join();
if (profile_worker.joinable()) profile_worker.join();
}
};
@@ -498,6 +520,19 @@ void scan(ModsPanel& panel, const launcher_app::Session& session);
// Re-reads the Mods folder and the Thunderstore listing.
void refresh_mods(Launcher& launcher, ModsPanel& panel);
void start_install(ModsPanel& panel, const fs::path& source, bool replace);
// The Windows file (or folder) picker; empty when cancelled. A file picker shows
// `filter_pattern` ("*.zip") under `filter_name`.
fs::path pick_path(HWND owner, bool folder, const wchar_t* title, const wchar_t* filter_name, const wchar_t* filter_pattern);
// ---------------------------------------------------------------- Profiles (gui_mods_profiles.cpp)
// The PROFILES page body, under the page header.
void profiles_page(const Fonts& fonts, ModsPanel& panel, HWND window, float height, bool installing);
// The rename and delete confirmations; call once a frame with the page's other popups.
void profile_popups(ModsPanel& panel);
// A profile loaded while its mods downloaded is put in order once they are in; call once
// a frame, after collect_install.
void settle_profile(ModsPanel& panel);
// ---------------------------------------------------------------- Thunderstore (gui_mods_browse.cpp)
+31 -19
View File
@@ -60,25 +60,10 @@ void collect_install(ModsPanel& panel, const launcher_app::Session& session) {
logging::write(logging::Level::info, logging::Channel::launcher, "Mod installed: " + panel.finished_name);
}
// The Windows file (or folder) picker; empty when cancelled.
// The picker for a mod to install: a .zip, or a folder.
fs::path pick(HWND owner, bool folder) {
ComPtr<IFileOpenDialog> dialog;
if (FAILED(CoCreateInstance(CLSID_FileOpenDialog, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&dialog)))) return {};
FILEOPENDIALOGOPTIONS options{};
dialog->GetOptions(&options);
dialog->SetOptions(options | FOS_FORCEFILESYSTEM | FOS_PATHMUSTEXIST | (folder ? FOS_PICKFOLDERS : FOS_FILEMUSTEXIST));
if (!folder) {
const COMDLG_FILTERSPEC filter[]{{L"Mod archive (*.zip)", L"*.zip"}};
dialog->SetFileTypes(1, filter);
}
dialog->SetTitle(folder ? L"Choose a mod folder to install" : L"Choose a mod .zip to install");
if (FAILED(dialog->Show(owner))) return {};
ComPtr<IShellItem> item;
PWSTR path{};
if (FAILED(dialog->GetResult(&item)) || FAILED(item->GetDisplayName(SIGDN_FILESYSPATH, &path))) return {};
fs::path result(path);
CoTaskMemFree(path);
return result;
return folder ? pick_path(owner, true, L"Choose a mod folder to install", nullptr, nullptr)
: pick_path(owner, false, L"Choose a mod .zip to install", L"Mod archive (*.zip)", L"*.zip");
}
// Everything in the mod's folder, as Explorer would count it. The game's merged
@@ -178,6 +163,10 @@ bool rail(const Fonts& fonts, ModsPanel& panel, HWND window, float width,
store.loaded ? std::string() : std::string("Loading the Thunderstore listing")))
panel.tab = 1;
if (panel.tab == 1) default_focus();
if (nav_tile(fonts, width, "PROFILES", panel.tab == 2,
panel.profiles_loaded ? std::to_string(panel.profiles.list.size()) : std::string(), false, std::string()))
panel.tab = 2;
if (panel.tab == 2) default_focus();
ImGui::Dummy(ImVec2(0, S(8)));
if (!pending.empty()) {
@@ -854,6 +843,26 @@ void install_modal(const Fonts& fonts, ModsPanel& panel, ImVec2 size) {
} // namespace
fs::path pick_path(HWND owner, bool folder, const wchar_t* title, const wchar_t* filter_name, const wchar_t* filter_pattern) {
ComPtr<IFileOpenDialog> dialog;
if (FAILED(CoCreateInstance(CLSID_FileOpenDialog, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&dialog)))) return {};
FILEOPENDIALOGOPTIONS options{};
dialog->GetOptions(&options);
dialog->SetOptions(options | FOS_FORCEFILESYSTEM | FOS_PATHMUSTEXIST | (folder ? FOS_PICKFOLDERS : FOS_FILEMUSTEXIST));
if (!folder && filter_name && filter_pattern) {
const COMDLG_FILTERSPEC filter[]{{filter_name, filter_pattern}};
dialog->SetFileTypes(1, filter);
}
dialog->SetTitle(title);
if (FAILED(dialog->Show(owner))) return {};
ComPtr<IShellItem> item;
PWSTR path{};
if (FAILED(dialog->GetResult(&item)) || FAILED(item->GetDisplayName(SIGDN_FILESYSPATH, &path))) return {};
fs::path result(path);
CoTaskMemFree(path);
return result;
}
void refresh_mods(Launcher& launcher, ModsPanel& panel) {
scan(panel, launcher.session());
refresh_listing(panel, ImGui::GetTime(), true);
@@ -1062,6 +1071,7 @@ void mods_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui, Mo
const auto& session = launcher.session();
if (!panel.scanned) scan(panel, session);
collect_install(panel, session);
settle_profile(panel);
refresh_listing(panel, ImGui::GetTime());
// A page, not a panel: the mod list and the Thunderstore browser both want
// the whole window. It sits at (0, 0), so window and screen space agree.
@@ -1103,6 +1113,7 @@ void mods_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui, Mo
const float status = panel.message.empty() ? S(4) : ImGui::GetTextLineHeight() * 2 + S(12);
const float body = ImGui::GetWindowHeight() - status;
if (panel.tab == 1) browse_page(launcher, fonts, panel, body, installing);
else if (panel.tab == 2) profiles_page(fonts, panel, window, body, installing);
else installed_page(launcher, fonts, panel, installed, body, installing);
if (!panel.message.empty()) {
ImGui::SetCursorPosY(ImGui::GetWindowHeight() - status + S(8));
@@ -1116,7 +1127,7 @@ void mods_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui, Mo
// Read before the overviews: one closing on Escape must not also leave the page.
const bool popup_open = ImGui::IsPopupOpen(nullptr, ImGuiPopupFlags_AnyPopupId);
if (panel.tab == 1) package_overview(fonts, panel, frame, installing);
else mod_overview(fonts, panel, installed, frame, installing);
else if (panel.tab == 0) mod_overview(fonts, panel, installed, frame, installing);
if (leave) close();
if (!installing && !popup_open && !ImGui::IsPopupOpen(nullptr, ImGuiPopupFlags_AnyPopupId) && !ImGui::IsAnyItemActive() &&
ImGui::IsKeyPressed(ImGuiKey_Escape, false)) {
@@ -1218,6 +1229,7 @@ void mods_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui, Mo
pop_primary_button();
ImGui::EndPopup();
}
profile_popups(panel);
g_drag_allowed = !ImGui::IsAnyItemHovered() && !ImGui::IsPopupOpen(nullptr, ImGuiPopupFlags_AnyPopupId);
ImGui::End();
if (installing) install_modal(fonts, panel, frame);
+12 -1
View File
@@ -327,6 +327,17 @@ std::vector<const thunderstore::Package*> updates(const Store& store, const thun
void start_store_install(ModsPanel& panel, std::vector<thunderstore::Package> packages) {
if (panel.installing || packages.empty()) return;
{
// A package brings what it needs with it (a mod pack is nothing else), needed mods first.
const auto have = installed_versions(panel.list);
std::vector<thunderstore::Package> expanded;
for (const auto& package : packages)
for (auto& needed : thunderstore::with_dependencies(package, panel.store.packages, have))
if (std::none_of(expanded.begin(), expanded.end(),
[&](const auto& queued) { return queued.full_name == needed.full_name; }))
expanded.push_back(std::move(needed));
packages = std::move(expanded);
}
if (panel.worker.joinable()) panel.worker.join();
panel.installing = true;
panel.cancel = false;
@@ -349,7 +360,7 @@ void start_store_install(ModsPanel& panel, std::vector<thunderstore::Package> pa
log(logging::Level::info, std::format("Mod {} from Thunderstore: {} v{} into Mods\\{}",
update ? "updated" : "installed", package.full_name, package.latest().number, name));
note = packages.size() > 1
? std::format("Updated {} mods. Changes apply the next time Skate starts.", done)
? std::format("Installed or updated {} mods. Changes apply the next time Skate starts.", done)
: std::format("{} {} v{}. It loads the next time Skate starts.", update ? "Updated" : "Installed",
package.title(), package.latest().number);
} catch (const std::exception& failure) {
+479
View File
@@ -0,0 +1,479 @@
#include "gui_internal.h"
#include "mod_profiles.h"
#include "updater.h"
#include "Engine/Core/Log/logging.h"
#include <format>
#include <fstream>
// The Mods panel's PROFILES page: named sets of mods to switch between, and
// sharing them as r2modman does, by code or by file (mod_profiles.h). Every mod
// stays installed once; loading a profile rewrites mods.json and downloads what
// it names that is missing.
namespace dingosdk::launcher_gui::detail {
namespace {
namespace profiles = launcher_profiles;
namespace ts = thunderstore;
constexpr int code_timeout_ms = 20000;
constexpr std::uint64_t max_code_answer = 8 * 1024 * 1024, max_profile_file = 64ull * 1024 * 1024;
void log(logging::Level level, const std::string& message) {
logging::write(level, logging::Channel::launcher, message);
}
void say(ModsPanel& panel, std::string message, bool error = false) {
panel.message = std::move(message);
panel.message_error = error;
}
bool store(ModsPanel& panel) {
try {
profiles::save(panel.root, panel.profiles);
return true;
} catch (const std::exception& failure) {
say(panel, failure.what(), true);
return false;
}
}
std::string trimmed(const char* text) {
std::string value(text);
while (!value.empty() && value.front() == ' ') value.erase(value.begin());
while (!value.empty() && value.back() == ' ') value.pop_back();
return value;
}
// "a, b, c and 4 more".
std::string named(const std::vector<std::string>& names) {
constexpr std::size_t shown = 3;
std::string text;
for (std::size_t i = 0; i < names.size() && i < shown; ++i) text += (i ? ", " : "") + names[i];
if (names.size() > shown) text += std::format(" and {} more", names.size() - shown);
return text;
}
// The profile's mods that are on, missing, and not in the Thunderstore listing either.
std::vector<std::string> unobtainable(const ModsPanel& panel, const std::vector<profiles::ProfileMod>& missing) {
std::vector<std::string> names;
for (const auto& mod : missing)
if (!package_for(panel.store, mod.name)) names.push_back(mod.name);
return names;
}
// Writes mods.json as the profile has it. False, with the reason shown, when that failed.
bool write_order(ModsPanel& panel, const profiles::Profile& profile, std::vector<profiles::ProfileMod>& missing) {
auto applied = profiles::apply(profile, panel.list);
try {
mods::save_mod_order(panel.root, applied.entries);
} catch (const std::exception& failure) {
say(panel, failure.what(), true);
return false;
}
panel.list.entries = std::move(applied.entries);
panel.list.issue.clear();
panel.marked.clear();
panel.selected = -1;
missing = std::move(applied.missing);
return true;
}
void load_profile(ModsPanel& panel, const profiles::Profile& profile) {
std::vector<profiles::ProfileMod> missing;
if (!write_order(panel, profile, missing)) return;
panel.profiles.active = profile.name;
if (!store(panel)) return;
log(logging::Level::info, std::format("Mod profile loaded: {} ({} mods, {} to download)", profile.name,
profile.mods.size(), missing.size()));
if (missing.empty()) {
say(panel, "Loaded \"" + profile.name + "\". Changes apply the next time Skate starts.");
return;
}
if (!panel.store.loaded) {
say(panel, std::format("Loaded \"{}\", but {} of its mods are not installed and the Thunderstore listing has not "
"arrived yet. Load it again in a moment to download them.", profile.name, missing.size()), true);
return;
}
std::vector<ts::Package> packages;
for (const auto& mod : missing)
if (const auto* package = package_for(panel.store, mod.name))
packages.push_back(package->at_version(mod.version));
if (packages.empty()) {
say(panel, std::format("Loaded \"{}\". {} of its mods are not installed and are not on Thunderstore: {}.",
profile.name, missing.size(), named(unobtainable(panel, missing))), true);
return;
}
// The downloaded mods arrive switched on at the end of the list; settle_profile puts
// them where the profile has them once the install is done.
panel.profile_settle = profile.name;
start_store_install(panel, std::move(packages));
}
void add_profile(ModsPanel& panel, profiles::Profile profile, std::string_view verb) {
if (panel.profiles.list.size() >= profiles::max_profiles) {
say(panel, std::format("There is room for {} profiles. Delete one first.", profiles::max_profiles), true);
return;
}
profile.name = panel.profiles.free_name(profiles::clean_profile_name(profile.name));
const auto name = profile.name;
const auto count = profile.mods.size();
const auto missing = profiles::apply(profile, panel.list).missing.size();
panel.profiles.list.push_back(std::move(profile));
if (!store(panel)) {
panel.profiles.list.pop_back();
return;
}
log(logging::Level::info, std::format("Mod profile {}: {} ({} mods)", verb, name, count));
say(panel, missing ? std::format("{} \"{}\" ({} mods). LOAD it to switch to it; {} of its mods will be downloaded.",
verb, name, count, missing)
: std::format("{} \"{}\" ({} mods). LOAD it to switch to it.", verb, name, count));
}
std::string read_file(const fs::path& path) {
std::error_code error;
const auto size = fs::file_size(path, error);
if (error || size > max_profile_file) throw std::runtime_error("That file is too large to be a mod profile.");
std::ifstream input(path, std::ios::binary);
std::string bytes(static_cast<std::size_t>(size), '\0');
if (!input.read(bytes.data(), static_cast<std::streamsize>(bytes.size())))
throw std::runtime_error("Could not read " + utf8(path.wstring()));
return bytes;
}
// Runs `task` off the UI thread; what it returns is shown by collect_profile.
template<class Task> void in_background(ModsPanel& panel, Task task) {
if (panel.profile_busy) return;
if (panel.profile_worker.joinable()) panel.profile_worker.join();
panel.profile_busy = true;
panel.message.clear();
panel.profile_worker = std::thread([&panel, task = std::move(task)] {
ModsPanel::ProfileResult result;
try {
result = task();
} catch (const std::exception& failure) {
result.message = failure.what();
result.error = true;
}
result.ready = true;
std::lock_guard lock(panel.mutex);
panel.profile_result = std::move(result);
panel.profile_busy = false;
});
}
void export_code(ModsPanel& panel, const profiles::Profile& profile) {
const auto missing_from_store = [&] {
std::size_t count{};
for (const auto& mod : profile.mods)
if (mod.enabled && !package_for(panel.store, mod.name)) ++count;
return panel.store.loaded ? count : 0;
}();
in_background(panel, [profile, missing_from_store] {
const auto answer = launcher_update::http_post(profiles::code_create_url(), L"application/octet-stream",
profiles::code_payload(profile), 64 * 1024, code_timeout_ms);
ModsPanel::ProfileResult result;
result.clipboard = profiles::parse_created_code(answer);
result.message = "The code for \"" + profile.name + "\" is on the clipboard: " + result.clipboard +
". Thunderstore keeps codes for a limited time.";
if (missing_from_store)
result.message += std::format(" {} of its mods are not on Thunderstore, so whoever imports it will not get those.",
missing_from_store);
log(logging::Level::info, "Mod profile exported as a code: " + profile.name);
return result;
});
}
void import_code(ModsPanel& panel, std::string code) {
in_background(panel, [code = std::move(code)] {
std::string payload;
launcher_update::http_stream(profiles::code_get_url(code), max_code_answer, code_timeout_ms,
[&](const char* data, DWORD size) { payload.append(data, size); });
ModsPanel::ProfileResult result;
result.imported = profiles::parse_code_payload(payload);
return result;
});
}
void export_file(ModsPanel& panel, const profiles::Profile& profile, HWND window) {
const auto folder = pick_path(window, true, L"Choose where to save the profile file", nullptr, nullptr);
if (folder.empty()) return;
try {
auto target = folder / (wide(profile.name) + L".r2z");
for (int number = 2; fs::exists(target); ++number)
target = folder / (wide(profile.name) + std::format(L" ({}).r2z", number));
const auto bytes = profiles::export_archive(profile);
std::ofstream output(target, std::ios::binary);
output.write(bytes.data(), static_cast<std::streamsize>(bytes.size()));
if (!output.flush()) throw std::runtime_error("Could not write " + utf8(target.wstring()));
log(logging::Level::info, "Mod profile exported as a file: " + profile.name);
say(panel, "Saved " + utf8(target.wstring()) + ". Anyone can import it here or in r2modman.");
} catch (const std::exception& failure) {
say(panel, failure.what(), true);
}
}
void import_file(ModsPanel& panel, HWND window) {
const auto path = pick_path(window, false, L"Choose a profile file to import", L"Mod profile (*.r2z; *.r2x)", L"*.r2z;*.r2x");
if (path.empty()) return;
try {
add_profile(panel, profiles::parse_archive(read_file(path)), "Imported");
} catch (const std::exception& failure) {
say(panel, failure.what(), true);
}
}
// Takes over what the background task finished.
void collect_profile(ModsPanel& panel) {
ModsPanel::ProfileResult result;
{
std::lock_guard lock(panel.mutex);
if (!panel.profile_result.ready) return;
result = std::exchange(panel.profile_result, {});
}
if (!result.clipboard.empty()) ImGui::SetClipboardText(result.clipboard.c_str());
if (result.imported) add_profile(panel, std::move(*result.imported), "Imported");
else say(panel, std::move(result.message), result.error);
}
} // namespace
void settle_profile(ModsPanel& panel) {
// Read with the page, so the rail can count them before PROFILES is opened.
if (!panel.profiles_loaded && !panel.root.empty()) {
panel.profiles = profiles::load(panel.root);
panel.profiles_loaded = true;
}
if (panel.profile_settle.empty() || panel.installing) return;
{
std::lock_guard lock(panel.mutex);
if (panel.finished) return; // collect_install has not rescanned yet
}
const auto name = std::exchange(panel.profile_settle, {});
const auto* profile = panel.profiles.find(name);
if (!profile) return;
// A failed download has already said why; keep that on screen.
const auto failure = panel.message_error ? panel.message : std::string();
std::vector<profiles::ProfileMod> missing;
if (!write_order(panel, *profile, missing)) return;
if (!failure.empty()) say(panel, "Loaded \"" + name + "\", but not all of it: " + failure, true);
else if (!missing.empty())
say(panel, std::format("Loaded \"{}\". {} of its mods are not installed and are not on Thunderstore: {}.", name,
missing.size(), named(unobtainable(panel, missing))), true);
else say(panel, "Loaded \"" + name + "\" and downloaded what it needed. Changes apply the next time Skate starts.");
}
void profiles_page(const Fonts& fonts, ModsPanel& panel, HWND window, float height, bool installing) {
collect_profile(panel);
const bool busy = installing || panel.profile_busy;
const float top = ImGui::GetCursorPosY();
ImGui::PushTextWrapPos(0);
ImGui::TextDisabled("A profile remembers which of your mods are on and in what order. Every mod stays installed "
"once; loading a profile switches the rest off. Share one as a code or a file, with ReSkate "
"or r2modman.");
ImGui::PopTextWrapPos();
ImGui::Spacing();
ImGui::BeginDisabled(busy);
// ------------------------------------------------ new, and the two ways in
const float button = S(280), gap = S(10);
ImGui::SetNextItemWidth(std::max(S(160), ImGui::GetContentRegionAvail().x - button - gap));
const bool entered_name = ImGui::InputTextWithHint("##profile_name", "Name for a new profile", panel.profile_name.data(),
panel.profile_name.size(), ImGuiInputTextFlags_EnterReturnsTrue);
ImGui::SameLine(0, gap);
push_primary_button();
const bool create = ImGui::Button("SAVE CURRENT MODS", ImVec2(button, 0)) || entered_name;
pop_primary_button();
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Save what MY MODS shows now as a new profile");
if (create) {
const auto name = trimmed(panel.profile_name.data());
if (!profiles::valid_profile_name(name))
say(panel, "Give the profile a name first (up to 48 characters, none of < > : \" / \\ | ? *).", true);
else if (panel.profiles.find(name)) say(panel, "A profile named \"" + name + "\" already exists.", true);
else {
const auto before = panel.profiles.list.size();
add_profile(panel, profiles::capture(name, panel.list), "Saved");
if (panel.profiles.list.size() > before) {
panel.profiles.active = panel.profiles.list.back().name;
store(panel);
panel.profile_name.fill(0);
}
}
}
const float half = (button - gap) * 0.5f;
ImGui::SetNextItemWidth(std::max(S(160), ImGui::GetContentRegionAvail().x - button - gap));
const bool entered_code = ImGui::InputTextWithHint("##profile_code", "Paste a profile code", panel.profile_code.data(),
panel.profile_code.size(), ImGuiInputTextFlags_EnterReturnsTrue);
ImGui::SameLine(0, gap);
if (ImGui::Button("IMPORT CODE", ImVec2(half, 0)) || entered_code) {
const auto code = trimmed(panel.profile_code.data());
if (!profiles::valid_code(code)) say(panel, "That is not a profile code. Paste the whole code you were given.", true);
else {
import_code(panel, code);
panel.profile_code.fill(0);
}
}
ImGui::SameLine(0, gap);
if (ImGui::Button("IMPORT FILE", ImVec2(half, 0))) import_file(panel, window);
ImGui::EndDisabled();
if (panel.profile_busy) {
ImGui::SameLine(0, gap);
ImGui::TextDisabled("Talking to Thunderstore...");
}
// ------------------------------------------------ the profiles
ImGui::Spacing();
ImGui::BeginChild("##profiles", ImVec2(0, height - (ImGui::GetCursorPosY() - top)), ImGuiChildFlags_NavFlattened);
if (panel.profiles.list.empty()) {
ImGui::Dummy(ImVec2(0, S(24)));
ImGui::TextDisabled("No profiles yet. Set up MY MODS the way you like, name it above and save it.");
}
// What a row asked for, carried out after the list is drawn: most of it changes the list.
enum class Ask { none, load, update, code, file, rename, remove } ask{Ask::none};
std::size_t asked{};
for (std::size_t index = 0; index < panel.profiles.list.size(); ++index) {
const auto& profile = panel.profiles.list[index];
ImGui::PushID(static_cast<int>(index));
const auto on = std::count_if(profile.mods.begin(), profile.mods.end(), [](const auto& mod) { return mod.enabled; });
const auto missing = profiles::apply(profile, panel.list).missing.size();
const bool current = profiles::in_use(profile, panel.list);
const bool last = !current && profile.name == panel.profiles.active;
ImGui::Dummy(ImVec2(0, S(6)));
ImGui::PushFont(fonts.bold);
ImGui::TextUnformatted(profile.name.c_str());
ImGui::PopFont();
if (current || last) {
ImGui::SameLine(0, S(12));
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(current ? color::good : color::warning), "%s",
current ? "IN USE" : "CHANGED SINCE LOADED");
}
ImGui::TextDisabled("%s", (std::format("{} mods, {} on", profile.mods.size(), on) +
(missing ? std::format(" | {} to download", missing) : std::string())).c_str());
ImGui::BeginDisabled(busy);
const auto act = [&](const char* label, Ask what, const char* tip, bool enabled = true, bool primary = false) {
ImGui::BeginDisabled(!enabled);
if (primary) push_primary_button();
if (ImGui::Button(label, ImVec2(S(122), 0))) {
ask = what;
asked = index;
}
if (primary) pop_primary_button();
ImGui::EndDisabled();
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) ImGui::SetTooltip("%s", tip);
ImGui::SameLine(0, S(8));
};
act("LOAD", Ask::load, current ? "MY MODS already matches this profile"
: "Switch MY MODS to this profile, downloading what is missing", !current, true);
act("UPDATE", Ask::update, "Overwrite this profile with what MY MODS shows now", !current);
act("COPY CODE", Ask::code, "Upload this profile to Thunderstore and copy its code");
act("SAVE FILE", Ask::file, "Save this profile as a .r2z file to send to someone");
act("RENAME", Ask::rename, "Give this profile another name");
act("DELETE", Ask::remove, "Delete this profile. No mod is uninstalled.");
ImGui::NewLine();
ImGui::EndDisabled();
ImGui::Dummy(ImVec2(0, S(6)));
ImGui::Separator();
ImGui::PopID();
}
ImGui::EndChild();
if (ask != Ask::none && asked < panel.profiles.list.size()) {
const auto profile = panel.profiles.list[asked]; // a copy: the list may change under it
switch (ask) {
case Ask::load: load_profile(panel, profile); break;
case Ask::update: {
panel.profiles.list[asked] = profiles::capture(profile.name, panel.list);
panel.profiles.active = profile.name;
if (store(panel)) say(panel, "Updated \"" + profile.name + "\" to what MY MODS shows now.");
else panel.profiles.list[asked] = profile;
break;
}
case Ask::code: export_code(panel, profile); break;
case Ask::file: export_file(panel, profile, window); break;
case Ask::rename:
panel.profile_rename = profile.name;
panel.profile_renamed.fill(0);
std::copy_n(profile.name.begin(), std::min(profile.name.size(), panel.profile_renamed.size() - 1),
panel.profile_renamed.begin());
break;
case Ask::remove: panel.profile_delete = profile.name; break;
case Ask::none: break;
}
}
}
void profile_popups(ModsPanel& panel) {
if (!panel.profile_rename.empty() && !ImGui::IsPopupOpen("Rename profile")) ImGui::OpenPopup("Rename profile");
if (!panel.profile_delete.empty() && !ImGui::IsPopupOpen("Delete profile")) ImGui::OpenPopup("Delete profile");
ImGui::SetNextWindowSize(ImVec2(S(440), 0));
if (ImGui::BeginPopupModal("Rename profile", nullptr, ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove)) {
ImGui::Text("New name for \"%s\":", panel.profile_rename.c_str());
ImGui::SetNextItemWidth(-1);
if (ImGui::IsWindowAppearing()) ImGui::SetKeyboardFocusHere();
const bool entered = ImGui::InputText("##renamed", panel.profile_renamed.data(), panel.profile_renamed.size(),
ImGuiInputTextFlags_EnterReturnsTrue);
const auto name = trimmed(panel.profile_renamed.data());
auto* profile = panel.profiles.find(panel.profile_rename);
const auto* other = panel.profiles.find(name);
const bool usable = profile && profiles::valid_profile_name(name) && (!other || other == profile);
if (!usable && !name.empty())
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(color::danger), "%s",
other && other != profile ? "Another profile has that name." : "That name cannot be used.");
ImGui::Spacing();
if (ImGui::Button("Cancel", ImVec2(S(110), 0)) || ImGui::IsKeyPressed(ImGuiKey_Escape, false)) {
panel.profile_rename.clear();
ImGui::CloseCurrentPopup();
}
ImGui::SameLine();
ImGui::BeginDisabled(!usable);
push_primary_button();
if (ImGui::Button("RENAME", ImVec2(S(110), 0)) || (entered && usable)) {
const auto old = profile->name;
profile->name = name;
if (panel.profiles.active == old) panel.profiles.active = name;
if (store(panel)) say(panel, "Renamed \"" + old + "\" to \"" + name + "\".");
else {
profile->name = old;
if (panel.profiles.active == name) panel.profiles.active = old;
}
panel.profile_rename.clear();
ImGui::CloseCurrentPopup();
}
pop_primary_button();
ImGui::EndDisabled();
ImGui::EndPopup();
}
ImGui::SetNextWindowSize(ImVec2(S(440), 0));
if (ImGui::BeginPopupModal("Delete profile", nullptr, ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove)) {
ImGui::PushTextWrapPos(0);
ImGui::Text("Delete the profile \"%s\"? Your mods stay installed and stay as they are now.",
panel.profile_delete.c_str());
ImGui::PopTextWrapPos();
ImGui::Spacing();
// Escape (and a controller's B) answers Cancel, never the destructive choice.
if (ImGui::Button("Cancel", ImVec2(S(110), 0)) || ImGui::IsKeyPressed(ImGuiKey_Escape, false)) {
panel.profile_delete.clear();
ImGui::CloseCurrentPopup();
}
ImGui::SameLine();
push_primary_button();
if (ImGui::Button("DELETE", ImVec2(S(110), 0))) {
const auto before = panel.profiles;
const auto name = panel.profile_delete;
std::erase_if(panel.profiles.list, [&](const auto& profile) { return profile.name == name; });
if (panel.profiles.active == name) panel.profiles.active.clear();
if (store(panel)) say(panel, "Deleted the profile \"" + name + "\". No mod was uninstalled.");
else panel.profiles = before;
panel.profile_delete.clear();
ImGui::CloseCurrentPopup();
}
pop_primary_button();
ImGui::EndPopup();
}
}
} // namespace dingosdk::launcher_gui::detail
+441
View File
@@ -0,0 +1,441 @@
#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
+90
View File
@@ -0,0 +1,90 @@
#pragma once
#include "Engine/Vfs/mod_list.h"
#include <filesystem>
#include <string>
#include <string_view>
#include <vector>
// Mod profiles: named sets of mods ("Street maps", "Server X") a player switches
// between. Every mod is installed once, in the one Mods folder; a profile is a
// saved list of which mods are on, in what order, and at what version. Loading
// one rewrites mods.json.
//
// A profile travels as r2modman does it, so the two managers read each other's:
// export.r2x (YAML) inside a zip (.r2z), and a code that names that zip on
// Thunderstore's profile service. No network here; gui_mods_profiles.cpp sends
// and fetches.
namespace dingosdk::launcher_profiles {
struct ProfileMod {
std::string name; // the mod's folder: Namespace-Name for a Thunderstore package
std::string version; // Major.Minor.Patch
bool enabled{true};
bool operator==(const ProfileMod&) const = default;
};
struct Profile {
std::string name;
std::vector<ProfileMod> mods; // highest priority first, as mods.json lists them
bool operator==(const Profile&) const = default;
};
struct Profiles {
std::string active; // the profile last loaded or saved; empty for none
std::vector<Profile> list;
Profile* find(std::string_view name);
// `wanted`, or `wanted (2)`, `wanted (3)`... when a profile already has that name.
std::string free_name(std::string_view wanted) const;
};
inline constexpr char profiles_file[] = "profiles.json";
inline constexpr std::size_t max_profiles = 64, max_profile_mods = 2000, max_name_bytes = 48;
// 1 to 48 bytes of printable text, with nothing a file name refuses (a profile
// exports as <name>.r2z) and no leading or trailing space.
bool valid_profile_name(std::string_view name);
// `name` made valid: refused characters dropped, cut to length, "Profile" when nothing is left.
std::string clean_profile_name(std::string_view name);
// <Mods>/profiles.json. A missing or unreadable file is no profiles; never throws.
Profiles load(const std::filesystem::path& mods_root) noexcept;
// Replaces the file atomically. Throws a readable message on failure.
void save(const std::filesystem::path& mods_root, const Profiles& profiles);
// The installed mods as they are now, as a profile.
Profile capture(std::string name, const mods::ModList& list);
struct Applied {
// What mods.json should hold: the profile's installed mods in its order with its
// switches, then every installed mod it does not name, switched off.
std::vector<mods::ModEntry> entries;
// The mods it switches on that are not installed, to be downloaded.
std::vector<ProfileMod> missing;
};
Applied apply(const Profile& profile, const mods::ModList& list);
// True when loading `profile` would change nothing: the same mods on, in the same order.
bool in_use(const Profile& profile, const mods::ModList& list);
// export.r2x. parse_export throws a readable message for text that is not one.
std::string export_text(const Profile& profile);
Profile parse_export(std::string_view text);
// A .r2z: a zip holding export.r2x. r2modman's own also carry config files, which are ignored.
std::string export_archive(const Profile& profile);
Profile parse_archive(std::string_view zip);
// Thunderstore's profile service: POST the payload to create_url() as
// application/octet-stream and it answers {"key":"<code>"}; GET get_url(code)
// answers the payload back. A payload is "#r2modman\n" and the .r2z in base64.
std::wstring code_create_url();
std::wstring code_get_url(std::string_view code);
std::string code_payload(const Profile& profile);
Profile parse_code_payload(std::string_view payload);
// The code in a create answer; throws when the answer has none.
std::string parse_created_code(std::string_view json);
// A code as the service gives them: hex digits and dashes. Surrounding space is not part of it.
bool valid_code(std::string_view code);
} // namespace dingosdk::launcher_profiles
+58
View File
@@ -68,6 +68,7 @@ std::optional<Version> parse_version(const Json& row) {
version.date_created = text(row, "date_created");
version.downloads = count(row, "downloads");
version.file_size = count(row, "file_size");
version.dependencies = strings(row, "dependencies");
if (version.number.empty() || !https(version.download_url)) return std::nullopt;
if (!https(version.icon)) version.icon.clear();
return version;
@@ -135,6 +136,63 @@ std::wstring community_page(std::string_view community) {
return widen(std::string(site) + "/c/" + std::string(community) + "/");
}
Package Package::at_version(std::string_view number) const {
auto result = *this;
const auto found = std::find_if(result.versions.begin(), result.versions.end(),
[&](const Version& version) { return version.number == number; });
if (found != result.versions.end()) std::rotate(result.versions.begin(), found, found + 1);
return result;
}
std::pair<std::string, std::string> split_dependency(std::string_view text) {
const auto dash = text.rfind('-');
if (dash == std::string_view::npos || dash == 0) return {std::string(text), {}};
const auto version = text.substr(dash + 1);
const bool numbered = !version.empty() && std::all_of(version.begin(), version.end(),
[](char ch) { return (ch >= '0' && ch <= '9') || ch == '.'; }) && version.find('.') != std::string_view::npos;
if (!numbered) return {std::string(text), {}};
return {std::string(text.substr(0, dash)), std::string(version)};
}
const Package* find_package(std::span<const Package> listing, std::string_view full_name) {
for (const auto& package : listing)
if (package.full_name == full_name) return &package;
return nullptr;
}
std::vector<Package> with_dependencies(const Package& package, std::span<const Package> listing,
const std::map<std::string, std::string, std::less<>>& have,
std::vector<std::string>* unknown) {
std::vector<Package> order;
std::vector<std::string> visiting;
const auto seen = [&](std::string_view name) {
return std::find(visiting.begin(), visiting.end(), name) != visiting.end() ||
std::any_of(order.begin(), order.end(), [&](const Package& done) { return done.full_name == name; });
};
// Depth first, so a package comes after what it needs. `visiting` breaks a cycle.
const auto visit = [&](const auto& self, const Package& current) -> void {
visiting.push_back(current.full_name);
for (const auto& text : current.latest().dependencies) {
const auto [name, version] = split_dependency(text);
if (seen(name)) continue;
const auto installed = have.find(folder_for(name));
if (installed != have.end() && (version.empty() || compare_versions(installed->second, version) >= 0)) continue;
const auto* needed = find_package(listing, name);
if (!needed) {
if (unknown && std::find(unknown->begin(), unknown->end(), name) == unknown->end()) unknown->push_back(name);
continue;
}
// The newest is installed unless only an older one will do, which no listing says:
// a dependency names the oldest version that works.
self(self, *needed);
}
visiting.pop_back();
order.push_back(current);
};
visit(visit, package);
return order;
}
std::string Package::title() const {
auto result = name;
std::replace(result.begin(), result.end(), '_', ' ');
+20 -1
View File
@@ -2,13 +2,15 @@
#include <cstdint>
#include <map>
#include <span>
#include <string>
#include <string_view>
#include <vector>
// Thunderstore (thunderstore.io/c/reskate): the package listing the launcher's
// Mods browser shows. No network here; gui_mods_browse.cpp fetches with
// launcher_update::http_get. ReSkate packages have no dependencies.
// launcher_update::http_get. A package may list others it needs (a mod pack is
// one that is nothing else); with_dependencies gathers them.
//
// A ReSkate package is a mod folder zipped with Thunderstore's manifest.json,
// icon.png and README.md at the top, and installs as Mods/<Namespace-Name>.
@@ -37,6 +39,7 @@ struct Version {
std::string date_created;
std::uint64_t downloads{};
std::uint64_t file_size{};
std::vector<std::string> dependencies; // Namespace-Name-1.2.3 each
};
struct Package {
@@ -55,8 +58,24 @@ struct Package {
// The name as the site shows it: underscores as spaces.
std::string title() const;
bool in_category(std::string_view category) const;
// This package with `number` as its latest version, for installing that one; unchanged
// when it has no such version.
Package at_version(std::string_view number) const;
};
// "Team-Mod-1.2.3" as {"Team-Mod", "1.2.3"}: how a dependency is named. The version is
// empty when the text does not end in one.
std::pair<std::string, std::string> split_dependency(std::string_view text);
// The package `full_name` (Namespace-Name) in `listing`, or null.
const Package* find_package(std::span<const Package> listing, std::string_view full_name);
// `package` and everything it needs from `listing`, each once and what is needed first,
// so installing in order never leaves a mod without what it depends on. A dependency that
// `have` (installed folders and their versions) holds at the version asked for or newer is
// left out; any other is taken at its latest. Names the listing lacks go to `unknown`.
std::vector<Package> with_dependencies(const Package& package, std::span<const Package> listing,
const std::map<std::string, std::string, std::less<>>& have,
std::vector<std::string>* unknown = nullptr);
// A package's README, as its page on the site shows it:
// /api/experimental/package/<owner>/<name>/<version>/readme/, a JSON object with "markdown".
std::wstring readme_url(const Package& package);
+46
View File
@@ -250,6 +250,52 @@ void http_stream(const std::wstring& url, std::uint64_t limit, int timeout_ms,
http_get(url, limit, timeout_ms, sink);
}
std::string http_post(const std::wstring& url, const wchar_t* content_type, std::string_view body,
std::uint64_t limit, int timeout_ms) {
const auto win32 = [](std::string_view action) {
fail(std::format("{} failed (Windows error {})", action, GetLastError()));
};
URL_COMPONENTS parts{}; parts.dwStructSize = sizeof(parts);
parts.dwHostNameLength = parts.dwUrlPathLength = parts.dwExtraInfoLength =
parts.dwUserNameLength = parts.dwPasswordLength = static_cast<DWORD>(-1);
if (url.size() >= 4096 || !WinHttpCrackUrl(url.c_str(), static_cast<DWORD>(url.size()), 0, &parts) ||
parts.nScheme != INTERNET_SCHEME_HTTPS || !parts.dwHostNameLength || parts.dwUserNameLength || parts.dwPasswordLength)
fail("Upload URL must be HTTPS: " + utf8(url));
if (body.size() > 16 * 1024 * 1024) fail("Upload is larger than expected");
const std::wstring host(parts.lpszHostName, parts.dwHostNameLength);
std::wstring resource = parts.dwUrlPathLength ? std::wstring(parts.lpszUrlPath, parts.dwUrlPathLength) : L"/";
if (parts.dwExtraInfoLength) resource.append(parts.lpszExtraInfo, parts.dwExtraInfoLength);
Internet session{WinHttpOpen(L"ReSkate-Launcher/1", WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, nullptr, nullptr, 0)};
if (!session.value) win32("WinHttpOpen");
if (!WinHttpSetTimeouts(session.value, timeout_ms, timeout_ms, timeout_ms, timeout_ms)) win32("WinHttpSetTimeouts");
Internet connection{WinHttpConnect(session.value, host.c_str(), parts.nPort, 0)};
if (!connection.value) win32("WinHttpConnect");
Internet request{WinHttpOpenRequest(connection.value, L"POST", resource.c_str(), nullptr, nullptr,
WINHTTP_DEFAULT_ACCEPT_TYPES, WINHTTP_FLAG_SECURE)};
if (!request.value) win32("WinHttpOpenRequest");
DWORD redirects = WINHTTP_DISABLE_REDIRECTS;
if (!WinHttpSetOption(request.value, WINHTTP_OPTION_DISABLE_FEATURE, &redirects, sizeof(redirects))) win32("WinHttpSetOption");
const auto headers = std::wstring(L"Content-Type: ") + content_type + L"\r\n";
if (!WinHttpSendRequest(request.value, headers.c_str(), static_cast<DWORD>(-1L), const_cast<char*>(body.data()),
static_cast<DWORD>(body.size()), static_cast<DWORD>(body.size()), 0) ||
!WinHttpReceiveResponse(request.value, nullptr)) win32("HTTPS upload");
DWORD status{}, size = sizeof(status);
if (!WinHttpQueryHeaders(request.value, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER,
nullptr, &status, &size, nullptr)) win32("WinHttpQueryHeaders");
if (status == 429) fail("Thunderstore is limiting requests from this network; try again in a little while");
if (status != 200 && status != 201) fail(std::format("{} returned HTTP {}", utf8(url), status));
std::string answer;
std::array<char, 16384> buffer{};
for (;;) {
DWORD read{};
if (!WinHttpReadData(request.value, buffer.data(), static_cast<DWORD>(buffer.size()), &read)) win32("WinHttpReadData");
if (!read) break;
if (answer.size() + read > limit) fail("The answer is larger than expected: " + utf8(url));
answer.append(buffer.data(), read);
}
return answer;
}
bool binary_updates_enabled() noexcept { return launcher_binary_updates; }
Config parse_config(std::string_view text) {
+4
View File
@@ -63,6 +63,10 @@ std::string releases_page();
// `limit` bytes, on a non-200 answer, or whatever `sink` throws (to cancel).
void http_stream(const std::wstring& url, std::uint64_t limit, int timeout_ms,
const std::function<void(const char*, DWORD)>& sink);
// An HTTPS POST of `body`; the answer's body, which must come with a 200 or 201 and
// fit in `limit` bytes. No redirect is followed. Throws a readable message otherwise.
std::string http_post(const std::wstring& url, const wchar_t* content_type, std::string_view body,
std::uint64_t limit, int timeout_ms);
// Reports bytes received and the expected total while a download runs.
using Progress = std::function<void(std::uint64_t received, std::uint64_t total)>;
+6 -1
View File
@@ -30,7 +30,8 @@ the launcher, the runtime that loads into the game, and the dedicated server.
- Drop a mod in `Mods/` and it is merged into the game at launch. Mods can add custom maps, loading
screens, cosmetics and scripts.
- Browse and install mods from the [Thunderstore community](https://thunderstore.io/c/reskate/) in the
launcher.
launcher. A mod that needs others (a mod pack) brings them with it.
- Save sets of mods as profiles, switch between them, and share one as a code or a file.
- Most mod changes apply in game without a restart.
- **Launcher.**
- Checks that you have the supported game build, and can download exactly that build with your Steam
@@ -114,6 +115,10 @@ accepts `park random` from its console or an admin.
- Install mods from the launcher's **MODS** page: browse Thunderstore, or drag a `.zip` or folder onto the
window.
- **PROFILES** on that page saves which mods are on, and in what order, under a name. Loading a profile
switches to it and downloads any of its mods you do not have; every mod stays installed once. **COPY CODE**
and **SAVE FILE** share a profile, and **IMPORT CODE** and **IMPORT FILE** take one in. The codes and
`.r2z` files are r2modman's, so the two managers read each other's.
- In game, the **MODS** tab of the ReSkate menu (**Insert**) turns mods on and off and applies the changes.
- Mods are checked against the game build they were made for. Outdated mods, or mods that can't be merged
cleanly, are left out with a message naming them, and the rest still load.
+3 -1
View File
@@ -115,7 +115,9 @@ Any role with at least one command can also use help and roles (the list).
Players
players Connected players and their SteamID64s.
kick kick <player>: off the server until it restarts.
kick kick <player>: off the server until it restarts, or for
as long as kick-time says.
kick-time kick-time <minutes>|restart: how long a kick lasts.
ban ban <player or SteamID64> [name]: for good.
unban unban <SteamID64>.
bans The ban list. (A role with ban, unban or bans also sees
+10 -1
View File
@@ -207,6 +207,12 @@ afk_kick_minutes Remove a player who has been away this many minutes, 1-1440
in chat or changing their objects. They are warned in chat
a minute before and can join again at once. Admins are
never removed for it. Console: afk-kick <minutes>|off.
kick_minutes How long a kicked player is kept out, by an admin's kick or
by a vote: 1-1440 minutes (default 0: until the server
restarts). They are told how long when they are kicked and
when they try to join. A kick keeps the time it was given;
changing this is for later kicks. A ban is still for good.
Console: kick-time <minutes>|restart.
word_warnings The ReSkate team keeps a list of words that are not allowed
in chat at all (read from api.reskate.dev with the global
bans). A message with one is never passed on, whatever
@@ -549,7 +555,10 @@ or Travel, and voice, distances, placement and kicks from the Multiplayer menu.
msg-party <player> <text> Message everyone in that player's party ("[DM from <you> to party]").
msg-admins <text> Message every admin who is online ("[DM from <you> to admins]").
Players can whisper each other with /w <player> <text> in chat.
kick <player> Until the server restarts. Name start or SteamID64.
kick <player> Until the server restarts, or for kick-time's minutes.
Name start or SteamID64.
kick-time <minutes>|restart How long a kicked player (by an admin or a vote) is kept
out: 1-1440 minutes, or until the server restarts.
Nobody kicks or bans a player of their rank or above; the console can.
ban <player or id> [name] For good. unban <id> bans
map <name> e.g. map San Vansterdam, map grom, map bbcity
+1 -1
View File
@@ -88,7 +88,7 @@ A player whose role has a command types it in chat with `/` in front (`/kick Nam
| Messages | `msg`, `msg-party`, `msg-admins`, `announce`, `announce-to`, `announcements` |
| Map and world | `map`, `maps`, `map-pool`, `rotation`, `park`, `park-mod`, `layer-sync`, `layer`, `layers`, `tod` |
| Server settings | `name`, `password`, `welcome`, `listed`, `chat-color`, `status`, `net`, `activity-log` |
| What players may do | `voice`, `voice-range`, `placement`, `objects`, `object-scaling`, `clear-objects`, `effects`, `noclip`, `nobail`, `boosts`, `tuning`, `parties`, `party-size`, `afk-kick`, `announce-throwdowns` |
| What players may do | `voice`, `voice-range`, `placement`, `objects`, `object-scaling`, `clear-objects`, `effects`, `noclip`, `nobail`, `boosts`, `tuning`, `parties`, `party-size`, `afk-kick`, `kick-time`, `announce-throwdowns` |
| Votes and polls | `votes`, `vote`, `vote-cancel`, `poll` |
| Network | `distances`, `crowd`, `rate`, `tps` |
| Anti-cheat | `speed-check`, `board-check`, `score-check`, `score-allow`, `bone-scale`, `bone-reach` |
+9
View File
@@ -217,6 +217,15 @@ int run() {
check(load_config(away.file).afk_kick == 15, "The away timer was not kept");
away.afk_kick = 1441;
check(config_error(away).find("afk_kick_minutes") != std::string::npos, "An away timer over a day was accepted");
// How long a kick lasts: until the restart unless set, and no more than a day.
ServerConfig kicks;
check(kicks.kick_minutes == 0 && config_error(kicks).find("kick_minutes") == std::string::npos, "A kick does not last until the restart by default");
kicks.file = folder / "kicks.json";
kicks.kick_minutes = 10;
save_config(kicks);
check(load_config(kicks.file).kick_minutes == 10, "The kick time was not kept");
kicks.kick_minutes = 1441;
check(config_error(kicks).find("kick_minutes") != std::string::npos, "A kick time over a day was accepted");
ServerConfig words;
check(words.word_warnings == 3 && config.word_warnings == 3, "word_warnings is not 3 unless set");
words.word_warnings = 11;
+20 -4
View File
@@ -16,7 +16,7 @@
namespace dingosdk::server {
namespace {
constexpr std::string_view help_text =
"status | net [player] | players | say <text> | msg <player> <text> | msg-party <player> <text> | msg-admins <text> | kick <player> | ban <player or SteamID64> [name] | unban <SteamID64> | bans\n"
"status | net [player] | players | say <text> | msg <player> <text> | msg-party <player> <text> | msg-admins <text> | kick <player> | kick-time <minutes>|restart | ban <player or SteamID64> [name] | unban <SteamID64> | bans\n"
"map <name, e.g. San Vansterdam> | maps [reload] | name <text> | password <text|off> | welcome <text|off> | listed on|off\n"
"voice on|off | voice-range <50-1000> | distances <full> <half> <half-return> <low> | crowd <n>|off | rate <KB/s> | bone-scale <1-8>|off | bone-reach <0.5-20>|off\n"
"placement everyone|admins|nobody | objects <number>|off | object-scaling on|off | effects on|off | clear-objects | noclip on|off | nobail on|off | boosts on|off | tuning on|off\n"
@@ -132,9 +132,20 @@ std::string Host::command(std::string_view line, std::uint64_t admin) {
if (!guest || !guest->handshaken) return "No single connected player matches \"" + std::string(argument) + "\".";
if (!outranks(guest->member.id)) return "You cannot kick a player of your rank or above.";
const auto label = guest_name(*guest);
kicked_.insert(guest->member.id);
drop(guest->member.id, "You were kicked from this server.");
return changed(label + " was kicked until the server restarts.");
const auto how_long = keep_out(guest->member.id);
drop(guest->member.id, "You were kicked from this server" + how_long + ".");
return changed(label + " was kicked" + how_long + ".");
}
if (name == "kick-time") {
const auto word = lower(argument);
const auto value = word == "restart" || word == "off" ? std::optional<std::uint64_t>(0) : number(argument);
if (!value || *value > 1440)
return "kick-time <minutes 1-1440>|restart (now " +
(config_.kick_minutes ? std::to_string(config_.kick_minutes) + " min" : std::string("until the server restarts")) + ")";
config_.kick_minutes = static_cast<unsigned>(*value);
return changed(config_.kick_minutes
? "Kicked players can join again after " + std::to_string(config_.kick_minutes) + " min. Players already kicked keep the time they were given."
: std::string("Kicked players stay out until the server restarts. Players already kicked keep the time they were given."));
}
if (name == "ban") {
auto [who, reason] = split(argument);
@@ -950,6 +961,11 @@ void Host::check_scoring(Guest &guest) {
// time. Flagged players are taken out of linked throwdowns and coop challenges (the roster's
// speeding flag: every client drops them from those), or kicked; the flag clears after a minute
// of normal speed.
std::string Host::keep_out(std::uint64_t id) {
const auto minutes = config_.kick_minutes;
kicked_[id] = minutes ? now_ + minutes * 60000000ULL : 0;
return minutes ? " for " + std::to_string(minutes) + (minutes == 1 ? " minute" : " minutes") : std::string(" until the server restarts");
}
bool Host::check_speed(Guest &guest, std::uint64_t sent) {
if (config_.speed_check == "off" || !guest.handshaken || !guest.world_ready || !sent) return true;
if (!guest.speed.sample(sent, now_)) return true;
+4 -1
View File
@@ -121,6 +121,7 @@ Layout layout(const ServerConfig &c) {
players.set("allow_parties", c.parties);
players.set("party_size", c.party_size);
players.set("afk_kick_minutes", c.afk_kick);
players.set("kick_minutes", c.kick_minutes);
players.set("word_warnings", c.word_warnings);
players.set("use_global_word_filter", c.global_words);
players.set("allow_voice_chat", c.voice_chat);
@@ -365,6 +366,7 @@ ServerConfig load_config(const std::filesystem::path &file, std::vector<std::str
c.parties = get("players", "allow_parties", c.parties, {"parties"});
c.party_size = std::clamp(get("players", "party_size", c.party_size), 2U, 8U);
c.afk_kick = get("players", "afk_kick_minutes", c.afk_kick);
c.kick_minutes = get("players", "kick_minutes", c.kick_minutes);
c.word_warnings = get("players", "word_warnings", c.word_warnings);
c.global_words = get("players", "use_global_word_filter", c.global_words);
c.voice_chat = get("players", "allow_voice_chat", c.voice_chat, {"voice_chat"});
@@ -756,7 +758,7 @@ bool custom_command_name_free(std::string_view name) noexcept {
// The chat's own commands, then the server commands admins type as /<command>.
for (const std::string_view taken :
{"help", "party", "p", "w", "whisper", "tell", "poll", "vote", "yes", "y", "no", "n", "tp",
"activity-log", "admin", "admins", "afk-kick", "role", "roles", "announce", "announce-throwdowns", "announce-to", "announcements",
"activity-log", "admin", "admins", "afk-kick", "kick-time", "role", "roles", "announce", "announce-throwdowns", "announce-to", "announcements",
"ban", "bans", "board-check", "bone-scale", "boosts", "boosts-allow", "chat-color", "chat-colour", "clear-objects", "crowd",
"distances", "effects", "kick", "layer", "layer-sync", "layers", "listed", "map", "map-pool", "maps", "msg",
"msg-admins", "msg-party", "name", "net", "nobail", "nobail-allow", "noclip", "noclip-allow", "object-limit",
@@ -826,6 +828,7 @@ std::string config_error(const ServerConfig &c) {
using namespace multiplayer;
if (!parse_colour(c.chat_color)) return "chat_color must be a colour like #8E5CFF.";
if (c.afk_kick > 1440) return "afk_kick_minutes must be 0 (never) to 1440.";
if (c.kick_minutes > 1440) return "kick_minutes must be 0 (until the server restarts) to 1440.";
if (c.word_warnings > 10) return "word_warnings must be 0 (words are not checked) to 10.";
if (!parse_colour(c.chat_text_color)) return "chat_text_color must be a colour like #D9C8FF.";
if (!valid_server_name(c.name)) return std::string("name must be ") + server_name_rule + ".";
+3
View File
@@ -158,6 +158,9 @@ struct ServerConfig {
// Minutes a player may be away (not moving, talking, typing or building) before the server
// removes them, 1 to 1440; 0: never. Admins are never removed for it.
unsigned afk_kick = 0;
// Minutes a kicked player (by an admin or by a vote) is kept out, 1 to 1440; 0: until the
// server restarts. A kick keeps the time it was given: changing this is for later kicks.
unsigned kick_minutes = 0;
// A chat message with a word the ReSkate team does not allow at all (word_lists.h) is never
// passed on. Its player is warned; after this many warnings, 1 to 10, the next gets them
// kicked. 0: nobody is warned or kicked, and the message is still not passed on.
+10 -3
View File
@@ -1742,9 +1742,16 @@ void Host::tick(std::uint64_t now) {
drop(id, "Disconnected.", how);
}
for (const auto &link : links) {
if (kicked_.contains(link.id)) {
transport_.disconnect(link.id, "You were kicked from this server.");
continue;
if (const auto kicked = kicked_.find(link.id); kicked != kicked_.end()) {
if (!kicked->second || now_ < kicked->second) {
const auto reason = kicked->second
? "You were kicked from this server. You can join again in " +
std::to_string((kicked->second - now_) / 60000000 + 1) + " min."
: std::string("You were kicked from this server.");
transport_.disconnect(link.id, reason.c_str());
continue;
}
kicked_.erase(kicked); // their time is up
}
if (is_banned(link.id)) {
transport_.disconnect(link.id, "You are banned from this server.");
+5 -1
View File
@@ -287,7 +287,11 @@ class Host {
PartyBook parties_;
std::uint64_t party_revision_{};
std::map<std::uint64_t, std::unique_ptr<Guest>> guests_;
std::set<std::uint64_t> kicked_;
// Kicked players and when each may join again (0: not before the server restarts).
std::map<std::uint64_t, std::uint64_t> kicked_;
// Keeps a player out for as long as kick_minutes says, and says how long for their notice
// and the log: " for 10 minutes", or " until the server restarts".
std::string keep_out(std::uint64_t id);
// The name each player last joined under, for banning one who has left by their SteamID.
std::map<std::uint64_t, std::string> seen_names_;
// Warnings each player has had for words that are not allowed at all ("word_warnings"),
+2 -2
View File
@@ -542,8 +542,8 @@ void Host::check_vote(bool expired) {
}
case VoteKind::kick:
if (find(done.target)) {
kicked_.insert(done.target);
drop(done.target, "You were kicked from this server by a vote.");
const auto how_long = keep_out(done.target);
drop(done.target, "You were kicked from this server by a vote" + how_long + ".");
}
break;
case VoteKind::poll: break;
+6 -2
View File
@@ -1,9 +1,9 @@
if(WIN32)
add_executable(dingosdk_launcher WIN32 Launcher/main.cpp Launcher/launch.cpp
Launcher/gui.cpp Launcher/gui_launcher.cpp Launcher/gui_renderer.cpp Launcher/gui_home.cpp
Launcher/gui_settings.cpp Launcher/gui_sign_in.cpp Launcher/gui_mods.cpp Launcher/gui_mods_browse.cpp
Launcher/gui_settings.cpp Launcher/gui_sign_in.cpp Launcher/gui_mods.cpp Launcher/gui_mods_browse.cpp Launcher/gui_mods_profiles.cpp
Launcher/gui_gamepad.cpp Launcher/gamepad_input.cpp Launcher/gui_changelog.cpp Launcher/game_settings.cpp
Launcher/updater.cpp Launcher/mod_manager.cpp Launcher/thunderstore.cpp Launcher/problem.h)
Launcher/updater.cpp Launcher/mod_manager.cpp Launcher/mod_profiles.cpp Launcher/thunderstore.cpp Launcher/problem.h)
target_link_libraries(dingosdk_launcher PRIVATE dingosdk_logging dingosdk_content_cache_install dingosdk_world_layer_scan dingosdk_launcher_support dingosdk_initfs
dingosdk_mod_list dingosdk_mods dingosdk_json dingosdk_miniz dingosdk_imgui dingosdk_playstation_input winhttp shell32 dwmapi windowscodecs ole32)
set_target_properties(dingosdk_launcher PROPERTIES OUTPUT_NAME "ReSkateLauncher")
@@ -29,6 +29,10 @@ if(DINGOSDK_BUILD_LAUNCHER_TESTS AND WIN32)
target_link_libraries(dingosdk_thunderstore_tests PRIVATE dingosdk_json dingosdk_miniz)
target_include_directories(dingosdk_thunderstore_tests PRIVATE "${PROJECT_SOURCE_DIR}")
add_test(NAME launcher_thunderstore COMMAND dingosdk_thunderstore_tests)
add_executable(dingosdk_mod_profiles_tests Launcher/Test/mod_profiles_tests.cpp Launcher/mod_profiles.cpp)
target_link_libraries(dingosdk_mod_profiles_tests PRIVATE dingosdk_mod_list dingosdk_json dingosdk_miniz)
target_include_directories(dingosdk_mod_profiles_tests PRIVATE "${PROJECT_SOURCE_DIR}")
add_test(NAME launcher_mod_profiles COMMAND dingosdk_mod_profiles_tests "${CMAKE_CURRENT_BINARY_DIR}/mod-profiles-fixture")
add_executable(dingosdk_thunderstore_package_tests Engine/Vfs/Test/thunderstore_package_tests.cpp Engine/Vfs/thunderstore_package.cpp)
target_link_libraries(dingosdk_thunderstore_package_tests PRIVATE dingosdk_json)
target_include_directories(dingosdk_thunderstore_package_tests PRIVATE "${PROJECT_SOURCE_DIR}")