Mod Manager: a pause menu tab after Mod Options to browse, install, update and switch mods in game

A third ReSkate tab in the game's pause menu (native_menu.cpp slot 2, the Store's tab art), drawn by
the overlay as the other two are (mod_manager_page.inl, included into hub_page.cpp):

- BROWSE: search, categories in a panel of their own (shown only, hidden or either, kept in the
  profile), sort, a featured carousel and rows (most popular, recently updated, new, the biggest
  categories), a grid for results, and a mod's own page with its README, what it brings with it,
  and INSTALL, switch on or off and uninstall. Works from a controller: the carousel's card is
  one stop that left and right turn, and B goes back from a mod's page (the page's Back is held
  meanwhile).
- MY MODS: the Mods folder with the states the ReSkate menu shows; UPDATES: Update all.
- The foot of the page shows the install's progress and the apply's, and Apply (View on a pad).
  Leaving the menu with changes not applied asks first (a two-answer card in message_card.cpp,
  answered by the runtime as the message is); LATER closes the menu.

Behind it, Extension/Assets/mod_store: Thunderstore's listing (cached on disk), the installed mods,
and a queue that downloads and installs packages with their dependencies, updates and uninstalls
them. An update or uninstall of a mod the game holds open waits in Mods/.reskate-pending and the
launcher makes it before the next start (launcher_mods::apply_pending). Package icons load into
their own SRV slots (store_icons.cpp), sharing the launcher's icon cache.

Tests: mod_store_view (search, categories, the front page's rows, install plans), the pending
updates and uninstalls in mod_manager_tests, and the third tab in native_menu_view_tests.
This commit is contained in:
AyeZeeBB committed 2026-10-24 19:55:56 -05:00
1 parent 54716f2798
commit 725c18ef3d
25 files changed
+3164 -52

No files matched your search

@@ -0,0 +1,162 @@
// The in-game Mod Manager's view of Thunderstore's listing: search, the front page's rows, what a
// package's button does, and what installing one brings with it.
#include "Extension/Assets/mod_store_view.h"
#include <algorithm>
#include <iostream>
#include <string>
using namespace dingosdk;
namespace ms = dingosdk::mod_store;
namespace ts = dingosdk::thunderstore;
namespace {
int failures = 0;
void check(bool condition, const char* what) {
if (condition) return;
std::cerr << "FAIL: " << what << '\n';
++failures;
}
// One package row of a listing, with one version.
std::string package(const std::string& owner, const std::string& name, const std::string& version, const std::string& categories,
const std::string& updated, unsigned downloads, const std::string& dependencies = {}, bool pinned = false,
bool deprecated = false, bool nsfw = false, const std::string& description = "A mod.") {
const auto full = owner + '-' + name;
return R"({"name": ")" + name + R"(", "full_name": ")" + full + R"(", "owner": ")" + owner + R"(",
"package_url": "https://thunderstore.io/c/reskate/p/)" + owner + '/' + name + R"(/",
"date_created": ")" + updated + R"(", "date_updated": ")" + updated + R"(",
"rating_score": 1, "is_pinned": )" + (pinned ? "true" : "false") + R"(, "is_deprecated": )" + (deprecated ? "true" : "false") +
R"(, "has_nsfw_content": )" + (nsfw ? "true" : "false") + R"(, "categories": [)" + categories + R"(],
"versions": [{"name": ")" + name + R"(", "full_name": ")" + full + '-' + version + R"(", "description": ")" + description + R"(",
"icon": "https://gcdn.thunderstore.io/live/repository/icons/)" + full + '-' + version + R"(.png",
"version_number": ")" + version + R"(", "dependencies": [)" + dependencies + R"(], "downloads": )" + std::to_string(downloads) + R"(,
"download_url": "https://thunderstore.io/package/download/)" + owner + '/' + name + '/' + version + R"(/",
"date_created": ")" + updated + R"(", "website_url": "", "is_active": true, "file_size": 1048576}]})";
}
bool has(const std::vector<const ts::Package*>& list, std::string_view full_name) {
return std::any_of(list.begin(), list.end(), [&](const ts::Package* p) { return p->full_name == full_name; });
}
} // namespace
int main() {
const auto text = "[" +
package("Zee", "Desert_Springs", "1.2.0", R"("Maps")", "2026-10-20T10:00:00Z", 5000) + "," +
package("Sandos", "Vancouver_Plaza", "2.0.0", R"("Maps")", "2026-06-01T10:00:00Z", 9000) + "," +
package("Fourth", "Board_Pack", "1.0.0", R"("Skateboards", "Cosmetics")", "2026-10-22T10:00:00Z", 300,
R"("Fourth-Board_Lib-1.0.0", "Missing-Thing-1.0.0")") + "," +
package("Fourth", "Board_Lib", "1.0.0", R"("Libraries")", "2026-10-01T10:00:00Z", 100) + "," +
package("Team", "Pinned_Pack", "1.0.0", R"("Modpacks")", "2025-01-01T10:00:00Z", 10, {}, true) + "," +
package("Old", "Deprecated_Map", "1.0.0", R"("Maps")", "2025-01-01T10:00:00Z", 80000, {}, false, true) + "," +
package("Bad", "Not_Safe", "1.0.0", R"("Cosmetics")", "2026-10-23T10:00:00Z", 90000, {}, false, false, true) + "," +
package("Bad", "Also_Not_Safe", "1.0.0", R"("NSFW")", "2026-10-23T10:00:00Z", 90000) + "," +
package("Zee", "Hoodie", "1.0.0", R"("Cosmetics")", "2026-10-10T10:00:00Z", 700, {}, false, false, false, "A warm desert hoodie") + "]";
const auto packages = ts::parse_listing(text);
check(packages.size() == 9, "the fixture listing parses");
ts::Installed none;
// ---- search
{
const auto all = ms::search(packages, {}, none);
check(!has(all, "Bad-Not_Safe") && !has(all, "Bad-Also_Not_Safe"), "NSFW packages are never listed, by mark or by category");
check(!has(all, "Old-Deprecated_Map"), "a deprecated package is not listed when it is not installed");
ts::Installed have{{"Old-Deprecated_Map", "1.0.0"}};
check(has(ms::search(packages, {}, have), "Old-Deprecated_Map"), "a deprecated package is listed once it is installed");
check(!all.empty() && all.front()->full_name == "Team-Pinned_Pack", "pinned packages come first");
check(all.size() > 2 && all[1]->full_name == "Sandos-Vancouver_Plaza", "then the most downloaded");
const auto desert = ms::search(packages, {"DESERT"}, none);
check(desert.size() == 2 && has(desert, "Zee-Desert_Springs") && has(desert, "Zee-Hoodie"),
"search finds the title and the description, in any case");
check(ms::search(packages, {"sandos"}, none).size() == 1, "search finds the owner");
const auto maps = ms::search(packages, {{}, {"Maps"}}, none);
check(maps.size() == 2 && has(maps, "Zee-Desert_Springs") && has(maps, "Sandos-Vancouver_Plaza"), "a category shown only keeps only its packages");
const auto several = ms::search(packages, {{}, {"Maps", "Skateboards"}}, none);
check(several.size() == 3 && has(several, "Fourth-Board_Pack"), "several categories shown only keep the packages in any of them");
const auto without = ms::search(packages, {{}, {}, {"Cosmetics"}}, none);
check(!has(without, "Fourth-Board_Pack") && !has(without, "Zee-Hoodie") && has(without, "Zee-Desert_Springs"),
"a hidden category leaves out every package in it, whatever else it is in");
const auto both = ms::search(packages, {{}, {"Skateboards", "Maps"}, {"Cosmetics"}}, none);
check(both.size() == 2 && !has(both, "Fourth-Board_Pack"), "hidden wins over shown only");
const auto updated = ms::search(packages, {{}, {"Maps"}, {}, ms::Sort::updated}, none);
check(updated.size() == 2 && updated.front()->full_name == "Zee-Desert_Springs", "LAST UPDATED puts the newest update first");
const auto by_name = ms::search(packages, {{}, {}, {}, ms::Sort::name}, none);
check(by_name.size() > 2 && by_name[1]->full_name == "Fourth-Board_Lib", "NAME sorts A to Z after the pinned ones");
}
// ---- categories
{
const auto categories = ms::categories(packages);
const auto count = [&](std::string_view name) {
for (const auto& [category, n] : categories)
if (category == name) return n;
return -1;
};
check(count("Maps") == 3 && count("Cosmetics") == 2, "categories count their packages, NSFW ones left out");
check(count("NSFW") == -1, "a category only NSFW packages have is not offered");
check(!categories.empty() && categories.front().first == "Maps", "the biggest category comes first");
}
// ---- the front page
{
const auto front = ms::front(packages, none, "2026-10-24", 3, 10);
check(front.featured.size() == 3, "featured holds at most what was asked for");
check(!front.featured.empty() && front.featured.front()->full_name == "Team-Pinned_Pack", "a pinned package is featured first");
check(front.featured.size() == 3 && front.featured[1]->full_name == "Zee-Desert_Springs",
"then the most downloaded of those updated in the last 60 days (not the older, more downloaded one)");
check(std::none_of(front.featured.begin(), front.featured.end(), [](const ts::Package* p) { return ms::nsfw(*p) || p->deprecated; }),
"nothing NSFW or deprecated is featured");
const auto shelf = [&](std::string_view title) -> const ms::Shelf* {
for (const auto& row : front.shelves)
if (row.title == title) return &row;
return nullptr;
};
const auto* popular = shelf("MOST POPULAR");
check(popular && !popular->packages.empty() && popular->packages.front()->full_name == "Sandos-Vancouver_Plaza", "MOST POPULAR is by downloads");
const auto* fresh = shelf("NEW");
check(fresh && !fresh->packages.empty() && fresh->packages.front()->full_name == "Fourth-Board_Pack", "NEW is by when it was made");
check(shelf("MAPS") && shelf("COSMETICS"), "the biggest categories get rows of their own");
check(!shelf("LIBRARIES") && !shelf("MODPACKS"), "libraries and mod packs get no row");
check(ms::front(packages, none, "2026-10-24", 8, 2).shelves.front().packages.size() == 2, "a row holds at most what was asked for");
}
// ---- what a package's button does
{
const auto* desert = ts::find_package(packages, "Zee-Desert_Springs");
check(desert && ms::action_for(*desert, none) == ms::Action::install, "not installed: INSTALL");
check(desert && ms::action_for(*desert, {{"Zee-Desert_Springs", "1.1.0"}}) == ms::Action::update, "older: UPDATE");
check(desert && ms::action_for(*desert, {{"Zee-Desert_Springs", "1.2.0"}}) == ms::Action::installed, "the newest: INSTALLED");
}
// ---- what installing brings
{
const auto* pack = ts::find_package(packages, "Fourth-Board_Pack");
check(pack != nullptr, "the pack is listed");
if (pack) {
const auto plan = ms::plan(std::vector<ts::Package>{*pack}, packages, none);
check(plan.packages.size() == 2 && plan.packages.front().full_name == "Fourth-Board_Lib" && plan.packages.back().full_name == "Fourth-Board_Pack",
"what a package needs comes first");
check(plan.unknown.size() == 1 && plan.unknown.front().starts_with("Missing-Thing"), "a dependency the listing lacks is named");
check(plan.bytes == 2 * 1048576, "the download size adds up");
const auto again = ms::plan(std::vector<ts::Package>{*pack}, packages, {{"Fourth-Board_Lib", "1.0.0"}});
check(again.packages.size() == 1, "a dependency already installed at its version is not fetched again");
const auto twice = ms::plan(std::vector<ts::Package>{*pack, *ts::find_package(packages, "Fourth-Board_Lib")}, packages, none);
check(twice.packages.size() == 2, "each package is fetched once");
}
}
// ---- text
check(ms::size_text(1536 * 1024) == "1.5 MB" && ms::size_text(2048) == "2 KB", "sizes read as KB and MB");
check(ms::count_text(950) == "950" && ms::count_text(12300) == "12.3K" && ms::count_text(2500000) == "2.5M", "counts read short");
check(ms::date_text("2026-10-24T12:00:00Z") == "Oct 24, 2026", "dates read as a player would write them");
if (failures) {
std::cerr << failures << " mod store view check(s) failed\n";
return 1;
}
std::cout << "mod store view: all checks passed\n";
return 0;
}
+663
View File
@@ -0,0 +1,663 @@
#include "mod_store.h"
#include "map_download.h"
#include "Engine/Core/Log/logging.h"
#include "Engine/Vfs/https_download.h"
#include "Engine/Vfs/mod_catalog.h"
#include "Engine/Vfs/mod_conflicts.h"
#include "Engine/Vfs/mod_list.h"
#include "Engine/Vfs/thunderstore_package.h"
#include "Launcher/mod_manager.h"
#include <Windows.h>
#include <algorithm>
#include <array>
#include <atomic>
#include <cctype>
#include <chrono>
#include <cstdio>
#include <filesystem>
#include <fstream>
#include <map>
#include <mutex>
#include <set>
#include <thread>
namespace dingosdk::mod_store {
namespace {
namespace fs = std::filesystem;
namespace package = thunderstore_package;
constexpr wchar_t agent[] = L"ReSkate";
constexpr std::uint64_t listing_most = 256ull << 20, index_most = 4ull << 20;
constexpr std::uint64_t stale_seconds = 10 * 60;
constexpr std::uint64_t largest_download = 8ull << 30;
constexpr std::uint32_t download_seconds = 4 * 60 * 60;
std::uint64_t unix_now() {
return static_cast<std::uint64_t>(std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::system_clock::now().time_since_epoch()).count());
}
fs::path mods_root() { return mods::engine_data_root() / mods::mods_folder; }
fs::path local_folder() {
std::array<wchar_t, 32768> local{};
const auto size = GetEnvironmentVariableW(L"LOCALAPPDATA", local.data(), static_cast<DWORD>(local.size()));
if (!size || size >= local.size()) return {};
return fs::path(local.data()) / L"ReSkate" / L"thunderstore";
}
std::wstring wide(std::string_view ascii) { return std::wstring(ascii.begin(), ascii.end()); }
bool same(std::string_view a, std::string_view b) {
return a.size() == b.size() && _strnicmp(a.data(), b.data(), a.size()) == 0;
}
void log(logging::Level level, const std::string& text) { logging::write(level, logging::Channel::assets, "Mod Manager: " + text); }
// ---------------------------------------------------------------- the listing
std::mutex listing_mutex;
std::shared_ptr<const Listing> current_listing = std::make_shared<Listing>(); // under the mutex
std::atomic<bool> fetching{};
std::atomic<std::uint64_t> next_fetch{};
fs::path listing_file() {
const auto folder = local_folder();
return folder.empty() ? fs::path() : folder / wide("listing-" + thunderstore::community() + ".json");
}
// One listing chunk (a JSON array) without its brackets, for joining chunks into one array.
std::string_view inner(std::string_view json) {
while (!json.empty() && std::isspace(static_cast<unsigned char>(json.front()))) json.remove_prefix(1);
while (!json.empty() && std::isspace(static_cast<unsigned char>(json.back()))) json.remove_suffix(1);
if (json.size() < 2 || json.front() != '[' || json.back() != ']') return {};
json = json.substr(1, json.size() - 2);
while (!json.empty() && std::isspace(static_cast<unsigned char>(json.front()))) json.remove_prefix(1);
return json;
}
void publish(std::vector<ts::Package> packages, std::uint64_t fetched, std::string error) {
std::lock_guard lock(listing_mutex);
auto next = std::make_shared<Listing>();
if (packages.empty() && !current_listing->packages.empty()) {
// A failed fetch keeps what was there.
next->packages = current_listing->packages;
next->fetched = current_listing->fetched;
} else {
next->packages = std::move(packages);
next->fetched = fetched;
}
next->error = std::move(error);
next->fetching = fetching.load();
next->serial = current_listing->serial + 1;
current_listing = std::move(next);
}
// The whole listing as one JSON array: the index's chunks, else the plain listing. Empty on failure.
std::string download_listing(std::string& error) {
const auto community = thunderstore::community();
https::Download result;
if (const auto index = https::get_text(thunderstore::listing_index_url(community), index_most, 30, agent, &result)) {
try {
const auto urls = thunderstore::parse_index(thunderstore::gunzip(*index, index_most));
std::string joined = "[";
bool all = !urls.empty();
for (const auto& url : urls) {
const auto chunk = https::get_text(url, listing_most, 120, agent, &result);
if (!chunk) { all = false; break; }
const auto text = thunderstore::gunzip(*chunk, listing_most);
const auto body = inner(text);
if (body.empty()) continue;
if (joined.size() > 1) joined += ',';
joined += body;
}
if (all) return joined + "]";
} catch (const std::exception&) {}
}
const auto plain = https::get_text(thunderstore::listing_url(community), listing_most, 180, agent, &result);
if (!plain) {
error = result.http_status && result.http_status != 200
? "Thunderstore answered " + std::to_string(result.http_status) + "."
: "Thunderstore could not be reached (Windows error " + std::to_string(result.error) + ").";
return {};
}
try { return thunderstore::gunzip(*plain, listing_most); } catch (const std::exception& failure) { error = failure.what(); }
return {};
}
void fetch_listing(bool from_disk) {
try {
const auto file = listing_file();
if (from_disk && !file.empty()) {
std::error_code error;
if (fs::is_regular_file(file, error)) {
std::ifstream in(file, std::ios::binary);
std::string text((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
auto packages = thunderstore::parse_listing(text);
const auto written = fs::last_write_time(file, error);
const auto age = error ? stale_seconds : static_cast<std::uint64_t>(std::max<long long>(0,
std::chrono::duration_cast<std::chrono::seconds>(fs::file_time_type::clock::now() - written).count()));
const auto when = unix_now() - std::min(age, unix_now());
if (!packages.empty()) {
publish(std::move(packages), when, {});
if (age < stale_seconds) {
next_fetch.store(when + stale_seconds);
fetching.store(false);
publish({}, 0, {});
return;
}
}
}
}
std::string error;
const auto text = download_listing(error);
auto packages = text.empty() ? std::vector<ts::Package>() : thunderstore::parse_listing(text);
if (!text.empty() && packages.empty()) error = "Thunderstore's listing could not be read.";
if (!packages.empty() && !file.empty()) {
std::error_code ignored;
fs::create_directories(file.parent_path(), ignored);
const auto temporary = fs::path(file).concat(L".tmp");
{
std::ofstream out(temporary, std::ios::binary | std::ios::trunc);
out.write(text.data(), static_cast<std::streamsize>(text.size()));
}
MoveFileExW(temporary.c_str(), file.c_str(), MOVEFILE_REPLACE_EXISTING);
}
log(packages.empty() ? logging::Level::warning : logging::Level::info,
packages.empty() ? "the listing could not be fetched: " + error : std::to_string(packages.size()) + " packages listed.");
const auto now = unix_now();
// A failure is tried again in a minute.
next_fetch.store(now + (packages.empty() ? 60 : stale_seconds));
fetching.store(false);
publish(std::move(packages), now, std::move(error));
} catch (const std::exception& failure) {
next_fetch.store(unix_now() + 60);
fetching.store(false);
publish({}, 0, failure.what());
} catch (...) {
next_fetch.store(unix_now() + 60);
fetching.store(false);
publish({}, 0, "the listing could not be read");
}
}
void start_fetch(bool from_disk) {
if (fetching.exchange(true)) return;
publish({}, 0, {});
try { std::thread([from_disk] { fetch_listing(from_disk); }).detach(); }
catch (...) { fetching.store(false); }
}
// ---------------------------------------------------------------- the installed mods
std::mutex mods_mutex;
std::shared_ptr<const Mods> current_mods; // under the mutex
std::atomic<bool> scanning{};
std::atomic<std::uint64_t> next_scan{}; // GetTickCount64
std::atomic<unsigned> mods_serial{};
// mods.json is written by one change at a time.
std::mutex write_mutex;
// Mods in the game whose new version went in here since the mods were last applied: the game still
// has the old one merged until they are.
std::mutex updated_mutex;
std::set<std::string, std::less<>> updated_since_apply; // under the mutex
bool updated_since(const std::string& folder) {
std::lock_guard lock(updated_mutex);
return std::any_of(updated_since_apply.begin(), updated_since_apply.end(), [&](const std::string& name) { return same(name, folder); });
}
std::atomic<std::uint64_t> applied_at{}; // GetTickCount64 when apply() last started a merge
Mods read_mods() {
Mods result;
result.settled = !live_mods::busy() && GetTickCount64() > applied_at.load() + 100;
const auto list = mods::scan_mods(mods::engine_data_root());
result.present = list.present;
result.issue = list.issue;
const auto& launch = mods::catalog();
const auto applied = live_mods::applied_mods();
const auto waiting = launcher_mods::pending(list.root);
result.waiting = waiting.updates.size() + waiting.removals.size();
const auto named = [](const std::vector<std::string>& names, const std::string& name) {
return std::any_of(names.begin(), names.end(), [&](const std::string& other) { return same(other, name); });
};
// ReSkate's own packs live outside the Mods folder's list, always on.
const auto kept = [&](const std::string& name) {
const auto owner = mods::variant_owner(name);
return (owner.empty() ? name : owner).starts_with("reskate-builtin-");
};
// A mod with variants is in effect when one of its variants is (mod_catalog.h).
const auto in_effect = [&](const std::string& name) {
return std::any_of(applied.begin(), applied.end(), [&](const std::string& other) {
return same(other, name) || same(mods::variant_owner(other), name);
});
};
const auto excluded = [&](const std::string& name) -> const mods::Mod* {
for (const auto& mod : launch.excluded)
if (same(mod.name, name)) return &mod;
return nullptr;
};
const auto aside = mods::set_aside_for_variants();
const auto title_of = [&](const std::string& name) {
for (const auto& entry : list.entries)
if (same(entry.mod.name, name)) return entry.mod.title.empty() ? name : entry.mod.title;
return name;
};
bool reload_for_update{};
for (const auto& entry : list.entries) {
const auto& mod = entry.mod;
Mod row{mod.name, mod.title.empty() ? mod.name : mod.title, mod.author, mod.version, mod.description,
mods::mod_contents(mod), entry.enabled, in_effect(mod.name)};
result.versions[mod.name] = mod.version;
const auto* left_out = excluded(mod.name);
const bool set_aside = entry.enabled && !row.in_effect && named(aside, mod.name);
if (named(waiting.removals, mod.name)) {
row.state = State::removal_waiting;
row.why = "It is uninstalled the next time the game starts: the game has its files open now.";
} else if (named(waiting.updates, mod.name)) {
row.state = State::update_waiting;
row.why = "Its update goes in the next time the game starts: the game has its files open now.";
} else if (left_out && !mods::conflict_winner(*left_out).empty()) {
row.state = State::left_out;
row.why = "It changes the same parts of the game as " + title_of(mods::conflict_winner(*left_out)) +
", which is higher in the load order, so only that one loads. Change the order in the launcher.";
} else if (left_out) {
row.state = State::not_loaded;
row.why = !left_out->outdated.empty() ? left_out->outdated
: "It could not be merged cleanly, so none of it is used. Reinstall it, or ask its author for an update.";
} else if (set_aside) {
row.state = State::set_aside;
row.why = "It changes what another mod's chosen variant replaces, and only one of them can be in the game.";
} else if (entry.enabled && row.in_effect && updated_since(mod.name) && (mod.provides_layout || mod.provides_levels)) {
row.state = State::apply_to_load;
row.why = "Its new version is installed: it goes in the game when the mods are applied.";
reload_for_update |= !live_mods::only_maps(mod.directory);
} else if (entry.enabled && row.in_effect) row.state = State::loaded;
else if (entry.enabled) row.state = State::apply_to_load;
else if (row.in_effect) row.state = State::apply_to_unload;
else row.state = State::disabled;
if (row.state == State::apply_to_load || row.state == State::apply_to_unload) ++result.changes;
result.mods.push_back(std::move(row));
}
// In effect, but its folder has been deleted: applying unloads it.
for (const auto& name : applied)
if (mods::variant_owner(name).empty() && !kept(name) &&
std::none_of(list.entries.begin(), list.entries.end(), [&](const mods::ModEntry& entry) { return same(entry.mod.name, name); })) {
Mod row{name, name};
row.in_effect = row.gone = true;
row.state = State::apply_to_unload;
row.why = "Its folder is gone, but it stays in the game until the mods are applied.";
++result.changes;
result.mods.push_back(std::move(row));
}
if (result.changes) result.effect = live_mods::preview(list);
// (preview only sees mods going in or out; a new version of one in the game is neither.)
if (reload_for_update && result.effect == live_mods::ApplyEffect::maps_only) result.effect = live_mods::ApplyEffect::reload_level;
return result;
}
void start_scan() {
if (scanning.exchange(true)) return;
next_scan.store(GetTickCount64() + 1500);
try {
std::thread([] {
std::shared_ptr<const Mods> fresh;
try {
auto read = read_mods();
read.serial = ++mods_serial;
fresh = std::make_shared<const Mods>(std::move(read));
} catch (...) {}
if (fresh) {
std::lock_guard lock(mods_mutex);
current_mods = std::move(fresh);
}
scanning.store(false);
}).detach();
} catch (...) { scanning.store(false); }
}
// After a change made here: read again at once (or as soon as the read under way ends).
void rescan() { next_scan.store(0); }
// ---------------------------------------------------------------- the queue
std::mutex progress_mutex;
Progress current_progress; // under the mutex
std::atomic<bool> busy{}, stop{};
https::Watch watch;
void set_activity(std::string text, float fraction = -1) {
std::lock_guard lock(progress_mutex);
current_progress.activity = std::move(text);
current_progress.fraction = fraction;
}
// The package in hand: what it is, and where the job is with it.
void set_package(Progress::Step step, const ts::Package* wanted, std::size_t at, std::size_t of, std::vector<std::string> queued = {}) {
std::lock_guard lock(progress_mutex);
current_progress.step = step;
current_progress.at = at;
current_progress.of = of;
current_progress.received = current_progress.total = 0;
if (wanted) {
current_progress.package = wanted->full_name;
current_progress.title = wanted->title();
current_progress.version = wanted->latest().number;
current_progress.icon = wanted->latest().icon;
current_progress.queued = std::move(queued);
}
}
void set_bytes(std::uint64_t received, std::uint64_t total) {
std::lock_guard lock(progress_mutex);
current_progress.received = received;
current_progress.total = total;
}
void finish(std::string result, bool failed) {
log(failed ? logging::Level::warning : logging::Level::info, result);
{
std::lock_guard lock(progress_mutex);
current_progress.busy = false;
current_progress.activity.clear();
current_progress.fraction = -1;
current_progress.step = Progress::Step::starting;
current_progress.package.clear();
current_progress.queued.clear();
current_progress.result = std::move(result);
current_progress.failed = failed;
++current_progress.serial;
}
busy.store(false);
rescan();
}
std::string begin(const char* what) {
if (map_download::view().stage != map_download::Stage::idle) return "A map for a session is being downloaded. Wait for it to finish.";
if (live_mods::busy()) return "The mods are being applied. Wait for it to finish.";
if (busy.exchange(true)) return std::string("Wait for the ") + what + " under way to finish.";
stop.store(false);
watch.cancel.store(false);
std::lock_guard lock(progress_mutex);
current_progress.busy = true;
current_progress.result.clear();
current_progress.failed = false;
current_progress.activity = "Starting...";
current_progress.fraction = -1;
current_progress.step = Progress::Step::starting;
current_progress.package.clear();
current_progress.queued.clear();
return {};
}
// Downloads and installs one package (its newest version). Returns what happened ("" when it went
// in now, "later" when it waits for the next start) or throws why not.
std::string install_one(const ts::Package& wanted, std::size_t at, std::size_t of, std::vector<std::string> queued) {
const auto& version = wanted.latest();
const auto root = mods_root();
const auto counter = of > 1 ? " (" + std::to_string(at + 1) + " of " + std::to_string(of) + ")" : std::string();
const auto archive_folder = root / L".reskate-download";
std::error_code error;
fs::create_directories(archive_folder, error);
const auto archive = archive_folder / (wide(wanted.full_name) + L"-ingame.zip");
struct Cleanup {
fs::path archive, folder;
~Cleanup() { std::error_code ignored; fs::remove(archive, ignored); fs::remove(folder, ignored); }
} cleanup{archive, archive_folder};
fs::remove(archive, error);
// Always from thunderstore.io, whatever the listing names (as the map download does).
const package::Name name{wanted.owner, wanted.name, version.number};
set_package(Progress::Step::downloading, &wanted, at, of, std::move(queued));
set_activity("Downloading " + wanted.title() + " v" + version.number + counter, 0);
watch.received.store(0);
watch.total.store(version.file_size);
https::Download result;
{
// How far it is, said a few times a second while the download runs.
struct Meter {
std::atomic<bool> done{};
std::thread thread;
~Meter() { done.store(true); if (thread.joinable()) thread.join(); }
} meter;
meter.thread = std::thread([&] {
while (!meter.done.load()) {
const auto total = watch.total.load(), received = watch.received.load();
set_bytes(received, total);
if (total) set_activity("Downloading " + wanted.title() + " v" + version.number + counter + " " + size_text(received) + " of " + size_text(total),
static_cast<float>(static_cast<double>(received) / static_cast<double>(total)));
Sleep(100);
}
});
result = https::get(package::download_url(name, version.number), archive, version.file_size ? version.file_size + (1 << 20) : largest_download,
download_seconds, agent, &watch);
}
if (stop.load()) throw std::runtime_error("Cancelled.");
if (!result.ok)
throw std::runtime_error(result.http_status && result.http_status != 200
? "the download failed (Thunderstore answered " + std::to_string(result.http_status) + ")"
: "the download failed (Windows error " + std::to_string(result.error) + ")");
set_package(Progress::Step::installing, nullptr, at, of);
set_activity("Installing " + wanted.title() + " v" + version.number + counter, 0);
launcher_mods::InstallOptions options;
options.folder = ts::folder_for(wanted.full_name);
options.author = wanted.owner;
options.require_content = true;
const auto meter_install = [&](float fraction) { set_activity("Installing " + wanted.title() + " v" + version.number + counter, fraction); };
const bool update = fs::exists(root / wide(options.folder), error);
try {
launcher_mods::install(root, archive, true, meter_install, stop, options);
if (update) {
std::lock_guard lock(updated_mutex);
updated_since_apply.insert(options.folder);
}
return {};
} catch (const std::exception& failure) {
if (!update || stop.load()) throw;
// The game holds the files of the copy it replaces: it goes in at the next start.
log(logging::Level::info, wanted.full_name + " is in use (" + failure.what() + "); its update waits for the next start.");
}
launcher_mods::install_later(root, archive, meter_install, stop, options);
return "later";
}
// ---------------------------------------------------------------- READMEs
std::mutex readme_mutex;
std::map<std::string, std::shared_ptr<const Readme>, std::less<>> readmes; // under the mutex; null while fetching
} // namespace
const char* state_label(State state) {
switch (state) {
case State::loaded: return "LOADED";
case State::disabled: return "DISABLED";
case State::apply_to_load: return "APPLY TO LOAD";
case State::apply_to_unload: return "APPLY TO UNLOAD";
case State::left_out: return "LEFT OUT";
case State::not_loaded: return "NOT LOADED";
case State::set_aside: return "SET ASIDE";
case State::update_waiting: return "RESTART TO UPDATE";
case State::removal_waiting: return "RESTART TO REMOVE";
}
return "";
}
std::shared_ptr<const Listing> listing() {
static std::atomic<bool> started{};
if (!started.exchange(true)) start_fetch(true);
else if (!fetching.load() && unix_now() >= next_fetch.load()) start_fetch(false);
std::lock_guard lock(listing_mutex);
return current_listing;
}
void refresh() {
if (!fetching.load()) start_fetch(false);
}
std::shared_ptr<const Mods> installed() {
if (!scanning.load() && GetTickCount64() >= next_scan.load()) start_scan();
std::lock_guard lock(mods_mutex);
return current_mods;
}
void set_enabled(const std::string& folder, bool on) {
try {
std::thread([folder, on] {
try {
std::lock_guard lock(write_mutex);
auto list = mods::scan_mods(mods::engine_data_root());
if (!list.issue.empty()) throw std::runtime_error("mods.json could not be read: " + list.issue);
for (auto& entry : list.entries)
if (same(entry.mod.name, folder)) entry.enabled = on;
mods::save_mod_order(list.root, list.entries);
log(logging::Level::info, folder + (on ? " switched on." : " switched off."));
} catch (const std::exception& failure) {
std::lock_guard lock(progress_mutex);
current_progress.result = std::string("Could not save mods.json: ") + failure.what();
current_progress.failed = true;
++current_progress.serial;
}
rescan();
}).detach();
} catch (...) {}
}
std::string apply() {
if (busy.load()) return "Wait for the install under way to finish.";
std::shared_ptr<const Mods> now;
{
std::lock_guard lock(mods_mutex);
now = current_mods;
}
const bool reload = now && now->effect == live_mods::ApplyEffect::reload_level;
const auto said = live_mods::apply(reload);
applied_at.store(GetTickCount64());
if (live_mods::busy()) {
std::lock_guard lock(updated_mutex);
updated_since_apply.clear();
}
rescan();
return said;
}
Progress progress() {
std::lock_guard lock(progress_mutex);
return current_progress;
}
std::string install(const std::vector<std::string>& wanted) {
const auto shown = listing();
std::vector<ts::Package> packages;
for (const auto& name : wanted)
if (const auto* found = ts::find_package(shown->packages, name)) packages.push_back(*found);
if (packages.empty()) return "That mod is not in Thunderstore's listing.";
std::shared_ptr<const Mods> mods_now;
{
std::lock_guard lock(mods_mutex);
mods_now = current_mods;
}
// The ones asked for are taken at their newest, installed or not; what they need only when it
// is not here at the version they ask for.
ts::Installed have = mods_now ? mods_now->versions : ts::Installed{};
for (const auto& package : packages) have.erase(ts::folder_for(package.full_name));
auto plan = mod_store::plan(packages, shown->packages, have);
if (plan.packages.empty()) return "Nothing to install.";
if (auto refused = begin("install"); !refused.empty()) return refused;
try {
std::thread([plan = std::move(plan)] {
std::size_t now_in{}, later{};
std::vector<std::string> failed;
for (std::size_t i = 0; i < plan.packages.size(); ++i) {
if (stop.load()) break;
const auto& package = plan.packages[i];
try {
std::vector<std::string> queued;
for (std::size_t j = i + 1; j < plan.packages.size(); ++j) queued.push_back(plan.packages[j].full_name);
const auto how = install_one(package, i, plan.packages.size(), std::move(queued));
(how.empty() ? now_in : later) += 1;
log(logging::Level::info, package.full_name + " v" + package.latest().number + (how.empty() ? " installed." : " waits for the next start."));
} catch (const std::exception& failure) {
if (stop.load()) break;
failed.push_back(package.title() + ": " + failure.what());
}
}
std::string result;
const auto title = plan.packages.back().title();
if (stop.load()) result = "Cancelled. " + std::to_string(now_in) + " of " + std::to_string(plan.packages.size()) + " installed.";
else if (failed.empty() && later == 0)
result = plan.packages.size() == 1 ? title + " is installed." : std::to_string(now_in) + " mods installed.";
else if (failed.empty())
result = later == plan.packages.size() ? "The game has the old files open: the update goes in the next time it starts."
: std::to_string(now_in) + " installed; " + std::to_string(later) + " go in the next time the game starts.";
else {
result = failed.size() == 1 ? failed.front() : std::to_string(failed.size()) + " could not be installed: " + failed.front();
if (now_in) result += " (" + std::to_string(now_in) + " installed)";
}
if (!plan.unknown.empty()) result += " Not on Thunderstore: " + plan.unknown.front() + (plan.unknown.size() > 1 ? " and more." : ".");
finish(std::move(result), stop.load() || !failed.empty() || !plan.unknown.empty());
}).detach();
} catch (...) {
finish("The install could not start.", true);
}
return {};
}
std::string uninstall(const std::string& folder) {
if (!mods::valid_mod_name(folder)) return "That is not a mod folder.";
if (auto refused = begin("install"); !refused.empty()) return refused;
try {
std::thread([folder] {
set_package(Progress::Step::uninstalling, nullptr, 0, 1);
{
std::lock_guard lock(progress_mutex);
current_progress.package = current_progress.title = folder;
}
set_activity("Uninstalling " + folder);
const auto root = mods_root();
try {
launcher_mods::remove(root, folder);
finish(folder + " is uninstalled (it is in the Recycle Bin).", false);
return;
} catch (const std::exception& failure) {
log(logging::Level::info, folder + " could not be removed now (" + failure.what() + "); it goes at the next start.");
}
try {
{
// Off now, so the next apply takes it out of the game; gone at the next start.
std::lock_guard lock(write_mutex);
auto list = mods::scan_mods(mods::engine_data_root());
if (list.issue.empty()) {
for (auto& entry : list.entries)
if (same(entry.mod.name, folder)) entry.enabled = false;
mods::save_mod_order(list.root, list.entries);
}
}
launcher_mods::remove_later(root, folder);
finish(folder + " is switched off, and uninstalled the next time the game starts.", false);
} catch (const std::exception& failure) {
finish(std::string("Could not uninstall it: ") + failure.what(), true);
}
}).detach();
} catch (...) {
finish("The uninstall could not start.", true);
}
return {};
}
void cancel() {
stop.store(true);
watch.cancel.store(true);
}
std::shared_ptr<const Readme> readme(const ts::Package& wanted) {
const auto key = wanted.full_name + '-' + wanted.latest().number;
{
std::lock_guard lock(readme_mutex);
if (const auto found = readmes.find(key); found != readmes.end()) return found->second;
if (readmes.size() >= 64) readmes.clear();
readmes.emplace(key, nullptr);
}
try {
std::thread([key, url = ts::readme_url(wanted)] {
auto result = std::make_shared<Readme>();
if (const auto json = https::get_text(url, 1 << 20, 20, agent)) {
try { result->text = ts::readme_lines(ts::parse_readme(*json), 300); }
catch (...) { result->failed = true; }
} else result->failed = true;
std::lock_guard lock(readme_mutex);
readmes[key] = std::move(result);
}).detach();
} catch (...) {}
return nullptr;
}
std::string today() {
SYSTEMTIME now{};
GetSystemTime(&now);
char text[16];
std::snprintf(text, sizeof(text), "%04u-%02u-%02u", now.wYear, now.wMonth, now.wDay);
return text;
}
}
+107
View File
@@ -0,0 +1,107 @@
#pragma once
#include "mod_store_view.h"
#include "Extension/Assets/live_mods.h"
#include <cstdint>
#include <memory>
#include <string>
#include <vector>
// The in-game Mod Manager (the pause menu's Mod Manager tab, Extension/UI/Overlay/mod_manager_page.inl):
// Thunderstore's listing for ReSkate's community, the installed mods, and a queue that downloads and
// installs packages with what they need, updates them and uninstalls them, as the launcher's Mods
// panel does. Everything that reads the disk or the network runs on a thread of its own; the page
// reads snapshots, every frame if it likes.
//
// A change takes effect as any change to the Mods folder does in game: plugins within two seconds
// (the plugin host looks for them), everything else when the mods are applied (live_mods.h), which
// the page offers. The game holds the files of mods it has loaded open, so an update or uninstall
// of one of those cannot be made while it runs: it waits in Mods/.reskate-pending and the launcher
// makes it before the next start (Launcher/mod_manager.h apply_pending).
namespace dingosdk::mod_store {
// The listing, as it was last read: at once from the copy kept on disk
// (%LOCALAPPDATA%\ReSkate\thunderstore\listing-<community>.json), and from Thunderstore when that is
// over 10 minutes old.
struct Listing {
std::vector<ts::Package> packages;
std::uint64_t fetched{}; // when this copy came from Thunderstore (Unix seconds); 0: none yet
bool fetching{};
std::string error; // why the last fetch failed; empty when it did not
unsigned serial{}; // changes with every new copy
};
// Any thread. Starts a fetch when one is due.
std::shared_ptr<const Listing> listing();
// Fetches it again now (unless a fetch is under way).
void refresh();
// An installed mod as the page shows it, its state as the Mods page in the ReSkate menu tells it.
enum class State { loaded, disabled, apply_to_load, apply_to_unload, left_out, not_loaded, set_aside, update_waiting, removal_waiting };
const char* state_label(State state);
struct Mod {
std::string folder, title, author, version, description;
std::vector<std::string> contents; // "Map", "Plugin"... (mod_list.h mod_contents)
bool enabled{}, in_effect{};
State state{};
std::string why; // what the state means for this mod, when it needs saying
bool gone{}; // its folder was deleted, but it is in effect until applied
};
struct Mods {
std::vector<Mod> mods; // in load order, highest first
ts::Installed versions; // every installed folder and its version
std::size_t changes{}; // what the Apply button would apply
live_mods::ApplyEffect effect{}; // and what it does to the level being played
std::size_t waiting{}; // updates and uninstalls waiting for the next start
std::string issue; // mods.json could not be read
bool present{}; // the Mods folder exists
// Read after the last apply had finished: only then do `changes` say what applying would still
// change (a reading from before or during one still has the applied ones in it).
bool settled{};
unsigned serial{};
};
// Any thread. Read on a thread of its own: again every 1.5 s while asked for, and at once after a
// change made here. Null until the first read is done.
std::shared_ptr<const Mods> installed();
// Switches a mod on or off in mods.json (on a thread of its own). Takes effect when applied.
void set_enabled(const std::string& folder, bool on);
// Applies the mods as they are now (live_mods::apply), reloading the level when that is needed for
// the change to show. Returns what happened, for the page.
std::string apply();
// The queue: one job at a time.
struct Progress {
bool busy{};
enum class Step { starting, downloading, installing, uninstalling } step{};
std::string activity; // "Downloading Vancouver Plaza (2 of 3)"
float fraction{-1}; // of the one in hand; below 0 when not known
// The package in hand (Namespace-Name; the folder, uninstalling), its name, the version going
// in and its icon's URL; which of the job's packages it is, of how many (from 0); while it
// downloads, how much has come of how much (0: not known); and the packages still to come.
std::string package, title, version, icon;
std::size_t at{}, of{};
std::uint64_t received{}, total{};
std::vector<std::string> queued;
std::string result; // what the last job did, for the page; empty while busy
bool failed{}; // the last job did not do all it was asked to
unsigned serial{}; // changes when a job ends
};
Progress progress();
// Installs the packages (Namespace-Name each, as in the listing) at their newest version, with the
// packages they need. An installed one is updated. Empty, or why it cannot start now.
std::string install(const std::vector<std::string>& packages);
// Uninstalls Mods/<folder>: to the Recycle Bin, or at the next start when the game holds its files.
std::string uninstall(const std::string& folder);
// Stops the download under way; what is installed stays.
void cancel();
// A package's README, fetched on a thread of its own the first time it is asked for; null until
// then (and `failed` when it could not be had).
struct Readme {
ts::Readme text;
bool failed{};
};
std::shared_ptr<const Readme> readme(const ts::Package& package);
// "YYYY-MM-DD" today (UTC), for the front page.
std::string today();
}
+186
View File
@@ -0,0 +1,186 @@
#include "mod_store_view.h"
#include <algorithm>
#include <array>
#include <charconv>
#include <cstdio>
#include <set>
namespace dingosdk::mod_store {
namespace {
std::string lower(std::string_view text) {
std::string result(text);
for (auto& c : result) if (c >= 'A' && c <= 'Z') c = static_cast<char>(c + 32);
return result;
}
bool installed_here(const ts::Package& package, const ts::Installed& installed) {
return installed.contains(ts::folder_for(package.full_name));
}
// Listed at all: never NSFW, and deprecated only when it is installed.
bool listed(const ts::Package& package, const ts::Installed& installed) {
return !nsfw(package) && (!package.deprecated || installed_here(package, installed));
}
// "YYYY-MM-DD..." as its year, month and day.
bool ymd(std::string_view text, int& year, int& month, int& day) {
if (text.size() < 10 || text[4] != '-' || text[7] != '-') return false;
const auto number = [&](std::size_t at, std::size_t length, int& out) {
return std::from_chars(text.data() + at, text.data() + at + length, out).ec == std::errc{};
};
return number(0, 4, year) && number(5, 2, month) && number(8, 2, day);
}
bool before(const ts::Package& a, const ts::Package& b, Sort sort) {
switch (sort) {
case Sort::popular: if (a.downloads != b.downloads) return a.downloads > b.downloads; break;
case Sort::updated: if (a.date_updated != b.date_updated) return a.date_updated > b.date_updated; break;
case Sort::newest: if (a.date_created != b.date_created) return a.date_created > b.date_created; break;
case Sort::rated: if (a.rating != b.rating) return a.rating > b.rating; break;
case Sort::name: break;
}
return lower(a.title()) < lower(b.title());
}
// The date `days` before "YYYY-MM-DD", in the same form (months taken as 30 days: close enough for
// what counts as recent).
std::string days_before(std::string_view today, int days) {
int year{}, month{}, day{};
if (!ymd(today, year, month, day)) return {};
int total = year * 360 + (month - 1) * 30 + (day - 1) - days;
if (total < 0) return {};
char text[16];
std::snprintf(text, sizeof(text), "%04d-%02d-%02d", total / 360, total % 360 / 30 + 1, total % 30 + 1);
return text;
}
} // namespace
const char* sort_label(Sort sort) {
static constexpr std::array<const char*, sort_count> labels{"MOST DOWNLOADED", "LAST UPDATED", "NEWEST", "TOP RATED", "NAME"};
return labels[static_cast<std::size_t>(sort)];
}
bool nsfw(const ts::Package& package) {
return package.nsfw || std::any_of(package.categories.begin(), package.categories.end(),
[](const std::string& category) { return lower(category) == "nsfw"; });
}
std::vector<const ts::Package*> search(std::span<const ts::Package> packages, const Query& query, const ts::Installed& installed) {
const auto wanted = lower(query.search);
std::vector<const ts::Package*> result;
for (const auto& package : packages) {
if (!listed(package, installed)) continue;
if (!ts::shown_by(package, ts::CategoryFilter{query.only, query.hidden})) continue;
if (!wanted.empty() && lower(package.title()).find(wanted) == std::string::npos &&
lower(package.owner).find(wanted) == std::string::npos &&
lower(package.latest().description).find(wanted) == std::string::npos)
continue;
result.push_back(&package);
}
std::stable_sort(result.begin(), result.end(), [&](const ts::Package* a, const ts::Package* b) {
if (a->pinned != b->pinned) return a->pinned;
return before(*a, *b, query.sort);
});
return result;
}
std::vector<std::pair<std::string, int>> categories(std::span<const ts::Package> packages) {
std::vector<std::pair<std::string, int>> result;
for (const auto& package : packages) {
if (nsfw(package)) continue;
for (const auto& category : package.categories) {
const auto found = std::find_if(result.begin(), result.end(), [&](const auto& entry) { return entry.first == category; });
if (found == result.end()) result.emplace_back(category, 1);
else ++found->second;
}
}
std::stable_sort(result.begin(), result.end(), [](const auto& a, const auto& b) {
if (a.second != b.second) return a.second > b.second;
return a.first < b.first;
});
return result;
}
Front front(std::span<const ts::Package> packages, const ts::Installed& installed, std::string_view today,
std::size_t featured_count, std::size_t per_shelf) {
Front result;
std::vector<const ts::Package*> shown;
for (const auto& package : packages)
if (listed(package, installed) && !package.deprecated) shown.push_back(&package);
const auto sorted = [&](Sort sort, auto keep) {
std::vector<const ts::Package*> list;
for (const auto* package : shown)
if (keep(*package)) list.push_back(package);
std::stable_sort(list.begin(), list.end(), [&](const ts::Package* a, const ts::Package* b) { return before(*a, *b, sort); });
if (list.size() > per_shelf) list.resize(per_shelf);
return list;
};
const auto any = [](const ts::Package&) { return true; };
// Featured: pinned first, then what is popular and still looked after.
const auto recent = days_before(today, 60);
std::set<const ts::Package*> taken;
for (const auto* package : shown)
if (package->pinned && result.featured.size() < featured_count) result.featured.push_back(package), taken.insert(package);
for (const auto* package : sorted(Sort::popular, [&](const ts::Package& p) { return recent.empty() || p.date_updated >= recent; }))
if (result.featured.size() < featured_count && taken.insert(package).second) result.featured.push_back(package);
for (const auto* package : sorted(Sort::popular, any))
if (result.featured.size() < featured_count && taken.insert(package).second) result.featured.push_back(package);
const auto shelf = [&](std::string title, std::vector<const ts::Package*> list) {
if (!list.empty()) result.shelves.push_back({std::move(title), std::move(list)});
};
shelf("MOST POPULAR", sorted(Sort::popular, any));
shelf("RECENTLY UPDATED", sorted(Sort::updated, any));
shelf("NEW", sorted(Sort::newest, any));
// Then the biggest categories, each its own row.
std::size_t rows{};
for (const auto& [category, count] : categories(packages)) {
if (rows >= 4) break;
const auto key = lower(category);
if (key == "modpacks" || key == "libraries" || key == "misc") continue;
shelf([&] { std::string title = category; for (auto& c : title) if (c >= 'a' && c <= 'z') c = static_cast<char>(c - 32); return title; }(),
sorted(Sort::popular, [&](const ts::Package& p) { return p.in_category(category); }));
++rows;
}
return result;
}
Action action_for(const ts::Package& package, const ts::Installed& installed) {
if (!installed_here(package, installed)) return Action::install;
return ts::update_available(package, installed) ? Action::update : Action::installed;
}
Plan plan(std::span<const ts::Package> wanted, std::span<const ts::Package> listing, const ts::Installed& installed) {
Plan result;
for (const auto& package : wanted)
for (auto& needed : ts::with_dependencies(package, listing, installed, &result.unknown))
if (std::none_of(result.packages.begin(), result.packages.end(),
[&](const ts::Package& queued) { return queued.full_name == needed.full_name; })) {
result.bytes += needed.latest().file_size;
result.packages.push_back(std::move(needed));
}
std::sort(result.unknown.begin(), result.unknown.end());
result.unknown.erase(std::unique(result.unknown.begin(), result.unknown.end()), result.unknown.end());
return result;
}
std::string size_text(std::uint64_t bytes) {
char text[32];
if (bytes >= (1ull << 30)) std::snprintf(text, sizeof(text), "%.1f GB", static_cast<double>(bytes) / (1ull << 30));
else if (bytes >= (1ull << 20)) std::snprintf(text, sizeof(text), "%.1f MB", static_cast<double>(bytes) / (1ull << 20));
else std::snprintf(text, sizeof(text), "%llu KB", static_cast<unsigned long long>((bytes + 1023) / 1024));
return text;
}
std::string count_text(std::uint64_t count) {
char text[32];
if (count >= 1000000) std::snprintf(text, sizeof(text), "%.1fM", static_cast<double>(count) / 1e6);
else if (count >= 1000) std::snprintf(text, sizeof(text), "%.1fK", static_cast<double>(count) / 1e3);
else std::snprintf(text, sizeof(text), "%llu", static_cast<unsigned long long>(count));
return text;
}
std::string date_text(std::string_view iso) {
static constexpr std::array<const char*, 12> months{"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"};
int year{}, month{}, day{};
if (!ymd(iso, year, month, day) || month < 1 || month > 12)
return std::string(iso.substr(0, std::min<std::size_t>(iso.size(), 10)));
return std::string(months[static_cast<std::size_t>(month - 1)]) + ' ' + std::to_string(day) + ", " + std::to_string(year);
}
}
+72
View File
@@ -0,0 +1,72 @@
#pragma once
#include "Launcher/thunderstore.h"
#include <cstddef>
#include <cstdint>
#include <span>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
// What the in-game Mod Manager (mod_store.h) shows of Thunderstore's listing, worked out from the
// listing alone: no game, disk or network here, so it is tested on its own.
namespace dingosdk::mod_store {
namespace ts = thunderstore;
enum class Sort { popular, updated, newest, rated, name };
inline constexpr std::size_t sort_count = 5;
const char* sort_label(Sort sort);
// Not safe for work: Thunderstore's own mark on the package, or a category of that name. Never
// listed in game, installed or not (as in the launcher).
bool nsfw(const ts::Package& package);
struct Query {
std::string search; // in the title, owner and description, any case
// The categories shown only (a package needs one of them, when there are any) and those hidden (a
// package in any of them is left out), as the launcher's GET MODS has them (thunderstore.h).
std::vector<std::string> only, hidden;
Sort sort{Sort::popular};
};
// The packages that match, pinned first, then in `sort` order. A deprecated package only shows once
// installed.
std::vector<const ts::Package*> search(std::span<const ts::Package> packages, const Query& query, const ts::Installed& installed);
// The categories the listing uses (its NSFW packages left out), with how many packages are in each,
// most first.
std::vector<std::pair<std::string, int>> categories(std::span<const ts::Package> packages);
// BROWSE's front page: a few packages to show big, then rows of them.
struct Shelf {
std::string title;
std::vector<const ts::Package*> packages;
};
struct Front {
std::vector<const ts::Package*> featured;
std::vector<Shelf> shelves;
};
// `today`: "YYYY-MM-DD", for what counts as recent. Featured: the pinned packages, then the most
// downloaded of those updated in the last 60 days, at most `featured_count`. Each shelf holds at
// most `per_shelf`.
Front front(std::span<const ts::Package> packages, const ts::Installed& installed, std::string_view today,
std::size_t featured_count = 8, std::size_t per_shelf = 24);
// What pressing a package's button does.
enum class Action { install, update, installed };
Action action_for(const ts::Package& package, const ts::Installed& installed);
// What installing `wanted` brings, needed packages first, each once: the packages to download, and
// the names of those the listing does not have.
struct Plan {
std::vector<ts::Package> packages;
std::vector<std::string> unknown;
std::uint64_t bytes{}; // what they download together (0 for those whose size is not listed)
};
Plan plan(std::span<const ts::Package> wanted, std::span<const ts::Package> listing, const ts::Installed& installed);
// "1.2 MB", "340 KB"; "4.1K", "12M" downloads.
std::string size_text(std::uint64_t bytes);
std::string count_text(std::uint64_t count);
// "2026-10-24T12:00:00Z" as "Oct 24, 2026".
std::string date_text(std::string_view iso);
}
@@ -50,16 +50,18 @@ void map_names() {
int main() {
try {
map_names();
check(missing_tabs({1, 2, 3, 4}, 8, true) == std::vector<unsigned>{0, 1},
check(missing_tabs({1, 2, 3, 4}, 8, true) == std::vector<unsigned>{0, 1, 2},
"All pages insert in canonical order together");
check(missing_tabs({1, 2, 3, 4, 8, 264}, 8, true).empty(),
check(missing_tabs({1, 2, 3, 4, 8, 264, 520}, 8, true).empty(),
"Existing tabs are never reordered or reinserted");
check(missing_tabs({1, 2, 3, 4, 8}, 8, true) == std::vector<unsigned>{1},
"A missing tools page appends without moving existing indices");
check(missing_tabs({1, 2, 3, 4}, 8, true, false) == std::vector<unsigned>{1},
check(missing_tabs({1, 2, 3, 4, 8}, 8, true) == std::vector<unsigned>{1, 2},
"A missing tools page appends without moving existing indices, the Mod Manager after it");
check(missing_tabs({1, 2, 3, 4, 8, 264}, 8, true) == std::vector<unsigned>{2},
"A missing Mod Manager appends after Mod Options");
check(missing_tabs({1, 2, 3, 4}, 8, true, false) == std::vector<unsigned>{1, 2},
"offline mode leaves the Multiplayer tab out");
check(missing_tabs({1, 2, 3, 4}, 8, false) == std::vector<unsigned>{0},
"The Multiplayer page does not wait for the overlay's tool callbacks");
"The Multiplayer page does not wait for the overlay's tool callbacks; the Mod Manager waits for Mod Options");
MultiplayerModel model;
const BrowserOptions by_name{{}, false, Sort::name};
model.local_id = 77; model.map = "Levels/San_Vansterdam";
+55 -19
View File
@@ -25,7 +25,7 @@ using menu_view::page_count, menu_view::tools_page;
// back before our models are released. Native tabs are found by their label, not index.
constexpr std::string_view store_tab_label = "ID_MENU_STORE", social_tab_label = "ID_MENU_SOCIAL";
constexpr unsigned social_tab_index = 4; // Before the Store is removed.
constexpr std::array<unsigned, page_count> page_slots{0, 1};
constexpr std::array<unsigned, page_count> page_slots{0, 1, 2};
// The Store item taken out of the pause menu, its index there, and that menu.
struct RemovedStore { Value item; unsigned index{}; Handle core{}; Address manager{};std::uint32_t key{}; };
RemovedStore removed_store;
@@ -76,7 +76,7 @@ Address action(const Context& context, std::string command, std::string argument
const bool unused = *slot == count;
if (unused) {
static const std::array functions{callbacks<0>(std::make_index_sequence<max_actions>{}),
callbacks<1>(std::make_index_sequence<max_actions>{})};
callbacks<1>(std::make_index_sequence<max_actions>{}), callbacks<2>(std::make_index_sequence<max_actions>{})};
// Native void-action descriptor used by this build. Only the invokers are
// replaced; it is never added to the game's global function registry.
const auto prototype = context.base + addr::native_menu::action_prototype;
@@ -191,8 +191,9 @@ namespace {
// Which of ReSkate's pages is on screen, for the overlay (native_menu_page): when one was last
// seen selected with the pause menu up (GetTickCount64; 0: not now), and which.
std::atomic<std::uint64_t> page_seen{};
std::atomic<int> page_shown{}; // 0: Multiplayer, 1: Mod Options
std::atomic<int> page_shown{}; // 0: Multiplayer, 1: Mod Options, 2: Mod Manager
std::atomic<bool> game_in_menu{};
std::atomic<bool> back_wanted{}; // hold_native_menu_back
// Returns the key of the tab the pause menu has selected (0: none of ours can be told).
std::uint32_t restore_last_tab(const Context& context, Value menu) {
static std::uint32_t remembered{};
@@ -234,6 +235,21 @@ std::uint32_t restore_last_tab(const Context& context, Value menu) {
}
return key_at(selected);
}
void build_back_action(const Context& context, State& s);
// The Mod Manager's Back while a mod's page is open: active, with no input and no navigation, so it
// swallows Escape and B (see build_back_action) and the overlay takes them; given back otherwise.
void hold_back(const Context& context, State& s, bool held) {
if (!held) {
build_back_action(context, s);
return;
}
const auto base = context.path(s.page, {0x10810f0b, button_field, 0xa704272a, 0xc52416ef, 0xa704272a});
const auto back = context.field(base, 0xa2b71c93);
context.set(context.field(back, 0x3d00de27), Address{});
context.text(context.field(back, 0xb1e14cd6), "");
context.set(context.field(base, 0x4b3146e9), true);
s.back_held = true;
}
// Page Back is a button action: input UI_Back, navigation "ToggleMenu", no
// delegate, handled by the shared navigation handler. An active action with
// neither swallows Escape/controller B. Build it directly rather than copying
@@ -248,6 +264,7 @@ void build_back_action(const Context& context, State& s) {
context.set(context.field(back, 0x3d00de27), back_input);
context.text(context.field(back, 0xb1e14cd6), "ToggleMenu");
context.set(context.field(base, 0x4b3146e9), true);
s.back_held = false;
}
void insert(const Context& context) {
auto& s = state();
@@ -464,8 +481,8 @@ void initialize(const Context& context, Root root, const MultiplayerModel& model
context.set(context.field(backdrop_stack, 0x3b14d43c), false);
context.set(context.field(backdrop_stack, 0x2a98baa3), false);
build_back_action(context, s);
static constexpr std::array<const char*, page_count> titles{"MULTIPLAYER", "MOD OPTIONS"},
tab_names{"Multiplayer", "Mod Options"};
static constexpr std::array<const char*, page_count> titles{"MULTIPLAYER", "MOD OPTIONS", "MOD MANAGER"},
tab_names{"Multiplayer", "Mod Options", "Mod Manager"};
context.text(context.path(s.page, {0x3781b603, text_field}), titles[s.slot]);
context.set(context.field(s.page, 0xe1d821a3), true);
context.set(context.field(s.page, 0x616cb924), false);
@@ -512,28 +529,41 @@ void initialize(const Context& context, Root root, const MultiplayerModel& model
context.copy(context.field(s.menu_item, 0xd78d240b),
context.address(context.path(s.core, {content, 0x54c9abee})));
tab_label(context, s.menu_item, tab_names[s.slot], menu_key());
// Seed both states with an already-live native fallback: Social for
// Multiplayer, Hub for ReSkate. Optional art must never gate either page.
// Copy only visual fields; actions remain attached to our own models.
// Seed every state with an already-live native fallback: Social for
// Multiplayer, Hub for ReSkate, and the Store's own art for the Mod Manager
// (the Store tab ReSkate takes out; Hub's when it is gone already). Optional
// art must never gate a page. Copy only visual fields; actions remain
// attached to our own models.
const auto core_items = context.path(s.core, {content, items});
const auto source_tab = context.field(context.element(core_items, s.slot ? 1 : social_index(context, s.core)), tab_data);
Value source_item = context.element(core_items, s.slot ? 1 : social_index(context, s.core));
if (s.slot == menu_view::mods_page) {
unsigned core_count{}, core_stride{};
context.array(core_items, 16, core_count, core_stride);
if (const auto store = find_tab(context, core_items, core_count, store_tab_label)) source_item = context.element(core_items, *store);
else if (removed_store.manager == context.manager && removed_store.item.handle &&
context.type_of(removed_store.item.handle) == removed_store.item.type) source_item = removed_store.item;
}
const auto source_tab = context.field(source_item, tab_data);
const auto target_tab = context.field(s.menu_item, tab_data);
for (const auto hash : {0xd4f0f044U, 0xbc92d1faU, 0xa3c18619U})
context.copy(context.field(target_tab, hash), context.address(context.field(source_tab, hash)));
// Prefer the exact requested glyph, resolving it from the locked native
// image registry rather than searching neighboring widget allocations.
// (The Mod Manager asks for none: the Store's art is what it wants.)
static constexpr std::array<const char*, page_count> idle_icons{"UI/Textures/Icons/HUB/img_Icon_SocialDefault_512",
"UI/Textures/Skatepedia/img_Skatepedia_SkateTricks_Idle_1024"};
"UI/Textures/Skatepedia/img_Skatepedia_SkateTricks_Idle_1024", nullptr};
static constexpr std::array<const char*, page_count> focused_icons{"UI/Textures/Icons/HUB/img_Icon_SocialFocused_512",
"UI/Textures/Skatepedia/img_Skatepedia_SkateTricks_Focused_1024"};
"UI/Textures/Skatepedia/img_Skatepedia_SkateTricks_Focused_1024", nullptr};
const auto* idle = idle_icons[s.slot];
const auto* focused = focused_icons[s.slot];
bool requested_icon{};
try { requested_icon = publish_tab_icons(context, target_tab, idle, focused); }
catch (const std::exception&) { /* The authored fallback is already retained. */ }
if (!requested_icon) logging::printf(logging::Level::info, logging::Channel::ui,
"Native %s menu: using native fallback icons; optional pair unavailable: %s / %s",
tab_names[s.slot], idle, focused);
if (idle && focused) {
try { requested_icon = publish_tab_icons(context, target_tab, idle, focused); }
catch (const std::exception&) { /* The authored fallback is already retained. */ }
if (!requested_icon) logging::printf(logging::Level::info, logging::Channel::ui,
"Native %s menu: using native fallback icons; optional pair unavailable: %s / %s",
tab_names[s.slot], idle, focused);
}
render(context, model);
insert(context);
status(std::string("Native ") + tab_names[s.slot] + " menu: tab registered.");
@@ -594,8 +624,9 @@ void tick_page(std::uintptr_t base, bool loading) noexcept {
hide_top_bar_currency(context, active.model);
update_hub(context);
// Tell the overlay while this page is the one on screen: it draws the body.
const bool ours = shown_tab == our_key || shown_tab == our_key + tools_page * 0x100;
const auto& shown_page = page_state(shown_tab == our_key ? 0 : tools_page);
const bool ours = shown_tab >= our_key && shown_tab < our_key + page_count * 0x100 && (shown_tab - our_key) % 0x100 == 0;
const unsigned shown_slot = ours ? (shown_tab - our_key) / 0x100 : 0;
const auto& shown_page = page_state(shown_slot);
bool on_screen = ours && game_in_menu.load(std::memory_order_relaxed) && shown_page.page.handle &&
context.type_of(shown_page.page.handle) == shown_page.page.type;
// The game's side menu (pause pressed again) and what opens from it (Settings...) go
@@ -613,9 +644,12 @@ void tick_page(std::uintptr_t base, bool loading) noexcept {
} catch (...) {}
}
// (Said at once when another page takes its place, not left to go stale.)
page_shown.store(shown_tab == our_key ? 0 : 1, std::memory_order_relaxed);
page_shown.store(static_cast<int>(shown_slot), std::memory_order_relaxed);
page_seen.store(on_screen ? now : 0, std::memory_order_relaxed);
}
if (s.slot == menu_view::mods_page && active.model.handle == s.core.handle && s.page.handle &&
context.type_of(s.page.handle) == s.page.type && back_wanted.load(std::memory_order_relaxed) != s.back_held)
hold_back(context, s, !s.back_held);
bool pending{};
{ std::lock_guard pending_lock(s.mutex); pending = !s.pending.empty(); }
if (active.model.handle == s.core.handle && s.page.handle && now < s.next_render && !pending &&
@@ -750,6 +784,8 @@ overlay::HubPage native_menu_page() noexcept {
if (!seen || GetTickCount64() - seen > 200) return {};
return {true, page_shown.load(std::memory_order_relaxed)};
}
void hold_native_menu_back(bool held) noexcept { back_wanted.store(held, std::memory_order_relaxed); }
bool native_pause_menu_open() noexcept { return game_in_menu.load(std::memory_order_relaxed); }
void tick_native_menu(std::uintptr_t base, bool loading) noexcept {
const ui::OwnUiWrites own_writes; // Custom labels are not the native trick list.
run_pending_ui_dump(base);
+6 -1
View File
@@ -17,8 +17,13 @@ void native_menu_before_level_transition(std::uintptr_t base, unsigned next) noe
// Uses the same validated runtime queues as the overlay. The reader must be a
// snapshot reader; it must not consume the overlay's console log cursor.
void set_native_menu_callbacks(const overlay::CallbacksV3& callbacks);
// Whether one of ReSkate's pages (Multiplayer, Mod Options) is the pause menu's page on
// Whether one of ReSkate's pages (Multiplayer, Mod Options, Mod Manager) is the pause menu's page on
// screen right now, and which. Any thread: the overlay draws the page from it
// (overlay::HubPageFeed).
overlay::HubPage native_menu_page() noexcept;
// While held, the Mod Manager page's Back does nothing, so B and Escape stay the overlay's: they
// leave a mod's page for the list instead of closing the pause menu. Any thread.
void hold_native_menu_back(bool held) noexcept;
// Whether the game's own pause menu is up, on any of its pages. Any thread.
bool native_pause_menu_open() noexcept;
} // namespace dingosdk::multiplayer
@@ -82,6 +82,7 @@ struct State {
std::uint64_t voice_pending_until{};
bool public_lobby{true}, was_active{}, host_seeded{};
bool browsing{}; // the lobby browser was on screen at the last render
bool back_held{}; // its Back is held by the overlay (hold_native_menu_back)
std::uint64_t next_id = 1, next_scan{}, next_retry{}, next_update{}, next_render{}, owner{}, feedback_until{};
std::string feedback, feedback_status;
};
+6 -4
View File
@@ -12,17 +12,19 @@ enum class Sort { name, players, map };
// column sets reverse, which flips only that column.
struct BrowserOptions { std::string query; bool same_map{}; Sort sort = Sort::players; bool reverse{}; };
// Owned pages: Multiplayer and Mod Options (needs the overlay's tool callbacks).
inline constexpr unsigned page_count = 2, tools_page = 1;
// Owned pages: Multiplayer, Mod Options (needs the overlay's tool callbacks) and the Mod Manager
// (Extension/Assets/mod_store.h), which sits after Mod Options.
inline constexpr unsigned page_count = 3, tools_page = 1, mods_page = 2;
// Return missing owned slots in canonical order. Existing native stack indices
// remain untouched, including their active focus bindings. Offline mode has
// no Multiplayer tab.
// no Multiplayer tab. The Mod Manager waits for Mod Options, so that it is never
// appended before it.
inline std::vector<unsigned> missing_tabs(const std::vector<std::uint32_t>& keys,
std::uint32_t multiplayer_key, bool has_tools,
bool has_multiplayer = true) {
std::vector<unsigned> result;
for (unsigned slot = 0; slot < page_count; ++slot)
if ((has_tools || slot != tools_page) && (has_multiplayer || slot != 0) &&
if ((has_tools || (slot != tools_page && slot != mods_page)) && (has_multiplayer || slot != 0) &&
std::find(keys.begin(), keys.end(), multiplayer_key + slot * 0x100) == keys.end()) result.push_back(slot);
return result;
}
+124 -10
View File
@@ -3,9 +3,13 @@
#include "skate_menu_internal.h"
#include "cursor.h"
#include "input_capture.h"
#include "scripted_pad.h"
#include "Extension/UI/NativeMenu/native_menu.h"
#include "Extension/UI/NativeMenu/native_menu_view.h"
#include "Extension/UI/NativeMenu/native_tools_view.h"
#include "Extension/Boot/discord_presence.h"
#include "Extension/Assets/map_download.h"
#include "Extension/Assets/mod_store.h"
#include "Extension/Music/local_music_playback.h"
#include "Extension/Profile/local_profile_runtime.h"
#include "Engine/Game/Input/controller_bindings.h"
@@ -16,6 +20,7 @@
#include <algorithm>
#include <array>
#include <atomic>
#include <cctype>
#include <chrono>
#include <cmath>
#include <map>
@@ -1422,7 +1427,12 @@ void settings_page(Ui &ui, ImVec2 a, ImVec2 z) {
// ---------------------------------------------------------------- focus
// Moves the focus one control over, by where the controls were drawn last frame.
void move_focus(Hub &h, int dx, int dy) {
#include "mod_manager_page.inl"
// `in_list`: up and down from a control in a scrolling list stay in that list while it has anything
// that way (the Mod Manager's rows, whose next row up can be out of view and further than the bar
// over the list).
void move_focus(Hub &h, int dx, int dy, bool in_list = false) {
const Item *from{};
for (const auto &entry : h.last)
if (entry.id == h.focus) from = &entry;
@@ -1433,8 +1443,13 @@ void move_focus(Hub &h, int dx, int dy) {
const ImVec2 centre((from->a.x + from->b.x) * .5f, (from->a.y + from->b.y) * .5f);
const Item *best{};
float best_score = FLT_MAX;
const auto same_list = [&](const Item &entry) { return from->list && entry.list == from->list; };
bool list_only{};
if (in_list && dy && from->list)
for (const auto &entry : h.last)
if (entry.id != from->id && same_list(entry) && (dy > 0 ? entry.a.y - from->b.y : from->a.y - entry.b.y) >= -2) list_only = true;
for (const auto &entry : h.last) {
if (entry.id == from->id) continue;
if (entry.id == from->id || (list_only && !same_list(entry))) continue;
// Measured from the nearest point of the other control, so a wide row is as close as it looks.
const ImVec2 closest(std::clamp(centre.x, entry.a.x, entry.b.x), std::clamp(centre.y, entry.a.y, entry.b.y));
const float along = dx ? (closest.x - centre.x) * static_cast<float>(dx) : (closest.y - centre.y) * static_cast<float>(dy);
@@ -1467,12 +1482,20 @@ bool hub_page_pending() {
if (!h.page.visible) h.dismissed = false;
}
const bool shown = h.page.visible && !h.dismissed;
// The Mod Manager's changes that are not applied are asked about once the pause menu has closed
// (not when another of its pages comes up), if it was open this time.
if (auto &mods = mod_page(); mods.visited && !shown && !dingosdk::multiplayer::native_pause_menu_open()) {
mods.visited = false;
offer_apply(mods);
}
if (shown != h.shown) {
h.shown = shown;
h.next_model = {};
h.searched = false;
h.last.clear();
if (!shown) {
dingosdk::multiplayer::hold_native_menu_back(false);
if (!mod_page().leaving) mod_page().declined = false;
h.recording_bind = 0;
set_typing(h, 0);
wipe(h.password), wipe(h.code_password), wipe(h.host_password);
@@ -1578,9 +1601,18 @@ void hub_frame(Hub &h, ImDrawList *draw, float u, ImVec2 a, ImVec2 z, float tabs
if (ui.moved && !h.typing) h.ring = false;
h.focus_moved = false;
int previous_tab{}, next_tab{};
// On the Mod Manager, a mod's page holds the menu's Back (hold_native_menu_back): B and Escape go
// back to the list there, and keep holding it until they are let go.
auto &mods_page = mod_page();
const bool on_mods = !embedded && h.page.section == 2;
const bool back_down = keys[7] || (ui.pad.buttons & 0x2000);
if (ui.input && !typing_before) {
// Back closes the game's menu, which takes a moment to say so: this goes at once.
if (!embedded && (key_new(7) || (pressed & 0x2000))) h.dismissed = true;
if (!embedded && (key_new(7) || (pressed & 0x2000))) {
if (on_mods && mods_hold_back(mods_page)) mods_back(h, mods_page), mods_page.hold_back_until = GetTickCount64() + 300, h.sound = sound_select;
else if (on_mods && guard_leave(mods_page)) offer_leave(mods_page), h.sound = sound_select;
else if (!on_mods || GetTickCount64() >= mods_page.hold_back_until) h.dismissed = true;
}
const std::array<bool, 4> held{keys[0] || (ui.pad.buttons & 1), keys[1] || (ui.pad.buttons & 2), keys[2] || (ui.pad.buttons & 4), keys[3] || (ui.pad.buttons & 8)};
const std::array<bool, 4> fresh{key_new(0) || (pressed & 1), key_new(1) || (pressed & 2), key_new(2) || (pressed & 4), key_new(3) || (pressed & 8)};
const auto tick = GetTickCount64();
@@ -1590,9 +1622,14 @@ void hub_frame(Hub &h, ImDrawList *draw, float u, ImVec2 a, ImVec2 z, float tabs
if (direction < 0 && tick >= h.repeat_at)
for (int i = 0; i < 4; ++i)
if (held[static_cast<std::size_t>(i)]) direction = i, h.repeat_at = tick + (embedded ? 160 : 70);
if (direction >= 0) {
if (direction >= 0 && on_mods && h.ring && h.focus == featured_card && (direction == 2 || direction == 3)) {
// Left and right on the Mod Manager's carousel turn it, at a pace to see each card go by.
mods_page.carousel_turn = direction == 3 ? 1 : -1;
h.repeat_at = std::max(h.repeat_at, tick + 260);
h.sound = sound_navigate;
} else if (direction >= 0) {
// The first press only shows where the focus is.
if (h.ring || !h.focus) move_focus(h, direction == 2 ? -1 : direction == 3 ? 1 : 0, direction == 0 ? -1 : direction == 1 ? 1 : 0);
if (h.ring || !h.focus) move_focus(h, direction == 2 ? -1 : direction == 3 ? 1 : 0, direction == 0 ? -1 : direction == 1 ? 1 : 0, on_mods);
h.ring = true;
h.focus_moved = true;
}
@@ -1602,6 +1639,26 @@ void hub_frame(Hub &h, ImDrawList *draw, float u, ImVec2 a, ImVec2 z, float tabs
previous_tab = key_new(5) || (pressed & 0x10000);
next_tab = key_new(6) || (pressed & 0x20000);
}
if (back_down && GetTickCount64() < mods_page.hold_back_until) mods_page.hold_back_until = GetTickCount64() + 300;
// The answer to leaving with changes not applied: LATER (or an apply that reloads nothing) lets go
// of Back, and a moment later presses it for the player, on the pad only the game reads.
if (const int answer = mods_page.leave_answer.exchange(0)) {
mods_page.leaving = false;
mods_page.declined = true;
mods_page.visited = false; // (asked already)
const bool reloads = answer == 1 && mods_page.mods && mods_page.mods->effect != live_mods::ApplyEffect::maps_only;
if (!reloads) mods_page.close_at = GetTickCount64() + 250;
}
if (mods_page.close_at && GetTickCount64() >= mods_page.close_at) {
mods_page.close_at = 0;
if (dingosdk::multiplayer::native_pause_menu_open()) {
XINPUT_GAMEPAD back{}, neutral{};
back.wButtons = XINPUT_GAMEPAD_B;
overlay::play_pad({{0, back}, {6, neutral}, {12, neutral}});
}
}
if (!embedded)
dingosdk::multiplayer::hold_native_menu_back(on_mods && (mods_hold_back(mods_page) || guard_leave(mods_page) || GetTickCount64() < mods_page.hold_back_until));
h.keys_before = keys;
// A key that ended the typing (Enter, Escape) is still down: it is not a fresh press of anything.
// And so is one held while the game was not the window in front.
@@ -1611,12 +1668,15 @@ void hub_frame(Hub &h, ImDrawList *draw, float u, ImVec2 a, ImVec2 z, float tabs
// ---- the page's tabs
const bool tools = !embedded && h.page.section == 1;
const bool mods = !embedded && h.page.section == 2;
{
// (Each page keeps the tab it was left on.)
std::vector<std::pair<int, const char *>> tabs;
int multiplayer_tab = static_cast<int>(h.tab);
int &open = tools ? h.tools_tab : multiplayer_tab;
if (tools) {
int &open = tools ? h.tools_tab : mods ? mod_page().tab : multiplayer_tab;
if (mods) {
tabs = {{browse_tab, "BROWSE"}, {installed_tab, "MY MODS"}, {updates_tab, "UPDATES"}};
} else if (tools) {
if (h.tools_tab == custom_tab) h.tools_tab = official_tab;
tabs = {{official_tab, "MAPS"}, {world_tab, "WORLD"}, {parks_tab, "PARKS"},
{player_tab, "PLAYER"}, {online_tab, "ONLINE"}, {visuals_tab, "VISUALS"}, {binds_tab, "BINDS"}};
@@ -1657,14 +1717,15 @@ void hub_frame(Hub &h, ImDrawList *draw, float u, ImVec2 a, ImVec2 z, float tabs
x = q.x + 20 * u;
}
cap(0x20000, "C");
if (!tools) h.tab = static_cast<Tab>(multiplayer_tab);
if (!tools && !mods) h.tab = static_cast<Tab>(multiplayer_tab);
}
// Under the connecting card the page is still drawn, to look at and not to use.
const bool page_input = ui.input, page_clicked = ui.clicked, page_doubled = ui.doubled;
const bool failed = !connecting && !h.failed.empty() && !in_session;
if ((connecting || failed) && !tools) ui.input = ui.clicked = ui.doubled = false;
if ((connecting || failed) && !tools && !mods) ui.input = ui.clicked = ui.doubled = false;
if (tools) tools_page(ui, a, z);
else if (mods) mod_manager_page(ui, a, z);
else switch (h.tab) {
case Tab::browser: browser_page(ui, a, z); break;
case Tab::host: host_page(ui, a, z); break;
@@ -1672,7 +1733,7 @@ void hub_frame(Hub &h, ImDrawList *draw, float u, ImVec2 a, ImVec2 z, float tabs
case Tab::voice: voice_page(ui, a, z); break;
case Tab::settings: settings_page(ui, a, z); break;
}
if ((connecting || failed) && !tools) {
if ((connecting || failed) && !tools && !mods) {
ui.input = page_input, ui.clicked = page_clicked, ui.doubled = page_doubled;
h.items.clear(); // nothing of the page takes the focus: only the card's way out
if (connecting) connecting_page(ui, a, z);
@@ -1751,4 +1812,57 @@ void draw_online_embedded(ImDrawList *draw, ImVec2 a, ImVec2 z, const Model &mod
hub_frame(h, draw, u, ImVec2(a.x, a.y + (tabs_height + 64) * u), z, a.y, input, true);
}
bool online_embedded_typing() { return hub().typing != 0; }
// Mods being applied with the pause menu closed (most often after the Mod Manager's question): a
// small card at the right edge, out of the way of skating, as the map download's is, then a few
// seconds of how it went.
namespace {
bool apply_card_seen{};
ULONGLONG apply_card_until{};
std::string apply_card_result;
}
bool mod_apply_card_pending() {
const bool applying = live_mods::busy() && dingosdk::map_download::view().stage == dingosdk::map_download::Stage::idle;
const bool menu = hub().shown || dingosdk::multiplayer::native_pause_menu_open();
if (applying && !menu) apply_card_seen = true, apply_card_until = 0;
else if (apply_card_seen && !applying) {
apply_card_seen = false;
apply_card_until = GetTickCount64() + 4000;
apply_card_result = live_mods::status();
}
return (applying && !menu) || GetTickCount64() < apply_card_until;
}
void draw_mod_apply_card() {
auto &s = state();
auto *bold = s.menu.bold ? s.menu.bold : ImGui::GetFont();
auto *body = s.menu.body ? s.menu.body : ImGui::GetFont();
const auto display = ImGui::GetIO().DisplaySize;
const float u = std::min(display.x / layout_width, display.y / layout_height);
const bool applying = live_mods::busy();
const auto progress = live_mods::progress();
const float width = 820 * u, inset = 30 * u, bar = 12 * u;
const float height = inset + 24 * u + 12 * u + 38 * u + 18 * u + bar + 14 * u + 26 * u + inset;
const ImVec2 min(std::floor(display.x - 48 * u - width), std::floor(display.y * 0.2f)), max(min.x + width, min.y + height);
auto *draw = ImGui::GetForegroundDrawList();
skate_theme::rough_rect(draw, min, max, IM_COL32(26, 26, 26, 235), 0x4d3, u * 2);
draw->AddRectFilled(min, ImVec2(min.x + 8 * u, max.y), applying ? theme::blue : skate_theme::good);
const float left = min.x + inset + 8 * u, right = max.x - inset;
float y = min.y + inset;
draw->AddText(bold, 24 * u, ImVec2(left, y), theme::blue, "MOD MANAGER");
y += 36 * u;
draw->AddText(bold, 38 * u, ImVec2(left, y), theme::paper, applying ? "APPLYING MODS" : "MODS APPLIED");
y += 56 * u;
const float fraction = !applying ? 1.f : progress.total ? static_cast<float>(progress.done) / static_cast<float>(progress.total) : -1.f;
draw->AddRectFilled(ImVec2(left, y), ImVec2(right, y + bar), skate_theme::tile_light, 6 * u);
if (fraction >= 0) draw->AddRectFilled(ImVec2(left, y), ImVec2(left + (right - left) * std::clamp(fraction, 0.f, 1.f), y + bar), applying ? theme::blue : skate_theme::good, 6 * u);
else {
const float t = static_cast<float>(std::fmod(ImGui::GetTime(), 2.0)) / 2.f, w = (right - left) * .25f;
const float x = left + (right - left - w) * (t < .5f ? t * 2 : 2 - t * 2);
draw->AddRectFilled(ImVec2(x, y), ImVec2(x + w, y + bar), theme::blue, 6 * u);
}
y += bar + 14 * u;
const std::string line = applying ? (progress.step.empty() ? std::string("Merging the mods into the game...") : progress.step)
: (apply_card_result.empty() ? std::string("Done.") : apply_card_result);
label(draw, body, 26 * u, ImVec2(left, y), right, theme::muted, line);
}
} // namespace dingosdk::overlay::detail
+101 -2
View File
@@ -1,6 +1,7 @@
#include "overlay_internal.h"
#include "Engine/Game/Input/controller_bindings.h"
#include <cmath>
#include <functional>
#include <mutex>
#include <string>
@@ -15,6 +16,14 @@ namespace {
std::mutex mutex;
std::string title_text, body_text; // under the mutex
std::atomic<bool> up{};
// The question (ask_question): what it says and its answers, under the mutex; whether it is up and
// which answer is picked.
struct Question {
std::string label, title, text, yes, no;
std::function<void(bool)> answer;
};
Question question; // under the mutex
std::atomic<bool> question_up{}, question_yes{true};
}
void show_message(std::string title, std::string text) noexcept {
@@ -27,12 +36,102 @@ void show_message(std::string title, std::string text) noexcept {
}
bool message_asking() noexcept { return up.load(); }
void message_dismiss() noexcept { up.store(false); }
void ask_question(std::string label, std::string title, std::string text, std::string yes, std::string no,
std::function<void(bool)> answer) noexcept {
try {
std::lock_guard lock(mutex);
question = {std::move(label), std::move(title), std::move(text), std::move(yes), std::move(no), std::move(answer)};
question_yes.store(true);
question_up.store(true);
} catch (...) {}
}
bool question_asking() noexcept { return question_up.load(); }
void question_pick(bool yes) noexcept { question_yes.store(yes); }
bool question_picked() noexcept { return question_yes.load(); }
void question_answer(bool yes) noexcept {
std::function<void(bool)> answer;
{
std::lock_guard lock(mutex);
if (!question_up.exchange(false)) return;
answer = std::move(question.answer);
question.answer = nullptr;
}
try { if (answer) answer(yes); } catch (...) {}
}
} // namespace dingosdk::overlay
namespace dingosdk::overlay::detail {
bool message_pending() { return up.load(); }
bool message_pending() { return up.load() || question_up.load(); }
namespace {
// The question (ask_question), drawn as the message is, with its two answers side by side: the
// picked one blue, as the map download's card does it.
void draw_question() {
Question shown;
{
std::lock_guard lock(mutex);
shown = {question.label, question.title, question.text, question.yes, question.no, nullptr};
}
auto& s = state();
auto* bold = s.menu.bold ? s.menu.bold : ImGui::GetFont();
auto* body = s.menu.body ? s.menu.body : ImGui::GetFont();
auto* heading = s.menu.heading ? s.menu.heading : bold;
const auto display = ImGui::GetIO().DisplaySize;
const float u = std::min(display.x / 3840.0f, display.y / 2160.0f);
const auto width_of = [](ImFont* font, float size, const std::string& what) { return font->CalcTextSizeA(size, FLT_MAX, 0, what.c_str()).x; };
dingosdk::ControllerInput pad;
DingoSDKOverlayReadControllerInput(&pad, true);
const float key_scale = u * 2;
const float inset = 56 * u, label_size = 28 * u, title_size = 64 * u, text_size = 38 * u;
const float width = std::min(1600 * u, display.x - 200 * u), wrap = width - inset * 2;
const float text_height = body->CalcTextSizeA(text_size, FLT_MAX, wrap, shown.text.c_str()).y;
const float button_width = 520 * u, button_height = 96 * u, between = 30 * u;
const float height = inset + label_size + 20 * u + title_size + 34 * u + text_height + 48 * u + button_height + inset;
const float under = 34 * u + 20 * key_scale;
const ImVec2 min(std::floor((display.x - width) * 0.5f), std::floor((display.y - height - under) * 0.5f)), max(min.x + width, min.y + height);
auto* draw = ImGui::GetForegroundDrawList();
draw->AddRectFilled(ImVec2(0, 0), display, IM_COL32(0, 0, 0, 170));
skate_theme::rough_rect(draw, min, max, IM_COL32(26, 26, 26, 250), 0x5e3, u * 2);
draw->AddRectFilled(min, ImVec2(min.x + 10 * u, max.y), theme::blue);
const float left = min.x + inset;
float y = min.y + inset;
draw->AddText(bold, label_size, ImVec2(left, y), theme::blue, shown.label.c_str());
y += label_size + 20 * u;
draw->AddText(heading, title_size, ImVec2(left, y), theme::paper, shown.title.c_str());
y += title_size + 34 * u;
draw->AddText(body, text_size, ImVec2(left, y), theme::paper, shown.text.c_str(), nullptr, wrap);
y += text_height + 48 * u;
// The two answers: the picked one blue. Pointing at one picks it, a click answers with it.
const float row = button_width * 2 + between;
const ImVec2 yes_min(std::floor(min.x + (width - row) * 0.5f), y), yes_max(yes_min.x + button_width, y + button_height);
const ImVec2 no_min(yes_max.x + between, y), no_max(no_min.x + button_width, y + button_height);
const bool over_yes = ImGui::IsMouseHoveringRect(yes_min, yes_max, false), over_no = ImGui::IsMouseHoveringRect(no_min, no_max, false);
if (over_yes) question_pick(true);
else if (over_no) question_pick(false);
const bool yes = question_picked();
const float size = 40 * u;
const auto answer_button = [&](ImVec2 a, ImVec2 b, const std::string& text, bool picked, unsigned seed) {
skate_theme::rough_rect(draw, a, b, picked ? theme::blue : skate_theme::tile_light, seed, u * 2);
draw->AddText(bold, size, ImVec2(a.x + (button_width - width_of(bold, size, text)) * 0.5f, a.y + (button_height - size) * 0.5f),
picked ? skate_theme::black : theme::paper, text.c_str());
};
answer_button(yes_min, yes_max, shown.yes, yes, 0x5e4);
answer_button(no_min, no_max, shown.no, !yes, 0x5e5);
const auto take = pad.available ? dingosdk::controller_combo_label(0x1000, pad.style) : std::string("Enter");
const auto back = pad.available ? dingosdk::controller_combo_label(0x2000, pad.style) : std::string("Esc");
const float hints = width_of(bold, 12 * key_scale, take) + width_of(bold, 14 * key_scale, "Choose") + width_of(bold, 12 * key_scale, back) +
width_of(bold, 14 * key_scale, shown.no) + 80 * key_scale;
float x = min.x + (width - hints) * 0.5f;
x += theme::keycap(draw, bold, key_scale, ImVec2(x, max.y + 34 * u), take.c_str(), "Choose") + 40 * key_scale;
theme::keycap(draw, bold, key_scale, ImVec2(x, max.y + 34 * u), back.c_str(), shown.no.c_str());
if ((over_yes || over_no) && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) question_answer(over_yes);
}
}
void draw_message() {
if (!up.load()) return;
if (!up.load()) {
if (question_up.load()) draw_question();
return;
}
std::string title, text;
{
std::lock_guard lock(mutex);
File diff suppressed because it is too large. Load diff
+12 -1
View File
@@ -1,5 +1,6 @@
#pragma once
#include "Engine/Game/World/park_rotation.h"
#include <functional>
#include "Engine/Game/World/park_editor.h"
#include "Engine/Game/World/world_layers.h"
#include "Engine/Game/World/world_controls.h"
@@ -452,6 +453,16 @@ void set_menu_map_pictures(const std::vector<MapVoteIcon>& pictures) noexcept;
void show_message(std::string title, std::string text) noexcept;
bool message_asking() noexcept;
void message_dismiss() noexcept;
// A question with two answers, in the message's look (message_card.cpp), held and answered by the
// runtime as the message is: left and right pick, A takes the pick, B is the second answer. `label`
// says what is asking ("MOD MANAGER"); `answer` is called once, with true for the first answer, on
// whichever thread answers it. A message to say OK to goes over it. Any thread.
void ask_question(std::string label, std::string title, std::string text, std::string yes, std::string no,
std::function<void(bool)> answer) noexcept;
bool question_asking() noexcept;
void question_pick(bool yes) noexcept;
bool question_picked() noexcept;
void question_answer(bool yes) noexcept;
bool map_vote_asking() noexcept;
void map_vote_move(int by) noexcept;
void map_vote_take() noexcept;
@@ -460,7 +471,7 @@ void map_vote_dismiss() noexcept;
using ChatFeed = MultiplayerChat (*)();
void set_chat_feed(ChatFeed) noexcept;
// The game's pause menu, as far as ReSkate's own pages in it go: whether one of them is the
// page on screen, and which (`section` 0: Multiplayer, 1: Mod Options). The overlay draws
// page on screen, and which (`section` 0: Multiplayer, 1: Mod Options, 2: Mod Manager). The overlay draws
// everything in such a page itself, its tabs too (hub_page.cpp). Read every presented frame
// (thread-safe, cheap).
struct HubPage {
+18
View File
@@ -192,6 +192,8 @@ struct State {
ComPtr<ID3D12Resource> mod_icon_texture, mod_icon_upload;
// Plugins' pictures (plugin_images.cpp), from the fifth slot on.
std::array<ComPtr<ID3D12Resource>, 16> plugin_textures, plugin_uploads;
// Thunderstore icons for the Mod Manager page (store_icons.cpp), after the screen look's slots.
std::array<ComPtr<ID3D12Resource>, 64> store_textures, store_uploads; // store_icon_slots
ComPtr<ID3D12GraphicsCommandList> commands;
ComPtr<ID3D12Fence> fence;
HANDLE fence_event = nullptr;
@@ -358,6 +360,22 @@ std::optional<PluginImageUpload> take_plugin_image();
// id 0: it failed; `later`: the slot was busy, so it waits for another frame.
void plugin_image_uploaded(const std::string& path, int slot, ImTextureID id, bool later = false);
void plugin_images_lost();
// Thunderstore packages' icons for the pause menu's Mod Manager page (store_icons.cpp), the same way,
// by their URL, in the SRV heap's slots from store_icon_first_slot on.
inline constexpr UINT store_icon_first_slot = screen_look_slot + screen_look_slots, store_icon_slots = 64;
ImTextureID store_icon(const std::string& url);
struct StoreIconUpload {
std::string url;
int slot{};
CardPixels pixels;
};
std::optional<StoreIconUpload> take_store_icon();
void store_icon_uploaded(const std::string& url, int slot, ImTextureID id, bool later = false);
void store_icons_lost();
// Mods being applied with the pause menu closed (hub_page.cpp): whether its card is up, and drawing it.
bool mod_apply_card_pending();
void draw_mod_apply_card();
static_assert(std::tuple_size_v<decltype(State::store_textures)> == store_icon_slots);
void mod_icon_uploaded(ImTextureID texture, unsigned serial);
void mod_icon_lost();
// Game debug text (game_text_overlay.cpp): polled every presented frame.
+23 -3
View File
@@ -91,6 +91,9 @@ void destroy_graphics() {
for (auto& texture : s.plugin_textures) texture.Reset();
for (auto& upload : s.plugin_uploads) upload.Reset();
plugin_images_lost();
for (auto& texture : s.store_textures) texture.Reset();
for (auto& upload : s.store_uploads) upload.Reset();
store_icons_lost();
screen_look::release();
s.commands.Reset(); s.rtvs.Reset(); s.srvs.Reset(); s.fence.Reset(); s.device.Reset();
if (s.fence_event) CloseHandle(s.fence_event);
@@ -300,8 +303,8 @@ bool setup_graphics() {
}
heap.Type = D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV;
// ImGui's atlas, the map download card's icon, the menu's map pictures, an item's mod icon,
// plugins' pictures (upload_image), and the screen look's
heap.NumDescriptors = screen_look_slot + screen_look_slots;
// plugins' pictures (upload_image), the screen look's, and the Mod Manager's icons
heap.NumDescriptors = store_icon_first_slot + store_icon_slots;
heap.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE;
graphics_result = s.device->CreateDescriptorHeap(&heap, IID_PPV_ARGS(&s.srvs));
if (FAILED(graphics_result)) {
@@ -724,6 +727,20 @@ void upload_plugin_image(State& s) {
plugin_image_uploaded(next->path, next->slot, id);
}
// A Thunderstore icon for the Mod Manager page (store_icons.cpp), one a frame, into the slot it was given.
void upload_store_icon(State& s) {
if (!s.device || !s.srvs) return;
auto next = take_store_icon();
if (!next) return;
const auto slot = static_cast<std::size_t>(next->slot);
if (s.store_textures[slot] && s.next_fence && !completed(s.next_fence, render_fence_timeout_ms)) {
store_icon_uploaded(next->url, next->slot, {}, true);
return;
}
const auto id = upload_image(s, next->pixels, store_icon_first_slot + static_cast<UINT>(slot), s.store_textures[slot], s.store_uploads[slot]);
store_icon_uploaded(next->url, next->slot, id);
}
void render(IDXGISwapChain* presented, UINT flags) {
auto& s = state();
if (flags & DXGI_PRESENT_TEST) return;
@@ -766,6 +783,7 @@ void render(IDXGISwapChain* presented, UINT flags) {
// Refreshes the chat feed every frame, which also decides whether T opens it.
const bool chat_frame = chat_pending();
const bool map_download_frame = map_download_pending();
const bool mod_apply_frame = mod_apply_card_pending();
const bool game_text_frame = game_text_pending();
const bool skate_hud_frame = skate_hud_pending();
const bool nametag_frame = nametags_pending();
@@ -794,7 +812,7 @@ void render(IDXGISwapChain* presented, UINT flags) {
}
// Hidden, the overlay still draws while a notice or chat line is on screen.
if (s.loaded_notice_posted && !notices_pending() && !chat_frame && !game_text_frame && !skate_hud_frame &&
!nametag_frame && !plugins_frame && !item_browser_frame && !perf_frame && !hub_frame && !map_download_frame && !look_frame &&
!nametag_frame && !plugins_frame && !item_browser_frame && !perf_frame && !hub_frame && !map_download_frame && !mod_apply_frame && !look_frame &&
!grab_frame) return;
} else if (!s.ui_was_interactive) {
s.ui_was_interactive = true;
@@ -867,6 +885,7 @@ void render(IDXGISwapChain* presented, UINT flags) {
draw_item_browser();
draw_plugin_dialog();
draw_chat();
if (mod_apply_frame) draw_mod_apply_card();
draw_map_download();
sync_menu_cursor(); // close buttons also change visibility, without a key message
ImGui::Render();
@@ -890,6 +909,7 @@ void render(IDXGISwapChain* presented, UINT flags) {
upload_menu_map_image(s);
upload_mod_icon(s);
upload_plugin_image(s);
upload_store_icon(s);
if (look_frame) {
// Drawn first, so ImGui draws over it. It leaves the back buffer in PRESENT, as it found it.
ID3D12DescriptorHeap* look_heaps[] = {s.srvs.Get()};
+229
View File
@@ -0,0 +1,229 @@
#include "overlay_internal.h"
#include "Engine/Core/Log/logging.h"
#include "Engine/Vfs/https_download.h"
#include <algorithm>
#include <array>
#include <chrono>
#include <condition_variable>
#include <deque>
#include <filesystem>
#include <fstream>
#include <map>
#include <mutex>
#include <thread>
// Thunderstore packages' icons for the Mod Manager page (mod_manager_page.inl), by their URL. They
// are kept on disk where the launcher keeps them (%LOCALAPPDATA%\ReSkate\thunderstore\icons, named
// as gui_mods_browse.cpp names them), so an icon either of them fetched is not fetched again. Three
// worker threads read, fetch and decode them (WIC, 256 pixels square), the one asked for last first:
// it is what the player is looking at now. The render thread uploads one a frame into the SRV heap's
// store icon slots, and an icon not drawn for 2 s gives its slot up when another needs it. Only the
// tiles on screen ask (mod_manager_page.inl), so the slots are enough for a full page of them.
namespace dingosdk::overlay::detail {
namespace {
namespace fs = std::filesystem;
constexpr std::size_t most_icons = 192; // known at once, decoded or waiting
constexpr std::size_t workers = 3;
constexpr double retry_seconds = 20; // an icon that could not be had is tried again after this
constexpr std::uint64_t most_icon_bytes = 4 * 1024 * 1024;
constexpr double idle_seconds = 2;
struct Entry {
enum class Stage { waiting, decoded, uploaded, failed } stage{Stage::waiting};
CardPixels pixels;
int slot{-1};
ImTextureID id{};
double drawn{}, failed{};
};
struct Cache {
std::mutex mutex;
std::condition_variable wake;
std::map<std::string, Entry, std::less<>> entries;
std::deque<std::string> queue;
std::size_t started{};
};
Cache& cache() {
static auto* value = new Cache;
return *value;
}
double seconds_now() {
return std::chrono::duration<double>(std::chrono::steady_clock::now().time_since_epoch()).count();
}
fs::path icon_folder() {
std::array<wchar_t, 32768> local{};
const auto length = GetEnvironmentVariableW(L"LOCALAPPDATA", local.data(), static_cast<DWORD>(local.size()));
if (!length || length >= local.size()) return {};
return fs::path(local.data()) / L"ReSkate" / L"thunderstore" / L"icons";
}
// Icon URLs end in Namespace-Name-1.2.3.png, so a file named after that never goes stale.
std::wstring icon_file(std::string_view url) {
const auto slash = url.rfind('/');
std::string name(url.substr(slash == std::string_view::npos ? 0 : slash + 1));
for (auto& ch : name)
if (!((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '.' || ch == '-' || ch == '_'))
ch = '_';
if (name.empty() || name.front() == '.') name.insert(name.begin(), 'i');
return std::wstring(name.begin(), name.end());
}
// Only Thunderstore's own hosts: a listing names its icons, and nothing else is fetched from here.
bool thunderstore_host(std::string_view url) {
constexpr std::string_view scheme = "https://";
if (!url.starts_with(scheme)) return false;
auto host = url.substr(scheme.size());
host = host.substr(0, host.find('/'));
return host == "thunderstore.io" || host == "gcdn.thunderstore.io" || host.ends_with(".thunderstore.io");
}
void decode_loop() {
auto& c = cache();
const auto folder = icon_folder();
for (;;) {
std::string url;
{
std::unique_lock lock(c.mutex);
c.wake.wait(lock, [&] { return !c.queue.empty(); });
url = std::move(c.queue.back());
c.queue.pop_back();
// Asked for long enough ago that it went out of view again: let it wait for the next ask.
const auto found = c.entries.find(url);
if (found == c.entries.end()) continue;
if (seconds_now() - found->second.drawn > idle_seconds) {
c.entries.erase(found);
continue;
}
}
CardPixels pixels;
bool ok{};
try {
const auto cached = folder.empty() ? fs::path() : folder / icon_file(url);
std::string bytes;
std::error_code error;
if (!cached.empty() && fs::is_regular_file(cached, error) && fs::file_size(cached, error) <= most_icon_bytes) {
std::ifstream in(cached, std::ios::binary);
bytes.assign(std::istreambuf_iterator<char>(in), std::istreambuf_iterator<char>());
}
if (bytes.empty() && thunderstore_host(url)) {
if (auto body = https::get_text(std::wstring(url.begin(), url.end()), most_icon_bytes, 20, L"ReSkate")) {
bytes = std::move(*body);
if (!cached.empty() && !bytes.empty()) {
fs::create_directories(folder, error);
std::ofstream out(cached, std::ios::binary | std::ios::trunc);
out.write(bytes.data(), static_cast<std::streamsize>(bytes.size()));
}
}
}
ok = !bytes.empty() && decode_picture(bytes, pixels, 256);
} catch (...) {
ok = false;
}
std::lock_guard lock(c.mutex);
const auto found = c.entries.find(url);
if (found == c.entries.end()) continue;
if (!ok) found->second.stage = Entry::Stage::failed, found->second.failed = seconds_now();
else {
found->second.pixels = std::move(pixels);
found->second.stage = Entry::Stage::decoded;
}
}
}
}
ImTextureID store_icon(const std::string& url) {
if (url.empty()) return {};
auto& c = cache();
std::lock_guard lock(c.mutex);
const double now = seconds_now();
auto found = c.entries.find(url);
if (found == c.entries.end()) {
if (c.entries.size() >= most_icons) {
// Make room: an icon that failed long enough ago, or the one drawn longest ago and idle.
auto oldest = c.entries.end();
for (auto it = c.entries.begin(); it != c.entries.end(); ++it)
if (it->second.slot < 0 && it->second.stage != Entry::Stage::waiting && now - it->second.drawn >= idle_seconds &&
(oldest == c.entries.end() || it->second.drawn < oldest->second.drawn))
oldest = it;
if (oldest == c.entries.end()) return {};
c.entries.erase(oldest);
}
found = c.entries.emplace(url, Entry{}).first;
c.queue.push_back(url);
if (c.started < workers && c.queue.size() > c.started) {
try { std::thread(decode_loop).detach(); ++c.started; } catch (...) {}
}
c.wake.notify_one();
} else if (found->second.stage == Entry::Stage::failed && now - found->second.failed >= retry_seconds) {
found->second.stage = Entry::Stage::waiting;
c.queue.push_back(url);
c.wake.notify_one();
}
auto& entry = found->second;
entry.drawn = now;
return entry.stage == Entry::Stage::uploaded ? entry.id : ImTextureID{};
}
std::optional<StoreIconUpload> take_store_icon() {
auto& c = cache();
std::lock_guard lock(c.mutex);
// The most recently asked for first: what is on screen now.
auto waiting = c.entries.end();
for (auto it = c.entries.begin(); it != c.entries.end(); ++it)
if (it->second.stage == Entry::Stage::decoded && (waiting == c.entries.end() || it->second.drawn > waiting->second.drawn)) waiting = it;
if (waiting == c.entries.end()) return std::nullopt;
std::array<bool, store_icon_slots> used{};
for (const auto& [url, entry] : c.entries)
if (entry.slot >= 0) used[static_cast<std::size_t>(entry.slot)] = true;
int slot = -1;
for (int i = 0; i < static_cast<int>(store_icon_slots); ++i)
if (!used[static_cast<std::size_t>(i)]) {
slot = i;
break;
}
if (slot < 0) {
// Every slot is in use: the icon drawn longest ago gives it up, if it is idle. Its pixels
// are gone, so drawn again it is read again.
const double now = seconds_now();
auto oldest = c.entries.end();
for (auto it = c.entries.begin(); it != c.entries.end(); ++it)
if (it->second.slot >= 0 && now - it->second.drawn >= idle_seconds &&
(oldest == c.entries.end() || it->second.drawn < oldest->second.drawn))
oldest = it;
if (oldest == c.entries.end()) return std::nullopt;
slot = oldest->second.slot;
c.entries.erase(oldest);
}
waiting->second.slot = slot;
waiting->second.stage = Entry::Stage::waiting; // until store_icon_uploaded
return StoreIconUpload{waiting->first, slot, waiting->second.pixels};
}
void store_icon_uploaded(const std::string& url, int slot, ImTextureID id, bool later) {
auto& c = cache();
std::lock_guard lock(c.mutex);
const auto found = c.entries.find(url);
if (found == c.entries.end() || found->second.slot != slot) return;
auto& entry = found->second;
if (later) {
entry.stage = Entry::Stage::decoded;
entry.slot = -1;
return;
}
if (!id) {
entry.stage = Entry::Stage::failed;
entry.slot = -1;
return;
}
entry.stage = Entry::Stage::uploaded;
entry.id = id;
entry.pixels.rgba.clear();
entry.pixels.rgba.shrink_to_fit();
}
void store_icons_lost() {
auto& c = cache();
std::lock_guard lock(c.mutex);
// Drawn again, they are read again; a read under way finds its entry gone and is dropped.
for (auto it = c.entries.begin(); it != c.entries.end();)
it = it->second.stage == Entry::Stage::waiting && it->second.slot < 0 ? std::next(it) : c.entries.erase(it);
}
}
+43
View File
@@ -548,6 +548,49 @@ int main() {
"The game's shader cache is ReSkate's own, under Game/Skate");
}
// Changes the game cannot make while it holds a mod's files: they wait for the next start.
{
const auto waiting_mods = root / L"waiting" / L"Mods";
make_zip(sources / L"held-v1.zip", {{"Held Mod/layout.toc", "v1"}});
make_zip(sources / L"held-v2.zip", {{"Held Mod/layout.toc", "v2"}});
check(install(waiting_mods, sources / L"held-v1.zip") == "Held Mod", "pending: the first copy installs");
auto held = CreateFileW((waiting_mods / L"Held Mod" / L"layout.toc").c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr,
OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
check(!error_of([&] { install(waiting_mods, sources / L"held-v2.zip", true); }).empty(), "pending: a copy in use cannot be replaced now");
std::atomic<bool> cancel{};
check(launcher_mods::install_later(waiting_mods, sources / L"held-v2.zip", {}, cancel) == "Held Mod", "pending: the update is kept for later");
auto waiting = launcher_mods::pending(waiting_mods);
check(waiting.updates == std::vector<std::string>{"Held Mod"} && waiting.removals.empty(), "pending: the update is what waits");
check(read(waiting_mods / L"Held Mod" / L"layout.toc") == "v1", "pending: the copy in use is untouched");
check(dingosdk::mods::scan_mods(waiting_mods.parent_path()).entries.size() == 1, "pending: the waiting copy is not a mod of its own");
const auto still_held = launcher_mods::apply_pending(waiting_mods);
check(still_held.size() == 1 && still_held.front().find("waits") != std::string::npos &&
read(waiting_mods / L"Held Mod" / L"layout.toc") == "v1" && launcher_mods::pending(waiting_mods).updates.size() == 1,
"pending: while the game still holds it, the update keeps waiting and the old copy stays");
CloseHandle(held);
const auto applied = launcher_mods::apply_pending(waiting_mods);
check(applied.size() == 1 && read(waiting_mods / L"Held Mod" / L"layout.toc") == "v2", "pending: at the next start the update goes in");
check(!fs::exists(waiting_mods / launcher_mods::pending_folder), "pending: nothing is left behind once it is all done");
check(launcher_mods::apply_pending(waiting_mods).empty(), "pending: nothing waiting is nothing to do");
launcher_mods::remove_later(waiting_mods, "Held Mod");
launcher_mods::remove_later(waiting_mods, "Held Mod");
waiting = launcher_mods::pending(waiting_mods);
check(waiting.removals == std::vector<std::string>{"Held Mod"} && waiting.updates.empty(), "pending: an uninstall waits, once");
check(launcher_mods::install_later(waiting_mods, sources / L"held-v1.zip", {}, cancel) == "Held Mod" &&
launcher_mods::pending(waiting_mods).removals.empty(),
"pending: installing it again takes back the uninstall");
launcher_mods::remove_later(waiting_mods, "Held Mod");
waiting = launcher_mods::pending(waiting_mods);
check(waiting.updates.empty() && waiting.removals.size() == 1, "pending: uninstalling it takes back the update");
check(!error_of([&] { launcher_mods::remove_later(waiting_mods, "..\\elsewhere"); }).empty(), "pending: only mod folder names are uninstalled");
// (A mod to uninstall that is gone already is simply dropped; the Recycle Bin is not tried here.)
launcher_mods::remove_later(waiting_mods, "Never Installed");
fs::remove_all(waiting_mods / L"Held Mod");
launcher_mods::apply_pending(waiting_mods);
check(!fs::exists(waiting_mods / launcher_mods::pending_folder), "pending: uninstalls of mods already gone are dropped");
}
std::error_code ignored;
fs::remove_all(root, ignored);
if (failures) {
+4
View File
@@ -607,6 +607,10 @@ void Launcher::run_play() {
fail("Skate is already running from this folder. Close it before starting it again.");
return;
}
// Updates and uninstalls the game's Mod Manager could not make while it ran (mod_manager.h),
// now that nothing holds the mods' files.
for (const auto& line : launcher_mods::apply_pending(mods_data_root() / mods::mods_folder))
logging::write(logging::Level::info, logging::Channel::launcher, line);
if (!ignore_mod_problems_ && !run_mod_merge()) return;
ignore_mod_problems_ = false;
set(Phase::launching, "Starting Skate");
+108
View File
@@ -485,4 +485,112 @@ void remove(const fs::path& mods, const std::string& name) {
recycle(target);
}
namespace {
constexpr wchar_t removals_file[] = L"remove.txt";
fs::path pending_root(const fs::path& mods) { return mods / from_utf8(pending_folder); }
std::vector<std::string> read_removals(const fs::path& mods) {
std::vector<std::string> names;
std::ifstream in(pending_root(mods) / removals_file);
for (std::string line; std::getline(in, line);) {
while (!line.empty() && (line.back() == '\r' || line.back() == ' ')) line.pop_back();
if (mods::valid_mod_name(line) && std::find(names.begin(), names.end(), line) == names.end()) names.push_back(line);
}
return names;
}
// Replaces remove.txt (or removes it when there is nothing in it).
void write_removals(const fs::path& mods, const std::vector<std::string>& names) {
const auto file = pending_root(mods) / removals_file;
std::error_code error;
if (names.empty()) {
fs::remove(file, error);
return;
}
fs::create_directories(file.parent_path());
const auto temporary = fs::path(file).concat(L".tmp");
{
std::ofstream out(temporary, std::ios::binary | std::ios::trunc);
for (const auto& name : names) out << name << '\n';
if (!out) fail("Could not note the mod to uninstall (is the disk full?).");
}
if (!MoveFileExW(temporary.c_str(), file.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH))
fail("Could not note the mod to uninstall.");
}
} // namespace
std::string install_later(const fs::path& mods, const fs::path& source, const Progress& progress,
const std::atomic<bool>& cancel, const InstallOptions& options) {
const auto name = install(pending_root(mods), source, true, progress, cancel, options);
auto removals = read_removals(mods);
if (std::erase(removals, name)) write_removals(mods, removals);
return name;
}
void remove_later(const fs::path& mods, const std::string& name) {
if (!mods::valid_mod_name(name)) fail("\"" + name + "\" is not a mod folder.");
std::error_code error;
fs::remove_all(pending_root(mods) / from_utf8(name), error);
auto removals = read_removals(mods);
if (std::find(removals.begin(), removals.end(), name) == removals.end()) removals.push_back(name);
write_removals(mods, removals);
}
Pending pending(const fs::path& mods) noexcept {
Pending result;
try {
std::error_code error;
for (fs::directory_iterator it(pending_root(mods), error), end; !error && it != end; it.increment(error)) {
const auto name = utf8(it->path().filename().wstring());
// (install()'s own staging folder is a dot folder too.)
if (it->is_directory(error) && mods::valid_mod_name(name) && !name.ends_with(".replaced")) result.updates.push_back(name);
}
result.removals = read_removals(mods);
} catch (...) {}
return result;
}
std::vector<std::string> apply_pending(const fs::path& mods) noexcept {
std::vector<std::string> lines;
try {
const auto waiting = pending(mods);
if (waiting.updates.empty() && waiting.removals.empty()) return lines;
const auto root = pending_root(mods);
for (const auto& name : waiting.updates) {
const auto target = mods / from_utf8(name), copy = root / from_utf8(name);
const auto aside = root / (from_utf8(name).wstring() + L".replaced");
std::error_code error;
fs::remove_all(aside, error);
if (fs::exists(target, error) && !MoveFileExW(target.c_str(), aside.c_str(), MOVEFILE_WRITE_THROUGH)) {
lines.push_back("The update of " + name + " waits: its folder is in use (Windows error " + std::to_string(GetLastError()) + ").");
continue;
}
if (!MoveFileExW(copy.c_str(), target.c_str(), MOVEFILE_WRITE_THROUGH)) {
const auto code = GetLastError();
MoveFileExW(aside.c_str(), target.c_str(), MOVEFILE_WRITE_THROUGH);
lines.push_back("The update of " + name + " waits: it could not be moved in (Windows error " + std::to_string(code) + ").");
continue;
}
fs::remove_all(aside, error);
lines.push_back("Updated " + name + " (installed in game, waiting for this start).");
}
std::vector<std::string> kept;
for (const auto& name : waiting.removals) {
std::error_code error;
if (!fs::is_directory(mods / from_utf8(name), error)) continue;
try {
recycle(mods / from_utf8(name));
lines.push_back("Uninstalled " + name + " (asked for in game).");
} catch (const std::exception& failure) {
kept.push_back(name);
lines.push_back("Uninstalling " + name + " waits: " + failure.what());
}
}
try { write_removals(mods, kept); } catch (const std::exception& failure) { lines.push_back(failure.what()); }
std::error_code error;
if (fs::is_empty(root, error)) fs::remove(root, error);
} catch (const std::exception& failure) {
lines.push_back(std::string("Mod changes waiting from the game could not be made: ") + failure.what());
} catch (...) {}
return lines;
}
} // namespace dingosdk::launcher_mods
+22
View File
@@ -54,6 +54,28 @@ std::string install(const std::filesystem::path& mods_root, const std::filesyste
// Moves Mods/<name> to the Recycle Bin. Throws a user-facing message.
void remove(const std::filesystem::path& mods_root, const std::string& name);
// Changes the game cannot make while it runs, because it holds the mod's files open (an update or
// uninstall of a mod it has loaded), wait in Mods/.reskate-pending until the next start: the new
// copy of a mod as a folder there, and the mods to uninstall in its remove.txt, a folder name a
// line. apply_pending makes them; the launcher calls it before each launch's merge.
inline constexpr char pending_folder[] = ".reskate-pending";
// Installs `source` as install() does, into the pending folder, and returns the mod's name. A
// removal waiting for the same mod is dropped: the update is what was asked for last.
std::string install_later(const std::filesystem::path& mods_root, const std::filesystem::path& source,
const Progress& progress, const std::atomic<bool>& cancel, const InstallOptions& options = {});
// Marks Mods/<name> to be uninstalled at the next start, dropping an update waiting for it.
// Throws a user-facing message.
void remove_later(const std::filesystem::path& mods_root, const std::string& name);
struct Pending {
std::vector<std::string> updates, removals;
};
// What waits. A missing or unreadable folder is nothing; never throws.
Pending pending(const std::filesystem::path& mods_root) noexcept;
// Moves each waiting copy into place (the old copy deleted for good, as an update's is) and moves
// each mod to remove to the Recycle Bin. What could not be done stays for the next try. Never
// throws; returns what happened, a line each, for the log.
std::vector<std::string> apply_pending(const std::filesystem::path& mods_root) noexcept;
// What the mods give the game's data, as one line: each mod that ships a layout, in load order, with
// its folder name and its files' fingerprint (Engine/Vfs/mod_list.h mod_fingerprint), then the
// variants in play. A mod that only holds a plugin, or loose files the game does not draw with, is not
+18 -6
View File
@@ -712,14 +712,16 @@ void update_model(std::uintptr_t client, TickState& frame) {
const bool map_prompt = dingosdk::map_download::asking();
// The vote for the next map comes up the same way (overlay.h: map_vote_asking), when no map is being asked about.
const bool map_vote = !map_prompt && dingosdk::overlay::map_vote_asking();
// And a message to say OK to (overlay.h: message_asking), over either.
// And a message to say OK to (overlay.h: message_asking), over either; and a question with two
// answers (overlay.h: ask_question), under the message.
const bool message_up = dingosdk::overlay::message_asking();
const bool question_up = !message_up && dingosdk::overlay::question_asking();
// The hold on the game's input outlasts the card's question until everything pressed is let
// go (or a moment has passed): the button that answered must not also be a press in the
// game, or in the pause menu behind the card.
static bool prompt_held{};
static ULONGLONG prompt_release_by{};
if (map_prompt || map_vote || message_up) {
if (map_prompt || map_vote || message_up || question_up) {
prompt_held = true;
prompt_release_by = 0;
} else if (prompt_held) {
@@ -730,7 +732,7 @@ void update_model(std::uintptr_t client, TickState& frame) {
std::ranges::any_of(held.keys, [](std::uint64_t word) { return word != 0; });
if (!pressed || GetTickCount64() >= prompt_release_by) prompt_held = false;
}
DingoSDKOverlaySetPromptInputCapture(map_prompt || map_vote || message_up || prompt_held);
DingoSDKOverlaySetPromptInputCapture(map_prompt || map_vote || message_up || question_up || prompt_held);
const unsigned poll_answers = dingosdk::multiplayer::server_poll_answers();
const bool placement_flight = dingosdk::pad_flight::requested();
bool freecam_controller = dingosdk::local_freecam_controller() || placement_flight;
@@ -761,10 +763,10 @@ void update_model(std::uintptr_t client, TickState& frame) {
dingosdk::plugins::plugin_input_capture());
dingosdk::ControllerInput controller;
if (freecam_controller_combo || freecam_combo || tp_to_freecam_combo || noclip_combo || forward_velocity_combo || up_velocity_combo || offboard_up_velocity_combo ||
vote_yes_combo || vote_no_combo || poll_answers || map_prompt || map_vote || message_up ||
vote_yes_combo || vote_no_combo || poll_answers || map_prompt || map_vote || message_up || question_up ||
std::ranges::any_of(action_combos, [](auto combo) { return combo != 0; }))
// (The card that asks has the pointer, as a menu does: it is still read for.)
DingoSDKOverlayReadControllerInput(&controller, map_prompt || map_vote || message_up);
DingoSDKOverlayReadControllerInput(&controller, map_prompt || map_vote || message_up || question_up);
for (std::size_t i = 0; i < action_combos.size(); ++i)
if (r.action_bind_latches[i].update(action_combos[i], controller, r.observer_failed || placement_flight)) {
std::array<char, 256> result{};
@@ -790,7 +792,7 @@ void update_model(std::uintptr_t client, TickState& frame) {
static bool prompt_before{};
static std::uint32_t buttons_before{};
// The keyboard's arrows, Enter and Esc do the same, above the pad's bits.
const bool prompt = map_prompt || map_vote || message_up;
const bool prompt = map_prompt || map_vote || message_up || question_up;
const std::uint32_t buttons = !prompt ? 0
: (controller.available ? controller.buttons : 0) | (DingoSDKOverlayReadPromptKeys() << 20);
// The message is over the others and is answered first: A or B, Enter or Esc, is its OK.
@@ -804,6 +806,16 @@ void update_model(std::uintptr_t client, TickState& frame) {
if (pressed & (0x1000 | 1u << 22)) dingosdk::map_download::answer(dingosdk::map_download::picked());
else if (pressed & (0x2000 | 1u << 23)) dingosdk::map_download::answer(false);
}
// The question: left and right pick, A takes the pick, B is its second answer.
static bool question_before{};
if (question_up && question_before) {
const auto pressed = buttons & ~buttons_before;
if (pressed & (0x0004 | 1u << 20)) dingosdk::overlay::question_pick(true);
if (pressed & (0x0008 | 1u << 21)) dingosdk::overlay::question_pick(false);
if (pressed & (0x1000 | 1u << 22)) dingosdk::overlay::question_answer(dingosdk::overlay::question_picked());
else if (pressed & (0x2000 | 1u << 23)) dingosdk::overlay::question_answer(false);
}
question_before = question_up;
// The vote's popup: left and right move the pick, A votes for it, B closes it.
static bool vote_before{};
if (!message_up && map_vote && vote_before) {
+5
View File
@@ -32,6 +32,11 @@ 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_store_view_tests Extension/Assets/Test/mod_store_view_tests.cpp Extension/Assets/mod_store_view.cpp
Launcher/thunderstore.cpp)
target_link_libraries(dingosdk_mod_store_view_tests PRIVATE dingosdk_json dingosdk_miniz)
target_include_directories(dingosdk_mod_store_view_tests PRIVATE "${PROJECT_SOURCE_DIR}")
add_test(NAME mod_store_view COMMAND dingosdk_mod_store_view_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}")
+3
View File
@@ -128,6 +128,9 @@ add_library(dingosdk_runtime SHARED
Extension/Assets/map_icons.cpp
Engine/Vfs/thunderstore_package.cpp
Launcher/mod_manager.cpp
Launcher/thunderstore.cpp
Extension/Assets/mod_store.cpp
Extension/Assets/mod_store_view.cpp
Extension/Assets/loose_files.cpp
Extension/Scripting/lua_startup.cpp
Extension/Scripting/lua_native_block.cpp
+1
View File
@@ -32,6 +32,7 @@ add_library(dingosdk_overlay STATIC
Extension/UI/Overlay/game_images.cpp
Extension/UI/Overlay/xray_overlay.cpp
Extension/UI/Overlay/plugin_images.cpp
Extension/UI/Overlay/store_icons.cpp
Extension/UI/Overlay/menu_map_pictures.cpp
Extension/UI/Overlay/skate_menu_skater.cpp
Extension/UI/Overlay/skate_menu_settings.cpp