mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-27 10:47:08 +01:00
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:
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user